diff --git a/python/functions/pipelines/automatic_eda_acceptance_test.py b/python/functions/pipelines/automatic_eda_acceptance_test.py new file mode 100644 index 00000000..d5d7ddc5 --- /dev/null +++ b/python/functions/pipelines/automatic_eda_acceptance_test.py @@ -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" + )