Shaky reads become visible and clearable on the Titles page

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 00:25:48 -04:00
parent f19b861251
commit 1d700db012
5 changed files with 106 additions and 13 deletions
+1
View File
@@ -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; }
+2 -1
View File
@@ -27,7 +27,7 @@
<div class="card">
<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 (or drop them in the <code>photos/</code> 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.</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 shaky reads (the model wasn't sure) that haven't been resolved or human-verified yet.</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="/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>
@@ -49,6 +49,7 @@
<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 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>
+40 -4
View File
@@ -1,7 +1,9 @@
<h1>Titles</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter catalog titles">
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles">
<button id="shakyfilter" aria-pressed="false" hidden
title="reads the model wasn't sure of — confirm or fix each one">shaky reads</button>
</div>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script>
@@ -32,20 +34,30 @@ function editorRow(c) {
</td></tr>`;
}
let SHAKY_ONLY = false;
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const shakyCount = CATALOG.filter(c => c.shaky).length;
const fbtn = document.getElementById("shakyfilter");
fbtn.hidden = shakyCount === 0 && !SHAKY_ONLY;
fbtn.textContent = `shaky reads — ${shakyCount}`;
fbtn.setAttribute("aria-pressed", String(SHAKY_ONLY));
const sorted = [...CATALOG].sort((a, b) =>
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
const rows = q
const searched = q
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: sorted;
const rows = SHAKY_ONLY ? searched.filter(c => c.shaky) : searched;
document.getElementById("catcount").innerHTML =
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
document.getElementById("catbody").innerHTML = rows.length
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}</td>
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}
${c.shaky ? `<span class="chip shaky"
title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}
@@ -60,6 +72,9 @@ function render() {
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}
${c.shaky ? `<button class="confirmread" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}"
title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
<button class="edit" data-key="${esc(lineKey(c))}"
title="fix a misread title or add cues you already know">edit</button>
</td>
@@ -83,6 +98,16 @@ async function refresh() {
document.getElementById("catbody").addEventListener("click", async e => {
const cancel = e.target.closest("button.canceledit");
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
const ok = e.target.closest("button.confirmread");
if (ok) {
const res = await apiPost("/api/edit-title", {
title_raw: ok.dataset.title,
source_photos: ok.dataset.photos,
confirm: true,
});
if (res) { LAST = null; refresh().catch(() => {}); }
return;
}
const rm = e.target.closest("button.removetitle");
if (rm) {
const f = rm.closest("form.editform");
@@ -128,12 +153,23 @@ document.getElementById("catbody").addEventListener("submit", async e => {
if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
if (v("language") !== (cue.language || "")) body.language = v("language");
if (Object.keys(body).length <= 2) { EDITING = null; render(); return; }
if (Object.keys(body).length <= 2) {
// saving unchanged: on a shaky line that means "it's right as-is"
if (orig.shaky) {
const res = await apiPost("/api/edit-title", { ...body, confirm: true });
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); return; }
}
EDITING = null; render(); return;
}
const res = await apiPost("/api/edit-title", body);
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
});
document.getElementById("catsearch").addEventListener("input", render);
document.getElementById("shakyfilter").addEventListener("click", () => {
SHAKY_ONLY = !SHAKY_ONLY;
render();
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+12 -3
View File
@@ -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,13 +879,16 @@ 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
# 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"),
+46
View File
@@ -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
)