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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user