Post-resolve dedupe: duplicate reads merge, review can veto
Rows resolving to the same (bgg_id, version_id — or both version-
unknown) are the same physical game read twice unless their extraction
cues conflict (two editions stay separate). The survivor is the read
whose transcription matches the BGG name; losers are marked
match_status=merged with a new merged_into column — no row is ever
deleted, and older matches.csv files without the column still read.
Downstream: diff skips merged rows but folds their photos into the
survivor's to_add provenance; enrich and the review passes ignore them.
The web UI gains a Merges section ("Jokin Ha... merged into Joking
Hazard") with a veto (v key) that restores the row as a distinct
approved match, plus a merged catalog chip and header tally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -164,3 +164,17 @@ def test_pending_rejected_and_unseen_are_reported():
|
||||
assert result.rejected == 1
|
||||
assert [c.object_id for c in result.unseen] == [9209] # informational
|
||||
assert result.recognized == 1
|
||||
|
||||
|
||||
def test_merged_rows_are_skipped_but_photos_carry_to_survivor():
|
||||
matches = [
|
||||
_match("Joking Hazard", "193621"),
|
||||
{**_match("Jokin Ha...", "193621", status="merged"),
|
||||
"merged_into": "Joking Hazard", "source_photos": "other.jpg"},
|
||||
]
|
||||
result = compute_diff(matches, []) # empty collection -> to_add
|
||||
assert result.merged == 1
|
||||
assert result.pending == [] # merged is not "needs review"
|
||||
(row,) = result.to_add
|
||||
assert row["title_raw"] == "Joking Hazard"
|
||||
assert row["source_photos"] == "other.jpg;x.jpg" # combined
|
||||
|
||||
@@ -343,3 +343,142 @@ def test_run_resolve_saves_progress_when_token_missing(tmp_path):
|
||||
)
|
||||
rows2 = run_resolve(cfg, client=full)
|
||||
assert [r.title_raw for r in rows2] == ["Wingspan"]
|
||||
|
||||
|
||||
# -- post-resolve dedupe ------------------------------------------------
|
||||
|
||||
from bggpipe.bgg_client import cache_key as _cache_key # noqa: E402
|
||||
from bggpipe.resolve import dedupe_matches # noqa: E402
|
||||
|
||||
|
||||
def _mrow(
|
||||
title, bgg_id, photos, name="Joking Hazard",
|
||||
vstatus="version_unknown", vid="", status="auto",
|
||||
):
|
||||
return {
|
||||
"title_raw": title,
|
||||
"bgg_id": bgg_id,
|
||||
"bgg_name": name,
|
||||
"year": "2016",
|
||||
"type": "boardgame",
|
||||
"match_status": status,
|
||||
"version_id": vid,
|
||||
"version_name": "",
|
||||
"version_status": vstatus,
|
||||
"candidates_json": "[]",
|
||||
"version_candidates_json": "[]",
|
||||
"source_photos": photos,
|
||||
"merged_into": "",
|
||||
}
|
||||
|
||||
|
||||
def _tentry(title, photos, **cues):
|
||||
return TitleEntry(
|
||||
title_raw=title,
|
||||
title_normalized=normalize_title(title),
|
||||
publisher_hint=cues.get("publisher_hint", ""),
|
||||
edition_hint=cues.get("edition_hint", ""),
|
||||
year_hint=cues.get("year_hint"),
|
||||
language_hint=cues.get("language_hint", ""),
|
||||
source_photos=tuple(photos),
|
||||
)
|
||||
|
||||
|
||||
def test_dedupe_merges_typo_read_into_canonical():
|
||||
rows = [
|
||||
_mrow("Jokin Ha...", "193621", "a.jpg"),
|
||||
_mrow("Joking Hazard", "193621", "b.jpg;c.jpg"),
|
||||
_mrow("Catan", "13", "d.jpg", name="CATAN"),
|
||||
]
|
||||
events = dedupe_matches(rows, [])
|
||||
(event,) = events
|
||||
assert event.loser_title == "Jokin Ha..."
|
||||
assert event.survivor_title == "Joking Hazard" # name-matching read survives
|
||||
by_title = {r["title_raw"]: r for r in rows}
|
||||
assert by_title["Jokin Ha..."]["match_status"] == "merged"
|
||||
assert by_title["Jokin Ha..."]["merged_into"] == "Joking Hazard"
|
||||
assert by_title["Joking Hazard"]["match_status"] == "auto" # untouched
|
||||
assert by_title["Catan"]["match_status"] == "auto"
|
||||
assert len(rows) == 3 # nothing disappears
|
||||
|
||||
|
||||
def test_dedupe_respects_conflicting_edition_cues():
|
||||
rows = [
|
||||
_mrow("Cosmic Encounter", "40529", "a.jpg", name="Cosmic Encounter"),
|
||||
_mrow("COSMIC ENCOUNTER", "40529", "b.jpg", name="Cosmic Encounter"),
|
||||
]
|
||||
titles = [
|
||||
_tentry("Cosmic Encounter", ["a.jpg"], edition_hint="42nd Anniversary Edition"),
|
||||
_tentry("COSMIC ENCOUNTER", ["b.jpg"], edition_hint="Eon 1977 edition"),
|
||||
]
|
||||
assert dedupe_matches(rows, titles) == []
|
||||
assert all(r["match_status"] == "auto" for r in rows)
|
||||
|
||||
|
||||
def test_dedupe_versions_must_agree():
|
||||
# different confident versions: two physical editions, never merged
|
||||
rows = [
|
||||
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
|
||||
_mrow("WINGSPAN", "266192", "b.jpg", vstatus="version_auto", vid="521212"),
|
||||
]
|
||||
assert dedupe_matches(rows, []) == []
|
||||
# same confident version: same box seen twice
|
||||
rows2 = [
|
||||
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
|
||||
_mrow("WINGSPAN", "266192", "b.jpg", vstatus="version_auto", vid="465063"),
|
||||
]
|
||||
assert len(dedupe_matches(rows2, [])) == 1
|
||||
# confident version vs unknown: conservative, no merge
|
||||
rows3 = [
|
||||
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
|
||||
_mrow("WINGSPAN", "266192", "b.jpg"),
|
||||
]
|
||||
assert dedupe_matches(rows3, []) == []
|
||||
|
||||
|
||||
def test_dedupe_is_idempotent_and_skips_merged():
|
||||
rows = [
|
||||
_mrow("Jokin Ha...", "193621", "a.jpg"),
|
||||
_mrow("Joking Hazard", "193621", "b.jpg"),
|
||||
]
|
||||
assert len(dedupe_matches(rows, [])) == 1
|
||||
assert dedupe_matches(rows, []) == [] # second pass: nothing new
|
||||
|
||||
|
||||
def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
|
||||
from bggpipe.resolve import write_matches as _wm # noqa: F401
|
||||
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
wingspan_xml = (
|
||||
'<items total="1"><item type="boardgame" id="266192">'
|
||||
'<name type="primary" value="Wingspan"/><yearpublished value="2019"/>'
|
||||
"</item></items>"
|
||||
)
|
||||
for query in ("Wingspan", "WINGSPAN!"):
|
||||
key = _cache_key(
|
||||
"search", {"query": query, "type": "boardgame,boardgameexpansion"}
|
||||
)
|
||||
(cache / key).write_text(wingspan_xml)
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "titles.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"title_raw": "Wingspan", "source_photos": ["a.jpg"]},
|
||||
{"title_raw": "WINGSPAN!", "source_photos": ["b.jpg"]}, # variant read
|
||||
]
|
||||
)
|
||||
)
|
||||
cfg = Config(data_dir=data_dir)
|
||||
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
|
||||
|
||||
run_resolve(cfg, client=client)
|
||||
from bggpipe.resolve import read_matches as _rm
|
||||
|
||||
saved = {r["title_raw"]: r for r in _rm(cfg.matches_path)}
|
||||
assert len(saved) == 2 # no row disappeared
|
||||
assert saved["Wingspan"]["match_status"] == "auto"
|
||||
assert saved["WINGSPAN!"]["match_status"] == "merged"
|
||||
assert saved["WINGSPAN!"]["merged_into"] == "Wingspan"
|
||||
|
||||
@@ -286,3 +286,41 @@ def test_catalog_and_unresolved_backlog_are_visible(tmp_path):
|
||||
by_title = {c["title_raw"]: c for c in state["catalog"]}
|
||||
assert by_title["Fresh Off The Shelf"]["status"] == "awaiting_resolve"
|
||||
assert by_title["Citadels"]["status"] == "ambiguous"
|
||||
|
||||
|
||||
def test_merge_veto_roundtrip(tmp_path):
|
||||
cfg = make_cfg(tmp_path)
|
||||
rows = read_matches(cfg.matches_path)
|
||||
rows.append(
|
||||
_row(
|
||||
title_raw="Jokin Ha...",
|
||||
match_status="merged",
|
||||
bgg_id="193621",
|
||||
bgg_name="Joking Hazard",
|
||||
source_photos="shelf.jpg",
|
||||
)
|
||||
)
|
||||
rows[-1]["merged_into"] = "Joking Hazard"
|
||||
write_matches(cfg.matches_path, rows)
|
||||
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||
|
||||
state = web.get("/api/state").json()
|
||||
(merge,) = state["merges"]
|
||||
assert merge["title_raw"] == "Jokin Ha..."
|
||||
assert merge["merged_into"] == "Joking Hazard"
|
||||
|
||||
state = web.post(
|
||||
"/api/veto-merge",
|
||||
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
||||
).json()
|
||||
assert state["merges"] == []
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Jokin Ha..."]["match_status"] == "approved"
|
||||
assert saved["Jokin Ha..."]["merged_into"] == ""
|
||||
|
||||
# veto on a non-merged row is refused
|
||||
bad = web.post(
|
||||
"/api/veto-merge",
|
||||
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user