#!/usr/bin/env python3 """Arma docs/diccionarios/referencia.html: navegador de la documentación, organizado como las apps (elegís app → sección → categoría → variable). NO tiene contenido propio: todo sale de las mismas fuentes que los .md/.csv (por eso no se puede desincronizar, que fue lo que le pasó al referencia.html viejo). USO: python scripts/gen_referencia.py """ from __future__ import annotations import csv, json, re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] RACING = ROOT.parent DIC = ROOT / "docs" / "diccionarios" var_dict = json.loads((ROOT / "vendor/data/var_dict.json").read_text(encoding="utf-8")) groups = json.loads((ROOT / "scripts/feature_groups.json").read_text(encoding="utf-8")) cats_rep = json.loads((DIC / "_reportes_categorias.json").read_text(encoding="utf-8")) ejes_def = json.loads((RACING / "data/analysis/next_goal/ejes_definition.json").read_text(encoding="utf-8")) lb = json.loads((ROOT / "webapp-static/site/lowblock.json").read_text(encoding="utf-8"))["options"] # ── variables (de variables.csv, la salida del otro generador) ──────────────── VARS: dict[str, dict] = {} for r in csv.DictReader((DIC / "variables.csv").open(encoding="utf-8")): key = r["variable"] if r["variable"] not in VARS else f'{r["variable"]}@{r["dataset"]}' VARS[key] = {"n": r["variable"], "l": r["nombre_de_uso"], "ds": r["dataset"], "o": r["origen"], "g": r["generado_por"], "e": r["entradas"], "c": r["como_se_calcula"], "t": r["tipo"], "sc": r.get("subcategoria", ""), "gr": r["grupos"], "nt": r["nota"]} # ── resumen "De qué se trata" de cada .md ───────────────────────────────────── def resumen(rel: str) -> str: p = DIC / f"{rel}.md" if not p.exists(): return "" m = re.search(r"## De qué se trata\s*\n(.+?)(?=\n#{2,3} |\n\| )", p.read_text(encoding="utf-8"), re.S) if not m: return "" txt = m.group(1).strip() # re.S: en los .md las negritas/código suelen cruzar el salto de línea txt = re.sub(r"\*\*(.+?)\*\*", r"\1", txt, flags=re.S) txt = re.sub(r"`(.+?)`", r"\1", txt, flags=re.S) txt = re.sub(r"\[(.+?)\]\([^)]+\)", r"\1", txt, flags=re.S) txt = re.sub(r"(?\1", txt) # itálicas out = [] for b in txt.split("\n\n"): b = b.strip() if not b: continue lines = [l.strip() for l in b.split("\n")] if all(re.match(r"^(\d+\.|[-•])\s+", l) for l in lines): # lista tag = "ol" if re.match(r"^\d+\.", lines[0]) else "ul" items = "".join(f"
  • {re.sub(r'^(\d+\.|[-•])\s+', '', l)}
  • " for l in lines) out.append(f"<{tag}>{items}") else: out.append(f"

    {' '.join(lines)}

    ") return "".join(out) # ── markdown → HTML (lo justo para nuestras fichas) ────────────────────────── def _inline(t: str) -> str: t = (t.replace("&", "&").replace("<", "<").replace(">", ">")) t = re.sub(r"`(.+?)`", r"\1", t) t = re.sub(r"\*\*(.+?)\*\*", r"\1", t) t = re.sub(r"(?\1", t) t = re.sub(r"\[(.+?)\]\([^)]+\)", r"\1", t) # links: se deja el texto return t def md_to_html(rel: str) -> str: """Convierte la ficha .md entera a HTML (headings, tablas, listas, citas).""" path = DIC / f"{rel}.md" if not path.exists(): return "" txt = path.read_text(encoding="utf-8") txt = re.sub(r"\n---\n\n\*\*Versión CSV\*\*.*\Z", "", txt, flags=re.S) # el pie del CSV sobra acá txt = re.sub(r"^# .+?\n", "", txt, count=1).lstrip("\n") # el h1 ya es el título # el "De qué se trata" ya se muestra arriba como resumen: no repetirlo en la ficha txt = re.sub(r"^## De qué se trata\s*\n.*?(?=\n## )", "", txt, count=1, flags=re.S).lstrip("\n") out, i, lines = [], 0, txt.split("\n") while i < len(lines): ln = lines[i] if not ln.strip() or ln.strip() == "---": i += 1; continue if ln.startswith("### "): out.append(f"

    {_inline(ln[4:].strip())}

    "); i += 1; continue if ln.startswith("## "): out.append(f"

    {_inline(ln[3:].strip())}

    "); i += 1; continue if ln.lstrip().startswith("|"): # tabla rows = [] while i < len(lines) and lines[i].lstrip().startswith("|"): rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]); i += 1 if len(rows) >= 2 and set("".join(rows[1]).replace(" ", "")) <= set("-:"): head, body = rows[0], rows[2:] else: head, body = None, rows t = ['
    '] if head and any(h for h in head): t.append("" + "".join(f"" for c in head) + "") t.append("" + "".join("" + "".join(f"" for c in r) + "" for r in body) + "
    {_inline(c)}
    {_inline(c)}
    ") out.append("".join(t)); continue if ln.startswith("> "): # cita buf = [] while i < len(lines) and lines[i].startswith(">"): buf.append(lines[i].lstrip("> ").rstrip()); i += 1 out.append(f'
    {_inline(" ".join(buf))}
    '); continue m = re.match(r"^(\d+\.|[-*])\s+", ln) # lista if m: tag = "ol" if ln[0].isdigit() else "ul" items = [] while i < len(lines) and re.match(r"^(\d+\.|[-*])\s+", lines[i]): cur = re.sub(r"^(\d+\.|[-*])\s+", "", lines[i]); i += 1 while i < len(lines) and lines[i].startswith(" ") and lines[i].strip() \ and not re.match(r"^\s*(\d+\.|[-*])\s+", lines[i]): cur += " " + lines[i].strip(); i += 1 items.append(f"
  • {_inline(cur)}
  • ") out.append(f"<{tag}>{''.join(items)}"); continue buf = [] # párrafo while i < len(lines) and lines[i].strip() and not re.match( r"^(#{2,3} |\||> |\d+\.\s|[-*]\s|---)", lines[i]): buf.append(lines[i].strip()); i += 1 if buf: out.append(f"

    {_inline(' '.join(buf))}

    ") return "".join(out) def cat_grupos() -> list: porg: dict[str, list[str]] = {} for col, gs in groups.items(): for g in gs: porg.setdefault(g, []).append(col) return [{"n": g, "v": sorted(porg[g], key=lambda c: (var_dict.get(c, {}).get("label", c) or c).lower())} for g in sorted(porg)] def cat_tipos() -> list: port: dict[str, list[str]] = {} for c, i in var_dict.items(): port.setdefault(i.get("tipo", "Otras"), []).append(c) return [{"n": t, "v": sorted(port[t], key=lambda c: (var_dict.get(c, {}).get("label", c) or c).lower())} for t in sorted(port)] EJE_LBL = {"verticalidad": "Verticalidad", "elaboracion": "Elaboración", "individual": "Juego individual", "rotura_profundidad": "Rotura de líneas / profundidad", "amplitud_centro": "Amplitud / centros", "robo_alto": "Robo alto", "aereo_segunda": "Aéreo / 2ª jugada"} cat_ejes = [{"n": EJE_LBL.get(e, e), "v": list(cs)} for e, cs in ejes_def.items()] cat_recursos = [ {"n": "Lado", "v": [], "txt": ["Ofensivo — el equipo usa el recurso", "Defensivo — se lo hacen al equipo"]}, {"n": "Bloque", "v": [], "txt": list(lb.get("bloques", {}).values())}, {"n": "Recursos", "v": [], "txt": list(lb.get("recursos", {}).values())}, {"n": "Métricas", "v": ["pv_jugada", "pv_jugada_exitosa", "evento_clave", "tiros_jugada", "xg_acum", "xg_jugada", "xg_jugada_exitosa", "goles", "pct_goles", "pct_exito", "n_intentos", "intentos_90"]}, ] def cats_de(rel: str) -> list: """Categorías de un reporte/modelo: si el ítem es una variable conocida, se enlaza.""" out = [] for cat, items in cats_rep.get(rel, {}).items(): v = [i for i in items if i in VARS] txt = [i for i in items if i not in VARS] out.append({"n": cat, "v": v, "txt": txt}) return out EV = [ ("top-equipos", "Top equipos", "equipos-y-variables/top-equipos", cat_grupos(), "Los grupos del botón «Agregar grupo»"), ("perfil-y-similares", "Perfil y similares", "equipos-y-variables/perfil-y-similares", cat_grupos(), "Los grupos del botón «Agregar grupo»"), ("ejes", "Ejes ataque/defensa", "equipos-y-variables/ejes-ataque-defensa", cat_ejes, "Los 7 ejes y las métricas que promedian"), ("recursos", "Recursos", "equipos-y-variables/recursos", cat_recursos, "Las opciones de cada desplegable"), ("scatter-libre", "Scatter libre", "equipos-y-variables/scatter-libre", cat_tipos(), "Las 262 variables, por tipo"), ] REPORTES = [("pre_match", "Pre-partido (head-to-head)"), ("block_zscore", "Bloques (z-score)"), ("triple_multitag", "Multi-Tag"), ("triple_centros", "Centros"), ("recuperaciones", "Recuperaciones"), ("post_match", "Post-partido"), ("ejes_pre", "Ejes — previo"), ("ejes_post", "Ejes — post")] MODELOS = [("attack_matchup_gnn", "Zonas de ataque (GNN)"), ("pv_distribution_gnn", "Peligro por zona (GNN)"), ("cruce", "Cruce: bloque y pasillos"), ("ptiro", "P(tiro) por jugada")] def sec_ev(): return [{"id": i, "n": n, "res": resumen(rel), "doc": md_to_html(rel), "cats": c, "hint": h} for i, n, rel, c, h in EV] APPS = [ {"id": "vercel", "n": "Vercel", "sub": "Gráficos de equipos", "url": "graficos-equipos-racing.vercel.app", "d": "La app estática. Solo consulta de equipos y variables: no genera reportes de partido.", "grupos": [{"n": "Equipos y variables", "secs": sec_ev()}]}, {"id": "hf", "n": "Hugging Face", "sub": "Space RRC", "url": "pablogrois/RRC", "d": "La app completa. Tiene lo mismo que Vercel más los reportes por partido y los modelos.", "grupos": [ {"n": "Equipos y variables", "secs": sec_ev()}, {"n": "Reportes de partido", "secs": [ {"id": f"r_{i}", "n": n, "res": resumen(f"reportes-de-partido/{i}"), "doc": md_to_html(f"reportes-de-partido/{i}"), "cats": cats_de(f"reportes-de-partido/{i}"), "hint": "Qué usa cada parte del reporte"} for i, n in REPORTES]}, {"n": "Modelos", "secs": [ {"id": f"m_{i}", "n": n, "res": resumen(f"modelos/{i}"), "doc": md_to_html(f"modelos/{i}"), "cats": cats_de(f"modelos/{i}"), "hint": "Datos, target y features"} for i, n in MODELOS]}, ]}, ] # ── índice inverso: variable → dónde aparece (app · sección · categoría) ───── USOS: dict[str, list] = {} for a in APPS: for g in a["grupos"]: for s in g["secs"]: for ci, c in enumerate(s["cats"]): for n in c.get("v", []): USOS.setdefault(n, []) ref = [a["id"], s["id"], ci, f'{s["n"]} · {c["n"]}', a["n"]] if ref not in USOS[n]: USOS[n].append(ref) # nº de variables distintas por sección (para las tarjetas) for a in APPS: for g in a["grupos"]: for s in g["secs"]: s["nv"] = len({n for c in s["cats"] for n in c.get("v", [])}) from datetime import date DB = {"apps": APPS, "vars": VARS, "usos": USOS, "meta": {"n": len(VARS), "dir": sum(1 for v in VARS.values() if v["o"] == "directa"), "fecha": date.today().isoformat()}} (DIC / "_referencia_db.json").write_text(json.dumps(DB, ensure_ascii=False), encoding="utf-8") # ── ensamblar el HTML (plantilla + datos embebidos) ────────────────────────── HERE = Path(__file__).resolve().parent head = (HERE / "_referencia_head.html").read_text(encoding="utf-8") body = (HERE / "_referencia_body.html").read_text(encoding="utf-8") n, d = len(VARS), DB["meta"]["dir"] body = (body.replace("__DB__", json.dumps(DB, ensure_ascii=False)) .replace("__N__", str(n)).replace("__D__", str(d)).replace("__C__", str(n - d)) .replace("__F__", DB["meta"]["fecha"])) html = head + "\n" + body out = DIC / "referencia.html" out.write_text(html, encoding="utf-8") # copias servidas por las dos apps (mismo candado en cada una, sin proyectos nuevos): # · Vercel: el sitio estático la publica tal cual # · Space HF: va dentro de src/ porque es lo único que el Dockerfile copia copias = [ROOT / "webapp-static" / "site" / "referencia.html", ROOT / "src" / "racing_reports" / "web" / "templates" / "referencia.html"] for c in copias: c.write_text(html, encoding="utf-8") print(f"OK referencia.html — {len(html)/1024:.0f} KB · {len(VARS)} variables · {len(APPS)} apps") for c in copias: print(f" copiada a {c.relative_to(ROOT)}")