Spaces:
Running
Running
| 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 | |