Review can search a chosen database: BGG or RPGGeek, by hand

The automatic cascade only reaches RPGGeek when BGG's board-game search
comes up empty — so every D&D box, which BGG does list as board games,
can never find its RPGGeek entry no matter how many times it is
reopened. Eric hit exactly that and settled for keeping them local.

Match cards now carry an editable query with two buttons, "search BGG"
and "search RPGGeek", which replace the row's ballot with whatever the
chosen database returns (owned counts and ranks attached when
available; if that stats call fails the results still stand and the
degradation is reported). The TUI's (f) re-search falls back to
RPGGeek automatically when the board-game search is empty.

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:58:58 -04:00
co-authored by Claude Fable 5
parent 50a710ce99
commit 9663f29702
6 changed files with 123 additions and 6 deletions
+49
View File
@@ -30,6 +30,7 @@ from bggpipe.models import (
)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import (
Candidate,
MatchRow,
TitleEntry,
_score_version,
@@ -369,6 +370,54 @@ class ReviewSession:
row["version_name"] = ""
self._save(row)
def research(self, row: dict, query: str, types: str | None = None) -> int:
"""Re-search on the human's terms and put the results on the row's
ballot. `types` targets a specific database — "rpgitem" for
RPGGeek, whose entries the automatic cascade never reaches when BGG
has a same-named BOARD game (every D&D box hits this)."""
query = query.strip()
if not query:
raise ValueError("a search needs some text")
results = (
self.client.search(query, types) if types else self.client.search(query)
)
cands = [
Candidate(
bgg_id=r.bgg_id,
name=r.name,
year=r.year,
type=r.type,
exact=normalize_title(r.name) == normalize_title(query),
fuzzy=0.0,
)
for r in results
][:12]
if cands:
try:
stats = {
t.bgg_id: t
for t in self.client.things([c.bgg_id for c in cands], stats=True)
}
except _BGG_ERRORS as err:
# owned counts and ranks only decorate the ballot — losing
# them must not lose the search the human just asked for
self._warn(f"couldn't fetch stats for these results ({err})")
stats = {}
for c in cands:
if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank
c.publishers = list(stats[c.bgg_id].publishers)
row["match_status"] = "ambiguous" if cands else "unmatched"
row["candidates_json"] = json.dumps(
[c.as_json() for c in cands], ensure_ascii=False
)
# a fresh ballot supersedes any earlier verdict on this row
row["bgg_id"] = ""
row["bgg_name"] = ""
self._save(row)
return len(cands)
def reopen_match(self, row: dict) -> None:
"""The human says a matched row is the WRONG game: clear the match
and immediately re-search so the card comes back as a ballot — the
+3
View File
@@ -249,6 +249,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
.rowactions button { padding: .25rem .7rem; box-shadow: none; }
.rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); }
.research { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.research input[type=text] { width: 12em; }
.research button { font-size: .78rem; }
.rowactions input[type=text] {
font: inherit; width: 8.5em; padding: .25rem .5rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
+16
View File
@@ -75,6 +75,14 @@ function matchCard(row, idx) {
<div class="rowactions">
<span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
aria-label="manual BGG id"></span>
<span class="research">
<input type="text" class="rq" value="${esc(row.title_raw)}"
aria-label="search text for ${esc(row.title_raw)}">
<button class="dosearch" data-types="">search BGG</button>
<button class="dosearch" data-types="rpgitem"
title="BGG's board-game entries hide same-named RPGs from the automatic search">
search RPGGeek</button>
</span>
<button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
<button class="golocal" title="press l"><kbd>l</kbd> not on BGG — keep locally</button>
</div>
@@ -196,6 +204,14 @@ function render() {
decide(b.closest(".card"), "reject"));
m.querySelectorAll(".golocal").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "local"));
m.querySelectorAll(".dosearch").forEach(b => b.onclick = () => {
const card = b.closest(".card");
post("/api/research", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
row_ix: rowIx(card), query: card.querySelector(".rq").value,
types: b.dataset.types || null,
});
});
m.querySelectorAll(".wronggame").forEach(b => b.onclick = () =>
post("/api/reopen-match", {
title_raw: b.closest(".card").dataset.title,
+22
View File
@@ -201,6 +201,14 @@ class LocalGameBody(BaseModel):
description: str | None = None
class ResearchBody(BaseModel):
title_raw: str
source_photos: str
row_ix: int | None = None
query: str
types: str | None = None # e.g. "rpgitem" to search RPGGeek
class RemoveBody(BaseModel):
title_raw: str
source_photos: str = ""
@@ -1344,6 +1352,20 @@ def create_app(
replay_titles(cfg)
return state()
@app.post("/api/research")
def api_research(body: ResearchBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
row = find_row(body.title_raw, body.source_photos, body.row_ix)
try:
session.research(row, body.query, body.types)
except ValueError as err:
raise HTTPException(400, str(err)) from err
except _BGG_ERRORS as err:
raise HTTPException(502, f"search failed: {err}") from err
return state()
@app.post("/api/reopen-match")
def api_reopen_match(body: RowRef) -> dict:
with lock: