The proofread checkpoint gets its place: nav order, badge, and a rename

Eric's observation: cleaning up raw reads happens BEFORE resolve, but
the app ordered Review ahead of Catalog and never said proofreading was
a step at all. The pipeline has two human checkpoints — proofread the
reads (after extract), decide the matches (after resolve) — and now the
app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue,
Library, Help in true workflow order; the Titles page (né Catalog — the
old name suggested a finished collection, which is the Library's job;
/catalog redirects) gets a badge counting unresolved shaky reads; an
edit marks its entry human-verified so the badge drains as you
proofread; the extract stage card nudges toward the proofread before
resolving; and the Help flow is rewritten as six stages + two
checkpoints with the loop called out.

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:19:59 -04:00
co-authored by Claude Fable 5
parent 25c1432a03
commit f19b861251
14 changed files with 163 additions and 155 deletions
+1
View File
@@ -243,6 +243,7 @@ def _merge(a: dict, b: dict) -> dict:
# the fields a human correction may override on an extracted entry
EDIT_FIELDS = (
"title_raw",
"confidence",
"publisher_hint",
"edition_hint",
"year_hint",
+1
View File
@@ -74,6 +74,7 @@ async function refreshBadges() {
if (el) el.textContent = n > 0 ? String(n) : "";
};
set("photos", p.reshoot);
set("titles", p.shaky_reads);
set("review", p.pending_review);
set("queue", p.to_add + p.to_update);
return p;
+11 -10
View File
@@ -2,7 +2,7 @@
<div class="pagebar">
<a href="#flow">The flow</a>
<a href="#pages">Pages</a>
<a href="#curation">Fixing the catalog</a>
<a href="#curation">Fixing the titles</a>
<a href="#statuses">Statuses</a>
<a href="#keys">Keyboard</a>
<a href="#files">Your data</a>
@@ -10,34 +10,35 @@
<h2 id="flow">The flow: shelves → collection</h2>
<div class="card">
<p>Six stages, run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<p>Six pipeline stages and two checkpoints that are yours. Stages run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<ol>
<li><b>extract</b> — every photo goes to Claude vision once; titles and edition cues (publisher, edition wording, year, language) land in the catalog. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
<li><b>resolve</b> — titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged for review, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
<li><b>review</b>you decide the flagged ones on the <a href="/review">Review</a> page. Every decision saves immediately.</li>
<li><b>extract</b> — every photo goes to Claude vision once; what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
<li><b>proofread</b> <i>(you, on <a href="/titles">Titles</a>)</i> — fix misread titles, add cues you know, split multi-box lines, remove non-games, <a href="#curation">details below</a>. Worth doing <i>before</i> resolve: a fix made now is one BGG search done right; a fix made later sends the title back through resolve again.</li>
<li><b>resolve</b>titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
<li><b>review</b> <i>(you, on <a href="/review">Review</a>)</i> — decide the flagged ones: which game, which edition, whether two reads are one box. Every decision saves immediately.</li>
<li><b>diff</b> — your existing BGG collection is fetched and compared, per copy. What's genuinely new lands in the <a href="/queue">Queue</a>.</li>
<li><b>upload</b> — a real browser logs into BGG and adds each queued game, slowly and politely. Always dry-run first; the buttons enforce that order.</li>
<li><b>enrich</b> — full metadata (players, weight, rank, artwork) fills the <a href="/library">Library</a>.</li>
</ol>
<p>Every stage is resumable: stop anything mid-run and nothing is lost. Re-runs skip work already done.</p>
<p>It's a loop, not a line: new photos, edits, and splits feed both checkpoints again, and the sidebar badges show when a page has work for you. Every stage is resumable stop anything mid-run and nothing is lost; re-runs skip work already done.</p>
</div>
<h2 id="pages">What each page is for</h2>
<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="/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="/catalog">Catalog</a></b> — every title read from your shelves, alphabetized, with its status and photos. This is also where you <a href="#curation">fix the data itself</a>: edit, split, remove.</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>
</div>
<h2 id="curation">Fixing the catalog: edit, split, remove</h2>
<h2 id="curation">Fixing the titles: edit, split, remove</h2>
<div class="card">
<p>Vision reads aren't perfect, and you know things the photos don't show. Every catalog line has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
<p><b>remove from catalog</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p>
<p><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p>
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
</div>
+6 -1
View File
@@ -46,7 +46,12 @@ function render() {
· <a href="/queue">inspect the queue</a>`;
document.getElementById("stages").innerHTML = [
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a><b>${P.titles}</b> so far`, runBtn("extract")),
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a><b>${P.titles}</b> so far`
+ (P.titles > 0
? P.shaky_reads > 0
? ` · <b>${P.shaky_reads}</b> shaky read(s) worth a <a href="/titles">proofread</a> before resolving`
: ` · <a href="/titles">proofread the titles</a> before resolving`
: ""), runBtn("extract")),
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
+1 -1
View File
@@ -142,7 +142,7 @@ function render() {
<div class="done">
<h2>${waiting
? "Resolved set fully reviewed"
: "All reviewed — the catalog is diff-ready"}</h2>
: "All reviewed — every title is diff-ready"}</h2>
<div class="nums">
<div>${s.summary.extracted}<span>extracted</span></div>
<div>${s.summary.recognized}<span>recognized</span></div>
@@ -1,4 +1,4 @@
<h1>Catalog</h1>
<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">
+21 -5
View File
@@ -1,6 +1,6 @@
"""The local web app: six pages in a shared sidebar shell.
Pipeline (/), Photos, Review, Catalog, Queue, and Library rendered
Pipeline (/), Photos, Titles, Review, Queue, Library, and Help rendered
server-side from templates/shell.html plus one fragment per page, with
static/app.css as the design system and static/app.js as shared client
plumbing. No template engine and no build step; served on localhost only.
@@ -228,11 +228,13 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
# (href, page-name, label, badge-name) — badge names match app.js
# ordered as the real workflow runs: photograph, proofread the reads
# (catalog), resolve + decide (review), then what ships (queue, library)
NAV_PAGES = (
("/", "pipeline", "Pipeline", ""),
("/photos", "photos", "Photos", "photos"),
("/titles", "titles", "Titles", "titles"),
("/review", "review", "Review", "review"),
("/catalog", "catalog", "Catalog", ""),
("/queue", "queue", "Queue", "queue"),
("/library", "library", "Library", ""),
("/help", "help", "Help", ""),
@@ -544,9 +546,14 @@ def create_app(
def review_page() -> str:
return render_page("review")
@app.get("/catalog", response_class=HTMLResponse)
def catalog_page() -> str:
return render_page("catalog")
@app.get("/titles", response_class=HTMLResponse)
def titles_page() -> str:
return render_page("titles")
@app.get("/catalog")
def catalog_redirect() -> Response:
# the page's old name; bookmarks keep working
return Response(status_code=307, headers={"Location": "/titles"})
@app.get("/queue", response_class=HTMLResponse)
def queue_page() -> str:
@@ -644,6 +651,14 @@ def create_app(
"photos": len(photo_names()),
"reshoot": len(open_sightings()),
"titles": len(session.titles),
# unresolved shaky reads: the proofread-before-resolve
# signal (an edit marks the entry high — human-verified)
"shaky_reads": sum(
1
for e in session.titles
if e.confidence != "high"
and e.title_raw not in {r["title_raw"] for r in session.rows}
),
"matches": dict(match_counts),
"pending_review": len(session.pending_rows())
+ len(session.version_rows()),
@@ -860,6 +875,7 @@ def create_app(
record["year_hint"] = int(year) if year else None
if not (record.keys() - {"match", "photos"}):
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