From 3be188a921278167fe9720c20b1723414649f0e6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 30 Jun 2026 18:20:17 +0200 Subject: [PATCH] feat(eda): profile_level (lite/standard/full) en render_automatic_eda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade el parámetro profile_level a render_automatic_eda como preset de consumo CPU/LLM que mapea a los flags existentes (run_models, run_series, run_llm, sample). Tres niveles: - lite (bajo consumo): run_llm=False, run_series=False, sample=2000 y modelos limitados a PCA + normalidad, SIN KMeans ni IsolationForest (lo caro en CPU). Para un vistazo rápido y barato. - standard (default): comportamiento histórico — modelos completos, serie, sin LLM. - full: standard + narrativa LLM por capítulo. Precedencia: un flag explícito del caller (run_llm=..., run_models=..., etc.) siempre prima sobre el default que fija el preset; el preset solo aplica al parámetro que se deja en None. Cableado del modo lite sin tocar profile_table (lo tocan otros agentes en paralelo): profile_table NO corre los modelos (evita pagar KMeans + IsolationForest); este pipeline los corre con run_eda_models(run_kmeans=False, run_isolation=False) reusando ctx['raw_numeric'], y quita raw_numeric del ctx para que el capítulo modelos no reproyecte clusters KMeans en vivo (project_clusters_2d). geo_points ya queda derivado, así que geospatial no se afecta. Cambio aditivo y retro-compatible: sin profile_level el comportamiento es idéntico al de v1.0.0 (standard). Tests nuevos cubren lite/standard, la precedencia flag-sobre-preset, y la equivalencia del default con el histórico. Bump 1.0.0 -> 1.1.0 + growth log en el .md. Skill /eda documenta --lite/--full. Verificación: golden lite/standard/full sobre titanic — lite 4.8s (PCA+norm, sin KMeans/iso/LLM/serie), standard 7.8s (modelos completos), full 38.3s (+LLM). Suite render_automatic_eda + automatic_eda: 96 passed. fn index sin error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/eda.md | 4 +- .../pipelines/render_automatic_eda.md | 72 +++++--- .../pipelines/render_automatic_eda.py | 125 +++++++++++-- .../pipelines/render_automatic_eda_test.py | 167 ++++++++++++++++++ 4 files changed, 334 insertions(+), 34 deletions(-) diff --git a/.claude/commands/eda.md b/.claude/commands/eda.md index 860c340f..c358fea8 100644 --- a/.claude/commands/eda.md +++ b/.claude/commands/eda.md @@ -27,6 +27,7 @@ Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar - `--series` → `run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica). - `--pdf` → `emit_pdf=True` (PDF A5 legacy de `render_eda_pdf`, legible en móvil). - `--legacy-only` → emite SOLO el PDF legacy (sin AutomaticEDA), para casos en que solo se quiera el PDF rápido. + - `--lite` / `--bajo-consumo` → `render_automatic_eda(profile_level="lite")`: EDA barato y rápido (CI, vistazo previo, máquina sin GPU/red). Apaga LLM y serie temporal y limita los modelos a **PCA + normalidad** (sin KMeans ni IsolationForest, lo caro en CPU), con `sample` reducido. `--full` → `profile_level="full"` (standard + narrativa LLM). Por defecto `profile_level="standard"` (comportamiento histórico). Un flag explícito (`--llm`, `--models`, ...) prima sobre el preset. Por defecto, **un EDA completo emite SIEMPRE el informe AutomaticEDA en sus dos formatos: PDF (A5 móvil) Y PPTX (16:9 para compartir)** con los 11 capítulos poblados (portada, overview, distribuciones, calidad, correlaciones, modelos, series, geoespacial, agregación, interpretación LLM). Usa el pipeline `render_automatic_eda` (o `profile_table(emit_automatic=True)`), que activa `run_models` y `run_series` para que los capítulos de modelos/series/geoespacial/agregación salgan poblados. Deja `run_llm` para cuando el usuario lo pida o interese la interpretación semántica + narrativa por capítulo (es la única parte que gasta tokens del modelo). @@ -50,7 +51,8 @@ from pipelines.render_automatic_eda import render_automatic_eda # tablas de agregación). run_llm=True añade la narrativa LLM por capítulo. r = render_automatic_eda( "/ruta/datos.duckdb", "ventas", - run_models=True, run_series=True, run_llm=False, out_dir="reports", + profile_level="standard", # "lite" = bajo consumo CPU/LLM; "full" = + narrativa LLM + out_dir="reports", ) print("status:", r["status"]) print("pdf: ", r["pdf_path"], "(", r["n_pages"], "págs )") diff --git a/python/functions/pipelines/render_automatic_eda.md b/python/functions/pipelines/render_automatic_eda.md index b157dfd2..50d4bcda 100644 --- a/python/functions/pipelines/render_automatic_eda.md +++ b/python/functions/pipelines/render_automatic_eda.md @@ -4,9 +4,9 @@ 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." +version: "1.1.0" +signature: "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" +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. El parametro profile_level es un preset de consumo CPU/LLM (lite/standard/full) que mapea a los flags run_models/run_series/run_llm/sample; un flag explicito siempre prima sobre el preset. lite=bajo consumo (sin LLM, sin serie, modelos solo PCA+normalidad sin KMeans/IsolationForest, sample reducido); standard=comportamiento historico; full=standard+narrativa LLM. 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 @@ -31,13 +31,15 @@ params: - 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." + desc: "Maximo 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). Un valor explicito prima sobre el preset." - 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." + desc: "Corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo 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. Un valor explicito prima sobre el preset." - 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)." + desc: "Calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries. Default None => lo fija el preset (standard/full=True, lite=False). Un valor explicito prima sobre el preset." - 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." + desc: "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. Default None => lo fija el preset (full=True, lite/standard=False). Un valor explicito prima sobre el preset." + - name: profile_level + desc: "Preset de consumo CPU/LLM (default 'standard'). Mapea a defaults de run_models/run_series/run_llm/sample; un flag explicito SIEMPRE prima. 'lite'=bajo consumo (run_llm=False, run_series=False, sample=2000, modelos solo PCA+normalidad sin KMeans/IsolationForest); 'standard'=comportamiento historico (modelos completos, serie, sin LLM); 'full'=standard+narrativa LLM. Un nivel desconocido cae a 'standard'." - name: out_dir desc: "Directorio de salida (se crea si no existe). Default 'reports'." - name: basename @@ -52,14 +54,21 @@ output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None, ```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") +# Informe completo a reports/ (standard = comportamiento por defecto historico). +r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", 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 +# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 37 39 -# Con narrativa LLM (titulos de segmento, descripcion geografica, etc.): -r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", run_llm=True) +# Bajo consumo (CPU/LLM): vistazo rapido y barato — sin LLM, sin serie, modelos +# solo PCA + normalidad (sin KMeans/IsolationForest), sample reducido. +r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="lite") + +# Maximo: standard + narrativa LLM por capitulo (titulos de segmento, etc.). +r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="full") + +# Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM: +r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", + profile_level="lite", run_llm=True) # el LLM SI se ejecuta ``` ## Cuando usarla @@ -72,20 +81,41 @@ llama a los dos renderers": este pipeline orquesta `profile_table` -> entregable para compartir un EDA, o como el motor detras de `profile_table( emit_automatic=True)` y del skill `/eda`. +Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) usa +`profile_level="lite"`: evita KMeans + IsolationForest (lo caro en CPU), la serie +temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo, +`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo. + ## 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). +- **Precedencia de flags vs preset**: `profile_level` solo fija los DEFAULTS de + `run_models`/`run_series`/`run_llm`/`sample` (los que quedan en None). Cualquiera + de esos flags pasado explicito gana al preset. Ej: `profile_level="lite", + run_llm=True` ejecuta el LLM pese a que lite lo apaga por defecto. +- **lite y la seleccion de features de modelo**: en lite los modelos (PCA + + normalidad) corren sobre la muestra numerica cruda (`ctx['raw_numeric']`), sin la + poda fina de features que aplica el modo standard (que excluye ids enteros y + columnas de baja cardinalidad antes de PCA/KMeans). Es el coste de mantener el + cableado minimo y barato; para el analisis fino de modelos usa standard/full. +- `profile_level="standard"`/`"full"` corren PCA/KMeans/IsolationForest + + ADF/KPSS/STL por columna (caro). Para un informe mas barato usa `"lite"` (o pon + los flags a False a mano): los capitulos modelos/timeseries se reducen pero el + resto del informe sale igual. +- `run_llm=True` (preset full o flag explicito) hace llamadas de red + (interpretacion del perfil + narrativa por capitulo). Sin red, usa lite/standard: + los capitulos siguen completos con su derivacion 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). + +## Capability growth log + +- v1.1.0 (2026-06-30) — anade el parametro `profile_level` (lite/standard/full), + preset de consumo CPU/LLM que mapea a los flags run_models/run_series/run_llm/ + sample. lite limita los modelos a PCA+normalidad (cableado a run_eda_models con + run_kmeans=False/run_isolation=False) y apaga LLM/serie. Cambio aditivo y + retro-compatible: sin profile_level el comportamiento es identico al de v1.0.0. diff --git a/python/functions/pipelines/render_automatic_eda.py b/python/functions/pipelines/render_automatic_eda.py index c0b58065..8090bc1f 100644 --- a/python/functions/pipelines/render_automatic_eda.py +++ b/python/functions/pipelines/render_automatic_eda.py @@ -34,21 +34,62 @@ 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 = 5000, - run_models: bool = True, - run_series: bool = True, - run_llm: bool = False, + 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, @@ -60,19 +101,39 @@ def render_automatic_eda( 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 + 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. - run_series: si True (default) calcula el análisis de serie temporal por + 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). - run_llm: si True (default False) hace la interpretación LLM del perfil y + 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). + 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__". @@ -90,6 +151,24 @@ def render_automatic_eda( 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( @@ -97,7 +176,7 @@ def render_automatic_eda( table, backend=backend, sample=sample, - run_models=run_models, + run_models=pt_run_models, run_llm=run_llm, run_series=run_series, emit_pdf=False, @@ -131,6 +210,28 @@ def render_automatic_eda( 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") diff --git a/python/functions/pipelines/render_automatic_eda_test.py b/python/functions/pipelines/render_automatic_eda_test.py index a463e4f7..b8941834 100644 --- a/python/functions/pipelines/render_automatic_eda_test.py +++ b/python/functions/pipelines/render_automatic_eda_test.py @@ -89,3 +89,170 @@ def test_pipeline_bad_db_degrades_without_raising(tmp_path): out_dir=str(tmp_path / "o")) assert r["status"] == "error" assert "error" in r + + +# --------------------------------------------------------------------------- # +# profile_level: preset de bajo consumo CPU/LLM. +# --------------------------------------------------------------------------- # +def _make_db_models(path): + """DB con >=2 numéricas continuas (alta cardinalidad, 3 clusters gaussianos). + + El DB `sales` de _make_db solo deja UNA columna de modelo tras la selección de + features (units es baja cardinalidad, lat/lon discretizadas), insuficiente para + PCA/KMeans/IsolationForest (necesitan >=2). Este DB sí tiene 3 numéricas + continuas con estructura de clusters para que el modo completo ejecute los + multivariantes. + """ + import random + from datetime import date, timedelta + + con = duckdb.connect(path) + con.execute( + "CREATE TABLE pts (d DATE, grp VARCHAR, x1 DOUBLE, x2 DOUBLE, x3 DOUBLE)" + ) + random.seed(42) + centers = [(0.0, 0.0, 0.0), (10.0, 10.0, 10.0), (20.0, 5.0, 15.0)] + d0 = date(2024, 1, 1) + rows = [] + for i in range(150): + cx, cy, cz = centers[i % 3] + rows.append(( + d0 + timedelta(days=i), f"g{i % 3}", + round(cx + random.gauss(0, 1.0), 4), + round(cy + random.gauss(0, 1.0), 4), + round(cz + random.gauss(0, 1.0), 4), + )) + con.executemany("INSERT INTO pts VALUES (?,?,?,?,?)", rows) + con.close() + + +def test_profile_level_lite_skips_expensive_models(tmp_path): + """lite: el bloque models trae PCA + normalidad pero NO KMeans/IsolationForest. + + Demuestra (DoD bajo consumo) que el camino lite no ejecuta los modelos caros + en CPU ni la capa LLM ni la serie temporal: prof['models'] queda con pca y + normality poblados y kmeans/outliers a None, prof['llm'] y prof['series'] a + None, y el capítulo `modelos` se renderiza igualmente (con PCA, sin clusters). + """ + import json + + db = str(tmp_path / "pts.duckdb") + _make_db_models(db) + out = str(tmp_path / "out") + r = render_automatic_eda(db, "pts", profile_level="lite", + out_dir=out, basename="lite") + assert r["status"] == "ok", r.get("error") + + models = (r["profile"] or {}).get("models") or {} + assert models.get("pca") is not None, "lite debe traer PCA" + assert models.get("normality") is not None, "lite debe traer normalidad" + assert models.get("kmeans") is None, "lite NO debe ejecutar KMeans" + assert models.get("outliers") is None, "lite NO debe ejecutar IsolationForest" + assert (r["profile"] or {}).get("llm") is None, "lite NO debe llamar al LLM" + assert (r["profile"] or {}).get("series") is None, "lite NO debe calcular serie" + + # El capítulo modelos sigue presente (lo puebla el PCA), sin clusters KMeans. + with open(r["manifest_path"], encoding="utf-8") as fh: + man = json.load(fh) + assert "modelos" in (man.get("chapters") or {}) + + +def test_profile_level_standard_runs_full_models(tmp_path): + """standard (default): modelos completos (KMeans + IsolationForest) y serie.""" + db = str(tmp_path / "pts.duckdb") + _make_db_models(db) + out = str(tmp_path / "out") + r = render_automatic_eda(db, "pts", profile_level="standard", + out_dir=out, basename="std") + assert r["status"] == "ok", r.get("error") + models = (r["profile"] or {}).get("models") or {} + assert models.get("pca") is not None + assert models.get("kmeans") is not None, "standard debe ejecutar KMeans" + assert models.get("outliers") is not None, "standard debe ejecutar IsolationForest" + assert (r["profile"] or {}).get("series") is not None, "standard calcula serie" + + +def _patch_pipeline_internals(monkeypatch, captured): + """Stub de las dependencias del pipeline para tests de resolución de flags. + + Sustituye profile_table / build_eda_render_ctx / renderers por stubs rápidos + sin red ni matplotlib, capturando los kwargs con los que se invocan. Permite + verificar la PRECEDENCIA flag-explícito-sobre-preset sin ejecutar el EDA real. + """ + import pipelines.render_automatic_eda as mod + + def fake_profile_table(db_path, table, **kw): + captured["run_llm"] = kw.get("run_llm") + captured["run_models"] = kw.get("run_models") + captured["run_series"] = kw.get("run_series") + captured["sample"] = kw.get("sample") + return {"status": "ok", "profile": {"columns": []}} + + def fake_ctx(db_path, table, prof, **kw): + captured["base_ctx"] = kw.get("base_ctx") + return {} + + monkeypatch.setattr(mod, "profile_table", fake_profile_table) + monkeypatch.setattr(mod, "build_eda_render_ctx", fake_ctx) + monkeypatch.setattr(mod, "render_automatic_eda_pdf", + lambda *a, **k: {"path": "x.pdf", "n_pages": 1, + "manifest_path": "m.json"}) + monkeypatch.setattr(mod, "render_automatic_eda_pptx", + lambda *a, **k: {"path": "x.pptx", "n_slides": 1}) + + +def test_explicit_flag_overrides_preset(monkeypatch): + """Precedencia: profile_level='lite' con run_llm=True explícito → LLM activo. + + El flag explícito del caller gana al default del preset. Se verifica tanto en + el flag que llega a profile_table (run_llm=True ⇒ profile_table llamará al + LLM) como en el base_ctx (run_cluster_llm=True ⇒ narrativa LLM por capítulo). + """ + captured = {} + _patch_pipeline_internals(monkeypatch, captured) + + captured.clear() + render_automatic_eda("db", "t", profile_level="lite", run_llm=True) + assert captured["run_llm"] is True, "flag explícito debe primar sobre preset lite" + assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True + + +def test_full_preset_enables_llm(monkeypatch): + """full: el preset resuelve run_llm=True y activa la narrativa LLM en el ctx.""" + captured = {} + _patch_pipeline_internals(monkeypatch, captured) + + captured.clear() + render_automatic_eda("db", "t", profile_level="full") + assert captured["run_llm"] is True + assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True + + +def test_no_profile_level_defaults_to_standard(monkeypatch): + """Retro-compat: sin profile_level ni flags, el comportamiento es el histórico. + + standard = run_models True, run_series True, run_llm False, sample 5000. Es el + mismo default que tenía el pipeline antes de introducir profile_level (cambio + aditivo: las llamadas existentes no cambian de comportamiento). + """ + captured = {} + _patch_pipeline_internals(monkeypatch, captured) + + captured.clear() + render_automatic_eda("db", "t") # sin profile_level ni flags de coste + assert captured["run_models"] is True + assert captured["run_series"] is True + assert captured["run_llm"] is False + assert captured["sample"] == 5000 + + +def test_lite_preset_defaults(monkeypatch): + """lite por defecto: run_llm/run_series False y sample reducido a 2000.""" + captured = {} + _patch_pipeline_internals(monkeypatch, captured) + + captured.clear() + render_automatic_eda("db", "t", profile_level="lite") + assert captured["run_llm"] is False + assert captured["run_series"] is False + assert captured["sample"] == 2000