Progressive title truncation, publisher tiebreak, thematic-year fix

Long transcribed box titles that defeat search now retry with shorter
heads (pre-separator, pre-"Game ..." descriptor, first-two-words from
the pre-subtitle part) matched exact-only against the head — fuzzy
thresholds stay untouched. Tie-breaks gain a publisher pick: when the
box showed a publisher and exactly one exact-named candidate is from
that publisher, it wins (SPI's Sorcerer 1975 now beats the more-owned
White Wizard Sorcerer 2019). The extract prompt excludes thematic/
subject years from year_hint; re-extracting Flat Top's photo drops the
bogus 1942, and a regression test pins that a wrong year can never
drive version selection. Re-extraction also drifted two transcriptions
(DUNGEON!, and Herbaceous misread as "Hebarceos") — fixtures added; the
misread demos review's re-search rescue in the end-to-end run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 14:14:15 -04:00
parent 09274e039c
commit 4a466f2a68
10 changed files with 218 additions and 25 deletions
+18 -18
View File
@@ -249,26 +249,26 @@
"title_normalized": "exploding kittens" "title_normalized": "exploding kittens"
}, },
{ {
"title_raw": "Herbaceous", "title_raw": "Hebarceos",
"confidence": "high", "confidence": "low",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "",
"art_notes": "Colorful spine with illustrated herb/plant imagery, 'A player's game of...' text visible", "art_notes": "yellow box with floral/plant illustration",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
"title_normalized": "herbaceous" "title_normalized": "hebarceos"
}, },
{ {
"title_raw": "Wiz-War", "title_raw": "Wiz-War",
"confidence": "high", "confidence": "medium",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Dark blue/black box, partial text 'board game of...battle and treasures'", "art_notes": "dark blue box, text 'board game of...battle and treasures'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -281,7 +281,7 @@
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Bright pink/magenta box with striped pattern, tagline 'Doodle your way to disaster'", "art_notes": "pink and purple striped box, subtitle 'Doodle your way to disaster'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -292,9 +292,9 @@
"confidence": "high", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": 1942, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'", "art_notes": "light blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -303,11 +303,11 @@
{ {
"title_raw": "Diplomacy", "title_raw": "Diplomacy",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Black spine, subtitle 'The Exciting Game of International Intrigue - For 2 to 7 Players'", "art_notes": "black spine, subtitle 'The Exciting Game of International Intrigue, For 2 to 7 Players'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -316,11 +316,11 @@
{ {
"title_raw": "Age of Renaissance", "title_raw": "Age of Renaissance",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "AH (Avalon Hill)",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Tan/brown spine with gold diamond emblem, subtitle about building dominant civilization of Europe", "art_notes": "tan/brown spine with gold emblem, subtitle about building dominant civilization of Europe",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -329,24 +329,24 @@
{ {
"title_raw": "History of the World", "title_raw": "History of the World",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "AH (Avalon Hill)",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Blue/purple spine, subtitle 'Game of World Conquest'", "art_notes": "light blue/lavender spine, subtitle 'Game of World Conquest'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
"title_normalized": "history of world" "title_normalized": "history of world"
}, },
{ {
"title_raw": "Dungeon!", "title_raw": "DUNGEON!",
"confidence": "high", "confidence": "high",
"publisher_hint": "TSR", "publisher_hint": "TSR",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Purple box bottom with fantasy dungeon-themed artwork, visible at base of stack", "art_notes": "purple/dark spine with stone dungeon texture illustration",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
+4
View File
@@ -84,6 +84,9 @@ SEARCHES: dict[str, list[tuple]] = {
(172242, "Exploding Kittens: NSFW Edition", 2015, BG, "primary"), (172242, "Exploding Kittens: NSFW Edition", 2015, BG, "primary"),
], ],
"Herbaceous": [(195314, "Herbaceous", 2017, BG, "primary")], "Herbaceous": [(195314, "Herbaceous", 2017, BG, "primary")],
# vision misread of Herbaceous on one run — search finds nothing;
# review's free-text re-search ("f Herbaceous") rescues it
"Hebarceos": [],
"Wiz-War": [ "Wiz-War": [
(1218, "Wiz-War", 1983, BG, "primary"), (1218, "Wiz-War", 1983, BG, "primary"),
(104710, "Wiz-War", 2012, BG, "alternate"), # primary: Wiz-War (Eighth Edition) (104710, "Wiz-War", 2012, BG, "alternate"), # primary: Wiz-War (Eighth Edition)
@@ -94,6 +97,7 @@ SEARCHES: dict[str, list[tuple]] = {
"Age of Renaissance": [(26, "Age of Renaissance", 1996, BG, "primary")], "Age of Renaissance": [(26, "Age of Renaissance", 1996, BG, "primary")],
"History of the World": [(224, "History of the World", 1991, BG, "primary")], "History of the World": [(224, "History of the World", 1991, BG, "primary")],
"Dungeon!": [(1339, "Dungeon!", 1975, BG, "primary")], "Dungeon!": [(1339, "Dungeon!", 1975, BG, "primary")],
"DUNGEON!": [(1339, "Dungeon!", 1975, BG, "primary")], # spelling drift
} }
# ids-param -> [(bgg_id, name, year, type, owned, rank, [publishers])] # ids-param -> [(bgg_id, name, year, type, owned, rank, [publishers])]
+1
View File
@@ -54,6 +54,7 @@ SEARCHES = {
+ search_item(205398, "Citadels", 2016, "boardgame") + search_item(205398, "Citadels", 2016, "boardgame")
), ),
"Blorvath: Quest of the Zzyzx": "", "Blorvath: Quest of the Zzyzx": "",
"Blorvath": "", # truncation-retry head of the nonsense title
} }
THINGS = { THINGS = {
+5 -1
View File
@@ -47,7 +47,11 @@ Respond with ONLY a JSON array, no prose, where each element is:
"publisher_hint": "publisher name or logo if legible, else null", "publisher_hint": "publisher name or logo if legible, else null",
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe', "edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
'Big Box', anniversary marks), else null", 'Big Box', anniversary marks), else null",
"year_hint": copyright/print year as an integer if legible, else null, "year_hint": copyright or publication year as an integer, ONLY if printed
as publishing info (copyright line, edition year). NEVER use
a year that is part of the game's title, theme, or subject
matter — a wargame about 1942 is not published in 1942.
When unsure whether a year is thematic, use null,
"language_hint": "language of the box text if determinable, else null", "language_hint": "language of the box text if determinable, else null",
"art_notes": "distinctive box-art notes (colorway, artwork style) that "art_notes": "distinctive box-art notes (colorway, artwork style) that
could identify the edition, else null" could identify the edition, else null"
+5
View File
@@ -38,6 +38,7 @@ class ThingDetails:
type: str type: str
owned: int | None = None owned: int | None = None
rank: int | None = None rank: int | None = None
publishers: tuple[str, ...] = field(default=())
versions: tuple[GameVersion, ...] = field(default=()) versions: tuple[GameVersion, ...] = field(default=())
@@ -124,6 +125,10 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
type=item.get("type", "boardgame"), type=item.get("type", "boardgame"),
owned=_attr_int(item.find(".//ratings/owned")), owned=_attr_int(item.find(".//ratings/owned")),
rank=_attr_int(rank_elem), rank=_attr_int(rank_elem),
publishers=tuple(
link.get("value", "")
for link in item.findall("link[@type='boardgamepublisher']")
),
versions=tuple(versions), versions=tuple(versions),
) )
) )
+74 -5
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import csv import csv
import json import json
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -76,6 +77,7 @@ class Candidate:
fuzzy: float fuzzy: float
owned: int | None = None owned: int | None = None
rank: int | None = None rank: int | None = None
publishers: list[str] = field(default_factory=list)
def as_json(self) -> dict: def as_json(self) -> dict:
return { return {
@@ -151,15 +153,55 @@ def load_titles(path: Path) -> list[TitleEntry]:
return entries return entries
def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]: _SEPARATORS = ("", " ", " - ", ": ", "; ")
"""Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id.""" _GAME_WORD = re.compile(r"\s+(?:the\s+|a\s+)?game\b", re.IGNORECASE)
def _truncation_heads(title_raw: str) -> list[str]:
"""Progressively shorter heads for box titles whose printed subtitle
defeats search ("CIVILIZATION Game of the Heroic Age - ..."): text
before the first subtitle separator, before a "(The) Game ..."
descriptor, then the first two words as a last resort."""
heads: list[str] = []
sep_head = next(
(title_raw.split(sep)[0] for sep in _SEPARATORS if sep in title_raw), None
)
if sep_head:
heads.append(sep_head)
match = _GAME_WORD.search(title_raw)
if match and match.start() > 0:
heads.append(title_raw[: match.start()])
# last resort: first two words — of the pre-subtitle part, so a title
# like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest"
words = (sep_head or title_raw).split()
if len(words) > 2:
heads.append(" ".join(words[:2]))
seen: set[str] = {normalize_title(title_raw)}
unique: list[str] = []
for head in heads:
norm = normalize_title(head)
if norm and norm not in seen:
seen.add(norm)
unique.append(head)
return unique[:3]
def _plausible_candidates(
client: BGGClient, entry: TitleEntry, query: str, head_normalized: str = ""
) -> list[Candidate]:
"""Search BGG and keep plausible candidates, one per id: exact-normalized
or fuzzy>=90 against the FULL title, or — on truncated retries — exact
(only exact: truncation must stay conservative) against the head."""
by_id: dict[int, Candidate] = {} by_id: dict[int, Candidate] = {}
for result in client.search(entry.title_raw): for result in client.search(query):
norm = normalize_title(result.name) norm = normalize_title(result.name)
exact = norm == entry.title_normalized exact = norm == entry.title_normalized
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized) fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
if not exact and fuzzy < FUZZY_THRESHOLD: if not exact and fuzzy < FUZZY_THRESHOLD:
if not (head_normalized and norm == head_normalized):
continue continue
exact = True # head-exact counts as strong, nothing weaker does
candidate = Candidate( candidate = Candidate(
bgg_id=result.bgg_id, bgg_id=result.bgg_id,
name=result.name, name=result.name,
@@ -194,8 +236,9 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
if c.bgg_id in stats: if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank c.rank = stats[c.bgg_id].rank
c.publishers = list(stats[c.bgg_id].publishers)
row.candidates = top row.candidates = top
chosen = _dominant(top) chosen = _publisher_pick(entry, top) or _dominant(top)
if chosen is None: if chosen is None:
row.match_status = "ambiguous" row.match_status = "ambiguous"
return row return row
@@ -209,6 +252,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
return row return row
def _publisher_pick(entry: TitleEntry, top: list[Candidate]) -> Candidate | None:
"""When a publisher was legible on the box, and exactly one exact-named
candidate is from that publisher, that's the game."""
if not entry.publisher_hint:
return None
hint = normalize_title(entry.publisher_hint)
matches = [
c
for c in top
if c.exact
and any(
fuzz.partial_ratio(hint, normalize_title(p)) >= 85 for p in c.publishers
)
]
return matches[0] if len(matches) == 1 else None
def _dominant(top: list[Candidate]) -> Candidate | None: def _dominant(top: list[Candidate]) -> Candidate | None:
"""The single clear winner among plausible candidates, if any. """The single clear winner among plausible candidates, if any.
@@ -291,7 +351,16 @@ def _resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> Non
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
row = _classify(client, entry, _plausible_candidates(client, entry)) cands = _plausible_candidates(client, entry, entry.title_raw)
if not cands:
# Long transcribed box titles ("CIVILIZATION Game of the Heroic
# Age - ...") defeat search: retry with progressively shorter heads
# instead of loosening the fuzzy threshold.
for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates(client, entry, head, normalize_title(head))
if cands:
break
row = _classify(client, entry, cands)
if row.match_status == "auto": if row.match_status == "auto":
_resolve_version(client, entry, row) _resolve_version(client, entry, row)
return row return row
@@ -0,0 +1 @@
<items total="0"></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="1339"><name type="primary" value="Dungeon!"/><yearpublished value="1975"/></item></items>
@@ -0,0 +1 @@
<items total="0"></items>
+107
View File
@@ -187,3 +187,110 @@ def test_score_version_no_overlap():
languages=("English",), languages=("English",),
) )
assert _score_version(entry, version) == 0 assert _score_version(entry, version) == 0
# -- progressive title truncation (long transcribed box titles) ---------
from bggpipe.normalize import normalize_title # noqa: E402
from bggpipe.resolve import _truncation_heads # noqa: E402
CIV_TITLE = (
"CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC"
)
AH_HINT = "The Avalon Hill Game Company, Baltimore, Maryland"
def _entry(title, **kw):
return TitleEntry(title_raw=title, title_normalized=normalize_title(title), **kw)
def test_truncation_heads_order_and_dedupe():
heads = _truncation_heads(CIV_TITLE)
assert heads[0] == "CIVILIZATION Game of the Heroic Age" # before separator
assert heads[1] == "CIVILIZATION" # before the "Game ..." descriptor
assert len(heads) <= 3
def test_truncation_heads_game_word_without_separator():
assert _truncation_heads("SORCERER The Game of Magical Conflict")[0] == "SORCERER"
def test_truncation_heads_two_word_fallback():
heads = _truncation_heads(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century"
)
assert heads[-1] == "STARFORCE ALPHA"
def test_short_titles_get_no_heads():
assert _truncation_heads("Catan") == []
assert _truncation_heads("Herbaceous") == []
def test_civilization_resolves_via_truncation(client):
entry = _entry(
CIV_TITLE,
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 71)
# publisher cue picks the Avalon Hill version, not Hartland/Gibsons
assert row.version_status == "version_auto"
assert row.version_id == 71001
def test_advanced_civilization_resolves_as_expansion(client):
entry = _entry(
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age - "
"Featuring New Civilization, Commodity, and Calamity Cards",
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id, row.type) == (
"auto",
177,
"boardgameexpansion",
)
assert row.version_status == "version_auto"
def test_sorcerer_publisher_tiebreak_beats_owned_dominance(client):
"""Head search finds TWO games named 'Sorcerer' (SPI 1975 vs White
Wizard 2019, which has far more owners) — the SPI publisher cue must
pick the SPI game, and the edition cue the Designer's Edition."""
entry = _entry(
"SORCERER The Game of Magical Conflict",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 3585)
assert row.version_status == "version_auto"
assert row.version_name == "SPI Designer's Edition"
def test_starforce_two_word_head_matches_full_title(client):
entry = _entry(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2524)
assert row.version_status == "version_auto"
def test_wrong_year_hint_never_drives_a_version(client):
"""Flat Top's box says 1942 (the theme, not the print year): version
scoring must not pick any version off the back of it."""
entry = _entry("FLAT TOP", year_hint=1942, language_hint="English")
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2529)
assert row.version_status == "version_unknown"
assert row.version_id is None