File size: 14,846 Bytes
e58615a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a798829
e58615a
 
 
a798829
 
e58615a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aca54bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e58615a
 
 
 
 
 
 
 
 
aca54bb
e58615a
 
 
 
 
 
 
 
 
a798829
 
e58615a
 
 
 
 
 
 
aca54bb
e58615a
 
 
 
ebd7ac3
 
 
 
7d5bb88
 
 
e58615a
 
7d5bb88
 
 
a798829
ebd7ac3
 
a798829
ebd7ac3
 
 
e58615a
 
 
 
 
976402e
 
 
e55298e
976402e
 
52019ff
 
 
e55298e
 
976402e
fd8ce11
 
976402e
 
 
 
 
e55298e
 
976402e
e58615a
 
 
 
 
 
 
 
 
 
e55298e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e58615a
 
 
 
 
 
 
 
 
 
 
9056455
 
d1dbbf2
 
e58615a
5f3cff9
 
 
 
 
 
 
 
64a7749
5b14b74
 
 
64a7749
 
 
 
5f3cff9
 
9056455
 
d1dbbf2
 
64a7749
a798829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e58615a
ebd7ac3
 
 
 
 
 
 
 
 
 
 
 
 
e58615a
 
 
 
 
 
 
 
 
 
 
e54caf0
ebd7ac3
e58615a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
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")),
                    state_a=bz.get("state_a", "both"),
                    state_b=bz.get("state_b", "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")),
                    state_a=flt.get("state_a", "both"),
                    state_b=flt.get("state_b", "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