feat(eda): wiring AutomaticEDA — build_eda_render_ctx + pipeline render_automatic_eda + profile_table(emit_automatic)

Conecta el motor AutomaticEDA con los datos crudos para que los 4 capítulos
dependientes de ctx (modelos, timeseries, geospatial, agregacion) salgan
POBLADOS en vez de degradar a una nota.

- build_eda_render_ctx (datascience, impure, dict-no-throw): dado db_path+table
  y el TableProfile agregado, construye el ctx con los datos crudos que el
  perfil no incluye: raw_numeric {col:[float|None]} alineado por fila (modelos /
  geospatial), timeseries_raw {time_col,t,series} vía extract_timeseries_raw,
  geo_points {lats,lons} desde el par lat/lon detectado, y db_path/table para el
  groupby/pivot push-down de agregacion. Muestrea con LIMIT (no trae la tabla
  entera a RAM). Compone detect_time_column / extract_timeseries_raw /
  detect_latlon_columns / duckdb_query_readonly (imports lazy para evitar ciclo).
- render_automatic_eda (pipeline): one-shot perfil -> ctx -> PDF + PPTX con los
  11 capítulos poblados; devuelve rutas + manifest de versiones por capítulo.
- profile_table: flag aditivo emit_automatic=True emite el AutomaticEDA PDF+PPTX
  además del flujo legacy (emit_pdf/render_eda_pdf intacto). Nuevas claves de
  retorno aeda_pdf_path / aeda_pptx_path / aeda_manifest_path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 16:08:41 +02:00
parent f5b30b23dc
commit f3d427d9e4
9 changed files with 867 additions and 2 deletions
@@ -0,0 +1,157 @@
"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX.
Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus dos formatos a la
vez (PDF móvil A5 + PPTX 16:9) con los 11 capítulos POBLADOS, en una sola
llamada. Compone, sin reimplementar su lógica, cuatro funciones del registry:
- profile_table : perfila la tabla end-to-end (TableProfile agregado),
opcionalmente con modelos baratos y análisis de serie.
- build_eda_render_ctx : construye el `ctx` con los DATOS CRUDOS que el
TableProfile agregado no incluye (raw_numeric para
modelos/geo, timeseries_raw para series, geo_points
para el mapa, db_path/table para la agregación
push-down). Sin él, esos capítulos degradan.
- render_automatic_eda_pdf : renderiza el documento por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento a PPTX.
El TableProfile agregado basta para portada/overview/distribuciones/calidad/
correlación, pero los capítulos `modelos`, `timeseries`, `geospatial` y
`agregacion` necesitan datos crudos (los clusters proyectados sobre el PCA, la
serie valor-vs-tiempo, los puntos lat/lon, las filas para el groupby/pivot).
`build_eda_render_ctx` los muestrea (LIMIT + push-down, sin traer la tabla
entera a RAM) y los entrega en `ctx`; este pipeline los pasa como `meta['ctx']`
a ambos renderers para que el informe salga completo.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
degrada a `{"status": "error", "error": str}`.
"""
import os
from datetime import datetime, timezone
from datascience import (
build_eda_render_ctx,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
)
from pipelines.profile_table import profile_table
# Tokens de almacenamiento por backend (para la portada del informe).
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}
def render_automatic_eda(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = True,
run_series: bool = True,
run_llm: bool = False,
out_dir: str = "reports",
basename: str = None,
ctx_extra: dict = None,
) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
Args:
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres".
sample: máximo de filas/valores muestreados por columna para el perfil
y para los datos crudos del ctx (LIMIT). Default 5000.
run_models: si True (default) corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad). Necesario para que el
capítulo `modelos` pinte los clusters sobre el plano PCA.
run_series: si True (default) calcula el análisis de serie temporal por
columna numérica. Necesario para el análisis del capítulo
`timeseries` (la gráfica de evolución sale de los datos crudos del
ctx aunque run_series sea False).
run_llm: si True (default False) hace la interpretación LLM del perfil y
ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
agregacion (títulos de segmento, descripción de la zona, selección de
agregaciones). Con False esos capítulos usan su derivación
cuantitativa (siguen completos, sin llamadas de red).
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_<table>_<timestamp>".
ctx_extra: dict opcional con claves de presentación/contexto extra que se
mezclan en el ctx (p.ej. dataset_name, description, source_origin).
No pisan las claves de datos calculadas por build_eda_render_ctx.
Returns:
dict (nunca lanza). En éxito::
{"status": "ok", "pdf_path": str, "pptx_path": str,
"manifest_path": str|None, "n_pages": int, "n_slides": int,
"pdf_note": str, "pptx_note": str, "profile": <TableProfile>}
En error: {"status": "error", "error": str}.
"""
try:
# 1) Perfil base + modelos/serie opcionales. No escribe report propio
# (write_report=False): este pipeline emite su propio par PDF/PPTX.
pres = profile_table(
db_path,
table,
backend=backend,
sample=sample,
run_models=run_models,
run_llm=run_llm,
run_series=run_series,
emit_pdf=False,
write_report=False,
)
if pres.get("status") != "ok":
return {"status": "error",
"error": f"profile_table falló: {pres.get('error')}"}
prof = pres.get("profile") or {}
# 2) Contexto de presentación + datos crudos para los 4 capítulos que los
# necesitan. Las claves de presentación van en base_ctx; build_eda_render_ctx
# añade raw_numeric / timeseries_raw / geo_points / db_path / table.
base_ctx = {
"dataset_name": table,
"source_origin": db_path,
"storage": _STORAGE.get(backend, backend),
}
if run_llm:
# Activa la narrativa LLM de los capítulos que la soportan.
base_ctx.update({
"run_cluster_llm": True,
"run_geo_llm": True,
"run_agg_llm": True,
})
if ctx_extra:
base_ctx.update(ctx_extra)
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = basename or f"aeda_{table}_{ts}"
pdf_path = os.path.join(out_dir, base + ".pdf")
pptx_path = os.path.join(out_dir, base + ".pptx")
meta = {"title": f"EDA — {table}", "ctx": ctx}
rpdf = render_automatic_eda_pdf(prof, pdf_path, meta) or {}
rpptx = render_automatic_eda_pptx(prof, pptx_path, meta) or {}
return {
"status": "ok",
"pdf_path": rpdf.get("path"),
"pptx_path": rpptx.get("path"),
"manifest_path": rpdf.get("manifest_path"),
"n_pages": rpdf.get("n_pages"),
"n_slides": rpptx.get("n_slides"),
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
return {"status": "error", "error": str(e)}