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:
co-authored by
Claude Fable 5
parent
09274e039c
commit
4a466f2a68
@@ -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",
|
||||
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
|
||||
'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",
|
||||
"art_notes": "distinctive box-art notes (colorway, artwork style) that
|
||||
could identify the edition, else null"
|
||||
|
||||
@@ -38,6 +38,7 @@ class ThingDetails:
|
||||
type: str
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: tuple[str, ...] = field(default=())
|
||||
versions: tuple[GameVersion, ...] = field(default=())
|
||||
|
||||
|
||||
@@ -124,6 +125,10 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
|
||||
type=item.get("type", "boardgame"),
|
||||
owned=_attr_int(item.find(".//ratings/owned")),
|
||||
rank=_attr_int(rank_elem),
|
||||
publishers=tuple(
|
||||
link.get("value", "")
|
||||
for link in item.findall("link[@type='boardgamepublisher']")
|
||||
),
|
||||
versions=tuple(versions),
|
||||
)
|
||||
)
|
||||
|
||||
+75
-6
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -76,6 +77,7 @@ class Candidate:
|
||||
fuzzy: float
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: list[str] = field(default_factory=list)
|
||||
|
||||
def as_json(self) -> dict:
|
||||
return {
|
||||
@@ -151,15 +153,55 @@ def load_titles(path: Path) -> list[TitleEntry]:
|
||||
return entries
|
||||
|
||||
|
||||
def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]:
|
||||
"""Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id."""
|
||||
_SEPARATORS = (" — ", " – ", " - ", ": ", "; ")
|
||||
_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] = {}
|
||||
for result in client.search(entry.title_raw):
|
||||
for result in client.search(query):
|
||||
norm = normalize_title(result.name)
|
||||
exact = norm == entry.title_normalized
|
||||
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
|
||||
if not exact and fuzzy < FUZZY_THRESHOLD:
|
||||
continue
|
||||
if not (head_normalized and norm == head_normalized):
|
||||
continue
|
||||
exact = True # head-exact counts as strong, nothing weaker does
|
||||
candidate = Candidate(
|
||||
bgg_id=result.bgg_id,
|
||||
name=result.name,
|
||||
@@ -194,8 +236,9 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
if c.bgg_id in stats:
|
||||
c.owned = stats[c.bgg_id].owned
|
||||
c.rank = stats[c.bgg_id].rank
|
||||
c.publishers = list(stats[c.bgg_id].publishers)
|
||||
row.candidates = top
|
||||
chosen = _dominant(top)
|
||||
chosen = _publisher_pick(entry, top) or _dominant(top)
|
||||
if chosen is None:
|
||||
row.match_status = "ambiguous"
|
||||
return row
|
||||
@@ -209,6 +252,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
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:
|
||||
"""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:
|
||||
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":
|
||||
_resolve_version(client, entry, row)
|
||||
return row
|
||||
|
||||
Reference in New Issue
Block a user