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
parent 74fc4fe847
commit 851b34e369
8 changed files with 202 additions and 14 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,versio
Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,19362101,First edition,version_auto,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 19362101, ""name"": ""First edition"", ""year"": 2016, ""publishers"": [""Cyanide & Happiness""], ""languages"": [""English""], ""score"": 3}]",IMG_4501.jpeg;IMG_4502.jpeg,,
EXPLODING KITTENS,172225,Exploding Kittens,2015,boardgame,auto,,,version_unknown,"[{""bgg_id"": 172225, ""name"": ""Exploding Kittens"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4505.jpeg,,
Hebarceos,195314,Herbaceous,2017,boardgame,merged,,,version_unknown,[],[],IMG_4502.jpeg,Herbaceous,
Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4502.jpeg;IMG_4504.jpeg;IMG_4528.jpeg,,
Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4502.jpeg,,1
Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4504.jpeg,,1
Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4528.jpeg,,1
SCRAWL,218866,Scrawl,2017,boardgame,auto,,,version_unknown,"[{""bgg_id"": 218866, ""name"": ""Scrawl"", ""year"": 2017, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529, ""name"": ""Flat Top"", ""year"": 1977, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
1 title_raw bgg_id bgg_name year type match_status version_id version_name version_status candidates_json version_candidates_json source_photos merged_into dedupe_veto
19 Joking Hazard 193621 Joking Hazard 2016 boardgame auto 19362101 First edition version_auto [{"bgg_id": 193621, "name": "Joking Hazard", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 19362101, "name": "First edition", "year": 2016, "publishers": ["Cyanide & Happiness"], "languages": ["English"], "score": 3}] IMG_4501.jpeg;IMG_4502.jpeg
20 EXPLODING KITTENS 172225 Exploding Kittens 2015 boardgame auto version_unknown [{"bgg_id": 172225, "name": "Exploding Kittens", "year": 2015, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg;IMG_4505.jpeg
21 Hebarceos 195314 Herbaceous 2017 boardgame merged version_unknown [] [] IMG_4502.jpeg Herbaceous
22 Wiz-War 104710 Wiz-War 2012 boardgame approved version_unknown [{"bgg_id": 1218, "name": "Wiz-War", "year": 1983, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 4000, "rank": 2500}, {"bgg_id": 104710, "name": "Wiz-War", "year": 2012, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 8000, "rank": 1200}] [] IMG_4502.jpeg;IMG_4504.jpeg;IMG_4528.jpeg IMG_4502.jpeg 1
23 Wiz-War 104710 Wiz-War 2012 boardgame approved version_unknown [{"bgg_id": 1218, "name": "Wiz-War", "year": 1983, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 4000, "rank": 2500}, {"bgg_id": 104710, "name": "Wiz-War", "year": 2012, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 8000, "rank": 1200}] [] IMG_4504.jpeg 1
24 Wiz-War 104710 Wiz-War 2012 boardgame approved version_unknown [{"bgg_id": 1218, "name": "Wiz-War", "year": 1983, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 4000, "rank": 2500}, {"bgg_id": 104710, "name": "Wiz-War", "year": 2012, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 8000, "rank": 1200}] [] IMG_4528.jpeg 1
25 SCRAWL 218866 Scrawl 2017 boardgame auto version_unknown [{"bgg_id": 218866, "name": "Scrawl", "year": 2017, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
26 FLAT TOP 2529 Flat Top 1977 boardgame auto version_unknown [{"bgg_id": 2529, "name": "Flat Top", "year": 1977, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
27 Diplomacy 483 Diplomacy 1959 boardgame auto version_unknown [{"bgg_id": 483, "name": "Diplomacy", "year": 1959, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
+2
View File
@@ -5,5 +5,7 @@ bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second
163412,Patchwork,2014,boardgame,,,PATCH WORK,IMG_4501.jpeg,
181254,Utter Nonsense,2015,boardgame,,,Utter Nonsense!,IMG_4501.jpeg;IMG_4507.jpeg;IMG_4556.jpeg,
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg,
104710,Wiz-War,2012,boardgame,,,Wiz-War,IMG_4504.jpeg,1
104710,Wiz-War,2012,boardgame,,,Wiz-War,IMG_4528.jpeg,1
218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg,
195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4502.jpeg;IMG_4507.jpeg,
1 bgg_id bgg_name year type version_id version_name title_raw source_photos second_copy
5 163412 Patchwork 2014 boardgame PATCH WORK IMG_4501.jpeg
6 181254 Utter Nonsense 2015 boardgame Utter Nonsense! IMG_4501.jpeg;IMG_4507.jpeg;IMG_4556.jpeg
7 50381 Cards Against Humanity 2009 boardgame Cards Against Humanity IMG_4501.jpeg
8 104710 Wiz-War 2012 boardgame Wiz-War IMG_4504.jpeg 1
9 104710 Wiz-War 2012 boardgame Wiz-War IMG_4528.jpeg 1
10 218866 Scrawl 2017 boardgame SCRAWL IMG_4502.jpeg
11 195314 Herbaceous 2017 boardgame Herbaceous IMG_4502.jpeg;IMG_4507.jpeg
+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:
+14
View File
@@ -405,3 +405,17 @@ def test_rpgitem_rows_are_local_only_never_queued():
assert result.local_only == ["ALICE IS MISSING"]
assert [r["bgg_id"] for r in result.to_add] == ["13"] # RPG never queued
assert result.recognized == 1 # RPGs aren't counted against the queue
def test_split_copies_become_extra_collection_entries():
# three vetoed per-photo copies, one owned: one claims the copy, the
# other two are queued as new entries
base = _match("Wiz-War", "104710")
copies = [
{**base, "source_photos": p, "dedupe_veto": "1"}
for p in ("a.jpg", "b.jpg", "c.jpg")
]
result = compute_diff(copies, [_item(104710, 900)])
assert result.already_owned == ["Wiz-War"]
assert len(result.to_add) == 2
assert all(r["second_copy"] == "1" for r in result.to_add)
+67
View File
@@ -469,3 +469,70 @@ def test_corrupt_dismiss_file_is_quarantined_not_fatal(tmp_path):
store = DismissStore(path)
assert store.keys == set()
assert (tmp_path / "dismissed.json.corrupt").exists()
def test_split_row_makes_per_photo_vetoed_copies(tmp_path):
from bggpipe.resolve import read_matches
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wiz-War",
match_status="approved",
bgg_id="104710",
bgg_name="Wiz-War",
source_photos="a.jpg;b.jpg;c.jpg",
version_candidates_json='[{"version_id": 1, "name": "8th"}]',
)
],
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
copies = session.split_row(session.rows[0])
saved = read_matches(cfg.matches_path)
assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"]
assert all(r["dedupe_veto"] == "1" for r in saved)
assert all(r["bgg_id"] == "104710" for r in saved) # match survives
# each copy picks its own edition: candidates kept, status reopened
assert all(r["version_status"] == "version_ambiguous" for r in saved)
assert all(r["version_id"] == "" for r in saved)
assert len(copies) == 3
def test_split_copies_survive_resolve_rerun(tmp_path):
import json as _json
from bggpipe.resolve import read_matches, run_resolve
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wiz-War",
match_status="approved",
bgg_id="104710",
source_photos="a.jpg;b.jpg;c.jpg",
)
],
)
(cfg.data_dir / "titles.json").write_text(
_json.dumps(
[{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}]
)
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
session.split_row(session.rows[0])
run_resolve(cfg, client=fixture_client())
saved = read_matches(cfg.matches_path)
assert len(saved) == 3 # not re-merged, not re-resolved
assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"]