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>
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_pdfde forma aditiva (ese sigue activo enprofile_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 deCHAPTER_ORDER(§3). Ej.:chapters/num_distr.py→build_num_distr. - Devuelve un
model.Chapter(id, title, version=CHAPTER_VERSION, blocks=[...])oNone. - Un capítulo que devuelve
Noneo cuyosblocksquedan 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. Recibectx['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 enctx['glossary'](ver §11). Si no se registró ninguno, el capítulo devuelveNoney 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(enmodel.py) versiona el motor global.- Al renderizar se escribe
automatic_eda_manifest.jsonjunto 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']:listde hasta N valores no nulos crudos de la columna. Mientras tanto OVERVIEW deriva ejemplos decategorical.top[].value(categóricas) y denumeric.{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:
- Lectura defensiva:
profile.get(...),or [], comprobarisinstance— nunca asumir que una clave existe ni lanzar. Nonesi no aplica: devuelveNone(oblocksvacíos) cuando el dataset no tiene lo que el capítulo necesita.- 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. - Tablas vía
DataTable: deja que el renderer las parta y repita cabecera; no pre-pagines tú. - 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):
-
Registrar el término en el colector compartido
ctx['glossary'](unmodel.GlossaryCollector, creado porbuild_documenty 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 cadakeygana).keydebe ser[A-Za-z0-9_]+. Si no hay colector enctx(renderizado suelto), el capítulo simplemente no marca términos — degrada sin romper. -
Marcar cada aparición en el texto de un bloque
Markdowncon 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.