merge(eda): suite de aceptacion de los 16 capitulos (29 passed, rescatado de ejecutor con auth caida)

This commit is contained in:
2026-06-30 22:07:21 +02:00
@@ -0,0 +1,466 @@
"""Batería de tests de ACEPTACIÓN del AutomaticEDA — "que cada AEDA salga como queremos".
Esta suite es la red de seguridad del subsistema EDA del grupo `eda`: garantiza
que CADA capítulo de un informe AutomaticEDA sale poblado y con su contenido
esencial, que la feature de capítulos sueltos (``only_chapters``) resuelve sus
dependencias de cómputo, que los capítulos opcionales devuelven None cuando no
aplican, que el informe de carpeta multi-tabla detecta la FK, y que el Markdown
trae el apéndice completo (matriz de asociación entera + describe con
skew/kurtosis). A diferencia de los tests unitarios de cada capítulo, aquí se
ejercita el pipeline END-TO-END sobre un dataset sintético determinista que
activa todos los capítulos a la vez.
Determinismo: el dataset se genera con ``seed`` fijo y el pipeline corre sin LLM
(``profile_level='standard'``), de modo que el manifest y el Markdown son
reproducibles entre corridas. Un único render `standard` se reutiliza vía un
fixture de scope module para no repetir el cómputo caro.
dict-no-throw: los pipelines del grupo `eda` nunca lanzan; aquí se asserta sobre
``status == 'ok'`` y luego sobre el contenido concreto del manifest / Markdown.
Honestidad (DoD): los asserts comprueban CONTENIDO real (texto esencial de cada
capítulo), no solo el heading. Si un capítulo dejara de emitir su contenido (un
cambio rompiera la distribución numérica, el Isolation Forest, la matriz de
correlación completa, …), el test correspondiente FALLA nombrando el capítulo y
el fragmento ausente — no se ablanda para que pase.
"""
import json
import os
import subprocess
import sys
import pytest
_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)
from datascience.automatic_eda import CHAPTER_ORDER # noqa: E402
from datascience.generate_synthetic_eda_folder import ( # noqa: E402
generate_synthetic_eda_folder,
)
from datascience.generate_synthetic_eda_table import ( # noqa: E402
generate_synthetic_eda_table,
)
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
from pipelines.render_automatic_eda_folder import ( # noqa: E402
render_automatic_eda_folder,
)
# --------------------------------------------------------------------------- #
# Parámetros deterministas del fixture de oro.
# --------------------------------------------------------------------------- #
SEED = 42
N_ROWS = 800
TABLE = "synthetic"
# El capítulo `analisis_llm` SOLO se computa con run_llm=True; en el preset
# `standard` (sin LLM, lo que esta suite usa) no debe aparecer. Por eso los
# capítulos esperados en un informe `standard` son todos los de CHAPTER_ORDER
# MENOS analisis_llm. CHAPTER_ORDER es la fuente de verdad de los 16 capítulos
# del motor (portada … glosario).
LLM_ONLY_CHAPTERS = {"analisis_llm"}
EXPECTED_STANDARD = [c for c in CHAPTER_ORDER if c not in LLM_ONLY_CHAPTERS]
def _pdf_text(path):
"""Texto del PDF vía pdftotext, o None si la herramienta no está disponible."""
try:
out = subprocess.run(
["pdftotext", "-layout", path, "-"],
capture_output=True, text=True, timeout=60,
)
return out.stdout if out.returncode == 0 else None
except Exception: # noqa: BLE001 — la verificación principal es sobre el MD.
return None
def _manifest_chapters(result):
"""Set de ids de capítulo presentes en el manifest del resultado."""
with open(result["manifest_path"], encoding="utf-8") as fh:
return set((json.load(fh).get("chapters") or {}).keys())
# --------------------------------------------------------------------------- #
# Fixtures de scope module: el dataset sintético se genera UNA vez y el render
# `standard` se computa UNA vez; todos los tests de contenido lo reutilizan.
# --------------------------------------------------------------------------- #
@pytest.fixture(scope="module")
def synth_db(tmp_path_factory):
"""Tabla sintética determinista que activa los 16 capítulos del motor."""
d = tmp_path_factory.mktemp("aeda_accept_synth")
db = str(d / "synthetic.duckdb")
g = generate_synthetic_eda_table(db, TABLE, n_rows=N_ROWS, seed=SEED)
assert g["status"] == "ok", g.get("error")
return {"db": db, "table": TABLE, "gen": g}
@pytest.fixture(scope="module")
def standard_run(synth_db, tmp_path_factory):
"""Render AutomaticEDA `standard` (sin LLM) sobre el dataset sintético.
Devuelve el dict del pipeline más el manifest cargado, el texto del Markdown
y el del PDF (si pdftotext está). Reutilizado por la mayoría de los tests.
"""
out = str(tmp_path_factory.mktemp("aeda_accept_std"))
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out, basename="synth_std",
)
assert r["status"] == "ok", r.get("error")
with open(r["manifest_path"], encoding="utf-8") as fh:
manifest = json.load(fh)
md = open(r["aeda_md_path"], encoding="utf-8").read()
return {
"r": r,
"manifest": manifest,
"chapters": manifest.get("chapters") or {},
"md": md,
"pdf_text": _pdf_text(r["pdf_path"]),
}
@pytest.fixture(scope="module")
def minimal_db(tmp_path_factory):
"""Tabla mínima SIN texto libre, SIN fecha y SIN lat/lon.
Sirve para comprobar que text_distr / timeseries / geospatial devuelven None
(no aparecen en el manifest) y el EDA no peta. Solo numéricas continuas +
una categórica de baja cardinalidad.
"""
import random
import duckdb
d = tmp_path_factory.mktemp("aeda_accept_min")
db = str(d / "minimal.duckdb")
con = duckdb.connect(db)
con.execute("CREATE TABLE minimal (a DOUBLE, b DOUBLE, c INTEGER, grp VARCHAR)")
random.seed(7)
rows = [
(round(random.gauss(10, 2), 3), round(random.gauss(50, 5), 3),
random.randint(1, 100), ["x", "y", "z"][i % 3])
for i in range(120)
]
con.executemany("INSERT INTO minimal VALUES (?,?,?,?)", rows)
con.close()
return {"db": db, "table": "minimal"}
# --------------------------------------------------------------------------- #
# 1) COBERTURA DE CAPÍTULOS (golden) — el manifest standard trae los 15
# capítulos no-LLM esperados, ninguno falta, y analisis_llm NO sale sin LLM.
# --------------------------------------------------------------------------- #
def test_standard_cubre_todos_los_capitulos_esperados(standard_run):
chapters = set(standard_run["chapters"].keys())
expected = set(EXPECTED_STANDARD)
missing = expected - chapters
assert not missing, (
"capítulos esperados ausentes del manifest standard: "
f"{sorted(missing)} (presentes: {sorted(chapters)})"
)
# analisis_llm requiere run_llm=True: en standard NO debe aparecer.
assert "analisis_llm" not in chapters, (
"analisis_llm apareció sin LLM: el preset standard no debería computarlo"
)
def test_manifest_top_level_es_valido(standard_run):
"""El manifest declara el motor y un dict de capítulos con metadatos por id."""
man = standard_run["manifest"]
assert man.get("engine") == "AutomaticEDA"
assert man.get("engine_version")
chapters = standard_run["chapters"]
# Cada capítulo trae version + nº de páginas/slides (formato del manifest).
for cid, meta in chapters.items():
assert meta.get("version"), f"capítulo {cid} sin version en el manifest"
assert (meta.get("n_pages") or 0) > 0, f"capítulo {cid} con 0 páginas"
# --------------------------------------------------------------------------- #
# 2) CONTENIDO CLAVE POR CAPÍTULO (acceptance) — cada capítulo trae su contenido
# ESENCIAL en el Markdown, no solo el heading. Un fragmento ausente nombra el
# capítulo y el texto que falta.
# --------------------------------------------------------------------------- #
# Fragmentos de texto ESTABLE que cada capítulo emite en el Markdown del dataset
# sintético. No son números frágiles: son etiquetas/estructura del capítulo más
# nombres de columna del fixture. Si un capítulo deja de poblar su contenido, su
# fragmento desaparece y el test falla nombrándolo.
CHAPTER_NEEDLES = {
"portada": ["800 filas", "19 columnas"],
"overview": ["Primeras filas (df.head)", "Diccionario de columnas",
"customer_id", "signup_date"],
"num_distr": ["Distribuciones numéricas", "vallas Tukey", "income"],
"cat_distr": ["Distribuciones categóricas", "Entropía", "Top categorías",
"country"],
"text_distr": ["Texto libre (NLP)", "TTR", "Términos más frecuentes",
"Idioma dominante"],
"calidad": ["Cómo se calcula la calidad", "Calidad global"],
"missingness": ["Datos faltantes", "Celdas faltantes (global)",
"Faltantes por columna"],
"outliers": ["Valores atípicos por columna", "Filas atípicas (multivariante)",
"Isolation Forest", "Filas analizadas"],
"correlacion": ["Matriz de asociación", "Pares más correlacionados"],
"relaciones": ["Candidatas a clave primaria", "customer_id"],
"modelos": ["PCA — varianza explicada", "Segmentación (KMeans)"],
"timeseries": ["Series temporales", "Columna de fecha", "signup_date"],
"geospatial": ["Análisis geoespacial", "Extensión geográfica", "Centroide"],
"agregacion": ["Agregación por grupos", "Agrupado por"],
"glosario": ["Glosario de términos",
"### Isolation Forest (anomalías multivariantes)",
"### PCA (componentes principales)"],
}
def test_needles_cubren_exactamente_los_capitulos_standard():
"""Guard de mantenimiento: las needles cubren los mismos 15 capítulos no-LLM.
Si alguien añade un capítulo nuevo a CHAPTER_ORDER, este test recuerda que
hay que documentar su contenido esencial aquí (o marcarlo como LLM-only)."""
assert set(CHAPTER_NEEDLES.keys()) == set(EXPECTED_STANDARD), (
"CHAPTER_NEEDLES desincronizado con los capítulos esperados de standard: "
f"falta needles para {set(EXPECTED_STANDARD) - set(CHAPTER_NEEDLES)}, "
f"sobra {set(CHAPTER_NEEDLES) - set(EXPECTED_STANDARD)}"
)
@pytest.mark.parametrize("chapter_id", list(CHAPTER_NEEDLES.keys()))
def test_capitulo_trae_su_contenido_esencial(standard_run, chapter_id):
md = standard_run["md"]
# Pre-condición: el capítulo está en el manifest (cobertura). Si no, es un
# fallo de cobertura, no de contenido — se reporta como tal.
assert chapter_id in standard_run["chapters"], (
f"capítulo {chapter_id} ausente del manifest (fallo de cobertura)"
)
for needle in CHAPTER_NEEDLES[chapter_id]:
assert needle in md, (
f"capítulo '{chapter_id}': falta su contenido esencial en el Markdown "
f"— fragmento ausente: {needle!r}"
)
def test_outliers_isolation_forest_poblado_no_degradado(standard_run):
"""El bloque multivariante (Isolation Forest) sale con datos, no degradado."""
md = standard_run["md"]
assert "Anomalías multivariantes" 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 en el informe completo"
)
# El perfil trae el bloque de modelos con los outliers multivariantes.
models = (standard_run["r"]["profile"] or {}).get("models") or {}
assert models.get("outliers") is not None, "profile['models']['outliers'] vacío"
# --------------------------------------------------------------------------- #
# 3) CAPÍTULOS SUELTOS CON DEPS RESUELTAS (acceptance de only_chapters) — pedir
# un capítulo suelto lo deja POBLADO porque la resolución de dependencias
# activa el cómputo que necesita, aunque el caller no lo pidiera.
# --------------------------------------------------------------------------- #
def test_only_outliers_isolation_forest_poblado(synth_db, tmp_path):
"""only=['outliers'] sin run_models explícito → IsolationForest poblado."""
out = str(tmp_path / "only_out")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["outliers"], out_dir=out, basename="only_outliers",
)
assert r["status"] == "ok", r.get("error")
# Documento = portada + outliers + glosario, nada más.
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Filas atípicas (multivariante)" in md
assert "Filas analizadas" in md, "Isolation Forest sin tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, (
"el 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
def test_only_timeseries_rango_temporal_presente(synth_db, tmp_path):
"""only=['timeseries'] → rango temporal poblado (run_series resuelto)."""
out = str(tmp_path / "only_ts")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["timeseries"], out_dir=out, basename="only_ts",
)
assert r["status"] == "ok", r.get("error")
assert "timeseries" in _manifest_chapters(r)
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Columna de fecha" in md
assert "signup_date" in md, "la serie no nombra su columna de fecha"
# run_series resuelto por deps → el perfil trae el análisis de serie.
assert (r["profile"] or {}).get("series") is not None, (
"only=['timeseries'] debe activar run_series por dependencias"
)
def test_only_correlacion_scatters_presentes(synth_db, tmp_path):
"""only=['correlacion'] → matriz + scatters de los pares fuertes."""
out = str(tmp_path / "only_corr")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["correlacion"], out_dir=out, basename="only_corr",
)
assert r["status"] == "ok", r.get("error")
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Matriz de asociación" in md
assert "Relaciones más fuertes (scatter)" in md, "faltan los scatters"
assert "Dispersión de" in md, "no se emitió ninguna figura de dispersión"
# --------------------------------------------------------------------------- #
# 4) NONE CUANDO NO APLICA — sobre una tabla sin texto largo, sin fecha y sin
# lat/lon, text_distr / timeseries / geospatial NO aparecen y el EDA no peta.
# --------------------------------------------------------------------------- #
def test_capitulos_opcionales_ausentes_cuando_no_aplican(minimal_db, tmp_path):
out = str(tmp_path / "minimal_out")
r = render_automatic_eda(
minimal_db["db"], minimal_db["table"],
profile_level="standard", out_dir=out, basename="minimal",
)
assert r["status"] == "ok", r.get("error")
chapters = _manifest_chapters(r)
for absent in ("text_distr", "timeseries", "geospatial"):
assert absent not in chapters, (
f"capítulo {absent} apareció en una tabla que no lo justifica "
f"(presentes: {sorted(chapters)})"
)
# El documento sigue siendo válido: portada + glosario + capítulos que sí
# aplican (overview/num_distr/correlacion al menos).
assert {"portada", "glosario", "overview", "num_distr"} <= chapters
# --------------------------------------------------------------------------- #
# 5) FOLDER MULTI-TABLA (acceptance) — el informe de carpeta perfila las N tablas
# y el capítulo de relaciones detecta la FK por containment.
# --------------------------------------------------------------------------- #
def test_folder_multitabla_con_fk_detectada(tmp_path):
fdir = str(tmp_path / "folder")
g = generate_synthetic_eda_folder(fdir, n_rows=300, seed=SEED)
assert g["status"] == "ok", g.get("error")
out = str(tmp_path / "fout")
rf = render_automatic_eda_folder(fdir, out_dir=out, basename="folder")
assert rf["status"] == "ok", rf.get("error")
# Las 3 tablas se perfilaron.
assert rf["n_tables"] == 3, f"esperadas 3 tablas, vistas {rf['n_tables']}"
# El manifest base trae el capítulo de relaciones inter-tabla.
with open(rf["manifest_path"], encoding="utf-8") as fh:
chapters = set((json.load(fh).get("chapters") or {}).keys())
assert "relaciones" in chapters, (
f"el documento de carpeta no incluye el capítulo de relaciones: {chapters}"
)
# El Markdown nombra las 3 tablas y declara la FK detectada por containment.
md = open(rf["md_path"], encoding="utf-8").read()
for tbl in ("customers", "orders", "reviews"):
assert tbl in md, f"la tabla {tbl} no aparece en el informe de carpeta"
assert "FK candidatas" in md, "no se declaran las FK candidatas"
assert "orders.customer_id" in md and "customers.customer_id" in md, (
"la FK orders→customers no se detectó por containment"
)
assert "reviews.customer_id" in md, "la FK reviews→customers no se detectó"
# --------------------------------------------------------------------------- #
# 6) MD COMPLETITUD (regresión) — el Markdown trae el apéndice con la matriz de
# asociación COMPLETA (todos los pares, no solo el top) y el describe con
# skew/kurtosis de todas las numéricas. Protege un fix ya mergeado.
# --------------------------------------------------------------------------- #
def test_md_apendice_matriz_correlacion_completa(standard_run):
md = standard_run["md"]
assert "Matriz de asociación — todos los pares" in md, (
"falta el apéndice con la matriz de asociación completa"
)
# Un par num-num de correlación BAJA que el top del capítulo NUNCA mostraría:
# su presencia prueba que el apéndice lista TODOS los pares, no solo el top.
assert "income ↔ longitude" in md, (
"el apéndice no contiene los pares de baja correlación: no es la matriz "
"completa, solo el top-k del capítulo"
)
def test_md_apendice_describe_con_skew_kurtosis(standard_run):
md = standard_run["md"]
assert "Estadísticos numéricos completos (describe)" in md, (
"falta el apéndice describe completo"
)
# La cabecera del describe del apéndice lleva las columnas skew y kurtosis
# (subcadena única de ese header). Sin ellas el describe está incompleto.
assert "| skew | kurtosis |" in md, (
"el describe del apéndice no trae las columnas skew/kurtosis"
)
# --------------------------------------------------------------------------- #
# 7) LAS 3 SALIDAS NO-VACÍAS — PDF con páginas, PPTX con slides, MD con un mínimo
# de caracteres, y los tres archivos en disco. Manifest válido.
# --------------------------------------------------------------------------- #
def test_tres_salidas_no_vacias(standard_run):
r = standard_run["r"]
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert r["aeda_md_path"] and os.path.exists(r["aeda_md_path"])
assert (r["n_pages"] or 0) > 0, "el PDF no tiene páginas"
assert (r["n_slides"] or 0) > 0, "el PPTX no tiene slides"
# El informe completo es grande: un mínimo holgado protege contra un MD vacío
# o truncado sin atarse a un tamaño exacto.
assert (r["md_chars"] or 0) > 10000, f"MD demasiado corto: {r['md_chars']} chars"
assert r["manifest_path"] and os.path.exists(r["manifest_path"])
def test_pdf_texto_extraible_con_contenido(standard_run):
"""Si pdftotext está disponible, el PDF debe traer texto real (no solo
imágenes): la portada nombra el dataset y su forma. Si no está la
herramienta, el test se omite (no es un fallo del EDA)."""
txt = standard_run["pdf_text"]
if txt is None:
pytest.skip("pdftotext no disponible")
assert len(txt) > 5000, "el PDF apenas tiene texto extraíble"
assert "Portada" in txt or "synthetic" in txt, (
"el texto del PDF no contiene la portada esperada"
)
# --------------------------------------------------------------------------- #
# DETERMINISMO — dos renders del MISMO dataset producen el MISMO manifest
# (mismos capítulos y mismos n_pages/n_slides por capítulo). El generated_at
# difiere por timestamp, por eso se compara el dict de capítulos, no el archivo.
# --------------------------------------------------------------------------- #
def test_render_es_determinista(synth_db, tmp_path):
out1 = str(tmp_path / "det1")
out2 = str(tmp_path / "det2")
r1 = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out1, basename="d1")
r2 = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out2, basename="d2")
assert r1["status"] == "ok" and r2["status"] == "ok"
c1 = json.load(open(r1["manifest_path"], encoding="utf-8")).get("chapters")
c2 = json.load(open(r2["manifest_path"], encoding="utf-8")).get("chapters")
assert c1 == c2, "el manifest no es determinista entre dos renders del mismo dataset"
# --------------------------------------------------------------------------- #
# SLOW (opcional, skippeable) — informe `full` con narrativa LLM. Requiere red /
# credenciales y NO es determinista, por eso está apagado salvo opt-in explícito
# vía la variable de entorno EDA_ACCEPT_LLM=1. Se omite con skipif (no con un
# marker custom) para no depender de registro de marks en la config del repo.
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(
os.environ.get("EDA_ACCEPT_LLM") != "1",
reason="full+LLM es lento/no determinista; exporta EDA_ACCEPT_LLM=1 para correrlo",
)
def test_full_incluye_capitulo_analisis_llm(synth_db, tmp_path):
out = str(tmp_path / "full")
r = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="full", out_dir=out, basename="full")
assert r["status"] == "ok", r.get("error")
assert "analisis_llm" in _manifest_chapters(r), (
"el preset full debe incluir el capítulo de análisis LLM"
)