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:
@@ -348,6 +348,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
.chip.no { background: #fbe3da; color: var(--stop-ink); }
|
.chip.no { background: #fbe3da; color: var(--stop-ink); }
|
||||||
.chip.open { background: #ece5f7; color: var(--accent-ink); }
|
.chip.open { background: #ece5f7; color: var(--accent-ink); }
|
||||||
.chip.merged { background: #e3ecf3; color: var(--navy); }
|
.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 ------------------------------------- */
|
/* -- merge notices: slim, undoable ------------------------------------- */
|
||||||
.card.merge { padding: .55rem 1rem; align-items: center; }
|
.card.merge { padding: .55rem 1rem; align-items: center; }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="card">
|
<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="/">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="/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="/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="/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>
|
<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 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 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 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>
|
</div>
|
||||||
|
|
||||||
<h2 id="keys">Keyboard shortcuts</h2>
|
<h2 id="keys">Keyboard shortcuts</h2>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<h1>Titles</h1>
|
<h1>Titles</h1>
|
||||||
<div class="pagebar"><span id="catcount"></span></div>
|
<div class="pagebar"><span id="catcount"></span></div>
|
||||||
<div class="filterbar">
|
<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>
|
||||||
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
|
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
|
||||||
<script>
|
<script>
|
||||||
@@ -32,20 +34,30 @@ function editorRow(c) {
|
|||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let SHAKY_ONLY = false;
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
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) =>
|
const sorted = [...CATALOG].sort((a, b) =>
|
||||||
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
|
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.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
|
||||||
: sorted;
|
: sorted;
|
||||||
|
const rows = SHAKY_ONLY ? searched.filter(c => c.shaky) : searched;
|
||||||
document.getElementById("catcount").innerHTML =
|
document.getElementById("catcount").innerHTML =
|
||||||
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
|
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
|
||||||
document.getElementById("catbody").innerHTML = rows.length
|
document.getElementById("catbody").innerHTML = rows.length
|
||||||
? `<div class="catalog"><table>` + rows.map(c => `
|
? `<div class="catalog"><table>` + rows.map(c => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="t">${esc(c.title_raw)}
|
<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>${statusChip(c)}</td>
|
||||||
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
||||||
${c.version_name ? " · " + esc(c.version_name) : ""}
|
${c.version_name ? " · " + esc(c.version_name) : ""}
|
||||||
@@ -60,6 +72,9 @@ function render() {
|
|||||||
title="one line, several boxes? make each photo its own copy">
|
title="one line, several boxes? make each photo its own copy">
|
||||||
split into copies</button>`
|
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))}"
|
<button class="edit" data-key="${esc(lineKey(c))}"
|
||||||
title="fix a misread title or add cues you already know">edit</button>
|
title="fix a misread title or add cues you already know">edit</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -83,6 +98,16 @@ async function refresh() {
|
|||||||
document.getElementById("catbody").addEventListener("click", async e => {
|
document.getElementById("catbody").addEventListener("click", async e => {
|
||||||
const cancel = e.target.closest("button.canceledit");
|
const cancel = e.target.closest("button.canceledit");
|
||||||
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
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");
|
const rm = e.target.closest("button.removetitle");
|
||||||
if (rm) {
|
if (rm) {
|
||||||
const f = rm.closest("form.editform");
|
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("edition") !== (cue.edition || "")) body.edition = v("edition");
|
||||||
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
|
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
|
||||||
if (v("language") !== (cue.language || "")) body.language = v("language");
|
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);
|
const res = await apiPost("/api/edit-title", body);
|
||||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("catsearch").addEventListener("input", render);
|
document.getElementById("catsearch").addEventListener("input", render);
|
||||||
|
document.getElementById("shakyfilter").addEventListener("click", () => {
|
||||||
|
SHAKY_ONLY = !SHAKY_ONLY;
|
||||||
|
render();
|
||||||
|
});
|
||||||
refresh().catch(err => errorBanner(err.message || err));
|
refresh().catch(err => errorBanner(err.message || err));
|
||||||
pollLoop(refresh, 5000, () => showBanner(""));
|
pollLoop(refresh, 5000, () => showBanner(""));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ class SplitBody(BaseModel):
|
|||||||
class EditBody(BaseModel):
|
class EditBody(BaseModel):
|
||||||
"""A human correction to an extracted read. None = leave that field
|
"""A human correction to an extracted read. None = leave that field
|
||||||
alone; for the cue fields, empty string = clear it (a corrected title
|
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
|
title_raw: str
|
||||||
source_photos: str = ""
|
source_photos: str = ""
|
||||||
@@ -170,6 +171,7 @@ class EditBody(BaseModel):
|
|||||||
edition: str | None = None
|
edition: str | None = None
|
||||||
year: str | None = None
|
year: str | None = None
|
||||||
language: str | None = None
|
language: str | None = None
|
||||||
|
confirm: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RemoveBody(BaseModel):
|
class RemoveBody(BaseModel):
|
||||||
@@ -440,6 +442,10 @@ def create_app(
|
|||||||
# split is a titles.json decision, no BGG data at stake
|
# split is a titles.json decision, no BGG data at stake
|
||||||
or bool(not row and entry and len(entry.source_photos) > 1),
|
or bool(not row and entry and len(entry.source_photos) > 1),
|
||||||
"split_copy": bool(row and row.get("dedupe_veto")),
|
"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:
|
for entry in session.titles:
|
||||||
@@ -873,18 +879,21 @@ def create_app(
|
|||||||
if year and not year.isdigit():
|
if year and not year.isdigit():
|
||||||
raise HTTPException(400, "year must be a number")
|
raise HTTPException(400, "year must be a number")
|
||||||
record["year_hint"] = int(year) if year else None
|
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")
|
raise HTTPException(400, "nothing to change")
|
||||||
record["confidence"] = "high" # a human verified this line
|
record["confidence"] = "high" # a human verified this line
|
||||||
# write order is crash-safety: drop the stale rows first (worst
|
# write order is crash-safety: drop the stale rows first (worst
|
||||||
# case on a crash: resolve recreates them from the uncorrected
|
# case on a crash: resolve recreates them from the uncorrected
|
||||||
# entry), then the durable record (replayed by every future
|
# 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
|
||||||
session.drop_rows(
|
# changed no data, so nothing is stale — no re-queue.
|
||||||
body.title_raw,
|
if changed:
|
||||||
record.get("photos"),
|
session.drop_rows(
|
||||||
new_title=record.get("title_raw"),
|
body.title_raw,
|
||||||
)
|
record.get("photos"),
|
||||||
|
new_title=record.get("title_raw"),
|
||||||
|
)
|
||||||
if "title_raw" in record and is_split(
|
if "title_raw" in record and is_split(
|
||||||
norm,
|
norm,
|
||||||
entry.source_photos,
|
entry.source_photos,
|
||||||
|
|||||||
@@ -823,3 +823,49 @@ def test_nav_order_matches_workflow(tmp_path):
|
|||||||
assert order == sorted(order)
|
assert order == sorted(order)
|
||||||
# the old address still lands on the page
|
# the old address still lands on the page
|
||||||
assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles"
|
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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user