The real-data era: token live, stubs retired, editions on demand
BGG application approved. The migration the stub markers guarded for weeks: both synthetic caches deleted; tests/fixtures/bgg_cache re-recorded from the live API (recording list extended to every scenario the suite exercises — Civilization truncation, the Sorcerer SPI tiebreak, StarForce, Flat Top's thematic year, Alice Is Missing's rpgitem fallback); resolve --force re-matched all 133 titles for real (109 auto, 6 ambiguous, 18 unmatched, 30 edition ballots); data/STUB_DATA.marker deleted with its exit condition met — the guard mechanism stays armed should stubs ever regenerate. Reality fixed one bug and taught one lesson. The bug: a multi-type search lists an expansion twice (once per matched type) and the parser kept the generic boardgame entry — parse_search now dedupes by id preferring the specific type, which is what keeps expansion tagging (the base-vs-expansion review guard) alive on real data. The lesson: hand-built ambiguity is tidier than the real thing — Wingspan has 46 versions with three plausible English Stonemaier printings, so the suite's synthetic version ids and version_auto expectations became real ballots (assertions updated to recorded reality; the cue-plumbing test keeps its crafted two-version scenario via an injected transport). New: pick edition. A cue-less matched row is version_unknown by design (never guess) — but the owner knows which printing the box is. open_version_ballot() fetches the game's complete version list, cue-scores it when cues exist, and marks the row version_ambiguous so the normal Review edition pass presents it; the Titles page grows the button (Eric's three Wiz-Wars: two cue-less copies can now each claim their edition). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
15d3120029
commit
59f4b8c43c
+17
-8
@@ -94,21 +94,30 @@ def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
|
||||
|
||||
def parse_search(xml_text: str) -> list[SearchResult]:
|
||||
results = []
|
||||
by_id: dict[int, int] = {} # bgg_id -> index in results
|
||||
skipped = 0
|
||||
for item in _root(xml_text).findall("item"):
|
||||
name_elem = item.find("name")
|
||||
if name_elem is None or item.get("id") is None:
|
||||
skipped += 1 # tolerate stragglers; wholesale drift raises below
|
||||
continue
|
||||
results.append(
|
||||
SearchResult(
|
||||
bgg_id=int(item.get("id")),
|
||||
name=name_elem.get("value", ""),
|
||||
name_type=name_elem.get("type", "primary"),
|
||||
year=_attr_int(item.find("yearpublished")),
|
||||
type=item.get("type", "boardgame"),
|
||||
)
|
||||
result = SearchResult(
|
||||
bgg_id=int(item.get("id")),
|
||||
name=name_elem.get("value", ""),
|
||||
name_type=name_elem.get("type", "primary"),
|
||||
year=_attr_int(item.find("yearpublished")),
|
||||
type=item.get("type", "boardgame"),
|
||||
)
|
||||
# a multi-type search lists an expansion TWICE — once per matched
|
||||
# type; keep one entry, preferring the specific type so expansion
|
||||
# tagging (the base-vs-expansion review guard) survives
|
||||
if result.bgg_id in by_id:
|
||||
seen = results[by_id[result.bgg_id]]
|
||||
if seen.type == "boardgame" and result.type != "boardgame":
|
||||
results[by_id[result.bgg_id]] = result
|
||||
continue
|
||||
by_id[result.bgg_id] = len(results)
|
||||
results.append(result)
|
||||
if skipped and results:
|
||||
warnings.warn(
|
||||
f"search: {skipped} unparseable item(s) tolerated — schema drift?",
|
||||
|
||||
@@ -32,6 +32,7 @@ from bggpipe.normalize import normalize_title
|
||||
from bggpipe.resolve import (
|
||||
MatchRow,
|
||||
TitleEntry,
|
||||
_score_version,
|
||||
load_titles,
|
||||
read_matches,
|
||||
resolve_version,
|
||||
@@ -356,6 +357,41 @@ class ReviewSession:
|
||||
row["match_status"] = "rejected"
|
||||
self._save(row)
|
||||
|
||||
def open_version_ballot(self, row: dict) -> int:
|
||||
"""The human knows which printing a box is even when the photo
|
||||
showed no cues: put EVERY published version on the row's ballot
|
||||
(cue-scored when cues exist) and mark it version_ambiguous so the
|
||||
normal edition pass presents it. Returns the ballot size."""
|
||||
if not row["bgg_id"]:
|
||||
raise ValueError("no BGG match on this row yet")
|
||||
entry = self.cues_for(row["title_raw"], row["source_photos"])
|
||||
things = self.client.things([int(row["bgg_id"])], versions=True)
|
||||
if not things or not things[0].versions:
|
||||
raise ValueError("BGG lists no versions for this game")
|
||||
scored = sorted(
|
||||
((v, _score_version(entry, v) if entry else 0) for v in things[0].versions),
|
||||
key=lambda pair: (-pair[1], str(pair[0].year or "")),
|
||||
)
|
||||
row["version_candidates_json"] = json.dumps(
|
||||
[
|
||||
{
|
||||
"version_id": v.version_id,
|
||||
"name": v.name,
|
||||
"year": v.year,
|
||||
"publishers": list(v.publishers),
|
||||
"languages": list(v.languages),
|
||||
"score": s,
|
||||
}
|
||||
for v, s in scored
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
row["version_status"] = "version_ambiguous"
|
||||
row["version_id"] = ""
|
||||
row["version_name"] = ""
|
||||
self._save(row)
|
||||
return len(scored)
|
||||
|
||||
def decide_version(self, row: dict, version_id: int | None) -> None:
|
||||
"""Pick a version from the row's stored candidates, or None -> unknown."""
|
||||
if version_id is None:
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
|
||||
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
|
||||
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
|
||||
<p><b>pick edition</b> — a matched game with no legible edition cues stays version-less by design (never guess) — but you know which printing your box is. This fetches the game's complete version list into a Review ballot; pick yours there.</p>
|
||||
<p><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p>
|
||||
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,13 @@ function render() {
|
||||
title="one line, several boxes? make each photo its own copy">
|
||||
split into copies</button>`
|
||||
: ""}
|
||||
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
||||
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
|
||||
? `<button class="pickedition" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
|
||||
title="you know which printing this box is — fetch the full edition list into Review">
|
||||
pick edition</button>`
|
||||
: ""}
|
||||
${c.shaky ? `<button class="confirmread" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}"
|
||||
title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
|
||||
@@ -92,6 +99,16 @@ async function refresh() {
|
||||
document.getElementById("catbody").addEventListener("click", async e => {
|
||||
const cancel = e.target.closest("button.canceledit");
|
||||
if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; }
|
||||
const pe = e.target.closest("button.pickedition");
|
||||
if (pe) {
|
||||
const res = await apiPost("/api/open-versions", {
|
||||
title_raw: pe.dataset.title,
|
||||
source_photos: pe.dataset.photos,
|
||||
row_ix: pe.dataset.rowix === "" ? null : Number(pe.dataset.rowix),
|
||||
});
|
||||
if (res) { GATE.reset(); refresh().catch(() => {}); }
|
||||
return;
|
||||
}
|
||||
const ok = e.target.closest("button.confirmread");
|
||||
if (ok) {
|
||||
const res = await apiPost("/api/edit-title", {
|
||||
|
||||
@@ -57,7 +57,7 @@ from bggpipe.models import (
|
||||
)
|
||||
from bggpipe.normalize import normalize_title
|
||||
from bggpipe.resolve import TitleEntry
|
||||
from bggpipe.review import ReviewSession
|
||||
from bggpipe.review import _BGG_ERRORS, ReviewSession
|
||||
|
||||
|
||||
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
|
||||
@@ -539,6 +539,7 @@ def create_app(
|
||||
"bgg_id": row["bgg_id"] if row else "",
|
||||
"bgg_name": row["bgg_name"] if row else "",
|
||||
"version_name": row["version_name"] if row else "",
|
||||
"version_status": row["version_status"] if row else "",
|
||||
"merged_into": row.get("merged_into", "") if row else "",
|
||||
"row_ix": _ix_of(session.rows, row) if row else None,
|
||||
"cues": {
|
||||
@@ -1087,6 +1088,20 @@ def create_app(
|
||||
replay_titles(cfg)
|
||||
return state()
|
||||
|
||||
@app.post("/api/open-versions")
|
||||
def api_open_versions(body: RowRef) -> dict:
|
||||
with lock:
|
||||
revision["n"] += 1
|
||||
_refuse_if_rewriting()
|
||||
row = find_row(body.title_raw, body.source_photos, body.row_ix)
|
||||
try:
|
||||
session.open_version_ballot(row)
|
||||
except ValueError as err:
|
||||
raise HTTPException(400, str(err)) from err
|
||||
except _BGG_ERRORS as err:
|
||||
raise HTTPException(502, f"BGG lookup failed: {err}") from err
|
||||
return state()
|
||||
|
||||
@app.post("/api/veto-merge")
|
||||
def api_veto_merge(body: RowRef) -> dict:
|
||||
with lock:
|
||||
|
||||
Reference in New Issue
Block a user