From 1d700db01204c32ca0e579810690f1fd18f520a7 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 3 Aug 2026 00:25:48 -0400 Subject: [PATCH] Shaky reads become visible and clearable on the Titles page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge counted lines nothing on the page identified. Each shaky line (vision confidence below high, nothing verified yet — the badge's exact predicate, served per-line so page and badge can't disagree) now shows a dashed gold "shaky read" chip, a filterbar toggle shows just them, and a "✓ looks right" button confirms a correct read in one click — recorded as a confidence-high edit record, so verification is as durable as any other curation and, unlike a real edit, re-queues nothing. Saving an unchanged edit form on a shaky line counts as confirming it. Help's chip legend explains the chip and its three ways to clear (confirm, edit, or a BGG match). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- src/bggpipe/static/app.css | 1 + src/bggpipe/templates/pages/help.html | 3 +- src/bggpipe/templates/pages/titles.html | 44 ++++++++++++++++++++--- src/bggpipe/webreview.py | 25 +++++++++----- tests/test_webreview.py | 46 +++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 62d1526..b651af1 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -348,6 +348,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .chip.no { background: #fbe3da; color: var(--stop-ink); } .chip.open { background: #ece5f7; color: var(--accent-ink); } .chip.merged { background: #e3ecf3; color: var(--navy); } +.chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); } /* -- merge notices: slim, undoable ------------------------------------- */ .card.merge { padding: .55rem 1rem; align-items: center; } diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index cbe68a6..d6dcdb1 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -27,7 +27,7 @@

Pipeline — 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.

Photos — drag photos in (or drop them in the photos/ folder). 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 with the same name re-extracts it.

-

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky reads (the model wasn't sure) that haven't been resolved or human-verified yet.

+

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — 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 shortcuts.

Queue — 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.

Library — 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.

@@ -49,6 +49,7 @@

ambiguous several plausible games — needs your pick on Review. unmatched nothing plausible found — enter a BGG id or re-search on Review.

merged two reads judged to be the same physical box; the merge is veto-able on Review. copy one copy of a title you split.

rejected you ruled it's not on BGG (or not a game worth matching); it stays listed but goes no further.

+

shaky read 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 ✓ looks right (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.

Keyboard shortcuts

diff --git a/src/bggpipe/templates/pages/titles.html b/src/bggpipe/templates/pages/titles.html index aaa69fa..4f8cd11 100644 --- a/src/bggpipe/templates/pages/titles.html +++ b/src/bggpipe/templates/pages/titles.html @@ -1,7 +1,9 @@

Titles

- + +

Nothing extracted yet — start on the photos page.

diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index e8c0dc6..f16e3c3 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -161,7 +161,8 @@ class SplitBody(BaseModel): class EditBody(BaseModel): """A human correction to an extracted read. None = leave that field alone; for the cue fields, empty string = clear it (a corrected title - may not be empty).""" + may not be empty). `confirm` alone means "this read is right as-is" — + it marks the line human-verified without changing anything.""" title_raw: str source_photos: str = "" @@ -170,6 +171,7 @@ class EditBody(BaseModel): edition: str | None = None year: str | None = None language: str | None = None + confirm: bool = False class RemoveBody(BaseModel): @@ -440,6 +442,10 @@ def create_app( # split is a titles.json decision, no BGG data at stake or bool(not row and entry and len(entry.source_photos) > 1), "split_copy": bool(row and row.get("dedupe_veto")), + # same predicate as /api/pipeline's shaky_reads badge: the + # model wasn't sure, and neither a match nor a human has + # verified the read yet + "shaky": bool(entry and not row and entry.confidence != "high"), } for entry in session.titles: @@ -873,18 +879,21 @@ def create_app( if year and not year.isdigit(): raise HTTPException(400, "year must be a number") record["year_hint"] = int(year) if year else None - if not (record.keys() - {"match", "photos"}): + changed = bool(record.keys() - {"match", "photos"}) + if not changed and not body.confirm: raise HTTPException(400, "nothing to change") record["confidence"] = "high" # a human verified this line # write order is crash-safety: drop the stale rows first (worst # case on a crash: resolve recreates them from the uncorrected # entry), then the durable record (replayed by every future - # rebuild), then the titles.json replay - session.drop_rows( - body.title_raw, - record.get("photos"), - new_title=record.get("title_raw"), - ) + # rebuild), then the titles.json replay. A confirm-only save + # changed no data, so nothing is stale — no re-queue. + if changed: + session.drop_rows( + body.title_raw, + record.get("photos"), + new_title=record.get("title_raw"), + ) if "title_raw" in record and is_split( norm, entry.source_photos, diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 3a6c4c6..2274058 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -823,3 +823,49 @@ def test_nav_order_matches_workflow(tmp_path): assert order == sorted(order) # the old address still lands on the page assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles" + + +def test_confirm_marks_shaky_read_verified_without_requeue(tmp_path): + cfg = make_cfg(tmp_path) + titles = json.loads(cfg.titles_path.read_text()) + titles.append( + { + "title_raw": "Blurry Spine", + "confidence": "low", + "source_photos": ["shelf.jpg"], + } + ) + cfg.titles_path.write_text(json.dumps(titles)) + web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + line = next( + c + for c in web.get("/api/state").json()["catalog"] + if c["title_raw"] == "Blurry Spine" + ) + assert line["shaky"] is True + rows_before = read_matches(cfg.matches_path) + + res = web.post( + "/api/edit-title", + json={ + "title_raw": "Blurry Spine", + "source_photos": "shelf.jpg", + "confirm": True, + }, + ) + assert res.status_code == 200 + line = next(c for c in res.json()["catalog"] if c["title_raw"] == "Blurry Spine") + assert line["shaky"] is False # verified: chip and badge both clear + assert web.get("/api/pipeline").json()["shaky_reads"] == 0 + # confirm changed no data, so nothing was re-queued + assert read_matches(cfg.matches_path) == rows_before + (record,) = json.loads(cfg.title_edits_path.read_text()) + assert record["confidence"] == "high" + # a change-free save without confirm is still refused + assert ( + web.post( + "/api/edit-title", + json={"title_raw": "Blurry Spine", "source_photos": "shelf.jpg"}, + ).status_code + == 400 + )