Spaces:
Running
Running
| from __future__ import annotations | |
| import json | |
| import unicodedata | |
| from dataclasses import asdict | |
| from pathlib import Path | |
| from typing import Any | |
| import yaml | |
| from racing_reports import vendor_env | |
| from racing_reports.config import DEFAULT_SETTINGS, Settings | |
| from racing_reports.datastore import DataStore | |
| from racing_reports.match_index import MatchIndex | |
| from racing_reports.models import ReportRequest, ReportResult | |
| from racing_reports.reports import block_zscore, ejes, post_match, pre_match, recuperaciones, triple_multitag | |
| from racing_reports.reports.bundle import ReportBundle | |
| from racing_reports.utils import git_commit, slugify, utc_now_iso, write_json | |
| VALID_REPORTS = {"pre_match", "post_match", "block_zscore", "triple_multitag", "triple_centros", | |
| "recuperaciones", "ejes_pre", "ejes_post"} | |
| def _norm(text: str) -> str: | |
| return ( | |
| unicodedata.normalize("NFKD", str(text)) | |
| .encode("ascii", "ignore") | |
| .decode("ascii") | |
| .strip() | |
| .lower() | |
| ) | |
| class ReportRunner: | |
| def __init__( | |
| self, datastore: DataStore | None = None, settings: Settings = DEFAULT_SETTINGS | |
| ): | |
| self.settings = settings | |
| self.datastore = datastore or DataStore(settings=settings) | |
| self.match_index = MatchIndex(self.datastore) | |
| # ── team id lookup ──────────────────────────────────────────────────── | |
| def _team_ids(self, league: str | None = None, season: str | None = None) -> dict[str, str]: | |
| # Base: JSON de Segunda (histórico). Se completa/pisa con el mapa nombre→id del | |
| # dataset del modelo para la liga pedida, así funciona para todas las ligas. | |
| ids: dict[str, str] = {} | |
| try: | |
| ids.update(json.loads(vendor_env.team_ids_path().read_text(encoding="utf-8"))) | |
| except Exception: | |
| pass | |
| if league: | |
| try: | |
| from racing_reports import web_meta | |
| ids.update(web_meta.team_ids(league, season or "")) | |
| except Exception: | |
| pass | |
| return ids | |
| def _resolve_id(self, name: str, team_ids: dict[str, str]) -> str: | |
| if name in team_ids: | |
| return team_ids[name] | |
| target = _norm(name) | |
| for k, v in team_ids.items(): | |
| if _norm(k) == target: | |
| return v | |
| raise ValueError( | |
| f"No se encontró el ID del equipo {name!r}. " | |
| f"Equipos disponibles: {', '.join(sorted(team_ids))}" | |
| ) | |
| # ── orquestación ────────────────────────────────────────────────────── | |
| def _ordered_reports(self, report_types: list[str]) -> list[str]: | |
| seen = [r for r in report_types if r in VALID_REPORTS] | |
| # post_match siempre corre el pre_match del mismo partido antes. | |
| if "post_match" in seen and "pre_match" not in seen: | |
| seen = ["pre_match"] + seen | |
| order = {"pre_match": 0, "block_zscore": 1, "triple_multitag": 2, "triple_centros": 3, | |
| "recuperaciones": 4, "post_match": 5, "ejes_pre": 6, "ejes_post": 7} | |
| return sorted(dict.fromkeys(seen), key=lambda r: order[r]) | |
| def run(self, request: ReportRequest) -> ReportResult: | |
| report_types = self._ordered_reports(request.report_types) | |
| if not report_types: | |
| raise ValueError("No se pidió ningún reporte válido.") | |
| team_ids = self._team_ids(request.league, request.season) | |
| home_name = request.home_team | |
| away_name = request.away_team | |
| match_date = None | |
| resolved_match_id = request.match_id | |
| if request.match_id: | |
| # Con matchId: resolvemos home/away/fecha desde el dataset bundleado (web_meta), | |
| # NO del preprocessed (éste todavía no se descargó; prepare_vendor corre después). | |
| from racing_reports import web_meta | |
| match = web_meta.find_match( | |
| request.league, request.season, request.match_id | |
| ) | |
| home_name = match["home_team"] | |
| away_name = match["away_team"] | |
| match_date = match["date"] | |
| elif {"post_match", "ejes_post"} & set(report_types): | |
| # Partido reciente sin matchId en el dataset: usamos lo que mandó el front | |
| # (local/visitante/fecha). El matchId real se resuelve más abajo desde el | |
| # preprocessed (post_match) o desde los artefactos de ejes (ejes_post). | |
| home_name = request.home_team | |
| away_name = request.away_team | |
| match_date = request.match_date or None | |
| paths = self.datastore.prepare_vendor( | |
| report_types, request.league, request.season | |
| ) | |
| # El head-to-head pro (pre/post) lee team_match_zone_features.parquet, que en | |
| # el bundle es SOLO Segunda. Para cualquier liga lo reconstruimos del | |
| # preprocessed antes de generar, así funciona con todas las ligas del modelo. | |
| zone_build_err: Exception | None = None | |
| if {"pre_match", "post_match"} & set(report_types): | |
| try: | |
| # sync_preprocessed DESCARGA de Azure si no está en cache (require_ | |
| # preprocessed solo mira el cache). El pre-match no lo bajaba antes. | |
| pre_csv = self.datastore.sync_preprocessed( | |
| request.league, request.season | |
| ) | |
| gen_zones = vendor_env.patch_helper_paths( | |
| preprocessed_csv=pre_csv, matches_csv=paths["matches_csv"], | |
| league=request.league, season=request.season, | |
| ) | |
| zpath = vendor_env.DATA_DIR / "team_match_zone_features.parquet" | |
| if zpath.is_symlink(): | |
| zpath.unlink() | |
| gen_zones.build_zone_features_from_preprocessed() | |
| except Exception as exc: # se adjunta si el reporte luego falla | |
| zone_build_err = exc | |
| output_dir = self.settings.output_dir / slugify(request.league) / str( | |
| request.season | |
| ) / slugify(f"{home_name}_vs_{away_name}_{request.match_id or 'libre'}") | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| html_paths: dict[str, Path] = {} | |
| bundles: dict[str, ReportBundle] = {} | |
| for report_type in report_types: | |
| out_path = output_dir / f"{report_type}.html" | |
| if report_type == "pre_match": | |
| try: | |
| result = pre_match.generate( | |
| out_path=out_path, | |
| league=request.league, | |
| season=request.season, | |
| home_name=home_name, | |
| away_name=away_name, | |
| home_team_id=self._resolve_id(home_name, team_ids), | |
| away_team_id=self._resolve_id(away_name, team_ids), | |
| matches_csv=paths["matches_csv"], | |
| settings=self.settings, | |
| ) | |
| except Exception as exc: | |
| if zone_build_err is not None: | |
| raise ValueError( | |
| f"{exc} · Causa probable: no se reconstruyeron las " | |
| f"zone-features de {request.league} {request.season} " | |
| f"({zone_build_err})" | |
| ) from exc | |
| raise | |
| elif report_type == "block_zscore": | |
| bz = request.settings.get("block_zscore", {}) | |
| result = block_zscore.generate( | |
| out_path=out_path, | |
| labeled_csv=self.datastore.require_labeled( | |
| request.league, request.season | |
| ), | |
| team_a=bz.get("team_a", home_name), | |
| team_b=bz.get("team_b", away_name), | |
| date_from=bz.get("date_from"), | |
| date_to=bz.get("date_to"), | |
| venue_a=bz.get("venue_a", bz.get("venue", "both")), | |
| venue_b=bz.get("venue_b", bz.get("venue", "both")), | |
| ) | |
| elif report_type in ("triple_multitag", "triple_centros", "recuperaciones"): | |
| flt = request.settings.get("filters", {}) | |
| fn = { | |
| "triple_multitag": triple_multitag.generate, | |
| "triple_centros": triple_multitag.generate_centros, | |
| "recuperaciones": recuperaciones.generate, | |
| }[report_type] | |
| result = fn( | |
| out_path=out_path, | |
| # labeled: trae label_after_rules (bloque Opta+modelo). Cae al | |
| # base si no hay etiquetado; el base deriva el bloque de phaseLabel. | |
| preprocessed_csv=self.datastore.require_labeled( | |
| request.league, request.season | |
| ), | |
| home_name=home_name, | |
| away_name=away_name, | |
| date_from=flt.get("date_from"), | |
| date_to=flt.get("date_to"), | |
| venue_a=flt.get("venue_a", flt.get("venue", "both")), | |
| venue_b=flt.get("venue_b", flt.get("venue", "both")), | |
| ) | |
| elif report_type == "ejes_pre": | |
| result = ejes.generate_pre( | |
| out_path=out_path, | |
| league=request.league, | |
| season=str(request.season), | |
| home_name=home_name, | |
| away_name=away_name, | |
| ) | |
| elif report_type == "ejes_post": | |
| result = ejes.generate_post( | |
| out_path=out_path, | |
| league=request.league, | |
| season=str(request.season), | |
| home_name=home_name, | |
| away_name=away_name, | |
| match_id=request.match_id or None, | |
| match_date=match_date, | |
| ) | |
| elif report_type == "post_match": | |
| # Si el partido no traía matchId (reciente, no está en el dataset), | |
| # lo resolvemos del preprocessed ya descargado por local/visitante/fecha. | |
| if not resolved_match_id: | |
| info = self.match_index.find_by_teams( | |
| request.league, request.season, home_name, away_name, match_date | |
| ) | |
| if info is None: | |
| raise ValueError( | |
| f"No se encontró {home_name} vs {away_name}" | |
| + (f" ({match_date})" if match_date else "") | |
| + f" en el preprocessed de {request.league} {request.season}." | |
| ) | |
| resolved_match_id = info.match_id | |
| title = ( | |
| f"{home_name} vs {away_name}" | |
| + (f" — {match_date}" if match_date else "") | |
| ) | |
| result = post_match.generate( | |
| out_path=out_path, | |
| preprocessed_csv=self.datastore.require_preprocessed( | |
| request.league, request.season | |
| ), | |
| matches_csv=paths["matches_csv"], | |
| league=request.league, | |
| season=request.season, | |
| match_id=resolved_match_id, | |
| home_name=home_name, | |
| away_name=away_name, | |
| team_ids=team_ids, | |
| title=title, | |
| match_date=match_date, | |
| ) | |
| if isinstance(result, ReportBundle): | |
| bundles[report_type] = result | |
| html_paths[report_type] = result.html_path | |
| else: | |
| html_paths[report_type] = Path(result) | |
| manifest_path = output_dir / "manifest.json" | |
| write_json(manifest_path, self._manifest(request, report_types, html_paths, bundles)) | |
| return ReportResult( | |
| output_dir=output_dir, | |
| html_paths=html_paths, | |
| manifest_path=manifest_path, | |
| bundles=bundles, | |
| ) | |
| def _manifest( | |
| self, | |
| request: ReportRequest, | |
| report_types: list[str], | |
| html_paths: dict[str, Path], | |
| bundles: dict[str, ReportBundle], | |
| ) -> dict[str, Any]: | |
| outputs: dict[str, Any] = {} | |
| for name, path in html_paths.items(): | |
| entry: dict[str, Any] = {"html": str(path)} | |
| bundle = bundles.get(name) | |
| if bundle is not None: | |
| entry["figures"] = [ | |
| { | |
| "name": f.name, | |
| "title": f.title, | |
| "section": f.section, | |
| "png": str(f.png_path), | |
| "svg": str(f.svg_path) if f.svg_path else None, | |
| } | |
| for f in bundle.figures | |
| ] | |
| entry["tables"] = [ | |
| { | |
| "name": t.name, | |
| "title": t.title, | |
| "section": t.section, | |
| "csv": str(t.csv_path), | |
| } | |
| for t in bundle.tables | |
| ] | |
| entry["warnings"] = list(bundle.warnings) | |
| entry["meta"] = bundle.meta | |
| outputs[name] = entry | |
| return { | |
| "generated_at": utc_now_iso(), | |
| "git_commit": git_commit(Path.cwd()), | |
| "request": asdict(request), | |
| "report_types_run": report_types, | |
| "outputs": outputs, | |
| } | |
| def request_from_yaml(path: Path) -> ReportRequest: | |
| payload = yaml.safe_load(path.read_text(encoding="utf-8")) | |
| settings = _json_safe(payload.get("settings", {})) | |
| return ReportRequest( | |
| league=str(payload["league"]), | |
| season=str(payload["season"]), | |
| match_id=str(payload.get("match_id", "")), | |
| home_team=str(payload["home_team"]), | |
| away_team=str(payload["away_team"]), | |
| report_types=list(payload.get("report_types", [])), | |
| settings=dict(settings), | |
| ) | |
| def _json_safe(value): | |
| if isinstance(value, dict): | |
| return {k: _json_safe(v) for k, v in value.items()} | |
| if isinstance(value, list): | |
| return [_json_safe(v) for v in value] | |
| if hasattr(value, "isoformat"): | |
| return value.isoformat() | |
| return value | |