Same-title copies become tellable apart; wrong autos become fixable

Three split Wiz-Wars meant three identical "Wiz-War" edition cards —
version cards now carry their copy's photo thumbnails and name the
source photo in the prompt, so each ballot says which box it's about.

And the .dungeon-class failure gets its escape hatch: "wrong match" on
any matched Titles row clears the match and returns the title to
Review's unmatched queue, where re-search and manual-id already live.
Help notes the BGG wrinkle that makes this matter for Wiz-War
specifically: early editions and the FFG remake are SEPARATE games, so
a copy whose edition isn't on the ballot belongs to the sibling entry.

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 19:49:19 -04:00
co-authored by Claude Fable 5
parent 42ef34c3a7
commit c5daad023d
9 changed files with 145 additions and 5 deletions
+17
View File
@@ -368,6 +368,23 @@ class ReviewSession:
row["version_name"] = ""
self._save(row)
def reopen_match(self, row: dict) -> None:
"""The human says a matched row is the WRONG game: back to the
unmatched queue with a clean slate — manual id and re-search live
there. (The .dungeon case: an exact-normalized impostor wins when
the real game never surfaced in the search.)"""
row["match_status"] = "unmatched"
row["bgg_id"] = ""
row["bgg_name"] = ""
row["year"] = ""
row["type"] = ""
row["candidates_json"] = "[]"
row["version_status"] = ""
row["version_id"] = ""
row["version_name"] = ""
row["version_candidates_json"] = "[]"
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
+1
View File
@@ -39,6 +39,7 @@
<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>wrong match</b> — an auto-match landed on the wrong game (same-name impostors happen). This clears the match and returns the title to Review's unmatched queue, where re-search and manual-id live. Note BGG sometimes splits one game's lineage across entries — Wiz-War's early editions and its FFG remake are separate games — so a copy whose edition isn't on the ballot may belong to the sibling entry.</p>
<p><b>add a game</b> (top of the Titles page) — a game no photo shows: an expansion stored inside a base box, a game away from the shelves. It joins the list like any read (BGG wants base game and expansion as separate collection entries, so boxes that hold both need this for the hidden half), and if a later photo shows it, the sighting merges instead of duplicating.</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>
+4 -1
View File
@@ -94,9 +94,12 @@ function versionCard(row, idx) {
return `
<section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
${shots(row.photos || [])}
<div class="body">
<p class="title">${esc(row.bgg_name || row.title_raw)}</p>
<p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
<p class="status">which edition${row.photos && row.photos.length
? ` is the copy in ${esc(row.photos.join(", "))}`
: ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
<ol class="cands">${cands}</ol>
<div class="rowactions">
<button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
+18
View File
@@ -86,6 +86,12 @@ 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)
? `<button class="wrongmatch" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
title="this matched the wrong game — send it back to Review to re-search">
wrong match</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)}"
@@ -115,6 +121,18 @@ 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 wm = e.target.closest("button.wrongmatch");
if (wm) {
if (!confirm(`"${wm.dataset.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: wm.dataset.title,
source_photos: wm.dataset.photos,
row_ix: wm.dataset.rowix === "" ? null : Number(wm.dataset.rowix),
});
if (res) { GATE.reset(); refresh().catch(() => {}); }
return;
}
const pe = e.target.closest("button.pickedition");
if (pe) {
const res = await apiPost("/api/open-versions", {
+14
View File
@@ -516,10 +516,13 @@ def create_app(
}
def version_payload(row: dict) -> dict:
available = photo_names()
return {
"row_ix": _ix_of(session.rows, row),
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
# same-title copies are told apart by their photos
"photos": [p for p in row["source_photos"].split(";") if p in available],
"bgg_name": row["bgg_name"],
"candidates": json.loads(row["version_candidates_json"] or "[]"),
}
@@ -1128,6 +1131,17 @@ def create_app(
replay_titles(cfg)
return state()
@app.post("/api/reopen-match")
def api_reopen_match(body: RowRef) -> dict:
with lock:
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")
session.reopen_match(row)
return state()
@app.post("/api/open-versions")
def api_open_versions(body: RowRef) -> dict:
with lock: