Durable curation: persisted splits + pre-resolve title edits, catalog A→Z
Wiz-War had no split button: can_split required a matches row, but fresh extractions leave multi-photo titles rowless until resolve runs. Splits are now a title-level decision persisted in data/title_splits.json, honored by extract's dedupe and resolve's dedupe on every rebuild, with the button on any multi-photo line — resolved or not. Same mechanism carries human corrections: data/title_edits.json stores fixed misreads and known cues (publisher/edition/year/language), applied before dedupe on every titles.json rebuild, editable from a new inline form on every catalog line. An edit drops the title's stale matches rows so resolve re-queries with the corrected data. The catalog page now sorts alphabetically (case-insensitive; split copies stay adjacent) instead of extraction order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
@@ -432,3 +432,132 @@ def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
||||
)
|
||||
rows = read_m(cfg.matches_path)
|
||||
assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
|
||||
|
||||
|
||||
# -- catalog curation: rowless splits and title edits ---------------------
|
||||
|
||||
|
||||
def _rowless_wizwar(cfg) -> None:
|
||||
"""Add a multi-photo extracted title with NO matches row (still awaiting
|
||||
resolve — the Wiz-War shape)."""
|
||||
titles = json.loads(cfg.titles_path.read_text())
|
||||
titles.append(
|
||||
{
|
||||
"title_raw": "Wiz-War",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Fantasy Flight Games",
|
||||
"edition_hint": "",
|
||||
"source_photos": ["shelf.jpg", "shelf2.jpg"],
|
||||
}
|
||||
)
|
||||
cfg.titles_path.write_text(json.dumps(titles))
|
||||
|
||||
|
||||
def test_rowless_multiphoto_title_is_splittable(tmp_path):
|
||||
cfg = make_cfg(tmp_path)
|
||||
_rowless_wizwar(cfg)
|
||||
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||
line = next(
|
||||
c
|
||||
for c in web.get("/api/state").json()["catalog"]
|
||||
if c["title_raw"] == "Wiz-War"
|
||||
)
|
||||
assert line["status"] == "awaiting_resolve"
|
||||
assert line["can_split"] is True
|
||||
|
||||
res = web.post(
|
||||
"/api/split",
|
||||
json={
|
||||
"title_raw": "Wiz-War",
|
||||
"source_photos": "shelf.jpg;shelf2.jpg",
|
||||
"row_ix": None,
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
copies = [c for c in res.json()["catalog"] if c["title_raw"] == "Wiz-War"]
|
||||
assert [c["photos"] for c in copies] == [["shelf.jpg"], ["shelf2.jpg"]]
|
||||
assert all(c["can_split"] is False for c in copies)
|
||||
# the decision persisted: the store holds it and titles.json is split
|
||||
assert "Wiz-War" in json.loads(cfg.title_splits_path.read_text())
|
||||
per_photo = [
|
||||
e["source_photos"]
|
||||
for e in json.loads(cfg.titles_path.read_text())
|
||||
if e["title_raw"] == "Wiz-War"
|
||||
]
|
||||
assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]]
|
||||
|
||||
|
||||
def test_split_still_requires_multiple_photos(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
res = web.post(
|
||||
"/api/split",
|
||||
json={
|
||||
"title_raw": "Fresh Off The Shelf",
|
||||
"source_photos": "shelf.jpg",
|
||||
"row_ix": None,
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_edit_title_corrects_read_and_requeues_row(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
# Citadels has an ambiguous matches row; correcting its read must drop
|
||||
# the stale row (it was searched with the old data) and store the fix
|
||||
res = web.post(
|
||||
"/api/edit-title",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"title_new": "Citadels: Dark City",
|
||||
"edition_hint": None,
|
||||
"year": "2004",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
state = res.json()
|
||||
titles = [c["title_raw"] for c in state["catalog"]]
|
||||
assert "Citadels: Dark City" in titles and "Citadels" not in titles
|
||||
corrected = next(
|
||||
e
|
||||
for e in json.loads(cfg.titles_path.read_text())
|
||||
if e["title_raw"] == "Citadels: Dark City"
|
||||
)
|
||||
assert corrected["year_hint"] == 2004
|
||||
assert corrected["publisher_hint"] == "Fantasy Flight" # untouched cue kept
|
||||
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
||||
(record,) = json.loads(cfg.title_edits_path.read_text())
|
||||
assert record["match"] == "Citadels"
|
||||
|
||||
|
||||
def test_edit_title_rejects_empty_and_noop(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
assert (
|
||||
web.post(
|
||||
"/api/edit-title",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"title_new": " ",
|
||||
},
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
web.post(
|
||||
"/api/edit-title",
|
||||
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
web.post(
|
||||
"/api/edit-title",
|
||||
json={
|
||||
"title_raw": "No Such Game",
|
||||
"source_photos": "shelf.jpg",
|
||||
"title_new": "X",
|
||||
},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user