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:
co-authored by
Claude Fable 5
parent
86d434a400
commit
626f255c01
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user