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:
+3
-1
@@ -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,,
|
||||
|
||||
|
@@ -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,
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user