Catalog pairs same-title rows by photos, not csv position
The Wiz-War lines were displaying each other's rows: an edit re-queue recreates its row at the END of matches.csv, and the catalog's positional per-title pairing then crossed the wires — the 4504 line wore 4528's open ballot while 4528's line offered 4504's pick-edition button (whose click re-targeted by photos and safely hit the other row, deepening the confusion). Pairing now matches run_resolve's rule: exact photo set, then overlap, then positional fallback, with unclaimed rows appended as their own lines. Plus a regression test with deliberately reversed csv order, and the open-ballot marker restyled as a quiet dotted link instead of a mis-wrapped button. 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
c94e8bc695
commit
3e61a03686
+1
-1
@@ -17,7 +17,7 @@ Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,versio
|
|||||||
Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4502.jpeg,,
|
Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4502.jpeg,,
|
||||||
SKIP-BO,1269,Skip-Bo,1967,boardgame,auto,,,version_unknown,"[{""bgg_id"": 1269, ""name"": ""Skip-Bo"", ""year"": 1967, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4542.jpeg,,
|
SKIP-BO,1269,Skip-Bo,1967,boardgame,auto,,,version_unknown,"[{""bgg_id"": 1269, ""name"": ""Skip-Bo"", ""year"": 1967, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4542.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,,
|
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,,
|
||||||
Wiz-War,589,Wiz-War,1983,boardgame,auto,,,version_ambiguous,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,,
|
Wiz-War,589,Wiz-War,1983,boardgame,auto,27352,Chessex sixth edition,version_approved,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,,
|
||||||
SCRAWL,202982,Scrawl,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 202982, ""name"": ""Scrawl"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,,
|
SCRAWL,202982,Scrawl,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 202982, ""name"": ""Scrawl"", ""year"": 2016, ""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,,
|
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,,
|
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,,
|
||||||
|
|||||||
|
@@ -324,6 +324,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
.catalog a:hover { color: var(--accent-ink); }
|
.catalog a:hover { color: var(--accent-ink); }
|
||||||
.catalog td.actions { text-align: right; }
|
.catalog td.actions { text-align: right; }
|
||||||
.catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
|
.catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
|
||||||
|
.catalog td.actions .ballotlink {
|
||||||
|
display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap;
|
||||||
|
font-size: .78rem; color: var(--accent-ink);
|
||||||
|
text-decoration: underline dotted; text-underline-offset: 3px;
|
||||||
|
}
|
||||||
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; }
|
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; }
|
||||||
.catalog td.actions button:hover { background: var(--board); }
|
.catalog td.actions button:hover { background: var(--board); }
|
||||||
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ function render() {
|
|||||||
split into copies</button>`
|
split into copies</button>`
|
||||||
: ""}
|
: ""}
|
||||||
${c.version_status === "version_ambiguous"
|
${c.version_status === "version_ambiguous"
|
||||||
? `<a class="linkbtn ballot" href="/review#editions"
|
? `<a class="ballotlink" href="/review#editions"
|
||||||
title="this copy's edition ballot is open">ballot in Review</a>`
|
title="this copy's edition ballot is open">ballot in Review →</a>`
|
||||||
: ""}
|
: ""}
|
||||||
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
||||||
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
|
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
|
||||||
|
|||||||
+31
-14
@@ -534,7 +534,6 @@ def create_app(
|
|||||||
rows_by_title: dict[str, list[dict]] = {}
|
rows_by_title: dict[str, list[dict]] = {}
|
||||||
for r in session.rows:
|
for r in session.rows:
|
||||||
rows_by_title.setdefault(r["title_raw"], []).append(r)
|
rows_by_title.setdefault(r["title_raw"], []).append(r)
|
||||||
title_seen: Counter[str] = Counter()
|
|
||||||
catalog = []
|
catalog = []
|
||||||
|
|
||||||
def catalog_line(entry, row) -> dict:
|
def catalog_line(entry, row) -> dict:
|
||||||
@@ -579,25 +578,43 @@ def create_app(
|
|||||||
"shaky": bool(entry and not row and entry.confidence != "high"),
|
"shaky": bool(entry and not row and entry.confidence != "high"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in session.titles:
|
# photo-overlap pairing first, positional fallback — the same rule
|
||||||
|
# as run_resolve. Positional-only broke the moment an edited copy's
|
||||||
|
# row was recreated at the END of matches.csv: same-title lines
|
||||||
|
# displayed each other's rows (buttons and ballots swapped owners)
|
||||||
|
claimed: set[int] = set()
|
||||||
|
|
||||||
|
def row_for(entry) -> dict | None:
|
||||||
same_title = rows_by_title.get(entry.title_raw, [])
|
same_title = rows_by_title.get(entry.title_raw, [])
|
||||||
ix = title_seen[entry.title_raw]
|
entry_photos = set(entry.source_photos)
|
||||||
title_seen[entry.title_raw] += 1
|
|
||||||
# positional pairing: the ix-th entry of a title reports the
|
def photos_of(r: dict) -> set[str]:
|
||||||
# ix-th row of that title (run_resolve pairs photo-overlap
|
return {p for p in r["source_photos"].split(";") if p}
|
||||||
# first, but split entries and split rows both keep sorted
|
|
||||||
# photo order, so the ordinals line up)
|
for match in (
|
||||||
row = same_title[ix] if ix < len(same_title) else None
|
lambda r: photos_of(r) == entry_photos,
|
||||||
|
lambda r: bool(photos_of(r) & entry_photos),
|
||||||
|
lambda r: True,
|
||||||
|
):
|
||||||
|
for r in same_title:
|
||||||
|
if id(r) not in claimed and match(r):
|
||||||
|
claimed.add(id(r))
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
for entry in session.titles:
|
||||||
|
row = row_for(entry)
|
||||||
# a split row's photo set is narrower than its entry's — show
|
# a split row's photo set is narrower than its entry's — show
|
||||||
# the row's own photos for split copies
|
# the row's own photos for split copies
|
||||||
catalog.append(
|
catalog.append(
|
||||||
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
|
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
|
||||||
)
|
)
|
||||||
# surplus rows beyond the entry count — split copies — are real
|
# rows no entry claimed — split copies — are real physical games
|
||||||
# physical games and get their own catalog lines
|
# and get their own catalog lines
|
||||||
for title, rows_for_title in rows_by_title.items():
|
for rows_for_title in rows_by_title.values():
|
||||||
for row in rows_for_title[title_seen[title] :]:
|
for row in rows_for_title:
|
||||||
catalog.append(catalog_line(None, row))
|
if id(row) not in claimed:
|
||||||
|
catalog.append(catalog_line(None, row))
|
||||||
unresolved_count = sum(
|
unresolved_count = sum(
|
||||||
1 for e in session.titles if e.title_raw not in resolved_titles
|
1 for e in session.titles if e.title_raw not in resolved_titles
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1202,3 +1202,44 @@ def test_version_cards_carry_their_photos(tmp_path):
|
|||||||
(card,) = web.get("/api/state").json()["versions"]
|
(card,) = web.get("/api/state").json()["versions"]
|
||||||
assert card["title_raw"] == "Dungeon!"
|
assert card["title_raw"] == "Dungeon!"
|
||||||
assert card["photos"] == ["shelf.jpg"] # same-title copies stay tellable
|
assert card["photos"] == ["shelf.jpg"] # same-title copies stay tellable
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path):
|
||||||
|
cfg = make_cfg(tmp_path)
|
||||||
|
titles = json.loads(cfg.titles_path.read_text())
|
||||||
|
titles += [
|
||||||
|
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["a.jpg"]},
|
||||||
|
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["b.jpg"]},
|
||||||
|
]
|
||||||
|
cfg.titles_path.write_text(json.dumps(titles))
|
||||||
|
rows = read_matches(cfg.matches_path)
|
||||||
|
# csv order REVERSED vs entry order: b's row first (an edit re-queue
|
||||||
|
# recreates a row at the file's end)
|
||||||
|
rows.append(
|
||||||
|
_row(
|
||||||
|
title_raw="WIZ-WAR",
|
||||||
|
match_status="auto",
|
||||||
|
bgg_id="589",
|
||||||
|
source_photos="b.jpg",
|
||||||
|
version_status="version_ambiguous",
|
||||||
|
version_candidates_json=VERSION_CANDIDATES,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
_row(
|
||||||
|
title_raw="WIZ-WAR",
|
||||||
|
match_status="auto",
|
||||||
|
bgg_id="589",
|
||||||
|
source_photos="a.jpg",
|
||||||
|
version_status="version_unknown",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
write_matches(cfg.matches_path, rows)
|
||||||
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||||
|
lines = {
|
||||||
|
tuple(c["photos"]): c
|
||||||
|
for c in web.get("/api/state").json()["catalog"]
|
||||||
|
if c["title_raw"] == "WIZ-WAR"
|
||||||
|
}
|
||||||
|
assert lines[("a.jpg",)]["version_status"] == "version_unknown"
|
||||||
|
assert lines[("b.jpg",)]["version_status"] == "version_ambiguous"
|
||||||
|
|||||||
Reference in New Issue
Block a user