Vscode / app /core /names.py
Erinaldorodrigues's picture
Add transparent prediction radar
7293dcc
Raw
History Blame Contribute Delete
4.38 kB
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