Files
fn_registry/docs/automatic_eda_contract.md
T
egutierrez d1a3d58a6b feat(eda): motor AutomaticEDA fase 4a — render fixes + keep-together + glosario clicable
Mejoras transversales del motor de render (no del contenido de capítulos):

1. Fix negrita pisa texto (PDF): _place_rich_lines mide el ancho REAL de cada
   span con las métricas de fuente del renderer (peso correcto) en vez del
   grid de ancho medio; negrita y normal en la misma línea ya no se solapan.
2. Zebra striping: filas pares sombreadas (#f6f8fa) en DataTable (PDF + PPTX),
   coherente al partir tablas largas (índice de fila lógico, no por página).
3. Keep-together: bloque Group nuevo; el renderer mide el grupo entero y lo
   mueve completo a la página/slide siguiente si no cabe, y encoge la figura
   (height_in) para dejar sitio a su título y texto. num_distr lo usa.
4. Caption siempre visible en toda figura PPTX (fallback al heading); la figura
   reserva el alto de su caption para que ambos quepan en el mismo slide.
5. Portada construida al final (con resumen agregado del análisis vía
   ctx['document_summary']) pero colocada primera por build_document.
6. Glosario: capítulo nuevo (último) + GlossaryCollector en ctx; los capítulos
   registran términos y marcan apariciones con [[term:key]]...[[/term]]. Links
   clicables reales: PDF (PyMuPDF, link GOTO) y PPTX (slide-jump nativo).
   Enganchado "entropía" en cat_distr como ejemplo end-to-end.

Funciones reutilizables delegadas a fn-constructor (tag eda):
- add_pdf_internal_links_py_datascience (PyMuPDF)
- pptx_link_run_to_slide_py_datascience (slide-jump)

Contrato docs/automatic_eda_contract.md actualizado (§1/§3/§5 + §11 nueva) con
la API de glosario, keep-together y zebra para la siguiente fase. PyMuPDF
declarado en pyproject. Suite verde (90 tests); golden titanic verificado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:35:19 +02:00

20 KiB

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 | Group | GlossaryEntry

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
Group(blocks, title=None) unidad keep-together: sus bloques se mantienen juntos el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. Ver §11
GlossaryEntry(key, label, definition) una entrada del glosario (destino clicable) la genera el capítulo glosario; registra su posición como destino de los términos marcados. Ver §11

Figure/Image aceptan height_in (hint): el renderer clampa la figura a esa altura máxima (lo usa Group para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es siempre visible (si no se da caption, cae al último heading o a "Figura").

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/<id>.py que expone dos símbolos:

CHAPTER_VERSION = "1.0.0"   # semver de generación del capítulo (ver §4)

def build_<id>(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_<id> donde <id> es el del módulo y el de CHAPTER_ORDER (§3). Ej.: chapters/num_distr.pybuild_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:

CHAPTER_ORDER = [
    "portada", "overview", "analisis_llm", "num_distr", "cat_distr", "calidad",
    "correlacion", "modelos", "timeseries", "geospatial", "agregacion",
    "glosario",
]

build_document(profile, ctx) recorre este orden, importa perezosamente chapters/<id>.py y llama build_<id>. Para añadir un capítulo NO se edita chapters_registry.py: basta crear el módulo chapters/<id>.py (con su <id> 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.

Dos capítulos tienen posición especial (los gestiona build_document, no toques esto):

  • portada: se construye el último (después del cuerpo) para poder resumir el análisis, pero se coloca el primero. Recibe ctx['document_summary'] (ver §5) con un resumen agregado del resto. Decisión del usuario: la portada refleja hallazgos.
  • glosario: se construye y se coloca el último. Lee los términos que los demás capítulos registraron en ctx['glossary'] (ver §11). Si no se registró ninguno, el capítulo devuelve None y desaparece.

Si tu capítulo usa un <id> 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: <Título> · v<version>.
  • ENGINE_VERSION (en model.py) versiona el motor global.
  • Al renderizar se escribe automatic_eda_manifest.json junto a la salida:
{
  "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
glossary GlossaryCollector compartido — los capítulos registran términos en él. Lo crea build_document; ver §11
document_summary dict con el resumen agregado del cuerpo (n_rows, n_cols, quality_score, n_numeric, n_categorical, chapter_titles, …). Lo calcula build_document y lo consume la portada

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.

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

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):

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.


11. Glosario, keep-together y zebra (motor, fase 4a)

Tres capacidades transversales del motor que todos los capítulos pueden usar. La 6.1 (glosario) requiere que el capítulo coopere (registrar + marcar términos); la 6.2 (keep-together) es opt-in por capítulo (envolver bloques en Group); la 6.3 (zebra) es automática (no hay nada que hacer).

11.1 Glosario con términos clicables

El glosario es un capítulo nuevo (chapters/glosario.py) que se renderiza siempre el último y lista cada término técnico que algún capítulo haya registrado. Cada aparición del término en el texto se vuelve un clic real que salta a su entrada: en PDF como link annotation interno (post-proceso con PyMuPDF, porque PdfPages no soporta hyperlinks internos), en PPTX como slide-jump nativo (ppaction://hlinksldjump).

API exacta para un capítulo (dos pasos):

  1. Registrar el término en el colector compartido ctx['glossary'] (un model.GlossaryCollector, creado por build_document y pasado a todos los capítulos):

    glossary = ctx.get("glossary")
    if isinstance(glossary, model.GlossaryCollector):
        glossary.add("entropia", "Entropía (de Shannon)", "Medida, en bits, de …")
    

    add(key, label, definition) es idempotente (la primera definición de cada key gana). key debe ser [A-Za-z0-9_]+. Si no hay colector en ctx (renderizado suelto), el capítulo simplemente no marca términos — degrada sin romper.

  2. Marcar cada aparición en el texto de un bloque Markdown con el span inline [[term:KEY]]texto visible[[/term]]. El texto visible puede llevar **negrita**. El marcador no altera el texto visible (se elimina como cualquier marcador inline); solo añade el destino clicable.

    # En cat_distr (ejemplo real ya implementado):
    "La [[term:entropia]]**entropía de Shannon**[[/term]] mide cómo de repartidos…"
    

Eso es todo: el capítulo glosario recoge los términos (orden alfabético por label), emite un GlossaryEntry por término, y los renderers cablean los enlaces automáticamente. Si ningún capítulo registró términos, el glosario no aparece.

Helpers de text_layout (no reimplementar): parse_inline_rich(text)[(texto, is_bold, term_key), …]; wrap_rich_terms(text, max_chars) → líneas de esos spans sin corte. strip_inline_md ya elimina los marcadores [[term:…]]/[[/term]]. (Las funciones previas parse_inline_bold / wrap_rich siguen existiendo, sin términos.)

Funciones del registry que cablean los enlaces (grupo eda, ya invocadas por los renderers; degradan en silencio si faltan): add_pdf_internal_links_py_datascience (PyMuPDF, link GOTO) y pptx_link_run_to_slide_py_datascience (salto a slide nativo). Dependencia: pymupdf (declarada en python/pyproject.toml).

Trabajo de la siguiente fase — enganchar más términos. El mecanismo está hecho y probado de extremo a extremo con entropia (en cat_distr). Cada capítulo debe registrar y marcar SUS términos con el mismo patrón de dos pasos. Candidatos por capítulo:

Capítulo Términos a enganchar (key sugerida)
cat_distr entropia (hecho)
calidad completitud, validez, consistencia
correlacion cramers_v, fdr (comparaciones múltiples), método de correlación usado
modelos pca, silhouette, isolation_forest
timeseries estacionariedad, acf_pacf, stl
num_distr iqr, curtosis, outlier (vallas de Tukey)

Define la definición de cada término en su capítulo (constante local, como _TERM_ENTROPIA_DEF en cat_distr) y márcalo en su primera aparición.

11.2 Keep-together: gráfico junto a su título y texto (Group)

Para que un encabezado no quede en una página/slide y su figura en la siguiente, envuelve los bloques de una misma idea en un model.Group:

blocks.append(model.Group(blocks=[
    model.Heading(text=str(name), level=2),
    model.Figure(make=_figura_perezosa(...), caption="…"),
    model.Markdown(text="explicación…"),
]))

El renderer mide el grupo entero antes de dibujar nada: si no cabe en lo que queda de página/slide pero cabe en una entera, lo mueve completo a la siguiente; y encoge la figura (vía height_in) lo justo para que el título + texto + figura quepan juntos. Si el grupo es más alto que una página entera, empieza en una nueva y fluye (degradación honesta, nunca corta). Ejemplo real implementado: num_distr envuelve cada columna (heading + figura histograma/boxplot + nota) en un Group.

Recomendado para agregacion y cualquier capítulo donde una figura deba ir pegada a su título/explicación. Coste: si un capítulo inspecciona chapter.blocks en sus tests, ahora encontrará Groups — aplana con un helper recursivo (ver num_distr_test.py::_flatten).

11.3 Zebra striping en tablas (automático)

Todo DataTable se renderiza con filas pares sombreadas (gris muy suave #f6f8fa) y cabecera con su fondo propio. Es automático en PDF y PPTX; el patrón se mantiene coherente cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por página). No hay nada que hacer en los capítulos.


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:

# 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.