From 54a9ab70c78a1a1185b1f8997323ce8e6253ed8f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 30 Jun 2026 21:35:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(eda):=20render=20AutomaticEDA=20por=20cap?= =?UTF-8?q?=C3=ADtulos=20sueltos=20con=20resoluci=C3=B3n=20de=20dependenci?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../automatic_eda/build_document_only_test.py | 109 ++++++++ .../datascience/automatic_eda/chapter_deps.py | 205 +++++++++++++++ .../automatic_eda/chapter_deps_test.py | 160 ++++++++++++ .../automatic_eda/chapters_registry.py | 37 ++- .../pipelines/render_automatic_eda.md | 53 +++- .../pipelines/render_automatic_eda.py | 106 +++++++- .../render_automatic_eda_only_test.py | 235 ++++++++++++++++++ 7 files changed, 893 insertions(+), 12 deletions(-) create mode 100644 python/functions/datascience/automatic_eda/build_document_only_test.py create mode 100644 python/functions/datascience/automatic_eda/chapter_deps.py create mode 100644 python/functions/datascience/automatic_eda/chapter_deps_test.py create mode 100644 python/functions/pipelines/render_automatic_eda_only_test.py diff --git a/python/functions/datascience/automatic_eda/build_document_only_test.py b/python/functions/datascience/automatic_eda/build_document_only_test.py new file mode 100644 index 00000000..db0e13e7 --- /dev/null +++ b/python/functions/datascience/automatic_eda/build_document_only_test.py @@ -0,0 +1,109 @@ +"""Tests del filtro `only` de build_document (selección de capítulos). + +Verifican que: + - only=None mantiene el comportamiento histórico (todos los capítulos). + - only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario + (última) están SIEMPRE presentes. + - only=[] produce el documento mínimo (solo portada + glosario). + - la selección también viaja por la clave reservada ctx['_only_chapters'] + (el canal que usan los renderers, que llaman build_document sin `only`), y + esa clave nunca se filtra a los capítulos. +""" + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions +if _FUNCTIONS not in sys.path: + sys.path.insert(0, _FUNCTIONS) + +from datascience.automatic_eda import build_document # noqa: E402 + + +def _profile_with_cat_and_num(): + """Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío).""" + return { + "table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91, + "duplicate_pct": 1.5, "null_cell_pct": 0.8, + "columns": [ + {"name": "region", "inferred_type": "categorical", + "categorical": { + "top": [{"value": "norte", "count": 50, "pct": 0.42}, + {"value": "sur", "count": 40, "pct": 0.33}, + {"value": "este", "count": 30, "pct": 0.25}], + "mode": "norte", "n_distinct": 3, "entropy": 1.55, + "imbalance": 0.1}}, + {"name": "importe", "inferred_type": "numeric", + "numeric": {"mean": 50.0, "median": 48.0, "std": 10.0, + "min": 10, "max": 99, "iqr": 15, + "histogram": [{"lo": 0, "hi": 50, "count": 40}, + {"lo": 50, "hi": 100, "count": 80}]}}, + ], + } + + +def test_only_none_is_full_document(): + """Retro-compat: sin `only`, salen todos los capítulos aplicables.""" + chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"}) + ids = [c.id for c in chs] + assert ids[0] == "portada" + assert ids[-1] == "glosario" + # El cuerpo trae las distribuciones (cat/num), no solo portada+glosario. + assert "num_distr" in ids + assert "cat_distr" in ids + + +def test_only_restricts_body_but_keeps_cover_and_glossary(): + # cat_distr registra el término "entropía" en el glosario, así que el + # glosario (destino del término clicable) aparece — demuestra el contrato + # "portada primera + capítulo + glosario última". + chs = build_document(_profile_with_cat_and_num(), + ctx={"dataset_name": "v"}, only=["cat_distr"]) + ids = [c.id for c in chs] + assert ids[0] == "portada", f"portada no es la primera: {ids}" + assert ids[-1] == "glosario", f"glosario no es la última: {ids}" + assert "cat_distr" in ids + # num_distr quedó fuera de la selección. + assert "num_distr" not in ids + + +def test_only_empty_yields_minimal_document(): + # only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece + # si algún capítulo registró términos (patrón preexistente: glosario vacío se + # omite). Sin cuerpo no hay términos → documento mínimo = solo portada. + chs = build_document(_profile_with_cat_and_num(), + ctx={"dataset_name": "v"}, only=[]) + ids = [c.id for c in chs] + assert ids == ["portada"], \ + f"only=[] debe dar el documento mínimo (solo portada), no {ids}" + + +def test_selection_via_reserved_ctx_key(): + """La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`.""" + chs = build_document(_profile_with_cat_and_num(), + ctx={"dataset_name": "v", + "_only_chapters": ["cat_distr"]}) + ids = [c.id for c in chs] + assert "cat_distr" in ids + assert "num_distr" not in ids + assert ids[0] == "portada" and ids[-1] == "glosario" + + +def test_explicit_only_arg_wins_over_ctx_key(): + """Si se pasan ambos, el argumento `only` manda sobre la clave del ctx.""" + chs = build_document(_profile_with_cat_and_num(), + ctx={"dataset_name": "v", + "_only_chapters": ["cat_distr"]}, + only=["num_distr"]) + ids = [c.id for c in chs] + assert "num_distr" in ids + assert "cat_distr" not in ids + + +def test_reserved_key_not_leaked_to_caller_ctx(): + """build_document no muta el ctx del caller (copia interna).""" + ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]} + build_document(_profile_with_cat_and_num(), ctx=ctx) + # La clave reservada sigue en el dict del caller (no se mutó su copia). + assert ctx["_only_chapters"] == ["num_distr"] diff --git a/python/functions/datascience/automatic_eda/chapter_deps.py b/python/functions/datascience/automatic_eda/chapter_deps.py new file mode 100644 index 00000000..98d1397c --- /dev/null +++ b/python/functions/datascience/automatic_eda/chapter_deps.py @@ -0,0 +1,205 @@ +"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA. + +Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para +computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo +consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un +SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve +los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos +capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la +vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa. + +Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se +evita tocar los ``chapters/.py`` (cada agente es dueño de su capítulo) y se +elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del +``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira. + +Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo: + + - ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR + para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros: + * ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA). + Lo leen ``outliers`` (fallback del multivariante) y ``modelos``. + * ``run_series`` -> ``profile['series']`` (análisis de serie temporal). + Lo lee ``timeseries``. + * ``run_llm`` -> ``profile['llm']`` (interpretación del modelo). + Lo lee ``analisis_llm``. + + - ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye + ``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está + vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse + ``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide. + Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``): + * ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real). + * ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/ + correlacion/missingness/geospatial: muestra numérica alineada por fila). + * ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda). + * ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``) + (geospatial: lat/lon). + * ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/ + text_distr/missingness/relaciones: push-down de queries propias). + +``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE +(la portada resume el documento y el glosario es el destino de los términos +clicables), así que aquí se declaran sin requisitos de cómputo. + +Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan +a test unitario directo. +""" + +from __future__ import annotations + +# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los +# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las +# claves vacías significan "no necesita ese tipo de dependencia". +CHAPTER_DEPS = { + # Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee + # el document_summary que arma build_document; el glosario lee los términos + # que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER + # entero y la validación los reconozca. + "portada": {"profile_flags": [], "ctx": []}, + "overview": {"profile_flags": [], "ctx": ["head_rows"]}, + "analisis_llm": {"profile_flags": ["run_llm"], "ctx": []}, + "num_distr": {"profile_flags": [], "ctx": []}, + "cat_distr": {"profile_flags": [], "ctx": []}, + # text_distr empuja su propia query de texto (no usa raw_numeric); necesita + # db_path/table en el ctx para hacerlo. + "text_distr": {"profile_flags": [], "ctx": ["db_path_table"]}, + "calidad": {"profile_flags": [], "ctx": []}, + # missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y + # puede empujar una query de patrón de nulos con db_path/table. + "missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]}, + # outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models + # asegura además el fallback profile['models']['outliers'] si el ctx faltara. + "outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]}, + "correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]}, + "relaciones": {"profile_flags": [], "ctx": ["db_path_table"]}, + "modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]}, + "timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]}, + "geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]}, + "agregacion": {"profile_flags": [], "ctx": ["db_path_table"]}, + "glosario": {"profile_flags": [], "ctx": []}, +} + +# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters. +ALWAYS_PRESENT = ("portada", "glosario") + +# Flags de coste reconocidos (el orden no importa; se devuelven como set). +KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm") + +# Mapeo de cada etiqueta de ctx a las claves REALES que produce +# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se +# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no +# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/ +# geo_points) son las piezas de datos podables. +CTX_LABEL_TO_KEYS = { + "head_rows": {"head_rows"}, + "raw_numeric": {"raw_numeric"}, + "timeseries_raw": {"timeseries_raw"}, + "geo_points": {"geo_points", "raw_numeric"}, + "db_path_table": set(), # db_path/table siempre presentes; nunca se podan. +} + +# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo +# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí. +DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points") + + +def _as_id_list(chapter_ids): + """Normaliza la entrada a una lista de ids string, defensiva. None -> [].""" + if chapter_ids is None: + return [] + if isinstance(chapter_ids, str): + return [chapter_ids] + return [c for c in chapter_ids if isinstance(c, str)] + + +def validate_chapter_ids(chapter_ids, order): + """Separa los ids pedidos en válidos y desconocidos respecto a ``order``. + + Args: + chapter_ids: lista (o str) de ids de capítulo pedidos. + order: lista canónica de ids válidos (CHAPTER_ORDER). + + Returns: + dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de + aparición de la entrada. Función pura. + """ + valid_set = set(order or []) + valid, unknown = [], [] + for cid in _as_id_list(chapter_ids): + (valid if cid in valid_set else unknown).append(cid) + return {"valid": valid, "unknown": unknown} + + +def resolve_requirements(chapter_ids): + """Une los requisitos de cómputo de los capítulos pedidos. + + Es el corazón de la resolución de dependencias: dado el subconjunto de + capítulos a renderizar, devuelve TODO lo que hay que activar/construir para + que esos capítulos lleguen COMPLETOS, y solo eso. + + Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente + porque el pipeline siempre los incluye; como no tienen requisitos, no alteran + el resultado, pero se contemplan para que el conjunto sea coherente. + + Args: + chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se + ignoran silenciosamente (la validación estricta es de quien llama). + None o lista vacía -> requisitos vacíos. + + Returns: + dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde + ``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función + pura. + """ + ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT) + profile_flags = set() + ctx_keys = set() + for cid in ids: + dep = CHAPTER_DEPS.get(cid) + if not isinstance(dep, dict): + continue + for f in dep.get("profile_flags", []) or []: + if f in KNOWN_PROFILE_FLAGS: + profile_flags.add(f) + for k in dep.get("ctx", []) or []: + ctx_keys.add(k) + return {"profile_flags": profile_flags, "ctx_keys": ctx_keys} + + +def resolve_profile_flags(chapter_ids): + """Atajo: solo el set de profile_flags a activar para los capítulos pedidos. + + Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS. + """ + return resolve_requirements(chapter_ids)["profile_flags"] + + +def needs_render_ctx(chapter_ids): + """True si algún capítulo pedido necesita datos crudos del ctx. + + Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero + (ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de + datos crudos. Función pura. + """ + return bool(resolve_requirements(chapter_ids)["ctx_keys"]) + + +def resolve_ctx_data_keys(chapter_ids): + """Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos. + + Traduce las etiquetas de ctx a las claves concretas que produce + ``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points). + El pipeline poda del ctx las claves de datos que NO estén en este set, para + que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table + nunca se podan (no aparecen aquí). Función pura. + + Returns: + set[str] subconjunto de DATA_CTX_KEYS. + """ + req = resolve_requirements(chapter_ids) + keep = set() + for label in req["ctx_keys"]: + keep |= CTX_LABEL_TO_KEYS.get(label, set()) + # Solo claves de datos podables (db_path/table se gestionan aparte). + return {k for k in keep if k in DATA_CTX_KEYS} diff --git a/python/functions/datascience/automatic_eda/chapter_deps_test.py b/python/functions/datascience/automatic_eda/chapter_deps_test.py new file mode 100644 index 00000000..aa77bf6b --- /dev/null +++ b/python/functions/datascience/automatic_eda/chapter_deps_test.py @@ -0,0 +1,160 @@ +"""Tests del mapa central de dependencias por capítulo (chapter_deps). + +Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente +sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges), +la validación de ids y los helpers de eficiencia (qué cómputo se salta). +""" + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions +if _FUNCTIONS not in sys.path: + sys.path.insert(0, _FUNCTIONS) + +from datascience.automatic_eda.chapter_deps import ( # noqa: E402 + ALWAYS_PRESENT, + CHAPTER_DEPS, + DATA_CTX_KEYS, + needs_render_ctx, + resolve_ctx_data_keys, + resolve_profile_flags, + resolve_requirements, + validate_chapter_ids, +) +from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402 + + +# --------------------------------------------------------------------------- # +# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más). +# --------------------------------------------------------------------------- # +def test_chapter_deps_covers_every_chapter_in_order(): + assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), ( + "CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER") + # Cada entrada tiene la forma esperada. + for cid, dep in CHAPTER_DEPS.items(): + assert isinstance(dep.get("profile_flags"), list), cid + assert isinstance(dep.get("ctx"), list), cid + + +# --------------------------------------------------------------------------- # +# resolve_requirements — golden: outliers exige run_models + raw_numeric. +# --------------------------------------------------------------------------- # +def test_resolve_outliers_requires_run_models_and_raw_numeric(): + req = resolve_requirements(["outliers"]) + assert "run_models" in req["profile_flags"] + assert "raw_numeric" in req["ctx_keys"] + assert "run_series" not in req["profile_flags"] + assert "run_llm" not in req["profile_flags"] + + +def test_resolve_timeseries_requires_run_series(): + req = resolve_requirements(["timeseries"]) + assert req["profile_flags"] == {"run_series"} + assert "timeseries_raw" in req["ctx_keys"] + + +def test_resolve_analisis_llm_requires_run_llm(): + assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"} + + +def test_resolve_union_of_several_chapters(): + req = resolve_requirements(["outliers", "timeseries", "analisis_llm"]) + assert req["profile_flags"] == {"run_models", "run_series", "run_llm"} + + +# --------------------------------------------------------------------------- # +# Eficiencia: capítulos que NO necesitan flags caros no los activan. +# --------------------------------------------------------------------------- # +def test_resolve_geospatial_needs_no_cost_flags(): + """geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos.""" + req = resolve_requirements(["geospatial"]) + assert req["profile_flags"] == set(), \ + "geospatial no debe activar run_models/run_series/run_llm" + assert "geo_points" in req["ctx_keys"] + + +def test_resolve_correlacion_needs_raw_numeric_but_no_models(): + req = resolve_requirements(["correlacion"]) + assert req["profile_flags"] == set() + assert "raw_numeric" in req["ctx_keys"] + + +def test_always_present_chapters_add_no_requirements(): + """portada y glosario están siempre, pero no arrastran cómputo.""" + for cid in ALWAYS_PRESENT: + req = resolve_requirements([cid]) + assert req["profile_flags"] == set() + assert req["ctx_keys"] == set() + + +def test_resolve_profile_flags_shortcut(): + assert resolve_profile_flags(["modelos"]) == {"run_models"} + assert resolve_profile_flags(["num_distr"]) == set() + + +# --------------------------------------------------------------------------- # +# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo. +# --------------------------------------------------------------------------- # +def test_needs_render_ctx_true_when_chapter_reads_raw_data(): + assert needs_render_ctx(["outliers"]) is True + assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down + assert needs_render_ctx(["timeseries"]) is True + + +def test_needs_render_ctx_false_for_purely_aggregated_chapters(): + """num_distr / cat_distr / calidad solo leen el profile agregado.""" + assert needs_render_ctx(["num_distr"]) is False + assert needs_render_ctx(["cat_distr", "calidad"]) is False + + +# --------------------------------------------------------------------------- # +# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no). +# --------------------------------------------------------------------------- # +def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric(): + assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"} + + +def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric(): + assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"} + + +def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable(): + """agregacion usa db_path/table (siempre presentes), 0 claves podables.""" + assert resolve_ctx_data_keys(["agregacion"]) == set() + + +def test_resolve_ctx_data_keys_subset_of_data_keys(): + keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"]) + assert keep <= set(DATA_CTX_KEYS) + assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep + + +# --------------------------------------------------------------------------- # +# validate_chapter_ids — separa válidos de desconocidos preservando orden. +# --------------------------------------------------------------------------- # +def test_validate_separates_known_and_unknown(): + out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"], + CHAPTER_ORDER) + assert out["valid"] == ["outliers", "timeseries"] + assert out["unknown"] == ["nope", "ghost"] + + +def test_validate_all_known(): + out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER) + assert out["unknown"] == [] + + +# --------------------------------------------------------------------------- # +# Robustez: entradas raras nunca lanzan. +# --------------------------------------------------------------------------- # +def test_resolve_handles_none_and_empty(): + assert resolve_requirements(None)["profile_flags"] == set() + assert resolve_requirements([])["profile_flags"] == set() + # ids desconocidos se ignoran silenciosamente en la resolución. + assert resolve_requirements(["no_existe"])["ctx_keys"] == set() + + +def test_resolve_accepts_single_string(): + assert resolve_requirements("outliers")["profile_flags"] == {"run_models"} diff --git a/python/functions/datascience/automatic_eda/chapters_registry.py b/python/functions/datascience/automatic_eda/chapters_registry.py index 17d956db..43653d7e 100644 --- a/python/functions/datascience/automatic_eda/chapters_registry.py +++ b/python/functions/datascience/automatic_eda/chapters_registry.py @@ -73,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict): return model.as_chapter(result) -def build_document(profile: dict, ctx: dict = None) -> list: - """Build the full ordered list of chapters for a TableProfile. +def build_document(profile: dict, ctx: dict = None, only: list = None) -> list: + """Build the ordered list of chapters for a TableProfile. Args: profile: the ``eda`` group TableProfile dict (may be None/empty). ctx: optional context dict carrying presentation metadata not present in the profile (dataset_name, source_origin, storage, generated_at, description, granularity, quality_criteria, head_rows, ...). + only: optional list of chapter ids to render. ``None`` (default) keeps + the historical behaviour — every implemented & applicable chapter in + canonical order. A list restricts the BODY to just those ids (in + canonical order), but the cover (``portada``) and glossary + (``glosario``) are ALWAYS included so the document stays valid and + the clickable terms keep a destination — so passing ``only=["x"]`` + yields portada + x + glosario. Unknown ids are simply skipped (the + caller is responsible for strict validation). ``only=[]`` yields the + minimal document (portada + glosario only). This argument is additive + and backward-compatible: the signature is unchanged for existing + callers (default ``None``). Returns: list[Chapter] in canonical order, containing only the chapters that are - implemented and applicable. Never raises. + implemented, applicable and selected. Never raises. """ if not isinstance(profile, dict): profile = {} # Copy ctx so the shared collector / summary we add do not leak to the caller. ctx = dict(ctx) if isinstance(ctx, dict) else {} + # only=None -> all body chapters (historical). only=list -> restrict body to + # that selection (portada/glosario are added unconditionally below). The + # renderers call build_document(profile, meta['ctx']) without an `only` + # argument, so the pipeline forwards the selection through a reserved ctx key + # (``_only_chapters``); an explicit `only` argument always wins. The key is + # popped from the local ctx copy so it never reaches the chapters. + if only is None: + _carried = ctx.pop("_only_chapters", None) + if isinstance(_carried, (list, tuple, set)): + only = list(_carried) + else: + ctx.pop("_only_chapters", None) + # A set makes the membership test cheap; the iteration order stays + # CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document. + only_set = set(only) if isinstance(only, (list, tuple, set)) else None + # A single glossary collector is shared by every chapter via ctx['glossary']. # Chapters call ctx['glossary'].add(key, label, definition) and mark in-text # appearances with [[term:key]]…[[/term]]; the glosario chapter renders the @@ -106,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list: for cid in CHAPTER_ORDER: if cid in (_PORTADA, _GLOSARIO): continue + # When a selection is given, skip body chapters outside it. portada and + # glosario are never filtered (handled out of this loop). + if only_set is not None and cid not in only_set: + continue ch = build_chapter(cid, profile, ctx) if ch is not None and ch.blocks: body.append(ch) diff --git a/python/functions/pipelines/render_automatic_eda.md b/python/functions/pipelines/render_automatic_eda.md index 50d4bcda..9efabf06 100644 --- a/python/functions/pipelines/render_automatic_eda.md +++ b/python/functions/pipelines/render_automatic_eda.md @@ -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__'." - 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:} 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 diff --git a/python/functions/pipelines/render_automatic_eda.py b/python/functions/pipelines/render_automatic_eda.py index 942ee456..48c96dd9 100644 --- a/python/functions/pipelines/render_automatic_eda.py +++ b/python/functions/pipelines/render_automatic_eda.py @@ -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. diff --git a/python/functions/pipelines/render_automatic_eda_only_test.py b/python/functions/pipelines/render_automatic_eda_only_test.py new file mode 100644 index 00000000..a6222345 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda_only_test.py @@ -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