Split into copies: the symmetric partner of the merge veto

Three identical boxes in three photos are indistinguishable from one
box photographed three times, so extract's dedupe folds them into one
entry — correct for overlapping shots, wrong for a shelf holding three
editions of a favorite game. The catalog now offers "split into copies"
on multi-photo rows: the row explodes into one row per photo, each
dedupe_veto-flagged so no future resolve re-merges them, each keeping
its match but reopening its own edition slot (candidates preserved when
present). Resolve's provenance-follow skips split rows (their photo
sets are human-authored), the catalog renders surplus split copies as
their own lines with a "copy" chip, and diff's vetoed-duplicate logic
turns them into the extra collection entries they are.

Applied to the real data: Wiz-War is now three copies across IMG_4502/
4504/4528 — one claims the owned collection entry, two queue as new
second-copy adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 19:10:26 -04:00
co-authored by Claude Fable 5
parent 74fc4fe847
commit 851b34e369
8 changed files with 202 additions and 14 deletions
+5 -1
View File
@@ -603,7 +603,11 @@ 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:
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
photos_updated = True
skipped += 1
+36
View File
@@ -222,6 +222,42 @@ class ReviewSession:
def merged_rows(self) -> list[dict]:
return [r for r in self.rows if r["match_status"] == "merged"]
def split_row(self, row: dict) -> list[dict]:
"""The human says one multi-photo row is actually N physical copies
(one per photo). Replace it with per-photo rows, each veto-flagged
so no future dedupe re-merges them, each with its own edition slot
(different copies are usually different editions)."""
photos = [p for p in row["source_photos"].split(";") if p]
if len(photos) < 2:
raise ValueError("only a multi-photo row can be split into copies")
# same concurrent-rewrite discipline as every decision: re-adopt the
# (possibly orphaned) row into the current list before mutating
self.reload_if_changed()
if not self._adopt(row):
self._warn(
f"{row['title_raw']!r} disappeared from matches.csv while "
"you split — nothing changed"
)
return []
has_candidates = (row.get("version_candidates_json") or "[]") != "[]"
ix = next(i for i, r in enumerate(self.rows) if r is row)
copies = []
for photo in photos:
copy = dict(row)
copy["source_photos"] = photo
copy["dedupe_veto"] = "1"
copy["merged_into"] = ""
# each physical copy picks its OWN edition in the version pass
copy["version_id"] = ""
copy["version_name"] = ""
copy["version_status"] = (
"version_ambiguous" if has_candidates else "version_unknown"
)
copies.append(copy)
self.rows[ix : ix + 1] = copies
self._save(copies[0])
return copies
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."""
+21 -1
View File
@@ -18,7 +18,8 @@ function render() {
document.getElementById("catbody").innerHTML = rows.length
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td class="t">${esc(c.title_raw)}
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}
@@ -26,6 +27,12 @@ function render() {
<td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
<td>${c.can_split
? `<button class="split" data-title="${esc(c.title_raw)}"
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>`
: `<p class="empty">${CATALOG.length
? "No titles match that filter."
@@ -42,6 +49,19 @@ async function refresh() {
render();
}
document.getElementById("catbody").addEventListener("click", async e => {
const b = e.target.closest("button.split");
if (!b) return;
const n = b.dataset.photos.split(";").length;
if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
`Each picks its own edition afterward.`)) return;
const res = await apiPost("/api/split", {
title_raw: b.dataset.title,
source_photos: b.dataset.photos,
row_ix: Number(b.dataset.rowix),
});
if (res) refresh().catch(() => {});
});
document.getElementById("catsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
+54 -11
View File
@@ -142,6 +142,12 @@ class VetoBody(BaseModel):
row_ix: int | None = None
class SplitBody(BaseModel):
title_raw: str
source_photos: str
row_ix: int | None = None
class RunBody(BaseModel):
dry_run: bool = True # upload only; the safe direction is the default
limit: int | None = None
@@ -354,6 +360,32 @@ def create_app(
rows_by_title.setdefault(r["title_raw"], []).append(r)
title_seen: Counter[str] = Counter()
catalog = []
def catalog_line(entry, row) -> dict:
return {
"title_raw": entry.title_raw if entry else row["title_raw"],
"confidence": entry.confidence if entry else "",
"photos": (
list(entry.source_photos)
if entry
else [p for p in row["source_photos"].split(";") if p]
),
"status": row["match_status"] if row else "awaiting_resolve",
"type": row["type"] if row else "",
"bgg_id": row["bgg_id"] if row else "",
"bgg_name": row["bgg_name"] if row else "",
"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,
"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
),
"split_copy": bool(row and row.get("dedupe_veto")),
}
for entry in session.titles:
same_title = rows_by_title.get(entry.title_raw, [])
ix = title_seen[entry.title_raw]
@@ -361,19 +393,18 @@ def create_app(
# positional pairing, same rule as run_resolve: the ix-th entry
# of a title reports the ix-th row of that title
row = same_title[ix] if ix < len(same_title) else None
# a split row's photo set is narrower than its entry's — show
# the row's own photos for split copies
catalog.append(
{
"title_raw": entry.title_raw,
"confidence": entry.confidence,
"photos": list(entry.source_photos),
"status": row["match_status"] if row else "awaiting_resolve",
"type": row["type"] if row else "",
"bgg_id": row["bgg_id"] if row else "",
"bgg_name": row["bgg_name"] if row else "",
"version_name": row["version_name"] if row else "",
"merged_into": row.get("merged_into", "") if row else "",
}
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
if row
else catalog_line(entry, None)
)
# surplus rows beyond the entry count — split copies — are real
# physical games and get their own catalog lines
for title, rows_for_title in rows_by_title.items():
for row in rows_for_title[title_seen[title] :]:
catalog.append(catalog_line(None, row))
unresolved_count = sum(
1 for e in session.titles if e.title_raw not in resolved_titles
)
@@ -664,6 +695,18 @@ def create_app(
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
@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
return state()
@app.post("/api/veto-merge")
def api_veto_merge(body: VetoBody) -> dict:
with lock: