diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index bdf596e7..a1e6331f 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -65,12 +65,14 @@ from .render_automatic_eda_pdf import render_automatic_eda_pdf from .render_automatic_eda_pptx import render_automatic_eda_pptx from .detect_time_column import detect_time_column from .extract_timeseries_raw import extract_timeseries_raw +from .build_eda_render_ctx import build_eda_render_ctx from .profile_datetime import profile_datetime from .resample_timeseries import resample_timeseries __all__ = [ "detect_time_column", "extract_timeseries_raw", + "build_eda_render_ctx", "profile_datetime", "resample_timeseries", "render_automatic_eda_pdf", diff --git a/python/functions/datascience/build_eda_render_ctx.md b/python/functions/datascience/build_eda_render_ctx.md new file mode 100644 index 00000000..4f098383 --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx.md @@ -0,0 +1,114 @@ +--- +name: build_eda_render_ctx +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: impure +signature: "def build_eda_render_ctx(db_path: str, table: str, profile: dict, backend: str = 'duckdb', sample: int = 5000, base_ctx: dict = None) -> dict" +description: "Constructor del `ctx` de datos crudos del motor AutomaticEDA. Dado un db_path+table (DuckDB o Postgres) y el TableProfile AGREGADO ya calculado por profile_table, produce el dict ctx que los renderers (render_automatic_eda_pdf/_pptx -> build_document(profile, ctx)) pasan a los capitulos que necesitan DATOS CRUDOS no presentes en el perfil agregado: modelos (project_clusters_2d en vivo), timeseries, geospatial y agregacion (groupby/pivot push-down). NO trae tablas enteras a RAM: muestrea con LIMIT sample y delega el push-down de la serie en extract_timeseries_raw. Construye el lector read-only query_fn(sql)->dict igual que profile_table (closure sobre duckdb_query_readonly / pg_query). Estilo dict-no-throw del grupo eda: NUNCA lanza; si una pieza falla, degrada esa clave a ausente/[] y sigue. Devuelve el ctx dict directamente (NO un wrapper {status,...}); se pasa tal cual como meta={'ctx': }. Claves de datos que produce: raw_numeric (muestra cruda alineada por fila), timeseries_raw (fechas+series), geo_points (lats/lons) y db_path+table para el push-down de agregacion. Respeta base_ctx: parte de una copia y solo AÑADE las claves de datos; las de presentacion (dataset_name, source_origin, ...) no se pisan." +tags: [eda, datascience, automatic-eda, render, ctx, extraction, read-only, duckdb, postgres, python] +uses_functions: [detect_time_column_py_datascience, extract_timeseries_raw_py_datascience, detect_latlon_columns_py_datascience, duckdb_query_readonly_py_infra, pg_query_py_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: db_path + desc: "ruta al archivo DuckDB, o DSN PostgreSQL si backend='postgres'. Se guarda tal cual en ctx['db_path'] (el capitulo agregacion lo usa para el groupby/pivot push-down via DuckDB) y se inyecta en el closure query_fn. No se valida aqui: si la base no existe, las queries devuelven status error y las claves de datos se omiten." + - name: table + desc: "nombre de la tabla. Se escapa con comillas dobles en las queries (raw_numeric y timeseries) y se guarda en ctx['table']." + - name: profile + desc: "TableProfile AGREGADO producido por profile_table. Solo se lee su clave `columns` (lista de ColumnProfile dict con name / inferred_type / numeric.{min,max} / semantic_type). Lectura defensiva: si no es dict o no tiene columns, se trata como []. NO se traen las filas crudas de aqui — se muestrean de la base." + - name: backend + desc: "'duckdb' (default) o 'postgres'. Selecciona el lector read-only del registry (duckdb_query_readonly / pg_query). Cualquier otro valor devuelve el base_ctx tal cual, SIN añadir claves de datos (ni siquiera db_path/table)." + - name: sample + desc: "maximo de filas a muestrear (clausula LIMIT) tanto para raw_numeric (una sola query SELECT de las numericas) como para timeseries_raw (max_rows de extract_timeseries_raw). Default 5000. Acota memoria y tiempo de render." + - name: base_ctx + desc: "dict opcional con claves de PRESENTACION ya preparadas (dataset_name, source_origin, ...). Se parte de una copia y NO se pisan sus claves; solo se añaden las de datos. Default None -> {}." +output: "El dict `ctx` directamente (NO un wrapper {status,...}); se pasa tal cual como meta={'ctx': } a render_automatic_eda_pdf/pptx. Nunca lanza. Para backends validos contiene SIEMPRE db_path + table, y opcionalmente: raw_numeric {col:[float|None,...]} (muestra cruda alineada por fila; omitida si no hay numericas o falla la query), timeseries_raw {time_col, t:[iso...], series:{col:[float|None,...]}} (solo si hay columna temporal + numericas y trae filas), geo_points {lats:[...], lons:[...]} (solo si se detecta par lat/lon y ambas estan en raw_numeric). Ante fallo global devuelve al menos {**base_ctx, 'db_path': db_path, 'table': table}. Backend desconocido -> base_ctx tal cual sin claves de datos." +tested: true +tests: ["test_db_path_y_table_en_ctx", "test_raw_numeric_con_columnas_numericas", "test_timeseries_raw_con_fecha", "test_geo_points_con_latlon", "test_sin_fecha_no_hay_timeseries", "test_base_ctx_preservado"] +test_file_path: "python/functions/datascience/build_eda_render_ctx_test.py" +file_path: "python/functions/datascience/build_eda_render_ctx.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from datascience import build_eda_render_ctx, render_automatic_eda_pdf +from datascience import profile_table # opcional: para obtener el TableProfile + +# 1) Perfil agregado de la tabla (push-down, sin RAM). +prof = profile_table("data/ventas.duckdb", "ventas_geo", write_report=False)["profile"] + +# 2) ctx de datos crudos para los capitulos (muestrea con LIMIT, no carga todo). +ctx = build_eda_render_ctx( + "data/ventas.duckdb", "ventas_geo", prof, + backend="duckdb", sample=5000, + base_ctx={"dataset_name": "Ventas con geolocalizacion"}, +) +# ctx == { +# "dataset_name": "Ventas con geolocalizacion", # preservado del base_ctx +# "db_path": "data/ventas.duckdb", "table": "ventas_geo", +# "raw_numeric": {"ventas": [1200.5, ...], "lat": [40.41, ...], "lon": [-3.70, ...]}, +# "timeseries_raw": {"time_col": "fecha", "t": ["2024-01-01", ...], "series": {...}}, +# "geo_points": {"lats": [40.41, ...], "lons": [-3.70, ...]}, +# } + +# 3) Se entrega tal cual a los renderers via meta={"ctx": ctx}. +render_automatic_eda_pdf(prof, "reports/eda.pdf", meta={"ctx": ctx}) +``` + +## Cuando usarla + +Justo antes de renderizar un AutomaticEDA (PDF o PPTX), cuando ya tienes el +TableProfile AGREGADO de `profile_table` pero los capitulos de modelos, +timeseries, geospatial y agregacion necesitan DATOS CRUDOS que el perfil +agregado no lleva (la muestra numerica alineada por fila, la serie cronologica, +el par lat/lon, y el db_path/table para el push-down del groupby/pivot). Es el +puente entre el perfil agregado y `build_document(profile, ctx)`: una sola +llamada produce el `ctx` completo muestreando con `LIMIT` en vez de cargar la +tabla entera en memoria. + +## Gotchas + +- **Impura**: lee de la base de datos a traves de `query_fn` (closure sobre + `duckdb_query_readonly` / `pg_query`). No abre conexiones fuera de esos + wrappers del registry. Estilo dict-no-throw del grupo `eda`: NUNCA lanza; ante + cualquier fallo (query, deteccion, render de una clave) degrada esa clave a + ausente/`[]` y sigue. Ante un fallo global devuelve al menos + `{**base_ctx, "db_path": db_path, "table": table}`. +- **`error_type` en el frontmatter es `error_go_core` por convencion del + registry** (toda funcion impura debe declararlo y el indexer lo exige), pero el + codigo NO lanza esa excepcion: degrada al ctx parcial. Es metadata, no + comportamiento. +- **Devuelve el ctx dict directamente, NO un wrapper `{status,...}`**: a + diferencia de `extract_timeseries_raw` / `profile_table`, esta funcion es el + ultimo eslabon antes del render y su salida se pasa tal cual como + `meta={"ctx": }`. No envuelvas su retorno. +- **Backend desconocido**: con un `backend` que no sea `duckdb` ni `postgres` + devuelve el `base_ctx` tal cual, SIN claves de datos (ni siquiera + `db_path`/`table`). Comprueba el backend antes si dependes de esas claves. +- **Alineacion por fila de `raw_numeric`**: `raw_numeric[col]` tiene una entrada + por fila muestreada (un valor no convertible a float queda como `None`, no se + descarta la fila) porque `project_clusters_2d` descarta filas listwise: todas + las columnas deben tener la MISMA longitud. `geo_points` se construye desde + `raw_numeric` para heredar esa alineacion. +- **`geo_points` exige lat/lon en `raw_numeric`**: el par lat/lon solo se adjunta + si ambas columnas se detectaron (nombre+rango) Y figuran en `raw_numeric` + (es decir, son numericas en el perfil). Si la tabla guarda lat/lon como texto + no promovido a numeric, no apareceran; el capitulo geospatial sabe degradar. +- **`timeseries_raw` depende del orden del backend**: hereda el `ORDER BY + "time_col"` de `extract_timeseries_raw`. Si la columna temporal esta guardada + como texto no ordenable lexicograficamente (p.ej. `DD/MM/YYYY`), el orden no + sera el cronologico real — normaliza la columna a date/timestamp antes. +- **`LIMIT sample`**: con tablas grandes obtienes el primer tramo (raw_numeric + por orden fisico, timeseries por orden cronologico), no un muestreo uniforme. + Sube `sample` si necesitas mas cobertura. +- **No loguear los datos crudos**: `raw_numeric` / `timeseries_raw` / + `geo_points` pueden contener datos sensibles. En trazas usa solo conteos y + nombres de columna, no el ctx completo. diff --git a/python/functions/datascience/build_eda_render_ctx.py b/python/functions/datascience/build_eda_render_ctx.py new file mode 100644 index 00000000..efcda2cb --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx.py @@ -0,0 +1,200 @@ +"""build_eda_render_ctx — constructor del `ctx` de datos crudos del motor AutomaticEDA. + +Funcion impura (lee de la base de datos) del grupo de capacidad `eda`. Dado un +``db_path`` + ``table`` (DuckDB o PostgreSQL) y el ``TableProfile`` AGREGADO ya +calculado por ``profile_table``, produce el dict ``ctx`` que los renderers +(``render_automatic_eda_pdf`` / ``render_automatic_eda_pptx`` -> +``build_document(profile, ctx)``) pasan a los capitulos que necesitan DATOS +CRUDOS no presentes en el perfil agregado: modelos (``project_clusters_2d`` en +vivo), timeseries, geospatial y agregacion (groupby/pivot push-down). + +NO trae tablas enteras a RAM: muestrea con ``LIMIT sample`` y, para la serie +temporal, delega el push-down en ``extract_timeseries_raw`` (una sola query +ordenada). El lector read-only ``query_fn(sql) -> dict`` se construye igual que +en ``profile_table`` (un closure sobre ``duckdb_query_readonly`` / ``pg_query``) +y nunca abre conexiones fuera de esos wrappers. + +Estilo dict-no-throw del grupo `eda`: la funcion NUNCA lanza. Si una pieza falla +(query, deteccion, render de una clave), esa clave se degrada a ausente / lista +vacia y el resto del ctx se construye igual. Ante un fallo global devuelve al +menos ``{**base_ctx, "db_path": db_path, "table": table}``. + +Claves de DATOS que produce (las consumen los capitulos): + - ``raw_numeric`` : {col: [float|None, ...]} muestra cruda de las columnas + numericas, ALINEADA POR FILA (una entrada por fila aunque + sea None). La leen modelos (clustering 2D en vivo) y + geospatial (lat/lon salen de aqui). + - ``timeseries_raw`` : {time_col, t: [iso...], series: {col: [float|None, ...]}}. + La lee el capitulo TIMESERIES. + - ``geo_points`` : {lats: [...], lons: [...]} listas alineadas (ya floats). + La lee el capitulo GEOSPATIAL. + - ``db_path``, ``table`` : los usa el capitulo AGREGACION para el groupby/pivot + push-down via DuckDB. + +Las claves de PRESENTACION que traiga ``base_ctx`` (dataset_name, source_origin, +...) NO se pisan: esta funcion solo AÑADE las claves de datos sobre una copia. +""" + + +def _to_float(value): + """Convierte un valor a float de forma defensiva. None si no es convertible. + + Un bool es subclase de int en Python pero nunca es un valor numerico de + serie/coordenada valido, asi que se trata como None (mismo criterio que + extract_timeseries_raw / detect_latlon_columns). + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + s = str(value).strip() + if not s: + return None + try: + return float(s) + except (TypeError, ValueError): + return None + + +def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None): + """Construye el ctx de datos crudos para los renderers de AutomaticEDA. + + Args: + db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres". + Se guarda tal cual en ctx["db_path"] (el capitulo agregacion lo usa + para el push-down). + table: nombre de la tabla. Se escapa con comillas dobles en las queries y + se guarda en ctx["table"]. + profile: TableProfile agregado producido por profile_table. Solo se lee + su clave ``columns`` (lista de ColumnProfile dict con name / + inferred_type / numeric.{min,max} / semantic_type). Lectura + defensiva: si no es dict o no tiene columns, se trata como []. + backend: "duckdb" (default) o "postgres". Selecciona el lector read-only + (duckdb_query_readonly / pg_query). Cualquier otro valor devuelve el + base_ctx tal cual, sin añadir claves de datos. + sample: maximo de filas a muestrear (clausula LIMIT) tanto para + raw_numeric como para timeseries_raw. Default 5000. + base_ctx: dict opcional con claves de presentacion ya preparadas + (dataset_name, source_origin, ...). Se parte de una copia y NO se + pisan sus claves; solo se añaden las de datos. Default None -> {}. + + Returns: + El dict ``ctx`` directamente (NO un wrapper {status,...}): se pasa tal + cual como ``meta={"ctx": }`` a render_automatic_eda_pdf/pptx. + Nunca lanza. Claves que puede contener: raw_numeric, timeseries_raw, + geo_points (omitidas si no aplican o fallan), y siempre db_path + table + para backends validos. + """ + # Copia de base_ctx: nunca mutamos el dict del caller. Las claves de + # presentacion que ya traiga se conservan; las de datos se añaden encima. + ctx = dict(base_ctx) if isinstance(base_ctx, dict) else {} + + try: + # 1) Lector read-only del backend activo, construido EXACTAMENTE como en + # profile_table (closure sobre el wrapper del registry). Imports perezosos + # dentro de la funcion: este modulo vive en el paquete `datascience`, asi + # que importar sus hermanas a nivel de modulo crearia un ciclo al cargar + # el __init__ del paquete. Lazy import rompe el ciclo y respeta el + # contrato (imports explicitos, sin `import *`). + if backend == "duckdb": + from infra import duckdb_query_readonly + + def query_fn(sql): + return duckdb_query_readonly(db_path, sql) + + elif backend == "postgres": + from infra import pg_query + + def query_fn(sql): + return pg_query(db_path, sql) + + else: + # Backend desconocido: devolver base_ctx tal cual, sin claves de datos. + return ctx + + # 7) db_path + table SIEMPRE (para backends validos): el capitulo + # agregacion los necesita para el groupby/pivot push-down via DuckDB. + ctx["db_path"] = db_path + ctx["table"] = table + + # 2) Columnas del perfil agregado (lectura defensiva). + cols = profile.get("columns") if isinstance(profile, dict) else None + cols = cols or [] + + # 3) Deteccion temporal/numerica con la funcion PURA del registry. + from datascience import detect_time_column + + det = detect_time_column(cols) + time_col = det.get("time_col") + numeric_cols = det.get("numeric_cols") or [] + + # 4) raw_numeric: muestra de las columnas numericas CRUDAS, ALINEADAS POR + # FILA en UNA sola query. Cada columna queda con una entrada por fila + # (None si no parsea) para no desalinear filas: project_clusters_2d + # descarta filas listwise, asi que las listas deben tener igual longitud. + raw_numeric = {} + if numeric_cols: + try: + cols_sql = ", ".join(f'"{c}"' for c in numeric_cols) + sql = f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}' + q = query_fn(sql) + if isinstance(q, dict) and q.get("status") == "ok": + rows = q.get("rows", []) or [] + raw_numeric = {c: [] for c in numeric_cols} + for row in rows: + for c in numeric_cols: + raw_numeric[c].append(_to_float(row.get(c))) + except Exception: # noqa: BLE001 - dict-no-throw: degradar la clave + raw_numeric = {} + if raw_numeric: + ctx["raw_numeric"] = raw_numeric + + # 5) timeseries_raw: SOLO si hay columna temporal y numericas. Se delega + # el push-down en la funcion impura extract_timeseries_raw (una sola query + # ordenada cronologicamente). Solo se adjunta si trae filas. + if time_col and numeric_cols: + try: + from datascience import extract_timeseries_raw + + ts = extract_timeseries_raw( + query_fn, table, time_col, numeric_cols, max_rows=sample + ) + if ( + isinstance(ts, dict) + and ts.get("status") == "ok" + and (ts.get("n") or 0) > 0 + ): + ctx["timeseries_raw"] = { + "time_col": ts["time_col"], + "t": ts["t"], + "series": ts["series"], + } + except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave + pass + + # 6) geo_points: detecta el par lat/lon con la funcion PURA del registry. + # Solo se adjunta si AMBAS columnas estan en raw_numeric (ya floats, + # alineadas por fila). Si no hay par o no estan, se omite: el capitulo + # geospatial sabe degradar. + try: + from datascience import detect_latlon_columns + + geo = detect_latlon_columns(cols) + lat_col = geo.get("lat_col") + lon_col = geo.get("lon_col") + if lat_col and lon_col and lat_col in raw_numeric and lon_col in raw_numeric: + ctx["geo_points"] = { + "lats": raw_numeric[lat_col], + "lons": raw_numeric[lon_col], + } + except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave + pass + + return ctx + except Exception: # noqa: BLE001 - dict-no-throw global: nunca reventar. + # Fallback minimo: copia de base_ctx + db_path/table para que el capitulo + # agregacion siga teniendo lo imprescindible. + out = dict(base_ctx) if isinstance(base_ctx, dict) else {} + out["db_path"] = db_path + out["table"] = table + return out diff --git a/python/functions/datascience/build_eda_render_ctx_test.py b/python/functions/datascience/build_eda_render_ctx_test.py new file mode 100644 index 00000000..bf8aee7a --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx_test.py @@ -0,0 +1,153 @@ +"""Tests para build_eda_render_ctx. + +Self-contained: crea un DuckDB temporal pequeño con una columna fecha, varias +numericas y un par lat/lon, construye un TableProfile minimo a mano (con la forma +de columnas del grupo `eda`: name / inferred_type / numeric.{min,max} / +semantic_type) y verifica que el ctx producido contiene las claves de datos que +consumen los capitulos del AutomaticEDA. +""" + +import os +import sys + +# El test importa funciones del registry como una app del registry: inserta el +# directorio raiz `python/functions` en sys.path y luego `from datascience import`. +_FUNCTIONS_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if _FUNCTIONS_ROOT not in sys.path: + sys.path.insert(0, _FUNCTIONS_ROOT) + +import duckdb # noqa: E402 + +from datascience import build_eda_render_ctx # noqa: E402 + +_TABLE = "ventas_geo" +# Filas: fecha creciente, 2 columnas numericas (ventas, unidades) y un par lat/lon +# (Madrid -> lat ~40, lon ~-3, dentro de [-90,90] y [-180,180]). +_ROWS = [ + ("2024-01-01", 1200.5, 12, 40.41, -3.70), + ("2024-01-02", 980.0, 9, 41.38, 2.17), + ("2024-01-03", 1500.25, 15, 37.39, -5.99), + ("2024-01-04", 1100.0, 11, 39.47, -0.38), + ("2024-01-05", 1750.75, 18, 43.26, -2.93), +] + + +def _make_db(tmp_path): + """Crea un DuckDB temporal con la tabla de prueba y devuelve su ruta.""" + db_path = os.path.join(str(tmp_path), "eda_ctx.duckdb") + con = duckdb.connect(db_path) + try: + con.execute( + f'CREATE TABLE "{_TABLE}" ' + "(fecha DATE, ventas DOUBLE, unidades INTEGER, lat DOUBLE, lon DOUBLE)" + ) + con.executemany( + f'INSERT INTO "{_TABLE}" VALUES (?, ?, ?, ?, ?)', _ROWS + ) + finally: + con.close() + return db_path + + +def _profile_with_date(): + """TableProfile minimo con columna fecha + numericas + lat/lon.""" + return { + "columns": [ + {"name": "fecha", "inferred_type": "datetime", "semantic_type": "datetime_iso"}, + { + "name": "ventas", + "inferred_type": "numeric", + "semantic_type": "decimal", + "numeric": {"min": 980.0, "max": 1750.75}, + }, + { + "name": "unidades", + "inferred_type": "numeric", + "semantic_type": "integer", + "numeric": {"min": 9, "max": 18}, + }, + { + "name": "lat", + "inferred_type": "numeric", + "semantic_type": "decimal", + "numeric": {"min": 37.39, "max": 43.26}, + }, + { + "name": "lon", + "inferred_type": "numeric", + "semantic_type": "decimal", + "numeric": {"min": -5.99, "max": 2.17}, + }, + ] + } + + +def _profile_without_date(): + """Mismo perfil pero SIN columna temporal (solo numericas).""" + prof = _profile_with_date() + prof["columns"] = [c for c in prof["columns"] if c["name"] != "fecha"] + return prof + + +def test_db_path_y_table_en_ctx(tmp_path): + db_path = _make_db(tmp_path) + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date()) + assert ctx["db_path"] == db_path + assert ctx["table"] == _TABLE + + +def test_raw_numeric_con_columnas_numericas(tmp_path): + db_path = _make_db(tmp_path) + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date()) + raw = ctx["raw_numeric"] + # Las 4 columnas numericas (ventas, unidades, lat, lon), listas no vacias y + # alineadas por fila (misma longitud == nº de filas). + for col in ("ventas", "unidades", "lat", "lon"): + assert col in raw + assert len(raw[col]) == len(_ROWS) + assert raw["ventas"][0] == 1200.5 + assert raw["unidades"][0] == 12.0 # int promovido a float + + +def test_timeseries_raw_con_fecha(tmp_path): + db_path = _make_db(tmp_path) + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date()) + ts = ctx["timeseries_raw"] + assert ts["time_col"] == "fecha" + assert len(ts["t"]) == len(_ROWS) # fechas ISO no vacias + # Las numericas aparecen como series paralelas a t. + for col in ("ventas", "unidades", "lat", "lon"): + assert col in ts["series"] + assert len(ts["series"][col]) == len(_ROWS) + + +def test_geo_points_con_latlon(tmp_path): + db_path = _make_db(tmp_path) + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date()) + geo = ctx["geo_points"] + assert len(geo["lats"]) == len(_ROWS) + assert len(geo["lons"]) == len(_ROWS) + # Listas alineadas, ya floats, leidas de raw_numeric. + assert geo["lats"][0] == 40.41 + assert geo["lons"][0] == -3.70 + + +def test_sin_fecha_no_hay_timeseries(tmp_path): + db_path = _make_db(tmp_path) + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_without_date()) + assert "timeseries_raw" not in ctx + # raw_numeric y geo_points siguen presentes (no dependen de la fecha). + assert "raw_numeric" in ctx + assert "geo_points" in ctx + + +def test_base_ctx_preservado(tmp_path): + db_path = _make_db(tmp_path) + base = {"dataset_name": "ventas_geo_demo", "source_origin": "test"} + ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date(), base_ctx=base) + # Las claves de presentacion del base_ctx no se pisan. + assert ctx["dataset_name"] == "ventas_geo_demo" + assert ctx["source_origin"] == "test" + # Y las de datos se añaden encima. + assert ctx["db_path"] == db_path + assert "raw_numeric" in ctx diff --git a/python/functions/pipelines/profile_table.md b/python/functions/pipelines/profile_table.md index eb8ce6d2..2808965a 100644 --- a/python/functions/pipelines/profile_table.md +++ b/python/functions/pipelines/profile_table.md @@ -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:, 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:, 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 diff --git a/python/functions/pipelines/profile_table.py b/python/functions/pipelines/profile_table.py index 8838a81e..8a0077af 100644 --- a/python/functions/pipelines/profile_table.py +++ b/python/functions/pipelines/profile_table.py @@ -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)} diff --git a/python/functions/pipelines/render_automatic_eda.md b/python/functions/pipelines/render_automatic_eda.md new file mode 100644 index 00000000..b157dfd2 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda.md @@ -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__'." + - 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:} 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). diff --git a/python/functions/pipelines/render_automatic_eda.py b/python/functions/pipelines/render_automatic_eda.py new file mode 100644 index 00000000..c0b58065 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda.py @@ -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_
_". + 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: + # 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)} diff --git a/python/functions/pipelines/render_automatic_eda_test.py b/python/functions/pipelines/render_automatic_eda_test.py new file mode 100644 index 00000000..a463e4f7 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda_test.py @@ -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