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)