From 50a710ce99eb05ef8ddf76148bd040102c8871b1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 5 Aug 2026 23:51:54 -0400 Subject: [PATCH] "Local" stops being a one-way door, and the head search stops skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering Eric's question — no, a local game never looks itself up again — by making it possible. A local row's Titles line gains "look it up", reopening it through the SAME cascade resolve uses (board games, truncation heads, then RPGGeek) rather than the partial re-implementation reopen_match had; that cascade is now one shared find_candidates() instead of two drifting copies. Review's manual (f) re-search falls back to RPGGeek too. That exposed a real matcher gap: truncation heads jumped from "drop the last word" straight to "first two words", so a printed title that buries the real name in the middle was unreachable — "ALICE IS MISSING A SILENT ROLE PLAYING GAME" never tried "ALICE IS MISSING". Heads now shrink from the right, longest first (bounded at six, since each is a rate-limited request); only exact normalized matches count for heads, so shorter heads cannot match loosely. Both of Eric's Alice Is Missing rows now find their RPGGeek entries (311654, and 380459 for Silent Falls) and await his picks in review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- data/matches.csv | 6 +++--- src/bggpipe/resolve.py | 23 +++++++++++++++++------ src/bggpipe/review.py | 9 +++++++-- src/bggpipe/templates/pages/help.html | 2 +- src/bggpipe/templates/pages/titles.html | 25 +++++++++++++++++++------ src/bggpipe/webreview.py | 6 ++++-- tests/test_resolve.py | 3 ++- tests/test_review.py | 24 ++++++++++++++++++++++++ 8 files changed, 77 insertions(+), 21 deletions(-) diff --git a/data/matches.csv b/data/matches.csv index 3795405..a183c07 100644 --- a/data/matches.csv +++ b/data/matches.csv @@ -23,7 +23,7 @@ FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529 Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, Age of Renaissance,26,Age of Renaissance,1996,boardgame,auto,,,version_unknown,"[{""bgg_id"": 26, ""name"": ""Age of Renaissance"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, History of the World,224,History of the World,1991,boardgame,auto,20727,Avalon Hill/Hasbro edition,version_auto,"[{""bgg_id"": 224, ""name"": ""History of the World"", ""year"": 1991, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 5948, ""rank"": 1274}, {""bgg_id"": 235591, ""name"": ""History of the World"", ""year"": 2018, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 1960, ""rank"": 3203}, {""bgg_id"": 426957, ""name"": ""History of the World"", ""year"": 2025, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 175, ""rank"": 19811}]","[{""version_id"": 20727, ""name"": ""Avalon Hill/Hasbro edition"", ""year"": 2001, ""publishers"": [""Avalon Hill""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg,, -Dungeon!,345770,.dungeon,2021,rpgitem,auto,,,version_unknown,"[{""bgg_id"": 345770, ""name"": "".dungeon"", ""year"": 2021, ""type"": ""rpgitem"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, +Dungeon!,,,,,ambiguous,,,,"[{""bgg_id"": 19503, ""name"": ""Dungeon (ICP)"", ""year"": 2001, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 77.8, ""owned"": 18, ""rank"": null}]",[],IMG_4502.jpeg,, TICKET TO RIDE,9209,Ticket to Ride,2004,boardgame,auto,294188,English edition 2012 with Spiel des Jahres logo,version_approved,"[{""bgg_id"": 9209, ""name"": ""Ticket to Ride"", ""year"": 2004, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 468280, ""name"": ""15th anniversary special edition"", ""year"": 2019, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 469076, ""name"": ""Afrikaans/English edition"", ""year"": 2019, ""publishers"": [""Days of Wonder""], ""languages"": [""Afrikaans"", ""English""], ""score"": 3}, {""version_id"": 410646, ""name"": ""English edition 2007 with \""Over 1.1 million games sold\"""", ""year"": 2007, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 191794, ""name"": ""English edition 2007 with \""Over 2 million games sold\"""", ""year"": 2007, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 228833, ""name"": ""English edition 2012 with \""Over 3 million games sold\"""", ""year"": 2012, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 294188, ""name"": ""English edition 2012 with Spiel des Jahres logo"", ""year"": 2012, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 236459, ""name"": ""English edition 2013"", ""year"": 2013, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 447075, ""name"": ""English edition 2015"", ""year"": 2015, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}]",IMG_4504.jpeg,, CATAN,13,Catan,1995,boardgame,auto,347121,English edition 2016,version_approved,"[{""bgg_id"": 13, ""name"": ""Catan"", ""year"": 1995, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 243104, ""rank"": 626}, {""bgg_id"": 66056, ""name"": ""Catan Двубоят"", ""year"": 2010, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 21829, ""rank"": 1187}]","[{""version_id"": 347121, ""name"": ""English edition 2016"", ""year"": 2016, ""publishers"": [""Catan Studio""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 495726, ""name"": ""English edition 2019"", ""year"": 2019, ""publishers"": [""Catan Studio""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 568647, ""name"": ""English edition 2020"", ""year"": 2020, ""publishers"": [""Catan Studio""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 733924, ""name"": ""English edition 2023"", ""year"": 2023, ""publishers"": [""Catan Studio""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 416798, ""name"": ""Afrikaans edition"", ""year"": 0, ""publishers"": [""Catan Studio""], ""languages"": [""Afrikaans""], ""score"": 2}]",IMG_4504.jpeg;IMG_4542.jpeg,, RAMPAGE,97903,Rampage,2013,boardgame,auto,,,version_unknown,"[{""bgg_id"": 97903, ""name"": ""Rampage"", ""year"": 2013, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4504.jpeg,, @@ -114,8 +114,8 @@ MACHI KORO,143884,Machi Koro,2012,boardgame,auto,453051,"English fifth anniversa Dark Cults: The Horror Story Game,1784,Dark Cults,1983,boardgame,auto,110926,First edition,version_approved,"[{""bgg_id"": 1784, ""name"": ""Dark Cults"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 52.6, ""owned"": null, ""rank"": null}]","[{""version_id"": 110926, ""name"": ""First edition"", ""year"": 1983, ""publishers"": [""Dark House""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 110927, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Dark House""], ""languages"": [""English""], ""score"": 3}]",IMG_4552.jpeg,, Give Me The Brain,176,Give Me the Brain!,1996,boardgame,auto,27673,Czech edition,version_auto,"[{""bgg_id"": 176, ""name"": ""Give Me the Brain!"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 27673, ""name"": ""Czech edition"", ""year"": 1997, ""publishers"": [""Cheapass Games""], ""languages"": [""Czech""], ""score"": 4}, {""version_id"": 28103, ""name"": ""Cheapass English first paper envelope edition 1996"", ""year"": 1996, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 795469, ""name"": ""Cheapass English paper envelope edition 1998"", ""year"": 1998, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 24093, ""name"": ""Cheapass English special edition 2002"", ""year"": 2002, ""publishers"": [""Cheapass Games"", ""James Ernest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 742802, ""name"": ""German zipper bag edition"", ""year"": 2000, ""publishers"": [""Cheapass Games"", ""Truant Spiele""], ""languages"": [""German""], ""score"": 2}]",IMG_4553.jpeg,, KILL DOCTOR LUCKY,257,Kill Doctor Lucky,1996,boardgame,auto,520364,"Cheapass envelope edition, second printing",version_auto,"[{""bgg_id"": 257, ""name"": ""Kill Doctor Lucky"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 520364, ""name"": ""Cheapass envelope edition, second printing"", ""year"": 1997, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 5}, {""version_id"": 28629, ""name"": ""Cheapass boxed edition, sixth printing"", ""year"": 2000, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 342532, ""name"": ""Cheapass English 19.5th Anniversary edition"", ""year"": 2016, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 302015, ""name"": ""Cheapass English/German 19.5th Anniversary edition"", ""year"": 2016, ""publishers"": [""Cheapass Games""], ""languages"": [""English"", ""German""], ""score"": 3}, {""version_id"": 24741, ""name"": ""Cheapass envelope edition, first printing"", ""year"": 1996, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 261344, ""name"": ""Cheapass envelope edition, fourth printing"", ""year"": 1999, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133486, ""name"": ""Cheapass print & play edition 2011"", ""year"": 2011, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 24760, ""name"": ""Cheapass special edition, eighth printing"", ""year"": 2002, ""publishers"": [""Cheapass Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4555.jpeg,, -ALICE IS MISSING SILENT FALLS EXPANSION,,,,,local,,,,[],[],IMG_4566.jpeg,, -ALICE IS MISSING A SILENT ROLE PLAYING GAME,,,,,local,,,,[],[],IMG_4566.jpeg,, +ALICE IS MISSING SILENT FALLS EXPANSION,,,,,ambiguous,,,,"[{""bgg_id"": 380459, ""name"": ""Alice is Missing: Silent Falls"", ""year"": 2023, ""type"": ""rpgitem"", ""exact"": true, ""fuzzy"": 85.3, ""owned"": 226, ""rank"": null}]",[],IMG_4566.jpeg,, +ALICE IS MISSING A SILENT ROLE PLAYING GAME,,,,,ambiguous,,,,"[{""bgg_id"": 311654, ""name"": ""Alice is Missing"", ""year"": 2020, ""type"": ""rpgitem"", ""exact"": true, ""fuzzy"": 56.1, ""owned"": 2008, ""rank"": null}]",[],IMG_4566.jpeg,, ETHERFIELDS KITTENBURG EXPANSION,358880,Etherfields: Kittenburg Expansion,2022,boardgame,auto,603694,English edition,version_auto,"[{""bgg_id"": 358880, ""name"": ""Etherfields: Kittenburg Expansion"", ""year"": 2022, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 603694, ""name"": ""English edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 709658, ""name"": ""Polish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 2}, {""version_id"": 699892, ""name"": ""Spanish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 2}]",IMG_4570.jpeg,, Parsely,258849,Parsely,2018,rpgitem,auto,,,version_unknown,"[{""bgg_id"": 258849, ""name"": ""Parsely"", ""year"": 2018, ""type"": ""rpgitem"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4573.jpeg,, "...and then, we held hands.",153999,"...and then, we held hands.",2015,boardgame,auto,280845,English edition,version_auto,"[{""bgg_id"": 153999, ""name"": ""...and then, we held hands."", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 280845, ""name"": ""English edition"", ""year"": 2015, ""publishers"": [""LudiCreations""], ""languages"": [""English""], ""score"": 3}]",image.jpg,, diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index 3d04ee6..bcf21b9 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -191,11 +191,13 @@ def _truncation_heads(title_raw: str) -> list[str]: match = _GAME_WORD.search(title_raw) if match and match.start() > 0: heads.append(title_raw[: match.start()]) - # last resort: first two words — of the pre-subtitle part, so a title - # like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest" + # last resort: shrink from the right, longest first — a printed title + # can bury the real name in the middle ("ALICE IS MISSING A SILENT ROLE + # PLAYING GAME" is "Alice is Missing"). Only exact normalized matches + # count for heads, so a short head cannot match loosely. words = (sep_head or title_raw).split() - if len(words) > 2: - heads.append(" ".join(words[:2])) + for size in range(len(words) - 1, 1, -1): + heads.append(" ".join(words[:size])) seen: set[str] = {normalize_title(title_raw)} unique: list[str] = [] @@ -204,7 +206,7 @@ def _truncation_heads(title_raw: str) -> list[str]: if norm and norm not in seen: seen.add(norm) unique.append(head) - return unique[:3] + return unique[:6] def _plausible_candidates( @@ -449,7 +451,11 @@ def _merged_candidates( return cands -def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: +def find_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]: + """Every search this pipeline knows, in order of confidence. Public + because review's reopen path must search exactly as resolve did — a + partial re-implementation there silently misses whatever the later + steps would have found.""" cands = _merged_candidates(client, entry, entry.title_raw) if not cands: # Long transcribed box titles ("CIVILIZATION Game of the Heroic @@ -472,6 +478,11 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: ) if cands: break + return cands + + +def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: + cands = find_candidates(client, entry) row = _classify(client, entry, cands) if row.match_status == "auto": resolve_version(client, entry, row) diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py index e83eaa5..88e3ab3 100644 --- a/src/bggpipe/review.py +++ b/src/bggpipe/review.py @@ -32,8 +32,8 @@ from bggpipe.normalize import normalize_title from bggpipe.resolve import ( MatchRow, TitleEntry, - _merged_candidates, _score_version, + find_candidates, load_titles, read_matches, resolve_version, @@ -391,7 +391,9 @@ class ReviewSession: title_normalized=normalize_title(row["title_raw"]), ) try: - cands = _merged_candidates(self.client, entry, row["title_raw"])[:8] + # the FULL cascade resolve uses — board games, truncation heads, + # then RPGGeek — so a row parked as "local" gets the same look + cands = find_candidates(self.client, entry)[:8] if cands: stats = { t.bgg_id: t @@ -547,6 +549,9 @@ class ReviewSession: def _research(self, query: str) -> list[dict]: try: results = self.client.search(query) + if not results: + # board games first, then RPGGeek — same database, same token + results = self.client.search(query, "rpgitem") except _BGG_ERRORS as err: self.console.print(f"[yellow]search unavailable: {err}[/yellow]") return [] diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index de4e898..e78c7f9 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -51,7 +51,7 @@

auto matched confidently, no review needed. approved you picked the match yourself.

ambiguous several plausible games — needs your pick on Review. unmatched nothing plausible found — enter a BGG id or re-search on Review.

merged two reads judged to be the same physical box; the merge is veto-able on Review. copy one copy of a title you split.

-

local — not on BGG you ruled it's a real game BGG doesn't have: it joins the Library from its own photo reads, and never uploads.

+

local — not on BGG you ruled it's a real game BGG doesn't have: it joins the Library from its own photo reads (fill in its facts and add a cover photo on its Library page), and never uploads. Changed your mind — or suspect it's on RPGGeek after all? look it up on its Titles row searches again, RPGGeek included.

rejected you ruled it's a bad read or not worth matching; it stays listed but goes no further.

shaky read the vision model wasn't sure of this transcription and nothing has verified it yet — these are what the Titles badge counts. Clear one by pressing its ✓ looks right (the read is fine as-is) or by editing it (you fixed it). A BGG match also clears it: a wrong read wouldn't have matched.

diff --git a/src/bggpipe/templates/pages/titles.html b/src/bggpipe/templates/pages/titles.html index 2ab5461..da7ceef 100644 --- a/src/bggpipe/templates/pages/titles.html +++ b/src/bggpipe/templates/pages/titles.html @@ -89,6 +89,12 @@ function render() { title="one line, several boxes? make each photo its own copy"> split into copies` : ""} + ${c.status === "local" + ? `` + : ""} ${c.version_status === "version_ambiguous" ? `ballot in Review →` @@ -124,15 +130,22 @@ document.getElementById("catbody").addEventListener("click", async e => { if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; } const wm = e.target.closest("button.wrongmatch"); if (wm) { + // in the edit panel it means "wrong match"; on a local row it means + // "look it up" — same reopen, different starting point const f = wm.closest("form.editform"); - if (!confirm(`"${f.dataset.title}" matched the wrong game? This clears the ` + - `match and sends it back to Review for a re-search.`)) return; + const src = f ? f.dataset : wm.dataset; + if (f && !confirm(`"${src.title}" matched the wrong game? This clears the ` + + `match and sends it back to Review for a re-search.`)) return; const res = await apiPost("/api/reopen-match", { - title_raw: f.dataset.title, - source_photos: f.dataset.photos, - row_ix: f.dataset.rowix === "" ? null : Number(f.dataset.rowix), + title_raw: src.title, + source_photos: src.photos, + row_ix: src.rowix === "" ? null : Number(src.rowix), }); - if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); } + if (res) { + EDITING = null; GATE.reset(); refresh().catch(() => {}); + showToast(`searching BGG and RPGGeek for ${esc(src.title)} — + pick a match in Review`); + } return; } const pe = e.target.closest("button.pickedition"); diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 6318e64..04ab85f 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -1350,8 +1350,10 @@ def create_app( revision["n"] += 1 _refuse_if_rewriting() row = find_row(body.title_raw, body.source_photos, body.row_ix) - if row["match_status"] not in RECOGNIZED_MATCH_STATUSES: - raise HTTPException(400, "only a matched row can be reopened") + # "local" reopens too: it means "BGG doesn't have this", and the + # re-search now covers RPGGeek, where such a game may well be + if row["match_status"] not in (*RECOGNIZED_MATCH_STATUSES, "local"): + raise HTTPException(400, "only a matched or local row can be reopened") session.reopen_match(row) return state() diff --git a/tests/test_resolve.py b/tests/test_resolve.py index b3b541e..0033799 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -213,7 +213,8 @@ def test_truncation_heads_order_and_dedupe(): heads = _truncation_heads(CIV_TITLE) assert heads[0] == "CIVILIZATION Game of the Heroic Age" # before separator assert heads[1] == "CIVILIZATION" # before the "Game ..." descriptor - assert len(heads) <= 3 + assert len(heads) <= 6 # bounded: each head is a rate-limited request + assert "CIVILIZATION Game" in heads # shrinks from the right, longest first def test_truncation_heads_game_word_without_separator(): diff --git a/tests/test_review.py b/tests/test_review.py index b559d3e..2eca996 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -647,3 +647,27 @@ def test_reopen_match_returns_a_fresh_ballot(tmp_path): dead.reopen_match(row2) assert row2["match_status"] == "unmatched" assert dead.warnings # the degradation is visible + + +def test_local_rows_can_be_looked_up_again_on_rpggeek(tmp_path): + """ "Local" means BGG has no board game by that name — but RPGGeek, + the same database under type=rpgitem, often does. A local row must not + be a one-way door.""" + cfg = _setup( + tmp_path, + [ + _row( + title_raw="ALICE IS MISSING: A SILENT ROLE PLAYING GAME", + match_status="local", + ) + ], + ) + session = ReviewSession( + cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client() + ) + row = session.rows[0] + session.reopen_match(row) + assert row["match_status"] == "ambiguous" + candidates = json.loads(row["candidates_json"]) + assert any(c["type"] == "rpgitem" for c in candidates) + assert any("Alice is Missing" in c["name"] for c in candidates)