Spaces:
Running
Web: selector de liga/temporada (data completa) + post-partido funcional
Browse files- routes_meta dataset-driven: ligas/temporadas/equipos/partidos salen del dataset
bundleado, intersecados con lo que tiene preprocessed en Azure ("data completa",
auto-configurable). datastore.available_league_seasons() lista Azure.
- web_meta.py: provee teams/matches/resolve por liga-temporada desde el bundle
(subset ahora incluye is_home para armar home/away).
- home.html: dropdowns de liga/temporada que recargan equipos y partidos; card de
post-partido funcional (elegís un partido JUGADO -> genera). Solo se ofrecen
partidos reales => no se puede generar uno inexistente.
- post_match habilitado en el endpoint /run (gateado por NN + match_id requerido);
post_match.generate devuelve ReportBundle para verse en la UI moderna.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/racing_reports/api/routes_meta.py +70 -46
- src/racing_reports/api/routes_reports.py +6 -1
- src/racing_reports/datastore.py +25 -0
- src/racing_reports/reports/post_match.py +18 -2
- src/racing_reports/web/app.py +6 -4
- src/racing_reports/web/templates/home.html +150 -85
- src/racing_reports/web_meta.py +95 -0
- tests/test_web_app.py +20 -5
- vendor/data/modeling/attack_prediction_dataset.parquet +2 -2
|
@@ -1,35 +1,74 @@
|
|
| 1 |
-
"""Endpoints meta: ligas, equipos, partidos, salud.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import
|
| 6 |
from datetime import date
|
|
|
|
| 7 |
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
|
| 10 |
-
from racing_reports import data_cache,
|
| 11 |
from racing_reports.api.deps import require_auth
|
| 12 |
from racing_reports.api.schemas import HealthResponse, MatchCandidate
|
| 13 |
from racing_reports.config import DEFAULT_SETTINGS
|
| 14 |
from racing_reports.datastore import DataStore
|
| 15 |
-
from racing_reports.match_index import MatchIndex
|
| 16 |
from racing_reports.utils import git_commit
|
| 17 |
-
from pathlib import Path
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/api")
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
@router.get("/health", response_model=HealthResponse)
|
| 28 |
def health() -> HealthResponse:
|
| 29 |
-
|
| 30 |
ds = DataStore()
|
| 31 |
-
|
| 32 |
-
cached = data_cache.is_cached(pre_path)
|
| 33 |
return HealthResponse(
|
| 34 |
ok=True,
|
| 35 |
nn_enabled=DEFAULT_SETTINGS.enable_nn_predictions,
|
|
@@ -40,33 +79,28 @@ def health() -> HealthResponse:
|
|
| 40 |
|
| 41 |
@router.get("/leagues", dependencies=[Depends(require_auth)])
|
| 42 |
def leagues() -> list[str]:
|
| 43 |
-
return list(
|
| 44 |
|
| 45 |
|
| 46 |
@router.get("/seasons", dependencies=[Depends(require_auth)])
|
| 47 |
def seasons(league: str) -> list[str]:
|
| 48 |
-
|
|
|
|
| 49 |
raise HTTPException(status_code=404, detail=f"Liga desconocida: {league}")
|
| 50 |
-
return list(
|
| 51 |
|
| 52 |
|
| 53 |
@router.get("/teams", dependencies=[Depends(require_auth)])
|
| 54 |
def teams(league: str, season: str) -> list[str]:
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
return sorted(ids.keys())
|
| 60 |
|
| 61 |
|
| 62 |
@router.get("/matches", dependencies=[Depends(require_auth)])
|
| 63 |
def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
|
| 64 |
-
|
| 65 |
-
df = idx.build(league, season)
|
| 66 |
-
if played_only:
|
| 67 |
-
today = date.today().isoformat()
|
| 68 |
-
df = df[df["date"] <= today]
|
| 69 |
-
return df.sort_values("date", ascending=False).to_dict("records")
|
| 70 |
|
| 71 |
|
| 72 |
@router.get(
|
|
@@ -75,24 +109,14 @@ def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
|
|
| 75 |
dependencies=[Depends(require_auth)],
|
| 76 |
)
|
| 77 |
def resolve(league: str, season: str, team_a: str, team_b: str) -> list[MatchCandidate]:
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
out = []
|
| 87 |
-
for _, m in pair.iterrows():
|
| 88 |
-
status = "played" if str(m["date"]) and str(m["date"]) <= today else "future"
|
| 89 |
-
out.append(
|
| 90 |
-
MatchCandidate(
|
| 91 |
-
match_id=str(m["match_id"]),
|
| 92 |
-
date=str(m["date"] or ""),
|
| 93 |
-
home_team=str(m["home_team"]),
|
| 94 |
-
away_team=str(m["away_team"]),
|
| 95 |
-
status=status,
|
| 96 |
-
)
|
| 97 |
)
|
| 98 |
-
|
|
|
|
|
|
| 1 |
+
"""Endpoints meta: ligas, temporadas, equipos, partidos, salud.
|
| 2 |
+
|
| 3 |
+
Las ligas/temporadas salen del dataset de modelado bundleado (web_meta) y, si se
|
| 4 |
+
puede listar Azure, se filtran a las que tienen el preprocessed subido ("data
|
| 5 |
+
completa"). Así los selectores andan offline para todas las ligas del modelo y
|
| 6 |
+
sólo se ofrecen las que realmente pueden generar el reporte completo.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import time
|
| 12 |
from datetime import date
|
| 13 |
+
from pathlib import Path
|
| 14 |
|
| 15 |
from fastapi import APIRouter, Depends, HTTPException
|
| 16 |
|
| 17 |
+
from racing_reports import data_cache, web_meta
|
| 18 |
from racing_reports.api.deps import require_auth
|
| 19 |
from racing_reports.api.schemas import HealthResponse, MatchCandidate
|
| 20 |
from racing_reports.config import DEFAULT_SETTINGS
|
| 21 |
from racing_reports.datastore import DataStore
|
|
|
|
| 22 |
from racing_reports.utils import git_commit
|
|
|
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/api")
|
| 25 |
|
| 26 |
+
_AVAIL_TTL_SECONDS = 1800
|
| 27 |
+
_avail_cache: dict[str, object] = {"ts": 0.0, "value": None}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _league_seasons() -> dict[str, list[str]]:
|
| 31 |
+
"""{liga: [temporadas]} ofrecidas: dataset ∩ (disponible en Azure si se sabe)."""
|
| 32 |
+
in_dataset = web_meta.league_seasons() if web_meta.has_dataset() else {}
|
| 33 |
+
if not in_dataset:
|
| 34 |
+
return {}
|
| 35 |
+
now = time.time()
|
| 36 |
+
if now - float(_avail_cache["ts"]) > _AVAIL_TTL_SECONDS:
|
| 37 |
+
try:
|
| 38 |
+
_avail_cache["value"] = DataStore().available_league_seasons()
|
| 39 |
+
except Exception:
|
| 40 |
+
_avail_cache["value"] = None
|
| 41 |
+
_avail_cache["ts"] = now
|
| 42 |
+
avail = _avail_cache["value"]
|
| 43 |
+
if not avail:
|
| 44 |
+
# No se pudo listar Azure (dev / sin credenciales): ofrecer todo el dataset.
|
| 45 |
+
return in_dataset
|
| 46 |
+
out: dict[str, list[str]] = {}
|
| 47 |
+
for league, seasons in in_dataset.items():
|
| 48 |
+
a = avail.get(league)
|
| 49 |
+
if not a:
|
| 50 |
+
continue
|
| 51 |
+
keep = [s for s in seasons if s in a]
|
| 52 |
+
if keep:
|
| 53 |
+
out[league] = keep
|
| 54 |
+
# Si la intersección quedó vacía (p.ej. naming distinto), no dejar al usuario
|
| 55 |
+
# sin nada: caer al dataset completo.
|
| 56 |
+
return out or in_dataset
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _default_league_season() -> tuple[str, str]:
|
| 60 |
+
ls = _league_seasons()
|
| 61 |
+
if not ls:
|
| 62 |
+
return ("Spanish Segunda Division", "25-26")
|
| 63 |
+
league = "Spanish Segunda Division" if "Spanish Segunda Division" in ls else next(iter(ls))
|
| 64 |
+
return (league, ls[league][0])
|
| 65 |
|
| 66 |
|
| 67 |
@router.get("/health", response_model=HealthResponse)
|
| 68 |
def health() -> HealthResponse:
|
| 69 |
+
league, season = _default_league_season()
|
| 70 |
ds = DataStore()
|
| 71 |
+
cached = data_cache.is_cached(ds.preprocessed_path(league, season))
|
|
|
|
| 72 |
return HealthResponse(
|
| 73 |
ok=True,
|
| 74 |
nn_enabled=DEFAULT_SETTINGS.enable_nn_predictions,
|
|
|
|
| 79 |
|
| 80 |
@router.get("/leagues", dependencies=[Depends(require_auth)])
|
| 81 |
def leagues() -> list[str]:
|
| 82 |
+
return list(_league_seasons().keys())
|
| 83 |
|
| 84 |
|
| 85 |
@router.get("/seasons", dependencies=[Depends(require_auth)])
|
| 86 |
def seasons(league: str) -> list[str]:
|
| 87 |
+
ls = _league_seasons()
|
| 88 |
+
if league not in ls:
|
| 89 |
raise HTTPException(status_code=404, detail=f"Liga desconocida: {league}")
|
| 90 |
+
return list(ls[league])
|
| 91 |
|
| 92 |
|
| 93 |
@router.get("/teams", dependencies=[Depends(require_auth)])
|
| 94 |
def teams(league: str, season: str) -> list[str]:
|
| 95 |
+
names = web_meta.teams(league, season)
|
| 96 |
+
if not names:
|
| 97 |
+
raise HTTPException(status_code=404, detail=f"Sin equipos para {league} {season}")
|
| 98 |
+
return names
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
@router.get("/matches", dependencies=[Depends(require_auth)])
|
| 102 |
def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
|
| 103 |
+
return web_meta.matches(league, season, played_only=played_only)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
@router.get(
|
|
|
|
| 109 |
dependencies=[Depends(require_auth)],
|
| 110 |
)
|
| 111 |
def resolve(league: str, season: str, team_a: str, team_b: str) -> list[MatchCandidate]:
|
| 112 |
+
pairs = web_meta.resolve_pair(league, season, team_a, team_b)
|
| 113 |
+
return [
|
| 114 |
+
MatchCandidate(
|
| 115 |
+
match_id=m["match_id"],
|
| 116 |
+
date=m["date"],
|
| 117 |
+
home_team=m["home_team"],
|
| 118 |
+
away_team=m["away_team"],
|
| 119 |
+
status=m["status"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
)
|
| 121 |
+
for m in pairs
|
| 122 |
+
]
|
|
@@ -14,7 +14,7 @@ from racing_reports.runner import ReportRunner
|
|
| 14 |
|
| 15 |
router = APIRouter(prefix="/api/reports")
|
| 16 |
|
| 17 |
-
SUPPORTED_REPORTS = {"pre_match", "block_zscore"} # post_match
|
| 18 |
|
| 19 |
|
| 20 |
@router.get(
|
|
@@ -60,6 +60,11 @@ def run(report_type: str, payload: RunRequest) -> RunResponse:
|
|
| 60 |
raise HTTPException(status_code=404, detail=f"Reporte desconocido: {report_type}")
|
| 61 |
if not payload.team_a or not payload.team_b:
|
| 62 |
raise HTTPException(status_code=400, detail="team_a y team_b son requeridos.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
settings_blob = {}
|
| 65 |
if report_type == "block_zscore":
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter(prefix="/api/reports")
|
| 16 |
|
| 17 |
+
SUPPORTED_REPORTS = {"pre_match", "block_zscore", "post_match"} # post_match requiere NN
|
| 18 |
|
| 19 |
|
| 20 |
@router.get(
|
|
|
|
| 60 |
raise HTTPException(status_code=404, detail=f"Reporte desconocido: {report_type}")
|
| 61 |
if not payload.team_a or not payload.team_b:
|
| 62 |
raise HTTPException(status_code=400, detail="team_a y team_b son requeridos.")
|
| 63 |
+
if report_type == "post_match" and not payload.match_id:
|
| 64 |
+
raise HTTPException(
|
| 65 |
+
status_code=400,
|
| 66 |
+
detail="Para el post-partido elegí un partido jugado (match_id requerido).",
|
| 67 |
+
)
|
| 68 |
|
| 69 |
settings_blob = {}
|
| 70 |
if report_type == "block_zscore":
|
|
@@ -109,6 +109,31 @@ class DataStore:
|
|
| 109 |
)
|
| 110 |
return service.get_file_system_client(self.settings.azure_filesystem)
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def _sync_named_csv(self, league: str, season: str, name: str, force: bool) -> Path:
|
| 113 |
local = self._csv_path(league, season, name)
|
| 114 |
if local.exists() and not force:
|
|
|
|
| 109 |
)
|
| 110 |
return service.get_file_system_client(self.settings.azure_filesystem)
|
| 111 |
|
| 112 |
+
def available_league_seasons(self) -> dict[str, list[str]] | None:
|
| 113 |
+
"""{liga: [temporadas]} que tienen el preprocessed subido en Azure.
|
| 114 |
+
|
| 115 |
+
Devuelve None si no se puede listar (sin credenciales / dev local / error):
|
| 116 |
+
el caller debe interpretar None como "desconocido, no filtrar".
|
| 117 |
+
"""
|
| 118 |
+
if self.local_data_dir is not None:
|
| 119 |
+
return None
|
| 120 |
+
name = self.settings.preprocessed_name
|
| 121 |
+
root = self.settings.azure_preprocessed_root.strip("/")
|
| 122 |
+
try:
|
| 123 |
+
fs = self._filesystem_client()
|
| 124 |
+
found: dict[str, set[str]] = {}
|
| 125 |
+
for p in fs.get_paths(path=root, recursive=True):
|
| 126 |
+
path_name = getattr(p, "name", "") or ""
|
| 127 |
+
rel = path_name[len(root):].lstrip("/") if path_name.startswith(root) else path_name
|
| 128 |
+
parts = rel.split("/")
|
| 129 |
+
if len(parts) >= 3 and parts[-1] == name:
|
| 130 |
+
found.setdefault(parts[-3], set()).add(parts[-2])
|
| 131 |
+
if not found:
|
| 132 |
+
return None
|
| 133 |
+
return {lg: sorted(ss, reverse=True) for lg, ss in sorted(found.items())}
|
| 134 |
+
except Exception:
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
def _sync_named_csv(self, league: str, season: str, name: str, force: bool) -> Path:
|
| 138 |
local = self._csv_path(league, season, name)
|
| 139 |
if local.exists() and not force:
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
| 12 |
import pandas as pd
|
| 13 |
|
| 14 |
from racing_reports import vendor_env
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class MatchNotPlayedError(RuntimeError):
|
|
@@ -31,7 +32,7 @@ def generate(
|
|
| 31 |
title: str,
|
| 32 |
season: str | None = None,
|
| 33 |
match_date: str | None = None,
|
| 34 |
-
) ->
|
| 35 |
gen = vendor_env.patch_helper_paths(
|
| 36 |
preprocessed_csv=preprocessed_csv, matches_csv=matches_csv
|
| 37 |
)
|
|
@@ -82,4 +83,19 @@ def generate(
|
|
| 82 |
section = pm.build_match_section(match_cfg, df_model, df_match, zone_df, league)
|
| 83 |
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 84 |
pm.build_html([section], out_path, match_cfg["title"])
|
| 85 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import pandas as pd
|
| 13 |
|
| 14 |
from racing_reports import vendor_env
|
| 15 |
+
from racing_reports.reports.bundle import ReportBundle
|
| 16 |
|
| 17 |
|
| 18 |
class MatchNotPlayedError(RuntimeError):
|
|
|
|
| 32 |
title: str,
|
| 33 |
season: str | None = None,
|
| 34 |
match_date: str | None = None,
|
| 35 |
+
) -> ReportBundle:
|
| 36 |
gen = vendor_env.patch_helper_paths(
|
| 37 |
preprocessed_csv=preprocessed_csv, matches_csv=matches_csv
|
| 38 |
)
|
|
|
|
| 83 |
section = pm.build_match_section(match_cfg, df_model, df_match, zone_df, league)
|
| 84 |
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
pm.build_html([section], out_path, match_cfg["title"])
|
| 86 |
+
return ReportBundle(
|
| 87 |
+
report_type="post_match",
|
| 88 |
+
html_path=out_path,
|
| 89 |
+
figures=[],
|
| 90 |
+
tables=[],
|
| 91 |
+
warnings=["Las 7 figuras del post-partido están embebidas: abrí 'Ver HTML completo'."],
|
| 92 |
+
meta={
|
| 93 |
+
"league": league,
|
| 94 |
+
"season": season or "",
|
| 95 |
+
"match_id": str(match_id),
|
| 96 |
+
"home_name": home_name,
|
| 97 |
+
"away_name": away_name,
|
| 98 |
+
"home_goals": home_goals,
|
| 99 |
+
"away_goals": away_goals,
|
| 100 |
+
},
|
| 101 |
+
)
|
|
@@ -95,17 +95,19 @@ def login(request: Request, password: str = Form(...)):
|
|
| 95 |
|
| 96 |
# ── home (SPA nuevo) ──────────────────────────────────────────────────────
|
| 97 |
@app.get("/", response_class=HTMLResponse)
|
| 98 |
-
def home(request: Request, league: str =
|
| 99 |
if not _password_ok(request):
|
| 100 |
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
|
|
|
|
|
|
| 101 |
return templates.TemplateResponse(
|
| 102 |
request,
|
| 103 |
"home.html",
|
| 104 |
{
|
| 105 |
"request": request,
|
| 106 |
-
"league": league,
|
| 107 |
-
"season": season,
|
| 108 |
-
"teams": _team_list(),
|
| 109 |
"nn_enabled": DEFAULT_SETTINGS.enable_nn_predictions,
|
| 110 |
},
|
| 111 |
)
|
|
|
|
| 95 |
|
| 96 |
# ── home (SPA nuevo) ──────────────────────────────────────────────────────
|
| 97 |
@app.get("/", response_class=HTMLResponse)
|
| 98 |
+
def home(request: Request, league: str | None = None, season: str | None = None):
|
| 99 |
if not _password_ok(request):
|
| 100 |
return RedirectResponse(url="/login", status_code=303)
|
| 101 |
+
from racing_reports.api.routes_meta import _default_league_season
|
| 102 |
+
|
| 103 |
+
def_league, def_season = _default_league_season()
|
| 104 |
return templates.TemplateResponse(
|
| 105 |
request,
|
| 106 |
"home.html",
|
| 107 |
{
|
| 108 |
"request": request,
|
| 109 |
+
"league": league or def_league,
|
| 110 |
+
"season": season or def_season,
|
|
|
|
| 111 |
"nn_enabled": DEFAULT_SETTINGS.enable_nn_predictions,
|
| 112 |
},
|
| 113 |
)
|
|
@@ -21,7 +21,6 @@
|
|
| 21 |
<style>
|
| 22 |
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
| 23 |
[x-cloak] { display: none !important; }
|
| 24 |
-
/* contenido de tablas preview embebido vía x-html */
|
| 25 |
.table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
|
| 26 |
.table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
|
| 27 |
.table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
|
|
@@ -31,16 +30,26 @@
|
|
| 31 |
|
| 32 |
<!-- Header -->
|
| 33 |
<header class="bg-gradient-to-r from-racing-700 to-ink-900 border-b border-racing-700/50">
|
| 34 |
-
<div class="max-w-6xl mx-auto px-4 py-5 flex items-center justify-between">
|
| 35 |
<div>
|
| 36 |
<h1 class="text-2xl font-bold">⚽ Reportes Racing</h1>
|
| 37 |
-
<p class="text-sm text-racing-200">
|
| 38 |
</div>
|
| 39 |
<div class="flex gap-3 items-center text-sm">
|
| 40 |
-
<
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
</header>
|
|
@@ -49,18 +58,18 @@
|
|
| 49 |
|
| 50 |
<!-- Setup -->
|
| 51 |
<section class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
|
| 52 |
-
<h2 class="text-lg font-semibold text-racing-500 mb-4">1.
|
| 53 |
<div class="grid sm:grid-cols-2 gap-4">
|
| 54 |
<div>
|
| 55 |
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo A</label>
|
| 56 |
<select x-model="teamA" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
|
| 57 |
-
|
| 58 |
</select>
|
| 59 |
</div>
|
| 60 |
<div>
|
| 61 |
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo B</label>
|
| 62 |
<select x-model="teamB" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
|
| 63 |
-
|
| 64 |
</select>
|
| 65 |
</div>
|
| 66 |
</div>
|
|
@@ -76,7 +85,8 @@
|
|
| 76 |
<div class="flex items-start justify-between mb-3 gap-4">
|
| 77 |
<div>
|
| 78 |
<h3 class="font-semibold">Reporte previo (head-to-head)</h3>
|
| 79 |
-
<p class="text-xs text-gray-400 mt-1">
|
|
|
|
| 80 |
</div>
|
| 81 |
</div>
|
| 82 |
<div class="flex flex-wrap gap-2">
|
|
@@ -90,15 +100,14 @@
|
|
| 90 |
<span x-show="reports.pre_match.catalogOpen">Ocultar catálogo</span>
|
| 91 |
</button>
|
| 92 |
</div>
|
| 93 |
-
<div x-show="reports.pre_match.catalogOpen" class="mt-4 grid sm:grid-cols-3 gap-2"
|
| 94 |
-
x-transition.duration.150ms>
|
| 95 |
<template x-for="fig in reports.pre_match.catalog?.figures || []" :key="fig.name">
|
| 96 |
<button @click="runOne('pre_match', fig.name)"
|
| 97 |
-
:disabled="fig.requires_nn"
|
| 98 |
-
:class="fig.requires_nn ? 'opacity-40 cursor-not-allowed' : 'hover:border-racing-500'"
|
| 99 |
class="text-left bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-xs">
|
| 100 |
<div class="font-medium" x-text="fig.title"></div>
|
| 101 |
-
<div class="text-gray-400 mt-1" x-text="fig.section + (fig.requires_nn ? ' · NN (no disponible)' : '')"></div>
|
| 102 |
</button>
|
| 103 |
</template>
|
| 104 |
</div>
|
|
@@ -144,10 +153,43 @@
|
|
| 144 |
</div>
|
| 145 |
</article>
|
| 146 |
|
| 147 |
-
<!-- Post-match card
|
| 148 |
-
<article class="bg-ink-700 border border-ink-600 rounded-2xl p-5 opacity-60">
|
| 149 |
-
<
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</article>
|
| 152 |
</section>
|
| 153 |
|
|
@@ -155,7 +197,6 @@
|
|
| 155 |
<section x-show="currentJob" class="space-y-4" x-transition.duration.200ms>
|
| 156 |
<h2 class="text-lg font-semibold text-racing-500">3. Resultados</h2>
|
| 157 |
|
| 158 |
-
<!-- Progreso -->
|
| 159 |
<div x-show="jobStatus && jobStatus.status !== 'done' && jobStatus.status !== 'error'"
|
| 160 |
class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
|
| 161 |
<div class="flex items-center gap-3">
|
|
@@ -166,21 +207,16 @@
|
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
<p class="text-xs text-gray-500 mt-3" x-show="!preprocessedCached">
|
| 169 |
-
Primera corrida después de un cold start: tarda ~3-5 min (baja el preprocessed
|
| 170 |
-
</p>
|
| 171 |
-
<p class="text-xs text-gray-500 mt-3" x-show="preprocessedCached">
|
| 172 |
-
Datos ya en RAM. Cada reporte tarda ~5-15 s.
|
| 173 |
</p>
|
|
|
|
| 174 |
</div>
|
| 175 |
|
| 176 |
-
<
|
| 177 |
-
<div x-show="jobStatus?.status === 'error'"
|
| 178 |
-
class="bg-red-950/40 border border-red-700/40 rounded-2xl p-5">
|
| 179 |
<div class="font-medium text-red-300">Falló la generación</div>
|
| 180 |
<pre class="text-xs text-red-200 mt-2 whitespace-pre-wrap" x-text="jobStatus?.error"></pre>
|
| 181 |
</div>
|
| 182 |
|
| 183 |
-
<!-- Done -->
|
| 184 |
<template x-if="jobStatus?.status === 'done'">
|
| 185 |
<div class="space-y-4">
|
| 186 |
<template x-for="(bundle, reportType) in jobStatus.bundles" :key="reportType">
|
|
@@ -202,16 +238,12 @@
|
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
|
| 205 |
-
<!-- Warnings -->
|
| 206 |
<template x-if="bundle.warnings && bundle.warnings.length">
|
| 207 |
<div class="bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3 mb-4 text-xs text-yellow-200">
|
| 208 |
-
<template x-for="w in bundle.warnings" :key="w">
|
| 209 |
-
<div x-text="w"></div>
|
| 210 |
-
</template>
|
| 211 |
</div>
|
| 212 |
</template>
|
| 213 |
|
| 214 |
-
<!-- Filtro visual: mostrar sólo la seleccionada si vino de runOne -->
|
| 215 |
<div class="grid sm:grid-cols-2 gap-4">
|
| 216 |
<template x-for="fig in visibleFigures(reportType, bundle.figures)" :key="fig.name">
|
| 217 |
<figure class="bg-ink-800 border border-ink-600 rounded-xl p-3">
|
|
@@ -230,7 +262,6 @@
|
|
| 230 |
</template>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
-
<!-- Tablas -->
|
| 234 |
<template x-if="bundle.tables.length">
|
| 235 |
<div class="mt-6 space-y-3">
|
| 236 |
<template x-for="table in bundle.tables" :key="table.name">
|
|
@@ -240,9 +271,7 @@
|
|
| 240 |
<span class="font-medium" x-text="table.title"></span>
|
| 241 |
<span class="text-xs text-gray-400 ml-2" x-text="table.section"></span>
|
| 242 |
</div>
|
| 243 |
-
<a :href="table.csv_url" download
|
| 244 |
-
@click.stop
|
| 245 |
-
class="text-racing-500 hover:underline text-xs">Descargar CSV</a>
|
| 246 |
</summary>
|
| 247 |
<div class="px-4 pb-4 overflow-x-auto table-preview" x-html="table.preview_html"></div>
|
| 248 |
</details>
|
|
@@ -266,8 +295,13 @@
|
|
| 266 |
return {
|
| 267 |
league: {{ league|tojson }},
|
| 268 |
season: {{ season|tojson }},
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
dateFrom: '',
|
| 272 |
dateTo: '',
|
| 273 |
nnEnabled: {{ ('true' if nn_enabled else 'false') }},
|
|
@@ -278,27 +312,63 @@
|
|
| 278 |
},
|
| 279 |
currentJob: null,
|
| 280 |
jobStatus: null,
|
| 281 |
-
onlyFigure: null,
|
| 282 |
pollTimer: null,
|
| 283 |
|
| 284 |
-
init() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
async _refreshHealth() {
|
| 287 |
try {
|
| 288 |
const r = await fetch('/api/health');
|
| 289 |
-
if (r.ok) {
|
| 290 |
-
const d = await r.json();
|
| 291 |
-
this.preprocessedCached = !!d.preprocessed_cached;
|
| 292 |
-
}
|
| 293 |
} catch (_e) {}
|
| 294 |
},
|
| 295 |
|
| 296 |
reportTitle(id) {
|
| 297 |
-
return ({
|
| 298 |
-
pre_match: 'Reporte previo (head-to-head)',
|
| 299 |
-
block_zscore: 'Bloques (z-score)',
|
| 300 |
-
post_match: 'Reporte post-partido',
|
| 301 |
-
})[id] || id;
|
| 302 |
},
|
| 303 |
|
| 304 |
visibleFigures(reportType, figs) {
|
|
@@ -310,51 +380,50 @@
|
|
| 310 |
const r = this.reports[reportId];
|
| 311 |
r.catalogOpen = !r.catalogOpen;
|
| 312 |
if (r.catalogOpen && !r.catalog) {
|
| 313 |
-
try {
|
| 314 |
-
|
| 315 |
-
r.catalog = await resp.json();
|
| 316 |
-
} catch (e) {
|
| 317 |
-
alert('Error cargando catálogo: ' + e);
|
| 318 |
-
r.catalogOpen = false;
|
| 319 |
-
}
|
| 320 |
}
|
| 321 |
},
|
| 322 |
|
| 323 |
-
async runFull(reportId)
|
| 324 |
async runOne(reportId, figName) { await this._run(reportId, figName); },
|
| 325 |
|
| 326 |
async _run(reportId, figName) {
|
| 327 |
if (!this.teamA || !this.teamB) { alert('Elegí equipo A y equipo B.'); return; }
|
| 328 |
if (this.teamA === this.teamB) { alert('Equipo A y B deben ser distintos.'); return; }
|
| 329 |
-
|
| 330 |
-
league: this.league,
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
team_b: this.teamB,
|
| 334 |
-
filters: {
|
| 335 |
-
date_from: this.dateFrom || null,
|
| 336 |
-
date_to: this.dateTo || null,
|
| 337 |
-
},
|
| 338 |
only: figName ? [figName] : [],
|
| 339 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
let resp;
|
| 341 |
try {
|
| 342 |
resp = await fetch(`/api/reports/${reportId}/run`, {
|
| 343 |
-
method: 'POST',
|
| 344 |
-
headers: { 'content-type': 'application/json' },
|
| 345 |
-
body: JSON.stringify(body),
|
| 346 |
});
|
| 347 |
-
} catch (e) {
|
| 348 |
-
alert('Error de red: ' + e); return;
|
| 349 |
-
}
|
| 350 |
if (!resp.ok) {
|
| 351 |
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 352 |
-
alert('Error: ' + err.detail);
|
| 353 |
-
return;
|
| 354 |
}
|
| 355 |
const { job_id } = await resp.json();
|
| 356 |
this.onlyFigure = figName;
|
| 357 |
-
this._startPolling(job_id,
|
| 358 |
},
|
| 359 |
|
| 360 |
_startPolling(jobId, title) {
|
|
@@ -363,9 +432,7 @@
|
|
| 363 |
if (this.pollTimer) clearInterval(this.pollTimer);
|
| 364 |
this.pollTimer = setInterval(() => this._poll(), 2000);
|
| 365 |
this._poll();
|
| 366 |
-
setTimeout(() => {
|
| 367 |
-
document.querySelector('section > h2')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 368 |
-
}, 100);
|
| 369 |
},
|
| 370 |
|
| 371 |
async _poll() {
|
|
@@ -375,11 +442,9 @@
|
|
| 375 |
if (!r.ok) return;
|
| 376 |
this.jobStatus = await r.json();
|
| 377 |
if (['done', 'error'].includes(this.jobStatus.status)) {
|
| 378 |
-
clearInterval(this.pollTimer);
|
| 379 |
-
this.pollTimer = null;
|
| 380 |
-
this._refreshHealth(); // cache RAM probablemente cambió
|
| 381 |
}
|
| 382 |
-
} catch (_e) {
|
| 383 |
},
|
| 384 |
};
|
| 385 |
}
|
|
|
|
| 21 |
<style>
|
| 22 |
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
| 23 |
[x-cloak] { display: none !important; }
|
|
|
|
| 24 |
.table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
|
| 25 |
.table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
|
| 26 |
.table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
|
|
|
|
| 30 |
|
| 31 |
<!-- Header -->
|
| 32 |
<header class="bg-gradient-to-r from-racing-700 to-ink-900 border-b border-racing-700/50">
|
| 33 |
+
<div class="max-w-6xl mx-auto px-4 py-5 flex items-center justify-between flex-wrap gap-3">
|
| 34 |
<div>
|
| 35 |
<h1 class="text-2xl font-bold">⚽ Reportes Racing</h1>
|
| 36 |
+
<p class="text-sm text-racing-200">Previo, bloques y post-partido · data en Azure, reportes on-demand</p>
|
| 37 |
</div>
|
| 38 |
<div class="flex gap-3 items-center text-sm">
|
| 39 |
+
<div>
|
| 40 |
+
<label class="block text-[10px] uppercase tracking-wide text-gray-400">Liga</label>
|
| 41 |
+
<select x-model="league" @change="onLeagueChange()"
|
| 42 |
+
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-1.5 min-w-[180px]">
|
| 43 |
+
<template x-for="lg in leagues" :key="lg"><option :value="lg" x-text="lg"></option></template>
|
| 44 |
+
</select>
|
| 45 |
+
</div>
|
| 46 |
+
<div>
|
| 47 |
+
<label class="block text-[10px] uppercase tracking-wide text-gray-400">Temporada</label>
|
| 48 |
+
<select x-model="season" @change="onSeasonChange()"
|
| 49 |
+
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-1.5">
|
| 50 |
+
<template x-for="s in seasons" :key="s"><option :value="s" x-text="s"></option></template>
|
| 51 |
+
</select>
|
| 52 |
+
</div>
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
</header>
|
|
|
|
| 58 |
|
| 59 |
<!-- Setup -->
|
| 60 |
<section class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
|
| 61 |
+
<h2 class="text-lg font-semibold text-racing-500 mb-4">1. Equipos (previo / bloques)</h2>
|
| 62 |
<div class="grid sm:grid-cols-2 gap-4">
|
| 63 |
<div>
|
| 64 |
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo A</label>
|
| 65 |
<select x-model="teamA" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
|
| 66 |
+
<template x-for="t in teams" :key="t"><option :value="t" x-text="t"></option></template>
|
| 67 |
</select>
|
| 68 |
</div>
|
| 69 |
<div>
|
| 70 |
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo B</label>
|
| 71 |
<select x-model="teamB" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
|
| 72 |
+
<template x-for="t in teams" :key="t"><option :value="t" x-text="t"></option></template>
|
| 73 |
</select>
|
| 74 |
</div>
|
| 75 |
</div>
|
|
|
|
| 85 |
<div class="flex items-start justify-between mb-3 gap-4">
|
| 86 |
<div>
|
| 87 |
<h3 class="font-semibold">Reporte previo (head-to-head)</h3>
|
| 88 |
+
<p class="text-xs text-gray-400 mt-1">Zonas, presión, win/loss, local/visitante. Predicción del modelo de ataque
|
| 89 |
+
<span x-show="nnEnabled" class="text-racing-200">activada</span><span x-show="!nnEnabled">deshabilitada (falta RR_ENABLE_NN_PREDICTIONS=1)</span>.</p>
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
<div class="flex flex-wrap gap-2">
|
|
|
|
| 100 |
<span x-show="reports.pre_match.catalogOpen">Ocultar catálogo</span>
|
| 101 |
</button>
|
| 102 |
</div>
|
| 103 |
+
<div x-show="reports.pre_match.catalogOpen" class="mt-4 grid sm:grid-cols-3 gap-2" x-transition.duration.150ms>
|
|
|
|
| 104 |
<template x-for="fig in reports.pre_match.catalog?.figures || []" :key="fig.name">
|
| 105 |
<button @click="runOne('pre_match', fig.name)"
|
| 106 |
+
:disabled="fig.requires_nn && !nnEnabled"
|
| 107 |
+
:class="(fig.requires_nn && !nnEnabled) ? 'opacity-40 cursor-not-allowed' : 'hover:border-racing-500'"
|
| 108 |
class="text-left bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-xs">
|
| 109 |
<div class="font-medium" x-text="fig.title"></div>
|
| 110 |
+
<div class="text-gray-400 mt-1" x-text="fig.section + (fig.requires_nn && !nnEnabled ? ' · NN (no disponible)' : '')"></div>
|
| 111 |
</button>
|
| 112 |
</template>
|
| 113 |
</div>
|
|
|
|
| 153 |
</div>
|
| 154 |
</article>
|
| 155 |
|
| 156 |
+
<!-- Post-match card -->
|
| 157 |
+
<article class="bg-ink-700 border border-ink-600 rounded-2xl p-5" :class="nnEnabled ? '' : 'opacity-60'">
|
| 158 |
+
<div class="flex items-start justify-between mb-3 gap-4">
|
| 159 |
+
<div>
|
| 160 |
+
<h3 class="font-semibold">Reporte post-partido</h3>
|
| 161 |
+
<p class="text-xs text-gray-400 mt-1">Elegí un partido <strong>ya jugado</strong>: predicción pre-partido (point-in-time) vs lo que ocurrió, por zona.</p>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<template x-if="!nnEnabled">
|
| 166 |
+
<p class="text-xs text-yellow-200 bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3">
|
| 167 |
+
Deshabilitado: falta la variable <code>RR_ENABLE_NN_PREDICTIONS=1</code> en el Space.
|
| 168 |
+
</p>
|
| 169 |
+
</template>
|
| 170 |
+
|
| 171 |
+
<template x-if="nnEnabled">
|
| 172 |
+
<div>
|
| 173 |
+
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Partido jugado</label>
|
| 174 |
+
<select x-model="selectedMatchId"
|
| 175 |
+
class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 mb-3">
|
| 176 |
+
<option value="">— Elegí un partido —</option>
|
| 177 |
+
<template x-for="m in playedMatches" :key="m.match_id">
|
| 178 |
+
<option :value="m.match_id" x-text="`${m.date} · ${m.home_team} vs ${m.away_team}`"></option>
|
| 179 |
+
</template>
|
| 180 |
+
</select>
|
| 181 |
+
<div class="flex items-center gap-3">
|
| 182 |
+
<button @click="runPostMatch()"
|
| 183 |
+
:disabled="!selectedMatchId"
|
| 184 |
+
:class="!selectedMatchId ? 'opacity-40 cursor-not-allowed' : 'hover:bg-racing-600'"
|
| 185 |
+
class="bg-racing-500 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">
|
| 186 |
+
Generar post-partido
|
| 187 |
+
</button>
|
| 188 |
+
<span class="text-xs text-gray-400" x-show="!playedMatches.length">No hay partidos jugados para esta liga/temporada.</span>
|
| 189 |
+
<span class="text-xs text-gray-400" x-show="playedMatches.length" x-text="`${playedMatches.length} partidos jugados disponibles`"></span>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</template>
|
| 193 |
</article>
|
| 194 |
</section>
|
| 195 |
|
|
|
|
| 197 |
<section x-show="currentJob" class="space-y-4" x-transition.duration.200ms>
|
| 198 |
<h2 class="text-lg font-semibold text-racing-500">3. Resultados</h2>
|
| 199 |
|
|
|
|
| 200 |
<div x-show="jobStatus && jobStatus.status !== 'done' && jobStatus.status !== 'error'"
|
| 201 |
class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
|
| 202 |
<div class="flex items-center gap-3">
|
|
|
|
| 207 |
</div>
|
| 208 |
</div>
|
| 209 |
<p class="text-xs text-gray-500 mt-3" x-show="!preprocessedCached">
|
| 210 |
+
Primera corrida después de un cold start: tarda ~3-5 min (baja el preprocessed y lo deja en RAM).
|
|
|
|
|
|
|
|
|
|
| 211 |
</p>
|
| 212 |
+
<p class="text-xs text-gray-500 mt-3" x-show="preprocessedCached">Datos ya en RAM. Cada reporte tarda ~5-15 s.</p>
|
| 213 |
</div>
|
| 214 |
|
| 215 |
+
<div x-show="jobStatus?.status === 'error'" class="bg-red-950/40 border border-red-700/40 rounded-2xl p-5">
|
|
|
|
|
|
|
| 216 |
<div class="font-medium text-red-300">Falló la generación</div>
|
| 217 |
<pre class="text-xs text-red-200 mt-2 whitespace-pre-wrap" x-text="jobStatus?.error"></pre>
|
| 218 |
</div>
|
| 219 |
|
|
|
|
| 220 |
<template x-if="jobStatus?.status === 'done'">
|
| 221 |
<div class="space-y-4">
|
| 222 |
<template x-for="(bundle, reportType) in jobStatus.bundles" :key="reportType">
|
|
|
|
| 238 |
</div>
|
| 239 |
</div>
|
| 240 |
|
|
|
|
| 241 |
<template x-if="bundle.warnings && bundle.warnings.length">
|
| 242 |
<div class="bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3 mb-4 text-xs text-yellow-200">
|
| 243 |
+
<template x-for="w in bundle.warnings" :key="w"><div x-text="w"></div></template>
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
</template>
|
| 246 |
|
|
|
|
| 247 |
<div class="grid sm:grid-cols-2 gap-4">
|
| 248 |
<template x-for="fig in visibleFigures(reportType, bundle.figures)" :key="fig.name">
|
| 249 |
<figure class="bg-ink-800 border border-ink-600 rounded-xl p-3">
|
|
|
|
| 262 |
</template>
|
| 263 |
</div>
|
| 264 |
|
|
|
|
| 265 |
<template x-if="bundle.tables.length">
|
| 266 |
<div class="mt-6 space-y-3">
|
| 267 |
<template x-for="table in bundle.tables" :key="table.name">
|
|
|
|
| 271 |
<span class="font-medium" x-text="table.title"></span>
|
| 272 |
<span class="text-xs text-gray-400 ml-2" x-text="table.section"></span>
|
| 273 |
</div>
|
| 274 |
+
<a :href="table.csv_url" download @click.stop class="text-racing-500 hover:underline text-xs">Descargar CSV</a>
|
|
|
|
|
|
|
| 275 |
</summary>
|
| 276 |
<div class="px-4 pb-4 overflow-x-auto table-preview" x-html="table.preview_html"></div>
|
| 277 |
</details>
|
|
|
|
| 295 |
return {
|
| 296 |
league: {{ league|tojson }},
|
| 297 |
season: {{ season|tojson }},
|
| 298 |
+
leagues: [],
|
| 299 |
+
seasons: [],
|
| 300 |
+
teams: [],
|
| 301 |
+
playedMatches: [],
|
| 302 |
+
selectedMatchId: '',
|
| 303 |
+
teamA: '',
|
| 304 |
+
teamB: '',
|
| 305 |
dateFrom: '',
|
| 306 |
dateTo: '',
|
| 307 |
nnEnabled: {{ ('true' if nn_enabled else 'false') }},
|
|
|
|
| 312 |
},
|
| 313 |
currentJob: null,
|
| 314 |
jobStatus: null,
|
| 315 |
+
onlyFigure: null,
|
| 316 |
pollTimer: null,
|
| 317 |
|
| 318 |
+
async init() {
|
| 319 |
+
await this.loadLeagues();
|
| 320 |
+
await this.loadSeasons(false);
|
| 321 |
+
await this.loadTeamsAndMatches();
|
| 322 |
+
this._refreshHealth();
|
| 323 |
+
},
|
| 324 |
+
|
| 325 |
+
async _getJSON(url) {
|
| 326 |
+
const r = await fetch(url);
|
| 327 |
+
if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
|
| 328 |
+
return r.json();
|
| 329 |
+
},
|
| 330 |
+
|
| 331 |
+
async loadLeagues() {
|
| 332 |
+
try {
|
| 333 |
+
this.leagues = await this._getJSON('/api/leagues');
|
| 334 |
+
if (this.leagues.length && !this.leagues.includes(this.league)) this.league = this.leagues[0];
|
| 335 |
+
} catch (e) { this.leagues = this.league ? [this.league] : []; }
|
| 336 |
+
},
|
| 337 |
+
|
| 338 |
+
async loadSeasons(reset = true) {
|
| 339 |
+
try {
|
| 340 |
+
this.seasons = await this._getJSON(`/api/seasons?league=${encodeURIComponent(this.league)}`);
|
| 341 |
+
if (this.seasons.length && (reset || !this.seasons.includes(this.season))) this.season = this.seasons[0];
|
| 342 |
+
} catch (e) { this.seasons = this.season ? [this.season] : []; }
|
| 343 |
+
},
|
| 344 |
+
|
| 345 |
+
async loadTeamsAndMatches() {
|
| 346 |
+
const q = `league=${encodeURIComponent(this.league)}&season=${encodeURIComponent(this.season)}`;
|
| 347 |
+
try {
|
| 348 |
+
this.teams = await this._getJSON(`/api/teams?${q}`);
|
| 349 |
+
if (this.teams.length) {
|
| 350 |
+
if (!this.teams.includes(this.teamA)) this.teamA = this.teams[0];
|
| 351 |
+
if (!this.teams.includes(this.teamB)) this.teamB = this.teams[Math.min(1, this.teams.length - 1)];
|
| 352 |
+
}
|
| 353 |
+
} catch (e) { this.teams = []; }
|
| 354 |
+
try {
|
| 355 |
+
this.playedMatches = await this._getJSON(`/api/matches?${q}&played_only=true`);
|
| 356 |
+
} catch (e) { this.playedMatches = []; }
|
| 357 |
+
this.selectedMatchId = '';
|
| 358 |
+
},
|
| 359 |
+
|
| 360 |
+
async onLeagueChange() { await this.loadSeasons(true); await this.loadTeamsAndMatches(); },
|
| 361 |
+
async onSeasonChange() { await this.loadTeamsAndMatches(); },
|
| 362 |
|
| 363 |
async _refreshHealth() {
|
| 364 |
try {
|
| 365 |
const r = await fetch('/api/health');
|
| 366 |
+
if (r.ok) { const d = await r.json(); this.preprocessedCached = !!d.preprocessed_cached; }
|
|
|
|
|
|
|
|
|
|
| 367 |
} catch (_e) {}
|
| 368 |
},
|
| 369 |
|
| 370 |
reportTitle(id) {
|
| 371 |
+
return ({ pre_match: 'Reporte previo (head-to-head)', block_zscore: 'Bloques (z-score)', post_match: 'Reporte post-partido' })[id] || id;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
},
|
| 373 |
|
| 374 |
visibleFigures(reportType, figs) {
|
|
|
|
| 380 |
const r = this.reports[reportId];
|
| 381 |
r.catalogOpen = !r.catalogOpen;
|
| 382 |
if (r.catalogOpen && !r.catalog) {
|
| 383 |
+
try { r.catalog = await this._getJSON(`/api/reports/${reportId}/catalog`); }
|
| 384 |
+
catch (e) { alert('Error cargando catálogo: ' + e); r.catalogOpen = false; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
}
|
| 386 |
},
|
| 387 |
|
| 388 |
+
async runFull(reportId) { await this._run(reportId, null); },
|
| 389 |
async runOne(reportId, figName) { await this._run(reportId, figName); },
|
| 390 |
|
| 391 |
async _run(reportId, figName) {
|
| 392 |
if (!this.teamA || !this.teamB) { alert('Elegí equipo A y equipo B.'); return; }
|
| 393 |
if (this.teamA === this.teamB) { alert('Equipo A y B deben ser distintos.'); return; }
|
| 394 |
+
await this._post(reportId, {
|
| 395 |
+
league: this.league, season: this.season,
|
| 396 |
+
team_a: this.teamA, team_b: this.teamB,
|
| 397 |
+
filters: { date_from: this.dateFrom || null, date_to: this.dateTo || null },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
only: figName ? [figName] : [],
|
| 399 |
+
}, figName, figName ? `${this.reportTitle(reportId)} · ${figName}` : this.reportTitle(reportId));
|
| 400 |
+
},
|
| 401 |
+
|
| 402 |
+
async runPostMatch() {
|
| 403 |
+
if (!this.selectedMatchId) { alert('Elegí un partido jugado.'); return; }
|
| 404 |
+
const m = this.playedMatches.find(x => x.match_id === this.selectedMatchId);
|
| 405 |
+
if (!m) { alert('Ese partido no existe para esta liga/temporada.'); return; }
|
| 406 |
+
await this._post('post_match', {
|
| 407 |
+
league: this.league, season: this.season,
|
| 408 |
+
team_a: m.home_team, team_b: m.away_team,
|
| 409 |
+
match_id: m.match_id, filters: {}, only: [],
|
| 410 |
+
}, null, `Post-partido · ${m.home_team} vs ${m.away_team}`);
|
| 411 |
+
},
|
| 412 |
+
|
| 413 |
+
async _post(reportId, body, figName, title) {
|
| 414 |
let resp;
|
| 415 |
try {
|
| 416 |
resp = await fetch(`/api/reports/${reportId}/run`, {
|
| 417 |
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
|
|
|
|
|
| 418 |
});
|
| 419 |
+
} catch (e) { alert('Error de red: ' + e); return; }
|
|
|
|
|
|
|
| 420 |
if (!resp.ok) {
|
| 421 |
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 422 |
+
alert('Error: ' + err.detail); return;
|
|
|
|
| 423 |
}
|
| 424 |
const { job_id } = await resp.json();
|
| 425 |
this.onlyFigure = figName;
|
| 426 |
+
this._startPolling(job_id, title);
|
| 427 |
},
|
| 428 |
|
| 429 |
_startPolling(jobId, title) {
|
|
|
|
| 432 |
if (this.pollTimer) clearInterval(this.pollTimer);
|
| 433 |
this.pollTimer = setInterval(() => this._poll(), 2000);
|
| 434 |
this._poll();
|
| 435 |
+
setTimeout(() => { document.querySelector('section > h2')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100);
|
|
|
|
|
|
|
| 436 |
},
|
| 437 |
|
| 438 |
async _poll() {
|
|
|
|
| 442 |
if (!r.ok) return;
|
| 443 |
this.jobStatus = await r.json();
|
| 444 |
if (['done', 'error'].includes(this.jobStatus.status)) {
|
| 445 |
+
clearInterval(this.pollTimer); this.pollTimer = null; this._refreshHealth();
|
|
|
|
|
|
|
| 446 |
}
|
| 447 |
+
} catch (_e) {}
|
| 448 |
},
|
| 449 |
};
|
| 450 |
}
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metadata para la web (ligas, temporadas, equipos, partidos) derivada del
|
| 2 |
+
dataset de modelado bundleado — sin depender de Azure para *seleccionar*.
|
| 3 |
+
|
| 4 |
+
El dataset (`vendor/data/modeling/attack_prediction_dataset.parquet`) trae, por
|
| 5 |
+
partido y equipo: league, season, fecha, team_name, opponent_name, is_home,
|
| 6 |
+
matchId. Con eso armamos los selectores y la lista de partidos jugados para
|
| 7 |
+
cualquiera de las ligas que el modelo soporta, offline.
|
| 8 |
+
|
| 9 |
+
La GENERACIÓN de reportes sigue necesitando el preprocessed (Azure); por eso
|
| 10 |
+
`available_league_seasons()` puede intersecar con lo que realmente está subido.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import threading
|
| 16 |
+
from datetime import date
|
| 17 |
+
from functools import lru_cache
|
| 18 |
+
|
| 19 |
+
import pandas as pd
|
| 20 |
+
|
| 21 |
+
from racing_reports import vendor_env
|
| 22 |
+
|
| 23 |
+
_LOCK = threading.Lock()
|
| 24 |
+
_CACHE: dict[str, object] = {}
|
| 25 |
+
|
| 26 |
+
_DATASET_PATH = vendor_env.DATA_DIR / "modeling" / "attack_prediction_dataset.parquet"
|
| 27 |
+
_COLS = ["matchId", "league", "season", "fecha", "team_name", "opponent_name", "is_home"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _load() -> pd.DataFrame:
|
| 31 |
+
"""Carga (cacheada) las columnas meta del dataset de modelado."""
|
| 32 |
+
with _LOCK:
|
| 33 |
+
df = _CACHE.get("df")
|
| 34 |
+
if df is None:
|
| 35 |
+
cols = [c for c in _COLS if c]
|
| 36 |
+
df = pd.read_parquet(_DATASET_PATH, columns=cols)
|
| 37 |
+
df["fecha"] = pd.to_datetime(df["fecha"], errors="coerce")
|
| 38 |
+
df["league"] = df["league"].astype(str)
|
| 39 |
+
df["season"] = df["season"].astype(str)
|
| 40 |
+
_CACHE["df"] = df
|
| 41 |
+
return df
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def has_dataset() -> bool:
|
| 45 |
+
return _DATASET_PATH.exists()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def league_seasons() -> dict[str, list[str]]:
|
| 49 |
+
"""{liga: [temporadas]} presentes en el dataset (orden temporada desc)."""
|
| 50 |
+
df = _load()
|
| 51 |
+
out: dict[str, list[str]] = {}
|
| 52 |
+
for league, sub in df.groupby("league"):
|
| 53 |
+
out[str(league)] = sorted(sub["season"].unique().tolist(), reverse=True)
|
| 54 |
+
return dict(sorted(out.items()))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def teams(league: str, season: str) -> list[str]:
|
| 58 |
+
"""Equipos de esa liga/temporada (nombres ordenados)."""
|
| 59 |
+
df = _load()
|
| 60 |
+
sub = df[(df["league"] == league) & (df["season"] == str(season))]
|
| 61 |
+
names = set(sub["team_name"].dropna().astype(str)) | set(sub["opponent_name"].dropna().astype(str))
|
| 62 |
+
return sorted(names)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
|
| 66 |
+
"""Lista de partidos (home/away/fecha/estado) de esa liga/temporada."""
|
| 67 |
+
df = _load()
|
| 68 |
+
sub = df[(df["league"] == league) & (df["season"] == str(season)) & (df["is_home"] == True)] # noqa: E712
|
| 69 |
+
sub = sub.drop_duplicates("matchId")
|
| 70 |
+
today = pd.Timestamp(date.today())
|
| 71 |
+
rows: list[dict] = []
|
| 72 |
+
for _, r in sub.sort_values("fecha", ascending=False).iterrows():
|
| 73 |
+
d = r["fecha"]
|
| 74 |
+
played = pd.notna(d) and d <= today
|
| 75 |
+
if played_only and not played:
|
| 76 |
+
continue
|
| 77 |
+
rows.append({
|
| 78 |
+
"match_id": str(r["matchId"]),
|
| 79 |
+
"date": d.strftime("%Y-%m-%d") if pd.notna(d) else "",
|
| 80 |
+
"home_team": str(r["team_name"]),
|
| 81 |
+
"away_team": str(r["opponent_name"]),
|
| 82 |
+
"status": "played" if played else "future",
|
| 83 |
+
})
|
| 84 |
+
return rows
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def resolve_pair(league: str, season: str, team_a: str, team_b: str) -> list[dict]:
|
| 88 |
+
"""Partidos entre A y B (en cualquier orden) en esa liga/temporada."""
|
| 89 |
+
a, b = team_a.strip().casefold(), team_b.strip().casefold()
|
| 90 |
+
out = []
|
| 91 |
+
for m in matches(league, season):
|
| 92 |
+
h, w = m["home_team"].casefold(), m["away_team"].casefold()
|
| 93 |
+
if {h, w} == {a, b}:
|
| 94 |
+
out.append(m)
|
| 95 |
+
return out
|
|
@@ -11,16 +11,31 @@ from racing_reports.web import app as web_app
|
|
| 11 |
|
| 12 |
|
| 13 |
def test_home_is_spa_with_teams_and_report_cards():
|
| 14 |
-
"""El nuevo home es un SPA que consume /api/.
|
| 15 |
-
|
| 16 |
client = TestClient(web_app.app)
|
| 17 |
resp = client.get("/")
|
| 18 |
assert resp.status_code == 200
|
| 19 |
-
assert "
|
|
|
|
| 20 |
assert "Reporte previo" in resp.text
|
| 21 |
assert "Bloques" in resp.text
|
| 22 |
-
assert "post-partido" in resp.text
|
| 23 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def test_legacy_home_still_works():
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def test_home_is_spa_with_teams_and_report_cards():
|
| 14 |
+
"""El nuevo home es un SPA que consume /api/. Equipos/ligas/partidos se cargan
|
| 15 |
+
client-side; verificamos los selectores y las cards de reportes."""
|
| 16 |
client = TestClient(web_app.app)
|
| 17 |
resp = client.get("/")
|
| 18 |
assert resp.status_code == 200
|
| 19 |
+
assert 'x-model="league"' in resp.text # selector de liga
|
| 20 |
+
assert 'x-model="season"' in resp.text # selector de temporada
|
| 21 |
assert "Reporte previo" in resp.text
|
| 22 |
assert "Bloques" in resp.text
|
| 23 |
+
assert "post-partido" in resp.text # card de post-partido
|
| 24 |
+
assert "runPostMatch" in resp.text # flujo de post-partido funcional
|
| 25 |
+
assert "/api/leagues" in resp.text # alpine consume la API meta
|
| 26 |
+
assert "/api/reports/" in resp.text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_meta_endpoints_are_dataset_driven():
|
| 30 |
+
"""leagues/seasons/teams/matches salen del dataset bundleado (offline)."""
|
| 31 |
+
from racing_reports.api import routes_meta as rm
|
| 32 |
+
ls = rm._league_seasons()
|
| 33 |
+
assert ls, "debe haber al menos una liga/temporada"
|
| 34 |
+
league = next(iter(ls))
|
| 35 |
+
season = ls[league][0]
|
| 36 |
+
assert rm.teams(league, season), "debe listar equipos"
|
| 37 |
+
# matches puede estar vacío para temporadas sin jugados, pero no debe romper
|
| 38 |
+
assert isinstance(rm.matches(league, season, played_only=True), list)
|
| 39 |
|
| 40 |
|
| 41 |
def test_legacy_home_still_works():
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:05b43be8a91cea8400eef47d55ea5dffb69809cfe73c8d26937c9f7955139d06
|
| 3 |
+
size 10011206
|