Files
bggpipe/src/bggpipe/resolve.py
T
Eric Wagoner 8420a0a1ca Post-resolve dedupe: duplicate reads merge, review can veto
Rows resolving to the same (bgg_id, version_id — or both version-
unknown) are the same physical game read twice unless their extraction
cues conflict (two editions stay separate). The survivor is the read
whose transcription matches the BGG name; losers are marked
match_status=merged with a new merged_into column — no row is ever
deleted, and older matches.csv files without the column still read.
Downstream: diff skips merged rows but folds their photos into the
survivor's to_add provenance; enrich and the review passes ignore them.
The web UI gains a Merges section ("Jokin Ha... merged into Joking
Hazard") with a veto (v key) that restores the row as a distinct
approved match, plus a merged catalog chip and header tally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:59:47 -04:00

547 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stage 2 — resolve extracted titles to BGG IDs and versions.
Reads data/titles.json, queries BGG search (+ thing stats for tie-breaks,
+ versions once a game is settled), classifies each title auto/ambiguous/
unmatched, and appends rows to data/matches.csv. Re-runs skip titles
already present in matches.csv unless --force.
"""
from __future__ import annotations
import csv
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
import typer
from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGAuthError, BGGClient
from bggpipe.config import Config
from bggpipe.extract import _cues_conflict
from bggpipe.models import GameVersion
from bggpipe.normalize import normalize_title
FUZZY_THRESHOLD = 90
# Tie-break dominance: an exact-named candidate wins outright only if it is
# clearly the well-known game (spec: obscure duplicates lose to famous ones).
DOMINANCE_MIN_OWNED = 100
DOMINANCE_FACTOR = 10
VERSION_PLAUSIBLE_SCORE = 2
MATCH_COLUMNS = [
"title_raw",
"bgg_id",
"bgg_name",
"year",
"type",
"match_status",
"version_id",
"version_name",
"version_status",
"candidates_json",
"version_candidates_json",
"source_photos",
"merged_into",
]
@dataclass(frozen=True)
class TitleEntry:
title_raw: str
title_normalized: str
confidence: str = "high"
publisher_hint: str = ""
edition_hint: str = ""
year_hint: int | None = None
language_hint: str = ""
art_notes: str = ""
source_photos: tuple[str, ...] = ()
@property
def has_version_cues(self) -> bool:
"""Cues strong enough to justify a versions fetch. Language alone
can never reach the plausibility threshold (it scores 1 of the
required 2), so fetching on it would waste a rate-limited request —
it still participates in scoring when other cues exist."""
return bool(self.publisher_hint or self.edition_hint or self.year_hint)
@dataclass
class Candidate:
bgg_id: int
name: str
year: int | None
type: str
exact: bool
fuzzy: float
owned: int | None = None
rank: int | None = None
publishers: list[str] = field(default_factory=list)
def as_json(self) -> dict:
return {
"bgg_id": self.bgg_id,
"name": self.name,
"year": self.year,
"type": self.type,
"exact": self.exact,
"fuzzy": round(self.fuzzy, 1),
"owned": self.owned,
"rank": self.rank,
}
@dataclass
class MatchRow:
title_raw: str
source_photos: tuple[str, ...] = ()
bgg_id: int | None = None
bgg_name: str = ""
year: int | None = None
type: str = ""
match_status: str = "unmatched"
version_id: int | None = None
version_name: str = ""
version_status: str = ""
candidates: list[Candidate] = field(default_factory=list)
version_candidates: list[dict] = field(default_factory=list)
def to_csv(self) -> dict[str, str]:
return {
"title_raw": self.title_raw,
"bgg_id": str(self.bgg_id) if self.bgg_id else "",
"bgg_name": self.bgg_name,
"year": str(self.year) if self.year else "",
"type": self.type,
"match_status": self.match_status,
"version_id": str(self.version_id) if self.version_id else "",
"version_name": self.version_name,
"version_status": self.version_status,
"candidates_json": json.dumps(
[c.as_json() for c in self.candidates], ensure_ascii=False
),
"version_candidates_json": json.dumps(
self.version_candidates, ensure_ascii=False
),
"source_photos": ";".join(self.source_photos),
}
def load_titles(path: Path) -> list[TitleEntry]:
if not path.exists():
raise FileNotFoundError(
f"{path} not found — run `bggpipe extract` or hand-write a title list."
)
entries = []
for raw in json.loads(path.read_text()):
title_raw = raw["title_raw"]
entries.append(
TitleEntry(
title_raw=title_raw,
title_normalized=raw.get("title_normalized")
or normalize_title(title_raw),
confidence=raw.get("confidence", "high"),
publisher_hint=raw.get("publisher_hint") or "",
edition_hint=raw.get("edition_hint") or "",
year_hint=raw.get("year_hint"),
language_hint=raw.get("language_hint") or "",
art_notes=raw.get("art_notes") or "",
source_photos=tuple(raw.get("source_photos") or ()),
)
)
return entries
_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(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:
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,
year=result.year,
type=result.type,
exact=exact,
fuzzy=fuzzy,
)
prev = by_id.get(result.bgg_id)
if prev is None or (candidate.exact, candidate.fuzzy) > (
prev.exact,
prev.fuzzy,
):
by_id[result.bgg_id] = candidate
return sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> MatchRow:
row = MatchRow(title_raw=entry.title_raw, source_photos=entry.source_photos)
if not cands:
row.match_status = "unmatched"
return row
if len(cands) == 1:
chosen = cands[0]
else:
top = cands[:5]
stats = {
t.bgg_id: t for t in client.things([c.bgg_id for c in top], stats=True)
}
for c in top:
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 = _publisher_pick(entry, top) or _dominant(top)
if chosen is None:
row.match_status = "ambiguous"
return row
row.bgg_id = chosen.bgg_id
row.bgg_name = chosen.name
row.year = chosen.year
row.type = chosen.type
row.match_status = "auto"
row.candidates = row.candidates or [chosen]
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.
Mixed boardgame/expansion candidates are never auto-resolved (the
spec's most common failure mode); otherwise an exact-named candidate
wins only when its owned-count dwarfs the runner-up's.
"""
if len({c.type for c in top}) > 1:
return None
ranked = sorted(top, key=lambda c: c.owned or 0, reverse=True)
best, second = ranked[0], ranked[1]
if (
best.exact
and (best.owned or 0) >= DOMINANCE_MIN_OWNED
and (best.owned or 0) >= DOMINANCE_FACTOR * max(second.owned or 0, 1)
):
return best
return None
def _score_version(entry: TitleEntry, version: GameVersion) -> int:
score = 0
if entry.publisher_hint:
hint = normalize_title(entry.publisher_hint)
if any(
fuzz.partial_ratio(hint, normalize_title(p)) >= 85
for p in version.publishers
):
score += 2
if entry.year_hint and version.year == entry.year_hint:
score += 2
if entry.language_hint and entry.language_hint.casefold() in {
lang.casefold() for lang in version.languages
}:
score += 1
if (
entry.edition_hint
and version.name
and fuzz.token_set_ratio(
normalize_title(entry.edition_hint), normalize_title(version.name)
)
>= 80
):
score += 2
return score
def _resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None:
"""Fill version_* fields on an auto/approved row. Never guess (spec)."""
if not entry.has_version_cues:
row.version_status = "version_unknown"
return
(thing,) = client.things([row.bgg_id], versions=True)
scored = sorted(
((v, _score_version(entry, v)) for v in thing.versions),
key=lambda pair: -pair[1],
)
plausible = [(v, s) for v, s in scored if s >= VERSION_PLAUSIBLE_SCORE]
if not plausible:
row.version_status = "version_unknown"
return
row.version_candidates = [
{
"version_id": v.version_id,
"name": v.name,
"year": v.year,
"publishers": list(v.publishers),
"languages": list(v.languages),
"score": s,
}
for v, s in plausible[:8]
]
if len(plausible) == 1 or plausible[0][1] > plausible[1][1]:
winner = plausible[0][0]
row.version_id = winner.version_id
row.version_name = winner.name
row.version_status = "version_auto"
else:
row.version_status = "version_ambiguous"
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
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
def _row_key(title_raw: str, source_photos: str) -> tuple[str, str]:
return (title_raw, source_photos)
@dataclass(frozen=True)
class MergeEvent:
loser_title: str
survivor_title: str
bgg_name: str
bgg_id: str
def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEvent]:
"""Post-resolve dedupe: rows resolving to the same (bgg_id, version_id —
or both version-unknown) are the same physical game seen twice (a typo
read, a partial spine) UNLESS their extraction cues conflict, which
means two editions. Losers are marked match_status="merged" pointing at
the survivor via merged_into — no row is ever deleted, and the review
UI can veto the merge."""
entry_by_key = {(e.title_raw, ";".join(e.source_photos)): e for e in titles}
entry_by_title: dict[str, TitleEntry] = {}
for e in titles:
entry_by_title.setdefault(e.title_raw, e)
def cues(row: dict) -> dict:
entry = entry_by_key.get(
(row["title_raw"], row["source_photos"])
) or entry_by_title.get(row["title_raw"])
if entry is None:
return {}
return {
"publisher_hint": entry.publisher_hint,
"edition_hint": entry.edition_hint,
"language_hint": entry.language_hint,
"year_hint": entry.year_hint,
}
groups: dict[tuple[str, str], list[dict]] = {}
for row in rows:
if row["match_status"] not in ("auto", "approved") or not row["bgg_id"]:
continue
confident = (
row["version_status"] in ("version_auto", "version_approved")
and row["version_id"]
)
key = (row["bgg_id"], row["version_id"] if confident else "")
groups.setdefault(key, []).append(row)
events: list[MergeEvent] = []
for (bgg_id, _version), group in groups.items():
if len(group) < 2:
continue
# survivor: the row whose transcription best matches the BGG name
group = sorted(
group,
key=lambda r: (
normalize_title(r["title_raw"]) != normalize_title(r["bgg_name"] or "")
),
)
survivor = group[0]
for loser in group[1:]:
if _cues_conflict(cues(survivor), cues(loser)):
continue # conflicting edition cues: genuinely two copies
loser["match_status"] = "merged"
loser["merged_into"] = survivor["title_raw"]
events.append(
MergeEvent(
loser_title=loser["title_raw"],
survivor_title=survivor["title_raw"],
bgg_name=survivor["bgg_name"],
bgg_id=bgg_id,
)
)
return events
def read_matches(path: Path) -> list[dict[str, str]]:
if not path.exists():
return []
with path.open(newline="") as f:
rows = list(csv.DictReader(f))
for row in rows: # files written before the merged_into column existed
row.setdefault("merged_into", "")
return rows
def write_matches(path: Path, rows: list[dict[str, str]]) -> None:
"""Atomic full rewrite — review updates rows in place decision by decision."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
with tmp.open("w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=MATCH_COLUMNS, extrasaction="ignore", restval=""
)
writer.writeheader()
writer.writerows(rows)
os.replace(tmp, path)
def read_existing_keys(path: Path) -> set[tuple[str, str]]:
if not path.exists():
return set()
with path.open(newline="") as f:
return {_row_key(r["title_raw"], r["source_photos"]) for r in csv.DictReader(f)}
def append_rows(path: Path, rows: list[MatchRow]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
is_new = not path.exists()
with path.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=MATCH_COLUMNS)
if is_new:
writer.writeheader()
for row in rows:
writer.writerow(row.to_csv())
def run_resolve(
cfg: Config, *, force: bool = False, client: BGGClient | None = None
) -> list[MatchRow]:
entries = load_titles(cfg.titles_path)
if force and cfg.matches_path.exists():
cfg.matches_path.unlink()
existing = read_existing_keys(cfg.matches_path)
client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
new_rows: list[MatchRow] = []
skipped = 0
blocked: list[str] = []
for entry in entries:
key = _row_key(entry.title_raw, ";".join(entry.source_photos))
if key in existing:
skipped += 1
continue
try:
row = resolve_entry(client, entry)
except BGGAuthError:
# No API token yet: cached titles still resolve; the rest wait.
# No row is written, so a future run picks them up untouched.
blocked.append(entry.title_raw)
typer.echo(f" {entry.title_raw!r} -> waiting on BGG API token")
continue
new_rows.append(row)
detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-"
version = f" [{row.version_status}]" if row.version_status else ""
typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}")
append_rows(cfg.matches_path, new_rows)
all_rows = read_matches(cfg.matches_path)
merges = dedupe_matches(all_rows, entries)
if merges:
write_matches(cfg.matches_path, all_rows)
typer.echo("")
for m in merges:
typer.echo(
f" merged {m.loser_title!r} into {m.survivor_title!r} — same "
f"game ({m.bgg_name}, {m.bgg_id}); veto in review if wrong"
)
counts: dict[str, int] = {}
for row in new_rows:
counts[row.match_status] = counts.get(row.match_status, 0) + 1
summary = ", ".join(f"{n} {status}" for status, n in sorted(counts.items()))
typer.echo(
f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); "
f"skipped {skipped} already in {cfg.matches_path}."
)
if blocked:
typer.echo(
f"\n{len(blocked)} title(s) are waiting on the BGG API "
"(register at https://boardgamegeek.com/applications, then set "
"BGG_API_TOKEN and re-run resolve — everything above is saved): "
+ ", ".join(blocked)
)
return new_rows