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
co-authored by Claude Fable 5
parent 86d434a400
commit 626f255c01
10 changed files with 187 additions and 17 deletions
+6
View File
@@ -66,6 +66,12 @@ class Config:
def games_path(self) -> Path:
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
def title_edits_path(self) -> Path:
# 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
def load_title_splits(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive
extract rebuilds and resolve --force. Each stored record is
{"title": ..., "photos": [...]} scoping the split to the sightings that
were on the split line (a bare string is legacy: unscoped). Returns
def _scoped_records(path: Path) -> list[dict]:
"""Parse a store of photo-scoped title decisions. Each stored record is
{"title": ..., "photos": [...]} scoping the decision to the sightings
that were on the line (a bare string is legacy: unscoped). Returns
[{"norm": <normalized title>, "photos": set | None}]."""
records = []
for item in _load_store(path):
@@ -334,6 +333,20 @@ def load_title_splits(path: Path) -> list[dict]:
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:
"""Does a split decision cover this (normalized title, photo set)?
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:
if is_split(normalize_title(title), photos or [], load_title_splits(path)):
return
def _record_scoped(path: Path, title: str, photos: list[str] | None) -> None:
if is_split(normalize_title(title), photos or [], _scoped_records(path)):
return # an existing record already covers this line
existing = _load_store(path)
record = {"title": title, "photos": sorted(photos) if photos else None}
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]:
"""Collapse same-normalized-title sightings unless their cues conflict —
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
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(
raw_dir: Path,
titles_path: Path,
unidentified_path: Path,
splits: list[dict] | None = None,
edits: list[dict] | None = None,
removals: 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
@@ -410,7 +449,10 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json")
if data.get("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)
atomic_write_text(
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."""
splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path)
removals = load_title_removals(cfg.title_removals_path)
raw_dir = cfg.extract_raw_dir
raw_photos = (
{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
if raw_photos and raw_photos >= known_photos:
rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits, removals
)
return
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)
else:
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(
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
)
@@ -547,6 +592,7 @@ def run_extract(
cfg.unidentified_path,
load_title_splits(cfg.title_splits_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}.")
if failed:
+5 -2
View File
@@ -270,13 +270,16 @@ class ReviewSession:
title_raw: str,
photos: list[str] | None = None,
new_title: str | None = None,
even_vetoed: bool = False,
) -> 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.
Rows carrying a human veto (dedupe_veto) are never dropped — the
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()
norm = normalize_title(title_raw)
keep: list[dict] = []
@@ -288,7 +291,7 @@ class ReviewSession:
)
if not targeted:
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:
row["title_raw"] = new_title
renamed += 1
+14
View File
@@ -25,6 +25,7 @@ function editorRow(c) {
<span class="editactions">
<button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button>
<button type="button" class="removetitle danger">remove from catalog</button>
</span>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form>
@@ -82,6 +83,19 @@ 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 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");
if (edit) {
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
+38
View File
@@ -42,6 +42,7 @@ from bggpipe.extract import (
is_split,
load_title_splits,
record_title_edit,
record_title_removal,
record_title_split,
replay_titles,
)
@@ -171,6 +172,11 @@ class EditBody(BaseModel):
language: str | None = None
class RemoveBody(BaseModel):
title_raw: str
source_photos: str = ""
class RunBody(BaseModel):
dry_run: bool = True # upload only; the safe direction is the default
limit: int | None = None
@@ -874,6 +880,38 @@ def create_app(
replay_titles(cfg)
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")
def api_veto_merge(body: VetoBody) -> dict:
with lock: