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:
Eric Wagoner
2026-08-02 19:47:08 -04:00
parent 24a7bcb9e0
commit a7f0cfee05
12 changed files with 641 additions and 28 deletions
+91
View File
@@ -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(
[