feat(eda): render AutomaticEDA por capítulos sueltos con resolución de dependencias

Permite renderizar un SUBCONJUNTO de capítulos del informe AutomaticEDA
(only_chapters=[...]) para iterar/testear un capítulo concreto sin generar el
documento entero, garantizando que el capítulo pedido SIEMPRE llegue poblado.

- Nuevo módulo automatic_eda/chapter_deps.py: mapa central CHAPTER_DEPS (fuente
  de verdad) que declara, por capítulo de CHAPTER_ORDER, qué flags de cómputo
  (run_models/run_series/run_llm) y qué piezas de ctx (raw_numeric, timeseries_raw,
  geo_points, head_rows, db_path/table) necesita para no salir degradado. Helpers
  puros: resolve_requirements, resolve_profile_flags, needs_render_ctx,
  resolve_ctx_data_keys, validate_chapter_ids.
- build_document(profile, ctx, only=None): parámetro only opcional que restringe
  el cuerpo a esos capítulos (portada primera + glosario última siempre). Lee la
  clave reservada ctx['_only_chapters'] cuando only es None, para propagar la
  selección a través de los renderers sin modificarlos. Retrocompatible.
- render_automatic_eda(..., only_chapters=None): valida los ids (error claro
  dict-no-throw), resuelve las dependencias activando el cómputo necesario aunque
  el caller no lo pidiera (un flag explícito siempre prima) y construyendo solo
  las piezas de ctx que los capítulos pedidos leen (salta build_eda_render_ctx
  entero si ninguno necesita datos crudos). only_chapters=None produce el
  documento completo idéntico al de hoy.
- Tests: chapter_deps_test.py (resolución pura), build_document_only_test.py
  (filtro), render_automatic_eda_only_test.py (golden con DuckDB: outliers suelto
  con IsolationForest poblado por resolución; timeseries activa run_series;
  eficiencia geospatial sin modelos; edge cases).
