"Local" stops being a one-way door, and the head search stops skipping

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 23:51:54 -04:00
co-authored by Claude Fable 5
parent 7e95ed607d
commit 50a710ce99
8 changed files with 77 additions and 21 deletions
+17 -6
View File
@@ -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)
+7 -2
View File
@@ -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 []
+1 -1
View File
@@ -51,7 +51,7 @@
<p><span class="chip ok">auto</span> matched confidently, no review needed. <span class="chip ok">approved</span> you picked the match yourself.</p>
<p><span class="chip open">ambiguous</span> several plausible games — needs your pick on Review. <span class="chip open">unmatched</span> nothing plausible found — enter a BGG id or re-search on Review.</p>
<p><span class="chip merged">merged</span> two reads judged to be the same physical box; the merge is veto-able on Review. <span class="chip merged">copy</span> one copy of a title you split.</p>
<p><span class="chip open">local — not on BGG</span> you ruled it's a real game BGG doesn't have: it joins the Library from its own photo reads, and never uploads.</p>
<p><span class="chip open">local — not on BGG</span> 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? <b>look it up</b> on its Titles row searches again, RPGGeek included.</p>
<p><span class="chip no">rejected</span> you ruled it's a bad read or not worth matching; it stays listed but goes no further.</p>
<p><span class="chip shaky">shaky read</span> 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 <b>✓ looks right</b> (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.</p>
</div>
+19 -6
View File
@@ -89,6 +89,12 @@ function render() {
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}
${c.status === "local"
? `<button class="wrongmatch" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
title="search BGG and RPGGeek again — RPGs and expansions often are listed">
look it up</button>`
: ""}
${c.version_status === "version_ambiguous"
? `<a class="ballotlink" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review →</a>`
@@ -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 <b>${esc(src.title)}</b> —
<a href="/review#matches">pick a match in Review</a>`);
}
return;
}
const pe = e.target.closest("button.pickedition");
+4 -2
View File
@@ -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()