The matcher stops trusting what the user can't see
Eric's question cut to the bone: "How would a user know? It matched
wiz-war and that IS the game." The auto looked unanimous because the
matcher discarded the evidence of doubt before anyone saw it — and
worse, BGG's search hides evidence of its own: results truncate
unordered in the several-hundreds (the game named "Dungeon!" appears
in NEITHER the "Dungeon!" nor the "Dungeon" search), and punctuation
can bury matches.
Three matcher changes: every title is searched raw AND depuncted,
merged by id; a name that becomes exact once its trailing
parenthetical is stripped ("Wiz-War (Eighth Edition)") is a sibling
edition — BGG files new editions as separate games — and enters the
candidate set at exact grade, so same-named lineages land in review as
a visible choice; and a LONE candidate must now earn trust (stats
fetched, sibling-grade never autos alone, true exacts must clear the
dominance ownership floor) — closing the fast path both impostors
(.dungeon at 31 owners, then Dungeon (ICP)) walked through.
Recorded outcomes: WIZ-WAR → ambiguous with all three lineages on the
ballot; Dungeon! → ambiguous (its true match is beyond BGG's search
horizon — that's what manual id is for); every legitimate auto in the
fixture set held. And the answer to Eric's second question is now
structural: re-match never re-decides — it demotes to unmatched and
the HUMAN picks from re-search or manual id; the machine only chooses
on first resolve, and it now chooses more humbly.
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
8db69685c4
commit
1dd72d2688
+61
-3
@@ -89,6 +89,7 @@ class Candidate:
|
||||
type: str
|
||||
exact: bool
|
||||
fuzzy: float
|
||||
sibling: bool = False # exactness earned via edition-suffix stripping
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: list[str] = field(default_factory=list)
|
||||
@@ -226,6 +227,18 @@ def _plausible_candidates(
|
||||
for result in results:
|
||||
norm = normalize_title(result.name)
|
||||
exact = norm == entry.title_normalized
|
||||
# BGG files new editions as SEPARATE games named "X (Nth Edition)":
|
||||
# a name that equals the title once its trailing parenthetical is
|
||||
# stripped is a sibling edition — exact-grade, or the match looks
|
||||
# unanimously confident while hiding the real choice (Wiz-War has
|
||||
# three same-named lineages; the spec's top failure mode)
|
||||
sibling = False
|
||||
if not exact and result.type != "boardgameexpansion":
|
||||
sibling = (
|
||||
normalize_title(_EDITION_SUFFIX.sub("", result.name))
|
||||
== entry.title_normalized
|
||||
)
|
||||
exact = sibling
|
||||
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
|
||||
if not exact and fuzzy < FUZZY_THRESHOLD:
|
||||
if not (head_normalized and norm == head_normalized):
|
||||
@@ -237,6 +250,7 @@ def _plausible_candidates(
|
||||
year=result.year,
|
||||
type=result.type,
|
||||
exact=exact,
|
||||
sibling=sibling,
|
||||
fuzzy=fuzzy,
|
||||
)
|
||||
prev = by_id.get(result.bgg_id)
|
||||
@@ -255,7 +269,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
return row
|
||||
|
||||
if len(cands) == 1:
|
||||
chosen = cands[0]
|
||||
# a lone candidate must still earn trust: BGG's search visibly
|
||||
# truncates generic queries (the game named "Dungeon!" appears in
|
||||
# NEITHER of its own searches), so the sole survivor may be an
|
||||
# impostor standing where the famous game should be. Sibling-grade
|
||||
# exactness never autos alone, and a true exact must clear the
|
||||
# same ownership floor the dominance rule enforces.
|
||||
only = cands[0]
|
||||
stats = {t.bgg_id: t for t in client.things([only.bgg_id], stats=True)}
|
||||
if only.bgg_id in stats:
|
||||
only.owned = stats[only.bgg_id].owned
|
||||
only.rank = stats[only.bgg_id].rank
|
||||
only.publishers = list(stats[only.bgg_id].publishers)
|
||||
row.candidates = [only]
|
||||
if only.sibling or not only.exact or (only.owned or 0) < DOMINANCE_MIN_OWNED:
|
||||
row.match_status = "ambiguous"
|
||||
return row
|
||||
chosen = only
|
||||
else:
|
||||
top = cands[:5]
|
||||
stats = {
|
||||
@@ -391,8 +421,36 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
|
||||
row.version_status = "version_ambiguous"
|
||||
|
||||
|
||||
_EDITION_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
|
||||
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
|
||||
|
||||
|
||||
def _depunct(title: str) -> str:
|
||||
"""BGG's search engine can choke on punctuation — the game literally
|
||||
named "Dungeon!" is missing from its own 824-result search. A plain
|
||||
query recovers it."""
|
||||
return " ".join(_PUNCT.sub(" ", title).split())
|
||||
|
||||
|
||||
def _merged_candidates(
|
||||
client: BGGClient, entry: TitleEntry, query: str, types: str | None = None
|
||||
) -> list[Candidate]:
|
||||
"""Search the raw title AND its punctuation-free form, merged by id
|
||||
(strongest evidence wins)."""
|
||||
cands = _plausible_candidates(client, entry, query, types=types)
|
||||
plain = _depunct(query)
|
||||
if plain.casefold() != query.casefold():
|
||||
by_id = {c.bgg_id: c for c in cands}
|
||||
for c in _plausible_candidates(client, entry, plain, types=types):
|
||||
prev = by_id.get(c.bgg_id)
|
||||
if prev is None or (c.exact, c.fuzzy) > (prev.exact, prev.fuzzy):
|
||||
by_id[c.bgg_id] = c
|
||||
cands = sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
|
||||
return cands
|
||||
|
||||
|
||||
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
cands = _plausible_candidates(client, entry, entry.title_raw)
|
||||
cands = _merged_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
|
||||
@@ -406,7 +464,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
# type=rpgitem (same API, same token). A hit becomes a LOCAL
|
||||
# library citizen: identified and enriched, never uploaded (diff
|
||||
# routes rpgitem rows to local_only).
|
||||
cands = _plausible_candidates(client, entry, entry.title_raw, types="rpgitem")
|
||||
cands = _merged_candidates(client, entry, entry.title_raw, types="rpgitem")
|
||||
if not cands:
|
||||
for head in _truncation_heads(entry.title_raw):
|
||||
cands = _plausible_candidates(
|
||||
|
||||
Reference in New Issue
Block a user