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.
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
+ )