Off-BGG games become local library citizens

An unmatched title that's a REAL game BGG doesn't have dead-ended:
manual id or reject. The RPG local-citizen pattern generalizes to a
human decision — review (web + TUI, key l) gains "not on BGG — keep
locally": match_status "local" clears any BGG identity, diff routes it
to local_only (never queued), and enrich synthesizes a library entry
from the game's own photo reads (name, year, publisher cue — no API
call, so even a blocked run lands them; pruning keeps local keys).
Library and Titles show a "local — not on BGG" chip; Help's legend,
review description, and shortcuts cover the new verb, distinguishing
it from reject (bad read / not a game).

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 18:59:56 -04:00
co-authored by Claude Fable 5
parent 59f4b8c43c
commit e187ed4f3f
15 changed files with 184 additions and 24 deletions
+8 -1
View File
@@ -135,6 +135,11 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
if status == "merged":
result.merged += 1 # represented by its survivor row
continue
if status == "local":
# the human's call: a real game BGG doesn't have — a library
# citizen only, never queued
result.local_only.append(row["title_raw"])
continue
if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]:
result.pending.append(row["title_raw"])
continue
@@ -316,7 +321,9 @@ def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else ""
local_note = (
f" · {len(result.local_only)} local-only (RPGs)" if result.local_only else ""
f" · {len(result.local_only)} local-only (RPGs, off-BGG games)"
if result.local_only
else ""
)
typer.echo(
f"\n{result.recognized} recognized · {len(result.already_owned)} already "
+31 -2
View File
@@ -28,7 +28,8 @@ from bggpipe.bgg_client import (
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized
from bggpipe.resolve import read_matches
from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20
@@ -95,12 +96,40 @@ def run_enrich(
games[key] = {**fetched[bgg_id], "version": version}
updated += 1
# games the human ruled off-BGG: library entries built from their own
# photo reads — no API involved, so a blocked run still lands them
local_keys: set[str] = set()
local_rows = [r for r in rows if r["match_status"] == "local"]
if local_rows:
try:
cues = {
(e.title_raw, ";".join(e.source_photos)): e
for e in load_titles(cfg.titles_path)
}
except FileNotFoundError:
cues = {}
for row in local_rows:
key = f"local:{normalize_title(row['title_raw'])}:{row['source_photos']}"
local_keys.add(key)
entry = cues.get((row["title_raw"], row["source_photos"]))
games[key] = {
"bgg_id": None,
"name": row["title_raw"],
"year": entry.year_hint if entry else None,
"type": "localgame",
"publishers": [entry.publisher_hint]
if entry and entry.publisher_hint
else [],
"source_photos": [p for p in row["source_photos"].split(";") if p],
}
updated += 1
# prune keys no current target claims: a row whose version was approved
# after a bare-key run (or was later rejected) must not leave an orphan
# entry in the frontend seed data. Only safe when nothing was blocked —
# a token-less run knows too little to declare anything stale.
if not blocked:
current = {key for key, _, _ in targets}
current = {key for key, _, _ in targets} | local_keys
stale = [k for k in games if k not in current]
for k in stale:
del games[k]
+17 -3
View File
@@ -357,6 +357,17 @@ class ReviewSession:
row["match_status"] = "rejected"
self._save(row)
def decide_local(self, row: dict) -> None:
"""A real game BGG simply doesn't have: a local library citizen —
listed and enriched from its own photo reads, never uploaded."""
row["match_status"] = "local"
row["bgg_id"] = ""
row["bgg_name"] = ""
row["version_status"] = ""
row["version_id"] = ""
row["version_name"] = ""
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
@@ -443,10 +454,10 @@ class ReviewSession:
while True:
self._show_item(row, candidates)
prompt = (
"[1-N] pick (s)kip (r)eject (m <id>) manual BGG id "
"(f <text>) re-search (q)uit > "
"[1-N] pick (s)kip (r)eject (l)ocal — not on BGG "
"(m <id>) manual BGG id (f <text>) re-search (q)uit > "
if row["match_status"] == "unmatched"
else "[1-N] pick (s)kip (r)eject (q)uit > "
else "[1-N] pick (s)kip (r)eject (l)ocal — not on BGG (q)uit > "
)
answer = self._ask(prompt)
lowered = answer.lower()
@@ -455,6 +466,9 @@ class ReviewSession:
if lowered == "r":
self.decide_reject(row)
return
if lowered == "l":
self.decide_local(row)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
self.decide_pick(row, candidates[int(answer) - 1])
return
+1
View File
@@ -155,6 +155,7 @@ function statusChip(c) {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "local") return `<span class="chip open">local — not on BGG</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
}
+4 -3
View File
@@ -28,7 +28,7 @@
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p>
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, and whether two same-game reads are really one box (merges show a veto). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.</p>
</div>
@@ -49,13 +49,14 @@
<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 no">rejected</span> you ruled it's not on BGG (or not a game worth matching); it stays listed but goes no further.</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 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>
<h2 id="keys">Keyboard shortcuts</h2>
<div class="card prose">
<p><b>Review:</b> <kbd>j</kbd>/<kbd>k</kbd> move between cards · <kbd>1</kbd><kbd>9</kbd> pick a candidate · <kbd>r</kbd> reject · <kbd>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p>
<p><b>Review:</b> <kbd>j</kbd>/<kbd>k</kbd> move between cards · <kbd>1</kbd><kbd>9</kbd> pick a candidate · <kbd>r</kbd> reject · <kbd>l</kbd> keep local (not on BGG) · <kbd>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p>
<p><b>Photo pages:</b> <kbd></kbd>/<kbd></kbd> move between photos.</p>
</div>
+2 -1
View File
@@ -27,7 +27,8 @@ function gameCard(g) {
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
<div class="info">
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}</div>
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
</div>
+5 -1
View File
@@ -3,7 +3,7 @@
<span id="sectionlinks"></span>
<span class="keyhelp">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick ·
<kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
<kbd>r</kbd> reject · <kbd>l</kbd> keep local · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
<kbd>v</kbd> veto merge
</span>
</div>
@@ -76,6 +76,7 @@ function matchCard(row, idx) {
<span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
aria-label="manual BGG id"></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>
</div>
</section>`;
@@ -179,6 +180,8 @@ function render() {
});
m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "reject"));
m.querySelectorAll(".golocal").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "local"));
m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
version(b.closest(".card"), "unknown"));
m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
@@ -238,6 +241,7 @@ document.addEventListener("keydown", e => {
if (li) version(card, "pick", Number(li.dataset.pickver));
}
else if (e.key === "r" && kind === "match") decide(card, "reject");
else if (e.key === "l" && kind === "match") decide(card, "local");
else if (e.key === "u" && kind === "version") version(card, "unknown");
else if (e.key === "v" && kind === "merge") vetoMerge(card);
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
+3
View File
@@ -618,6 +618,7 @@ def create_app(
"ambiguous": counts.get("ambiguous", 0),
"unmatched": counts.get("unmatched", 0),
"rejected": counts.get("rejected", 0),
"local": counts.get("local", 0),
"version_updates": version_updates,
"total": len(session.rows),
"extracted": len(session.titles),
@@ -898,6 +899,8 @@ def create_app(
session.decide_manual(row, body.bgg_id)
elif body.action == "reject":
session.decide_reject(row)
elif body.action == "local":
session.decide_local(row)
else:
raise HTTPException(400, f"unknown action {body.action!r}")
return state()