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
+7 -2
View File
@@ -5,7 +5,7 @@ lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, emit_automatic: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla."
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
uses_functions:
@@ -26,6 +26,9 @@ uses_functions:
- exploratory_caveats_py_datascience
- render_eda_markdown_py_datascience
- render_eda_pdf_py_datascience
- build_eda_render_ctx_py_datascience
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
- duckdb_query_readonly_py_infra
- pg_query_py_infra
uses_types: []
@@ -55,11 +58,13 @@ params:
desc: "Si True (default False) calcula por columna numerica un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF, STL y, si parece de niveles, retornos). Ordena por la primera columna datetime si existe; si no, por el orden fisico. Guardado en col['series'] y agregado en prof['series']."
- name: emit_pdf
desc: "Si True (default False) renderiza un PDF multipagina vertical (legible en movil) del perfil junto al report markdown y devuelve su ruta en pdf_path."
- name: emit_automatic
desc: "Si True (default False) emite ADEMAS el informe AutomaticEDA completo en PDF (A5 movil) Y PPTX (16:9) con los 11 capitulos del motor; construye el ctx de datos crudos con build_eda_render_ctx para que modelos/timeseries/geospatial/agregacion salgan poblados. Aditivo: no sustituye a emit_pdf. Rutas en aeda_pdf_path / aeda_pptx_path / aeda_manifest_path."
- name: report_dir
desc: "Directorio donde escribir los reports si write_report (y el PDF si emit_pdf). Default 'reports'. Se crea si no existe."
- name: write_report
desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths markdown/json del retorno son None (emit_pdf es independiente)."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None} o {status:'error', error:str} (dict-no-throw)."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None, aeda_pdf_path:str|None, aeda_pptx_path:str|None, aeda_manifest_path:str|None (estos tres solo con emit_automatic)} o {status:'error', error:str} (dict-no-throw)."
---
## Ejemplo
@@ -32,11 +32,14 @@ from datascience import (
acf_pacf,
adf_kpss_stationarity,
association_matrix,
build_eda_render_ctx,
column_quality_score,
describe_numeric,
eda_llm_insights,
exploratory_caveats,
infer_semantic_type,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
render_eda_markdown,
render_eda_pdf,
run_eda_models,
@@ -385,6 +388,7 @@ def profile_table(
run_llm: bool = False,
run_series: bool = False,
emit_pdf: bool = False,
emit_automatic: bool = False,
report_dir: str = "reports",
write_report: bool = True,
) -> dict:
@@ -412,6 +416,15 @@ def profile_table(
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
(legible en movil) del perfil junto al report markdown y devuelve su
ruta en pdf_path.
emit_automatic: si True (default False) emite ademas el informe
AutomaticEDA COMPLETO en sus dos formatos (PDF A5 movil + PPTX 16:9)
con los 11 capitulos del motor por capitulos. Construye el contexto
de datos crudos con build_eda_render_ctx (raw_numeric para modelos/
geo, timeseries_raw para series, geo_points para el mapa, db_path/
table para la agregacion push-down) para que los capitulos modelos/
timeseries/geospatial/agregacion salgan poblados, no degradados. Es
ADITIVO: no sustituye a emit_pdf (render_eda_pdf). Sus rutas vuelven
en aeda_pdf_path / aeda_pptx_path / aeda_manifest_path.
report_dir: directorio donde escribir los reports si write_report.
Default "reports". Se crea si no existe.
write_report: si True (default), escribe un report markdown + un JSON
@@ -727,12 +740,51 @@ def profile_table(
except Exception: # noqa: BLE001
pdf_path = None
# Informe AutomaticEDA completo (PDF + PPTX por capitulos). Aditivo:
# convive con emit_pdf (render_eda_pdf) sin sustituirlo. Construye el ctx
# con los datos crudos para que modelos/timeseries/geospatial/agregacion
# salgan poblados; degrada por clave si build_eda_render_ctx falla.
aeda_pdf_path = None
aeda_pptx_path = None
aeda_manifest_path = None
if emit_automatic:
try:
os.makedirs(report_dir, exist_ok=True)
base_ctx = {
"dataset_name": table,
"source_origin": db_path,
"storage": "DuckDB" if backend == "duckdb" else (
"PostgreSQL" if backend == "postgres" else backend),
}
if run_llm:
base_ctx.update({"run_cluster_llm": True,
"run_geo_llm": True, "run_agg_llm": True})
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx)
meta = {"title": f"EDA — {table}", "ctx": ctx}
aeda_pdf_target = os.path.join(report_dir,
f"aeda_{table}_{ts}.pdf")
aeda_pptx_target = os.path.join(report_dir,
f"aeda_{table}_{ts}.pptx")
rpdf = render_automatic_eda_pdf(prof, aeda_pdf_target, meta) or {}
rpptx = render_automatic_eda_pptx(
prof, aeda_pptx_target, meta) or {}
aeda_pdf_path = rpdf.get("path")
aeda_pptx_path = rpptx.get("path")
aeda_manifest_path = rpdf.get("manifest_path")
except Exception: # noqa: BLE001
pass
return {
"status": "ok",
"profile": prof,
"report_md_path": report_md_path,
"report_json_path": report_json_path,
"pdf_path": pdf_path,
"aeda_pdf_path": aeda_pdf_path,
"aeda_pptx_path": aeda_pptx_path,
"aeda_manifest_path": aeda_manifest_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,91 @@
---
name: render_automatic_eda
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "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"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions:
- profile_table_py_pipelines
- build_eda_render_ctx_py_datascience
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: true
tests:
- "render end-to-end sobre DuckDB sintetico con categoricas + fecha + lat/lon emite PDF y PPTX con paginas/slides"
test_file_path: "python/functions/pipelines/render_automatic_eda_test.py"
file_path: "python/functions/pipelines/render_automatic_eda.py"
params:
- name: db_path
desc: "Ruta al archivo DuckDB (read-only, debe existir) o DSN PostgreSQL si backend='postgres'."
- name: table
desc: "Nombre de la tabla a perfilar e informar."
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado y muestreo."
- name: sample
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default 5000."
- name: run_models
desc: "Si True (default) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA."
- name: run_series
desc: "Si True (default) calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries (la grafica de evolucion sale de los datos crudos del ctx aunque sea False)."
- name: run_llm
desc: "Si True (default False) hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red."
- name: out_dir
desc: "Directorio de salida (se crea si no existe). Default 'reports'."
- name: basename
desc: "Nombre base de los archivos sin extension. Default 'aeda_<table>_<timestamp>'."
- name: ctx_extra
desc: "Dict opcional con claves de presentacion/contexto extra que se mezclan en el ctx (dataset_name, description, source_origin, ...); no pisan las claves de datos calculadas por build_eda_render_ctx."
output: "dict {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>} o {status:'error', error:str} (dict-no-throw)."
---
## Ejemplo
```python
from pipelines.render_automatic_eda import render_automatic_eda
# Tabla DuckDB con categoricas + fecha + numericas: informe completo a reports/.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
run_models=True, run_series=True, out_dir="reports")
print(r["status"], r["pdf_path"], r["pptx_path"], r["n_pages"], r["n_slides"])
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 14 16
# Con narrativa LLM (titulos de segmento, descripcion geografica, etc.):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", run_llm=True)
```
## Cuando usarla
Cuando quieras el informe AutomaticEDA COMPLETO (PDF + PPTX) de una tabla en una
sola llamada, con los capitulos de modelos, series, geoespacial y agregacion ya
poblados (no degradados). Es el reemplazo de "perfila + monta el ctx a mano +
llama a los dos renderers": este pipeline orquesta `profile_table` ->
`build_eda_render_ctx` -> `render_automatic_eda_pdf`/`_pptx`. Usalo como
entregable para compartir un EDA, o como el motor detras de `profile_table(
emit_automatic=True)` y del skill `/eda`.
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
- `db_path` debe existir: DuckDB read-only no crea la base.
- `run_models=True` y `run_series=True` por defecto encarecen el perfil (PCA/
KMeans/IsolationForest + ADF/KPSS/STL por columna). Para un informe mas barato
ponlos a False: los capitulos modelos/timeseries se omiten o se reducen, pero
el resto del informe sale igual.
- `run_llm=True` hace llamadas de red (interpretacion del perfil + narrativa por
capitulo). Sin red, dejalo en False: los capitulos siguen completos con su
derivacion cuantitativa (titulos de segmento derivados, nota geografica
derivada, seleccion de agregaciones cuantitativa).
- El PPTX requiere `python-pptx`; si no esta instalado, `pptx_path` sera None y
`pptx_note` lo explica (el PDF se emite igual).
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla
entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria).
@@ -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)}
@@ -0,0 +1,91 @@
"""Test del pipeline render_automatic_eda — EDA completo a PDF + PPTX.
Self-contained: crea un DuckDB temporal pequeño con categóricas + fecha + lat/lon
+ varias numéricas, corre el pipeline (sin LLM) y verifica que emite PDF y PPTX
con páginas/slides, manifest, y que los capítulos dependientes de ctx quedan
POBLADOS (sin la nota de degradación).
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
import duckdb # noqa: E402
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
def _make_db(path):
con = duckdb.connect(path)
con.execute(
"CREATE TABLE sales (d DATE, region VARCHAR, channel VARCHAR, "
"amount DOUBLE, units INTEGER, lat DOUBLE, lon DOUBLE)"
)
from datetime import date, timedelta
regions = ["norte", "sur", "este"]
channels = ["web", "tienda"]
centers = {"norte": (43.0, -3.0), "sur": (37.0, -5.0), "este": (39.5, -0.4)}
rows = []
d0 = date(2024, 1, 1)
for i in range(180):
r = regions[i % 3]
ch = channels[i % 2]
clat, clon = centers[r]
rows.append((
d0 + timedelta(days=i), r, ch,
round(100 + (i % 7) * 13.5 + (5 if ch == "web" else 0), 2),
10 + (i % 5),
round(clat + (i % 3) * 0.1, 4),
round(clon + (i % 4) * 0.1, 4),
))
con.executemany("INSERT INTO sales VALUES (?,?,?,?,?,?,?)", rows)
con.close()
def test_pipeline_emits_pdf_and_pptx_with_chapters(tmp_path):
db = str(tmp_path / "sales.duckdb")
_make_db(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "sales", run_models=True, run_series=True,
run_llm=False, out_dir=out, basename="test_sales")
assert r["status"] == "ok", r.get("error")
# Both formats produced.
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert (r["n_pages"] or 0) > 0
assert (r["n_slides"] or 0) > 0
# Per-chapter manifest written next to the output.
assert r["manifest_path"] and os.path.exists(r["manifest_path"])
def test_pipeline_chapters_populated_not_degraded(tmp_path):
"""The 4 ctx-dependent chapters build with real data (no degradation note)."""
import json
db = str(tmp_path / "sales.duckdb")
_make_db(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "sales", run_models=True, run_series=True,
run_llm=False, out_dir=out, basename="t2")
assert r["status"] == "ok"
# The manifest lists the ctx-dependent chapters as actually rendered.
with open(r["manifest_path"], encoding="utf-8") as fh:
man = json.load(fh)
chapters = man.get("chapters") or {}
for cid in ("modelos", "timeseries", "geospatial", "agregacion"):
assert cid in chapters, f"capítulo {cid} ausente del manifest: {list(chapters)}"
def test_pipeline_bad_db_degrades_without_raising(tmp_path):
r = render_automatic_eda(str(tmp_path / "nope.duckdb"), "ghost",
out_dir=str(tmp_path / "o"))
assert r["status"] == "error"
assert "error" in r