a74a5a047f
Mejoras transversales del motor AutomaticEDA (PDF + PPTX) sobre el modelo de bloques: 1. DPI alto global: toda figura/imagen embebida se rasteriza a 220 dpi (antes 150, y en PDF la página se guardaba a ~100 dpi re-rasterizando los imshow). En PDF se aplica savefig.dpi=220 a la página; el texto sigue vectorial y seleccionable. Permite ampliar en el móvil sin pixelar. Imagen embebida medida: ~1081px (antes ~492px). 2. Tabla ancha → imagen de alta resolución: cuando un DataTable tiene demasiadas columnas para ser legible como texto (criterio _table_fits_as_text), se dibuja entera como una imagen nítida (nueva función render_table_as_figure_py_datascience: cabecera sombreada + zebra) escalada para caber completa, de modo que el lector hace zoom y la lee sin perder datos. Las tablas que sí caben siguen como texto seleccionable / tabla nativa. Aplica en PDF y PPTX. El df.head de 19 columnas del dataset sintético ya no se corta: sale como imagen. 3. Group.layout: nuevo hint retrocompatible (default "stack"). "side_by_side" coloca la tabla a la izquierda (~55%) y la figura a la derecha (~45%) en la misma slide PPTX (cae a apilado si no hay par tabla+figura o no caben); en PDF se trata como "stack" (el ancho A5 móvil no admite dos columnas). Pensado para que el capítulo cat_distr ponga el gráfico al lado de la tabla en PPT. 4. Portada con índice clicable: la lista de capítulos pasa de "Este informe incluye..." (markdown) a un Heading "Índice" + un TocEntry por capítulo. El renderer registra el inicio de cada capítulo y cablea cada entrada como salto real (PDF: link GOTO PyMuPDF; PPTX: salto a slide nativo), reutilizando el mecanismo del glosario clicable. Modelo: Group gana `layout`; nuevo bloque TocEntry; normalizers y __init__ actualizados. Contrato: documentado en docs/automatic_eda_contract.md §11.4 (incluye el contrato exacto del campo layout para el agente de cat_distr). Tests: nuevo render_quality_test.py (13 golden: DPI alto real, tabla ancha→imagen PDF/PPTX, narrow→texto, side_by_side PPTX dos columnas / PDF apilado, índice clicable PDF+PPTX, retrocompatibilidad layout por defecto). render_features_test actualizado al índice nuevo. Suite: 188 passed (módulo) + 38 passed/1 skipped (acceptance + pipeline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
480 lines
25 KiB
Markdown
480 lines
25 KiB
Markdown
# 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; **si cabe** como texto se parte por filas repitiendo cabecera; **si NO cabe** (demasiadas columnas) se rasteriza entera como imagen de alta resolución para hacer zoom. Ver §11.4 |
|
||
| `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, page_break_before=False, layout="stack")` | 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. `layout="side_by_side"` coloca tabla+figura en dos columnas (solo PPTX). Ver §11 y §11.4 |
|
||
| `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 |
|
||
| `TocEntry(label, target_id)` | una entrada de **índice clicable** en la portada | la genera el capítulo `portada`; el renderer la cablea como salto al inicio del capítulo cuyo `id` o `title` coincide con `target_id`. Ver §11.4 |
|
||
|
||
`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:
|
||
|
||
```python
|
||
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.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", "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:
|
||
|
||
```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 |
|
||
| `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`.
|
||
|
||
```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`.
|
||
|
||
---
|
||
|
||
## 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):
|
||
|
||
```python
|
||
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.
|
||
|
||
```python
|
||
# 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`:
|
||
|
||
```python
|
||
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á `Group`s — 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.
|
||
|
||
### 11.4 Calidad de render global: DPI alto, tabla ancha → imagen, figura al lado, índice clicable
|
||
|
||
Cuatro capacidades transversales del motor, **todas automáticas salvo `layout`** (que un
|
||
capítulo activa explícitamente). Aplican a PDF y PPTX salvo donde se indique.
|
||
|
||
**(a) DPI alto (automático).** Toda figura/imagen embebida se rasteriza a **220 dpi**
|
||
(constante `_RASTER_DPI` en ambos renderers; en PDF se aplica también al `savefig` de la
|
||
página, porque matplotlib re-rasteriza cada `imshow` al escribir la página). Objetivo:
|
||
ampliar en el móvil y leer detalle (ejes, celdas) sin pixelar. El texto sigue siendo
|
||
vectorial y seleccionable. No hay nada que hacer en los capítulos.
|
||
|
||
**(b) Tabla ancha → imagen de alta resolución (automático).** Cuando un `DataTable` tiene
|
||
**demasiadas columnas para ser legible como texto** en el ancho útil (criterio
|
||
`_table_fits_as_text`: ancho mínimo legible por columna × nº de columnas > ancho útil; en
|
||
la práctica salta sobre tablas tipo `df.head` con muchas columnas), en vez de comprimir las
|
||
columnas hasta hacerlas ilegibles, la tabla se dibuja **entera como una imagen de alta
|
||
resolución** (función `render_table_as_figure_py_datascience`: cabecera sombreada + zebra)
|
||
escalada para caber completa, de modo que el lector hace **zoom** y la lee sin perder datos.
|
||
Si la tabla **sí cabe**, se mantiene como texto seleccionable (PDF) / tabla nativa (PPTX).
|
||
Las `KVTable` (2 columnas) caben siempre y se quedan como texto. No hay nada que hacer en
|
||
los capítulos.
|
||
|
||
**(c) Figura al lado de la tabla — `Group(layout="side_by_side")`.** Hint de layout que un
|
||
capítulo activa para que su **tabla quede a la izquierda y su figura a la derecha** en la
|
||
misma diapositiva, en lugar de apiladas:
|
||
|
||
```python
|
||
model.Group(
|
||
layout="side_by_side",
|
||
blocks=[
|
||
model.Heading(text=str(name), level=2), # va a ancho completo arriba
|
||
model.DataTable(header=..., rows=...), # columna IZQUIERDA (~55%)
|
||
model.Figure(make=_grafico_perezoso(...)), # columna DERECHA (~45%)
|
||
model.Markdown(text="explicación…"), # va a ancho completo abajo
|
||
])
|
||
```
|
||
|
||
Contrato exacto del campo:
|
||
|
||
| Campo | Valor | Efecto |
|
||
|---|---|---|
|
||
| `layout` | `"stack"` (por defecto) | comportamiento histórico: apilado vertical (keep-together). |
|
||
| `layout` | `"side_by_side"` | **PPTX**: la tabla (rasterizada a imagen) ocupa la columna izquierda (~55% del ancho útil) y la figura la derecha (~45%); cualquier otro bloque (heading, markdown) va a ancho completo arriba/abajo. Si no hay un par tabla+figura, o no caben lado a lado en una slide, **cae automáticamente a apilado**. **PDF**: se trata **igual que `stack`** (el ancho A5 móvil no admite dos columnas legibles). Valores desconocidos degradan a `"stack"`. |
|
||
|
||
Es **retrocompatible**: un `Group` sin `layout` (o `layout="stack"`) se comporta exactamente
|
||
como antes. El capítulo `cat_distr` es el consumidor previsto (gráfico a la derecha de la
|
||
tabla de categorías en PPT); este motor solo provee el soporte.
|
||
|
||
**(d) Índice clicable en la portada — `TocEntry`.** La portada emite un `Heading("Índice")`
|
||
seguido de un `TocEntry(label, target_id)` por capítulo. El renderer registra la
|
||
página/slide de inicio de **cada** capítulo (indexado por `id` **y** por `title`) y cablea
|
||
cada `TocEntry` como un salto real a ese inicio: en **PDF** vía
|
||
`add_pdf_internal_links_py_datascience` (link GOTO de PyMuPDF), en **PPTX** vía
|
||
`pptx_link_run_to_slide_py_datascience` (salto a slide nativo). Como la portada solo conoce
|
||
los **títulos** de los capítulos, el `target_id` se hace coincidir contra el `title` (o el
|
||
`id`) de destino. Si un destino no resuelve, la entrada se muestra igualmente como texto
|
||
(en color de enlace), nunca se corta. Es el mismo mecanismo que los términos clicables del
|
||
glosario (§11.1), reutilizado en sentido portada → capítulo.
|
||
|
||
---
|
||
|
||
## 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.
|