- .md del pipeline: documenta only_chapters + emit_md; version 1.1.0 -> 1.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:35:46 +02:00
parent 50c05d126c
commit 54a9ab70c7
7 changed files with 893 additions and 12 deletions
@@ -4,8 +4,8 @@ kind: pipeline
lang: py
domain: pipelines
purity: impure
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"
version: "1.2.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, emit_md: bool = True, only_chapters: list = 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:
@@ -46,6 +46,10 @@ params:
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."
- name: emit_md
desc: "Ademas del PDF y el PPTX, emite un Markdown autocontenido del mismo documento por capitulos (texto + tablas markdown, sin binarios) para pegar a un LLM. Default True. La ruta sale en aeda_md_path."
- name: only_chapters
desc: "Lista opcional de ids de capitulo a renderizar (subconjunto de CHAPTER_ORDER) para iterar/testear un capitulo suelto sin generar el documento entero. Default None => documento COMPLETO (retrocompatible). Cuando se pasa una lista: (1) se VALIDA contra CHAPTER_ORDER, un id desconocido o lista vacia devuelve error claro listando los validos; (2) se RESUELVEN las dependencias de computo de esos capitulos (automatic_eda.chapter_deps) activando los flags que necesiten (run_models/run_series/run_llm) aunque el caller no los pidiera y construyendo SOLO las piezas de ctx que leen, de modo que el capitulo suelto SIEMPRE llega poblado (p.ej. ['outliers'] activa run_models y conserva raw_numeric -> Isolation Forest completo) sin malgastar CPU/LLM en lo que ningun capitulo pedido usa; (3) el documento y su manifest contienen SOLO esos capitulos MAS portada (primera) y glosario (ultima, cuando hay terminos clicables). Un flag explicito del caller prima sobre la resolucion de dependencias."
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)."
---
@@ -69,6 +73,21 @@ 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
# Capitulo SUELTO: itera/testea un capitulo sin generar el documento entero. La
# resolucion de dependencias activa el computo que el capitulo necesita aunque no
# se pase explicito. Pedir solo 'outliers' activa run_models y conserva
# raw_numeric -> el bloque Isolation Forest sale COMPLETO. Documento = portada +
# outliers + glosario.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["outliers"])
# Varios capitulos sueltos a la vez (se unen sus dependencias):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
only_chapters=["correlacion", "missingness"])
# id desconocido -> error claro listando los validos (dict-no-throw, no lanza):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["nope"])
# {'status': 'error', 'error': 'only_chapters con ids desconocidos: nope. Capitulos validos: portada, overview, ...'}
```
## Cuando usarla
@@ -86,6 +105,16 @@ Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) us
temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo.
Cuando estes **iterando o testeando UN capitulo concreto** (afinar el render de
outliers, comprobar el mapa geoespacial, depurar la agregacion) usa
`only_chapters=[...]`: genera el documento con solo esos capitulos (+ portada y
glosario), pero **resuelve sus dependencias de computo** para que el capitulo
suelto nunca salga degradado — pedir `['outliers']` activa run_models y conserva
`raw_numeric` aunque no los pases, y a la vez no malgasta CPU/LLM en lo que ningun
capitulo pedido necesita (pedir `['geospatial']` no corre modelos). Es mucho mas
rapido que renderizar el informe entero en cada iteracion. El mapa central de
dependencias vive en `automatic_eda/chapter_deps.py` (fuente de verdad).
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
@@ -111,9 +140,29 @@ temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
- 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).
- **`only_chapters` y el glosario**: el glosario (ultimo capitulo) solo aparece si
algun capitulo del cuerpo registro terminos clicables. Un capitulo suelto que no
registra terminos (p.ej. `timeseries`, `geospatial`) sale como portada + ese
capitulo, sin glosario, porque no hay nada que enlazar — es correcto, no un fallo.
- **`only_chapters` con `profile_level="lite"`**: en capitulos sueltos el preset
solo gobierna `sample`; los modelos NO usan el camino "lite" (que podaria
`ctx['raw_numeric']` y dejaria a outliers sin su multivariante en vivo). Quien
manda en capitulos sueltos es la resolucion de dependencias, no el preset de
coste de modelos.
## Capability growth log
- v1.2.0 (2026-06-30) — anade el parametro `only_chapters`: renderiza un
SUBCONJUNTO de capitulos (para iterar/testear uno suelto) resolviendo sus
dependencias de computo via `automatic_eda/chapter_deps.py` (mapa central
CHAPTER_DEPS): activa los flags de coste que el capitulo necesita (run_models/
run_series/run_llm) aunque el caller no los pase y construye solo las piezas de
ctx que lee, de modo que el capitulo suelto SIEMPRE llega poblado (golden:
['outliers'] -> Isolation Forest completo) sin malgastar en lo que no usa. La
seleccion viaja a build_document por la clave reservada `ctx['_only_chapters']`
(los renderers no cambian). Valida ids (error claro dict-no-throw). Cambio
aditivo y retro-compatible: `only_chapters=None` produce el documento completo
identico a v1.1.0.
- 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
@@ -99,6 +99,7 @@ def render_automatic_eda(
basename: str = None,
ctx_extra: dict = None,
emit_md: bool = True,
only_chapters: list = None,
) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
@@ -150,6 +151,29 @@ def render_automatic_eda(
MISMO documento por capítulos (texto plano + tablas markdown, sin
binarios), pensado para pegar a un LLM. Default True. La ruta sale en
la clave de retorno ``aeda_md_path``. No altera las demás salidas.
only_chapters: lista opcional de ids de capítulo a renderizar (un
SUBCONJUNTO de CHAPTER_ORDER) para iterar/testear un capítulo concreto
sin generar el documento entero. Default None => documento COMPLETO,
idéntico al de hoy (retrocompatible). Cuando se pasa una lista:
- Se VALIDA contra CHAPTER_ORDER; un id desconocido devuelve un error
claro listando los válidos (dict-no-throw, no lanza). Lista vacía
``[]`` también devuelve error (pasa al menos un capítulo o None).
- Se RESUELVEN las dependencias de cómputo de esos capítulos
(``automatic_eda.chapter_deps``): se activan los flags de coste que
necesiten (run_models / run_series / run_llm) AUNQUE el caller no
los pidiera, y se construyen SOLO las piezas de ``ctx`` que esos
capítulos leen. Así un capítulo suelto SIEMPRE llega poblado —
p.ej. ``only_chapters=['outliers']`` activa run_models y conserva
``ctx['raw_numeric']`` para que el bloque IsolationForest salga
completo— y a la vez no se malgasta CPU/LLM en lo que ningún
capítulo pedido usa (pedir solo ``geospatial`` no corre modelos).
- El documento (PDF/PPTX/MD) y su manifest contienen SOLO esos
capítulos, MÁS la portada (primera) y el glosario (última), que se
incluyen siempre para que el documento sea válido y los términos
clicables tengan destino.
- Un flag explícito del caller (run_models/run_series/run_llm != None)
SIEMPRE prima sobre lo que resuelvan las dependencias.
Returns:
dict (nunca lanza). En éxito::
@@ -169,11 +193,56 @@ def render_automatic_eda(
# "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"]
# 0.bis) Modo "capítulos sueltos": valida la selección y RESUELVE sus
# dependencias de cómputo. Es lo que garantiza que un capítulo pedido
# llegue completo (activa lo que necesita) sin malgastar en lo que no.
# Cuando only_chapters es None se conserva el camino histórico (preset).
if only_chapters is not None:
from datascience.automatic_eda import CHAPTER_ORDER
from datascience.automatic_eda.chapter_deps import (
needs_render_ctx,
resolve_ctx_data_keys,
resolve_requirements,
validate_chapter_ids,
)
if not isinstance(only_chapters, (list, tuple)):
return {"status": "error",
"error": "only_chapters debe ser una lista de ids de "
"capítulo o None (documento completo)."}
only_chapters = [c for c in only_chapters]
if not only_chapters:
return {"status": "error",
"error": "only_chapters=[] está vacío. Pasa al menos un "
"capítulo, o None para el documento completo. "
"Capítulos válidos: " + ", ".join(CHAPTER_ORDER)}
checked = validate_chapter_ids(only_chapters, CHAPTER_ORDER)
if checked["unknown"]:
return {"status": "error",
"error": "only_chapters con ids desconocidos: "
+ ", ".join(checked["unknown"])
+ ". Capítulos válidos: "
+ ", ".join(CHAPTER_ORDER)}
only_chapters = checked["valid"]
# Las dependencias fijan el DEFAULT de cada flag de coste (eficiencia:
# lo que ningún capítulo pedido necesita queda en False); un flag
# explícito del caller (!= None) sigue primando.
dep_flags = resolve_requirements(only_chapters)["profile_flags"]
run_models = ("run_models" in dep_flags) if run_models is None else run_models
run_series = ("run_series" in dep_flags) if run_series is None else run_series
run_llm = ("run_llm" in dep_flags) if run_llm is None else run_llm
# En capítulos sueltos no se usa el camino "modelos baratos" (lite),
# que poda ctx['raw_numeric']: un capítulo como outliers lo necesita
# para su multivariante en vivo. El preset solo gobierna `sample`.
model_opts = None
else:
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
# 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.
@@ -217,10 +286,25 @@ def render_automatic_eda(
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,
)
# En modo capítulos sueltos, si NINGÚN capítulo pedido necesita datos
# crudos del ctx, se salta build_eda_render_ctx por completo (ahorro real
# de I/O): solo se conservan presentación + db_path/table. Si sí los
# necesita, se construye el ctx y luego se PODAN las piezas de datos que
# ningún capítulo pedido usa (db_path/table nunca se podan).
if only_chapters is not None and not needs_render_ctx(only_chapters):
ctx = dict(base_ctx)
ctx["db_path"] = db_path
ctx["table"] = table
else:
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
if only_chapters is not None and isinstance(ctx, dict):
keep = resolve_ctx_data_keys(only_chapters)
for k in ("head_rows", "raw_numeric", "timeseries_raw", "geo_points"):
if k not in keep:
ctx.pop(k, None)
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
# IsolationForest). profile_table no corrió los modelos; aquí se corren
@@ -245,6 +329,13 @@ def render_automatic_eda(
ctx.pop("raw_numeric", None)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
# En modo capítulos sueltos, la selección viaja a build_document por una
# clave reservada del ctx (los renderers llaman build_document sin pasar
# `only`): build_document filtra el cuerpo a esos capítulos y siempre
# añade portada (primera) + glosario (última). build_document la consume
# y la quita, así que no llega a los capítulos.
if only_chapters is not None and isinstance(ctx, dict):
ctx["_only_chapters"] = list(only_chapters)
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}"
@@ -283,6 +374,7 @@ def render_automatic_eda(
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"md_note": rmd.get("note"),
"only_chapters": only_chapters,
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
@@ -0,0 +1,235 @@
"""Tests del modo `only_chapters` del pipeline render_automatic_eda.
Cubre la tarea de "capítulos sueltos con resolución de dependencias":
- Golden (DuckDB real): pedir SOLO un capítulo genera un documento con solo
portada + ese capítulo + glosario, y el capítulo llega COMPLETO porque la
resolución de dependencias activó el cómputo que necesita aunque el caller
no lo pidiera (outliers → run_models + raw_numeric → IsolationForest poblado;
timeseries → run_series; correlacion → raw_numeric).
- Eficiencia: pedir un capítulo que NO necesita flags caros (geospatial) no los
activa, y un capítulo puramente agregado (num_distr) ni siquiera construye el
ctx de datos crudos.
- Edge: id desconocido / lista vacía / no-lista devuelven error claro sin
lanzar; only_chapters=None mantiene el comportamiento histórico.
"""
import json
import os
import random
import sys
from datetime import date, timedelta
_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_models(path):
"""DB con fecha + 3 numéricas continuas en 3 clusters gaussianos.
Garantiza material para outliers/modelos (>=2 numéricas → IsolationForest),
timeseries (columna DATE) y correlacion (numéricas). Mismo shape que el
fixture del test del pipeline base.
"""
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 _manifest_chapters(result):
with open(result["manifest_path"], encoding="utf-8") as fh:
return set((json.load(fh).get("chapters") or {}).keys())
# --------------------------------------------------------------------------- #
# GOLDEN — outliers suelto: IsolationForest poblado por resolución de deps.
# --------------------------------------------------------------------------- #
def test_only_outliers_isolation_forest_populated_without_explicit_run_models(tmp_path):
"""El corazón de la tarea: pedir SOLO 'outliers' sin run_models explícito
activa run_models por dependencias y conserva ctx['raw_numeric'], de modo que
el bloque multivariante (Isolation Forest) sale con datos, no degradado."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
# NB: no se pasa run_models — la resolución de dependencias debe activarlo.
r = render_automatic_eda(db, "pts", only_chapters=["outliers"],
out_dir=out, basename="only_outliers")
assert r["status"] == "ok", r.get("error")
assert r["only_chapters"] == ["outliers"]
# Documento = portada + outliers + glosario, nada más.
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
# El multivariante salió POBLADO (no la nota de degradación). Se comprueba en
# el Markdown (mismo documento por capítulos, texto plano fiable).
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Filas atípicas (multivariante)" in md
assert "Filas analizadas" in md, "el Isolation Forest no trae su tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, \
"el bloque multivariante salió degradado pese a resolver las deps"
# La resolución activó run_models → el perfil trae el bloque de modelos.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is not None
# --------------------------------------------------------------------------- #
# GOLDEN — timeseries suelto activa run_series.
# --------------------------------------------------------------------------- #
def test_only_timeseries_activates_run_series(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["timeseries"],
out_dir=out, basename="only_ts")
assert r["status"] == "ok", r.get("error")
assert "timeseries" in _manifest_chapters(r)
assert "modelos" not in _manifest_chapters(r)
# run_series resuelto por deps → el perfil trae el análisis de serie.
assert (r["profile"] or {}).get("series") is not None, \
"only_chapters=['timeseries'] debe activar run_series"
# --------------------------------------------------------------------------- #
# GOLDEN — correlacion suelto construye raw_numeric (sin activar modelos).
# --------------------------------------------------------------------------- #
def test_only_correlacion_builds_raw_numeric_without_models(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["correlacion"],
out_dir=out, basename="only_corr")
assert r["status"] == "ok", r.get("error")
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
# Eficiencia: correlacion no necesita los modelos → no se corrieron.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is None
assert (r["profile"] or {}).get("series") is None
# --------------------------------------------------------------------------- #
# Eficiencia y precedencia — vía stub (sin DuckDB).
# --------------------------------------------------------------------------- #
def _patch(monkeypatch, cap):
import pipelines.render_automatic_eda as mod
def fake_pt(db, t, **kw):
cap["run_models"] = kw.get("run_models")
cap["run_series"] = kw.get("run_series")
cap["run_llm"] = kw.get("run_llm")
return {"status": "ok", "profile": {"columns": []}}
def fake_ctx(db, t, prof, **kw):
cap["ctx_called"] = True
return {"db_path": db, "table": t}
cap["ctx_called"] = False
monkeypatch.setattr(mod, "profile_table", fake_pt)
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})
monkeypatch.setattr(mod, "render_automatic_eda_markdown",
lambda *a, **k: {"path": "x.md", "n_chars": 1})
def test_only_geospatial_does_not_activate_cost_flags(monkeypatch):
"""Eficiencia: pedir solo geospatial NO corre modelos/serie/LLM."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["geospatial"])
assert cap["run_models"] is False
assert cap["run_series"] is False
assert cap["run_llm"] is False
def test_only_outliers_activates_run_models_via_deps(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["run_models"] is True
assert cap["run_series"] is False
def test_explicit_flag_overrides_dependency_resolution(monkeypatch):
"""run_models=False explícito gana, aunque outliers lo pediría por deps."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"], run_models=False)
assert cap["run_models"] is False
def test_purely_aggregated_chapter_skips_render_ctx(monkeypatch):
"""num_distr solo lee el profile → build_eda_render_ctx no se llama."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["num_distr"])
assert cap["ctx_called"] is False, \
"num_distr no necesita datos crudos: el ctx no debe construirse"
def test_chapter_that_needs_ctx_builds_it(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["ctx_called"] is True
# --------------------------------------------------------------------------- #
# EDGE — errores claros sin lanzar.
# --------------------------------------------------------------------------- #
def test_unknown_chapter_id_returns_clear_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters=["no_existe"])
assert r["status"] == "error"
assert "no_existe" in r["error"]
assert "Capítulos válidos" in r["error"]
# Algún id válido conocido aparece en la lista.
assert "outliers" in r["error"]
def test_empty_only_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t", only_chapters=[])
assert r["status"] == "error"
assert "vac" in r["error"].lower()
def test_only_chapters_not_a_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters="outliers")
assert r["status"] == "error"
def test_only_none_keeps_full_document(tmp_path):
"""Retro-compat: only_chapters=None genera el documento completo."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", out_dir=out, basename="full")
assert r["status"] == "ok", r.get("error")
chapters = _manifest_chapters(r)
# Documento completo: muchos más capítulos que portada/glosario.
assert {"portada", "glosario", "overview", "correlacion"} <= chapters
assert len(chapters) > 4