Web review UI: bggpipe review --web (FastAPI, localhost, no build step)

One self-contained page (inline CSS/JS, system fonts, works offline):
match cards show source photos, extracted cues, and candidates with
cached-XML thumbnails (placeholder tiles until real fixtures exist);
actions are pick / manual BGG id / reject, plus a skippable editions
pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r
reject, m manual, u unknown, d dismiss. Every decision writes
matches.csv through the same ReviewSession methods the TUI now shares —
the TUI remains as the no-flag fallback. unidentified.json renders as
visually distinct reshoot work-orders with dismissals persisted in
data/unidentified_dismissed.json (survives extract rebuilds). Progress
tally and a diff-ready done screen; photo serving is allowlisted to
photos/ contents; server binds 127.0.0.1 only. Layout leaves room for
a later games.json browse view.

Provenance guard: fixture generators now write STUB_FIXTURES.marker
into their cache dirs, and CLAUDE.md gains the hard rule that stub-
resolved version_ids are placeholders — upload must refuse to run
while data/bgg_cache/STUB_FIXTURES.marker exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 15:13:29 -04:00
parent 4af83d3626
commit 2273ac7cf7
11 changed files with 1070 additions and 16 deletions
+1
View File
@@ -20,6 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items.
- Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025).
- BGG has **no write API**: writes drive the real website with a logged-in Playwright session.
- **Stub-resolved data is never upload-ready.** All version_ids (and some game data) in `matches.csv`, `to_add.csv`, and `to_update.csv` currently come from SYNTHETIC stub fixtures — placeholders until real fixtures exist. When `BGG_API_TOKEN` arrives: delete both cache dirs, re-record fixtures, `resolve --force`, re-review. The caches carry a `STUB_FIXTURES.marker` provenance file (written by the fixture generators); the upload stage MUST refuse to run while `data/bgg_cache/STUB_FIXTURES.marker` exists.
## Domain gotchas
+2
View File
@@ -12,6 +12,8 @@ dependencies = [
"pillow>=12.3.0",
"pillow-heif>=1.5.0",
"rich>=15.0.0",
"fastapi>=0.141.1",
"uvicorn>=0.52.1",
]
[project.scripts]
+6
View File
@@ -348,6 +348,12 @@ def main() -> None:
target.mkdir(parents=True, exist_ok=True)
for name, xml in files.items():
(target / name).write_text(xml)
# provenance marker: anything resolved from this cache is stub-derived
# and NOT upload-ready; re-recording real fixtures removes the marker
(target / "STUB_FIXTURES.marker").write_text(
"This cache contains hand-written stub XML, not real BGG "
"responses. Data resolved from it must not be uploaded.\n"
)
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
+4
View File
@@ -104,6 +104,10 @@ THINGS = {
def main() -> None:
FIXTURE_CACHE.mkdir(parents=True, exist_ok=True)
(FIXTURE_CACHE / "STUB_FIXTURES.marker").write_text(
"This cache contains hand-written stub XML, not real BGG "
"responses. Data resolved from it must not be uploaded.\n"
)
for query, items in SEARCHES.items():
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
total = items.count("<item ")
+15 -4
View File
@@ -59,12 +59,23 @@ def resolve(
@app.command()
def review(config: ConfigOpt = None) -> None:
def review(
web: Annotated[
bool, typer.Option("--web", help="Serve the review UI on localhost")
] = False,
port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377,
config: ConfigOpt = None,
) -> None:
"""Stage 3: human review of ambiguous/unmatched items."""
from bggpipe.review import run_review
cfg = load_config(config)
run_review(cfg)
if web:
from bggpipe.webreview import run_web_review
run_web_review(cfg, port=port)
else:
from bggpipe.review import run_review
run_review(cfg)
@app.command()
+43 -12
View File
@@ -109,6 +109,45 @@ class ReviewSession:
shim.version_candidates, ensure_ascii=False
)
# -- decision API (shared by the TUI and the web UI) ----------------
def pending_rows(self) -> list[dict]:
return [r for r in self.rows if r["match_status"] in ("ambiguous", "unmatched")]
def version_rows(self) -> list[dict]:
return [r for r in self.rows if r["version_status"] == "version_ambiguous"]
def cues_for(self, title_raw: str):
return self._titles.get(title_raw)
def decide_pick(self, row: dict, candidate: dict) -> None:
self._apply_choice(row, candidate)
def decide_manual(self, row: dict, bgg_id: int) -> None:
self._manual_id(row, bgg_id)
def decide_reject(self, row: dict) -> None:
row["match_status"] = "rejected"
self._save()
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:
row["version_status"] = "version_unknown"
row["version_id"] = ""
row["version_name"] = ""
else:
candidates = json.loads(row["version_candidates_json"] or "[]")
chosen = next(
(v for v in candidates if v.get("version_id") == version_id), None
)
if chosen is None:
raise ValueError(f"version {version_id} is not a stored candidate")
row["version_status"] = "version_approved"
row["version_id"] = str(version_id)
row["version_name"] = chosen.get("name") or ""
self._save()
# -- displays -------------------------------------------------------
def _show_item(self, row: dict, candidates: list[dict]) -> None:
@@ -152,11 +191,10 @@ class ReviewSession:
if lowered == "s":
return
if lowered == "r":
row["match_status"] = "rejected"
self._save()
self.decide_reject(row)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
self._apply_choice(row, candidates[int(answer) - 1])
self.decide_pick(row, candidates[int(answer) - 1])
return
if lowered.startswith("m ") and answer[2:].strip().isdigit():
self._manual_id(row, int(answer[2:].strip()))
@@ -214,17 +252,10 @@ class ReviewSession:
if lowered == "s":
return
if lowered == "u":
row["version_status"] = "version_unknown"
row["version_id"] = ""
row["version_name"] = ""
self._save()
self.decide_version(row, None)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
chosen = candidates[int(answer) - 1]
row["version_status"] = "version_approved"
row["version_id"] = str(chosen.get("version_id") or "")
row["version_name"] = chosen.get("name") or ""
self._save()
self.decide_version(row, candidates[int(answer) - 1].get("version_id"))
return
self.console.print("[yellow]didn't understand that — try again[/yellow]")
+419
View File
@@ -0,0 +1,419 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe review</title>
<style>
:root {
--felt: #2c4136;
--felt-deep: #24362d;
--paper: #f7f4ec;
--paper-edge: #e6e0d2;
--ink: #24291f;
--ink-soft: #5c6355;
--brass: #c08f2f;
--brass-deep: #93691c;
--kraft: #efe4cd;
--approve: #3e6b4f;
--reject: #96473a;
--focus: #7fb08f;
}
* { box-sizing: border-box; }
html { background: var(--felt); }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.06), transparent 60%),
var(--felt);
min-height: 100vh;
}
header {
position: sticky; top: 0; z-index: 5;
background: var(--felt-deep);
color: var(--paper);
padding: .6rem 1.2rem;
display: flex; align-items: baseline; gap: 1.2rem; flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,.12);
}
.wordmark {
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-size: 1.25rem; letter-spacing: .02em;
}
.wordmark small { opacity: .55; font-family: system-ui, sans-serif; font-size: .75rem; margin-left: .5rem; }
#tally { font-size: .85rem; opacity: .85; display: flex; gap: 1rem; }
#tally b { color: var(--brass); font-weight: 600; }
.keyhelp { margin-left: auto; font-size: .75rem; opacity: .6; }
kbd {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: .72rem;
background: var(--paper);
color: var(--ink);
border: 1px solid var(--paper-edge);
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 .35em;
display: inline-block; min-width: 1.4em; text-align: center;
}
header kbd { background: rgba(255,255,255,.14); color: var(--paper); border-color: rgba(255,255,255,.25); }
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
h2 {
color: var(--paper);
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-weight: 500; font-size: 1.05rem; letter-spacing: .04em;
margin: 2rem 0 .8rem;
}
h2 .count { opacity: .6; font-size: .85rem; }
.card {
background: var(--paper);
border-radius: 8px;
border-left: 6px solid transparent;
box-shadow: 0 1px 3px rgba(0,0,0,.35);
padding: 1rem 1.1rem;
margin-bottom: 1rem;
display: flex; gap: 1.1rem;
scroll-margin-top: 5rem;
}
.card.active {
border-left-color: var(--brass);
box-shadow: 0 6px 18px rgba(0,0,0,.45);
}
@media (prefers-reduced-motion: no-preference) {
.card { transition: box-shadow .15s ease, border-color .15s ease; }
}
.shots { flex: 0 0 180px; display: flex; flex-direction: column; gap: .5rem; }
.shots img { width: 100%; border-radius: 4px; border: 1px solid var(--paper-edge); display: block; }
.shots .noshot {
color: var(--ink-soft); font-size: .8rem; border: 1px dashed var(--paper-edge);
border-radius: 4px; padding: 1.2rem .6rem; text-align: center;
}
.body { flex: 1; min-width: 0; }
.title { font-size: 1.15rem; font-weight: 650; margin: 0 0 .15rem; }
.status { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.cues { margin: .5rem 0 .7rem; display: flex; flex-wrap: wrap; gap: .35rem; }
.cue {
font-size: .74rem; background: #eee9db; border: 1px solid var(--paper-edge);
border-radius: 999px; padding: .1rem .6rem; color: var(--ink);
}
.cue b { font-weight: 600; color: var(--ink-soft); }
.cands { list-style: none; margin: 0; padding: 0; }
.cands li {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .5rem; border-radius: 6px; cursor: pointer;
}
.cands li:hover { background: #ede8da; }
.thumb { width: 42px; height: 42px; border-radius: 4px; object-fit: cover; border: 1px solid var(--paper-edge); flex: 0 0 42px; }
.thumb.ph {
display: flex; align-items: center; justify-content: center;
background: var(--felt); color: var(--paper);
font-family: "Iowan Old Style", Palatino, Georgia, serif; font-size: 1.2rem;
}
.cname { font-weight: 600; }
.cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
.rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
.rowactions button {
font: inherit; border: 1px solid var(--paper-edge); background: #fff;
border-radius: 6px; padding: .25rem .7rem; cursor: pointer;
}
.rowactions button.reject { color: var(--reject); border-color: var(--reject); }
.rowactions input[type=text] {
font: inherit; width: 8.5em; padding: .25rem .5rem;
border: 1px solid var(--paper-edge); border-radius: 6px;
}
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* reshoot work-orders: deliberately not cards, tickets */
.ticket {
background: var(--kraft);
border: 2px dashed var(--brass-deep);
border-radius: 4px;
padding: .8rem 1rem;
margin-bottom: .8rem;
display: flex; gap: 1rem; align-items: flex-start;
scroll-margin-top: 5rem;
}
.ticket.active { border-style: solid; box-shadow: 0 6px 18px rgba(0,0,0,.45); }
.ticket .stencil {
writing-mode: vertical-rl; text-orientation: mixed;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: .7rem; letter-spacing: .35em; font-weight: 700;
color: var(--brass-deep); text-transform: uppercase;
border-right: 1px solid var(--brass-deep); padding-right: .5rem;
}
.ticket img { width: 130px; border-radius: 3px; border: 1px solid var(--brass-deep); }
.ticket .loc { font-weight: 600; margin-bottom: .25rem; }
.ticket .partial { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .85rem; }
.ticket .notes { color: #6b5b33; font-size: .85rem; margin-top: .2rem; }
.ticket button {
font: inherit; font-size: .8rem; margin-top: .5rem;
background: none; border: 1px solid var(--brass-deep); color: var(--brass-deep);
border-radius: 6px; padding: .2rem .6rem; cursor: pointer;
}
.done {
background: var(--paper); border-radius: 8px; padding: 1.6rem;
text-align: center; box-shadow: 0 6px 18px rgba(0,0,0,.4);
}
.done h2 { color: var(--ink); margin-top: 0; }
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
.done .nums div { font-size: 1.6rem; font-weight: 700; }
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.done code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
background: var(--felt); color: var(--paper);
padding: .3rem .8rem; border-radius: 6px;
}
@media (max-width: 700px) {
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
}
</style>
</head>
<body>
<header>
<span class="wordmark">bggpipe <small>review</small></span>
<span id="tally"></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>d</kbd> dismiss
</span>
</header>
<main id="main"></main>
<script>
"use strict";
let STATE = null;
let active = 0;
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
async function refresh() {
STATE = await (await fetch("/api/state")).json();
render();
}
async function post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = (await res.json()).detail || res.statusText;
alert("That didn't save: " + detail);
return;
}
STATE = await res.json();
render();
}
function cueChips(cues) {
const parts = [];
if (cues.publisher) parts.push(`<span class="cue"><b>publisher</b> ${esc(cues.publisher)}</span>`);
if (cues.edition) parts.push(`<span class="cue"><b>edition</b> ${esc(cues.edition)}</span>`);
if (cues.year) parts.push(`<span class="cue"><b>year</b> ${esc(cues.year)}</span>`);
if (cues.language) parts.push(`<span class="cue"><b>language</b> ${esc(cues.language)}</span>`);
if (cues.art_notes) parts.push(`<span class="cue"><b>art</b> ${esc(cues.art_notes)}</span>`);
return parts.length ? `<div class="cues">${parts.join("")}</div>` : "";
}
function shots(photos) {
if (!photos.length) return `<div class="shots"><div class="noshot">photo not on disk</div></div>`;
return `<div class="shots">` + photos.map(p =>
`<a href="/photos/${encodeURIComponent(p)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(p)}" alt="source photo ${esc(p)}"></a>`
).join("") + `</div>`;
}
function thumbHtml(c) {
if (c.thumbnail) return `<img class="thumb" src="${esc(c.thumbnail)}" alt="">`;
const initial = (c.name || "?").trim().charAt(0).toUpperCase();
return `<div class="thumb ph" aria-hidden="true">${esc(initial)}</div>`;
}
function matchCard(row, idx) {
const cands = row.candidates.map((c, i) => `
<li data-pick="${c.bgg_id}" title="press ${i + 1}">
<kbd>${i + 1}</kbd> ${thumbHtml(c)}
<span><span class="cname">${esc(c.name)}</span>
<span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span>
</li>`).join("");
return `
<section class="card actionable" data-kind="match" data-idx="${idx}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
${shots(row.photos)}
<div class="body">
<p class="title">${esc(row.title_raw)}</p>
<p class="status">${esc(row.match_status)} · ${idx + 1} of ${STATE.pending.length + STATE.versions.length} to review</p>
${cueChips(row.cues)}
<ol class="cands">${cands || "<li class='cmeta'>no candidates — enter a BGG id or reject</li>"}</ol>
<div class="rowactions">
<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>
</div>
</div>
</section>`;
}
function versionCard(row, idx) {
const cands = row.candidates.map((v, i) => `
<li data-pickver="${v.version_id}" title="press ${i + 1}">
<kbd>${i + 1}</kbd>
<span><span class="cname">${esc(v.name)}</span>
<span class="cmeta">${esc(v.year ?? "—")} · ${esc((v.publishers || []).join(", "))}
· ${esc((v.languages || []).join(", "))} · score ${esc(v.score ?? "—")}</span></span>
</li>`).join("");
return `
<section class="card actionable" data-kind="version" data-idx="${idx}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_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>
<ol class="cands">${cands}</ol>
<div class="rowactions">
<button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
</div>
</div>
</section>`;
}
function ticket(s) {
const img = s.photo_exists
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
: "";
return `
<section class="ticket actionable" data-kind="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
${img}
<div>
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<div class="notes">from ${esc(s.photo)} — take a closer shot, drop it in photos/, run extract</div>
<button title="press d"><kbd>d</kbd> dismiss — found it / not a game</button>
</div>
</section>`;
}
function render() {
const m = document.getElementById("main");
const s = STATE;
document.getElementById("tally").innerHTML =
`<span><b>${s.pending.length}</b> matches</span>
<span><b>${s.versions.length}</b> editions</span>
<span><b>${s.unidentified.length}</b> reshoot</span>
<span>${s.decisions} decided this sitting</span>`;
let html = "";
if (!s.pending.length && !s.versions.length) {
html += `
<div class="done">
<h2>All reviewed — this catalog is diff-ready</h2>
<div class="nums">
<div>${s.summary.recognized}<span>recognized</span></div>
<div>${s.summary.version_updates}<span>with versions</span></div>
<div>${s.summary.rejected}<span>rejected</span></div>
</div>
<p>Next: <code>uv run bggpipe diff</code></p>
${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block the diff.</p>` : ""}
</div>`;
}
if (s.pending.length) {
html += `<h2>Matches <span class="count">— pick the game each photo shows</span></h2>`;
html += s.pending.map(matchCard).join("");
}
if (s.versions.length) {
html += `<h2>Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
html += s.versions.map(versionCard).join("");
}
if (s.unidentified.length) {
html += `<h2>Reshoot <span class="count">— boxes seen but not identified</span></h2>`;
html += s.unidentified.map(ticket).join("");
}
m.innerHTML = html;
const cards = actionables();
if (active >= cards.length) active = Math.max(0, cards.length - 1);
highlight();
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
const card = li.closest(".card");
decide(card, "pick", Number(li.dataset.pick));
});
m.querySelectorAll("[data-pickver]").forEach(li => li.onclick = () => {
const card = li.closest(".card");
version(card, "pick", Number(li.dataset.pickver));
});
m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "reject"));
m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
version(b.closest(".card"), "unknown"));
m.querySelectorAll(".ticket button").forEach(b => b.onclick = () =>
dismiss(b.closest(".ticket")));
m.querySelectorAll(".rowactions input").forEach(inp => {
inp.onkeydown = e => {
if (e.key === "Enter" && inp.value.trim().match(/^\d+$/)) {
decide(inp.closest(".card"), "manual", Number(inp.value.trim()));
}
if (e.key === "Escape") inp.blur();
e.stopPropagation();
};
});
}
const actionables = () => [...document.querySelectorAll(".actionable")];
function highlight() {
actionables().forEach((el, i) => el.classList.toggle("active", i === active));
const el = actionables()[active];
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
}
const decide = (card, action, bgg_id = null) => post("/api/decision", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
action, bgg_id,
});
const version = (card, action, version_id = null) => post("/api/version", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
action, version_id,
});
const dismiss = t => post("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
const cards = actionables();
if (!cards.length) return;
const card = cards[active];
const kind = card?.dataset.kind;
if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); }
else if (/^[1-9]$/.test(e.key) && kind === "match") {
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
if (li) decide(card, "pick", Number(li.dataset.pick));
}
else if (/^[1-9]$/.test(e.key) && kind === "version") {
const li = card.querySelectorAll("[data-pickver]")[Number(e.key) - 1];
if (li) version(card, "pick", Number(li.dataset.pickver));
}
else if (e.key === "r" && kind === "match") decide(card, "reject");
else if (e.key === "u" && kind === "version") version(card, "unknown");
else if (e.key === "d" && kind === "ticket") dismiss(card);
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
});
refresh();
</script>
</body>
</html>
+251
View File
@@ -0,0 +1,251 @@
"""`bggpipe review --web` — the review TUI's local web face.
FastAPI + one self-contained HTML page (inline CSS/JS, no build step),
served on localhost only. All decision logic and matches.csv writes go
through ReviewSession — this module is purely an interface. Also renders
data/unidentified.json as reshoot work-orders with a persisted dismiss
action (data/unidentified_dismissed.json survives extract rebuilds).
The page layout is shared-shell by design: a future "browse" view of
games.json mounts as a sibling section without touching the review code.
"""
from __future__ import annotations
import io
import json
from importlib import resources
from pathlib import Path
import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
from rich.console import Console
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.review import ReviewSession
DEFAULT_PORT = 8377
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
"""bgg_id -> thumbnail URL, from cached /thing XML only (no live calls).
Stub fixtures carry no thumbnails — the UI shows placeholders then."""
thumbnails: dict[int, str] = {}
if not cache_dir.is_dir():
return thumbnails
for path in cache_dir.glob("thing_*.xml"):
try:
root = _safe_fromstring(path.read_text())
except Exception: # noqa: BLE001 — a corrupt cache file must not kill the UI
continue
for item in root.findall("item"):
thumb = (item.findtext("thumbnail") or "").strip()
if thumb and item.get("id"):
thumbnails[int(item.get("id"))] = thumb
return thumbnails
def _sighting_key(photo: str, sighting: dict) -> str:
return "|".join(
[
photo,
sighting.get("location", ""),
sighting.get("partial_text", ""),
sighting.get("art_notes", ""),
]
)
class DismissStore:
"""Dismissed reshoot sightings, kept apart from unidentified.json so
extract's rebuilds can't resurrect them."""
def __init__(self, path: Path) -> None:
self.path = path
self.keys: set[str] = (
set(json.loads(path.read_text())) if path.exists() else set()
)
def add(self, key: str) -> None:
self.keys.add(key)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(sorted(self.keys), indent=2) + "\n")
class DecisionBody(BaseModel):
title_raw: str
source_photos: str
action: str # "pick" | "manual" | "reject"
bgg_id: int | None = None
class VersionBody(BaseModel):
title_raw: str
source_photos: str
action: str # "pick" | "unknown"
version_id: int | None = None
class DismissBody(BaseModel):
photo: str
location: str = ""
partial_text: str = ""
art_notes: str = ""
def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
app = FastAPI(title="bggpipe review")
session = ReviewSession(
cfg,
console=Console(file=io.StringIO()),
input_fn=lambda prompt: "",
client=client,
)
thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json")
def find_row(title_raw: str, source_photos: str) -> dict:
for row in session.rows:
if row["title_raw"] == title_raw and row["source_photos"] == source_photos:
return row
raise HTTPException(404, "row not found — matches.csv changed underneath?")
def photo_names() -> set[str]:
if not cfg.photos_dir.is_dir():
return set()
return {p.name for p in cfg.photos_dir.iterdir() if p.is_file()}
def row_payload(row: dict) -> dict:
entry = session.cues_for(row["title_raw"])
available = photo_names()
candidates = json.loads(row["candidates_json"] or "[]")
for c in candidates:
c["thumbnail"] = thumbnails.get(c.get("bgg_id"))
return {
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
"match_status": row["match_status"],
"photos": [p for p in row["source_photos"].split(";") if p in available],
"cues": {
"publisher": entry.publisher_hint if entry else "",
"edition": entry.edition_hint if entry else "",
"year": entry.year_hint if entry else None,
"language": entry.language_hint if entry else "",
"art_notes": entry.art_notes if entry else "",
},
"candidates": candidates,
}
def version_payload(row: dict) -> dict:
return {
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
"bgg_name": row["bgg_name"],
"candidates": json.loads(row["version_candidates_json"] or "[]"),
}
def state() -> dict:
counts: dict[str, int] = {}
for row in session.rows:
counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1
version_updates = sum(
1
for r in session.rows
if r["version_status"] in ("version_auto", "version_approved")
and r["version_id"]
)
available = photo_names()
sightings = []
if cfg.unidentified_path.exists():
for photo, entries in json.loads(cfg.unidentified_path.read_text()).items():
for s in entries:
if _sighting_key(photo, s) in dismissed.keys:
continue
sightings.append(
{**s, "photo": photo, "photo_exists": photo in available}
)
return {
"pending": [row_payload(r) for r in session.pending_rows()],
"versions": [version_payload(r) for r in session.version_rows()],
"unidentified": sightings,
"decisions": session.decisions,
"summary": {
"recognized": counts.get("auto", 0) + counts.get("approved", 0),
"ambiguous": counts.get("ambiguous", 0),
"unmatched": counts.get("unmatched", 0),
"rejected": counts.get("rejected", 0),
"version_updates": version_updates,
"total": len(session.rows),
},
}
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return (resources.files("bggpipe") / "templates" / "review.html").read_text()
@app.get("/api/state")
def api_state() -> dict:
return state()
@app.post("/api/decision")
def api_decision(body: DecisionBody) -> dict:
row = find_row(body.title_raw, body.source_photos)
if body.action == "pick":
candidates = json.loads(row["candidates_json"] or "[]")
chosen = next(
(c for c in candidates if c.get("bgg_id") == body.bgg_id), None
)
if chosen is None:
raise HTTPException(400, f"bgg_id {body.bgg_id} is not a candidate")
session.decide_pick(row, chosen)
elif body.action == "manual":
if not body.bgg_id:
raise HTTPException(400, "manual decision needs a bgg_id")
session.decide_manual(row, body.bgg_id)
elif body.action == "reject":
session.decide_reject(row)
else:
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
@app.post("/api/version")
def api_version(body: VersionBody) -> dict:
row = find_row(body.title_raw, body.source_photos)
if body.action == "unknown":
session.decide_version(row, None)
elif body.action == "pick":
try:
session.decide_version(row, body.version_id)
except ValueError as err:
raise HTTPException(400, str(err)) from err
else:
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
@app.post("/api/dismiss")
def api_dismiss(body: DismissBody) -> dict:
dismissed.add(_sighting_key(body.photo, body.model_dump(exclude={"photo"})))
return state()
@app.get("/photos/{name}")
def photo(name: str):
if name not in photo_names(): # also blocks any path traversal
raise HTTPException(404, "no such photo")
return FileResponse(cfg.photos_dir / name)
return app
def run_web_review(cfg: Config, *, port: int = DEFAULT_PORT) -> None:
import uvicorn
app = create_app(cfg)
typer.echo(
f"Review UI: http://127.0.0.1:{port}/ (localhost only; every "
"decision saves to matches.csv immediately — Ctrl-C anytime)"
)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
+1
View File
@@ -0,0 +1 @@
This cache contains hand-written stub XML, not real BGG responses. Data resolved from it must not be uploaded.
+270
View File
@@ -0,0 +1,270 @@
"""Web review UI tests via FastAPI's TestClient — no server, no network,
no live BGG (the injected client 401s on any cache miss)."""
from __future__ import annotations
import json
import httpx
from fastapi.testclient import TestClient
from bggpipe.bgg_client import BGGClient, cache_key
from bggpipe.config import Config
from bggpipe.resolve import read_matches, write_matches
from bggpipe.webreview import create_app, load_thumbnails
CITADELS_CANDIDATES = json.dumps(
[
{
"bgg_id": 478,
"name": "Citadels",
"year": 2000,
"type": "boardgame",
"owned": 85000,
"rank": 250,
},
{
"bgg_id": 205398,
"name": "Citadels",
"year": 2016,
"type": "boardgame",
"owned": 24000,
"rank": 400,
},
]
)
VERSION_CANDIDATES = json.dumps(
[
{
"version_id": 111,
"name": "First edition",
"year": 1975,
"publishers": ["TSR"],
"languages": ["English"],
"score": 3,
},
{
"version_id": 222,
"name": "Second edition",
"year": 1980,
"publishers": ["TSR"],
"languages": ["English"],
"score": 3,
},
]
)
def _row(**overrides) -> dict:
row = {
"title_raw": "",
"bgg_id": "",
"bgg_name": "",
"year": "",
"type": "",
"match_status": "auto",
"version_id": "",
"version_name": "",
"version_status": "version_unknown",
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "shelf.jpg",
}
row.update(overrides)
return row
def make_cfg(tmp_path) -> Config:
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
cfg.photos_dir.mkdir(parents=True)
(cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg")
write_matches(
cfg.matches_path,
[
_row(
title_raw="Citadels",
match_status="ambiguous",
candidates_json=CITADELS_CANDIDATES,
),
_row(title_raw="Mystery", match_status="unmatched"),
_row(
title_raw="Dungeon!",
match_status="auto",
bgg_id="1339",
bgg_name="Dungeon!",
version_status="version_ambiguous",
version_candidates_json=VERSION_CANDIDATES,
),
],
)
cfg.titles_path.write_text(
json.dumps(
[
{
"title_raw": "Citadels",
"publisher_hint": "Fantasy Flight",
"edition_hint": "",
"source_photos": ["shelf.jpg"],
}
]
)
)
cfg.unidentified_path.write_text(
json.dumps(
{
"shelf.jpg": [
{
"location": "top shelf, far left",
"partial_text": "EMP",
"art_notes": "black box, gold letters",
}
]
}
)
)
return cfg
def unauthorized_client(tmp_path) -> BGGClient:
return BGGClient(
cache_dir=tmp_path / "no_cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
def make_client(tmp_path) -> tuple[TestClient, Config]:
cfg = make_cfg(tmp_path)
app = create_app(cfg, client=unauthorized_client(tmp_path))
return TestClient(app), cfg
def test_index_serves_page(tmp_path):
web, _ = make_client(tmp_path)
response = web.get("/")
assert response.status_code == 200
assert "bggpipe" in response.text
def test_state_lists_pending_versions_and_tickets(tmp_path):
web, _ = make_client(tmp_path)
state = web.get("/api/state").json()
assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"]
citadels = state["pending"][0]
assert citadels["cues"]["publisher"] == "Fantasy Flight"
assert citadels["photos"] == ["shelf.jpg"]
assert citadels["candidates"][0]["thumbnail"] is None # stub cache: placeholder
assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"]
assert state["unidentified"][0]["location"] == "top shelf, far left"
assert state["summary"]["total"] == 3
def test_pick_candidate_persists(tmp_path):
web, cfg = make_client(tmp_path)
state = web.post(
"/api/decision",
json={
"title_raw": "Citadels",
"source_photos": "shelf.jpg",
"action": "pick",
"bgg_id": 205398,
},
).json()
assert [r["title_raw"] for r in state["pending"]] == ["Mystery"]
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Citadels"]["match_status"] == "approved"
assert saved["Citadels"]["bgg_id"] == "205398"
def test_manual_id_degrades_without_token(tmp_path):
web, cfg = make_client(tmp_path)
response = web.post(
"/api/decision",
json={
"title_raw": "Mystery",
"source_photos": "shelf.jpg",
"action": "manual",
"bgg_id": 99999,
},
)
assert response.status_code == 200
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Mystery"]["match_status"] == "approved"
assert saved["Mystery"]["bgg_id"] == "99999"
assert saved["Mystery"]["bgg_name"] == "" # lookup blocked, id recorded
def test_reject_and_bad_pick(tmp_path):
web, cfg = make_client(tmp_path)
web.post(
"/api/decision",
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"},
)
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Mystery"]["match_status"] == "rejected"
bad = web.post(
"/api/decision",
json={
"title_raw": "Citadels",
"source_photos": "shelf.jpg",
"action": "pick",
"bgg_id": 42,
},
)
assert bad.status_code == 400
def test_version_pick_and_unknown(tmp_path):
web, cfg = make_client(tmp_path)
state = web.post(
"/api/version",
json={
"title_raw": "Dungeon!",
"source_photos": "shelf.jpg",
"action": "pick",
"version_id": 222,
},
).json()
assert state["versions"] == []
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Dungeon!"]["version_status"] == "version_approved"
assert saved["Dungeon!"]["version_id"] == "222"
assert state["summary"]["version_updates"] == 1
def test_dismiss_persists_across_restarts(tmp_path):
web, cfg = make_client(tmp_path)
state = web.post(
"/api/dismiss",
json={
"photo": "shelf.jpg",
"location": "top shelf, far left",
"partial_text": "EMP",
"art_notes": "black box, gold letters",
},
).json()
assert state["unidentified"] == []
# a brand-new app instance (fresh server) still honors the dismissal
web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
assert web2.get("/api/state").json()["unidentified"] == []
def test_photo_serving_is_locked_down(tmp_path):
web, _ = make_client(tmp_path)
assert web.get("/photos/shelf.jpg").status_code == 200
assert web.get("/photos/nope.jpg").status_code == 404
assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404
def test_thumbnails_come_from_cached_thing_xml(tmp_path):
cache = tmp_path / "cache"
cache.mkdir()
key = cache_key("thing", {"id": "478", "stats": "1"})
(cache / key).write_text(
'<items><item type="boardgame" id="478">'
"<thumbnail>https://cf.example/citadels.jpg</thumbnail>"
'<name type="primary" value="Citadels"/></item></items>'
)
thumbs = load_thumbnails(cache)
assert thumbs == {478: "https://cf.example/citadels.jpg"}
Generated
+58
View File
@@ -59,12 +59,14 @@ source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "defusedxml" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pillow" },
{ name = "pillow-heif" },
{ name = "rapidfuzz" },
{ name = "rich" },
{ name = "typer" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
@@ -77,12 +79,14 @@ dev = [
requires-dist = [
{ name = "anthropic", specifier = ">=0.120.2" },
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "pillow-heif", specifier = ">=1.5.0" },
{ name = "rapidfuzz", specifier = ">=3.9" },
{ name = "rich", specifier = ">=15.0.0" },
{ name = "typer", specifier = ">=0.12" },
{ name = "uvicorn", specifier = ">=0.52.1" },
]
[package.metadata.requires-dev]
@@ -100,6 +104,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -136,6 +152,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -646,6 +678,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typer"
version = "0.27.0"
@@ -681,3 +726,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
]