from __future__ import annotations from collections import defaultdict import re import unicodedata from rapidfuzz import fuzz from app.models import FinishedMatch, TeamIdentity ALIASES = { "manchester city": "man city", "manchester united": "man united", "internazionale": "inter", "internazionale milano": "inter", "inter milan": "inter", "paris saint germain": "psg", "atletico de madrid": "atletico madrid", "club atletico de madrid": "atletico madrid", "bayern munchen": "bayern munich", "borussia monchengladbach": "gladbach", "sporting clube de portugal": "sporting cp", "sporting lisbon": "sporting cp", # The Odds API expands these Dutch acronyms while football-data.org uses # the official short form. Keep only exact, competition-known aliases; # short unknown acronyms must still fail closed in fuzzy matching. "psv eindhoven": "psv", "az alkmaar": "az", "nec nijmegen": "nec", } def normalize_name(value: str) -> str: value = unicodedata.normalize("NFKD", value or "") value = "".join(ch for ch in value if not unicodedata.combining(ch)) value = value.lower().replace("&", " and ") value = re.sub(r"[^a-z0-9 ]+", " ", value) value = re.sub( r"\b(fc|afc|cf|sc|ac|calcio|club|football|futebol|deportivo|fk|sv|vfl|ssc|ss|as)\b", " ", value, ) value = re.sub(r"\s+", " ", value).strip() return ALIASES.get(value, value) def similarity(a: str, b: str) -> float: na, nb = normalize_name(a), normalize_name(b) if not na or not nb: return 0.0 if na == nb: return 100.0 if min(len(na), len(nb)) <= 3: return 0.0 return max(float(fuzz.WRatio(na, nb)), float(fuzz.token_set_ratio(na, nb))) def build_team_catalog(matches: list[FinishedMatch], competition: str) -> list[TeamIdentity]: aliases: dict[str, set[str]] = defaultdict(set) names: dict[str, str] = {} for m in matches: if m.competition != competition: continue for key, name, extra in ( (m.home_key, m.home, m.home_aliases), (m.away_key, m.away, m.away_aliases), ): names.setdefault(key, name) aliases[key].add(name) aliases[key].update(a for a in extra if a) return [ TeamIdentity(key=key, name=names[key], aliases=tuple(sorted(aliases[key]))) for key in sorted(names) ] def _identity_score(query: str, identity: TeamIdentity) -> float: scores = [similarity(query, identity.name)] qn = normalize_name(query) for alias in identity.aliases: an = normalize_name(alias) if qn and qn == an: return 100.0 if len(an) >= 4: scores.append(similarity(query, alias)) return max(scores) if scores else 0.0 def resolve_identity( name: str, catalog: list[TeamIdentity], minimum: float = 82.0, minimum_margin: float = 6.0, ) -> tuple[TeamIdentity | None, float, float]: if not catalog: return None, 0.0, 0.0 ranked = sorted( ((_identity_score(name, identity), identity) for identity in catalog), key=lambda item: item[0], reverse=True, ) best_score, best = ranked[0] second_score = ranked[1][0] if len(ranked) > 1 else 0.0 margin = best_score - second_score if best_score < minimum: return None, best_score, margin # Exact/near-exact matches are safe even if two clubs have similar long names. if best_score < 97.0 and margin < minimum_margin: return None, best_score, margin return best, best_score, margin def resolve_event_pair( home_name: str, away_name: str, catalog: list[TeamIdentity], minimum: float = 82.0, ) -> tuple[TeamIdentity | None, TeamIdentity | None, float, dict[str, float]]: home, hs, hm = resolve_identity(home_name, catalog, minimum=minimum) away, as_, am = resolve_identity(away_name, catalog, minimum=minimum) detail = { "home_score": hs, "away_score": as_, "home_margin": hm, "away_margin": am, } if not home or not away or home.key == away.key: return None, None, 0.0, detail confidence = min(hs, as_) / 100.0 if min(hm, am) < 8.0 and min(hs, as_) < 97.0: confidence *= 0.94 return home, away, confidence, detail