Remove-from-catalog: a third durable curation store

Titles that aren't games (misread box art, out-of-scope items) can now
be removed outright: a danger button in the catalog's edit panel posts
/api/remove-title, which drops the line's matches rows (veto'd ones
too — removal is the human explicitly discarding the line), records the
decision photo-scoped in data/title_removals.json, and replays
titles.json. Every rebuild filters removed sightings after edits and
before dedupe, so re-extraction cannot resurrect them; undo by deleting
the record from the store. The three stores now share one scoped-record
parser and recorder.

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 20:15:46 -04:00
parent 86d434a400
commit 626f255c01
10 changed files with 187 additions and 17 deletions
+2 -2
View File
@@ -47,11 +47,11 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan). - Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry. - Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance. - Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe) and `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place). - **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), and `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope). - **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift. - Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
## Git ## Git
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here. - Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here.
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`. - Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`.
+6
View File
@@ -0,0 +1,6 @@
[
{
"match": "Hangermuger 4 800",
"title_raw": "Huggermugger"
}
]
+2 -2
View File
@@ -783,7 +783,7 @@
"title_normalized": "dixit" "title_normalized": "dixit"
}, },
{ {
"title_raw": "Hangermuger 4 800", "title_raw": "Huggermugger",
"confidence": "low", "confidence": "low",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
@@ -793,7 +793,7 @@
"source_photos": [ "source_photos": [
"IMG_4514.jpeg" "IMG_4514.jpeg"
], ],
"title_normalized": "hangermuger 4 800" "title_normalized": "huggermugger"
}, },
{ {
"title_raw": "The Ain't It Cool Trivia Game", "title_raw": "The Ain't It Cool Trivia Game",
+6
View File
@@ -66,6 +66,12 @@ class Config:
def games_path(self) -> Path: def games_path(self) -> Path:
return self.data_dir / "games.json" return self.data_dir / "games.json"
@property
def title_removals_path(self) -> Path:
# titles the human removed from the catalog (not a game, misread) —
# filtered out of every titles.json rebuild
return self.data_dir / "title_removals.json"
@property @property
def title_edits_path(self) -> Path: def title_edits_path(self) -> Path:
# human corrections to extracted reads (misspellings, known cues) — # human corrections to extracted reads (misspellings, known cues) —
+57 -11
View File
@@ -313,11 +313,10 @@ def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
return entries return entries
def load_title_splits(path: Path) -> list[dict]: def _scoped_records(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive """Parse a store of photo-scoped title decisions. Each stored record is
extract rebuilds and resolve --force. Each stored record is {"title": ..., "photos": [...]} scoping the decision to the sightings
{"title": ..., "photos": [...]} scoping the split to the sightings that that were on the line (a bare string is legacy: unscoped). Returns
were on the split line (a bare string is legacy: unscoped). Returns
[{"norm": <normalized title>, "photos": set | None}].""" [{"norm": <normalized title>, "photos": set | None}]."""
records = [] records = []
for item in _load_store(path): for item in _load_store(path):
@@ -334,6 +333,20 @@ def load_title_splits(path: Path) -> list[dict]:
return records return records
def load_title_splits(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive
extract rebuilds and resolve --force."""
return _scoped_records(path)
def load_title_removals(path: Path) -> list[dict]:
"""Titles the human removed from the catalog (not a game, a misread of
box art, out of scope) — their sightings are filtered out of every
rebuild, so re-extraction cannot resurrect them. Undo by deleting the
record from the store file."""
return _scoped_records(path)
def is_split(norm: str, photos, splits: list[dict]) -> bool: def is_split(norm: str, photos, splits: list[dict]) -> bool:
"""Does a split decision cover this (normalized title, photo set)? """Does a split decision cover this (normalized title, photo set)?
Photo-scoped records only bind sightings from the photos that were on Photo-scoped records only bind sightings from the photos that were on
@@ -345,9 +358,9 @@ def is_split(norm: str, photos, splits: list[dict]) -> bool:
) )
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None: def _record_scoped(path: Path, title: str, photos: list[str] | None) -> None:
if is_split(normalize_title(title), photos or [], load_title_splits(path)): if is_split(normalize_title(title), photos or [], _scoped_records(path)):
return return # an existing record already covers this line
existing = _load_store(path) existing = _load_store(path)
record = {"title": title, "photos": sorted(photos) if photos else None} record = {"title": title, "photos": sorted(photos) if photos else None}
atomic_write_text( atomic_write_text(
@@ -355,6 +368,16 @@ def record_title_split(path: Path, title: str, photos: list[str] | None = None)
) )
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
_record_scoped(path, title, photos)
def record_title_removal(
path: Path, title: str, photos: list[str] | None = None
) -> None:
_record_scoped(path, title, photos)
def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]: def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]:
"""Collapse same-normalized-title sightings unless their cues conflict — """Collapse same-normalized-title sightings unless their cues conflict —
or unless the human declared them split (several physical copies): or unless the human declared them split (several physical copies):
@@ -383,12 +406,28 @@ def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> lis
return result return result
def apply_title_removals(entries: list[dict], removals: list[dict]) -> list[dict]:
"""Filter out sightings the human removed. Runs AFTER edits (records
key on the title as displayed when removal was clicked) and before
dedupe (so a removed sighting can't be absorbed into a survivor)."""
if not removals:
return entries
return [
e
for e in entries
if not is_split(
normalize_title(e["title_raw"]), e.get("source_photos"), removals
)
]
def rebuild_artifacts( def rebuild_artifacts(
raw_dir: Path, raw_dir: Path,
titles_path: Path, titles_path: Path,
unidentified_path: Path, unidentified_path: Path,
splits: list[dict] | None = None, splits: list[dict] | None = None,
edits: list[dict] | None = None, edits: list[dict] | None = None,
removals: list[dict] | None = None,
) -> tuple[list[dict], dict[str, list[dict]]]: ) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw """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 cache. A raw file is either an object with titles/unidentified or a bare
@@ -410,7 +449,10 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json") photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"): if data.get("unidentified"):
unidentified[photo] = data["unidentified"] unidentified[photo] = data["unidentified"]
deduped = dedupe_entries(apply_title_edits(entries, edits or []), splits) entries = apply_title_removals(
apply_title_edits(entries, edits or []), removals or []
)
deduped = dedupe_entries(entries, splits)
titles_path.parent.mkdir(parents=True, exist_ok=True) titles_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text( atomic_write_text(
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n" titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
@@ -429,6 +471,7 @@ def replay_titles(cfg: Config) -> None:
photo-level splits and targeted edits can take hold.""" photo-level splits and targeted edits can take hold."""
splits = load_title_splits(cfg.title_splits_path) splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path) edits = load_title_edits(cfg.title_edits_path)
removals = load_title_removals(cfg.title_removals_path)
raw_dir = cfg.extract_raw_dir raw_dir = cfg.extract_raw_dir
raw_photos = ( raw_photos = (
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")} {f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
@@ -444,7 +487,7 @@ def replay_titles(cfg: Config) -> None:
# them would silently truncate the committed catalog # them would silently truncate the committed catalog
if raw_photos and raw_photos >= known_photos: if raw_photos and raw_photos >= known_photos:
rebuild_artifacts( rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits, removals
) )
return return
if not cfg.titles_path.exists(): if not cfg.titles_path.exists():
@@ -456,7 +499,9 @@ def replay_titles(cfg: Config) -> None:
exploded.extend({**entry, "source_photos": [p]} for p in photos) exploded.extend({**entry, "source_photos": [p]} for p in photos)
else: else:
exploded.append(dict(entry)) exploded.append(dict(entry))
deduped = dedupe_entries(apply_title_edits(exploded, edits), splits) deduped = dedupe_entries(
apply_title_removals(apply_title_edits(exploded, edits), removals), splits
)
atomic_write_text( atomic_write_text(
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n" cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
) )
@@ -547,6 +592,7 @@ def run_extract(
cfg.unidentified_path, cfg.unidentified_path,
load_title_splits(cfg.title_splits_path), load_title_splits(cfg.title_splits_path),
load_title_edits(cfg.title_edits_path), load_title_edits(cfg.title_edits_path),
load_title_removals(cfg.title_removals_path),
) )
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.") typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
if failed: if failed:
+5 -2
View File
@@ -270,13 +270,16 @@ class ReviewSession:
title_raw: str, title_raw: str,
photos: list[str] | None = None, photos: list[str] | None = None,
new_title: str | None = None, new_title: str | None = None,
even_vetoed: bool = False,
) -> int: ) -> int:
"""An edit invalidated these rows — the BGG match was made against """An edit invalidated these rows — the BGG match was made against
the uncorrected read. Remove them so resolve re-queries with the the uncorrected read. Remove them so resolve re-queries with the
fix; `photos` narrows the cull to one copy of a split title. fix; `photos` narrows the cull to one copy of a split title.
Rows carrying a human veto (dedupe_veto) are never dropped — the Rows carrying a human veto (dedupe_veto) are never dropped — the
match itself was human-vetted, and dropping would erase the veto; match itself was human-vetted, and dropping would erase the veto;
a rename updates their title in place so they follow the entry.""" a rename updates their title in place so they follow the entry.
`even_vetoed` is for title REMOVAL, where the human is explicitly
discarding the line, vetted or not."""
self.reload_if_changed() self.reload_if_changed()
norm = normalize_title(title_raw) norm = normalize_title(title_raw)
keep: list[dict] = [] keep: list[dict] = []
@@ -288,7 +291,7 @@ class ReviewSession:
) )
if not targeted: if not targeted:
keep.append(row) keep.append(row)
elif row.get("dedupe_veto"): elif row.get("dedupe_veto") and not even_vetoed:
if new_title and row["title_raw"] != new_title: if new_title and row["title_raw"] != new_title:
row["title_raw"] = new_title row["title_raw"] = new_title
renamed += 1 renamed += 1
+14
View File
@@ -25,6 +25,7 @@ function editorRow(c) {
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">save</button> <button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button> <button type="button" class="canceledit">cancel</button>
<button type="button" class="removetitle danger">remove from catalog</button>
</span> </span>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span> <span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form> </form>
@@ -82,6 +83,19 @@ 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 rm = e.target.closest("button.removetitle");
if (rm) {
const f = rm.closest("form.editform");
if (!confirm(`Remove "${f.dataset.title}" from the catalog? ` +
`Re-running extract won't bring it back — the removal is saved ` +
`in data/title_removals.json (delete its record there to undo).`)) return;
const res = await apiPost("/api/remove-title", {
title_raw: f.dataset.title,
source_photos: f.dataset.photos,
});
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
return;
}
const edit = e.target.closest("button.edit"); const edit = e.target.closest("button.edit");
if (edit) { if (edit) {
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key; EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
+38
View File
@@ -42,6 +42,7 @@ from bggpipe.extract import (
is_split, is_split,
load_title_splits, load_title_splits,
record_title_edit, record_title_edit,
record_title_removal,
record_title_split, record_title_split,
replay_titles, replay_titles,
) )
@@ -171,6 +172,11 @@ class EditBody(BaseModel):
language: str | None = None language: str | None = None
class RemoveBody(BaseModel):
title_raw: str
source_photos: str = ""
class RunBody(BaseModel): class RunBody(BaseModel):
dry_run: bool = True # upload only; the safe direction is the default dry_run: bool = True # upload only; the safe direction is the default
limit: int | None = None limit: int | None = None
@@ -874,6 +880,38 @@ def create_app(
replay_titles(cfg) replay_titles(cfg)
return state() return state()
@app.post("/api/remove-title")
def api_remove_title(body: RemoveBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
photos = [p for p in body.source_photos.split(";") if p]
entry = _find_entry(body.title_raw, body.source_photos)
norm = normalize_title(body.title_raw)
has_rows = any(
normalize_title(r["title_raw"]) == norm
and (
not photos
or {p for p in r["source_photos"].split(";") if p} & set(photos)
)
for r in session.rows
)
# surplus rows without an entry (a split copy whose entry is
# gone) are removable too — rows alone are enough to act on
if entry is None and not has_rows:
raise HTTPException(
404, "title not found — titles.json changed underneath?"
)
# removal is the human explicitly discarding the line: veto'd
# rows go too, then the durable record, then the replay
session.drop_rows(body.title_raw, photos or None, even_vetoed=True)
record_title_removal(
cfg.title_removals_path, body.title_raw, photos or None
)
replay_titles(cfg)
return state()
@app.post("/api/veto-merge") @app.post("/api/veto-merge")
def api_veto_merge(body: VetoBody) -> dict: def api_veto_merge(body: VetoBody) -> dict:
with lock: with lock:
+17
View File
@@ -24,6 +24,7 @@ from bggpipe.extract import (
replay_titles, replay_titles,
run_extract, run_extract,
) )
from bggpipe.normalize import normalize_title
def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)): def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)):
@@ -234,6 +235,22 @@ def test_edits_chain_and_target_photos():
assert entries[1] == _entry("Wiz-War", "b.jpg") # untargeted copy untouched assert entries[1] == _entry("Wiz-War", "b.jpg") # untargeted copy untouched
def test_removals_filter_rebuilds_photo_scoped():
from bggpipe.extract import apply_title_removals
entries = [
_entry("Not A Game", "a.jpg"),
_entry("Not A Game", "b.jpg"),
_entry("Catan", "a.jpg"),
]
removals = [{"norm": normalize_title("Not A Game"), "photos": {"a.jpg"}}]
kept = apply_title_removals(entries, removals)
assert [(e["title_raw"], e["source_photos"]) for e in kept] == [
("Not A Game", ["b.jpg"]), # other sighting untouched
("Catan", ["a.jpg"]),
]
def test_stores_roundtrip_and_replay_from_raw(tmp_path): def test_stores_roundtrip_and_replay_from_raw(tmp_path):
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos") cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
raw = cfg.extract_raw_dir raw = cfg.extract_raw_dir
+40
View File
@@ -726,3 +726,43 @@ def test_single_photo_rowless_entry_is_not_splittable(tmp_path):
if c["title_raw"] == "Fresh Off The Shelf" if c["title_raw"] == "Fresh Off The Shelf"
) )
assert line["can_split"] is False assert line["can_split"] is False
def test_remove_title_is_durable_and_drops_vetoed_rows(tmp_path):
cfg = make_cfg(tmp_path)
rows = read_matches(cfg.matches_path)
rows.append(
_row(
title_raw="Citadels",
match_status="approved",
bgg_id="478",
source_photos="shelf.jpg",
dedupe_veto="1",
)
)
write_matches(cfg.matches_path, rows)
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
res = web.post(
"/api/remove-title",
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
)
assert res.status_code == 200
assert "Citadels" not in [c["title_raw"] for c in res.json()["catalog"]]
# removal is explicit: the veto'd row goes too (unlike edits)
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
(record,) = json.loads(cfg.title_removals_path.read_text())
assert record == {"title": "Citadels", "photos": ["shelf.jpg"]}
# durable: a fresh replay keeps it gone; the sibling title is untouched
from bggpipe.extract import replay_titles
replay_titles(cfg)
titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
assert titles == {"Fresh Off The Shelf"}
# unknown titles still 404
assert (
web.post(
"/api/remove-title",
json={"title_raw": "No Such Game", "source_photos": "x.jpg"},
).status_code
== 404
)