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:
@@ -12,9 +12,16 @@ import typer
|
||||
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.extract import (
|
||||
apply_title_edits,
|
||||
dedupe_entries,
|
||||
load_title_edits,
|
||||
load_title_splits,
|
||||
parse_vision_response,
|
||||
prepare_image,
|
||||
rebuild_artifacts,
|
||||
record_title_edit,
|
||||
record_title_split,
|
||||
replay_titles,
|
||||
run_extract,
|
||||
)
|
||||
|
||||
@@ -167,6 +174,90 @@ def test_dedupe_conflicting_years_stay_separate():
|
||||
assert len(deduped) == 2
|
||||
|
||||
|
||||
def test_dedupe_split_titles_never_merge():
|
||||
deduped = dedupe_entries(
|
||||
[_entry("Wiz-War", "a.jpg"), _entry("Wiz-War", "b.jpg")],
|
||||
split_titles={"wiz war"},
|
||||
)
|
||||
assert len(deduped) == 2 # the human said: separate physical copies
|
||||
|
||||
|
||||
def test_edits_fix_misreads_before_dedupe():
|
||||
# a corrected misspelling merges with the correctly-read sighting
|
||||
edits = [{"match": "Hebarceos", "title_raw": "Herbaceous"}]
|
||||
deduped = dedupe_entries(
|
||||
apply_title_edits(
|
||||
[_entry("Hebarceos", "a.jpg"), _entry("Herbaceous", "b.jpg")], edits
|
||||
)
|
||||
)
|
||||
assert len(deduped) == 1
|
||||
assert deduped[0]["title_raw"] == "Herbaceous"
|
||||
assert deduped[0]["source_photos"] == ["a.jpg", "b.jpg"]
|
||||
|
||||
|
||||
def test_edits_chain_and_target_photos():
|
||||
edits = [
|
||||
{"match": "Wiz-War", "photos": ["a.jpg"], "edition_hint": "7th Edition"},
|
||||
{"match": "Wiz-War", "title_raw": "Wiz-War!", "photos": ["a.jpg"]},
|
||||
# made later, against the renamed title — must chain onto the result
|
||||
{"match": "Wiz-War!", "photos": ["a.jpg"], "year_hint": 1997},
|
||||
]
|
||||
entries = apply_title_edits(
|
||||
[_entry("Wiz-War", "a.jpg"), _entry("Wiz-War", "b.jpg")], edits
|
||||
)
|
||||
assert entries[0]["title_raw"] == "Wiz-War!"
|
||||
assert entries[0]["edition_hint"] == "7th Edition"
|
||||
assert entries[0]["year_hint"] == 1997
|
||||
assert entries[1] == _entry("Wiz-War", "b.jpg") # untargeted copy untouched
|
||||
|
||||
|
||||
def test_stores_roundtrip_and_replay_from_raw(tmp_path):
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
raw = cfg.extract_raw_dir
|
||||
raw.mkdir(parents=True)
|
||||
for photo in ("a.jpg", "b.jpg"):
|
||||
(raw / f"{photo}.json").write_text(
|
||||
json.dumps({"titles": [_entry("Wiz-War", photo)], "unidentified": []})
|
||||
)
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War")
|
||||
record_title_split(cfg.title_splits_path, "wiz war") # dupe, normalized away
|
||||
record_title_edit(
|
||||
cfg.title_edits_path,
|
||||
{"match": "Wiz-War", "photos": ["a.jpg"], "edition_hint": "7th Edition"},
|
||||
)
|
||||
replay_titles(cfg)
|
||||
titles = json.loads(cfg.titles_path.read_text())
|
||||
assert [e["source_photos"] for e in titles] == [["a.jpg"], ["b.jpg"]]
|
||||
assert titles[0]["edition_hint"] == "7th Edition"
|
||||
assert len(load_title_splits(cfg.title_splits_path)) == 1
|
||||
assert len(load_title_edits(cfg.title_edits_path)) == 1
|
||||
# a later full rebuild (a real extract run) honors the same stores
|
||||
rebuild_artifacts(
|
||||
raw,
|
||||
cfg.titles_path,
|
||||
cfg.unidentified_path,
|
||||
load_title_splits(cfg.title_splits_path),
|
||||
load_title_edits(cfg.title_edits_path),
|
||||
)
|
||||
assert len(json.loads(cfg.titles_path.read_text())) == 2
|
||||
|
||||
|
||||
def test_replay_without_raw_caches_explodes_merged_entries(tmp_path):
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
cfg.data_dir.mkdir(parents=True)
|
||||
merged = _entry("Wiz-War", "a.jpg")
|
||||
merged["source_photos"] = ["a.jpg", "b.jpg", "c.jpg"]
|
||||
cfg.titles_path.write_text(json.dumps([merged, _entry("Catan", "a.jpg")]))
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War")
|
||||
replay_titles(cfg)
|
||||
titles = json.loads(cfg.titles_path.read_text())
|
||||
by_title = {}
|
||||
for e in titles:
|
||||
by_title.setdefault(e["title_raw"], []).append(e["source_photos"])
|
||||
assert by_title["Wiz-War"] == [["a.jpg"], ["b.jpg"], ["c.jpg"]]
|
||||
assert by_title["Catan"] == [["a.jpg"]] # non-split entries survive intact
|
||||
|
||||
|
||||
def test_dedupe_upgrades_confidence():
|
||||
deduped = dedupe_entries(
|
||||
[
|
||||
|
||||
@@ -439,6 +439,15 @@ def test_dedupe_versions_must_agree():
|
||||
assert dedupe_matches(rows3, []) == []
|
||||
|
||||
|
||||
def test_dedupe_matches_skips_split_titles():
|
||||
rows = [
|
||||
_mrow("Wiz-War", "94", "a.jpg"),
|
||||
_mrow("Wiz-War", "94", "b.jpg"),
|
||||
]
|
||||
assert dedupe_matches(rows, [], split_titles={"wiz war"}) == []
|
||||
assert all(not r["merged_into"] for r in rows)
|
||||
|
||||
|
||||
def test_dedupe_is_idempotent_and_skips_merged():
|
||||
rows = [
|
||||
_mrow("Jokin Ha...", "193621", "a.jpg"),
|
||||
|
||||
@@ -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