Durable curation: persisted splits + pre-resolve title edits, catalog A→Z

Wiz-War had no split button: can_split required a matches row, but fresh
extractions leave multi-photo titles rowless until resolve runs. Splits
are now a title-level decision persisted in data/title_splits.json,
honored by extract's dedupe and resolve's dedupe on every rebuild, with
the button on any multi-photo line — resolved or not.

Same mechanism carries human corrections: data/title_edits.json stores
fixed misreads and known cues (publisher/edition/year/language), applied
before dedupe on every titles.json rebuild, editable from a new inline
form on every catalog line. An edit drops the title's stale matches rows
so resolve re-queries with the corrected data.

The catalog page now sorts alphabetically (case-insensitive; split
copies stay adjacent) instead of extraction order.

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-02 19:47:08 -04:00
co-authored by Claude Fable 5
parent 24a7bcb9e0
commit a7f0cfee05
12 changed files with 641 additions and 28 deletions
+12
View File
@@ -66,6 +66,18 @@ class Config:
def games_path(self) -> Path:
return self.data_dir / "games.json"
@property
def title_edits_path(self) -> Path:
# human corrections to extracted reads (misspellings, known cues) —
# replayed on every titles.json rebuild
return self.data_dir / "title_edits.json"
@property
def title_splits_path(self) -> Path:
# titles the human declared to be MULTIPLE physical copies: extract
# and resolve dedupe must never cross-photo-merge them again
return self.data_dir / "title_splits.json"
@property
def dismissed_path(self) -> Path:
return self.data_dir / "unidentified_dismissed.json"
+124 -5
View File
@@ -240,11 +240,93 @@ def _merge(a: dict, b: dict) -> dict:
return merged
def dedupe_entries(entries: list[dict]) -> list[dict]:
"""Collapse same-normalized-title sightings unless their cues conflict."""
# the fields a human correction may override on an extracted entry
EDIT_FIELDS = (
"title_raw",
"publisher_hint",
"edition_hint",
"year_hint",
"language_hint",
"art_notes",
)
def load_title_edits(path: Path) -> list[dict]:
"""Human corrections to raw reads (fixed misspellings, cues the owner
knows offhand). Each record: {"match": <title as displayed when the fix
was made>, "photos": [...] to target one copy (optional), <EDIT_FIELDS
to override>}. Applied on every rebuild, before dedupe."""
if not path.exists():
return []
return json.loads(path.read_text())
def record_title_edit(path: Path, record: dict) -> None:
existing = load_title_edits(path)
atomic_write_text(
path, json.dumps([*existing, record], indent=2, ensure_ascii=False) + "\n"
)
def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
"""Apply stored corrections in order. Matching is by the title an entry
CURRENTLY carries, so a later edit made against an earlier edit's result
chains naturally; each record applies at most once per entry."""
if not edits:
return entries
for entry in entries:
applied: set[int] = set()
while True:
norm = normalize_title(entry["title_raw"])
photos = set(entry.get("source_photos") or [])
hit = next(
(
i
for i, e in enumerate(edits)
if i not in applied
and normalize_title(e["match"]) == norm
and (not e.get("photos") or set(e["photos"]) & photos)
),
None,
)
if hit is None:
break
applied.add(hit)
for key in EDIT_FIELDS:
if key in edits[hit]:
entry[key] = edits[hit][key]
return entries
def load_title_splits(path: Path) -> set[str]:
"""Normalized titles the human split into per-photo copies — a durable
review decision that must survive extract rebuilds and resolve --force."""
if not path.exists():
return set()
return {normalize_title(t) for t in json.loads(path.read_text())}
def record_title_split(path: Path, title: str) -> None:
existing = json.loads(path.read_text()) if path.exists() else []
if normalize_title(title) in {normalize_title(t) for t in existing}:
return
atomic_write_text(
path, json.dumps([*existing, title], indent=2, ensure_ascii=False) + "\n"
)
def dedupe_entries(
entries: list[dict], split_titles: set[str] = frozenset()
) -> list[dict]:
"""Collapse same-normalized-title sightings unless their cues conflict —
or unless the human declared the title split (several physical copies):
those stay one entry per photo."""
result: list[dict] = []
for entry in entries:
entry = {**entry, "title_normalized": normalize_title(entry["title_raw"])}
if entry["title_normalized"] in split_titles:
result.append(entry)
continue
for existing in result:
if existing["title_normalized"] == entry[
"title_normalized"
@@ -257,7 +339,11 @@ def dedupe_entries(entries: list[dict]) -> list[dict]:
def rebuild_artifacts(
raw_dir: Path, titles_path: Path, unidentified_path: Path
raw_dir: Path,
titles_path: Path,
unidentified_path: Path,
split_titles: set[str] = frozenset(),
edits: list[dict] | None = None,
) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw
cache. A raw file is either an object with titles/unidentified or a bare
@@ -279,7 +365,7 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"):
unidentified[photo] = data["unidentified"]
deduped = dedupe_entries(entries)
deduped = dedupe_entries(apply_title_edits(entries, edits or []), split_titles)
titles_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
@@ -290,6 +376,35 @@ def rebuild_artifacts(
return deduped, unidentified
def replay_titles(cfg: Config) -> None:
"""Re-derive titles.json after a stored split or edit changed the rules.
Prefers the raw caches (per-photo cue fidelity); without them (trimmed
or hand-built data) it replays the current titles.json entries through
the same edit + dedupe path, exploding multi-photo entries first so
photo-level splits and targeted edits can take hold."""
splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path)
raw_dir = cfg.extract_raw_dir
if raw_dir.is_dir() and any(raw_dir.glob("*.json")):
rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits
)
return
if not cfg.titles_path.exists():
return
exploded: list[dict] = []
for entry in json.loads(cfg.titles_path.read_text()):
photos = entry.get("source_photos") or []
if len(photos) > 1:
exploded.extend({**entry, "source_photos": [p]} for p in photos)
else:
exploded.append(dict(entry))
deduped = dedupe_entries(apply_title_edits(exploded, edits), splits)
atomic_write_text(
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
)
def run_extract(
cfg: Config,
*,
@@ -370,7 +485,11 @@ def run_extract(
typer.echo(f" {photo.name}: {len(result['titles'])} title(s){note}")
deduped, unidentified = rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path
raw_dir,
cfg.titles_path,
cfg.unidentified_path,
load_title_splits(cfg.title_splits_path),
load_title_edits(cfg.title_edits_path),
)
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
if failed:
+11 -9
View File
@@ -22,7 +22,7 @@ from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for
from bggpipe.config import Config
from bggpipe.extract import cues_conflict
from bggpipe.extract import cues_conflict, load_title_splits
from bggpipe.fsio import atomic_write_csv
from bggpipe.models import (
RECOGNIZED_MATCH_STATUSES,
@@ -406,9 +406,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
# type=rpgitem (same API, same token). A hit becomes a LOCAL
# library citizen: identified and enriched, never uploaded (diff
# routes rpgitem rows to local_only).
cands = _plausible_candidates(
client, entry, entry.title_raw, types="rpgitem"
)
cands = _plausible_candidates(client, entry, entry.title_raw, types="rpgitem")
if not cands:
for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates(
@@ -430,7 +428,11 @@ class MergeEvent:
bgg_id: str
def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEvent]:
def dedupe_matches(
rows: list[dict],
titles: list[TitleEntry],
split_titles: set[str] = frozenset(),
) -> list[MergeEvent]:
"""Post-resolve dedupe: rows resolving to the same (bgg_id, version_id —
or both version-unknown) are the same physical game seen twice (a typo
read, a partial spine) UNLESS their extraction cues conflict, which
@@ -464,6 +466,8 @@ def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEven
# re-running resolve must never overturn that (spec: re-runs
# lose no work, least of all review decisions)
continue
if normalize_title(row["title_raw"]) in split_titles:
continue # human-split title: per-photo rows stay separate
key = (
row["bgg_id"],
row["version_id"] if is_confident_version(row) else "",
@@ -603,9 +607,7 @@ def run_resolve(
row_dict = paired_by_id.get(id(entry))
if row_dict is not None:
photos = ";".join(entry.source_photos)
if row_dict["source_photos"] != photos and not row_dict.get(
"dedupe_veto"
):
if row_dict["source_photos"] != photos and not row_dict.get("dedupe_veto"):
# provenance follows the entry — except on split/vetoed rows,
# whose per-copy photo sets are human-authored
row_dict["source_photos"] = photos
@@ -639,7 +641,7 @@ def run_resolve(
typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}")
all_rows = existing_rows + [row.to_csv() for row in new_rows]
merges = dedupe_matches(all_rows, entries)
merges = dedupe_matches(all_rows, entries, load_title_splits(cfg.title_splits_path))
if new_rows or photos_updated or merges:
write_matches(cfg.matches_path, all_rows) # atomic full rewrite
if merges:
+21
View File
@@ -258,6 +258,27 @@ class ReviewSession:
self._save(copies[0])
return copies
def drop_rows(self, title_raw: str, photos: list[str] | None = None) -> int:
"""An edit invalidated these rows — the BGG match was made against
the uncorrected read. Remove them so resolve re-queries with the
fix; `photos` narrows the cull to one copy of a split title."""
self.reload_if_changed()
def stale(row: dict) -> bool:
if row["title_raw"] != title_raw:
return False
if photos is None:
return True
row_photos = {p for p in row["source_photos"].split(";") if p}
return bool(row_photos & set(photos))
keep = [r for r in self.rows if not stale(r)]
dropped = len(self.rows) - len(keep)
if dropped:
self.rows = keep
self._save()
return dropped
def veto_merge(self, row: dict) -> None:
"""The human says these are NOT the same physical game: restore the
row as a distinct, human-confirmed match."""
+15
View File
@@ -310,6 +310,21 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
text-underline-offset: 2px;
}
.catalog a:hover { color: var(--accent-ink); }
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
.editform label {
display: flex; flex-direction: column; gap: .15rem;
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
color: var(--ink-soft);
}
.editform input {
font: inherit; font-size: .85rem; color: var(--ink);
border: 1px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; background: var(--board);
}
.editform input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.editactions { display: flex; gap: .5rem; }
.edithint { font-size: .78rem; color: var(--ink-soft); align-self: center; }
.chip {
font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
+64 -7
View File
@@ -7,12 +7,37 @@
<script>
"use strict";
let CATALOG = [];
let EDITING = null; // lineKey of the row whose editor is open
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
function editorRow(c) {
const cue = c.cues || {};
return `
<tr class="editrow"><td colspan="5">
<form class="editform" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}">
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
<label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
<label>Language <input name="language" value="${esc(cue.language || "")}"></label>
<span class="editactions">
<button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button>
</span>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form>
</td></tr>`;
}
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const sorted = [...CATALOG].sort((a, b) =>
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
const rows = q
? CATALOG.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: CATALOG;
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: sorted;
document.getElementById("catcount").innerHTML =
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
document.getElementById("catbody").innerHTML = rows.length
@@ -27,13 +52,17 @@ function render() {
<td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
<td>${c.can_split
<td class="rowactions">${c.can_split
? `<button class="split" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix}"
data-photos="${esc(c.photos.join(";"))}"
data-rowix="${c.row_ix ?? ""}"
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}</td>
</tr>`).join("") + `</table></div>`
: ""}
<button class="edit" data-key="${esc(lineKey(c))}"
title="fix a misread title or add cues you already know">edit</button>
</td>
</tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
: `<p class="empty">${CATALOG.length
? "No titles match that filter."
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
@@ -42,6 +71,7 @@ function render() {
let LAST = null;
async function refresh() {
const state = await fetchJSON("/api/state");
if (EDITING) return; // never repaint under an open editor
const payload = JSON.stringify(state.catalog);
if (payload === LAST) return;
LAST = payload;
@@ -50,6 +80,14 @@ 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 edit = e.target.closest("button.edit");
if (edit) {
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
render();
return;
}
const b = e.target.closest("button.split");
if (!b) return;
const n = b.dataset.photos.split(";").length;
@@ -58,10 +96,29 @@ document.getElementById("catbody").addEventListener("click", async e => {
const res = await apiPost("/api/split", {
title_raw: b.dataset.title,
source_photos: b.dataset.photos,
row_ix: Number(b.dataset.rowix),
row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
});
if (res) refresh().catch(() => {});
});
document.getElementById("catbody").addEventListener("submit", async e => {
const f = e.target.closest("form.editform");
if (!f) return;
e.preventDefault();
const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
const cue = orig.cues || {};
const v = name => f.elements[name].value;
const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
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; }
const res = await apiPost("/api/edit-title", body);
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
});
document.getElementById("catsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
+118 -6
View File
@@ -38,6 +38,11 @@ from rich.console import Console
from bggpipe.bgg_client import BGGClient, cached_paths
from bggpipe.config import DEFAULT_REVIEW_PORT, Config
from bggpipe.extract import (
record_title_edit,
record_title_split,
replay_titles,
)
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
from bggpipe.jobs import JobRunner
from bggpipe.models import (
@@ -148,6 +153,19 @@ class SplitBody(BaseModel):
row_ix: int | None = None
class EditBody(BaseModel):
"""A human correction to an extracted read. None = leave that field
alone; empty string = clear it."""
title_raw: str
source_photos: str = ""
title_new: str | None = None
publisher: str | None = None
edition: str | None = None
year: str | None = None
language: str | None = None
class RunBody(BaseModel):
dry_run: bool = True # upload only; the safe direction is the default
limit: int | None = None
@@ -377,12 +395,21 @@ def create_app(
"version_name": row["version_name"] if row else "",
"merged_into": row.get("merged_into", "") if row else "",
"row_ix": _ix_of(session.rows, row) if row else None,
"cues": {
"publisher": entry.publisher_hint if entry else "",
"edition": entry.edition_hint if entry else "",
"year": entry.year_hint if entry else None,
"language": entry.language_hint if entry else "",
},
"can_split": bool(
row
and row["match_status"] in RECOGNIZED_MATCH_STATUSES
and not row.get("dedupe_veto")
and len(row["source_photos"].split(";")) > 1
),
)
# no matches row yet (title still awaiting resolve): the
# 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")),
}
@@ -695,16 +722,101 @@ def create_app(
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
def _find_entry(title_raw: str, source_photos: str):
photos = [p for p in source_photos.split(";") if p]
return next(
(
e
for e in session.titles
if e.title_raw == title_raw
and (not photos or list(e.source_photos) == photos)
),
None,
)
@app.post("/api/split")
def api_split(body: SplitBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
row = find_row(body.title_raw, body.source_photos, body.row_ix)
try:
session.split_row(row)
except ValueError as err:
raise HTTPException(400, str(err)) from err
freshen()
row = session.find_row(body.title_raw, body.source_photos, body.row_ix)
if row is not None:
try:
session.split_row(row)
except ValueError as err:
raise HTTPException(400, str(err)) from err
else:
# no matches row yet — the title is still awaiting resolve;
# splitting is purely a titles.json (extraction) decision
entry = _find_entry(body.title_raw, body.source_photos)
if entry is None:
raise HTTPException(
404, "title not found — titles.json changed underneath?"
)
if len(entry.source_photos) < 2:
raise HTTPException(
400, "only a multi-photo title can be split into copies"
)
# persist the decision so every future extract rebuild and
# resolve dedupe keeps the copies apart, then re-derive
# titles.json so each copy carries its own photo's cues
record_title_split(cfg.title_splits_path, body.title_raw)
replay_titles(cfg)
return state()
@app.post("/api/edit-title")
def api_edit_title(body: EditBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
entry = _find_entry(body.title_raw, body.source_photos)
if entry is None:
raise HTTPException(
404, "title not found — titles.json changed underneath?"
)
record: dict = {"match": body.title_raw}
photos = [p for p in body.source_photos.split(";") if p]
same_title = [e for e in session.titles if e.title_raw == body.title_raw]
if photos and len(same_title) > 1:
# several copies/editions share this title: the fix targets
# only the copy the human was looking at
record["photos"] = photos
if body.title_new is not None:
corrected = body.title_new.strip()
if not corrected:
raise HTTPException(400, "corrected title cannot be empty")
if corrected != body.title_raw:
record["title_raw"] = corrected
for key, value in (
("publisher_hint", body.publisher),
("edition_hint", body.edition),
("language_hint", body.language),
):
if value is not None:
record[key] = value.strip()
if body.year is not None:
year = body.year.strip()
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 any(
key in record
for key in (
"title_raw",
"publisher_hint",
"edition_hint",
"language_hint",
"year_hint",
)
):
raise HTTPException(400, "nothing to change")
record_title_edit(cfg.title_edits_path, record)
replay_titles(cfg)
# rows matched against the uncorrected read are stale: drop
# them so the next resolve re-queries with the fix
session.drop_rows(body.title_raw, photos if "photos" in record else None)
return state()
@app.post("/api/veto-merge")