"""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, run_eda_models, ) from pipelines.profile_table import profile_table # Tokens de almacenamiento por backend (para la portada del informe). _STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"} # Presets de consumo CPU/LLM: cada profile_level fija SOLO los DEFAULTS de los # flags que controlan el coste (un flag explícito del caller siempre prima sobre # el preset). model_opts != None marca el camino "modelos baratos" (lite): los # modelos NO los corre profile_table (que ejecutaría KMeans + IsolationForest), # sino run_eda_models con esa granularidad, de modo que el coste CPU de los # multivariantes nunca se paga. model_opts None => modelos completos como hasta # ahora (profile_table los corre con todos los algoritmos). _PROFILE_PRESETS = { # Bajo consumo: sin LLM, sin serie, sample reducido y modelos limitados a # PCA + normalidad (sin KMeans ni IsolationForest, lo caro en CPU). Vistazo # rápido y barato de una tabla. "lite": { "run_models": True, "run_series": False, "run_llm": False, "sample": 2000, "model_opts": {"run_kmeans": False, "run_isolation": False}, }, # Default: idéntico al comportamiento histórico del pipeline (modelos # completos, serie temporal, sin LLM, sample 5000). "standard": { "run_models": True, "run_series": True, "run_llm": False, "sample": 5000, "model_opts": None, }, # Máximo: standard + narrativa LLM (interpretación del perfil y de los # capítulos modelos/geospatial/agregacion). Es la única parte que gasta # tokens del modelo. "full": { "run_models": True, "run_series": True, "run_llm": True, "sample": 5000, "model_opts": None, }, } def render_automatic_eda( db_path: str, table: str, backend: str = "duckdb", sample: int = None, run_models: bool = None, run_series: bool = None, run_llm: bool = None, profile_level: str = "standard", 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 None => lo fija el preset de profile_level (lite=2000, standard/full=5000). run_models: corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad). Necesario para que el capítulo `modelos` pinte los clusters sobre el plano PCA. Default None => lo fija el preset (True en los tres niveles); en `lite` los modelos se limitan a PCA + normalidad (ver profile_level). run_series: 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). Default None => lo fija el preset (standard/full=True, lite=False). run_llm: 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). Default None => lo fija el preset (full=True, lite/standard=False). profile_level: preset de consumo CPU/LLM. Mapea a defaults de los flags anteriores; un flag explícito SIEMPRE prima sobre el preset (el preset solo fija el default cuando el flag se deja en None): - "lite" bajo consumo: run_llm=False, run_series=False, sample=2000 y modelos limitados a **PCA + normalidad** (SIN KMeans ni IsolationForest, que es lo caro en CPU). Pensado para un vistazo rápido y barato. El capítulo `modelos` sale con PCA + normalidad, sin el scatter de clusters. - "standard" (default): comportamiento histórico — modelos completos (PCA/KMeans/IsolationForest/normalidad), serie temporal, sin LLM. - "full" standard + narrativa LLM (run_llm=True). Ejemplo de precedencia: profile_level="lite" con run_llm=True explícito => el LLM SÍ se ejecuta (el flag explícito gana al preset). out_dir: directorio de salida (se crea si no existe). Default "reports". basename: nombre base de los archivos sin extensión. Default "aeda__". 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": } En error: {"status": "error", "error": str}. """ try: # 0) Resolución del preset: el profile_level fija los DEFAULTS de los # flags de coste; cualquier flag que el caller haya pasado explícito # (!= None) prima sobre el preset. Un profile_level desconocido cae a # "standard" (comportamiento histórico), sin lanzar. preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"]) sample = preset["sample"] if sample is None else sample run_models = preset["run_models"] if run_models is None else run_models run_series = preset["run_series"] if run_series is None else run_series run_llm = preset["run_llm"] if run_llm is None else run_llm model_opts = preset["model_opts"] # En el camino "modelos baratos" (lite) profile_table NO corre los # modelos: los ejecuta este pipeline con run_eda_models y la granularidad # del preset, evitando pagar el coste CPU de KMeans + IsolationForest. # En standard/full profile_table los corre completos como siempre. lite_models = bool(run_models) and model_opts is not None pt_run_models = bool(run_models) and not lite_models # 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=pt_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, ) # 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni # IsolationForest). profile_table no corrió los modelos; aquí se corren # con run_eda_models reusando la muestra numérica alineada por fila que # build_eda_render_ctx ya trajo en ctx['raw_numeric'] (no se reimplementa # la lógica de los modelos: se delega en run_eda_models con la # granularidad del preset). if lite_models: raw_numeric = ctx.get("raw_numeric") if isinstance(ctx, dict) else None if isinstance(raw_numeric, dict) and raw_numeric: model_input = { col: {"values": vals, "type": "numeric"} for col, vals in raw_numeric.items() } prof["models"] = run_eda_models(model_input, **model_opts) # Quita raw_numeric del ctx para que el capítulo `modelos` NO # reproyecte clusters KMeans en vivo (project_clusters_2d ejecuta # KMeans): en lite ese coste se evita. geo_points ya quedó derivado # en ctx por build_eda_render_ctx, así que el capítulo geospatial no # se ve afectado. if isinstance(ctx, dict): ctx.pop("raw_numeric", None) # 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)}