Files
fn_registry/docs/automatic_eda_contract.md
T
egutierrez a74a5a047f feat(eda): render quality global — DPI 220, tablas anchas como imagen, layout side_by_side, índice clicable
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>
2026-07-01 01:34:21 +02:00

25 KiB
Raw Blame History

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:

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.

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:

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:

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