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
+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"]