Files
fn_registry/python/functions/pipelines/render_automatic_eda.py
T
egutierrez 48de3ce3da feat(eda): salida Markdown del AutomaticEDA para pegar a un LLM
Añade un tercer formato de salida al AutomaticEDA, junto al PDF y el PPTX:
un Markdown autocontenido del MISMO documento por capítulos
(chapters_registry.build_document), optimizado para incorporar a un LLM
(texto plano + tablas markdown reales, sin binarios incrustados).

- render_md_impl.render_md(chapters, out_path, meta): serializa los bloques
  del modelo (Heading/Markdown/KVTable/DataTable/Figure/Image/Caption/Note/
  Group/GlossaryEntry) a Markdown. Cabecera con metadatos + índice navegable
  con anclas GitHub; tablas volcadas enteras (el MD no pagina); marcadores de
  glosario eliminados conservando la negrita; glosario al final.
- Figuras: un LLM no ve la imagen, así que se prioriza texto + datos. Se emite
  el caption y, cuando la figura tiene barras (histograma), se extrae la tabla
  de bins (Desde/Hasta/Frecuencia) de los artistas matplotlib. La banda ±1σ
  (axvspan) se descarta por ancho para que no aparezca como un falso bin.
  PNG opcional vía meta['embed_figures'] (off por defecto → sin binarios).
- render_automatic_eda_markdown: función pública del registry (tag eda),
  espejo de render_automatic_eda_pdf/pptx, acepta lista de capítulos o un
  TableProfile (build_document). dict-no-throw.
- render_automatic_eda (pipeline): emite también el .md (emit_md=True por
  defecto, clave de retorno aeda_md_path). Cambio aditivo: PDF/PPTX/manifest
  siguen saliendo igual.

Tests: golden de todos los kinds + regresión del filtro de la banda ±1σ +
edge documento vacío + profile path. Suite del paquete y del pipeline verde
(122 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:52:08 +02:00

282 lines
13 KiB
Python

"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX + MD.
Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus tres formatos a la
vez (PDF móvil A5 + PPTX 16:9 + Markdown autocontenido para pegar a un LLM) con
los capítulos POBLADOS, en una sola llamada. Compone, sin reimplementar su
lógica, varias 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.
- render_automatic_eda_markdown : serializa el mismo documento a Markdown
autocontenido (texto + tablas markdown, sin
binarios) para incorporar a un LLM.
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_markdown,
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,
emit_md: bool = True,
) -> 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_<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.
emit_md: además del PDF y el PPTX, emite un Markdown autocontenido del
MISMO documento por capítulos (texto plano + tablas markdown, sin
binarios), pensado para pegar a un LLM. Default True. La ruta sale en
la clave de retorno ``aeda_md_path``. No altera las demás salidas.
Returns:
dict (nunca lanza). En éxito::
{"status": "ok", "pdf_path": str, "pptx_path": str,
"aeda_md_path": str|None, "manifest_path": str|None,
"n_pages": int, "n_slides": int, "md_chars": int|None,
"pdf_note": str, "pptx_note": str, "md_note": str|None,
"profile": <TableProfile>}
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 {}
# Salida Markdown autocontenida (mismo documento por capítulos) para
# pegar a un LLM. Aditiva: no afecta a PDF/PPTX/manifest. dict-no-throw.
rmd = {}
md_path = None
if emit_md:
md_path = os.path.join(out_dir, base + ".md")
rmd = render_automatic_eda_markdown(prof, md_path, meta) or {}
return {
"status": "ok",
"pdf_path": rpdf.get("path"),
"pptx_path": rpptx.get("path"),
"aeda_md_path": rmd.get("path"),
"manifest_path": rpdf.get("manifest_path"),
"n_pages": rpdf.get("n_pages"),
"n_slides": rpptx.get("n_slides"),
"md_chars": rmd.get("n_chars"),
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"md_note": rmd.get("note"),
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
return {"status": "error", "error": str(e)}