# AutomaticEDA — contrato de capítulos Documento autoritativo para **escribir capítulos** del informe AutomaticEDA. Léelo entero antes de añadir un capítulo: define el modelo de bloques, la firma del builder, el versionado, dónde colocar el módulo, cómo se registra en el orden del documento, qué claves del `profile` consume cada capítulo y un ejemplo completo de capítulo de referencia (OVERVIEW). AutomaticEDA es la capa intermedia entre **contenido** (lo que un capítulo quiere decir) y **formato de salida** (PDF móvil + PPTX para compartir). Un mismo documento por capítulos se renderiza a los dos formatos con garantía de **no-corte**: el texto se envuelve a líneas completas, las tablas largas se parten por filas repitiendo la cabecera, y figuras/imágenes se escalan para caber enteras. - Código del motor: `python/functions/datascience/automatic_eda/` (paquete de soporte). - Funciones públicas del registry (grupo `eda`): `render_automatic_eda_pdf`, `render_automatic_eda_pptx`. - Sustituye evolutivamente a `render_eda_pdf` **de forma aditiva** (ese sigue activo en `profile_table(emit_pdf=True)`). --- ## 1. Modelo de documento ``` Document = list[Chapter] Chapter = { id: str, title: str, version: str, blocks: list[Block] } Block = Heading | Markdown | KVTable | DataTable | Figure | Image | Caption | Note ``` Importa el modelo desde `datascience.automatic_eda.model` (o `from datascience.automatic_eda import ...`). Todos los bloques son dataclasses; los renderers también aceptan **dicts** con la clave `kind` (lectura defensiva: lo no reconocido se degrada a `Note`, nunca lanza). ### Bloques | Bloque | Construcción | Qué hace en el render | |---|---|---| | `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento | | `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** | | `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve | | `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **se parte por filas repitiendo cabecera**; las celdas largas se envuelven dentro de su columna | | `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) | | `Image(path, caption=None, height_in=None)` | ruta a PNG/JPG | se escala para caber entera | | `Caption(text)` / `Note(text)` | texto auxiliar pequeño | pie/nota en gris; `Note` es además el fallback de lo desconocido | ### Subset de markdown soportado (`Markdown`) `#`/`##`/`###` → headings; `-`/`*` → viñetas; líneas `| a | b |` consecutivas → una `DataTable`; línea en blanco → separación de párrafo; `**bold**`/`__bold__`/`` `code` `` → se quitan los marcadores y se conserva el texto. Todo lo demás se renderiza tal cual. Garantía: ningún carácter se pierde; lo que no cabe se envuelve o pasa de página/slide. --- ## 2. Firma del builder de capítulo (OBLIGATORIA) Cada capítulo es un módulo `python/functions/datascience/automatic_eda/chapters/.py` que expone **dos** símbolos: ```python CHAPTER_VERSION = "1.0.0" # semver de generación del capítulo (ver §4) def build_(profile: dict, ctx: dict) -> "Chapter | None": """Construye el capítulo desde el TableProfile y el contexto de presentación. Devuelve None si el capítulo NO aplica a este dataset (p.ej. timeseries sin columna fecha). Lee SIEMPRE defensivamente con .get y NUNCA lanza. """ ``` - El nombre de la función es exactamente `build_` donde `` es el del módulo y el de `CHAPTER_ORDER` (§3). Ej.: `chapters/num_distr.py` → `build_num_distr`. - Devuelve un `model.Chapter(id, title, version=CHAPTER_VERSION, blocks=[...])` o `None`. - Un capítulo que devuelve `None` o cuyos `blocks` quedan vacíos se omite del documento. --- ## 3. Registro y orden del documento El orden canónico está **pre-declarado** en `python/functions/datascience/automatic_eda/chapters_registry.py`: ```python CHAPTER_ORDER = [ "portada", "overview", "num_distr", "cat_distr", "calidad", "correlacion", "modelos", "analisis_llm", "timeseries", "geospatial", "agregacion", ] ``` `build_document(profile, ctx)` recorre este orden, importa perezosamente `chapters/.py` y llama `build_`. **Para añadir un capítulo NO se edita `chapters_registry.py`**: basta crear el módulo `chapters/.py` (con su `` ya en `CHAPTER_ORDER`) y aparecerá automáticamente en su posición. Esto permite que muchos agentes trabajen **en paralelo** sin contención: cada uno toca solo su archivo. Si tu capítulo usa un `` que aún no está en `CHAPTER_ORDER`, añádelo en la posición correcta (única edición compartida; coordínala con el orquestador). `build_document` nunca lanza: un capítulo cuyo módulo no existe se salta, y uno que falla o devuelve `None` se omite. --- ## 4. Versionado por capítulo + manifiesto - `CHAPTER_VERSION` (semver) identifica la **generación** del capítulo. Bumpéalo cuando cambies qué/cómo emite el capítulo (no en cada corrida). Se estampa en el pie de cada página/slide: ` · v`. - `ENGINE_VERSION` (en `model.py`) versiona el motor global. - Al renderizar se escribe `automatic_eda_manifest.json` junto a la salida: ```json { "engine": "AutomaticEDA", "engine_version": "1.0.0", "generated_at": "2026-06-30 12:20:56 UTC", "chapters": { "portada": { "version": "1.0.0", "n_pages": 1, "n_slides": 1 }, "overview": { "version": "1.0.0", "n_pages": 2, "n_slides": 2 } } } ``` Llamar a uno o ambos renderers crea/actualiza el manifiesto (read-modify-write defensivo). Esto habilita el **seguimiento y la mejora continua por capítulo**. --- ## 5. `ctx` — contexto de presentación `ctx` lleva metadatos que **no están** en el `TableProfile` (lo aporta el caller via `meta['ctx']`). Claves convencionales (todas opcionales): | Clave | Uso | |---|---| | `dataset_name` | nombre del dataset (portada). Default: `profile['table']` | | `source_origin` | de dónde viene el dataset (portada). Default: `profile['source']` | | `storage` | tecnología de almacenamiento (portada). Default: inferido de `source` | | `generated_at` | fecha de generación (portada/manifiesto). Default: `profiled_at`/ahora | | `description` | frase de descripción del dataset (portada) | | `granularity` | "Cada fila es…" (portada). Default: derivado de `key_candidates` | | `quality_criteria` | criterios del score de calidad (portada) | | `head_rows` | `list[dict]` con `df.head` (overview). Ver §7 | Un capítulo puede definir y consumir sus propias claves `ctx` — documenta cuáles en su docstring. --- ## 6. Claves del `profile` que consume cada capítulo El `TableProfile` lo produce `profile_table(...)["profile"]` (grupo `eda`). Claves de nivel superior: `table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows, duplicate_pct, null_cell_pct, constant_cols, all_null_cols, quality_score, type_breakdown, key_candidates, columns[], correlations, llm, models, series, caveats`. Cada `columns[i]`: `name, inferred_type, semantic_type, physical_type, distinct_count, unique_pct, null_count, null_pct, empty_count, empty_pct, flags, quality_score, numeric{min,max,mean,median,std,variance,cv,iqr,skew,kurtosis,p1..p99,mode,n_outliers, outlier_pct,zero_pct,negative_pct,distribution_type,histogram[{lo,hi,count}]}, categorical{top[{value,count,pct}],mode,n_distinct,entropy,imbalance,len_min/mean/max}, reexpression, series{...}`. | Capítulo | Claves del profile que consume | |---|---| | `portada` | `table, source, profiled_at, n_rows, n_cols, quality_score, key_candidates` + `ctx` | | `overview` | `columns[].{name,inferred_type,semantic_type,physical_type,null_pct,null_count,categorical.top,numeric.{min,median,max,mean,std}}`, `head_rows` (ver §7) | | `num_distr` (pendiente) | `columns[] numeric.{histogram,mean,median,std,outlier_pct,...}` | | `cat_distr` (pendiente) | `columns[] categorical.{top,entropy,imbalance}` | | `calidad` (pendiente) | `quality_score`, `columns[].{quality_score,flags,issues}`, `duplicate_*`, `null_cell_pct`, `constant_cols`, `all_null_cols` | | `correlacion` (pendiente) | `correlations.pairs[{a,b,value,method}]`, `correlations.levels_caveat` | | `modelos` (pendiente) | `models.{pca,kmeans,outliers,normality}` | | `analisis_llm` (pendiente) | `llm` | | `timeseries` (pendiente) | `series{col:{stationarity,acf_pacf,stl,levels_*}}` | | `geospatial` (pendiente) | columnas con `semantic_type` geográfico (lat/lon) | | `agregacion` (pendiente) | `columns[]` + agregados que la fase de cálculo añada | --- ## 7. Claves nuevas del profile que la fase de cálculo debe añadir El `TableProfile` actual **no** trae estas claves; el capítulo OVERVIEW las consume y, si faltan, degrada honestamente (placeholder + derivación de valores reales). Para un overview completo, la fase de cálculo (otro agente) debe añadir: - `profile['head_rows']`: `list[dict]` con las primeras N filas (`df.head`), una por dict `{columna: valor}`. Mientras tanto OVERVIEW muestra un placeholder. - `columns[i]['examples']`: `list` de hasta N valores **no nulos** crudos de la columna. Mientras tanto OVERVIEW deriva ejemplos de `categorical.top[].value` (categóricas) y de `numeric.{min,median,max}` (numéricas) — son valores reales, no inventados. Sugerencia de implementación (no obligatoria en esta fase): una función del registry que muestree `head_rows`/`examples` desde DuckDB y las inyecte en el profile antes de renderizar (delegar a `fn-constructor`, tag `eda`). --- ## 8. Ejemplo COMPLETO de capítulo de referencia (OVERVIEW) Copia este patrón. Archivo real: `python/functions/datascience/automatic_eda/chapters/overview.py`. ```python from .. import model CHAPTER_VERSION = "1.0.0" CHAPTER_ID = "overview" CHAPTER_TITLE = "Overview" def _fmt_num(v, d=3): # ... formateo defensivo (None -> "—", floats compactos) ... ... def _examples_for(col: dict) -> str: # 1) col['examples'] si existe; 2) categorical.top[].value; # 3) numeric.{min,median,max}. Nunca celda vacía ni inventada. ... def build_overview(profile: dict, ctx: dict): profile = profile or {} ctx = ctx or {} cols = profile.get("columns") or [] if not cols and not (ctx.get("head_rows") or profile.get("head_rows")): return None # no aplica. blocks = [ model.Heading(text="Primeras filas (df.head)", level=2), _head_block(profile, ctx), # DataTable(df.head) o Note si falta head_rows. ] cols_block = _columns_block(profile) # DataTable: nombre/tipo/nulos/ejemplos. if cols_block is not None: blocks.append(model.Heading(text="Diccionario de columnas", level=2)) blocks.append(cols_block) desc_block = _describe_block(profile) # DataTable: mean/median/min/max/std. if desc_block is not None: blocks.append(model.Heading(text="Resumen estadístico numérico", level=2)) blocks.append(desc_block) return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE, version=CHAPTER_VERSION, blocks=blocks) ``` Puntos clave que todo capítulo debe respetar: 1. **Lectura defensiva**: `profile.get(...)`, `or []`, comprobar `isinstance` — nunca asumir que una clave existe ni lanzar. 2. **`None` si no aplica**: devuelve `None` (o `blocks` vacíos) cuando el dataset no tiene lo que el capítulo necesita. 3. **No inventar**: si falta un dato (p.ej. `df.head`), muestra un placeholder honesto o deriva de valores reales del perfil; deja el hueco documentado. 4. **Tablas vía `DataTable`**: deja que el renderer las parta y repita cabecera; no pre-pagines tú. 5. **Figuras vía `Figure(make=...)`**: pásalas perezosas; las dibuja y escala el renderer. --- ## 9. Cómo se prueba un capítulo ```python from datascience.automatic_eda import build_document, render_pdf, render_pptx chapters = build_document(profile, ctx={"dataset_name": "..."}) render_pdf(chapters, "reports/x.pdf", {"title": "EDA"}) render_pptx(chapters, "reports/x.pptx", {"title": "EDA"}) ``` O directo desde las funciones públicas con el profile entero (construyen los capítulos): ```python from datascience import render_automatic_eda_pdf, render_automatic_eda_pptx render_automatic_eda_pdf(profile, "reports/x.pdf", {"ctx": {...}}) render_automatic_eda_pptx(profile, "reports/x.pptx", {"ctx": {...}}) ``` Añade un test self-contained por capítulo (perfil sintético, sin DuckDB) que verifique sus bloques presentes y el no-corte (texto largo intacto en la salida). Patrón: `render_automatic_eda_pdf_test.py`. --- ## 10. Integración futura con `profile_table` (siguiente fase) `profile_table(emit_pdf=True)` usa hoy `render_eda_pdf` (intacto). En la siguiente fase se añadirá `emit_automatic=True` (o se migrará `emit_pdf`) para que cada EDA emita **siempre** PDF + PPTX del motor AutomaticEDA desde el mismo profile: ```python # Bosquejo de la integración aditiva (NO activar si rompe los tests actuales): if emit_automatic: ctx = {"dataset_name": table, "source_origin": db_path, ...} render_automatic_eda_pdf(prof, os.path.join(report_dir, f"aeda_{table}_{ts}.pdf"), {"title": f"EDA — {table}", "ctx": ctx}) render_automatic_eda_pptx(prof, os.path.join(report_dir, f"aeda_{table}_{ts}.pptx"), {"title": f"EDA — {table}", "ctx": ctx}) ``` Hasta entonces los renderers se invocan directamente sobre el `profile` que `profile_table` ya devuelve.