Compare commits

..

20 Commits

Author SHA1 Message Date
egutierrez ab21e5d90b merge: 4b flag profile_level lite/standard/full en render_automatic_eda (lite 4.5s vs full 39.3s, verificado met) 2026-06-30 18:29:44 +02:00
egutierrez da60211826 merge: 4b relaciones — capitulo PK/FK + candidatos intra/inter-tabla (reusa infer_fk_containment_duckdb+build_join_graph, verificado met) 2026-06-30 18:22:29 +02:00
egutierrez 3be188a921 feat(eda): profile_level (lite/standard/full) en render_automatic_eda
Añade el parámetro profile_level a render_automatic_eda como preset de
consumo CPU/LLM que mapea a los flags existentes (run_models, run_series,
run_llm, sample). Tres niveles:

- lite (bajo consumo): run_llm=False, run_series=False, sample=2000 y modelos
  limitados a PCA + normalidad, SIN KMeans ni IsolationForest (lo caro en CPU).
  Para un vistazo rápido y barato.
- standard (default): comportamiento histórico — modelos completos, serie,
  sin LLM.
- full: standard + narrativa LLM por capítulo.

Precedencia: un flag explícito del caller (run_llm=..., run_models=..., etc.)
siempre prima sobre el default que fija el preset; el preset solo aplica al
parámetro que se deja en None.

Cableado del modo lite sin tocar profile_table (lo tocan otros agentes en
paralelo): profile_table NO corre los modelos (evita pagar KMeans +
IsolationForest); este pipeline los corre con run_eda_models(run_kmeans=False,
run_isolation=False) reusando ctx['raw_numeric'], y quita raw_numeric del ctx
para que el capítulo modelos no reproyecte clusters KMeans en vivo
(project_clusters_2d). geo_points ya queda derivado, así que geospatial no se
afecta.

Cambio aditivo y retro-compatible: sin profile_level el comportamiento es
idéntico al de v1.0.0 (standard). Tests nuevos cubren lite/standard, la
precedencia flag-sobre-preset, y la equivalencia del default con el histórico.
Bump 1.0.0 -> 1.1.0 + growth log en el .md. Skill /eda documenta --lite/--full.

Verificación: golden lite/standard/full sobre titanic — lite 4.8s (PCA+norm,
sin KMeans/iso/LLM/serie), standard 7.8s (modelos completos), full 38.3s
(+LLM). Suite render_automatic_eda + automatic_eda: 96 passed. fn index sin
error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:17 +02:00
egutierrez aa5aa67d50 merge: 4b calidad — nueva formula (completeness 0.6+validity 0.4, dataset row_uniqueness, outliers fuera a Observaciones, sin doble conteo) report 2046 (verificado met) 2026-06-30 18:17:23 +02:00
egutierrez 68f4ddabce feat(eda): capítulo RELACIONES para AutomaticEDA
Añade el capítulo `relaciones` al motor AutomaticEDA: analiza las
relaciones de clave de la tabla/base y se coloca tras `correlacion`,
antes de `modelos`, en CHAPTER_ORDER.

Capas que renderiza (solo las que aplican; None si no hay nada que decir):
- Claves declaradas: PK/FK/UNIQUE reales del esquema DuckDB, vía la nueva
  función `detect_declared_keys_duckdb` (lee `duckdb_constraints()`).
- Candidatos a clave primaria: los `key_candidates` del TableProfile.
- FK candidatas inter-tabla: reusa `infer_fk_containment_duckdb`
  (containment + señal de nombre) y `build_join_graph` (roles de nodos +
  diagrama Mermaid pegable). Solo si la fuente DuckDB tiene varias tablas.
- FK candidatas intra-tabla: heurística nombre + cardinalidad, vía la nueva
  función pura `suggest_intratable_fk_candidates`, marcada como sugerencia.

Engancha al glosario clicable los términos PK, FK, containment/inclusión y
cardinalidad (contrato §11.1) y usa Group (keep-together) para el grafo.

Funciones nuevas del registry (grupo `eda`):
- detect_declared_keys_duckdb (impure, datascience) + test.
- suggest_intratable_fk_candidates (pure, datascience) + test.

Tests: relaciones_test.py (golden intra + inter, edges, no-cut render) +
los tests de ambas funciones. Suite automatic_eda + render_automatic_eda
verde (89 passed). Golden end-to-end con el pipeline render_automatic_eda
verificado sobre titanic (intra) y una BD customers/orders (inter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:15:15 +02:00
egutierrez 43821ab11d merge: 4b analisis_llm — dedup Diccionario de datos + Datos personales (verificado met) 2026-06-30 18:14:17 +02:00
egutierrez 32054ad781 merge: 4b portada — tamano grande junto al nombre + descripcion y granularidad funcionando (verificado met) 2026-06-30 18:12:22 +02:00
egutierrez a2074a0167 feat(eda): nueva fórmula de calidad de datos (report 2046) + capítulo calidad
Implementa el modelo de calidad del report 2046 en el grupo eda.

Score de columna: 0.6·completeness + 0.4·validity con renormalización por
aplicabilidad (si la validez no es medible —texto libre o columna 100% nula— el
score se basa solo en completeness). Validez = conformidad real al tipo: nativo
numérico/fecha/bool = 1.0; texto promovido a número/fecha = parse rate
(validity_rate); texto con semantic_type = match_rate; texto libre = no aplica.

Outliers, columnas constantes e identificadores salen del score a un bloque de
observaciones analíticas (no son defectos de calidad). Se elimina el doble
conteo de la falta de datos (mostly_null ya no castiga validez) y el bug de
escala de outliers (que además ya no entran en el score).

Score de dataset: 100·(0.85·cell_quality + 0.15·row_uniqueness) en vez de la
media simple. Se pobla duplicate_rows/duplicate_pct push-down en
summarize_table_duckdb (COUNT sobre DISTINCT *, sin RAM) para habilitar la
unicidad de registro; renormaliza a solo cell_quality si no se puede calcular.

Capítulo calidad (v2.0.0): intro de dos dimensiones (60/40) que declara que los
outliers no bajan el score; tabla de scores Columna|Calidad|Completitud|Validez
(sin Consistencia, n/a cuando no aplica); DOS tablas separadas (Problemas de
calidad vs Observaciones analíticas); resumen con Unicidad de registro; glosario
clicable de completitud, validez, unicidad de registro y calidad de datos.

Verificado: 123 tests verdes (automatic_eda + render_automatic_eda +
column_quality_score + summarize_table_duckdb + profile_table). Golden EDA de
titanic (run_models+run_llm) con score recomputado a mano, outliers separados en
observaciones y glosario clicable (5 links GOTO en el PDF).

column_quality_score v2.0.0, summarize_table_duckdb v1.1.0, profile_table v1.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:10:23 +02:00
egutierrez d001d90306 merge: 4b glosario_hooks — terminos clicables en correlacion/modelos/agregacion (12/12 PDF+PPT, verificado met) 2026-06-30 18:09:37 +02:00
egutierrez 7045f37554 fix(eda): quita rótulos duplicados en capítulo ANÁLISIS LLM
El capítulo etiquetaba dos secciones por partida doble: un Heading de nivel 2
más el 'title' del propio DataTable, imprimiendo 'Diccionario de datos' y
'Datos personales (PII / RGPD)' dos veces seguidas en PDF y PPTX.

Se elimina el 'title' de ambos DataTable y se conserva el Heading único (el
patrón canónico OVERVIEW del contrato §8: el rótulo lo da el Heading, la tabla
solo repite su cabecera de columnas al paginar). El DataTable de PII mantiene su
'note' orientativa. La columna del diccionario ya lee 'Significado de negocio'.

CHAPTER_VERSION 1.0.0 -> 1.1.0. Test nuevo
test_sin_rotulos_duplicados_y_significado_de_negocio fija: tablas sin title,
cabecera exacta 'Significado de negocio', y cada rótulo una sola vez en el PDF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:07:12 +02:00
egutierrez fa8db01059 merge: 4b num_distr — desv std (sigma) en leyenda del histograma (verificado met) 2026-06-30 18:06:46 +02:00
egutierrez f2ac734ef7 merge: 4b head_rows — overview muestra df.head (build_eda_render_ctx pobla head_rows, verificado met) 2026-06-30 18:04:51 +02:00
egutierrez 048781df3f feat(eda): portada — tamaño grande + descripción/granularidad reales
El capítulo PORTADA ahora muestra SIEMPRE el tamaño del dataset (N filas ×
M columnas) en grande, como heading junto al nombre y agrupado con él
(Group keep-together), en lugar de enterrarlo en la tabla de metadatos.

La Descripción y la Granularidad ya no salen vacías ni con placeholders:
se resuelven por cascada — ctx explícito > bloque LLM (profile['llm'].summary
/ row_meaning de eda_llm_insights) > derivación del propio perfil (forma,
mezcla de tipos y score de calidad para la descripción; columnas
key_candidates o la forma de la tabla para una frase 'Cada fila es…').
Las derivaciones son honestas (declaran que vienen del perfil) y nunca
inventan significado de negocio.

Añade chapters/portada_test.py: golden (tamaño grande + textos del LLM,
sin fila 'Tamaño' duplicada), fallbacks sin LLM (keys / forma), prioridad
de ctx, edge de perfil vacío sin lanzar, y render a PDF + PPTX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:04:05 +02:00
egutierrez a421f13d2e feat(eda): engancha glosario clicable en correlacion/modelos/agregacion
Fase 4b — extiende el glosario clicable de AutomaticEDA (mecanismo ya probado
end-to-end con `entropia` en cat_distr) a tres capítulos más, siguiendo el
contrato sección 11 (glossary.add(key,label,def) + span [[term:KEY]]texto[[/term]]):

- correlacion: Pearson, Spearman, Cramér's V, razón de correlación (η) y la
  corrección por comparaciones múltiples (FDR). Los métodos se marcan en el
  intro (siempre presente); FDR se registra y marca solo cuando se emite su
  resumen, para no dejar entradas de glosario sin aparición que las referencie.
- modelos: PCA, KMeans, coeficiente de silueta (silhouette), Isolation Forest y
  la estandarización z-score. Cada término se registra dentro de la sección que
  lo usa (tras su early-return), de modo que un término solo entra al glosario
  cuando su sección realmente se renderiza.
- agregacion: agrupación (split-apply-combine / groupby) y tabla dinámica
  (pivot), ambos en el intro siempre presente.

Solo se añaden los enganches de glosario: ningún cambio en la lógica de datos.
El texto visible es idéntico con o sin marcador (los renderers lo eliminan),
así que el layout de línea no cambia. Sin colector en ctx (render suelto) los
capítulos degradan y no marcan nada.

Tests: un test de glosario por capítulo verifica registro + marcado y la
degradación sin colector. Suite AutomaticEDA + render pipeline: 87 passed.
Golden titanic (run_models+series+llm): los 12 términos aparecen como entradas
del glosario en PDF (16 link annotations GOTO) y PPTX (15 saltos hlinksldjump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:02:31 +02:00
egutierrez 13c82be780 feat(eda): NUM DISTR muestra el valor de σ (std) en la leyenda del histograma
La leyenda de cada histograma del capítulo de distribuciones numéricas ya
reporta el valor de la media y la mediana; ahora también reporta el valor de
la desviación estándar σ. La entrada de leyenda de la banda ±1σ pasa a incluir
el número (±1σ (σ = X)) y, cuando la banda no puede dibujarse (sin media o
std<=0) pero σ es conocido, se añade una entrada de leyenda mediante un handle
proxy sin trazo, de modo que el valor de σ se reporta siempre.

No se altera el boxplot de Tukey ni el keep-together (Group) por columna.
Se añaden tests de la leyenda: golden (σ con valor junto a media y mediana),
edge sin banda (proxy) y edge sin std (no revienta). Bump 1.1.0 -> 1.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:01:12 +02:00
egutierrez 7fb00defdf fix(fleetclaude): reusar contexto dentro de la flota tmux en vez de abrir kitty nueva
Lanzar `fleetclaude` estando ya dentro de una flota tmux viva abría una ventana
kitty nueva (y creaba un perfil/socket nuevo fleetN+1) en vez de mostrar la flota
en el pane actual. Causa: con $TMUX definido el launcher saltaba el `exec tmux
attach` y caía a la rama `setsid kitty`.

Cambio: cuando se invoca sin --new desde dentro de una flota fleetview viva (el
socket actual, derivado de $TMUX, tiene una sesión homónima con window 'console'),
se trae la TUI al contexto/pane actual (`fleetview show`, o `tmux select-window`
de la window console como fallback sin binario) y se retorna 0 antes de las ramas
kitty/wt.exe. Nuevo flag --new para forzar el comportamiento clásico (flota+ventana
nueva) aun dentro de tmux; pasar --session con un nombre distinto al perfil actual
equivale a --new implícito. Fuera de tmux el comportamiento es intacto (exec tmux
attach reutiliza la terminal).

Fix incidental: `local left_pane="" right_pane=""` (antes `local left_pane
right_pane` reventaba con "unbound variable" bajo `set -u` al reutilizar una sesión
existente, p. ej. con --reuse/--session sobre una flota viva).

Verificación e2e con sockets aislados fctest* (sin tocar la flota del humano):
golden (reuse, exit 0, kitty invariante), --new y --session-distinto (no reuse,
ruta ventana-nueva), fuera de tmux (salta reuse, ruta attach). bash -n limpio.

Docs: launch_fleetclaude.md (signature, params --new, ejemplo, cuando usarla,
gotchas, growth log v1.7.0) + /fleet show en .claude/commands/fleet.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:56:41 +02:00
egutierrez b1d205203a feat(eda): poblar head_rows real en el capitulo OVERVIEW (df.head)
El capitulo OVERVIEW del motor AutomaticEDA mostraba "df.head no disponible"
porque ninguna fase de calculo poblaba las primeras filas crudas de la tabla.

- build_eda_render_ctx: nuevo bloque que muestrea SELECT * LIMIT head_n
  (param nuevo head_n=10) y lo expone en ctx["head_rows"] como lista de
  dicts fila. Estilo dict-no-throw: si la query falla, se omite la clave.
- profile_table: puebla prof["head_rows"] reusando _sample_rows (SELECT de
  las columnas LIMIT 10) tras recalcular el type_breakdown. Asi el report
  JSON sidecar tambien lo lleva y el capitulo lo recoge via profile aunque
  no se construya el ctx.
- overview.py: la nota del DataTable de df.head ahora indica el total de
  filas del dataset cuando se conoce ("primeras 10 filas de 891"). Bump
  CHAPTER_VERSION 1.0.0 -> 1.1.0.
- overview_test.py (nuevo): golden (head via profile y via ctx, render PDF
  + PPTX muestran las filas reales, placeholder ausente), edge (sin
  head_rows degrada a nota honesta sin romper, None/vacio devuelven None).

Verificado end-to-end con titanic: render_automatic_eda emite PDF + PPTX con
df.head visible (Braund/Cumings/Heikkinen + columnas) y sin el placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:56:24 +02:00
egutierrez c6d9bc26da merge: Fase 4a AutomaticEDA motor+glosario (verificado met)
- fix negrita-pisa PDF, zebra striping (PDF+PPT), keep-together (Group: heading+figura+texto misma pagina/slide), imagenes con caption en PPT
- portada construida-al-final mostrada en posicion 1 (con resumen agregado del cuerpo)
- capitulo glosario al final + terminos clicables REALES: PDF link annotation (add_pdf_internal_links, PyMuPDF) + PPT hyperlink nativo (pptx_link_run_to_slide); entropia enganchado en cat_distr como ejemplo E2E
- contrato docs/automatic_eda_contract.md §11 (glosario + keep-together + zebra)
- pymupdf>=1.28.0
2026-06-30 17:45:30 +02:00
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
egutierrez b5334a2e97 merge: Fase 3 AutomaticEDA wiring (verificado met)
- build_eda_render_ctx: arma ctx (raw_numeric, timeseries_raw, geo_points, db_path+table) desde tabla DuckDB
- pipeline render_automatic_eda: perfila + ctx + build_document -> PDF + PPTX (11 capitulos poblados)
- profile_table: flag emit_automatic emite el report AutomaticEDA (PDF+PPT) sin romper render_eda_pdf
- text_layout: render real de **negrita** en PDF y PPTX
- .claude/commands/eda.md actualizado

Los 4 capitulos que degradaban (modelos/timeseries/geospatial/agregacion) ahora salen POBLADOS end-to-end.
2026-06-30 16:19:52 +02:00
58 changed files with 5983 additions and 570 deletions
+3 -1
View File
@@ -27,6 +27,7 @@ Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar
- `--series``run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica).
- `--pdf``emit_pdf=True` (PDF A5 legacy de `render_eda_pdf`, legible en móvil).
- `--legacy-only` → emite SOLO el PDF legacy (sin AutomaticEDA), para casos en que solo se quiera el PDF rápido.
- `--lite` / `--bajo-consumo``render_automatic_eda(profile_level="lite")`: EDA barato y rápido (CI, vistazo previo, máquina sin GPU/red). Apaga LLM y serie temporal y limita los modelos a **PCA + normalidad** (sin KMeans ni IsolationForest, lo caro en CPU), con `sample` reducido. `--full``profile_level="full"` (standard + narrativa LLM). Por defecto `profile_level="standard"` (comportamiento histórico). Un flag explícito (`--llm`, `--models`, ...) prima sobre el preset.
Por defecto, **un EDA completo emite SIEMPRE el informe AutomaticEDA en sus dos formatos: PDF (A5 móvil) Y PPTX (16:9 para compartir)** con los 11 capítulos poblados (portada, overview, distribuciones, calidad, correlaciones, modelos, series, geoespacial, agregación, interpretación LLM). Usa el pipeline `render_automatic_eda` (o `profile_table(emit_automatic=True)`), que activa `run_models` y `run_series` para que los capítulos de modelos/series/geoespacial/agregación salgan poblados. Deja `run_llm` para cuando el usuario lo pida o interese la interpretación semántica + narrativa por capítulo (es la única parte que gasta tokens del modelo).
@@ -50,7 +51,8 @@ from pipelines.render_automatic_eda import render_automatic_eda
# tablas de agregación). run_llm=True añade la narrativa LLM por capítulo.
r = render_automatic_eda(
"/ruta/datos.duckdb", "ventas",
run_models=True, run_series=True, run_llm=False, out_dir="reports",
profile_level="standard", # "lite" = bajo consumo CPU/LLM; "full" = + narrativa LLM
out_dir="reports",
)
print("status:", r["status"])
print("pdf: ", r["pdf_path"], "(", r["n_pages"], "págs )")
+26 -3
View File
@@ -1,6 +1,6 @@
---
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet.
argument-hint: "[texto|sessionId|PID para saltar — vacío = listar la flota]"
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet. `/fleet show` trae la TUI al contexto tmux actual.
argument-hint: "[show | texto|sessionId|PID para saltar — vacío = listar la flota]"
---
# /fleet — ver y navegar la flota de Claudes
@@ -33,9 +33,32 @@ cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview" && go build -o fleetv
- la sesión actual / orquestador si la puedes identificar (su `session_id` coincide con el de quien invoca).
4. Si la lista está vacía, indícalo y sugiere que el perfil fleet podría no estar activo (revisar `$FLEET_SOCKET` y que la sesión tmux exista).
### `show` → traer la TUI al contexto tmux actual
Si `$ARGUMENTS` es exactamente `show` (alias `open`/`attach`), el usuario quiere
volver a ver el panel FleetView en el contexto/pane actual sin abrir ninguna
ventana ni arrancar una flota nueva. Ejecuta:
```bash
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" show
```
Comportamiento (decidido por la app, no abre terminal externa):
- **dentro de tmux con la flota viva** → `select-window` de la window `console`
del socket fleet (trae la TUI al frente; no abre nada).
- **fuera de tmux** → `attach` a la sesión fleet en la terminal actual (la reutiliza).
- **sin flota viva** → error claro, exit 1, no abre nada (sugiere arrancarla con
`fleetclaude`).
Es el equivalente del comportamiento de `fleetclaude` sin args invocado dentro de
una flota viva (reuse de contexto): úsalo cuando ya tengas una flota corriendo y
solo quieras recuperar la vista del panel. Para abrir una flota NUEVA aparte, usa
`fleetclaude --new` (no este comando).
### Con argumentos → saltar con foco
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID.
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID (cualquier valor que no sea `show`).
1. Ejecuta:
```bash
+73 -28
View File
@@ -3,10 +3,10 @@ name: launch_fleetclaude
kind: function
lang: bash
domain: infra
version: "1.6.0"
version: "1.7.0"
purity: impure
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--new] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. REUSO DE CONTEXTO: si se invoca DENTRO de una flota tmux viva (su window 'console') sin --new, NO abre ventana ni crea un perfil nuevo; trae la TUI al pane/contexto actual (equivale a 'fleetview show'). El flag --new fuerza una flota+ventana nueva aunque estes en tmux. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: fuera de tmux, o con --new, cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher, wsl, windows-terminal]
params:
- name: --cwd
@@ -14,12 +14,14 @@ params:
- name: --bin
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
- name: --session
desc: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...)."
desc: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...). Invocado DENTRO de tmux con un nombre DISTINTO al de la flota actual equivale a --new (pides otra flota: ventana nueva, sin reuse de contexto)."
- name: --reuse
desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)."
- name: --new
desc: "Fuerza una flota NUEVA en una ventana NUEVA (kitty/wt.exe) incluso estando dentro de una flota tmux. Opcional. Es la via explicita para abrir una FleetView aparte; sin este flag, invocado dentro de una flota viva se reusa el contexto actual (no abre ventana ni crea perfil)."
- name: --cols
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta a ella (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
output: "Caso reuse de contexto (dentro de una flota tmux viva, sin --new): trae la TUI al pane/contexto actual con select-window de la window 'console' (o 'fleetview show' si el binario existe) y retorna 0, sin abrir nada. Caso ventana-nueva (fuera de tmux, o con --new): crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito, !=0 con mensaje claro si no hay terminal ni contexto que reusar."
uses_functions:
- supervise_fleetview_tui_bash_infra
uses_types: []
@@ -36,32 +38,44 @@ file_path: "bash/functions/infra/launch_fleetclaude.sh"
## Ejemplo
```bash
# Via fn run (resuelve por nombre o ID):
fn run launch_fleetclaude
# DENTRO de una flota tmux viva (p. ej. en el pane del orquestador): reusa el
# contexto, trae la TUI al pane actual. NO abre ventana ni crea perfil nuevo.
fleetclaude
# Perfil nuevo automatico (fleet la 1a vez; fleet2, fleet3, ... si ya hay uno):
launch_fleetclaude
# FUERA de tmux: perfil nuevo automatico (fleet la 1a vez; fleet2, ... si ya hay
# uno) en una ventana de terminal nueva, reutilizando la terminal actual (attach):
fleetclaude
# Forzar una flota+ventana NUEVA aunque estes dentro de una flota tmux:
fleetclaude --new
# Reattach a la flota principal 'fleet' (comportamiento idempotente clasico):
launch_fleetclaude --reuse
fleetclaude --reuse
# Perfil con nombre fijo y ancho de pane personalizado:
launch_fleetclaude --session trabajo --cols 50
fleetclaude --session trabajo --cols 50
# Via fn run (resuelve por nombre o ID):
fn run launch_fleetclaude
```
Tras invocarlo aparece una ventana de terminal titulada `FleetView (<perfil>)` con dos
panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
aislados con su propia flota: puedes tener varias FleetView abiertas a la vez.
Por defecto, volver a invocarlo abre un perfil NUEVO (no reusa); usa `--reuse`
o `--session <nombre>` para volver a una flota concreta.
Dentro de una flota viva, `fleetclaude` sin args reusa el contexto (la window
`console` pasa al frente). Fuera de tmux (o con `--new`) aparece una ventana de
terminal titulada `FleetView (<perfil>)` con dos panes lado a lado: a la izquierda
la TUI `fleetview`, a la derecha una sesion de `claude --dangerously-skip-permissions`.
Cada perfil es un socket+sesion tmux aislados con su propia flota: puedes tener
varias FleetView abiertas a la vez con `--new`.
## Cuando usarla
Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de
N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un
Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o
al retomar el trabajo en el repo `fn_registry`.
al retomar el trabajo en el repo `fn_registry`. Si **ya estas dentro de una
flota** (en el pane del orquestador) y solo quieres volver a ver la TUI, lanza
`fleetclaude` sin args: trae el panel al contexto actual sin abrir otra ventana
ni arrancar una flota duplicada. Usa `--new` solo cuando quieras DELIBERADAMENTE
una segunda flota aparte.
## Gotchas
@@ -87,10 +101,27 @@ al retomar el trabajo en el repo `fn_registry`.
funciona en un PC con kitty y en otro WSL sin kitty, cada uno elige su
terminal. Causa raiz del sintoma "se lanza la flota pero no se ve": kitty no
instalado en WSL hacia que la sesion tmux se creara sin ventana que la mostrara.
- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de
una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de
nesting); cae a la ruta ventana-nueva (auto-deteccion de terminal). Fuera de
tmux y con TTY, reutiliza la terminal actual con `exec tmux attach`.
- **Dentro de una flota tmux viva: reuse de contexto (no ventana nueva)**: si
invocas `fleetclaude` sin `--new` desde dentro de una flota fleetview viva
(`$TMUX` definido y el socket actual tiene una sesion homonima con window
`console`), NO abre ventana ni crea un perfil `fleetN+1`: trae la TUI al pane
actual (`fleetview show`, o `tmux -L <perfil> select-window -t <perfil>:console`
si el binario no esta compilado) y retorna 0. El perfil de la flota actual se
deriva de `$TMUX` (basename del socket = nombre `-L`), senal fiable aunque
`$FLEET_SOCKET` venga vacio (ver `detect_fleet_context`). **`--new`** fuerza el
comportamiento clasico (flota+ventana nueva); pasar `--session <otro>` distinto
al perfil actual equivale a `--new` implicito. Fuera de tmux y con TTY, reutiliza
la terminal actual con `exec tmux attach` (nunca `attach` anidado dentro de
tmux). Sin TTY ni contexto que reusar (atajo de escritorio/cron) cae a la ruta
ventana-nueva. Antes de este fix (v1.6.0 y anteriores) cualquier `fleetclaude`
dentro de tmux abria una kitty nueva y un socket `fleetN+1` — el sintoma que
acumulaba 6+ sockets `fleet*`.
- **`local x` unbound bajo `set -u`**: el archivo corre con `set -euo pipefail`.
`local left_pane right_pane` dejaba esas vars *unbound* (no vacias), asi que la
rama "reutilizar sesion existente" (`--reuse`/`--session <vivo>`) reventaba con
`left_pane: unbound variable` al evaluar `[[ -z "$left_pane" ]]`. Se inicializan
explicitamente a `""` (`local left_pane="" right_pane=""`). Si tocas estas vars,
no vuelvas a declararlas sin valor.
- **kitty detached (setsid)**: la ventana kitty se lanza con `setsid ... &` para
sobrevivir al cierre de la terminal que la invoco. La ventana de Windows
Terminal (wt.exe) ya es un proceso Windows independiente del arbol Linux, asi
@@ -128,15 +159,29 @@ al retomar el trabajo en el repo `fn_registry`.
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
conmutar de Claude redistribuyen el espacio.
- **tmux siempre; terminal (kitty/wt.exe) solo sin TTY**: `tmux` es obligatorio
(aborta != 0 si falta). Una terminal nueva (kitty o Windows Terminal) solo se
necesita en la ruta sin-TTY (dentro de tmux, atajo de escritorio, cron, script),
donde abre una ventana nueva. Invocado desde una terminal interactiva fuera de
tmux (el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
`exec tmux attach` y no necesita ni kitty ni wt.exe.
- **tmux siempre; terminal (kitty/wt.exe) solo en la ruta ventana-nueva**: `tmux`
es obligatorio (aborta != 0 si falta). Una terminal nueva (kitty o Windows
Terminal) solo se necesita en la ruta ventana-nueva: `--new`, o sin TTY ni flota
viva que reusar (atajo de escritorio, cron, script). Dentro de una flota viva sin
`--new` se reusa el contexto (ni kitty ni wt.exe). Invocado desde una terminal
interactiva fuera de tmux (el caso normal del alias `fleetclaude`), reutiliza la
terminal actual con `exec tmux attach` y tampoco necesita kitty ni wt.exe.
## Capability growth log
- v1.7.0 (2026-06-30) — **reuse de contexto dentro de la flota + flag `--new`**.
Invocado sin `--new` desde dentro de una flota tmux viva (su window `console`),
`fleetclaude` ya NO abre una kitty nueva ni crea un perfil `fleetN+1`: trae la
TUI al pane/contexto actual (`fleetview show`, o `tmux -L <perfil> select-window
-t <perfil>:console` como fallback sin binario) y retorna 0. El perfil actual se
deriva de `$TMUX` (basename del socket); pasar `--session <otro>` distinto al
actual equivale a `--new` implicito. Nuevo flag `--new` para forzar la ruta
clasica (flota+ventana nueva) aun dentro de tmux. Fuera de tmux el comportamiento
es intacto (`exec tmux attach` reutiliza la terminal). Arregla el sintoma de que
lanzar `fleetclaude` dentro de una flota abria ventana kitty + socket nuevo
(`fleet7`, `fleet8`, ...). Fix incidental: `local left_pane="" right_pane=""`
(antes `local left_pane right_pane` reventaba con `unbound variable` bajo
`set -u` al reutilizar una sesion existente).
- v1.6.0 (2026-06-29) — **auto-deteccion de terminal (kitty ↔ Windows Terminal)**.
La ruta ventana-nueva ya no asume kitty: elige terminal segun el host. kitty si
esta instalado y hay display (`$DISPLAY`/`$WAYLAND_DISPLAY`); si no, en WSL abre
+61 -2
View File
@@ -23,6 +23,7 @@ launch_fleetclaude() {
local cols=52
local explicit_session=0 # 1 si el usuario pasó --session <name> a mano
local reuse=0 # 1 si el usuario pidió --reuse (reattach al perfil principal)
local want_new=0 # 1 si el usuario pidió --new (forzar flota+ventana nueva)
local T="" # socket tmux aislado; se fija al resolver el perfil
# -----------------------------------------------------------------------
@@ -46,6 +47,9 @@ launch_fleetclaude() {
--reuse)
reuse=1
;;
--new)
want_new=1
;;
--cols)
shift
cols="${1:-40}"
@@ -62,6 +66,11 @@ Claudes). Sin --session ni --reuse, cada invocacion abre un perfil NUEVO: usa
el primer nombre libre de la secuencia fleet, fleet2, fleet3, ... Asi puedes
tener varias FleetView abiertas a la vez, cada una con su flota independiente.
REUSO DE CONTEXTO: si ya estas DENTRO de una flota tmux viva (p. ej. en el pane
del orquestador), 'fleetclaude' sin args NO abre una ventana ni crea un perfil
nuevo: trae la TUI al contexto/pane actual (equivale a 'fleetview show'). Para
abrir explicitamente una flota aparte en una ventana nueva, usa --new.
Opciones:
--cwd <dir> Directorio de trabajo de los panes.
Default: raiz del repo fn_registry (derivada dinamicamente).
@@ -69,13 +78,21 @@ Opciones:
Default: <repo>/apps/fleetview/fleetview
--session <name> Fija el perfil (socket+sesion) por nombre exacto; reutiliza
el existente si ya esta vivo. Sin esta opcion, perfil auto.
Si se invoca DENTRO de tmux con un nombre DISTINTO al de la
flota actual, equivale a --new (pides otra flota).
--reuse Reattach al perfil principal 'fleet' en vez de abrir uno
nuevo (vuelve al comportamiento idempotente clasico).
--new Fuerza una flota NUEVA en una ventana NUEVA (kitty/wt.exe),
incluso dentro de tmux. Es la via explicita para tener una
FleetView aparte; sin este flag, dentro de tmux se reusa el
contexto actual.
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
-h, --help Muestra esta ayuda.
Ejemplos:
launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...)
launch_fleetclaude # dentro de la flota: reusa el contexto;
# fuera de tmux: perfil nuevo (fleet, ...)
launch_fleetclaude --new # flota+ventana nueva aunque estes en tmux
launch_fleetclaude --reuse # reattach a la flota principal 'fleet'
launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo'
launch_fleetclaude --cwd ~/fn_registry --cols 50
@@ -127,6 +144,45 @@ USAGE
return 1
fi
# -----------------------------------------------------------------------
# REUSO DE CONTEXTO (sin --new): si ya estamos DENTRO de una flota tmux
# viva, 'fleetclaude' sin args NO abre una ventana/terminal nueva ni crea
# un perfil fleetN+1 — trae la TUI al contexto/pane actual, igual que
# 'fleetview show'. El flag --new fuerza el comportamiento clasico (flota
# nueva en ventana nueva); --reuse mantiene su semantica historica.
#
# El perfil de la flota actual se deriva de $TMUX (el basename del socket
# es el nombre -L; senal fiable aunque $FLEET_SOCKET venga vacio, ver
# detect_fleet_context). Si se paso --session con un nombre DISTINTO al
# actual, es pedir OTRA flota -> se trata como --new implicito (no reusa).
# "Flota viva" = el socket tiene una sesion homonima con una window
# 'console' (la firma de una FleetView), no un tmux cualquiera.
# -----------------------------------------------------------------------
if [[ "$want_new" -eq 0 && "$reuse" -eq 0 && -n "${TMUX:-}" ]]; then
local current_socket target_socket
current_socket="$(basename "${TMUX%%,*}")"
target_socket="$current_socket"
[[ "$explicit_session" -eq 1 ]] && target_socket="$session"
if [[ "$target_socket" == "$current_socket" ]] \
&& tmux -L "$current_socket" has-session -t "$current_socket" 2>/dev/null \
&& tmux -L "$current_socket" list-windows -t "$current_socket" \
-F '#{window_name}' 2>/dev/null | grep -qx console; then
# Traer la TUI al contexto actual sin abrir nada nuevo. Preferimos
# el binario (centraliza la politica en la app: 'fleetview show');
# si no esta compilado, caemos a 'select-window' directo, que es lo
# que 'show' hace por dentro dentro de tmux (cero dependencia).
if [[ -x "$bin" ]] \
&& FLEET_SOCKET="$current_socket" FLEET_SESSION="$current_socket" \
"$bin" show 2>/dev/null; then
return 0
fi
tmux -L "$current_socket" select-window -t "$current_socket":console
echo "launch_fleetclaude: flota '$current_socket' viva; TUI traida al contexto actual (sin ventana nueva)."
return 0
fi
fi
# -----------------------------------------------------------------------
# Resolver el PERFIL (socket+sesion tmux comparten nombre).
#
@@ -200,7 +256,10 @@ USAGE
# indice 1 y cualquier referencia a console.0 falla con
# "can't find pane: 0". Los pane ID son estables e inmunes al base-index.
# -----------------------------------------------------------------------
local left_pane right_pane
# Inicializadas a "" (no solo declaradas): bajo `set -u` una `local x` sin
# valor queda *unbound*, y al reutilizar una sesion existente el `[[ -z
# "$left_pane" ]]` de mas abajo reventaba con "unbound variable".
local left_pane="" right_pane=""
if $T has-session -t "$session" 2>/dev/null; then
echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola."
else
+123 -3
View File
@@ -25,7 +25,8 @@ cabecera, y figuras/imágenes se escalan para caber enteras.
```
Document = list[Chapter]
Chapter = { id: str, title: str, version: str, blocks: list[Block] }
Block = Heading | Markdown | KVTable | DataTable | Figure | Image | Caption | Note
Block = Heading | Markdown | KVTable | DataTable | Figure | Image | Caption
| Note | Group | GlossaryEntry
```
Importa el modelo desde `datascience.automatic_eda.model` (o
@@ -44,6 +45,10 @@ reconocido se degrada a `Note`, nunca lanza).
| `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`)
@@ -84,8 +89,9 @@ El orden canónico está **pre-declarado** en
```python
CHAPTER_ORDER = [
"portada", "overview", "num_distr", "cat_distr", "calidad", "correlacion",
"modelos", "analisis_llm", "timeseries", "geospatial", "agregacion",
"portada", "overview", "analisis_llm", "num_distr", "cat_distr", "calidad",
"correlacion", "modelos", "timeseries", "geospatial", "agregacion",
"glosario",
]
```
@@ -95,6 +101,15 @@ CHAPTER_ORDER = [
`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).
@@ -143,6 +158,8 @@ defensivo). Esto habilita el **seguimiento y la mejora continua por capítulo**.
| `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.
@@ -279,6 +296,109 @@ sus bloques presentes y el no-corte (texto largo intacto en la salida). Patrón:
---
## 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.
---
## 10. Integración futura con `profile_table` (siguiente fase)
`profile_table(emit_pdf=True)` usa hoy `render_eda_pdf` (intacto). En la siguiente fase
+6
View File
@@ -34,6 +34,7 @@ from .theils_u import theils_u
from .correlation_ratio import correlation_ratio
from .mutual_info_columns import mutual_info_columns
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
from .build_join_graph import build_join_graph
from .association_matrix import association_matrix
from .correlation_matrix_duckdb import correlation_matrix_duckdb
@@ -68,11 +69,15 @@ from .extract_timeseries_raw import extract_timeseries_raw
from .build_eda_render_ctx import build_eda_render_ctx
from .profile_datetime import profile_datetime
from .resample_timeseries import resample_timeseries
from .add_pdf_internal_links import add_pdf_internal_links
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
__all__ = [
"suggest_intratable_fk_candidates",
"detect_time_column",
"extract_timeseries_raw",
"build_eda_render_ctx",
"add_pdf_internal_links",
"profile_datetime",
"resample_timeseries",
"render_automatic_eda_pdf",
@@ -95,6 +100,7 @@ __all__ = [
"correlation_ratio",
"mutual_info_columns",
"infer_fk_containment_duckdb",
"detect_declared_keys_duckdb",
"build_join_graph",
"association_matrix",
"correlation_matrix_duckdb",
@@ -0,0 +1,85 @@
---
name: add_pdf_internal_links
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def add_pdf_internal_links(pdf_path: str, links: list) -> dict"
description: "Postprocesa un PDF YA escrito insertando link annotations internos de tipo GOTO ('ir a') con PyMuPDF (import fitz). Pensado para PDFs generados por matplotlib PdfPages, que NO soporta hyperlinks internos: tras escribir el PDF se reabre y, por cada entrada de `links`, se añade una anotacion clicable desde un rectangulo de una pagina origen (src_page + src_rect en puntos top-left) hasta un punto de una pagina destino (dst_page + dst_point). Caso de uso tipico del grupo eda: hacer clicables los terminos de un AutomaticEDA que apuntan a su entrada en el glosario al final del documento. Estilo dict-no-throw: NUNCA lanza; valida cada link y SALTA (n_skipped++) los malformados o fuera de rango en vez de fallar. Guarda de forma segura escribiendo a un temporal en el mismo directorio y haciendo os.replace atomico (evita corromper el original). Devuelve {status:ok,n_links,n_skipped} o {status:error,error}; si pymupdf no esta disponible o el archivo no existe devuelve status error."
tags: [eda, datascience, pdf, links, glossary, pymupdf, fitz, postprocess, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: pdf_path
desc: "ruta al PDF existente (str no vacio). Se reescribe IN SITU (in-place) tras añadir los links: se guarda a un temporal `.<base>.tmp_links` en el mismo directorio y se reemplaza atomicamente con os.replace. Si no es str o no existe el archivo -> {status:error}."
- name: links
desc: "lista de dicts, uno por link a insertar. Cada dict: src_page (int 0-based de la pagina origen), src_rect ([x0,y0,x1,y1] del rectangulo clicable en PUNTOS PDF 1/72\" con origen ARRIBA-IZQUIERDA), dst_page (int 0-based de la pagina destino), dst_point ([x,y] punto destino, mismos puntos top-left). Las entradas que no son dict, con page fuera de rango [0,page_count), src_rect que no tenga 4 numeros o dst_point que no tenga 2 numeros se SALTAN (n_skipped++), no lanzan. None se trata como lista vacia."
output: "dict (NUNCA lanza): en exito {\"status\":\"ok\",\"n_links\":int,\"n_skipped\":int} con n_links = anotaciones GOTO insertadas y n_skipped = entradas invalidas saltadas. En fallo {\"status\":\"error\",\"error\":str}: pymupdf no disponible, pdf_path no es str / no existe, links no es lista, o cualquier excepcion global (el PDF original queda intacto porque el replace solo ocurre tras un save correcto)."
tested: true
tests: ["test_add_goto_link_basico", "test_links_invalidos_se_saltan", "test_archivo_inexistente_devuelve_error"]
test_file_path: "python/functions/datascience/add_pdf_internal_links_test.py"
file_path: "python/functions/datascience/add_pdf_internal_links.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import add_pdf_internal_links
# Tienes un PDF ya escrito por matplotlib PdfPages (sin hyperlinks internos).
# Quieres que el texto "Margen bruto" de la pagina 0 (rectangulo en puntos
# top-left) salte a su entrada del glosario en la ultima pagina (indice 7).
res = add_pdf_internal_links(
"reports/eda.pdf",
[
{"src_page": 0, "src_rect": [72, 120, 180, 134], "dst_page": 7, "dst_point": [72, 200]},
{"src_page": 0, "src_rect": [72, 140, 180, 154], "dst_page": 7, "dst_point": [72, 260]},
],
)
# res == {"status": "ok", "n_links": 2, "n_skipped": 0}
```
## Cuando usarla
Justo DESPUES de escribir un PDF con matplotlib `PdfPages` (o cualquier motor
que no genere hyperlinks internos) cuando necesitas que ciertos terminos o
referencias sean clicables y salten a otra pagina del mismo documento — el caso
canonico es enlazar los terminos de un AutomaticEDA con su entrada de glosario
al final. Es un paso de postproceso: primero generas el PDF y calculas en que
rectangulo quedo cada termino (en puntos PDF), luego pasas esa lista a esta
funcion para inyectar las anotaciones GOTO.
## Gotchas
- **Impura — reescribe el archivo IN SITU.** El PDF en `pdf_path` se reemplaza
por la version con los links. El guardado es seguro: escribe a un temporal
`.<base>.tmp_links` en el MISMO directorio y hace `os.replace` atomico tras
cerrar el documento, asi un fallo a mitad no corrompe el original. Aun asi,
conserva una copia si el PDF es valioso.
- **Sistema de coordenadas: puntos top-left, igual que matplotlib.** PyMuPDF y
matplotlib (PdfPages) usan ambos PUNTOS PDF (1/72") con el origen ARRIBA-
IZQUIERDA, asi que los rectangulos/puntos COINCIDEN: el `src_rect` que calcules
con la geometria de la figura matplotlib se pasa tal cual, sin invertir el eje
Y. (Ojo: el espacio de datos de matplotlib SI tiene el origen abajo; lo que
coincide es el espacio de la PAGINA en puntos.)
- **Indices de pagina 0-based.** `src_page` / `dst_page` son indices base 0
(la primera pagina es 0). Fuera del rango `[0, page_count)` el link se SALTA
(cuenta en `n_skipped`), no lanza.
- **dict-no-throw, validacion por-link.** Las entradas malformadas (no dict,
page fuera de rango, `src_rect` sin 4 numeros, `dst_point` sin 2 numeros) se
saltan individualmente e incrementan `n_skipped`; el resto de links validos se
insertan igual. La funcion solo devuelve `{status:error}` ante fallos globales
(pymupdf ausente, archivo inexistente, `links` no es lista).
- **`error_type: error_go_core` es metadata del registry, no comportamiento.**
Toda funcion impura debe declararlo y el indexer lo exige, pero el codigo NUNCA
lanza esa excepcion: degrada al dict de estado.
- **Requiere PyMuPDF (`import fitz`).** Si no esta instalado devuelve
`{"status":"error","error":"pymupdf no disponible: ..."}`. En el registry el
venv `python/.venv` ya lo trae.
@@ -0,0 +1,132 @@
"""Postprocesa un PDF existente insertando link annotations internos (GOTO).
Motor: PyMuPDF (``import fitz``). Pensado para PDFs generados por matplotlib
``PdfPages``, que no soporta hyperlinks internos: tras escribir el PDF, esta
funcion lo reabre y le añade anotaciones "ir a" (GOTO) desde un rectangulo de
una pagina origen hasta un punto de una pagina destino. Util para hacer
clicables terminos que apuntan a su entrada en un glosario al final del
documento.
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; devuelve un dict de estado.
"""
import os
def add_pdf_internal_links(pdf_path: str, links: list) -> dict:
"""Añade link annotations internos (GOTO) a un PDF ya escrito.
Postprocesa un PDF (p.ej. generado por matplotlib PdfPages, que NO soporta
hyperlinks internos) insertando, por cada entrada de ``links``, una
anotacion de tipo "ir a" desde un rectangulo de una pagina origen hasta un
punto de una pagina destino. Sirve para hacer clicables terminos que apuntan
a su entrada en un glosario al final del documento.
Args:
pdf_path: ruta al PDF existente (se reescribe in situ).
links: lista de dicts, cada uno:
{
"src_page": int, # indice 0-based de la pagina origen
"src_rect": [x0,y0,x1,y1], # rectangulo clicable, en PUNTOS PDF
# (1/72") con origen ARRIBA-IZQUIERDA
"dst_page": int, # indice 0-based de la pagina destino
"dst_point": [x, y], # punto destino, mismos puntos top-left
}
Returns:
dict (NUNCA lanza): {"status":"ok","n_links":int,"n_skipped":int}
o {"status":"error","error":str}. Si pymupdf no esta disponible o el
archivo no existe -> {"status":"error", ...}.
"""
try:
try:
import fitz # PyMuPDF
except Exception as exc: # ImportError u otro fallo de carga
return {"status": "error", "error": f"pymupdf no disponible: {exc}"}
if not isinstance(pdf_path, str) or not pdf_path:
return {"status": "error", "error": "pdf_path debe ser una ruta no vacia"}
if not os.path.isfile(pdf_path):
return {"status": "error", "error": f"el archivo no existe: {pdf_path}"}
if links is None:
links = []
if not isinstance(links, (list, tuple)):
return {"status": "error", "error": "links debe ser una lista de dicts"}
doc = fitz.open(pdf_path)
try:
n_pages = doc.page_count
n_ok = 0
n_skipped = 0
for link in links:
if not isinstance(link, dict):
n_skipped += 1
continue
src_page = link.get("src_page")
dst_page = link.get("dst_page")
src_rect = link.get("src_rect")
dst_point = link.get("dst_point")
# src_page / dst_page: enteros 0-based en rango.
if not _is_int(src_page) or not _is_int(dst_page):
n_skipped += 1
continue
if not (0 <= src_page < n_pages) or not (0 <= dst_page < n_pages):
n_skipped += 1
continue
# src_rect: 4 numeros.
if not _is_num_seq(src_rect, 4):
n_skipped += 1
continue
# dst_point: 2 numeros.
if not _is_num_seq(dst_point, 2):
n_skipped += 1
continue
try:
doc[int(src_page)].insert_link(
{
"kind": fitz.LINK_GOTO,
"from": fitz.Rect(*[float(v) for v in src_rect]),
"page": int(dst_page),
"to": fitz.Point(*[float(v) for v in dst_point]),
}
)
n_ok += 1
except Exception:
n_skipped += 1
continue
# Guardado seguro: escribir a temporal en el mismo directorio y
# reemplazar atomicamente (evita corromper el PDF original).
directory = os.path.dirname(os.path.abspath(pdf_path)) or "."
base = os.path.basename(pdf_path)
tmp_path = os.path.join(directory, f".{base}.tmp_links")
doc.save(tmp_path)
finally:
doc.close()
os.replace(tmp_path, pdf_path)
return {"status": "ok", "n_links": n_ok, "n_skipped": n_skipped}
except Exception as exc: # degrada cualquier fallo a dict de error
return {"status": "error", "error": str(exc)}
def _is_int(value) -> bool:
"""True si value es un entero (no bool)."""
return isinstance(value, int) and not isinstance(value, bool)
def _is_num_seq(value, length: int) -> bool:
"""True si value es una secuencia de `length` numeros (int/float, no bool)."""
if not isinstance(value, (list, tuple)) or len(value) != length:
return False
for v in value:
if isinstance(v, bool) or not isinstance(v, (int, float)):
return False
return True
@@ -0,0 +1,77 @@
"""Tests para add_pdf_internal_links."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from add_pdf_internal_links import add_pdf_internal_links
def test_add_goto_link_basico(tmp_path):
"""Golden: un PDF de 2 paginas recibe un link GOTO de la pag 0 a la pag 1."""
fitz = pytest.importorskip("fitz")
# 1) PDF temporal de 2 paginas A5 (~419x595 puntos).
pdf = str(tmp_path / "doc.pdf")
doc = fitz.open()
doc.new_page(width=419, height=595)
doc.new_page(width=419, height=595)
doc.save(pdf)
doc.close()
# 2) Insertar un link interno desde la pag 0 hacia la pag 1.
res = add_pdf_internal_links(
pdf,
[{"src_page": 0, "src_rect": [50, 50, 200, 70], "dst_page": 1, "dst_point": [40, 40]}],
)
assert res["status"] == "ok"
assert res["n_links"] == 1
assert res["n_skipped"] == 0
# 3) Reabrir y verificar que la pag 0 tiene un link GOTO a la pag 1.
doc = fitz.open(pdf)
try:
links = doc[0].get_links()
goto = [l for l in links if l.get("kind") == fitz.LINK_GOTO and l.get("page") == 1]
assert len(goto) >= 1
finally:
doc.close()
def test_links_invalidos_se_saltan(tmp_path):
"""Edge: entradas malformadas o fuera de rango incrementan n_skipped, no lanzan."""
fitz = pytest.importorskip("fitz")
pdf = str(tmp_path / "doc.pdf")
doc = fitz.open()
doc.new_page(width=419, height=595)
doc.new_page(width=419, height=595)
doc.save(pdf)
doc.close()
res = add_pdf_internal_links(
pdf,
[
# valido
{"src_page": 0, "src_rect": [10, 10, 90, 30], "dst_page": 1, "dst_point": [20, 20]},
# dst_page fuera de rango
{"src_page": 0, "src_rect": [10, 40, 90, 60], "dst_page": 9, "dst_point": [20, 20]},
# src_rect con 3 numeros
{"src_page": 0, "src_rect": [10, 70, 90], "dst_page": 1, "dst_point": [20, 20]},
# no es dict
"no-soy-un-dict",
],
)
assert res["status"] == "ok"
assert res["n_links"] == 1
assert res["n_skipped"] == 3
def test_archivo_inexistente_devuelve_error():
"""Error path: pdf_path inexistente -> status error sin lanzar."""
res = add_pdf_internal_links("/ruta/que/no/existe_xyz.pdf", [])
assert res["status"] == "error"
assert "error" in res
@@ -21,6 +21,9 @@ from .model import ( # noqa: F401
Chapter,
DataTable,
Figure,
GlossaryCollector,
GlossaryEntry,
Group,
Heading,
Image,
KVTable,
@@ -45,6 +48,9 @@ __all__ = [
"Image",
"Caption",
"Note",
"Group",
"GlossaryEntry",
"GlossaryCollector",
"Chapter",
"as_blocks",
"as_chapters",
@@ -89,6 +89,35 @@ _DEF_MAX_CARD = 20
_DEF_MAX_MEASURES = 4
_DEF_TOP_N = 12
# Glossary terms this chapter explains. Both appear in the always-rendered intro,
# so they are registered and marked clickable whenever a collector is in ctx —
# the canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
# block. Mapping key -> (label, definition).
_TERM_DEFS = {
"groupby": (
"Agrupación (split-apply-combine)",
"Operación de agrupación (group by): parte la tabla en grupos según los "
"valores de una columna categórica, aplica un cálculo (conteo, media, "
"mediana…) dentro de cada grupo y combina los resultados en una tabla "
"resumen. Es el patrón split-apply-combine."),
"pivot_table": (
"Tabla dinámica (pivot)",
"Tabla dinámica que cruza dos variables categóricas — una en las filas y "
"otra en las columnas — y rellena cada celda con un agregado (media, "
"suma…) de una medida numérica. Resume de un vistazo cómo interactúan las "
"dos categóricas sobre esa medida."),
}
def _term(mark: bool, key: str, text: str) -> str:
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
The visible text is identical with or without the marker (the renderers strip
it), so wrapping never changes line layout — it only adds the link.
"""
return f"[[term:{key}]]{text}[[/term]]" if mark else text
# --------------------------------------------------------------------------- #
# Formatting helpers (mirror the other chapters' defensive style).
@@ -525,13 +554,18 @@ def _sections_live(profile: dict, ctx: dict, candidates: dict) -> list:
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def _intro_blocks() -> list:
def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
if gloss is not None:
for key, (label, definition) in _TERM_DEFS.items():
gloss.add(key, label, definition)
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
text = (
"Este capítulo analiza la tabla **por grupos** (split-apply-combine): "
f"Este capítulo analiza la tabla {t_groupby}: "
"elige las columnas categóricas más informativas — por su cardinalidad "
"y relevancia, no todas contra todas, para no inflar comparaciones "
"espurias — y resume las variables numéricas dentro de cada grupo "
"(conteo, media, mediana, desviación). Las **tablas dinámicas** (pivot) "
f"(conteo, media, mediana, desviación). Las {t_pivot} "
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
"(siempre desde cero) comparan los grupos de un vistazo."
)
@@ -556,13 +590,21 @@ def build_agregacion(profile: dict, ctx: dict):
if not isinstance(profile, dict):
return None
# Shared glossary collector: groupby + pivot_table live in the always-present
# intro, so they are registered + marked there. Degrades silently (mark_term
# False) when no collector is in ctx (standalone render).
glossary = ctx.get("glossary")
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
mark_term = gloss is not None
# Pre-computed results take precedence (offline / tests / forward-compat).
pre = ctx.get("aggregations")
if _is_dict(pre) and (pre.get("groupby") or pre.get("pivots")):
sections = _sections_from_precomputed(pre)
if not sections:
return None
blocks = _intro_blocks() + sections + _insights_section(ctx)
blocks = (_intro_blocks(gloss, mark_term) + sections
+ _insights_section(ctx))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -583,10 +625,11 @@ def build_agregacion(profile: dict, ctx: dict):
"crudos. Pasa ctx['db_path'] + ctx['table'] (para el cálculo "
"push-down en DuckDB) o ctx['aggregations'] ya precalculado. "
f"Columnas categóricas candidatas: {keys or ''}.")
blocks = _intro_blocks() + [note] + _insights_section(ctx)
blocks = (_intro_blocks(gloss, mark_term) + [note]
+ _insights_section(ctx))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
blocks = _intro_blocks() + sections + _insights_section(ctx)
blocks = _intro_blocks(gloss, mark_term) + sections + _insights_section(ctx)
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -254,3 +254,25 @@ def test_anti_corte_muchos_grupos_y_texto_largo():
# First, middle and last words of the long paragraph all present.
for i in (0, 60, 119):
assert f"palabra{i}" in txt
def test_glosario_engancha_groupby_y_pivot():
"""Mejora 4b: la agrupación (split-apply-combine) y la tabla dinámica (pivot)
se registran en el colector compartido y se marcan clicables en el cuerpo.
Sin colector en ctx, el capítulo degrada y no marca nada."""
from datascience.automatic_eda.model import GlossaryCollector
g = GlossaryCollector()
ctx = dict(_ctx_precomputed())
ctx["glossary"] = g
ch = build_agregacion(_profile(), ctx)
assert ch is not None
keys = {t["key"] for t in g.terms()}
assert {"groupby", "pivot_table"} <= keys
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
assert "[[term:groupby]]" in body and "[[term:pivot_table]]" in body
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
ch2 = build_agregacion(_profile(), _ctx_precomputed())
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
assert "[[term:" not in body2
@@ -42,7 +42,11 @@ from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
# 1.1.0: drop the duplicated section labels — the dictionary and PII DataTables
# no longer carry a ``title`` (the section Heading labels them once, per the
# OVERVIEW pattern in the contract). The data-dictionary column already reads
# "Significado de negocio".
CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "analisis_llm"
CHAPTER_TITLE = "Análisis LLM"
@@ -118,6 +122,11 @@ def _dictionary_block(llm: dict):
Columns: Columna / Descripción / Significado de negocio / Unidad. The
paginator splits this by rows repeating the header and wraps long cells, so a
long dictionary (many columns) never gets cut.
The block carries **no** ``title``: the section is labelled once by the
``Heading`` that ``build_analisis_llm`` appends right before it (the canonical
OVERVIEW pattern, contract §8). Giving the table its own ``title`` too would
print "Diccionario de datos" twice in a row.
"""
entries = llm.get("dictionary")
if not isinstance(entries, (list, tuple)) or not entries:
@@ -137,7 +146,7 @@ def _dictionary_block(llm: dict):
])
if not rows:
return None
return model.DataTable(header=header, rows=rows, title="Diccionario de datos")
return model.DataTable(header=header, rows=rows)
def _analyses_blocks(llm: dict) -> list:
@@ -159,7 +168,12 @@ def _cleaning_blocks(llm: dict) -> list:
def _pii_block(llm: dict):
"""DataTable for PII/GDPR findings, or None if absent/empty."""
"""DataTable for PII/GDPR findings, or None if absent/empty.
Like the dictionary block, it carries **no** ``title`` (the ``Heading`` in
``build_analisis_llm`` labels the section once); it keeps its ``note`` with
the orientative-detection caveat, which the renderers print under the table.
"""
entries = llm.get("pii")
if not isinstance(entries, (list, tuple)) or not entries:
return None
@@ -176,7 +190,7 @@ def _pii_block(llm: dict):
if not rows:
return None
return model.DataTable(
header=header, rows=rows, title="Datos personales (PII / RGPD)",
header=header, rows=rows,
note="detección automática orientativa — revisar antes de tratar los datos")
@@ -24,7 +24,7 @@ from pptx import Presentation
from datascience.automatic_eda.chapters.analisis_llm import (
build_analisis_llm, CHAPTER_VERSION)
from datascience.automatic_eda.chapters_registry import build_document
from datascience.automatic_eda.model import Chapter, DataTable
from datascience.automatic_eda.model import Chapter, DataTable, Heading
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
@@ -117,6 +117,45 @@ def test_golden_build_y_render_pdf_pptx():
assert "DESCTOKEN" in ptx
def test_sin_rotulos_duplicados_y_significado_de_negocio():
"""The dictionary / PII sections must be labelled ONCE.
Regression for the duplicated 'Diccionario de datos' and 'Datos personales
(PII / RGPD)' headings (each section used to print its label twice: a Heading
plus the DataTable's own title). The fix drops the DataTable title and keeps
a single Heading — the OVERVIEW pattern. The data-dictionary column header is
also pinned to the exact text 'Significado de negocio'.
"""
ch = build_analisis_llm(_profile(), {})
assert ch is not None
# Structure: section labels come from Headings; tables carry no title.
headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
assert headings.count("Diccionario de datos") == 1
assert headings.count("Datos personales (PII / RGPD)") == 1
for b in ch.blocks:
if isinstance(b, DataTable):
assert not b.title, f"DataTable should not duplicate the label: {b.title!r}"
# The data dictionary's third column reads exactly 'Significado de negocio'.
dicts = [b for b in ch.blocks if isinstance(b, DataTable) and "Descripción" in b.header]
assert dicts, "expected the data-dictionary DataTable"
assert dicts[0].header == ["Columna", "Descripción", "Significado de negocio", "Unidad"]
# The PII table keeps its orientative-detection note.
pii = [b for b in ch.blocks if isinstance(b, DataTable) and b.header == ["Columna", "Tipo", "Severidad"]]
assert pii and pii[0].note and "orientativa" in pii[0].note
# Render: each label appears exactly once across the whole document (the only
# 'Diccionario de datos' / 'Datos personales' producer is this chapter).
with tempfile.TemporaryDirectory() as d:
out_pdf = os.path.join(d, "eda.pdf")
render_automatic_eda_pdf(_profile(), out_pdf, {"title": "EDA — ventas"})
txt = _pdf_text(out_pdf)
assert txt.count("Diccionario de datos") == 1
assert txt.count("Datos personales") == 1
def test_orden_capitulo_junto_a_overview():
chapters = build_document(_profile(), {})
ids = [c.id for c in chapters]
@@ -1,22 +1,26 @@
"""Data-quality chapter (CALIDAD) for AutomaticEDA.
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
chapter answers, in Spanish and as tables, the three things the user asked for:
chapter implements the quality model of report 2046:
1. **En qué se basa la calidad** — an intro paragraph explaining the criteria and
their weights (completeness, validity, consistency) before any number, plus a
table-level summary (global score and aggregates).
1. **En qué se basa la calidad** — an intro paragraph explaining the two scored
dimensions and their weights (completitud 60%, validez 40%) plus the
table-level row uniqueness, BEFORE any number, and stating explicitly that
outliers are reported as observations and do **not** lower the score. The
criteria terms (calidad de datos, completitud, validez, unicidad de registro)
are hooked into the shared glossary as clickable jumps.
2. **Scores por columna** — a table with, per column, the total quality score and
its breakdown into completeness / validity / consistency.
3. **Problemas en español** — a second table listing, per column, the readable
issues in Spanish (kept separate from the type ``flags``).
its breakdown into completeness / validity (no consistency dimension).
3. **Problemas de calidad** — a table listing ONLY real quality defects
(nulls, empty cells, values not conforming to their type/semantics).
4. **Observaciones analíticas** — a SEPARATE table for outliers, constant
columns, high-cardinality ids and strong skew, with an explicit note that
these do not affect the score.
The breakdown and the issues are NOT recomputed here: they come from the registry
function ``column_quality_score`` (group ``eda``), which already derives
``{score, completeness, validity, consistency, issues}`` from the ColumnProfile.
This chapter is render-only — it consumes that function and lays the result out
as model blocks; the renderers paginate tables (splitting by rows, repeating the
header) and wrap long cells so nothing is ever cut.
The breakdown, issues and observations are NOT recomputed here: they come from
the registry function ``column_quality_score`` (group ``eda``), which derives
``{score, completeness, validity, dimensions, applicable, issues,
observations}`` from the ColumnProfile. This chapter is render-only.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
@@ -33,28 +37,47 @@ try: # pragma: no cover - import wiring
except Exception: # noqa: BLE001 - never let an import error abort the document.
_column_quality_score = None
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "2.0.0"
CHAPTER_ID = "calidad"
CHAPTER_TITLE = "Calidad"
# Weights mirror column_quality_score: completeness 0.5, validity 0.3,
# consistency 0.2. Kept here only to render the human explanation; the actual
# numbers always come from the function so the two never drift in computation.
_CRITERIA_INTRO = (
"La calidad de cada columna es un score de 0 a 100 que combina tres "
"criterios, cada uno con un peso:\n\n"
"- **Completitud (peso 50%)**: proporción de valores presentes (sin nulos "
"ni vacíos). Una columna con muchos nulos baja de score.\n"
"- **Validez (peso 30%)**: los valores son coherentes con su tipo y rango "
"esperado (penaliza outliers y semánticas declaradas que no coinciden).\n"
"- **Consistencia (peso 20%)**: la columna aporta información útil (penaliza "
"columnas constantes o identificadores de cardinalidad muy alta).\n\n"
"Score = 100 × (0,5·completitud + 0,3·validez + 0,2·consistencia). "
"Los problemas detectados por columna se listan en español más abajo."
)
# Glossary terms this chapter explains (report 2046 §6). Registered in the shared
# collector and marked clickable on their first appearance (contract §11.1).
_TERMS = {
"calidad_datos": (
"Calidad de datos (score 0-100)",
"Mide hasta qué punto los datos están presentes y son utilizables tal "
"cual, no si son «buenos para el análisis». Se compone solo de "
"dimensiones medibles automáticamente desde el perfil de la tabla, sin "
"fuente externa de verdad: completitud (60%), validez (40%, cuando es "
"medible) y, a nivel de tabla, unicidad de registro. Los valores "
"atípicos NO bajan la calidad: se listan aparte como observaciones.",
),
"completitud": (
"Completitud",
"Proporción de valores realmente presentes en una columna (1 % de "
"nulos; en texto, las celdas vacías también cuentan como faltantes). Los "
"nulos y vacíos bajan el score porque falta información que debería "
"estar. Pesa el 60% del score de columna.",
),
"validez": (
"Validez",
"Proporción de valores que encajan con su tipo o formato esperado: un "
"número que parsea, una fecha legible, un email con forma de email. Los "
"valores que no parsean a su tipo bajan el score. Si la columna es texto "
"libre sin formato esperado, la validez no se puede medir y el score se "
"basa solo en la completitud. Pesa el 40% del score cuando es medible.",
),
"unicidad_registro": (
"Unicidad de registro",
"A nivel de tabla, las filas duplicadas restan calidad al conjunto "
"(1 % de filas duplicadas). Es distinta de que una columna no-clave "
"repita valores, que no es un defecto de calidad.",
),
}
# Cap for the joined issues cell so a single row never grows taller than a page;
# the remainder is summarized as "(+N más)" instead of being silently dropped.
# Cap for the joined cell so a single row never grows taller than a page; the
# remainder is summarized as "(+N más)" instead of being silently dropped.
_ISSUES_MAXLEN = 160
@@ -82,12 +105,19 @@ def _fmt_unit_pct(value) -> str:
return str(value)
def _fmt_validity(value) -> str:
"""Validity is ``None`` when not applicable: show ``n/a`` not a fake 0%."""
if value is None:
return "n/a"
return _fmt_unit_pct(value)
def _quality_of(col: dict) -> dict:
"""Return ``{score, completeness, validity, consistency, issues}`` for a column.
"""Return the quality dict for a column.
Uses the registry ``column_quality_score`` when available; otherwise falls
back to the per-column ``quality_score`` already in the profile (number only,
empty breakdown/issues). Never raises.
empty breakdown/issues/observations). Never raises.
"""
if not isinstance(col, dict):
col = {}
@@ -98,26 +128,25 @@ def _quality_of(col: dict) -> dict:
return res
except Exception: # noqa: BLE001 - degrade instead of aborting.
pass
# Fallback: only the final score is available pre-computed in the profile.
return {
"score": col.get("quality_score"),
"completeness": None,
"validity": None,
"consistency": None,
"issues": [],
"observations": [],
}
def _join_issues(issues) -> str:
"""Join Spanish issue strings into one cell, truncating overly long lists.
def _join_cells(items) -> str:
"""Join Spanish strings into one cell, truncating overly long lists.
The renderer wraps cell text, but a column with many long issues could make a
single row taller than a whole page; cap the length and append ``(+N más)``
so the count of hidden issues is honest rather than silently lost.
The renderer wraps cell text, but a column with many long entries could make
a single row taller than a whole page; cap the length and append ``(+N más)``
so the count of hidden entries is honest rather than silently lost.
"""
if not isinstance(issues, (list, tuple)) or not issues:
if not isinstance(items, (list, tuple)) or not items:
return ""
parts = [model._safe_str(i).strip() for i in issues]
parts = [model._safe_str(i).strip() for i in items]
parts = [p for p in parts if p]
if not parts:
return ""
@@ -142,6 +171,33 @@ def _columns_with_quality(profile: dict):
yield c, _quality_of(c)
def _fmt_unit_pct_or_pct(value) -> str:
"""Format a value that may be a 0-1 fraction or an already-0-100 percentage."""
try:
num = float(value)
except (TypeError, ValueError):
return model._safe_str(value)
if num != num: # NaN
return ""
pct = num * 100 if num <= 1.0 else num
text = f"{pct:.1f}".rstrip("0").rstrip(".")
return f"{text}%"
def _row_uniqueness(profile: dict):
"""Return row uniqueness (1 - duplicate_pct) in [0,1], or None if unknown."""
dup = profile.get("duplicate_pct")
if dup is None:
return None
try:
d = float(dup)
except (TypeError, ValueError):
return None
if d > 1.0: # tolerate a 0-100 scale
d = d / 100.0
return max(0.0, min(1.0, 1.0 - d))
def _summary_block(profile: dict, evaluated: list):
"""Table-level KVTable: global score and quality aggregates."""
rows = []
@@ -153,14 +209,15 @@ def _summary_block(profile: dict, evaluated: list):
if isinstance(q.get("completeness"), (int, float))]
vals = [q.get("validity") for _, q in evaluated
if isinstance(q.get("validity"), (int, float))]
cons = [q.get("consistency") for _, q in evaluated
if isinstance(q.get("consistency"), (int, float))]
if comps:
rows.append(("Completitud media", _fmt_unit_pct(sum(comps) / len(comps))))
if vals:
rows.append(("Validez media", _fmt_unit_pct(sum(vals) / len(vals))))
if cons:
rows.append(("Consistencia media", _fmt_unit_pct(sum(cons) / len(cons))))
rows.append(("Validez media (donde aplica)",
_fmt_unit_pct(sum(vals) / len(vals))))
ru = _row_uniqueness(profile)
if ru is not None:
rows.append(("Unicidad de registro", _fmt_unit_pct(ru)))
n_problem = sum(1 for _, q in evaluated if q.get("issues"))
rows.append(("Columnas con problemas", str(n_problem)))
@@ -182,22 +239,9 @@ def _summary_block(profile: dict, evaluated: list):
return model.KVTable(rows=rows, title="Resumen de calidad")
def _fmt_unit_pct_or_pct(value) -> str:
"""Format a value that may be a 0-1 fraction or an already-0-100 percentage."""
try:
num = float(value)
except (TypeError, ValueError):
return model._safe_str(value)
if num != num: # NaN
return ""
pct = num * 100 if num <= 1.0 else num
text = f"{pct:.1f}".rstrip("0").rstrip(".")
return f"{text}%"
def _scores_block(evaluated: list):
"""DataTable with per-column score and its three-criteria breakdown."""
header = ["Columna", "Calidad", "Completitud", "Validez", "Consistencia"]
"""DataTable with per-column score and its completeness/validity breakdown."""
header = ["Columna", "Calidad", "Completitud", "Validez"]
rows = []
# Worst columns first so the reader sees the problems at the top.
ordered = sorted(
@@ -210,22 +254,22 @@ def _scores_block(evaluated: list):
col.get("name") or "(col)",
_fmt_score(q.get("score")),
_fmt_unit_pct(q.get("completeness")),
_fmt_unit_pct(q.get("validity")),
_fmt_unit_pct(q.get("consistency")),
_fmt_validity(q.get("validity")),
])
if not rows:
return None
return model.DataTable(header=header, rows=rows,
title="Scores de calidad por columna",
note="0 = peor, 100 = mejor; ordenado de peor a mejor")
note="0 = peor, 100 = mejor; «n/a» = dimensión no "
"medible; ordenado de peor a mejor")
def _issues_block(evaluated: list):
"""DataTable listing Spanish issues per column, or a Note when there are none."""
header = ["Columna", "Problemas detectados (español)"]
"""DataTable listing ONLY real quality defects per column, or a Note."""
header = ["Columna", "Problemas de calidad (español)"]
rows = []
for col, q in evaluated:
joined = _join_issues(q.get("issues"))
joined = _join_cells(q.get("issues"))
if joined:
rows.append([col.get("name") or "(col)", joined])
if not rows:
@@ -235,6 +279,63 @@ def _issues_block(evaluated: list):
title="Problemas de calidad por columna")
def _observations_block(evaluated: list):
"""DataTable listing analytical observations per column, or None.
Observations (outliers, constant columns, ids, strong skew) are NOT quality
defects: they do not affect the score. Returned as a separate table from the
issues so the report never presents a legitimate outlier as a problem.
"""
header = ["Columna", "Observaciones analíticas"]
rows = []
for col, q in evaluated:
joined = _join_cells(q.get("observations"))
if joined:
rows.append([col.get("name") or "(col)", joined])
if not rows:
return None
return model.DataTable(
header=header, rows=rows,
title="Observaciones analíticas por columna",
note="No son defectos de calidad y NO afectan al score; orientan el "
"análisis (atípicos, columnas constantes, identificadores).")
def _term(key: str, label: str, mark: bool) -> str:
"""Render a term as a clickable glossary span when marking is enabled."""
if mark:
return f"[[term:{key}]]**{label}**[[/term]]"
return f"**{label}**"
def _criteria_intro(mark: bool) -> str:
"""Intro paragraph explaining the two scored dimensions and the principle."""
calidad = _term("calidad_datos", "calidad de datos", mark)
completitud = _term("completitud", "Completitud (peso 60%)", mark)
validez = _term("validez", "Validez (peso 40%, cuando es medible)", mark)
unicidad = _term("unicidad_registro", "unicidad de registro", mark)
return (
f"La {calidad} de cada columna es un score de 0 a 100 que combina solo "
"dimensiones medibles desde el perfil de la tabla, sin fuente externa "
"de verdad:\n\n"
f"- {completitud}: proporción de valores presentes (1 % de nulos; en "
"texto, las celdas vacías cuentan como faltantes). Los nulos y vacíos "
"bajan el score.\n"
f"- {validez}: proporción de valores que encajan con su tipo o formato "
"(un número que parsea, una fecha legible, un email con forma de email). "
"Si una columna es texto libre sin formato esperado, la validez no se "
"mide y el score se basa solo en la completitud.\n\n"
f"Score de columna = 100 × (0,6·completitud + 0,4·validez), "
"renormalizado cuando la validez no aplica. A nivel de tabla se añade "
f"la {unicidad} (1 % de filas duplicadas).\n\n"
"**Los valores atípicos (outliers) NO bajan la calidad.** Un valor "
"extremo puede ser real y correcto; detectar atípicos es parte del "
"análisis de la distribución, no un juicio de corrección. Por eso, junto "
"con las columnas constantes y los identificadores, se listan aparte "
"como **observaciones analíticas** que no afectan al score."
)
def build_calidad(profile: dict, ctx: dict):
"""Build the data-quality Chapter, or None if the profile has no columns.
@@ -250,17 +351,35 @@ def build_calidad(profile: dict, ctx: dict):
if not evaluated:
return None # no columns to score -> chapter does not apply.
# Register the criteria terms in the shared glossary (if present) and mark
# their first appearance clickable. Contract §11.1.
glossary = ctx.get("glossary")
mark = False
if isinstance(glossary, model.GlossaryCollector):
for key, (label, definition) in _TERMS.items():
glossary.add(key, label, definition)
mark = True
blocks = [
model.Heading(text="Cómo se calcula la calidad", level=2),
model.Markdown(text=_CRITERIA_INTRO),
model.Markdown(text=_criteria_intro(mark)),
_summary_block(profile, evaluated),
model.Heading(text="Scores por columna", level=2),
]
scores = _scores_block(evaluated)
if scores is not None:
blocks.append(scores)
blocks.append(model.Heading(text="Problemas detectados", level=2))
blocks.append(model.Heading(text="Problemas de calidad", level=2))
blocks.append(_issues_block(evaluated))
observations = _observations_block(evaluated)
if observations is not None:
blocks.append(model.Heading(text="Observaciones analíticas", level=2))
blocks.append(model.Note(
"Las observaciones siguientes NO son defectos de calidad y no "
"afectan al score: son señales para orientar el análisis."))
blocks.append(observations)
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -1,11 +1,12 @@
"""Tests for the CALIDAD chapter — DoD: golden + edges + anti-cut.
"""Tests for the CALIDAD chapter — DoD: golden + edges + anti-cut + glossary.
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that the chapter explains the quality criteria, shows
per-column scores with the completeness/validity/consistency breakdown, lists the
issues in Spanish (separate from the type flags), returns None when it does not
apply, and that a wide profile with long names renders to PDF and PPTX without
cutting any cell text (long content wraps, it is never truncated).
and deterministic. Verifies the report-2046 quality model: the chapter explains
the two scored dimensions (completitud 60% / validez 40%), shows per-column
scores without a consistency column, keeps quality DEFECTS (issues) separate
from analytical OBSERVATIONS (outliers, constant, ids), hooks the criteria terms
into the glossary, returns None when it does not apply, and renders a wide
profile to PDF and PPTX without cutting any cell text.
"""
import os
@@ -20,28 +21,30 @@ from datascience.automatic_eda.chapters.calidad import (
CHAPTER_VERSION,
)
from datascience.automatic_eda import build_document, render_pdf, render_pptx
from datascience.automatic_eda import model
def _profile() -> dict:
"""A small profile with one column per quality problem (nulls, outliers,
constant, high-cardinality id) plus one clean column."""
constant, high-cardinality id) plus one clean column. ``outlier_pct`` is in
the 0-100 scale that describe_numeric actually emits."""
return {
"table": "demo",
"quality_score": 72.5,
"quality_score": 82.0,
"duplicate_pct": 0.04,
"null_cell_pct": 0.11,
"constant_cols": ["flag_const"],
"all_null_cols": [],
"columns": [
{"name": "edad", "inferred_type": "integer", "null_pct": 0.2,
"numeric": {"outlier_pct": 0.15, "min": 0, "max": 99},
"quality_score": 60},
{"name": "edad", "inferred_type": "numeric", "null_pct": 0.2,
"n_rows": 100, "unique_pct": 0.5,
"numeric": {"outlier_pct": 15.0, "min": 0, "max": 99}},
{"name": "nombre", "inferred_type": "text", "null_pct": 0.0,
"unique_pct": 0.98, "quality_score": 80},
"unique_pct": 0.98, "flags": ["possible_id"]},
{"name": "flag_const", "inferred_type": "text", "null_pct": 0.0,
"flags": ["constant"], "quality_score": 50},
{"name": "limpia", "inferred_type": "float", "null_pct": 0.0,
"numeric": {"outlier_pct": 0.0}, "quality_score": 100},
"unique_pct": 0.01, "flags": ["constant"]},
{"name": "limpia", "inferred_type": "numeric", "null_pct": 0.0,
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
],
}
@@ -50,16 +53,9 @@ def _tables(chapter):
return [b for b in chapter.blocks if getattr(b, "kind", None) == "data_table"]
def _scores_table(chapter):
def _table_by_title(chapter, needle):
for t in _tables(chapter):
if "Scores" in (t.title or ""):
return t
return None
def _issues_table(chapter):
for t in _tables(chapter):
if "Problemas" in (t.title or ""):
if needle in (t.title or ""):
return t
return None
@@ -73,41 +69,84 @@ def test_golden_chapter_estructura_y_version():
assert ch.id == "calidad"
assert ch.version == CHAPTER_VERSION
kinds = [b.kind for b in ch.blocks]
# intro heading + markdown criteria + summary kv + scores table + issues table
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
def test_golden_intro_explica_criterios_y_pesos():
def test_golden_intro_explica_dos_dimensiones_y_pesos():
ch = build_calidad(_profile(), {})
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
for needle in ("Completitud", "Validez", "Consistencia",
"50%", "30%", "20%"):
for needle in ("Completitud", "Validez", "60%", "40%",
"unicidad de registro"):
assert needle in intro, f"falta {needle!r} en la intro de criterios"
# El principio: los outliers NO bajan la calidad.
assert "atípicos" in intro and "NO bajan" in intro
# Ya no se menciona la dimensión consistencia eliminada.
assert "20%" not in intro
def test_golden_scores_incluyen_desglose_por_criterio():
def test_golden_scores_sin_columna_consistencia():
ch = build_calidad(_profile(), {})
scores = _scores_table(ch)
scores = _table_by_title(ch, "Scores")
assert scores is not None
assert scores.header == ["Columna", "Calidad", "Completitud",
"Validez", "Consistencia"]
# 4 columns scored, none dropped.
assert scores.header == ["Columna", "Calidad", "Completitud", "Validez"]
assert "Consistencia" not in scores.header
assert len(scores.rows) == 4
names = {r[0] for r in scores.rows}
assert names == {"edad", "nombre", "flag_const", "limpia"}
def test_golden_issues_en_espanol_separados_de_flags():
def test_golden_outliers_en_observaciones_no_en_problemas():
ch = build_calidad(_profile(), {})
issues = _issues_table(ch)
assert issues is not None
flat = " | ".join(" ".join(r) for r in issues.rows)
assert "nulos" in flat # completeness issue (ES)
assert "outliers" in flat # validity issue (ES)
assert "columna constante" in flat
assert "posible id de alta cardinalidad" in flat
# The raw type flag string must NOT leak as a "problem".
assert "constant" not in flat or "columna constante" in flat
problemas = _table_by_title(ch, "Problemas de calidad")
observaciones = _table_by_title(ch, "Observaciones")
assert problemas is not None
assert observaciones is not None
problemas_txt = " | ".join(" ".join(r) for r in problemas.rows)
observaciones_txt = " | ".join(" ".join(r) for r in observaciones.rows)
# Los nulos SÍ son problema de calidad.
assert "nulos" in problemas_txt
# Los outliers NO aparecen como problema...
assert "atípic" not in problemas_txt and "outlier" not in problemas_txt
# ...sino como observación analítica.
assert "atípic" in observaciones_txt
# Constante e id: observaciones, no problemas.
assert "constante" in observaciones_txt
assert "identificador" in observaciones_txt
assert "constante" not in problemas_txt
def test_golden_score_columna_limpia_es_100():
"""Columna sin nulos, numérica nativa: score 100 aunque tenga (o no) outliers."""
ch = build_calidad(_profile(), {})
scores = _table_by_title(ch, "Scores")
by_name = {r[0]: r for r in scores.rows}
assert by_name["limpia"][1] == "100 / 100"
# edad: 20% nulos -> 100*(0.6*0.8 + 0.4*1.0) = 88; los outliers no bajan nada.
assert by_name["edad"][1] == "88 / 100"
# --------------------------------------------------------------------------- #
# Glosario (contrato §11.1)
# --------------------------------------------------------------------------- #
def test_glosario_registra_los_cuatro_terminos_y_marca_clicable():
glossary = model.GlossaryCollector()
ch = build_calidad(_profile(), {"glossary": glossary})
for key in ("calidad_datos", "completitud", "validez", "unicidad_registro"):
assert glossary.has(key), f"término {key!r} no registrado en el glosario"
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
# Con colector presente, la primera aparición se marca clicable.
assert "[[term:completitud]]" in intro
assert "[[term:validez]]" in intro
assert "[[term:calidad_datos]]" in intro
assert "[[term:unicidad_registro]]" in intro
def test_sin_glosario_no_marca_terminos():
ch = build_calidad(_profile(), {}) # ctx sin glossary
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
assert "[[term:" not in intro
# --------------------------------------------------------------------------- #
@@ -124,17 +163,17 @@ def test_edge_perfil_limpio_sin_problemas_usa_nota():
prof = {
"quality_score": 100,
"columns": [
{"name": "a", "inferred_type": "float", "null_pct": 0.0,
"numeric": {"outlier_pct": 0.0}},
{"name": "b", "inferred_type": "float", "null_pct": 0.0,
"numeric": {"outlier_pct": 0.0}},
{"name": "a", "inferred_type": "numeric", "null_pct": 0.0,
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
{"name": "b", "inferred_type": "numeric", "null_pct": 0.0,
"unique_pct": 0.5, "numeric": {"outlier_pct": 0.0}},
],
}
ch = build_calidad(prof, {})
assert ch is not None
assert _issues_table(ch) is None # no issues table
assert _table_by_title(ch, "Problemas de calidad") is None # no issues table
notes = [b for b in ch.blocks if b.kind == "note"]
assert notes and "No se detectaron problemas" in notes[0].text
assert any("No se detectaron problemas" in n.text for n in notes)
# --------------------------------------------------------------------------- #
@@ -143,44 +182,42 @@ def test_edge_perfil_limpio_sin_problemas_usa_nota():
def _wide_profile(ncols: int = 22) -> dict:
cols = [
{"name": "identificador_unico_de_transaccion_con_nombre_muy_largo",
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.99},
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.99,
"flags": ["possible_id"]},
{"name": "columna_constante_sin_ninguna_variacion_de_valor",
"inferred_type": "text", "null_pct": 0.0, "flags": ["constant"]},
"inferred_type": "text", "null_pct": 0.0, "unique_pct": 0.01,
"flags": ["constant"]},
]
for k in range(ncols - 2):
cols.append({
"name": f"metrica_numerica_de_negocio_{k:02d}_con_nombre_largo",
"inferred_type": "float", "null_pct": 0.1 + (k % 3) * 0.05,
"numeric": {"outlier_pct": 0.08, "min": 0, "max": 1000},
"inferred_type": "numeric", "null_pct": 0.1 + (k % 3) * 0.05,
"unique_pct": 0.5,
"numeric": {"outlier_pct": 8.0, "min": 0, "max": 1000},
})
return {"table": "ancha", "quality_score": 70.0, "columns": cols}
return {"table": "ancha", "quality_score": 70.0, "duplicate_pct": 0.0,
"columns": cols}
def test_anticut_pdf_y_pptx_no_truncan_nombres_largos():
prof = _wide_profile(22)
full = build_document(prof, {"dataset_name": "ancha"})
assert any(c.id == "calidad" for c in full)
# Render ONLY the calidad chapter so the anti-cut assertions are scoped to
# this chapter (other chapters, e.g. portada, legitimately contain '…').
chapters = [c for c in full if c.id == "calidad"]
long_name = "metrica_numerica_de_negocio_00_con_nombre_largo"
with tempfile.TemporaryDirectory() as d:
pdf = os.path.join(d, "q.pdf")
pptx = os.path.join(d, "q.pptx")
rp = render_pdf(chapters, pdf, {"title": "EDA"})
rx = render_pptx(chapters, pptx, {"title": "EDA"})
render_pptx(chapters, pptx, {"title": "EDA"})
assert os.path.exists(pdf) and os.path.exists(pptx)
# The wide table forces pagination across several pages/slides.
assert (rp or {}).get("n_pages", 0) >= 2
# PDF: the long name survives whole once wraps (spaces/newlines) removed,
# and there is no truncation marker.
pdf_txt = "".join((pg.extract_text() or "") for pg in PdfReader(pdf).pages)
assert "" not in pdf_txt and "..." not in pdf_txt
norm = re.sub(r"\s+", "", pdf_txt)
assert long_name in norm, "el nombre largo se cortó en el PDF"
# PPTX: long name present in some cell, untruncated.
allt = []
for s in Presentation(pptx).slides:
for sh in s.shapes:
@@ -33,10 +33,23 @@ import math
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "cat_distr"
CHAPTER_TITLE = "Distribuciones categóricas"
# Glossary term this chapter explains. Registered in the shared collector and
# marked clickable on its first appearance (end-to-end glossary example —
# mejora 6). Other chapters hook their own terms the same way (see the contract).
_TERM_ENTROPIA_KEY = "entropia"
_TERM_ENTROPIA_LABEL = "Entropía (de Shannon)"
_TERM_ENTROPIA_DEF = (
"Medida, en bits, de cómo de repartidos están los valores de una columna "
"categórica. Vale 0 cuando una sola categoría concentra todas las filas "
"(máxima previsibilidad) y alcanza su máximo, log2(k) para k categorías "
"distintas, cuando todas aparecen por igual (máxima diversidad). La entropía "
"normalizada (entropía dividida por su máximo) la lleva al rango 01 para "
"comparar columnas con distinto número de categorías.")
# Cap the number of categorical columns rendered to keep the document bounded;
# the rest are summarized in a closing note (no silent truncation).
MAX_COLS = 40
@@ -337,10 +350,14 @@ def _topk_table(cat: dict):
note=note)
def _intro_blocks(n_rows):
def _intro_blocks(n_rows, mark_term: bool = False):
total = _fmt_int(n_rows)
# Mark the first appearance of the term as a clickable glossary jump when the
# term was registered (mark_term). The visible text is identical either way.
entropia = ("[[term:entropia]]**entropía de Shannon**[[/term]]" if mark_term
else "**entropía de Shannon**")
text = (
"La **entropía de Shannon** mide cómo de repartidos están los valores de "
f"La {entropia} mide cómo de repartidos están los valores de "
"una columna categórica, en bits. Vale 0 cuando una sola categoría "
"concentra todas las filas (máxima previsibilidad) y alcanza su máximo, "
"log2(k) para k categorías distintas, cuando todas aparecen por igual "
@@ -370,7 +387,15 @@ def build_cat_distr(profile: dict, ctx: dict):
return None
n_rows = profile.get("n_rows")
blocks = list(_intro_blocks(n_rows))
# Register "entropía" in the shared glossary collector (if present) and mark
# its first appearance clickable. End-to-end glossary example (mejora 6).
glossary = ctx.get("glossary")
mark_term = False
if isinstance(glossary, model.GlossaryCollector):
glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
_TERM_ENTROPIA_DEF)
mark_term = True
blocks = list(_intro_blocks(n_rows, mark_term=mark_term))
rendered = cat_cols[:MAX_COLS]
for col in rendered:
@@ -47,6 +47,53 @@ _MAX_MATRIX_LABELS = 16
# How many pairs to show in each of the top-positive / top-negative tables.
_TOP_N = 10
# Glossary terms this chapter explains. Each is registered in the shared
# collector (ctx['glossary']) and marked clickable on its first appearance in the
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
# implementation): ``glossary.add(key, label, definition)`` + the inline span
# ``[[term:KEY]]texto visible[[/term]]`` in a Markdown block. Mapping key ->
# (label, definition). ``fdr`` is only registered when the FDR summary is present.
_TERM_DEFS = {
"pearson": (
"Pearson (coeficiente r)",
"Coeficiente de correlación lineal de Pearson (r) entre dos variables "
"numéricas. Va de 1 (relación lineal inversa perfecta) a +1 (directa "
"perfecta); 0 indica ausencia de relación lineal. Sólo capta relaciones "
"lineales, por eso lleva signo."),
"spearman": (
"Spearman (correlación de rangos)",
"Correlación de rangos de Spearman: el coeficiente de Pearson calculado "
"sobre los puestos (rangos) de los valores en vez de sus magnitudes. Mide "
"relaciones monótonas (no necesariamente lineales), va de 1 a +1 y es "
"robusta frente a valores atípicos."),
"cramers_v": (
"Cramér's V",
"Medida de asociación entre dos variables categóricas, derivada del "
"estadístico chi-cuadrado y normalizada al rango 01 (0 = independientes, "
"1 = asociación total). No tiene signo: sólo mide la intensidad."),
"correlation_ratio": (
"Razón de correlación (η)",
"Razón de correlación (eta) entre una variable numérica y una "
"categórica: la fracción de la varianza de la numérica explicada por los "
"grupos de la categórica. Va de 0 (los grupos no explican nada) a 1 (la "
"explican toda); no tiene signo."),
"fdr": (
"Comparaciones múltiples (FDR)",
"Al evaluar muchos pares a la vez, algunos parecen significativos por "
"puro azar. La corrección por tasa de falsos descubrimientos (FDR, "
"Benjamini-Hochberg) ajusta los p-valores para controlar la proporción "
"esperada de falsos positivos entre los pares declarados significativos."),
}
def _term(mark: bool, key: str, text: str) -> str:
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
The visible text is identical with or without the marker (the renderers strip
the marker), so wrapping never changes line layout — it only adds the link.
"""
return f"[[term:{key}]]{text}[[/term]]" if mark else text
def _is_num(v) -> bool:
"""True for a real, finite int/float (not bool, not NaN/inf)."""
@@ -245,7 +292,7 @@ def _methods_block(corr: dict):
return model.KVTable(rows=rows, title="Métodos de asociación")
def _fdr_text(corr: dict) -> str | None:
def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
"""One-line summary of the multiple-testing (FDR) correction, or None."""
mt = corr.get("multiple_testing")
if not isinstance(mt, dict) or not mt:
@@ -254,7 +301,8 @@ def _fdr_text(corr: dict) -> str | None:
alpha = mt.get("alpha")
n_tests = mt.get("n_tests")
n_rej = mt.get("n_rejected")
parts = [f"Corrección por comparaciones múltiples ({method}"]
multi = _term(mark_term, "fdr", "comparaciones múltiples")
parts = [f"Corrección por {multi} ({method}"]
if _is_num(alpha):
parts[0] += f", α={float(alpha):g}"
parts[0] += ")."
@@ -289,13 +337,31 @@ def build_correlacion(profile: dict, ctx: dict):
blocks: list = []
# Intro: what this chapter shows and how to read the sign.
# Register the always-present method terms in the shared glossary and mark
# their first appearance clickable (the FDR term is registered lazily below,
# only when the FDR summary is actually emitted). Degrades silently when no
# collector is in ctx (standalone render) — mark_term stays False.
glossary = ctx.get("glossary")
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
mark_term = gloss is not None
if gloss is not None:
for key in ("pearson", "spearman", "cramers_v", "correlation_ratio"):
label, definition = _TERM_DEFS[key]
gloss.add(key, label, definition)
# Intro: what this chapter shows and how to read the sign. Build the marked
# method names as locals first (avoids backslash-in-f-string for "Cramér's V").
t_pearson = _term(mark_term, "pearson", "Pearson")
t_spearman = _term(mark_term, "spearman", "Spearman")
t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
blocks.append(model.Markdown(text=(
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
"sus tipos (Pearson/Spearman entre numéricas — con **signo**; Cramér's V "
"entre categóricas; razón de correlación num-categórica; información mutua "
"como medida común no lineal). Sólo las correlaciones **num-num** tienen "
"dirección: por eso los pares **negativos** son siempre num-num.")))
f"sus tipos ({t_pearson}/{t_spearman} entre numéricas — con **signo**; "
f"{t_cramers} entre categóricas; {t_corr_ratio} num-categórica; "
"información mutua como medida común no lineal). Sólo las correlaciones "
"**num-num** tienen dirección: por eso los pares **negativos** son siempre "
"num-num.")))
# 1) Association matrix (heatmap).
labels, trimmed = _ordered_labels(pairs)
@@ -337,9 +403,13 @@ def build_correlacion(profile: dict, ctx: dict):
"no estacionarias y pueden ser espurias (GrangerNewbold). Compáralas "
"sobre los retornos/diferencias antes de interpretarlas.")))
# 4) FDR summary + methods legend.
fdr_text = _fdr_text(corr)
# 4) FDR summary + methods legend. Register the FDR term only when its
# summary is emitted, so the glossary never lists an unreferenced entry.
fdr_text = _fdr_text(corr, mark_term=mark_term)
if fdr_text:
if gloss is not None:
label, definition = _TERM_DEFS["fdr"]
gloss.add("fdr", label, definition)
blocks.append(model.Markdown(text=fdr_text))
methods = _methods_block(corr)
if methods is not None:
@@ -173,3 +173,25 @@ def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
# A short, unbreakable fragment of the long label survives the wrap.
assert "azufre" in _pdf_text(pdf)
def test_glosario_engancha_metodos_y_fdr():
"""Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
razón de correlación) y la corrección por comparaciones múltiples (FDR) se
registran en el colector compartido y se marcan clicables en el cuerpo. Sin
colector en ctx, el capítulo degrada y no marca nada."""
from datascience.automatic_eda.model import GlossaryCollector
g = GlossaryCollector()
ch = build_correlacion(_profile(), {"glossary": g})
assert ch is not None
keys = {t["key"] for t in g.terms()}
assert {"pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"} <= keys
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
for k in ("pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"):
assert f"[[term:{k}]]" in body, k
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
ch2 = build_correlacion(_profile(), {})
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
assert "[[term:" not in body2
@@ -0,0 +1,47 @@
"""Glossary chapter (GLOSARIO) — always the last chapter, clickable terms.
Renders one entry per glossary term that the other chapters registered during
the document build through ``ctx['glossary'].add(key, label, definition)`` (see
``GlossaryCollector`` in ``model.py``). Each entry is a clickable destination:
every in-text appearance a chapter marked with ``[[term:key]]texto[[/term]]``
becomes a real jump to its entry here — PDF link annotations (PyMuPDF) and PPTX
native slide jumps, both wired by the renderers.
Returns ``None`` when no term was registered (there is nothing to show), so the
chapter simply disappears from documents that did not mark any term.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "glosario"
CHAPTER_TITLE = "Glosario"
def build_glosario(profile: dict, ctx: dict):
"""Build the glossary Chapter from the shared collector, or None if empty."""
ctx = ctx or {}
glossary = ctx.get("glossary")
if not isinstance(glossary, model.GlossaryCollector) or not glossary:
return None
blocks = [
model.Heading(text="Glosario de términos", level=1),
model.Markdown(text=(
"Definición de los términos técnicos que aparecen en el informe. "
"Cada término va resaltado en el texto y, al pulsarlo, salta a su "
"definición en esta sección.")),
]
# One clickable destination per term, alphabetically by visible label.
for term in glossary.terms(by="label"):
blocks.append(model.GlossaryEntry(
key=model._safe_str(term.get("key")),
label=model._safe_str(term.get("label")),
definition=model._safe_str(term.get("definition"))))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -55,6 +55,62 @@ _CLUSTER_COLORS = [
"#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
]
# Glossary terms this chapter explains. Each is registered in the shared
# collector (ctx['glossary']) and marked clickable on its first appearance — the
# canonical two-step pattern (see ``cat_distr``): ``glossary.add(key, label,
# definition)`` + the inline span ``[[term:KEY]]texto[[/term]]`` in a Markdown
# block. A term is registered only when its section is actually rendered, so the
# glossary never lists an entry no in-text appearance points to.
_TERM_DEFS = {
"zscore": (
"Estandarización z-score",
"Transformación que lleva cada columna numérica a media 0 y desviación "
"típica 1: a cada valor le resta la media de su columna y lo divide por "
"la desviación típica. Así variables con escalas muy distintas (euros "
"frente a un ratio 01) pesan por igual en las distancias y la varianza."),
"pca": (
"PCA (componentes principales)",
"El análisis de componentes principales resume muchas variables "
"numéricas correlacionadas en pocos ejes nuevos (componentes), "
"ortogonales entre sí y ordenados por la cantidad de varianza que "
"capturan. Permite ver la estructura de los datos en 2D y saber cuántas "
"dimensiones bastan para explicarlos."),
"kmeans": (
"KMeans (segmentación)",
"Algoritmo de agrupamiento no supervisado que reparte las filas en k "
"segmentos: asigna cada fila al centro (centroide) más cercano y recoloca "
"los centroides de forma iterativa hasta minimizar la distancia interna "
"de cada grupo. Aquí k se elige automáticamente."),
"silhouette": (
"Coeficiente de silueta (silhouette)",
"Métrica de calidad de un agrupamiento, en el rango 1 a 1: para cada "
"fila compara cómo de cerca está de su propio segmento frente al segmento "
"vecino más próximo. Cuanto más alto el promedio, más compactos y "
"separados están los segmentos."),
"isolation_forest": (
"Isolation Forest (anomalías)",
"Algoritmo de detección de anomalías multivariante: construye árboles que "
"parten el espacio con cortes aleatorios y mide cuántos cortes hacen "
"falta para aislar cada fila. Las filas raras se aíslan con muy pocos "
"cortes y se marcan como outliers según un umbral de contaminación."),
}
def _term(mark: bool, key: str, text: str) -> str:
"""Wrap ``text`` as a clickable glossary span when ``mark`` is True.
The visible text is identical with or without the marker (the renderers strip
it), so wrapping never changes line layout — it only adds the link.
"""
return f"[[term:{key}]]{text}[[/term]]" if mark else text
def _register(gloss, key: str) -> None:
"""Register term ``key`` in the collector (idempotent); no-op if gloss None."""
if gloss is not None:
label, definition = _TERM_DEFS[key]
gloss.add(key, label, definition)
# --------------------------------------------------------------------------- #
# Formatting helpers (mirror the overview chapter's defensive style).
@@ -252,34 +308,37 @@ def _make_cluster_scatter(projection: dict):
# --------------------------------------------------------------------------- #
# Section builders. Each returns a list of blocks (possibly empty).
# --------------------------------------------------------------------------- #
def _normalization_intro() -> list:
def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
_register(gloss, "zscore")
zscore = _term(mark_term, "zscore", "**estandarizan con z-score**")
text = (
"Estos modelos son **no supervisados**: buscan estructura latente sin "
"una variable objetivo. Antes de aplicarlos, todas las columnas "
"numéricas se **estandarizan con z-score** (cada valor menos la media, "
"dividido por la desviación típica). Sin esta normalización, una "
"variable con escala grande (p.ej. ingresos en euros) dominaría las "
"distancias y la varianza frente a otra de escala pequeña (p.ej. un "
"ratio entre 0 y 1), sesgando tanto el PCA como el KMeans. Tras la "
"estandarización todas las variables pesan por igual."
f"numéricas se {zscore} (cada valor menos la media, dividido por la "
"desviación típica). Sin esta normalización, una variable con escala "
"grande (p.ej. ingresos en euros) dominaría las distancias y la varianza "
"frente a otra de escala pequeña (p.ej. un ratio entre 0 y 1), sesgando "
"tanto el PCA como el KMeans. Tras la estandarización todas las variables "
"pesan por igual."
)
return [model.Heading(text="Modelos no supervisados", level=1),
model.Markdown(text=text)]
def _pca_section(pca: dict) -> list:
def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
if not _is_dict(pca) or not pca.get("explained_variance_ratio"):
return []
_register(gloss, "pca")
blocks = [model.Heading(text="PCA — varianza explicada", level=2)]
n_used = pca.get("n_rows_used")
n_feat = pca.get("n_features")
intro = (
f"El PCA resume {_fmt_num(n_feat)} variables numéricas en componentes "
f"ortogonales ordenados por la varianza que capturan "
f"({_fmt_num(n_used)} filas usadas tras eliminar nulos). El gráfico de "
"sedimentación (scree) muestra cuánta varianza aporta cada componente y "
"su acumulado: un codo marca cuántos componentes bastan."
f"El {_term(mark_term, 'pca', 'PCA')} resume {_fmt_num(n_feat)} variables "
"numéricas en componentes ortogonales ordenados por la varianza que "
f"capturan ({_fmt_num(n_used)} filas usadas tras eliminar nulos). El "
"gráfico de sedimentación (scree) muestra cuánta varianza aporta cada "
"componente y su acumulado: un codo marca cuántos componentes bastan."
)
blocks.append(model.Markdown(text=intro))
@@ -325,11 +384,14 @@ def _pca_section(pca: dict) -> list:
return blocks
def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
def _kmeans_section(kmeans: dict, projection: dict, titles,
gloss=None, mark_term: bool = False) -> list:
has_km = _is_dict(kmeans) and kmeans.get("best_k")
has_proj = _is_dict(projection) and projection.get("points")
if not has_km and not has_proj:
return []
_register(gloss, "kmeans")
_register(gloss, "silhouette")
blocks = [model.Heading(text="Segmentación (KMeans)", level=2)]
@@ -337,9 +399,11 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
sil = (projection or {}).get("silhouette")
if sil is None:
sil = (kmeans or {}).get("silhouette")
t_kmeans = _term(mark_term, "kmeans", "KMeans")
t_sil = _term(mark_term, "silhouette", "*silhouette*")
intro = (
f"KMeans agrupa las filas en **{_fmt_num(best_k)} segmentos** elegidos "
"automáticamente maximizando el coeficiente de *silhouette* "
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
f"elegidos automáticamente maximizando el coeficiente de {t_sil} "
f"(**{_fmt_num(sil)}**, rango 1 a 1: cuanto más alto, segmentos más "
"compactos y separados). Los segmentos se proyectan sobre el plano de "
"los dos primeros componentes principales para visualizarlos."
@@ -394,16 +458,18 @@ def _kmeans_section(kmeans: dict, projection: dict, titles) -> list:
return blocks
def _outliers_section(outliers: dict) -> list:
def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> list:
if not _is_dict(outliers) or outliers.get("n_outliers") is None:
return []
if outliers.get("note") and not outliers.get("n_rows_used"):
# insufficient data — nothing meaningful to show.
return []
_register(gloss, "isolation_forest")
blocks = [model.Heading(text="Detección de anomalías (Isolation Forest)",
level=2)]
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
explain = (
"**Isolation Forest** detecta filas anómalas de forma *multivariante*: "
f"{isof} detecta filas anómalas de forma *multivariante*: "
"construye árboles que parten el espacio con cortes aleatorios y mide "
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
"(combinaciones de valores poco frecuentes considerando **todas las "
@@ -484,15 +550,21 @@ def build_modelos(profile: dict, ctx: dict):
(kmeans and kmeans.get("best_k")) or (projection and projection.get("points"))
) else None
# Shared glossary collector: terms are registered + marked clickable inside
# each section, only when that section actually renders (no orphan entries).
glossary = ctx.get("glossary")
gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
mark_term = gloss is not None
sections = []
sections += _pca_section(pca) if pca else []
sections += _kmeans_section(kmeans, projection, titles)
sections += _outliers_section(outliers) if outliers else []
sections += _pca_section(pca, gloss, mark_term) if pca else []
sections += _kmeans_section(kmeans, projection, titles, gloss, mark_term)
sections += _outliers_section(outliers, gloss, mark_term) if outliers else []
sections += _normality_section(normality) if normality else []
if not sections:
return None # models block present but nothing renderable.
blocks = _normalization_intro() + sections
blocks = _normalization_intro(gloss, mark_term) + sections
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -257,3 +257,26 @@ def test_anticortes_tabla_normalidad_larga_no_corta():
# Every column name survives (wrapped/split, never truncated).
for i in (0, 19, 39):
assert f"col_{i}" in txt
def test_glosario_engancha_terminos_modelos():
"""Mejora 4b: PCA, KMeans, silhouette, Isolation Forest y la estandarización
z-score se registran en el colector compartido y se marcan clicables en el
cuerpo. Sin colector en ctx, el capítulo degrada y no marca nada."""
from datascience.automatic_eda.model import GlossaryCollector
g = GlossaryCollector()
ctx = dict(_ctx_full())
ctx["glossary"] = g
ch = build_modelos(_profile(), ctx)
assert ch is not None
keys = {t["key"] for t in g.terms()}
assert {"zscore", "pca", "kmeans", "silhouette", "isolation_forest"} <= keys
body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
for k in ("zscore", "pca", "kmeans", "silhouette", "isolation_forest"):
assert f"[[term:{k}]]" in body, k
# Sin colector: degrada limpio (ningún marcador en el cuerpo).
ch2 = build_modelos(_profile(), _ctx_full())
body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
assert "[[term:" not in body2
@@ -1,9 +1,10 @@
"""Numeric distributions chapter (NUM DISTR) for AutomaticEDA.
For every numeric column the chapter draws, as a single indivisible figure, a
histogram with the **mean, median and ±1σ band drawn as reference lines** and a
**Tukey boxplot right below it** sharing the same X axis — exactly the user
requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
histogram with the **mean, median and ±1σ band drawn as reference lines** (the
legend reports the numeric value of the mean, the median **and the standard
deviation σ**) and a **Tukey boxplot right below it** sharing the same X axis —
exactly the user requirement for this chapter. Each figure is emitted as a lazy ``Figure`` block
so the renderers rasterize and scale it to fit a whole page/slide and nothing is
ever cut; columns with many numerics simply flow across pages as small
multiples.
@@ -34,7 +35,7 @@ try:
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
build_boxplot_stats = None # type: ignore[assignment]
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.2.0"
CHAPTER_ID = "num_distr"
CHAPTER_TITLE = "Distribuciones numéricas"
@@ -140,9 +141,11 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
std = numeric.get("std")
# ±1σ band first (behind the lines), then median (solid) and mean (dashed).
# The band's legend entry also reports the numeric value of the standard
# deviation, so the reader sees mean, median AND σ at a glance.
if mean is not None and std is not None and std > 0:
ax_h.axvspan(mean - std, mean + std, color="#f0c27b", alpha=0.22,
zorder=1, label="±1σ")
zorder=1, label=f"±1σ (σ = {_fmt_num(std)})")
if median is not None:
ax_h.axvline(median, color="#2e8b57", linestyle="-", linewidth=1.6,
zorder=4, label=f"mediana = {_fmt_num(median)}")
@@ -152,7 +155,19 @@ def _make_hist_box(name: str, numeric: dict, box: dict):
ax_h.set_ylabel("frecuencia", fontsize=8)
ax_h.tick_params(labelsize=7)
ax_h.legend(fontsize=6.5, loc="upper right", framealpha=0.85)
# Always surface σ in the legend: if the ±1σ band could not be drawn (no mean
# or std<=0) but σ is still known, add a label-only proxy handle so the value
# of the standard deviation is reported regardless of the band.
handles, labels = ax_h.get_legend_handles_labels()
if std is not None and not any("σ =" in lbl for lbl in labels):
from matplotlib.lines import Line2D
proxy = Line2D([], [], linestyle="none", marker="",
label=f"σ = {_fmt_num(std)}")
handles.append(proxy)
labels.append(f"σ = {_fmt_num(std)}")
if handles:
ax_h.legend(handles, labels, fontsize=6.5, loc="upper right",
framealpha=0.85)
for spine in ("top", "right"):
ax_h.spines[spine].set_visible(False)
@@ -278,12 +293,17 @@ def build_num_distr(profile: dict, ctx: dict):
box = build_boxplot_stats(numeric) or {}
except Exception: # noqa: BLE001 — degrade, never raise.
box = {}
blocks.append(model.Heading(text=str(name), level=2))
blocks.append(model.Figure(
make=_figure_maker(name, numeric, box),
caption=f"Distribución de «{name}» — histograma (media/mediana/±σ) "
f"y boxplot."))
blocks.append(model.Markdown(text=_stats_note(name, numeric, box)))
# Keep the column heading, its figure and its stats note together on the
# same page/slide (mejora 3 — keep-together): the renderers measure the
# whole Group and move it whole when it would not fit.
blocks.append(model.Group(blocks=[
model.Heading(text=str(name), level=2),
model.Figure(
make=_figure_maker(name, numeric, box),
caption=f"Distribución de «{name}» — histograma "
f"(media/mediana/±σ) y boxplot."),
model.Markdown(text=_stats_note(name, numeric, box)),
]))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -65,19 +65,33 @@ def _pdf_text(path: str) -> str:
return re.sub(r"\s+", " ", txt)
def _flatten(blocks):
"""Expand keep-together Groups so the per-column heading/figure/markdown are
inspectable as a flat block list (the chapter wraps each column in a Group)."""
out = []
for b in blocks:
if getattr(b, "kind", "") == "group":
out.extend(_flatten(getattr(b, "blocks", []) or []))
else:
out.append(b)
return out
def test_golden_chapter_estructura_y_bloques():
ch = build_num_distr(_profile(n_numeric=2), {})
assert ch is not None
assert ch.id == "num_distr"
assert ch.version == CHAPTER_VERSION
kinds = [b.kind for b in ch.blocks]
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
flat = _flatten(ch.blocks)
kinds = [b.kind for b in flat]
# Heading + intro Markdown, then per column: Heading + Figure + Markdown.
assert kinds[0] == "heading"
assert kinds[1] == "markdown"
assert kinds.count("figure") == 2 # one figure per numeric column.
assert kinds.count("heading") == 1 + 2 # chapter title + one per column.
# Each figure has a lazy maker that produces a real matplotlib figure.
figs = [b for b in ch.blocks if b.kind == "figure"]
figs = [b for b in flat if b.kind == "figure"]
fig = figs[0].make()
assert fig is not None
# Two stacked axes: histogram + boxplot share the figure.
@@ -90,7 +104,8 @@ def test_golden_media_mediana_sigma_y_boxplot_presentes():
# The intro documents the three reference lines and the Tukey boxplot; the
# per-column note carries the actual mean/median/σ numbers and the shape.
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
md_texts = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
md_texts = " ".join(b.text for b in _flatten(ch.blocks)
if b.kind == "markdown")
assert "media" in md_texts and "mediana" in md_texts
assert "±1σ" in md_texts or "σ" in md_texts
assert "boxplot" in md_texts.lower()
@@ -126,7 +141,8 @@ def test_anti_corte_muchas_columnas_pdf_y_pptx():
# 8 numeric columns + long note text: nothing may be cut. Every column
# heading must survive in both the PDF text and the PPTX deck.
ch = build_num_distr(_profile(n_numeric=8), {})
names = [b.text for b in ch.blocks if b.kind == "heading" and b.level == 2]
names = [b.text for b in _flatten(ch.blocks)
if b.kind == "heading" and b.level == 2]
assert len(names) == 8
with tempfile.TemporaryDirectory() as d:
pdf = os.path.join(d, "num.pdf")
@@ -143,6 +159,50 @@ def test_anti_corte_muchas_columnas_pdf_y_pptx():
assert res_pptx["n_slides"] >= 8 # at least one slide per column figure.
def _hist_legend_texts(numeric, box=None):
"""Build the per-column figure and return its histogram-legend label texts."""
from datascience.automatic_eda.chapters.num_distr import _make_hist_box
import matplotlib.pyplot as plt
fig = _make_hist_box("col", numeric, box or {})
ax_h = fig.axes[0] # the histogram is the top axis.
leg = ax_h.get_legend()
texts = [t.get_text() for t in leg.get_texts()] if leg else []
plt.close(fig)
return texts
def test_golden_leyenda_histograma_reporta_valor_std():
# The histogram legend must report the numeric value of the standard
# deviation σ next to mean and median.
numeric = _numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5)
texts = _hist_legend_texts(numeric)
joined = " ".join(texts)
assert any("σ =" in t for t in texts), f"σ value missing in legend: {texts}"
assert "12.3" in joined, f"std value 12.3 not in legend: {texts}"
assert any("media =" in t for t in texts)
assert any("mediana =" in t for t in texts)
def test_edge_std_en_leyenda_aunque_no_haya_banda():
# When the ±1σ band cannot be drawn (no mean) but σ is known, the legend
# still surfaces the σ value via a label-only proxy handle.
numeric = _numeric_block(42.5, 40.0, 7.5, 1.0, 100.0, "right-skewed", 0)
numeric["mean"] = None # forces the band off; σ must still appear.
texts = _hist_legend_texts(numeric)
assert any("σ = 7.5" in t for t in texts), f"σ proxy missing: {texts}"
def test_edge_sin_std_no_revienta_la_figura():
# A numeric block without σ must not raise and simply omits the σ entry.
import matplotlib.pyplot as plt
numeric = _numeric_block(42.5, 40.0, 0.0, 1.0, 100.0, "discrete", 0)
numeric["std"] = None
texts = _hist_legend_texts(numeric)
assert not any("σ =" in t for t in texts)
# mean/median lines still produce their own legend entries.
assert any("media =" in t for t in texts)
def test_distribution_gloss_cubre_todas_las_etiquetas():
# Every label detect_distribution_type can emit has a Spanish gloss.
for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
@@ -20,7 +20,7 @@ from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "overview"
CHAPTER_TITLE = "Overview"
@@ -90,8 +90,14 @@ def _head_block(profile: dict, ctx: dict):
if not cols:
cols = list(head[0].keys())
rows = [[model._safe_str(r.get(c)) for c in cols] for r in head[:10]]
return model.DataTable(header=cols, rows=rows,
note=f"primeras {len(rows)} filas")
# Honest note: how many rows are shown and, when known, out of how many
# rows the dataset has (so "primeras 10 filas de 891" gives context).
note = f"primeras {len(rows)} filas"
n_rows = profile.get("n_rows")
if isinstance(n_rows, int) and not isinstance(n_rows, bool) \
and n_rows > len(rows):
note += f" de {n_rows:,}".replace(",", ".")
return model.DataTable(header=cols, rows=rows, note=note)
return model.Note(
"df.head no disponible: el TableProfile no incluye 'head_rows'. La fase "
"de cálculo debe añadir profile['head_rows'] (lista de dicts fila) o "
@@ -0,0 +1,187 @@
"""Tests for the OVERVIEW chapter — DoD: golden + edges + degradation.
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that ``build_overview`` renders the raw first rows
(``df.head``) as a DataTable when ``head_rows`` is present both when it arrives
via ``profile['head_rows']`` (populated by ``profile_table``) and via
``ctx['head_rows']`` (populated by ``build_eda_render_ctx``) that the chapter
also renders the column dictionary and the numeric describe, that the full
document renders to PDF and PPTX showing the head values, and that a profile with
NO head data degrades to an honest note instead of raising or inventing rows.
"""
import os
import re
import tempfile
from pypdf import PdfReader
from pptx import Presentation
from datascience.automatic_eda.model import DataTable, Note
from datascience.automatic_eda.chapters.overview import (
CHAPTER_ID, CHAPTER_VERSION, build_overview,
)
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
def _columns() -> list:
return [
{"name": "PassengerId", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 2.0, "median": 2.0, "min": 1.0,
"max": 3.0, "std": 1.0}},
{"name": "Survived", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 0.33, "median": 0.0, "min": 0.0,
"max": 1.0, "std": 0.58}},
{"name": "Pclass", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 2.33, "median": 3.0, "min": 1.0,
"max": 3.0, "std": 1.15}},
{"name": "Name", "inferred_type": "categorical", "null_pct": 0.0,
"null_count": 0, "distinct_count": 3},
{"name": "Sex", "inferred_type": "categorical", "null_pct": 0.0,
"null_count": 0, "distinct_count": 2,
"categorical": {"top": [{"value": "male", "count": 2},
{"value": "female", "count": 1}]}},
]
def _head_rows() -> list:
return [
{"PassengerId": 1, "Survived": 0, "Pclass": 3,
"Name": "Braund Owen", "Sex": "male"},
{"PassengerId": 2, "Survived": 1, "Pclass": 1,
"Name": "Cumings Florence", "Sex": "female"},
{"PassengerId": 3, "Survived": 1, "Pclass": 3,
"Name": "Heikkinen Laina", "Sex": "female"},
]
def _profile(with_head: bool = True) -> dict:
prof = {
"table": "titanic",
"source": "/data/titanic.csv",
"profiled_at": "2026-06-30T10:00:00+00:00",
"n_rows": 891,
"n_cols": 5,
"quality_score": 88.0,
"columns": _columns(),
}
if with_head:
prof["head_rows"] = _head_rows()
return prof
def _pdf_text(path: str) -> str:
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
return re.sub(r"\s+", " ", txt)
def _pptx_text(path: str) -> str:
prs = Presentation(path)
parts = []
for sl in prs.slides:
for sh in sl.shapes:
if sh.has_text_frame:
parts.append(sh.text_frame.text)
if sh.has_table:
tb = sh.table
for r in range(len(tb.rows)):
for c in range(len(tb.columns)):
parts.append(tb.cell(r, c).text)
return re.sub(r"\s+", " ", " ".join(parts))
def _flatten(blocks):
"""Recursively flatten Group blocks into a flat list (none here today)."""
out = []
for b in blocks:
inner = getattr(b, "blocks", None)
if inner is not None and getattr(b, "kind", None) == "group":
out.extend(_flatten(inner))
else:
out.append(b)
return out
def test_golden_build_overview_muestra_head_desde_profile():
ch = build_overview(_profile(), {})
assert ch is not None
assert ch.id == CHAPTER_ID
assert ch.version == CHAPTER_VERSION
blocks = _flatten(ch.blocks)
# The first DataTable is df.head: its header is the column names and the
# real first rows are present (not a placeholder note).
tables = [b for b in blocks if isinstance(b, DataTable)]
assert tables, "overview must emit at least the df.head DataTable"
head_tbl = tables[0]
assert head_tbl.header == ["PassengerId", "Survived", "Pclass",
"Name", "Sex"]
assert len(head_tbl.rows) == 3
flat = [str(c) for row in head_tbl.rows for c in row]
assert "Braund Owen" in flat and "Cumings Florence" in flat
# Honest note carries how many rows shown out of the dataset total.
assert head_tbl.note is not None
assert "primeras 3 filas" in head_tbl.note and "891" in head_tbl.note
# No "df.head no disponible" placeholder when head_rows is present.
assert not any(isinstance(b, Note) and "no disponible" in b.text
for b in blocks)
def test_golden_head_desde_ctx_tambien_funciona():
# head_rows absent in profile but present in ctx (build_eda_render_ctx path).
prof = _profile(with_head=False)
ch = build_overview(prof, {"head_rows": _head_rows()})
assert ch is not None
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
flat = [str(c) for row in tables[0].rows for c in row]
assert "Braund Owen" in flat
def test_golden_render_pdf_muestra_head():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pdf")
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pdf_text(out)
assert "Braund" in txt and "male" in txt
assert "primeras" in txt # head note rendered.
assert "df.head" in txt # chapter heading rendered.
assert "no disponible" not in txt # placeholder NOT shown.
def test_golden_render_pptx_muestra_head():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pptx")
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pptx_text(out)
assert "Braund" in txt and "Cumings" in txt
def test_edge_sin_head_rows_degrada_a_nota_honesta():
# No head data anywhere: chapter still builds (columns exist), shows the
# honest placeholder note, and never invents rows nor raises.
prof = _profile(with_head=False)
ch = build_overview(prof, {})
assert ch is not None
blocks = _flatten(ch.blocks)
assert any(isinstance(b, Note) and "no disponible" in b.text
for b in blocks)
# The first DataTable now is the column dictionary, not df.head rows.
tables = [b for b in blocks if isinstance(b, DataTable)]
assert all("Braund" not in str(c)
for tbl in tables for row in tbl.rows for c in row)
def test_edge_none_y_vacio_no_rompen():
# Nothing to render at all -> None, no raise.
assert build_overview(None, None) is None
assert build_overview({}, {}) is None
assert build_overview({"columns": []}, {}) is None
# Only head_rows (no columns) still yields a chapter with the head table.
ch = build_overview({"columns": []}, {"head_rows": _head_rows()})
assert ch is not None
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
assert tables and len(tables[0].rows) == 3
@@ -2,8 +2,17 @@
Builds the document cover from a TableProfile plus an optional ``ctx`` of
presentation metadata. Reads everything defensively (``.get``) and degrades
honestly: a field that is neither in the profile nor in ``ctx`` is shown as a
placeholder rather than invented, leaving a hook for the LLM layer to fill it.
honestly.
The dataset size (N rows x M columns) is always shown big, as a heading right
under the dataset name (kept together in a ``Group``), not buried in the
metadata table. The Description and Granularity are resolved through a cascade
so they are never empty: an explicit ``ctx`` value wins; otherwise the LLM block
(``profile['llm']`` from ``eda_llm_insights``) provides ``summary`` /
``row_meaning``; otherwise a short summary is derived from the profile itself
(shape, column-type mix, quality score) and a "Cada fila es…" sentence from the
key-candidate columns or the table shape. Nothing is invented: the derived
fallbacks state that they come from the profile.
Contract for chapter authors (see ``docs/capabilities/automatic_eda.md``):
build_<id>(profile: dict, ctx: dict) -> Chapter | None
@@ -17,10 +26,15 @@ from datetime import datetime, timezone
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.2.0"
CHAPTER_ID = "portada"
CHAPTER_TITLE = "Portada"
# Key under which eda_llm_insights stores its interpretive block in the profile.
# The cover reads ``summary`` (what the table is) and ``row_meaning`` (what one
# row represents) from it when the LLM layer ran (``run_llm``).
_LLM_KEY = "llm"
# Default human description of what the table quality score measures. Chapters
# can override it via ctx["quality_criteria"].
_DEFAULT_QUALITY_CRITERIA = (
@@ -67,6 +81,53 @@ def _fmt_int(v) -> str:
return str(v)
def _fmt_pct(value) -> str:
"""Format a percentage that may arrive as a 01 fraction or a 0100 number."""
if value is None:
return ""
try:
v = float(value)
except (TypeError, ValueError):
return str(value)
if 0 < v <= 1.0:
v *= 100.0
return f"{v:.1f}%"
def _summary_blocks(summary) -> list:
"""Mini-summary of the rest of the analysis, shown on the cover (mejora 5).
The cover is built AFTER the body (``build_document`` passes the aggregated
``ctx['document_summary']``), so it can reflect what the analysis found:
shape, column types, quality flags and which chapters were included. Returns
an empty list when there is no summary (the cover degrades to its metadata
table only)."""
if not isinstance(summary, dict) or not summary:
return []
rows = []
n_num = summary.get("n_numeric")
n_cat = summary.get("n_categorical")
if n_num is not None or n_cat is not None:
rows.append(("Columnas numéricas / categóricas",
f"{_fmt_int(n_num)} / {_fmt_int(n_cat)}"))
if summary.get("duplicate_pct") is not None:
rows.append(("Filas duplicadas", _fmt_pct(summary.get("duplicate_pct"))))
if summary.get("null_cell_pct") is not None:
rows.append(("Celdas nulas", _fmt_pct(summary.get("null_cell_pct"))))
titles = summary.get("chapter_titles") or []
if titles:
rows.append(("Capítulos del informe", _fmt_int(len(titles))))
blocks = [model.Heading(text="Resumen del análisis", level=2)]
if rows:
blocks.append(model.KVTable(rows=rows))
if titles:
bullets = "\n".join(f"- {model._safe_str(t)}" for t in titles)
blocks.append(model.Markdown(
text="Este informe incluye los siguientes capítulos:\n" + bullets))
return blocks
def _fmt_date_eu(value) -> str:
"""Format a date/ISO string as European DD/MM/AAAA HH:mm (UI convention).
@@ -95,6 +156,88 @@ def _fmt_date_eu(value) -> str:
return s
def _llm_block(profile: dict, ctx: dict) -> dict:
"""Return the interpretive LLM block (``eda_llm_insights`` output), or {}.
It is stored under ``profile['llm']`` by ``profile_table(run_llm=True)`` and
may also be forwarded in ``ctx['llm']``. Read defensively: anything that is
not a dict degrades to an empty dict so the cover never raises.
"""
block = profile.get(_LLM_KEY)
if not isinstance(block, dict):
block = ctx.get(_LLM_KEY)
return block if isinstance(block, dict) else {}
def _count_column_types(profile: dict, ctx: dict):
"""Best-effort (n_numeric, n_categorical) for the dataset.
Prefers the aggregated ``ctx['document_summary']`` (computed by the engine
over the whole body); falls back to counting the profile columns directly so
the cover still has the numbers when no summary was passed.
"""
summary = ctx.get("document_summary")
if isinstance(summary, dict):
n_num = summary.get("n_numeric")
n_cat = summary.get("n_categorical")
if n_num is not None or n_cat is not None:
return n_num, n_cat
cols = profile.get("columns") or []
n_num = sum(1 for c in cols if isinstance(c, dict)
and c.get("inferred_type") == "numeric")
n_cat = sum(1 for c in cols if isinstance(c, dict)
and isinstance(c.get("categorical"), dict)
and c.get("categorical", {}).get("top")
and c.get("inferred_type") != "numeric")
return n_num, n_cat
def _derive_description(profile: dict, ctx: dict) -> str:
"""A short, honest description of the dataset from the profile.
Used only when no explicit ``ctx['description']`` and no LLM ``summary`` are
available. Summarizes shape, column-type mix and quality score; never empty,
never invents business meaning (it states the description was derived)."""
n_rows = profile.get("n_rows")
n_cols = profile.get("n_cols")
n_num, n_cat = _count_column_types(profile, ctx)
head = f"Conjunto de datos con {_fmt_int(n_rows)} filas y {_fmt_int(n_cols)} columnas"
type_bits = []
if n_num:
type_bits.append(f"{_fmt_int(n_num)} numéricas")
if n_cat:
type_bits.append(f"{_fmt_int(n_cat)} categóricas")
if type_bits:
head += " (" + ", ".join(type_bits) + ")"
parts = [head + "."]
score = profile.get("quality_score")
if score is not None:
parts.append(f"Calidad media estimada: {score}/100.")
parts.append(
"Resumen derivado del perfil; active la interpretación LLM (`run_llm`) "
"para una descripción de negocio más rica.")
return " ".join(parts)
def _derive_granularity(profile: dict, dataset_name: str) -> str:
"""A ``Cada fila es…`` granularity sentence from the profile.
Prefers the key-candidate columns (a row is identified by them); when no key
is detected, falls back to the table shape so the line is always meaningful
and starts with ``Cada fila es`` as the user requested."""
keys = profile.get("key_candidates") or []
if keys:
shown = ", ".join(str(k) for k in keys[:3])
more = "" if len(keys) <= 3 else f" (y {len(keys) - 3} más)"
return (f"Cada fila es un registro identificado por {shown}{more}, "
"candidata(s) a clave por ser únicas y sin nulos.")
n_rows = profile.get("n_rows")
tail = f" El dataset tiene {_fmt_int(n_rows)} filas en total." if n_rows else ""
return (f"Cada fila es un registro de «{dataset_name}». No se detectó una "
"columna identificadora única, así que la granularidad se infiere "
"de la forma de la tabla." + tail)
def build_portada(profile: dict, ctx: dict):
"""Build the cover Chapter, or None if there is truly nothing to show."""
profile = profile or {}
@@ -119,30 +262,38 @@ def build_portada(profile: dict, ctx: dict):
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
quality_value = "" if score is None else f"{score} / 100"
# Granularity: ctx wins; else derive from key candidates; else be honest.
llm = _llm_block(profile, ctx)
# Granularity: explicit ctx wins; then the LLM "row_meaning"; then the key
# candidates; finally a shape-based fallback. Always a real "Cada fila es…".
granularity = ctx.get("granularity")
if not granularity:
keys = profile.get("key_candidates") or []
if keys:
granularity = ("Cada fila parece identificada por "
+ ", ".join(str(k) for k in keys[:3]) + ".")
else:
granularity = ("Cada fila es… (granularidad no determinada — "
"pendiente de la capa de cálculo/LLM).")
granularity = (llm.get("row_meaning") or "").strip() or None
if not granularity:
granularity = _derive_granularity(profile, str(dataset_name))
# Description: explicit ctx wins; then the LLM "summary"; finally a short
# profile-derived summary. Never the old empty placeholder.
description = ctx.get("description")
if not description:
description = ("Descripción no provista — pendiente de la capa LLM "
"(`run_llm`) o de `ctx['description']`.")
description = (llm.get("summary") or "").strip() or None
if not description:
description = _derive_description(profile, ctx)
blocks = [
# Title + dataset size shown together and BIG (Heading) at the top, kept on
# the same page (Group). The size is no longer buried in the metadata table.
cover = [
model.Heading(text=str(dataset_name), level=1),
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
model.Heading(text=shape, level=2),
]
blocks = [
model.Group(blocks=cover),
model.KVTable(rows=[
("Fuente", source_origin),
("Almacenamiento", storage),
("Generado", when),
("Tamaño", shape),
("Calidad", quality_value),
("Criterios de calidad", quality_criteria),
]),
@@ -152,5 +303,8 @@ def build_portada(profile: dict, ctx: dict):
model.Markdown(text=str(granularity)),
]
# Mini-summary of the rest of the analysis (built last, shown on the cover).
blocks.extend(_summary_blocks(ctx.get("document_summary")))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -0,0 +1,197 @@
"""Tests for the PORTADA (cover) chapter — DoD: golden + edges + render.
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies the Fase 4b improvements:
1. The dataset size (N rows x M columns) is always shown BIG as a level-2
heading kept together with the dataset name in a ``Group`` and is no longer
a row of the metadata table.
2. Description and Granularity are resolved through a real cascade and are never
the old empty placeholders: an explicit ``ctx`` value wins; otherwise the LLM
block (``profile['llm']``) provides ``summary`` / ``row_meaning``; otherwise a
short summary is derived from the profile and a "Cada fila es…" sentence from
the key-candidate columns or the table shape.
3. The chapter degrades without raising on empty/None input.
4. It renders inside the full document to both PDF and PPTX showing that content.
"""
import os
import re
import tempfile
from pypdf import PdfReader
from pptx import Presentation
from datascience.automatic_eda.model import Group, Heading, KVTable, Markdown
from datascience.automatic_eda.chapters.portada import (
CHAPTER_ID, CHAPTER_VERSION, build_portada,
)
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
def _profile(with_llm: bool = True, with_keys: bool = True) -> dict:
prof = {
"table": "titanic",
"source": "/data/titanic.csv",
"profiled_at": "2026-06-30T10:00:00+00:00",
"n_rows": 891,
"n_cols": 12,
"quality_score": 78.0,
"columns": [
{"name": "PassengerId", "inferred_type": "numeric",
"null_pct": 0.0, "numeric": {"mean": 446.0, "min": 1.0,
"max": 891.0, "std": 257.0}},
{"name": "Survived", "inferred_type": "numeric",
"null_pct": 0.0, "numeric": {"mean": 0.38, "min": 0.0,
"max": 1.0, "std": 0.49}},
{"name": "Sex", "inferred_type": "categorical", "null_pct": 0.0,
"categorical": {"top": [{"value": "male", "count": 577, "pct": 0.65},
{"value": "female", "count": 314,
"pct": 0.35}],
"mode": "male", "n_distinct": 2, "entropy": 0.93}},
],
}
if with_keys:
prof["key_candidates"] = ["PassengerId"]
if with_llm:
prof["llm"] = {
"summary": "Pasajeros del Titanic con su supervivencia y datos de viaje.",
"row_meaning": "Cada fila es un pasajero del Titanic.",
"dictionary": [], "pii": [], "cleaning": [], "analyses": [],
}
return prof
def _pdf_text(path: str) -> str:
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
return re.sub(r"\s+", " ", txt)
def _pptx_text(path: str) -> str:
prs = Presentation(path)
parts = []
for sl in prs.slides:
for sh in sl.shapes:
if sh.has_text_frame:
parts.append(sh.text_frame.text)
if sh.has_table:
tb = sh.table
for r in range(len(tb.rows)):
for c in range(len(tb.columns)):
parts.append(tb.cell(r, c).text)
return re.sub(r"\s+", " ", " ".join(parts))
def _markdown_after(blocks, heading_text):
"""Return the Markdown block that follows a Heading whose text matches."""
for i, b in enumerate(blocks):
if isinstance(b, Heading) and heading_text.lower() in b.text.lower():
for nb in blocks[i + 1:]:
if isinstance(nb, Markdown):
return nb
return None
def test_golden_tamano_grande_y_textos_llm():
ch = build_portada(_profile(), {})
assert ch is not None
assert ch.id == CHAPTER_ID
assert ch.version == CHAPTER_VERSION
# 1) Title + size kept together in a Group; size is a BIG level-2 heading.
group = next(b for b in ch.blocks if isinstance(b, Group))
inner = group.blocks
assert isinstance(inner[0], Heading) and inner[0].level == 1
assert inner[0].text == "titanic"
size_h = next(b for b in inner if isinstance(b, Heading) and b.level == 2)
assert "891" in size_h.text and "12" in size_h.text
assert "filas" in size_h.text and "columnas" in size_h.text
# 2) Size is no longer a row of the metadata table.
kv = next(b for b in ch.blocks if isinstance(b, KVTable))
labels = [r[0] for r in kv.rows]
assert "Tamaño" not in labels
assert "Fuente" in labels and "Calidad" in labels
# 3) Description and Granularity come from the LLM block.
desc = _markdown_after(ch.blocks, "Descripción")
gran = _markdown_after(ch.blocks, "Granularidad")
assert desc is not None and "Titanic" in desc.text
assert gran is not None and gran.text.startswith("Cada fila es")
assert "pasajero" in gran.text.lower()
def test_fallback_sin_llm_usa_keys_y_perfil():
# No LLM block: description derived from the profile, granularity from keys.
ch = build_portada(_profile(with_llm=False, with_keys=True), {})
desc = _markdown_after(ch.blocks, "Descripción")
gran = _markdown_after(ch.blocks, "Granularidad")
# Description is the derived summary, never the old "pendiente" placeholder.
assert "pendiente" not in desc.text.lower()
assert "891" in desc.text and "columnas" in desc.text
assert "numéricas" in desc.text or "categóricas" in desc.text
# Granularity mentions the key candidate and starts with "Cada fila es".
assert gran.text.startswith("Cada fila es")
assert "PassengerId" in gran.text
assert "" not in gran.text # the old ellipsis placeholder is gone.
def test_fallback_sin_llm_sin_keys_usa_forma():
ch = build_portada(_profile(with_llm=False, with_keys=False), {})
gran = _markdown_after(ch.blocks, "Granularidad")
assert gran.text.startswith("Cada fila es")
assert "titanic" in gran.text.lower()
assert "pendiente" not in gran.text.lower()
def test_ctx_explicito_gana_sobre_llm():
ctx = {"description": "Descripción manual.",
"granularity": "Cada fila es una unidad manual."}
ch = build_portada(_profile(), ctx)
desc = _markdown_after(ch.blocks, "Descripción")
gran = _markdown_after(ch.blocks, "Granularidad")
assert desc.text == "Descripción manual."
assert gran.text == "Cada fila es una unidad manual."
def test_edge_perfil_vacio_no_lanza():
# Empty / None never raise; the cover still shows a size and real texts.
for prof, ctx in (({}, {}), (None, None)):
ch = build_portada(prof, ctx)
assert ch is not None
group = next(b for b in ch.blocks if isinstance(b, Group))
size_h = next(b for b in group.blocks
if isinstance(b, Heading) and b.level == 2)
assert "filas" in size_h.text and "columnas" in size_h.text
desc = _markdown_after(ch.blocks, "Descripción")
gran = _markdown_after(ch.blocks, "Granularidad")
assert desc.text and "pendiente" not in desc.text.lower()
assert gran.text.startswith("Cada fila es")
def test_golden_render_pdf_muestra_portada():
prof = _profile()
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pdf")
res = render_automatic_eda_pdf(prof, out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pdf_text(out)
assert "titanic" in txt.lower()
assert "891" in txt and "filas" in txt and "columnas" in txt
assert "Titanic" in txt # LLM summary in the Description.
assert "Cada fila es" in txt # granularity sentence.
def test_golden_render_pptx_muestra_portada():
prof = _profile()
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pptx")
res = render_automatic_eda_pptx(prof, out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pptx_text(out)
assert "titanic" in txt.lower()
assert "891" in txt and "columnas" in txt
assert "Cada fila es" in txt
@@ -0,0 +1,500 @@
"""Key-relations chapter (RELACIONES) — the keys / join structure of the data.
This chapter is the *relational* section of an AutomaticEDA report. It answers a
single question for the table (or the whole DuckDB source it lives in): **how do
the keys relate?** It composes, without reimplementing them, the registry's
relation primitives and degrades honestly when a layer does not apply.
It renders, in order, only the layers that have something to say:
1. **Declared keys** (real schema constraints) when the DuckDB source declares
PRIMARY KEY / FOREIGN KEY / UNIQUE constraints, they are read verbatim via
``detect_declared_keys_duckdb`` and shown as ground truth: which column is the
PK, which columns are FKs and the table/column they point to.
2. **Primary-key candidates** the ``key_candidates`` the TableProfile already
carries (columns whose cardinality equals the row count, with no nulls). These
are *candidates*: a column that could serve as the row identifier.
3. **Foreign-key candidates** when none are declared:
- **Inter-table** (the DuckDB source has several tables): real FK candidates by
name signal + value containment via ``infer_fk_containment_duckdb``, plus the
join graph (roles + a pasteable Mermaid diagram) via ``build_join_graph``.
- **Intra-table** (a single table): columns that *look* like a foreign key by a
name+cardinality heuristic (``suggest_intratable_fk_candidates``). This is a
**suggestion**, explicitly flagged as a heuristic, never an assertion.
``build_relaciones(profile, ctx) -> Chapter | None``: returns ``None`` when there
is nothing to say (no declared key, no key candidates, and no FK candidate
inter- or intra-table). Reads everything defensively (``.get``) and never raises:
anything missing degrades to a note or is omitted; a failing registry call drops
its layer instead of aborting the chapter.
ctx keys this chapter consumes (all optional):
db_path, table : str the DuckDB file and table being profiled (set by
``build_eda_render_ctx``). ``db_path`` is needed to read declared
constraints, to list the sibling tables, and to run the containment-based
FK inference. Without it, only the profile-derived layers (PK candidates,
intra-table FK heuristic) are available.
glossary : model.GlossaryCollector shared glossary; the chapter registers
the relational terms (PK, FK, containment, cardinality) and marks their
first appearance clickable.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
# Pure/impure registry functions (group ``eda``) this chapter composes. Imported
# defensively (module-leaf imports, like the AGREGACION chapter) so the chapter
# still builds — degrading the affected layer to nothing — if a function is
# somehow unavailable / not indexed yet.
try:
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
detect_declared_keys_duckdb = None # type: ignore[assignment]
try:
from datascience.infer_fk_containment_duckdb import infer_fk_containment_duckdb
except Exception: # noqa: BLE001
infer_fk_containment_duckdb = None # type: ignore[assignment]
try:
from datascience.build_join_graph import build_join_graph
except Exception: # noqa: BLE001
build_join_graph = None # type: ignore[assignment]
try:
from datascience.suggest_intratable_fk_candidates import (
suggest_intratable_fk_candidates,
)
except Exception: # noqa: BLE001
suggest_intratable_fk_candidates = None # type: ignore[assignment]
try:
from infra import duckdb_list_tables
except Exception: # noqa: BLE001
duckdb_list_tables = None # type: ignore[assignment]
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "relaciones"
CHAPTER_TITLE = "Relaciones de clave"
# Cap the inter-table FK table so a wide schema does not blow up the page; the
# rest is summarized in a closing note (no silent truncation).
MAX_FK_ROWS = 40
# --------------------------------------------------------------------------- #
# Glossary terms this chapter explains. Registered in the shared collector and
# marked clickable on their first appearance (contract §11.1).
# --------------------------------------------------------------------------- #
_TERMS = {
"pk": (
"Clave primaria (PK)",
"Columna (o conjunto de columnas) que identifica de forma única cada fila "
"de una tabla: sus valores no se repiten y no son nulos. Una tabla tiene "
"como mucho una clave primaria; es el ancla por la que otras tablas la "
"referencian.",
),
"fk": (
"Clave foránea (FK)",
"Columna de una tabla cuyos valores apuntan a la clave primaria de otra "
"tabla (o de la misma), creando una relación entre ambas. Una FK suele ser "
"N:1: muchas filas de la tabla origen comparten el mismo valor de la tabla "
"destino.",
),
"containment": (
"Containment / inclusión",
"Señal con la que se infiere una clave foránea sin que la base la declare: "
"la fracción de valores distintos de una columna A que también aparecen "
"como valores de otra columna B. Si casi todos los valores de A están "
"contenidos en B (inclusión ≈ 1) y B parece una clave, A → B es una FK "
"candidata.",
),
"cardinalidad": (
"Cardinalidad",
"Número de valores distintos de una columna. Cardinalidad igual al número "
"de filas (y sin nulos) señala un identificador (candidato a clave "
"primaria); cardinalidad alta pero menor que el número de filas, con "
"valores repetidos, es típica de una clave foránea.",
),
}
def _register_terms(ctx: dict) -> bool:
"""Register the relational terms in the shared glossary. Returns whether the
in-text appearances should be marked clickable."""
glossary = ctx.get("glossary")
if not isinstance(glossary, model.GlossaryCollector):
return False
for key, (label, definition) in _TERMS.items():
glossary.add(key, label, definition)
return True
# --------------------------------------------------------------------------- #
# Formatting helpers (mirror the other chapters' defensive style).
# --------------------------------------------------------------------------- #
def _fmt_int(value) -> str:
if value is None:
return ""
try:
return f"{int(value):,}".replace(",", ".")
except (TypeError, ValueError):
return model._safe_str(value)
def _fmt_pct_fraction(value, decimals: int = 1) -> str:
"""Format a 01 fraction as a percentage. None -> placeholder."""
if value is None:
return ""
try:
v = float(value)
except (TypeError, ValueError):
return model._safe_str(value)
if v <= 1.0:
v *= 100.0
return f"{v:.{decimals}f}%"
def _fmt_ratio(value, decimals: int = 3) -> str:
"""Format an already-01 ratio (inclusion) as a plain number."""
if value is None:
return ""
try:
return f"{float(value):.{decimals}f}".rstrip("0").rstrip(".")
except (TypeError, ValueError):
return model._safe_str(value)
def _is_dict(v) -> bool:
return isinstance(v, dict)
def _columns_by_name(profile: dict) -> dict:
"""Index the profile columns by name for quick metric lookup."""
out = {}
for col in (profile.get("columns") or []):
if _is_dict(col) and col.get("name") is not None:
out[col.get("name")] = col
return out
# --------------------------------------------------------------------------- #
# Layer 1 — declared keys (real schema constraints).
# --------------------------------------------------------------------------- #
def _declared_keys(db_path: str, table: str):
"""Read declared PK/FK/UNIQUE for the source, or None if unavailable."""
if not db_path or detect_declared_keys_duckdb is None:
return None
try:
out = detect_declared_keys_duckdb(db_path, table)
except Exception: # noqa: BLE001 — dict-no-throw: treat as unavailable.
return None
if not _is_dict(out) or out.get("status") != "ok":
return None
return out
def _declared_section(declared: dict) -> list:
"""Blocks for the declared-keys layer, or [] if there is nothing declared."""
pks = [p for p in (declared.get("primary_keys") or []) if _is_dict(p)]
fks = [f for f in (declared.get("foreign_keys") or []) if _is_dict(f)]
uqs = [u for u in (declared.get("unique") or []) if _is_dict(u)]
if not (pks or fks or uqs):
return []
blocks = [
model.Heading(text="Claves declaradas en el esquema", level=2),
model.Markdown(text=(
"La base **declara** estas relaciones de clave como restricciones "
"reales del esquema (constraints). Son la verdad de referencia: no se "
"infieren, se leen tal cual de la definición de las tablas.")),
]
if pks:
rows = [[model._safe_str(p.get("table")),
", ".join(model._safe_str(c) for c in (p.get("columns") or []))]
for p in pks]
blocks.append(model.DataTable(
header=["Tabla", "Columna(s) PK"], rows=rows,
title="Claves primarias declaradas",
note="Cada fila: la clave primaria declarada de una tabla."))
if fks:
rows = []
for f in fks:
src = ", ".join(model._safe_str(c) for c in (f.get("columns") or []))
dst = ", ".join(
model._safe_str(c) for c in (f.get("referenced_columns") or []))
rows.append([
model._safe_str(f.get("table")), src,
model._safe_str(f.get("referenced_table")), dst])
blocks.append(model.DataTable(
header=["Tabla origen", "Columna(s) FK", "→ Tabla destino",
"Columna(s) destino"],
rows=rows, title="Claves foráneas declaradas",
note="Cada fila: una FK declarada — origen → destino."))
if uqs:
rows = [[model._safe_str(u.get("table")),
", ".join(model._safe_str(c) for c in (u.get("columns") or []))]
for u in uqs]
blocks.append(model.DataTable(
header=["Tabla", "Columna(s) UNIQUE"], rows=rows,
title="Restricciones UNIQUE declaradas"))
return blocks
# --------------------------------------------------------------------------- #
# Layer 2 — primary-key candidates (from the profile).
# --------------------------------------------------------------------------- #
def _pk_candidates_section(profile: dict, mark: bool) -> list:
"""Blocks for the PK-candidates layer, or [] if there are none."""
keys = [k for k in (profile.get("key_candidates") or []) if k is not None]
if not keys:
return []
by_name = _columns_by_name(profile)
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
else "**clave primaria**")
intro = (
f"Estas columnas son **candidatas a {pk}**: su "
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y no "
"tienen nulos, así que cada valor identifica una fila distinta. Son "
"candidatas, no una clave declarada: la base no las marca como tal."
if mark else
"Estas columnas son **candidatas a clave primaria**: su cardinalidad "
"iguala al número de filas y no tienen nulos, así que cada valor "
"identifica una fila distinta.")
rows = []
for name in keys:
col = by_name.get(name) or {}
rows.append([
model._safe_str(name),
_fmt_int(col.get("distinct_count")),
_fmt_pct_fraction(col.get("unique_pct")),
model._safe_str(col.get("inferred_type") or col.get("physical_type") or ""),
])
return [
model.Heading(text="Candidatos a clave primaria", level=2),
model.Markdown(text=intro),
model.DataTable(
header=["Columna", "Valores distintos", "% único", "Tipo"],
rows=rows, title="Candidatas a clave primaria",
note=f"{_fmt_int(profile.get('n_rows'))} filas en total como referencia."),
]
# --------------------------------------------------------------------------- #
# Layer 3a — inter-table FK candidates (containment) + join graph.
# --------------------------------------------------------------------------- #
def _list_source_tables(db_path: str) -> list:
"""List the tables in the DuckDB source, or [] if it can't be listed."""
if not db_path or duckdb_list_tables is None:
return []
try:
out = duckdb_list_tables(db_path)
except Exception: # noqa: BLE001
return []
if not _is_dict(out) or out.get("status") != "ok":
return []
return [t for t in (out.get("tables") or []) if isinstance(t, str)]
def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
"""Blocks for the inter-table FK layer (containment + join graph), or []."""
if infer_fk_containment_duckdb is None or len(tables) < 2:
return []
try:
fk = infer_fk_containment_duckdb(db_path, tables=tables)
except Exception: # noqa: BLE001
return []
if not _is_dict(fk) or fk.get("status") != "ok":
return []
candidates = [c for c in (fk.get("fk_candidates") or []) if _is_dict(c)]
if not candidates:
return []
containment = ("[[term:containment]]containment (inclusión de valores)[[/term]]"
if mark else "containment (inclusión de valores)")
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
blocks = [
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
model.Markdown(text=(
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se infieren "
f"por señal de nombre y por {containment}: una columna de una tabla cuyos "
"valores están contenidos en la clave de otra. No están declaradas por "
"la base; son la relación más probable según los datos.")),
]
shown = candidates[:MAX_FK_ROWS]
rows = []
for c in shown:
rows.append([
f"{model._safe_str(c.get('from_table'))}.{model._safe_str(c.get('from_col'))}",
f"{model._safe_str(c.get('to_table'))}.{model._safe_str(c.get('to_col'))}",
_fmt_ratio(c.get("inclusion")),
model._safe_str(c.get("cardinality") or ""),
"" if c.get("name_match") else "no",
])
note = "Ordenadas por señal de nombre e inclusión."
if len(candidates) > len(shown):
note += f" Se muestran {len(shown)} de {len(candidates)} candidatas."
blocks.append(model.DataTable(
header=["Origen", "→ Destino", "Inclusión", "Cardinalidad", "Coincide nombre"],
rows=rows, title="FK candidatas por containment", note=note))
# Join graph: node roles + a pasteable Mermaid diagram, kept together.
if build_join_graph is not None:
try:
graph = build_join_graph(candidates, tables=tables)
except Exception: # noqa: BLE001
graph = None
if _is_dict(graph):
graph_blocks = [model.Heading(text="Grafo de relaciones", level=3)]
nodes = [n for n in (graph.get("nodes") or []) if _is_dict(n)]
if nodes:
node_rows = [[
model._safe_str(n.get("table")),
model._safe_str(n.get("role") or ""),
_fmt_int(n.get("out_degree")),
_fmt_int(n.get("in_degree")),
] for n in nodes]
graph_blocks.append(model.DataTable(
header=["Tabla", "Rol", "FK salientes", "FK entrantes"],
rows=node_rows, title="Tablas y su rol en el grafo",
note="Rol: fact (apunta a otras), dimension (referenciada), "
"bridge (ambas), standalone (aislada)."))
hubs = [h for h in (graph.get("hubs") or []) if h]
if hubs:
graph_blocks.append(model.Markdown(text=(
"Tablas con más relaciones salientes (candidatas a tabla de "
"hechos): " + ", ".join(model._safe_str(h) for h in hubs) + ".")))
mermaid = model._safe_str(graph.get("mermaid")).strip()
if mermaid:
graph_blocks.append(model.Markdown(text=(
"Diagrama de las relaciones (pegable en un bloque Mermaid):")))
graph_blocks.append(model.Markdown(
text="```mermaid\n" + mermaid + "\n```"))
if len(graph_blocks) > 1:
blocks.append(model.Group(blocks=graph_blocks,
title="Grafo de relaciones"))
skipped = [s for s in (fk.get("skipped") or []) if s]
if skipped:
blocks.append(model.Note(
"Algunos pares se omitieron por tamaño: "
+ "; ".join(model._safe_str(s) for s in skipped) + "."))
return blocks
# --------------------------------------------------------------------------- #
# Layer 3b — intra-table FK candidates (name+cardinality heuristic).
# --------------------------------------------------------------------------- #
def _intra_table_section(profile: dict, mark: bool) -> list:
"""Blocks for the intra-table FK heuristic layer, or [] if no candidates."""
if suggest_intratable_fk_candidates is None:
return []
try:
cands = suggest_intratable_fk_candidates(profile)
except Exception: # noqa: BLE001
return []
cands = [c for c in (cands or []) if _is_dict(c)]
if not cands:
return []
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
blocks = [
model.Heading(text="Posibles claves foráneas (heurística de nombre)", level=2),
model.Markdown(text=(
f"No hay otras tablas que referenciar, pero algunas columnas **parecen** "
f"{fk_term} por su nombre (terminan en «id») y su cardinalidad (muchos "
"valores repetidos, N:1). Es una **sugerencia heurística**, no una "
"afirmación: el nombre de la tabla destino es una conjetura y no se "
"comprueba inclusión de valores contra ninguna tabla real.")),
]
rows = []
for c in cands:
rows.append([
model._safe_str(c.get("column")),
model._safe_str(c.get("ref_table_guess") or ""),
_fmt_int(c.get("distinct_count")),
_fmt_pct_fraction(c.get("unique_pct")),
model._safe_str(c.get("inferred_type") or c.get("physical_type") or ""),
model._safe_str(c.get("reason") or ""),
])
blocks.append(model.DataTable(
header=["Columna", "Posible tabla", "Valores distintos", "% único",
"Tipo", "Motivo"],
rows=rows, title="Posibles FK por nombre y cardinalidad",
note="Heurística: posibles falsos positivos/negativos. No confirma containment."))
blocks.append(model.Note(
"Estas sugerencias se basan solo en el nombre y la cardinalidad. Para "
"confirmarlas haría falta la tabla destino y comprobar la inclusión de "
"valores (containment)."))
return blocks
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def _intro_blocks(mark: bool) -> list:
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
text = (
f"Este capítulo analiza las **relaciones de clave** de la tabla: qué columna "
f"identifica cada fila (la {pk}) y qué columnas referencian a otra tabla (las "
f"{fk}). Cuando la base las **declara** como restricciones del esquema, se "
"muestran tal cual; cuando no, se proponen las más probables a partir de los "
"datos —por inclusión de valores entre tablas (containment) o, en una sola "
"tabla, por una heurística de nombre y cardinalidad— siempre marcadas como "
"candidatas, nunca como hechos.")
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]
def build_relaciones(profile: dict, ctx: dict):
"""Build the RELACIONES Chapter, or None if there is nothing to say.
Args:
profile: the ``eda`` group TableProfile dict (may be None/empty).
ctx: presentation context. Consumes ``db_path`` + ``table`` (to read
declared constraints, list sibling tables and run the containment FK
inference) and ``glossary`` (to register the relational terms).
Returns:
A ``model.Chapter`` with the applicable relation layers; or ``None`` when
the dataset has no declared key, no key candidates and no FK candidate
(neither inter- nor intra-table).
"""
if not isinstance(profile, dict):
profile = {}
ctx = ctx if isinstance(ctx, dict) else {}
db_path = ctx.get("db_path")
table = ctx.get("table")
mark = _register_terms(ctx)
# Build each layer; the chapter is the concatenation of the non-empty ones.
declared = _declared_keys(db_path, table)
declared_blocks = _declared_section(declared) if declared else []
declared_has_fk = bool(declared and declared.get("foreign_keys"))
pk_blocks = _pk_candidates_section(profile, mark)
tables = _list_source_tables(db_path)
inter_blocks = _inter_table_section(db_path, tables, mark)
# The intra-table heuristic only makes sense when no real FK is available for
# this table — neither declared nor inferred inter-table. Otherwise the real
# relations already answer the question and the heuristic is just noise.
if declared_has_fk or inter_blocks:
intra_blocks = []
else:
intra_blocks = _intra_table_section(profile, mark)
body = declared_blocks + pk_blocks + inter_blocks + intra_blocks
if not body:
return None # chapter does not apply: nothing to say about relations.
blocks = _intro_blocks(mark) + body
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -0,0 +1,273 @@
"""Tests for the RELACIONES chapter — DoD: golden(s) + edges + no-cut render.
Two goldens covering the two real paths of the chapter:
- **Intra-table** (a single table, no db source for relations): the chapter shows
the primary-key candidates from the profile and the heuristic foreign-key
suggestions (name + cardinality), explicitly flagged as a heuristic. Renders to
PDF and PPTX with nothing cut.
- **Inter-table** (a real DuckDB file with two related tables, customers/orders,
with a declared FK): the chapter shows the declared keys, the containment-based
FK candidates and the join graph (roles + a pasteable Mermaid diagram).
Edges: a profile with no key candidate and no FK-looking column returns None;
``None`` / ``{}`` profiles do not raise. The chapter registers its glossary terms.
Layers that depend on the sibling registry functions delegated alongside this
chapter (``detect_declared_keys_duckdb``, ``suggest_intratable_fk_candidates``)
are asserted **conditionally on the function being importable**, so the chapter's
honest-degradation contract is what is tested, never a hard dependency on import
timing.
"""
import os
import tempfile
import duckdb
from pptx import Presentation
from pypdf import PdfReader
from datascience.automatic_eda.chapters.relaciones import build_relaciones
from datascience.automatic_eda.model import Chapter, Group, GlossaryCollector
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
# The optional sibling functions: their layers are asserted only when present.
try:
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
except Exception: # noqa: BLE001
detect_declared_keys_duckdb = None
try:
from datascience.suggest_intratable_fk_candidates import (
suggest_intratable_fk_candidates,
)
except Exception: # noqa: BLE001
suggest_intratable_fk_candidates = None
# --------------------------------------------------------------------------- #
# Helpers.
# --------------------------------------------------------------------------- #
def _flatten(blocks) -> list:
"""Flatten Group blocks so a test can inspect every leaf block."""
out = []
for b in blocks:
if isinstance(b, Group):
out.extend(_flatten(b.blocks))
else:
out.append(b)
return out
def _text_of(chapter: Chapter) -> str:
"""Collect all visible text of a chapter's blocks into one string."""
parts = []
for b in _flatten(chapter.blocks):
for attr in ("text", "title", "note"):
v = getattr(b, attr, None)
if isinstance(v, str):
parts.append(v)
header = getattr(b, "header", None)
if isinstance(header, list):
parts.extend(str(c) for c in header)
rows = getattr(b, "rows", None)
if isinstance(rows, list):
for r in rows:
if isinstance(r, (list, tuple)):
parts.extend(str(c) for c in r)
else:
parts.append(str(r))
return "\n".join(parts)
def _render_both(chapter: Chapter, tag: str):
"""Render the chapter to PDF and PPTX; return (pdf_text, n_slides)."""
tmp = tempfile.mkdtemp(prefix=f"relaciones_{tag}_")
pdf_path = os.path.join(tmp, "out.pdf")
pptx_path = os.path.join(tmp, "out.pptx")
meta = {"title": f"EDA — {tag}"}
render_automatic_eda_pdf([chapter], pdf_path, meta)
render_automatic_eda_pptx([chapter], pptx_path, meta)
assert os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0
assert os.path.exists(pptx_path) and os.path.getsize(pptx_path) > 0
text = "".join(p.extract_text() or "" for p in PdfReader(pdf_path).pages)
n_slides = len(Presentation(pptx_path).slides)
return text, n_slides
# --------------------------------------------------------------------------- #
# Fixtures.
# --------------------------------------------------------------------------- #
def _titanic_profile() -> dict:
"""A single-table profile: a PK candidate + a column that looks like a FK."""
return {
"table": "titanic",
"source": "/data/titanic.csv",
"n_rows": 891,
"n_cols": 4,
"key_candidates": ["PassengerId"],
"columns": [
{"name": "PassengerId", "inferred_type": "numeric",
"physical_type": "BIGINT", "distinct_count": 891,
"unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "ticket_id", "inferred_type": "numeric",
"physical_type": "BIGINT", "distinct_count": 681,
"unique_pct": 0.76, "flags": []},
{"name": "fare", "inferred_type": "numeric",
"physical_type": "DOUBLE", "distinct_count": 248,
"unique_pct": 0.28, "flags": []},
{"name": "sex", "inferred_type": "categorical",
"physical_type": "VARCHAR", "distinct_count": 2,
"unique_pct": 0.002, "flags": []},
],
}
def _make_relational_db(path: str) -> None:
"""Create a small DuckDB with customers(id) <- orders(customer_id), real FK."""
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders(id INTEGER PRIMARY KEY, "
"customer_id INTEGER REFERENCES customers(id), amount DOUBLE)")
con.execute("INSERT INTO customers VALUES "
"(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')")
con.execute("INSERT INTO orders VALUES "
"(1,1,10.0),(2,1,20.0),(3,2,30.0),(4,3,40.0),"
"(5,3,50.0),(6,4,60.0),(7,5,70.0),(8,2,80.0)")
con.close()
def _orders_profile() -> dict:
"""A profile for the `orders` table of the relational DB."""
return {
"table": "orders",
"source": "orders",
"n_rows": 8,
"n_cols": 3,
"key_candidates": ["id"],
"columns": [
{"name": "id", "inferred_type": "numeric", "physical_type": "INTEGER",
"distinct_count": 8, "unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "customer_id", "inferred_type": "numeric",
"physical_type": "INTEGER", "distinct_count": 5, "unique_pct": 0.625,
"flags": []},
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 8, "unique_pct": 1.0, "flags": []},
],
}
# --------------------------------------------------------------------------- #
# Golden 1 — intra-table.
# --------------------------------------------------------------------------- #
def test_golden_intra_table_pk_and_fk_heuristic():
"""Single table: PK candidate shown; FK heuristic shown (if fn available);
renders to PDF + PPTX with nothing cut."""
prof = _titanic_profile()
glossary = GlossaryCollector()
# No db_path: only the profile-derived layers apply (no declared, no inter).
chapter = build_relaciones(prof, {"glossary": glossary})
assert isinstance(chapter, Chapter)
assert chapter.id == "relaciones"
text = _text_of(chapter)
# PK candidate is always present (comes from the profile).
assert "Candidatos a clave primaria" in text
assert "PassengerId" in text
# Glossary terms got registered.
for key in ("pk", "fk", "cardinalidad"):
assert glossary.has(key)
# FK heuristic layer: present iff the delegated function is importable.
if suggest_intratable_fk_candidates is not None:
assert "Posibles claves foráneas" in text
assert "ticket_id" in text
# The float measure and the PK itself are NOT suggested as FKs.
assert "Posibles FK por nombre" in text
pdf_text, n_slides = _render_both(chapter, "intra")
assert "PassengerId" in pdf_text
assert n_slides >= 1
# --------------------------------------------------------------------------- #
# Golden 2 — inter-table (real DuckDB).
# --------------------------------------------------------------------------- #
def test_golden_inter_table_containment_and_join_graph():
"""Two related tables: declared FK (if fn available) + containment FK
candidate + Mermaid join graph."""
tmp = tempfile.mkdtemp(prefix="relaciones_db_")
db_path = os.path.join(tmp, "shop.duckdb")
_make_relational_db(db_path)
prof = _orders_profile()
glossary = GlossaryCollector()
chapter = build_relaciones(
prof, {"db_path": db_path, "table": "orders", "glossary": glossary})
assert isinstance(chapter, Chapter)
text = _text_of(chapter)
# Inter-table containment FK candidate: customer_id -> customers.id. This path
# uses infer_fk_containment_duckdb + build_join_graph, both already in the
# registry, so it must be present.
assert "Claves foráneas candidatas (inter-tabla)" in text
assert "orders.customer_id" in text
assert "customers.id" in text
# Join graph with a pasteable Mermaid diagram.
assert "Grafo de relaciones" in text
assert "mermaid" in text
assert "graph LR" in text
assert "containment" in text.lower()
# Declared-keys layer: present iff the delegated function is importable.
if detect_declared_keys_duckdb is not None:
assert "Claves declaradas en el esquema" in text
assert "Claves foráneas declaradas" in text
pdf_text, n_slides = _render_both(chapter, "inter")
assert "customer_id" in pdf_text
assert n_slides >= 1
# --------------------------------------------------------------------------- #
# Edges.
# --------------------------------------------------------------------------- #
def test_none_when_no_relations():
"""No key candidates, no FK-looking columns, no db source -> None."""
prof = {
"table": "flat", "n_rows": 100, "n_cols": 2, "key_candidates": [],
"columns": [
{"name": "value", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 50, "unique_pct": 0.5, "flags": []},
{"name": "label", "inferred_type": "categorical",
"physical_type": "VARCHAR", "distinct_count": 3, "unique_pct": 0.03,
"flags": []},
],
}
assert build_relaciones(prof, {}) is None
def test_empty_and_none_profile_do_not_raise():
"""None / {} profile and missing ctx degrade to None without raising."""
assert build_relaciones(None, None) is None
assert build_relaciones({}, {}) is None
assert build_relaciones({}, {"glossary": GlossaryCollector()}) is None
def test_pk_candidate_only_builds_chapter():
"""A profile with only a key candidate (no FK anything, no db) still builds:
the relations chapter applies because there is a PK candidate to report."""
prof = {
"table": "t", "n_rows": 10, "n_cols": 1, "key_candidates": ["row_id"],
"columns": [
{"name": "row_id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 10, "unique_pct": 1.0, "flags": ["possible_id"]},
],
}
chapter = build_relaciones(prof, {})
assert isinstance(chapter, Chapter)
assert "Candidatos a clave primaria" in _text_of(chapter)
@@ -26,19 +26,27 @@ from . import model
# placeholders other agents will fill by creating chapters/<id>.py — they will
# appear in this exact position automatically once their module exists.
CHAPTER_ORDER = [
"portada", # cover
"portada", # cover — BUILT LAST, PLACED FIRST (see build_document).
"overview", # df.head + columns/types/nulls/examples + describe
"analisis_llm", # LLM interpretation — sits next to overview (user request)
"num_distr", # numeric distributions
"cat_distr", # categorical distributions
"calidad", # data quality
"correlacion", # correlations / associations
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
"modelos", # cheap models (PCA/KMeans/outliers)
"timeseries", # time-series analysis
"geospatial", # geospatial
"agregacion", # aggregations / pivots
"glosario", # glossary — ALWAYS LAST; clickable term destinations.
]
# Chapters whose position is special-cased by build_document: portada is built
# last (so it can summarize the rest) but placed first; glosario is built and
# placed last (it reads the terms every other chapter registered).
_PORTADA = "portada"
_GLOSARIO = "glosario"
def build_chapter(chapter_id: str, profile: dict, ctx: dict):
"""Build a single chapter by id, or None if absent/not-applicable/error.
@@ -75,15 +83,72 @@ def build_document(profile: dict, ctx: dict = None) -> list:
list[Chapter] in canonical order, containing only the chapters that are
implemented and applicable. Never raises.
"""
if profile is None:
profile = {}
if not isinstance(profile, dict):
profile = {}
if ctx is None:
ctx = {}
chapters = []
# Copy ctx so the shared collector / summary we add do not leak to the caller.
ctx = dict(ctx) if isinstance(ctx, dict) else {}
# A single glossary collector is shared by every chapter via ctx['glossary'].
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
# registered terms and the renderers wire the clickable links.
glossary = ctx.get("glossary")
if not isinstance(glossary, model.GlossaryCollector):
glossary = model.GlossaryCollector()
ctx["glossary"] = glossary
# 1) Body: every chapter except portada (built last) and glosario (placed
# last), in canonical order. This also fills the glossary collector.
body = []
for cid in CHAPTER_ORDER:
if cid in (_PORTADA, _GLOSARIO):
continue
ch = build_chapter(cid, profile, ctx)
if ch is not None and ch.blocks:
chapters.append(ch)
body.append(ch)
# 2) Aggregated summary of the rest, for the cover (user decision: the cover
# is BUILT after the body so it can reflect what the analysis found).
ctx["document_summary"] = _summarize_document(profile, body)
# 3) Build the cover last, place it FIRST.
portada = build_chapter(_PORTADA, profile, ctx)
# 4) Build the glossary last (reads the terms the body registered), place LAST.
glosario = build_chapter(_GLOSARIO, profile, ctx)
chapters = []
if portada is not None and portada.blocks:
chapters.append(portada)
chapters.extend(body)
if glosario is not None and glosario.blocks:
chapters.append(glosario)
return chapters
def _summarize_document(profile: dict, body: list) -> dict:
"""Aggregate a tiny findings summary of the body for the cover. Never raises.
Returns a dict with dataset shape, quality, column-type counts and the list
of chapters actually included enough for the cover to show a mini-summary
of the analysis without re-deriving anything."""
try:
cols = profile.get("columns") or []
n_num = sum(1 for c in cols if isinstance(c, dict)
and c.get("inferred_type") == "numeric")
n_cat = sum(1 for c in cols if isinstance(c, dict)
and isinstance(c.get("categorical"), dict)
and c.get("categorical", {}).get("top")
and c.get("inferred_type") != "numeric")
return {
"n_chapters": len(body),
"chapter_titles": [getattr(c, "title", "") for c in body],
"n_rows": profile.get("n_rows"),
"n_cols": profile.get("n_cols"),
"quality_score": profile.get("quality_score"),
"n_numeric": n_num,
"n_categorical": n_cat,
"duplicate_pct": profile.get("duplicate_pct"),
"null_cell_pct": profile.get("null_cell_pct"),
}
except Exception: # noqa: BLE001 — the summary is best-effort.
return {"n_chapters": len(body) if isinstance(body, list) else 0}
@@ -128,6 +128,39 @@ class Note:
kind: str = field(default="note", init=False)
@dataclass
class Group:
"""A keep-together unit: its blocks render on the SAME page/slide.
Renderers measure the whole group first; if it does not fit in the remaining
space they move it *whole* to the next page (PDF) or slide (PPTX) before
drawing anything so a heading never gets stranded apart from the figure and
text it introduces. If the group is taller than a full page even on its own,
it starts on a fresh page and flows (honest degradation, never cut). Use it to
bind ``Heading`` + ``Markdown`` + ``Figure`` of one idea together (see the
DISTR NUM / AGREGACION chapters).
"""
blocks: list = field(default_factory=list)
title: Optional[str] = None
kind: str = field(default="group", init=False)
@dataclass
class GlossaryEntry:
"""One glossary term: a clickable destination at the end of the document.
Rendered as the term ``label`` (heading) plus its ``definition`` (markdown).
The renderers register its page/slide position as the link target so every
in-text appearance of the same ``key`` becomes a real clickable jump (PDF link
annotation via PyMuPDF; PPTX internal slide jump)."""
key: str = ""
label: str = ""
definition: str = ""
kind: str = field(default="glossary_entry", init=False)
@dataclass
class Chapter:
"""An ordered set of blocks with an id, a title and a generation version."""
@@ -150,13 +183,17 @@ _BLOCK_BY_KIND = {
"image": Image,
"caption": Caption,
"note": Note,
"group": Group,
"glossary_entry": GlossaryEntry,
}
def as_block(obj: Any):
"""Coerce a value into a block dataclass. Unknown values become a Note."""
if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image,
Caption, Note)):
Caption, Note, Group, GlossaryEntry)):
if isinstance(obj, Group):
obj.blocks = as_blocks(obj.blocks)
return obj
if isinstance(obj, dict):
kind = obj.get("kind")
@@ -189,6 +226,13 @@ def as_block(obj: Any):
return Caption(text=_safe_str(obj.get("text")))
if cls is Note:
return Note(text=_safe_str(obj.get("text")))
if cls is Group:
return Group(blocks=as_blocks(obj.get("blocks")),
title=obj.get("title"))
if cls is GlossaryEntry:
return GlossaryEntry(key=_safe_str(obj.get("key")),
label=_safe_str(obj.get("label")),
definition=_safe_str(obj.get("definition")))
except Exception: # noqa: BLE001 — never raise on a malformed block.
return Note(text=_safe_str(obj))
return Note(text=_safe_str(obj))
@@ -246,6 +290,67 @@ def _safe_str(v: Any) -> str:
return ""
# --------------------------------------------------------------------------- #
# Glossary collector — chapters register the terms they use; the glosario
# chapter renders them at the end and the renderers wire the clickable links.
# --------------------------------------------------------------------------- #
class GlossaryCollector:
"""Accumulates glossary terms registered by chapters during document build.
A single instance is created by :func:`build_document` and passed to every
chapter via ``ctx['glossary']``. A chapter calls ``add(key, label,
definition)`` to declare a term it explains (e.g. ``"entropia"``
"Entropía"), and marks each in-text appearance with the inline span
``[[term:key]]texto visible[[/term]]`` (see ``text_layout.parse_inline_rich``).
The ``glosario`` chapter reads ``terms()`` to emit one :class:`GlossaryEntry`
per term; the renderers turn every marked appearance into a real click that
jumps to that entry. First registration of a key wins (idempotent); never
raises."""
def __init__(self):
self._terms: dict = {}
self._order: list = []
def add(self, key: Any, label: Any = None, definition: Any = "") -> str:
"""Register a term and return its normalized key (''. if invalid)."""
try:
k = _safe_str(key).strip()
if not k:
return ""
if k not in self._terms:
self._terms[k] = {
"key": k,
"label": _safe_str(label).strip() or k,
"definition": _safe_str(definition),
}
self._order.append(k)
return k
except Exception: # noqa: BLE001 — collecting a term never breaks a build.
return ""
def has(self, key: Any) -> bool:
return _safe_str(key).strip() in self._terms
def get(self, key: Any) -> Optional[dict]:
return self._terms.get(_safe_str(key).strip())
def terms(self, by: str = "label") -> list:
"""Return the registered terms as dicts.
``by='label'`` (default) sorts alphabetically by visible label;
``by='order'`` keeps first-appearance order."""
if by == "order":
return [self._terms[k] for k in self._order]
return sorted(self._terms.values(),
key=lambda t: _safe_str(t.get("label")).lower())
def __len__(self) -> int:
return len(self._terms)
def __bool__(self) -> bool:
return bool(self._terms)
# --------------------------------------------------------------------------- #
# Manifest — per-chapter versions and page/slide counts for tracking.
# --------------------------------------------------------------------------- #
@@ -0,0 +1,354 @@
"""Tests for the AutomaticEDA engine features added in phase 4a.
Covers, with executable evidence, the six render-engine improvements:
1. Bold no longer overlaps the following text in the PDF (real width measured).
2. Zebra striping on data tables (PDF Rectangle fills + PPTX cell fills).
3. Keep-together: a Group moves whole to the next page/slide (heading never gets
stranded from its figure).
4. Every PPTX figure carries a visible caption/title (fallback to the heading).
5. Cover is built last but placed first and reflects an aggregated summary.
6. Glossary is the last chapter; the term "entropía" is a real clickable link in
the PDF (PyMuPDF GOTO annotation) and in the PPTX (native slide-jump run).
Self-contained: synthetic profiles, no DuckDB. Heavy renderer checks (fitz/pptx)
skip cleanly when the optional engine is missing.
"""
import os
import sys
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
import matplotlib # noqa: E402
matplotlib.use("Agg")
import matplotlib.colors as mcolors # noqa: E402
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.patches import Rectangle # noqa: E402
from datascience.automatic_eda import model # noqa: E402
from datascience.automatic_eda import render_pdf_impl as RP # noqa: E402
from datascience.automatic_eda import render_pptx_impl as RX # noqa: E402
from datascience.automatic_eda import build_document # noqa: E402
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf # noqa: E402
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx # noqa: E402
class _FakePdf:
"""Stand-in for PdfPages so the placers can call _new_page in unit tests."""
def savefig(self, fig): # noqa: D401
pass
def _small_fig():
fig = plt.figure(figsize=(4.0, 1.5))
ax = fig.add_subplot(111)
ax.plot([0, 1, 2], [1, 3, 2])
return fig
def _profile_with_cat_and_num():
"""A tiny profile that triggers cat_distr (→ entropía term) and num_distr."""
return {
"table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
"duplicate_pct": 1.5, "null_cell_pct": 0.8,
"columns": [
{"name": "region", "inferred_type": "categorical",
"categorical": {
"top": [{"value": "norte", "count": 50, "pct": 0.42},
{"value": "sur", "count": 40, "pct": 0.33},
{"value": "este", "count": 30, "pct": 0.25}],
"mode": "norte", "n_distinct": 3, "entropy": 1.55,
"imbalance": 0.1}},
{"name": "importe", "inferred_type": "numeric",
"numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
"min": 10, "max": 99, "iqr": 15,
"histogram": [{"lo": 0, "hi": 50, "count": 40},
{"lo": 50, "hi": 100, "count": 80}]}},
],
}
# --------------------------------------------------------------------------- #
# 1) Bold does not overlap the following text (PDF).
# --------------------------------------------------------------------------- #
def test_pdf_bold_span_does_not_overlap_following_text():
fig = plt.figure(figsize=(RP._W, RP._H))
st = RP._PdfState(_FakePdf(), "t")
st.fig = fig
st.page = 1
# A wide bold token immediately followed by normal text on the SAME line.
rich = [[("PALABRAMUYANCHAENNEGRITA", True, None),
(" texto normal justo después", False, None)]]
RP._place_rich_lines(st, rich, RP._FS_BODY, RP._INK)
renderer = fig.canvas.get_renderer()
boxes = sorted((t.get_window_extent(renderer) for t in fig.texts),
key=lambda b: b.x0)
assert len(boxes) == 2, "se esperaban dos spans dibujados"
# The bold span ends before the normal span starts (no overlap). 1px slack.
assert boxes[0].x1 <= boxes[1].x0 + 1.0, \
"la negrita se solapa con el texto siguiente"
plt.close(fig)
# --------------------------------------------------------------------------- #
# 2) Zebra striping.
# --------------------------------------------------------------------------- #
def _facecolor_eq(artist, hexcolor) -> bool:
want = mcolors.to_rgba(hexcolor)
got = artist.get_facecolor()
return all(abs(a - b) < 0.02 for a, b in zip(got[:3], want[:3]))
def test_pdf_table_has_zebra_striping():
fig = plt.figure(figsize=(RP._W, RP._H))
st = RP._PdfState(_FakePdf(), "t")
st.fig = fig
st.page = 1
st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
dt = model.DataTable(header=["A", "B"],
rows=[["1", "x"], ["2", "y"], ["3", "z"], ["4", "w"]])
RP._place_data_table(st, dt)
zebra = [a for a in fig.findobj(Rectangle) if _facecolor_eq(a, RP._ZEBRA)]
# 4 data rows → even rows (1-based 2 and 4) shaded = 2 zebra rectangles.
assert len(zebra) == 2, f"esperadas 2 filas zebra, hay {len(zebra)}"
plt.close(fig)
def test_pptx_table_has_zebra_striping(tmp_path):
pptx = pytest.importorskip("pptx")
from pptx import Presentation
from pptx.dml.color import RGBColor
doc = [model.Chapter(id="c", title="Tabla", version="1.0.0", blocks=[
model.DataTable(header=["A", "B"],
rows=[["1", "x"], ["2", "y"], ["3", "z"], ["4", "w"]])])]
out = str(tmp_path / "zebra.pptx")
assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]
prs = Presentation(out)
table = None
for slide in prs.slides:
for sh in slide.shapes:
if sh.has_table:
table = sh.table
break
assert table is not None, "no se encontró la tabla en el deck"
zebra = RGBColor(0xF6, 0xF8, 0xFA)
white = RGBColor(0xFF, 0xFF, 0xFF)
# Row 0 = header; data rows follow. Even data rows (table rows 2, 4) shaded.
assert table.cell(1, 0).fill.fore_color.rgb == white
assert table.cell(2, 0).fill.fore_color.rgb == zebra
assert table.cell(4, 0).fill.fore_color.rgb == zebra
# --------------------------------------------------------------------------- #
# 3) Keep-together (Group): heading + figure never split.
# --------------------------------------------------------------------------- #
def test_pdf_group_moves_whole_to_next_page_when_it_does_not_fit():
fig = plt.figure(figsize=(RP._W, RP._H))
st = RP._PdfState(_FakePdf(), "t")
st.fig = fig
st.page = 1
st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
grp = model.Group(blocks=[
model.Heading(text="Sección con figura", level=2),
model.Figure(make=_small_fig, caption="cap"),
model.Markdown(text="Descripción breve de la figura."),
])
# Only ~0.4in left: the group does not fit here but fits on a fresh page.
st.y = RP._CONTENT_BOTTOM - 0.4
page_before = st.page
RP._place_group(st, grp)
# Exactly one page break: the whole group (heading+figure+text) stays
# together on the new page — no second break inside it.
assert st.page == page_before + 1
plt.close(st.fig)
def test_pdf_group_does_not_break_when_it_fits():
fig = plt.figure(figsize=(RP._W, RP._H))
st = RP._PdfState(_FakePdf(), "t")
st.fig = fig
st.page = 1
st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
grp = model.Group(blocks=[
model.Heading(text="Cabe entera", level=2),
model.Figure(make=_small_fig, caption="cap"),
])
st.y = RP._CONTENT_TOP # empty page → fits, must not break.
page_before = st.page
RP._place_group(st, grp)
assert st.page == page_before
plt.close(st.fig)
def test_pptx_group_moves_whole_to_next_slide(tmp_path):
pytest.importorskip("pptx")
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
prs.slide_width = Inches(RX._W)
prs.slide_height = Inches(RX._H)
st = RX._PptxState(prs, "t")
st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
RX._new_slide(st, cont=False)
grp = model.Group(blocks=[
model.Heading(text="Sección con figura", level=2),
model.Figure(make=_small_fig, caption="cap"),
model.Markdown(text="Descripción breve."),
])
st.y = RX._CONTENT_BOTTOM - 0.4 # does not fit here.
slide_before = st.slide_no
RX._place_group(st, grp)
assert st.slide_no == slide_before + 1 # one jump; group kept together.
# --------------------------------------------------------------------------- #
# 4) Every PPTX figure carries a visible caption/title.
# --------------------------------------------------------------------------- #
def test_pptx_figure_without_caption_gets_heading_title(tmp_path):
pytest.importorskip("pptx")
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
doc = [model.Chapter(id="c", title="Cap", version="1.0.0", blocks=[
model.Heading(text="Mi sección gráfica", level=2),
model.Figure(make=_small_fig), # NO caption provided.
])]
out = str(tmp_path / "cap.pptx")
assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]
prs = Presentation(out)
for slide in prs.slides:
has_pic = any(sh.shape_type == MSO_SHAPE_TYPE.PICTURE
for sh in slide.shapes)
if not has_pic:
continue
italic = [r.text for sh in slide.shapes if sh.has_text_frame
for p in sh.text_frame.paragraphs for r in p.runs
if r.font.italic and r.text.strip()]
assert italic, "la figura no lleva caption visible en su slide"
assert any("Mi sección gráfica" in t for t in italic), \
"el caption no cayó al título de la sección"
return
pytest.fail("no se encontró ningún slide con imagen")
def test_pptx_no_figure_slide_is_ever_untitled(tmp_path):
"""Invariant: across many figures (incl. tall ones), NO slide with an image
lacks a visible caption the caption never spills to the next slide."""
pytest.importorskip("pptx")
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
def _tall_fig():
fig = plt.figure(figsize=(5.0, 4.6)) # nearly square → fills the slide.
fig.add_subplot(111).bar([1, 2, 3], [4, 5, 6])
return fig
blocks = []
for i in range(6):
blocks.append(model.Heading(text=f"Gráfico {i}", level=2))
blocks.append(model.Figure(
make=_tall_fig,
caption=("Una descripción de la figura deliberadamente larga para "
"que el caption ocupe más de una línea al envolverse en el "
f"ancho del slide — figura número {i} del bloque.")))
doc = [model.Chapter(id="c", title="Muchas figuras", version="1.0.0",
blocks=blocks)]
out = str(tmp_path / "many.pptx")
assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]
prs = Presentation(out)
missing = []
pics = 0
for i, slide in enumerate(prs.slides):
if not any(sh.shape_type == MSO_SHAPE_TYPE.PICTURE
for sh in slide.shapes):
continue
pics += 1
italic = [r.text for sh in slide.shapes if sh.has_text_frame
for p in sh.text_frame.paragraphs for r in p.runs
if r.font.italic and r.text.strip()]
if not italic:
missing.append(i)
assert pics >= 6, f"esperadas >=6 figuras, hay {pics}"
assert not missing, f"slides con imagen sin caption: {missing}"
# --------------------------------------------------------------------------- #
# 5) Cover built last, placed first, with an aggregated summary.
# --------------------------------------------------------------------------- #
def test_cover_first_glossary_last_with_summary():
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
ids = [c.id for c in chs]
assert ids[0] == "portada", f"la portada no es la primera: {ids}"
assert ids[-1] == "glosario", f"el glosario no es el último: {ids}"
cover = chs[0]
headings = [b.text for b in cover.blocks if b.kind == "heading"]
assert any("Resumen" in h for h in headings), \
"la portada no incluye el resumen agregado"
# The summary reflects the body chapters (e.g. the numeric/categorical ones).
cover_text = " ".join(
b.text for b in cover.blocks if getattr(b, "kind", "") == "markdown")
assert "Distribuciones" in cover_text, \
"el resumen de portada no menciona los capítulos del cuerpo"
# --------------------------------------------------------------------------- #
# 6) Glossary clickable in PDF (PyMuPDF GOTO) and PPTX (native slide jump).
# --------------------------------------------------------------------------- #
def test_pdf_glossary_term_is_clickable(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "glos.pdf")
res = render_automatic_eda_pdf(_profile_with_cat_and_num(), out,
{"ctx": {"dataset_name": "v"},
"write_manifest": False})
assert res["path"] == out and os.path.exists(out)
doc = fitz.open(out)
goto = [(pno, l) for pno in range(doc.page_count)
for l in doc[pno].get_links() if l.get("kind") == fitz.LINK_GOTO]
doc.close()
assert goto, "no hay ningún enlace interno (entropía → glosario) en el PDF"
# Destination must be a real page in the document (the glossary page).
assert all(0 <= l.get("page", -1) for _p, l in goto)
def test_pptx_glossary_term_is_clickable(tmp_path):
pytest.importorskip("pptx")
from pptx import Presentation
from pptx.oxml.ns import qn
out = str(tmp_path / "glos.pptx")
res = render_automatic_eda_pptx(_profile_with_cat_and_num(), out,
{"ctx": {"dataset_name": "v"},
"write_manifest": False})
assert res["path"] == out and os.path.exists(out)
prs = Presentation(out)
found = False
for slide in prs.slides:
for sh in slide.shapes:
if not sh.has_text_frame:
continue
for p in sh.text_frame.paragraphs:
for r in p.runs:
rpr = r._r.find(qn("a:rPr"))
if rpr is None:
continue
hl = rpr.find(qn("a:hlinkClick"))
if hl is not None and \
hl.get("action") == "ppaction://hlinksldjump":
found = True
assert found, "ningún término tiene hyperlink de salto a slide en el PPTX"
@@ -60,6 +60,8 @@ _FS_BODY, _FS_CELL, _FS_NOTE = 10.5, 9.0, 9.0
_GAP = 0.12 # vertical gap after a block, inches.
_CELL_PAD = 0.06 # horizontal padding inside a table cell, inches.
_ROW_VPAD = 0.05 # vertical padding inside a table row, inches.
_ZEBRA = "#f6f8fa" # very light grey for zebra-striped (even) table rows.
_LINK = "#2a6f97" # accent colour for clickable glossary terms.
class _PdfState:
@@ -73,6 +75,11 @@ class _PdfState:
self.page = 0 # global page counter.
self.chapter = None # current Chapter (for the footer).
self.chapter_pages = 0 # pages produced for the current chapter.
self.last_heading = "" # text of the most recent heading.
# Glossary wiring (mejora 6). Pages are 0-based; rects/points are in PDF
# points (1/72") with a top-left origin — same convention as PyMuPDF.
self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}]
self.term_dests = {} # key -> {page, point:[x,y]}
# --------------------------------------------------------------------------- #
@@ -121,6 +128,35 @@ def _draw_footer(st: _PdfState) -> None:
transform=st.fig.transFigure, color=_RULE, lw=0.6))
def _text_width_in(st: _PdfState, s: str, fs: float, bold: bool) -> float:
"""Real rendered width (inches) of ``s`` at ``fs`` with the given weight.
Measured with the Agg renderer's own font metrics (the same TrueType the PDF
backend embeds), so a **bold** span advances the cursor by its ACTUAL width
fixing the bug where bold text overlapped the following normal text because
the cursor advanced by the normal-weight average-glyph estimate. Falls back to
the deterministic character grid if the renderer is unavailable, so it never
raises.
"""
if not s:
return 0.0
try:
from matplotlib.font_manager import FontProperties
renderer = st.fig.canvas.get_renderer()
prop = FontProperties(family="sans-serif", size=fs,
weight="bold" if bold else "normal")
w_px, _h, _d = renderer.get_text_width_height_descent(s, prop, False)
return w_px / float(st.fig.dpi)
except Exception: # noqa: BLE001 — fall back to the conservative grid metric.
return tl.avg_char_width_in(fs) * len(s)
def _pt_rect(x0_in: float, y_top_in: float, x1_in: float,
y_bottom_in: float) -> list:
"""An inches box (top-left origin) → a PDF-points rect for PyMuPDF links."""
return [x0_in * 72.0, y_top_in * 72.0, x1_in * 72.0, y_bottom_in * 72.0]
def _remaining(st: _PdfState) -> float:
return _CONTENT_BOTTOM - st.y
@@ -138,6 +174,7 @@ def _place_heading(st: _PdfState, block) -> None:
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
text = tl.strip_inline_md(getattr(block, "text", ""))
st.last_heading = text or st.last_heading
max_chars = tl.chars_per_line(_USABLE_W, fs)
lines = tl.wrap(text, max_chars)
lh = tl.line_height_in(fs, leading=1.2)
@@ -171,17 +208,19 @@ def _place_text_lines(st: _PdfState, lines: list, fs: float, color: str,
def _place_rich_lines(st: _PdfState, rich_lines: list, fs: float, color: str,
indent: float = 0.0, prefixes=None) -> None:
"""Draw pre-wrapped lines of styled segments (bold spans rendered bold).
"""Draw pre-wrapped lines of styled segments (bold + clickable term spans).
Each line is ``[(text, is_bold), ...]``. Segments are placed left-to-right,
advancing x by the deterministic character grid (same metric the wrapper
used), so a bold span is rendered with ``fontweight='bold'`` without
changing the line's measured width — the no-cut guarantee is preserved.
Each line is a list of ``(text, is_bold)`` or ``(text, is_bold, term_key)``
segments. Segments are placed left-to-right, advancing x by the segment's
REAL rendered width (measured with the renderer's font metrics for the actual
weight) this is what stops a bold span from overlapping the following text:
the cursor no longer advances by the normal-weight estimate. A segment with a
``term_key`` is drawn in the accent colour and its rectangle is recorded in
``st.term_sources`` so it becomes a clickable jump to the glossary entry.
``prefixes`` is an optional ``(first_line, other_lines)`` pair (e.g. a
bullet) drawn before the segments.
"""
lh = tl.line_height_in(fs)
cw = tl.avg_char_width_in(fs)
for idx, segs in enumerate(rich_lines):
_ensure_space(st, lh)
x = _ML + indent
@@ -190,14 +229,23 @@ def _place_rich_lines(st: _PdfState, rich_lines: list, fs: float, color: str,
if prefix:
st.fig.text(_xf(x), _yf(st.y), prefix, fontsize=fs, color=color,
ha="left", va="top")
x += cw * len(prefix)
for seg_text, is_bold in segs:
x += _text_width_in(st, prefix, fs, False)
for seg in segs:
if len(seg) == 3:
seg_text, is_bold, term = seg
else:
seg_text, is_bold, term = seg[0], seg[1], None
if seg_text == "":
continue
st.fig.text(_xf(x), _yf(st.y), seg_text, fontsize=fs, color=color,
ha="left", va="top",
w = _text_width_in(st, seg_text, fs, bool(is_bold))
st.fig.text(_xf(x), _yf(st.y), seg_text, fontsize=fs,
color=(_LINK if term else color), ha="left", va="top",
fontweight="bold" if is_bold else "normal")
x += cw * len(seg_text)
if term:
st.term_sources.append({
"key": term, "page": st.page - 1,
"rect": _pt_rect(x, st.y, x + w, st.y + lh)})
x += w
st.y += lh
@@ -242,7 +290,7 @@ def _place_markdown(st: _PdfState, block) -> None:
if stripped.startswith("- ") or stripped.startswith("* "):
content = stripped[2:] # keep inline markers for bold rendering.
bullet_chars = tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)
rich = tl.wrap_rich(content, bullet_chars)
rich = tl.wrap_rich_terms(content, bullet_chars)
_place_rich_lines(st, rich, _FS_BODY, _INK,
prefixes=("", " "))
i += 1
@@ -258,7 +306,8 @@ def _place_markdown(st: _PdfState, block) -> None:
j += 1
text = " ".join(para)
max_chars = tl.chars_per_line(_USABLE_W, _FS_BODY)
_place_rich_lines(st, tl.wrap_rich(text, max_chars), _FS_BODY, _INK)
_place_rich_lines(st, tl.wrap_rich_terms(text, max_chars), _FS_BODY,
_INK)
i = j
st.y += _GAP
@@ -325,15 +374,18 @@ def _wrap_row(cells: list, widths: list, fs: float) -> list:
def _draw_table_row(st: _PdfState, cells_lines: list, widths: list, fs: float,
y0: float, header: bool) -> float:
y0: float, header: bool, zebra: bool = False) -> float:
lh = tl.line_height_in(fs)
nlines = max((len(c) for c in cells_lines), default=1)
row_h = lh * nlines + _ROW_VPAD * 2
if header:
# Background: header band, or a faint zebra fill for even data rows. Drawn
# below the text/rule (zorder 0) so striping never hides cell content.
bg = _HEAD_BG if header else (_ZEBRA if zebra else None)
if bg is not None:
st.fig.add_artist(Rectangle(
(_xf(_ML), _yf(y0 + row_h)), _xf(_ML + _USABLE_W) - _xf(_ML),
_yf(y0) - _yf(y0 + row_h), transform=st.fig.transFigure,
color=_HEAD_BG, lw=0, zorder=0))
color=bg, lw=0, zorder=0))
x = _ML
for c, lines in enumerate(cells_lines):
for k, ln in enumerate(lines):
@@ -378,14 +430,18 @@ def _place_data_table(st: _PdfState, block) -> None:
+ _ROW_VPAD * 2
_ensure_space(st, header_h() + max(first_row_h, lh))
draw_header()
for r in rows:
# ``data_idx`` is the LOGICAL row index (not reset across page breaks) so the
# zebra pattern stays coherent when a long table splits and repeats the
# header: even rows (1-based) are shaded → 0-based odd indices.
for data_idx, r in enumerate(rows):
cells_lines = _wrap_row(r, widths, fs)
row_h = lh * max((len(c) for c in cells_lines), default=1) \
+ _ROW_VPAD * 2
if _remaining(st) < row_h:
_new_page(st)
draw_header() # repeat header on the continuation page.
st.y += _draw_table_row(st, cells_lines, widths, fs, st.y, header=False)
st.y += _draw_table_row(st, cells_lines, widths, fs, st.y,
header=False, zebra=(data_idx % 2 == 1))
note = getattr(block, "note", None)
if note:
_place_text_lines(st, tl.wrap(model._safe_str(note),
@@ -414,53 +470,98 @@ def _png_from_figure(fig) -> bytes:
return buf.read()
def _place_image_array(st: _PdfState, arr, caption) -> None:
def _figure_png_cached(block):
"""Rasterize a Figure to PNG bytes ONCE and cache (bytes, aspect).
Measuring (keep-together) and drawing must agree on the REAL aspect ratio:
``bbox_inches='tight'`` changes it vs ``figsize``, so we rasterize once and
reuse the bytes for both. Cached on the block; never raises."""
cached = getattr(block, "_aeda_png", None)
if cached is not None:
return cached
fig, owned = _resolve_figure(block)
data = None
if fig is not None:
try:
data = _png_from_figure(fig)
finally:
if owned:
try:
plt.close(fig)
except Exception: # noqa: BLE001
pass
aspect = 0.66
if data is not None:
try:
arr = mpimg.imread(io.BytesIO(data))
aspect = (arr.shape[0] / arr.shape[1]) if arr.shape[1] else 0.66
except Exception: # noqa: BLE001
aspect = 0.66
try:
block._aeda_png = (data, aspect)
return block._aeda_png
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
return (data, aspect)
def _image_aspect(block) -> float:
"""Real aspect (h/w) of an Image block by path, for measurement."""
path = getattr(block, "path", "")
if path and os.path.exists(path):
try:
arr = mpimg.imread(path)
return (arr.shape[0] / arr.shape[1]) if arr.shape[1] else 0.66
except Exception: # noqa: BLE001
pass
return 0.66
def _place_image_array(st: _PdfState, arr, caption, max_h_in=None) -> None:
h_px, w_px = arr.shape[0], arr.shape[1]
aspect = (h_px / w_px) if w_px else 1.0
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
# the image to (max_h - cap_reserve) so figure + caption always fit the same
# page. cap_reserve adds a cushion so the caption never spills to next page.
cap_lines = (tl.wrap(model._safe_str(caption),
tl.chars_per_line(_USABLE_W, _FS_NOTE))
if caption else [])
cap_real = tl.line_height_in(_FS_NOTE) * len(cap_lines) if caption else 0.0
cap_reserve = (cap_real + 0.04 + 0.08) if caption else 0.0
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
# height_in hint (model.Figure/Image): cap the height so a figure in a
# keep-together Group shrinks to leave room for its heading and text.
if isinstance(max_h_in, (int, float)) and max_h_in > 0:
max_h = min(max_h, float(max_h_in))
max_img_h = max(max_h - cap_reserve, 0.6)
target_w = _USABLE_W
target_h = target_w * aspect
if target_h > max_h:
target_h = max_h
if target_h > max_img_h:
target_h = max_img_h
target_w = target_h / aspect if aspect else _USABLE_W
cap_h = tl.line_height_in(_FS_NOTE) + 0.04 if caption else 0.0
# Move whole image to next page if it does not fit in remaining space.
if _remaining(st) < target_h + cap_h:
if (max_h) >= target_h + cap_h:
_new_page(st)
else:
# Taller than a full page even at min — already clamped to max_h.
_new_page(st)
if _remaining(st) < target_h + cap_reserve:
_new_page(st)
left_frac = _xf(_ML + (_USABLE_W - target_w) / 2.0)
bottom_frac = _yf(st.y + target_h)
ax = st.fig.add_axes([left_frac, bottom_frac, target_w / _W, target_h / _H])
ax.imshow(arr)
ax.axis("off")
st.y += target_h + 0.04
if caption:
_place_text_lines(st, tl.wrap(model._safe_str(caption),
tl.chars_per_line(_USABLE_W, _FS_NOTE)),
_FS_NOTE, _MUTED, style="italic")
if cap_lines:
_place_text_lines(st, cap_lines, _FS_NOTE, _MUTED, style="italic")
st.y += _GAP
def _place_figure(st: _PdfState, block) -> None:
fig, owned = _resolve_figure(block)
if fig is None:
png, _aspect = _figure_png_cached(block)
if png is None:
_place_text_lines(st, ["(figura no disponible)"], _FS_NOTE, _MUTED,
style="italic")
st.y += _GAP
return
try:
png = _png_from_figure(fig)
finally:
if owned:
try:
plt.close(fig)
except Exception: # noqa: BLE001
pass
arr = mpimg.imread(io.BytesIO(png))
_place_image_array(st, arr, getattr(block, "caption", None))
_place_image_array(st, arr, getattr(block, "caption", None),
max_h_in=getattr(block, "height_in", None))
def _place_image(st: _PdfState, block) -> None:
@@ -471,7 +572,8 @@ def _place_image(st: _PdfState, block) -> None:
st.y += _GAP
return
arr = mpimg.imread(path)
_place_image_array(st, arr, getattr(block, "caption", None))
_place_image_array(st, arr, getattr(block, "caption", None),
max_h_in=getattr(block, "height_in", None))
def _place_caption(st: _PdfState, block) -> None:
@@ -488,6 +590,189 @@ def _place_note(st: _PdfState, block) -> None:
st.y += _GAP
# --------------------------------------------------------------------------- #
# Block measurement (mejora 3 — keep-together). These estimate a block's height
# WITHOUT drawing it, so a Group can decide to move whole to the next page before
# anything is drawn. Over-estimating is safe: it only triggers an earlier page
# break, never a content cut (the placers keep their own no-cut pagination).
# --------------------------------------------------------------------------- #
def _measure_heading_text(text: str, level: int) -> float:
level = max(1, min(3, int(level or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
h = tl.line_height_in(fs, leading=1.2) * len(lines) + 0.06
if level == 1:
h += 0.10
return h + _GAP
def _measure_markdown(block) -> float:
raw = str(getattr(block, "text", "") or "")
md_lines = raw.split("\n")
h = 0.0
i, n = 0, len(md_lines)
while i < n:
stripped = md_lines[i].strip()
if stripped.startswith("|") and stripped.endswith("|"):
j = i
while j < n and md_lines[j].strip().startswith("|") \
and md_lines[j].strip().endswith("|"):
j += 1
h += (tl.line_height_in(_FS_CELL) + _ROW_VPAD * 2) * (j - i) + _GAP
i = j
continue
if stripped == "":
h += tl.line_height_in(_FS_BODY) * 0.5
i += 1
continue
if stripped.startswith("### "):
h += _measure_heading_text(stripped[4:], 3)
i += 1
continue
if stripped.startswith("## "):
h += _measure_heading_text(stripped[3:], 2)
i += 1
continue
if stripped.startswith("# "):
h += _measure_heading_text(stripped[2:], 1)
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
lines = tl.wrap_rich_terms(
stripped[2:], tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY))
h += tl.line_height_in(_FS_BODY) * len(lines)
i += 1
continue
para = [stripped]
j = i + 1
while j < n:
nxt = md_lines[j].strip()
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
break
para.append(nxt)
j += 1
lines = tl.wrap_rich_terms(" ".join(para),
tl.chars_per_line(_USABLE_W, _FS_BODY))
h += tl.line_height_in(_FS_BODY) * len(lines)
i = j
return h + _GAP
def _measure_figure_like(block) -> float:
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
hint = getattr(block, "height_in", None)
if isinstance(hint, (int, float)) and hint > 0:
target_h = min(float(hint), max_h)
else:
# Real rasterized aspect (cached) so measuring matches drawing.
if getattr(block, "kind", "") == "image":
aspect = _image_aspect(block)
else:
_data, aspect = _figure_png_cached(block)
target_h = min(_USABLE_W * aspect, max_h)
cap = getattr(block, "caption", None)
cap_h = tl.line_height_in(_FS_NOTE) + 0.04 if cap else 0.0
return target_h + 0.04 + cap_h + _GAP
def _measure_block(st: _PdfState, block) -> float:
kind = getattr(block, "kind", "")
try:
if kind == "heading":
return _measure_heading_text(getattr(block, "text", ""),
getattr(block, "level", 1))
if kind == "markdown":
return _measure_markdown(block)
if kind in ("figure", "image"):
return _measure_figure_like(block)
if kind in ("caption", "note"):
lines = tl.wrap(getattr(block, "text", ""),
tl.chars_per_line(_USABLE_W, _FS_NOTE))
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
if kind == "kv_table":
rows = getattr(block, "rows", []) or []
return (tl.line_height_in(_FS_BODY) + _ROW_VPAD) * (len(rows) + 1) \
+ _GAP
if kind == "data_table":
rows = getattr(block, "rows", []) or []
return (tl.line_height_in(_FS_CELL) + _ROW_VPAD * 2) \
* (len(rows) + 1) + _GAP
if kind == "group":
return sum(_measure_block(st, b)
for b in (getattr(block, "blocks", []) or []))
except Exception: # noqa: BLE001 — a measurement never aborts rendering.
pass
return tl.line_height_in(_FS_BODY)
def _shrink_group_figures(st: _PdfState, blocks: list, avail_full: float) -> None:
"""Cap each figure's height (via height_in) so the whole group fits a page.
The figure shrinks just enough to leave room for its heading, text and
caption keep-together puts the chart on the SAME page as its title and
description instead of pushing it to the next page."""
fig_blocks = [b for b in blocks
if getattr(b, "kind", "") in ("figure", "image")]
if not fig_blocks:
return
nonfig_h = sum(_measure_block(st, b) for b in blocks
if getattr(b, "kind", "") not in ("figure", "image"))
fig_overhead = tl.line_height_in(_FS_NOTE) + 0.04 + 0.04 + _GAP
budget = avail_full - nonfig_h - 0.08 * len(fig_blocks)
if budget <= 0.8:
return
per = budget / len(fig_blocks) - fig_overhead
if per <= 0.6:
return
for fb in fig_blocks:
cur = getattr(fb, "height_in", None)
fb.height_in = (min(float(cur), per)
if isinstance(cur, (int, float)) and cur > 0 else per)
def _place_group(st: _PdfState, block) -> None:
"""Render a keep-together Group: move it whole to the next page if needed."""
blocks = getattr(block, "blocks", []) or []
if not blocks:
return
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
_shrink_group_figures(st, blocks, avail_full)
total = sum(_measure_block(st, b) for b in blocks)
if total <= avail_full:
# Fits on one page: keep it together by moving whole when it won't fit.
if total > _remaining(st):
_new_page(st)
elif st.y > _CONTENT_TOP + 1e-6:
# Taller than a full page: at least start it on a fresh page, then flow.
_new_page(st)
for b in blocks:
placer = _PLACERS.get(getattr(b, "kind", ""), _place_note)
try:
placer(st, b)
except Exception: # noqa: BLE001 — a bad block never aborts the group.
pass
def _place_glossary_entry(st: _PdfState, block) -> None:
"""Render one glossary term and register it as a clickable link target."""
key = getattr(block, "key", "")
label = getattr(block, "label", "") or key
definition = getattr(block, "definition", "")
# Reserve the term + its first definition line together, then anchor the
# destination at the resolved page/position before drawing.
_ensure_space(st, tl.line_height_in(_FS_H3, leading=1.2)
+ tl.line_height_in(_FS_BODY) * 2)
if key:
st.term_dests[key] = {"page": st.page - 1,
"point": [_ML * 72.0, st.y * 72.0]}
_place_heading(st, model.Heading(text=str(label), level=3))
if definition:
_place_text_lines(st, tl.wrap(model._safe_str(definition),
tl.chars_per_line(_USABLE_W, _FS_BODY)),
_FS_BODY, _INK)
st.y += _GAP * 0.5
_PLACERS = {
"heading": _place_heading,
"markdown": _place_markdown,
@@ -497,6 +782,8 @@ _PLACERS = {
"image": _place_image,
"caption": _place_caption,
"note": _place_note,
"group": _place_group,
"glossary_entry": _place_glossary_entry,
}
@@ -553,8 +840,42 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
return {"path": None, "n_pages": 0, "chapters": [],
"note": f"fallo al escribir el PDF: {e}"}
# Mejora 6 — wire clickable glossary links now the PDF is closed on disk.
# PdfPages cannot emit internal hyperlinks, so we post-process with PyMuPDF
# (delegated registry function). Degrades silently if it is unavailable.
n_links = _wire_glossary_links(st, out_path, notes)
note = f"{n_pages} páginas"
if n_links:
note += f" · {n_links} enlaces de glosario"
if notes:
note += " · " + "; ".join(notes)
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
"note": note}
def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
"""Build {source rect → glossary dest} links and apply them via PyMuPDF.
Returns the number of links applied (0 if there is nothing to wire or the
post-processor is unavailable). Never raises."""
try:
links = []
for src in st.term_sources:
dest = st.term_dests.get(src.get("key"))
if not dest:
continue
links.append({
"src_page": src["page"], "src_rect": src["rect"],
"dst_page": dest["page"], "dst_point": dest["point"]})
if not links:
return 0
from datascience.add_pdf_internal_links import add_pdf_internal_links
res = add_pdf_internal_links(out_path, links)
if isinstance(res, dict) and res.get("status") == "ok":
return int(res.get("n_links") or 0)
if isinstance(res, dict) and res.get("error"):
notes.append(f"glosario sin enlaces: {res.get('error')}")
except Exception as e: # noqa: BLE001 — links are best-effort.
notes.append(f"glosario sin enlaces: {e}")
return 0
@@ -43,6 +43,8 @@ _ACCENT = (0x2A, 0x6F, 0x97)
_MUTED = (0x8A, 0x8A, 0x8A)
_HEAD_BG = (0xEE, 0xF3, 0xF6)
_WHITE = (0xFF, 0xFF, 0xFF)
_ZEBRA = (0xF6, 0xF8, 0xFA) # faint grey for even (zebra) data rows.
_LINK = (0x2A, 0x6F, 0x97) # accent colour for clickable glossary terms.
_FS_TITLE = 26
_FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
@@ -59,6 +61,10 @@ class _PptxState:
self.chapter = None
self.slide_no = 0
self.chapter_slides = 0
self.last_heading = "" # text of the most recent heading.
# Glossary wiring (mejora 6): runs to link and per-term target slide.
self.term_runs = [] # [(key, run)]
self.term_anchor_slide = {} # key -> Slide (glossary entry)
def _rgb(c):
@@ -155,9 +161,13 @@ def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
indent=0.0, bullet=False) -> None:
"""Add pre-wrapped lines of styled segments as one paragraph per line.
Each line is ``[(text, is_bold), ...]``; every segment becomes its own run
so ``**bold**`` spans render with native PowerPoint bold (``run.font.bold``)
without affecting the measured height (one paragraph per pre-wrapped line).
Each line is a list of ``(text, is_bold)`` or ``(text, is_bold, term_key)``
segments; every segment becomes its own run so ``**bold**`` spans render with
native PowerPoint bold (``run.font.bold``) without affecting the measured
height (one paragraph per pre-wrapped line). A segment carrying a
``term_key`` is drawn in the accent colour and its run is recorded in
``st.term_runs`` so it later becomes a native hyperlink jumping to the
glossary slide of that term.
"""
lh = tl.line_height_in(fs)
height = lh * len(rich_lines) + 0.05
@@ -176,14 +186,20 @@ def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
r0.text = ""
r0.font.size = Pt(fs)
r0.font.color.rgb = _rgb(color)
for seg_text, is_bold in segs:
for seg in segs:
if len(seg) == 3:
seg_text, is_bold, term = seg
else:
seg_text, is_bold, term = seg[0], seg[1], None
if seg_text == "":
continue
run = p.add_run()
run.text = seg_text
run.font.size = Pt(fs)
run.font.bold = bool(is_bold)
run.font.color.rgb = _rgb(color)
run.font.color.rgb = _rgb(_LINK if term else color)
if term:
st.term_runs.append((term, run, st.slide))
st.y += height
@@ -191,6 +207,7 @@ def _place_heading(st: _PptxState, block) -> None:
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
text = tl.strip_inline_md(getattr(block, "text", ""))
st.last_heading = text or st.last_heading
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
_add_text(st, lines, fs, _INK, bold=True)
st.y += 0.04
@@ -233,12 +250,12 @@ def _place_markdown(st: _PptxState, block) -> None:
continue
if stripped.startswith("- ") or stripped.startswith("* "):
content = stripped[2:] # keep inline markers for bold rendering.
rich = tl.wrap_rich(content,
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
rich = tl.wrap_rich_terms(content,
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
_add_rich_text(st, rich, _FS_BODY, _INK, bullet=True)
i += 1
continue
para = [stripped] # keep inline markers; wrap_rich renders **bold**.
para = [stripped] # keep inline markers; wrap_rich_terms renders **bold**.
j = i + 1
while j < n:
nxt = md_lines[j].strip()
@@ -247,8 +264,8 @@ def _place_markdown(st: _PptxState, block) -> None:
para.append(nxt)
j += 1
text = " ".join(para)
_add_rich_text(st, tl.wrap_rich(text, tl.chars_per_line(_USABLE_W, _FS_BODY)),
_FS_BODY, _INK)
_add_rich_text(st, tl.wrap_rich_terms(
text, tl.chars_per_line(_USABLE_W, _FS_BODY)), _FS_BODY, _INK)
i = j
st.y += _GAP
@@ -295,7 +312,8 @@ def _row_height_in(cells, widths, fs) -> float:
return lh * maxlines + 0.10
def _emit_table(st: _PptxState, header, chunk, widths, fs) -> None:
def _emit_table(st: _PptxState, header, chunk, widths, fs,
start_index: int = 0) -> None:
nrows = len(chunk) + (1 if header else 0)
ncol = len(widths)
# Pre-measure total height to size the shape (pptx still auto-grows rows).
@@ -319,11 +337,14 @@ def _emit_table(st: _PptxState, header, chunk, widths, fs) -> None:
cell.text = model._safe_str(header[c]) if c < len(header) else ""
_style_cell(cell, fs, _INK, bold=True, fill=_HEAD_BG)
ridx = 1
for r in chunk:
# Zebra striping: shade even data rows (1-based) using the GLOBAL row index
# (start_index offset) so the pattern stays coherent across split chunks.
for k, r in enumerate(chunk):
fill = _ZEBRA if (start_index + k) % 2 == 1 else _WHITE
for c in range(ncol):
cell = gtable.cell(ridx, c)
cell.text = model._safe_str(r[c]) if c < len(r) else ""
_style_cell(cell, fs, _INK, bold=False, fill=_WHITE)
_style_cell(cell, fs, _INK, bold=False, fill=fill)
ridx += 1
st.y += total_h + _GAP
@@ -367,6 +388,7 @@ def _place_data_table(st: _PptxState, block, shaded_header=True,
avail = _remaining(st) - header_h
chunk = []
used = 0.0
chunk_start = idx # global index of the first row in this chunk (zebra).
while idx < n:
rh = _row_height_in(rows[idx], widths, fs)
if used + rh > avail and chunk:
@@ -374,7 +396,7 @@ def _place_data_table(st: _PptxState, block, shaded_header=True,
chunk.append(rows[idx])
used += rh
idx += 1
_emit_table(st, header, chunk, widths, fs)
_emit_table(st, header, chunk, widths, fs, start_index=chunk_start)
note = getattr(block, "note", None)
if note:
_add_text(st, tl.wrap(model._safe_str(note),
@@ -421,54 +443,97 @@ def _resolve_png(block):
pass
def _place_picture_bytes(st: _PptxState, data: bytes, caption) -> None:
def _figure_bytes_cached(block):
"""Rasterize a figure/image to PNG bytes ONCE and cache (bytes, aspect).
Measuring (keep-together) and drawing must agree on the real aspect ratio
``bbox_inches='tight'`` changes it vs ``figsize``, so we rasterize once and
reuse the bytes for both. Cached on the block; never raises."""
cached = getattr(block, "_aeda_png", None)
if cached is not None:
return cached
kind = getattr(block, "kind", "")
data = None
if kind == "image":
path = getattr(block, "path", "")
if path and os.path.exists(path):
try:
with open(path, "rb") as fh:
data = fh.read()
except Exception: # noqa: BLE001
data = None
else:
data = _resolve_png(block)
aspect = 0.66
if data is not None:
w_px, h_px = _img_size_px(data)
aspect = (h_px / w_px) if w_px else 0.66
try:
block._aeda_png = (data, aspect)
return block._aeda_png
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
return (data, aspect)
def _place_picture_bytes(st: _PptxState, data: bytes, caption,
max_h_in=None) -> None:
# Mejora 4 — every figure on a slide carries a visible caption/title. If the
# block has no caption, fall back to the current section heading, then to a
# generic label, so no image is ever shown untitled.
caption = (model._safe_str(caption).strip()
or model._safe_str(st.last_heading).strip() or "Figura")
w_px, h_px = _img_size_px(data)
aspect = (h_px / w_px) if w_px else 0.66
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
# the image to (max_h - cap_reserve): a figure never fills the whole slide,
# so its caption always fits on the SAME slide and no image is untitled.
# cap_real = what _add_text consumes; cap_reserve adds the post-image gap and
# a small cushion so the caption never spills to the next slide.
cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE))
cap_real = tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05
cap_reserve = cap_real + 0.05 + 0.10
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
# height_in hint (model.Figure/Image): cap the target height so a figure in a
# keep-together Group shrinks to leave room for its heading and text.
if isinstance(max_h_in, (int, float)) and max_h_in > 0:
max_h = min(max_h, float(max_h_in))
max_img_h = max(max_h - cap_reserve, 0.6)
target_w = _USABLE_W
target_h = target_w * aspect
if target_h > max_h:
target_h = max_h
if target_h > max_img_h:
target_h = max_img_h
target_w = target_h / aspect if aspect else _USABLE_W
cap_h = tl.line_height_in(_FS_NOTE) + 0.05 if caption else 0.0
if _remaining(st) < target_h + cap_h:
# Keep the image and its caption together on the same slide.
if _remaining(st) < target_h + cap_reserve:
_new_slide(st, cont=True)
left = _ML + (_USABLE_W - target_w) / 2.0
st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
width=Inches(target_w), height=Inches(target_h))
st.y += target_h + 0.05
if caption:
_add_text(st, tl.wrap(model._safe_str(caption),
tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
italic=True)
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
st.y += _GAP
def _place_figure(st: _PptxState, block) -> None:
png = _resolve_png(block)
png, _aspect = _figure_bytes_cached(block)
if png is None:
_add_text(st, ["(figura no disponible)"], _FS_NOTE, _MUTED, italic=True)
st.y += _GAP
return
_place_picture_bytes(st, png, getattr(block, "caption", None))
_place_picture_bytes(st, png, getattr(block, "caption", None),
max_h_in=getattr(block, "height_in", None))
def _place_image(st: _PptxState, block) -> None:
path = getattr(block, "path", "")
if not path or not os.path.exists(path):
data, _aspect = _figure_bytes_cached(block)
if data is None:
path = getattr(block, "path", "")
_add_text(st, [f"(imagen no encontrada: {path})"], _FS_NOTE, _MUTED,
italic=True)
st.y += _GAP
return
try:
with open(path, "rb") as fh:
data = fh.read()
except Exception as e: # noqa: BLE001
_add_text(st, [f"(no se pudo leer la imagen: {e})"], _FS_NOTE, _MUTED,
italic=True)
st.y += _GAP
return
_place_picture_bytes(st, data, getattr(block, "caption", None))
_place_picture_bytes(st, data, getattr(block, "caption", None),
max_h_in=getattr(block, "height_in", None))
def _place_caption(st: _PptxState, block) -> None:
@@ -482,6 +547,170 @@ def _place_note(st: _PptxState, block) -> None:
_place_caption(st, block)
# --------------------------------------------------------------------------- #
# Block measurement (mejora 3 — keep-together). Estimate a block's slide height
# WITHOUT drawing it so a Group can move whole to the next slide before drawing.
# Over-estimating only triggers an earlier slide break, never a content cut.
# --------------------------------------------------------------------------- #
def _measure_heading_text(text: str, level: int) -> float:
level = max(1, min(3, int(level or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04
def _measure_markdown(block) -> float:
raw = str(getattr(block, "text", "") or "")
md_lines = raw.split("\n")
h = 0.0
i, n = 0, len(md_lines)
while i < n:
stripped = md_lines[i].strip()
if stripped.startswith("|") and stripped.endswith("|"):
j = i
while j < n and md_lines[j].strip().startswith("|") \
and md_lines[j].strip().endswith("|"):
j += 1
h += (tl.line_height_in(_FS_CELL) + 0.10) * (j - i) + _GAP
i = j
continue
if stripped == "":
h += tl.line_height_in(_FS_BODY) * 0.4
i += 1
continue
if stripped.startswith("### "):
h += _measure_heading_text(stripped[4:], 3)
i += 1
continue
if stripped.startswith("## "):
h += _measure_heading_text(stripped[3:], 2)
i += 1
continue
if stripped.startswith("# "):
h += _measure_heading_text(stripped[2:], 1)
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
lines = tl.wrap_rich_terms(
stripped[2:], tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
h += tl.line_height_in(_FS_BODY) * len(lines) + 0.05
i += 1
continue
para = [stripped]
j = i + 1
while j < n:
nxt = md_lines[j].strip()
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
break
para.append(nxt)
j += 1
lines = tl.wrap_rich_terms(" ".join(para),
tl.chars_per_line(_USABLE_W, _FS_BODY))
h += tl.line_height_in(_FS_BODY) * len(lines) + 0.05
i = j
return h + _GAP
def _measure_figure_like(block) -> float:
max_h = _CONTENT_BOTTOM - _CONTENT_TOP
hint = getattr(block, "height_in", None)
if isinstance(hint, (int, float)) and hint > 0:
max_h = min(max_h, float(hint))
# Use the REAL rasterized aspect (cached) so measuring matches drawing — this
# is what keeps a figure together with its heading instead of splitting.
_data, aspect = _figure_bytes_cached(block)
target_h = min(_USABLE_W * aspect, max_h)
# Caption is always emitted now (mejora 4), so always reserve its line.
cap_h = tl.line_height_in(_FS_NOTE) + 0.05
return target_h + 0.05 + cap_h + _GAP
def _measure_block(st: _PptxState, block) -> float:
kind = getattr(block, "kind", "")
try:
if kind == "heading":
return _measure_heading_text(getattr(block, "text", ""),
getattr(block, "level", 1))
if kind == "markdown":
return _measure_markdown(block)
if kind in ("figure", "image"):
return _measure_figure_like(block)
if kind in ("caption", "note"):
lines = tl.wrap(getattr(block, "text", ""),
tl.chars_per_line(_USABLE_W, _FS_NOTE))
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
if kind in ("kv_table", "data_table"):
rows = getattr(block, "rows", []) or []
return (tl.line_height_in(_FS_CELL) + 0.10) * (len(rows) + 1) + _GAP
if kind == "group":
return sum(_measure_block(st, b)
for b in (getattr(block, "blocks", []) or []))
except Exception: # noqa: BLE001 — a measurement never aborts rendering.
pass
return tl.line_height_in(_FS_BODY)
def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> None:
"""Cap each figure's height (via height_in) so the whole group fits a slide.
The figure shrinks just enough to leave room for its heading, text and
caption that is how keep-together puts a chart on the SAME slide as its
title and description instead of pushing it to the next slide."""
fig_blocks = [b for b in blocks
if getattr(b, "kind", "") in ("figure", "image")]
if not fig_blocks:
return
nonfig_h = sum(_measure_block(st, b) for b in blocks
if getattr(b, "kind", "") not in ("figure", "image"))
fig_overhead = tl.line_height_in(_FS_NOTE) + 0.05 + 0.05 + _GAP
budget = avail_full - nonfig_h - 0.10 * len(fig_blocks)
if budget <= 1.0:
return # not enough room to keep together; let it flow (degrade).
per = budget / len(fig_blocks) - fig_overhead
if per <= 0.8:
return
for fb in fig_blocks:
cur = getattr(fb, "height_in", None)
fb.height_in = (min(float(cur), per)
if isinstance(cur, (int, float)) and cur > 0 else per)
def _place_group(st: _PptxState, block) -> None:
"""Render a keep-together Group: move it whole to the next slide if needed."""
blocks = getattr(block, "blocks", []) or []
if not blocks:
return
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
_shrink_group_figures(st, blocks, avail_full)
total = sum(_measure_block(st, b) for b in blocks)
if total <= avail_full:
if total > _remaining(st):
_new_slide(st, cont=True)
elif st.y > _CONTENT_TOP + 1e-6:
_new_slide(st, cont=True)
for b in blocks:
placer = _PLACERS.get(getattr(b, "kind", ""), _place_note)
try:
placer(st, b)
except Exception: # noqa: BLE001 — a bad block never aborts the group.
pass
def _place_glossary_entry(st: _PptxState, block) -> None:
"""Render one glossary term and register its slide as the link target."""
key = getattr(block, "key", "")
label = getattr(block, "label", "") or key
definition = getattr(block, "definition", "")
_ensure(st, tl.line_height_in(_FS_H3) + tl.line_height_in(_FS_BODY) * 2)
if key:
st.term_anchor_slide[key] = st.slide
_place_heading(st, model.Heading(text=str(label), level=3))
if definition:
_add_text(st, tl.wrap(model._safe_str(definition),
tl.chars_per_line(_USABLE_W, _FS_BODY)), _FS_BODY, _INK)
st.y += _GAP
_PLACERS = {
"heading": _place_heading,
"markdown": _place_markdown,
@@ -491,6 +720,8 @@ _PLACERS = {
"image": _place_image,
"caption": _place_caption,
"note": _place_note,
"group": _place_group,
"glossary_entry": _place_glossary_entry,
}
@@ -542,6 +773,9 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
_new_slide(st, cont=False)
_place_note(st, model.Note(
"(documento vacío — sin capítulos aplicables)"))
# Mejora 6 — wire clickable glossary terms to their entry slide (native
# PowerPoint slide-jump). Delegated registry function; degrades silently.
n_links = _wire_glossary_links(st, notes)
prs.save(out_path)
n_slides = st.slide_no
except Exception as e: # noqa: BLE001
@@ -549,7 +783,35 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
"note": f"fallo al escribir el PPTX: {e}"}
note = f"{n_slides} slides"
if n_links:
note += f" · {n_links} enlaces de glosario"
if notes:
note += " · " + "; ".join(notes)
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
"note": note}
def _wire_glossary_links(st: _PptxState, notes: list) -> int:
"""Turn each recorded term run into a native jump to its glossary slide.
Returns the number of links applied. A term whose only appearance is inside
its own glossary entry (source slide == target slide) is skipped. Never
raises."""
if not st.term_runs or not st.term_anchor_slide:
return 0
linked = 0
try:
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
except Exception as e: # noqa: BLE001
notes.append(f"glosario sin enlaces: {e}")
return 0
for key, run, src_slide in st.term_runs:
tgt = st.term_anchor_slide.get(key)
if tgt is None or tgt is src_slide:
continue
try:
if pptx_link_run_to_slide(run, src_slide, tgt):
linked += 1
except Exception: # noqa: BLE001 — links are best-effort.
pass
return linked
@@ -24,6 +24,13 @@ import textwrap
# the visible text matches ``strip_inline_md`` exactly.
_INLINE_SPAN_RE = re.compile(r"(\*\*.+?\*\*|__.+?__|`.+?`)")
# Glossary term span: ``[[term:key]]texto visible[[/term]]``. The visible text
# (which may itself contain ``**bold**``) is kept and tagged with ``key`` so the
# renderers can turn each appearance into a clickable jump to the glossary entry.
_TERM_SPAN_RE = re.compile(r"\[\[term:([A-Za-z0-9_]+)\]\](.*?)\[\[/term\]\]",
re.S)
_TERM_OPEN_RE = re.compile(r"\[\[term:[A-Za-z0-9_]+\]\]")
def avg_char_width_in(fontsize_pt: float) -> float:
"""Approximate average glyph width in inches for a sans-serif font.
@@ -86,11 +93,21 @@ def strip_inline_md(text: str) -> str:
if not text:
return ""
s = str(text)
# Drop glossary term markers, keeping the visible inner text.
s = _TERM_SPAN_RE.sub(lambda m: m.group(2), s)
s = _TERM_OPEN_RE.sub("", s) # leftover unbalanced open marker.
s = s.replace("[[/term]]", "") # leftover unbalanced close marker.
for marker in ("**", "__", "`"):
s = s.replace(marker, "")
return s
def _strip_term_markers(s: str) -> str:
"""Remove any (balanced or leftover) glossary term markers, keeping text."""
s = _TERM_OPEN_RE.sub("", s)
return s.replace("[[/term]]", "")
def _strip_leftover_markers(s: str) -> str:
"""Drop any unbalanced inline markers from a plain (non-span) fragment.
@@ -222,6 +239,118 @@ def wrap_rich(text: str, max_chars: int):
return lines or [[("", False)]]
def parse_inline_rich(text: str):
"""Split ``text`` into ``[(fragment, is_bold, term_key), ...]``.
Extends :func:`parse_inline_bold` with glossary term spans
``[[term:key]]visible[[/term]]``: the inner ``visible`` text is parsed for
``**bold**`` as usual and every resulting fragment carries ``term_key`` so the
renderers can make it clickable. Text outside a term span gets ``term_key =
None``. Unbalanced term markers are stripped (kept identical to
:func:`strip_inline_md`). The concatenation of all fragment texts equals
``strip_inline_md(text)`` visible characters and wrapping are unchanged; only
the bold flag and the term key are added. Adjacent fragments with the same
(bold, term) are merged.
"""
s = "" if text is None else str(text)
if not s:
return []
out = []
def _emit(fragment: str, bold: bool, term) -> None:
if fragment == "":
return
if out and out[-1][1] == bold and out[-1][2] == term:
out[-1] = (out[-1][0] + fragment, bold, term)
else:
out.append((fragment, bold, term))
def _emit_bolded(segment: str, term) -> None:
# Reuse the bold parser on a term-marker-free segment.
for frag, bold in parse_inline_bold(_strip_term_markers(segment)):
_emit(frag, bold, term)
pos = 0
for m in _TERM_SPAN_RE.finditer(s):
if m.start() > pos:
_emit_bolded(s[pos:m.start()], None)
_emit_bolded(m.group(2), m.group(1))
pos = m.end()
if pos < len(s):
_emit_bolded(s[pos:], None)
return out
def wrap_rich_terms(text: str, max_chars: int):
"""Like :func:`wrap_rich` but preserving glossary term keys per fragment.
Returns ``list[list[(fragment, is_bold, term_key)]]`` one inner list per
output line. Wrapping is word-aware and hard-splits over-long tokens so no
line exceeds ``max_chars`` (the renderers measure these very lines). Term and
bold flags never widen a line: the visible width matches :func:`wrap`.
"""
if max_chars < 1:
max_chars = 1
spans = parse_inline_rich(text)
if not spans:
return [[("", False, None)]]
tokens = [] # each: (word, bold, term) or ("\n", None, None)
for frag, bold, term in spans:
parts = frag.split("\n")
for pi, part in enumerate(parts):
if pi > 0:
tokens.append(("\n", None, None))
for word in part.split(" "):
if word == "":
continue
tokens.append((word, bold, term))
lines = []
cur = []
cur_len = 0
def _flush():
nonlocal cur, cur_len
merged = []
for k, (word, bold, term) in enumerate(cur):
piece = word if k == 0 else " " + word
if merged and merged[-1][1] == bold and merged[-1][2] == term:
merged[-1] = (merged[-1][0] + piece, bold, term)
else:
merged.append((piece, bold, term))
lines.append(merged or [("", False, None)])
cur = []
cur_len = 0
for word, bold, term in tokens:
if bold is None: # forced newline
_flush()
continue
if len(word) > max_chars:
if cur:
_flush()
chunks = _hard_split(word, max_chars)
for ci, chunk in enumerate(chunks):
if ci < len(chunks) - 1:
lines.append([(chunk, bold, term)])
else:
cur = [(chunk, bold, term)]
cur_len = len(chunk)
continue
add = len(word) if cur_len == 0 else cur_len + 1 + len(word)
if cur_len != 0 and add > max_chars:
_flush()
cur = [(word, bold, term)]
cur_len = len(word)
else:
cur.append((word, bold, term))
cur_len = add
if cur:
_flush()
return lines or [[("", False, None)]]
def parse_md_table(lines: list):
"""Parse consecutive ``| a | b |`` lines into ``(header, rows)`` or None.
@@ -20,6 +20,10 @@ vacia y el resto del ctx se construye igual. Ante un fallo global devuelve al
menos ``{**base_ctx, "db_path": db_path, "table": table}``.
Claves de DATOS que produce (las consumen los capitulos):
- ``head_rows`` : [ {col: valor, ...}, ... ] primeras filas CRUDAS de la
tabla (``SELECT * LIMIT head_n``), una entrada por fila.
La lee el capitulo OVERVIEW para mostrar df.head real en
lugar del placeholder "df.head no disponible".
- ``raw_numeric`` : {col: [float|None, ...]} muestra cruda de las columnas
numericas, ALINEADA POR FILA (una entrada por fila aunque
sea None). La leen modelos (clustering 2D en vivo) y
@@ -56,7 +60,7 @@ def _to_float(value):
return None
def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None):
def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None, head_n=10):
"""Construye el ctx de datos crudos para los renderers de AutomaticEDA.
Args:
@@ -77,13 +81,15 @@ def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000,
base_ctx: dict opcional con claves de presentacion ya preparadas
(dataset_name, source_origin, ...). Se parte de una copia y NO se
pisan sus claves; solo se añaden las de datos. Default None -> {}.
head_n: numero de filas crudas a muestrear para ``ctx["head_rows"]``
(df.head del capitulo OVERVIEW). Default 10. <=0 omite la clave.
Returns:
El dict ``ctx`` directamente (NO un wrapper {status,...}): se pasa tal
cual como ``meta={"ctx": <ese dict>}`` a render_automatic_eda_pdf/pptx.
Nunca lanza. Claves que puede contener: raw_numeric, timeseries_raw,
geo_points (omitidas si no aplican o fallan), y siempre db_path + table
para backends validos.
Nunca lanza. Claves que puede contener: head_rows, raw_numeric,
timeseries_raw, geo_points (omitidas si no aplican o fallan), y siempre
db_path + table para backends validos.
"""
# Copia de base_ctx: nunca mutamos el dict del caller. Las claves de
# presentacion que ya traiga se conservan; las de datos se añaden encima.
@@ -117,6 +123,24 @@ def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000,
ctx["db_path"] = db_path
ctx["table"] = table
# 1.5) head_rows: primeras filas CRUDAS de la tabla (SELECT * LIMIT n)
# para que el capitulo OVERVIEW muestre df.head real en vez del
# placeholder. Una sola query, dict-no-throw: si falla, se omite la
# clave (el capitulo degrada a su nota honesta). No se pisa una clave
# head_rows que ya viniera en base_ctx (presentacion).
if head_n and int(head_n) > 0 and "head_rows" not in ctx:
try:
hq = query_fn(f'SELECT * FROM "{table}" LIMIT {int(head_n)}')
if isinstance(hq, dict) and hq.get("status") == "ok":
hrows = [
dict(r) for r in (hq.get("rows") or [])
if isinstance(r, dict)
]
if hrows:
ctx["head_rows"] = hrows
except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave
pass
# 2) Columnas del perfil agregado (lectura defensiva).
cols = profile.get("columns") if isinstance(profile, dict) else None
cols = cols or []
@@ -4,10 +4,10 @@ name: column_quality_score
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "2.0.0"
purity: pure
signature: "def column_quality_score(col: dict) -> dict"
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda, con desglose completeness/validity/consistency y lista de issues legibles. Funcion pura, no muta el input."
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda. Combina completeness (0.6) y validity (0.4) con renormalizacion por aplicabilidad; los outliers, columnas constantes e ids NO bajan el score (van a observations). Devuelve desglose por dimension, issues (defectos) y observations (señales analiticas). Funcion pura, no muta el input."
tags: [eda, data-quality, profiling, scoring, datascience]
uses_functions: []
uses_types: []
@@ -17,20 +17,26 @@ error_type: ""
imports: []
example: |
from datascience import column_quality_score
col = {"name": "precio", "inferred_type": "float", "null_pct": 0.2,
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 0.08}}
col = {"name": "precio", "inferred_type": "numeric", "null_pct": 0.2,
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 8.0}}
column_quality_score(col)
# {"score": 86.8, "completeness": 0.8, "validity": 0.92,
# "consistency": 1.0, "issues": ["20% nulos", "8% outliers"]}
# {"score": 88.0, "completeness": 0.8, "validity": 1.0,
# "applicable": ["completeness", "validity"], "issues": ["20% nulos"],
# "observations": ["8% de valores atípicos (z-score>3): ..."]}
tested: true
tests:
- "test_clean_column_high_score"
- "test_half_null_lowers_completeness_and_score"
- "test_constant_column_flags_issue"
- "test_weights_60_40_native_type"
- "test_outliers_do_not_penalize_score"
- "test_nulls_lower_score_more_than_outliers"
- "test_validity_from_parse_rate_lowers_score"
- "test_validity_from_match_rate"
- "test_free_text_renormalizes_to_completeness_only"
- "test_all_null_column_scores_zero"
- "test_constant_column_scores_full_and_is_observation"
- "test_high_cardinality_id_scores_full_and_is_observation"
- "test_mostly_null_no_double_counts_validity"
- "test_empty_dict_does_not_crash"
- "test_outliers_penalize_validity"
- "test_mostly_null_flag_halves_validity"
- "test_high_cardinality_text_flagged_as_id"
- "test_none_values_treated_defensively"
- "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/column_quality_score_test.py"
@@ -38,16 +44,22 @@ file_path: "python/functions/datascience/column_quality_score.py"
params:
- name: col
desc: >
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb).
Se leen sus claves de forma defensiva con .get(...) y se toleran valores
None. Claves usadas: null_pct (0-1), inferred_type, semantic_type,
unique_pct (0-1), flags (list[str], reconoce "constant"/"mostly_null"),
numeric ({outlier_pct: 0-1, ...}|None) y match_rate (opcional, 0-1).
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb /
profile_table). Se leen sus claves de forma defensiva con .get(...) y se
toleran valores None. Claves usadas: null_pct (0-1), n_rows, empty_count
(texto), inferred_type, semantic_type, validity_rate (0-1, lo expone
profile_table al promocionar texto a numero/fecha), match_rate (0-1),
unique_pct (0-1), flags (list[str], reconoce
"constant"/"possible_id"/"high_cardinality") y numeric ({outlier_pct: 0-100,
skew, ...}|None).
output: >
dict con score (float 0-100, redondeado a 1 decimal), completeness (0-1),
validity (0-1), consistency (0-1) e issues (list[str] de descripciones
legibles de los problemas detectados). score = round(100 * (0.5*completeness
+ 0.3*validity + 0.2*consistency), 1).
dict con score (float 0-100, 1 decimal), completeness (0-1), validity (0-1 o
None si no aplicable), dimensions ({completeness, validity}), applicable
(list[str] de dimensiones que entraron en el score), issues (list[str] SOLO de
defectos de calidad: nulos, vacios, valores no conformes) y observations
(list[str] de señales analiticas que NO bajan el score: outliers, columna
constante, posible id, asimetria). score = round(100 * (0.6*completeness +
0.4*validity) / pesos_aplicables, 1), renormalizado cuando validity no aplica.
---
## Ejemplo
@@ -59,51 +71,71 @@ from datascience import column_quality_score
col = {
"name": "precio",
"physical_type": "DOUBLE",
"inferred_type": "float",
"inferred_type": "numeric",
"semantic_type": "",
"count": 800,
"n_rows": 1000,
"null_count": 200,
"null_pct": 0.20,
"distinct_count": 400,
"unique_pct": 0.40,
"flags": [],
"numeric": {"outlier_pct": 0.08},
"numeric": {"outlier_pct": 8.0, "skew": 0.3},
"categorical": None,
"datetime": None,
}
column_quality_score(col)
# {
# "score": 86.8,
# "completeness": 0.8, # 1 - 0.20
# "validity": 0.92, # 1 - min(0.08, 0.3)
# "consistency": 1.0,
# "issues": ["20% nulos", "8% outliers"],
# "score": 88.0, # 100 * (0.6*0.8 + 0.4*1.0)
# "completeness": 0.8, # 1 - 0.20
# "validity": 1.0, # numerica nativa: el tipo es conforme
# "dimensions": {"completeness": 0.8, "validity": 1.0},
# "applicable": ["completeness", "validity"],
# "issues": ["20% nulos"], # SOLO defectos de calidad
# "observations": ["8% de valores atípicos (z-score>3): ..."], # NO bajan score
# }
```
## Cuando usarla
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
`summarize_table_duckdb`) y necesites un numero 0-100 por columna para
ordenar/priorizar limpieza de datos, pintar semaforos de calidad en un
dashboard, o decidir que columnas descartar antes de modelar. Es la capa de
scoring sobre el ColumnProfile crudo: lee el perfil, no toca los datos.
`summarize_table_duckdb` / `profile_table`) y necesites un numero 0-100 por
columna para ordenar/priorizar limpieza de datos, pintar semaforos de calidad,
o decidir que columnas descartar antes de modelar. Separa los **defectos de
calidad reales** (`issues`: nulos, vacios, valores que no parsean a su tipo) de
las **observaciones analiticas** (`observations`: outliers, columnas constantes,
ids), que se reportan pero no penalizan. Es la capa de scoring sobre el
ColumnProfile crudo: lee el perfil, no toca los datos.
## Notas
## Gotchas
Funcion pura, sin I/O ni dependencias externas, no muta `col`. Lee todas las
claves con `.get(...)` y tolera que vengan en `None` (un ColumnProfile recien
salido de `summarize_table_duckdb` trae muchas claves a `None`), por lo que
nunca falla por claves ausentes — un `{}` produce un resultado bien definido.
Funcion pura, sin I/O, no muta `col`. Aun asi conviene saber:
Pesos del score: completeness 0.5, validity 0.3, consistency 0.2.
- **Los outliers NO bajan el score.** Un valor extremo puede ser real y correcto
(un cliente que compra mucho); detectar atipicos es analisis de la
distribucion, no un juicio de correccion. Salen en `observations`, no en
`issues`. Mismo trato para columnas constantes e identificadores de alta
cardinalidad: son observaciones, no defectos.
- **`validity` puede ser `None`** (no aplicable): texto libre sin `semantic_type`
ni `validity_rate`, o columna 100% nula. En ese caso el score se renormaliza a
solo `completeness` (la columna no se premia ni castiga por algo no medible).
- **`outlier_pct` se interpreta en escala 0-100** (la que emite
`describe_numeric`, z-score>3). Pasar una fraccion 0-1 produce un texto de
observacion con el % equivocado, pero NUNCA afecta al score.
- **`validity_rate` lo puebla `profile_table`** al promocionar una columna de
texto a numero/fecha (fraccion que parsea). Si no esta presente y el tipo es
nativo numerico/fecha/bool, `validity = 1.0`.
- Sin doble conteo: la falta de datos cuenta solo en `completeness` (el antiguo
castigo de `mostly_null` sobre `validity` se elimino).
- **completeness** = `1 - null_pct` (None -> 0 nulls -> 1.0).
- **validity**: parte de 1.0 y penaliza `min(outlier_pct, 0.3)` en columnas
numericas, `0.5 * (1 - match_rate)` si hay `semantic_type` declarado con
`match_rate` bajo disponible, y multiplica por 0.5 si el flag `mostly_null`
esta presente.
- **consistency**: 1.0 salvo flag `constant` (-> 0.3, columna poco informativa)
o texto con `unique_pct > 0.9` (-> 0.6, posible id de alta cardinalidad).
## Capability growth log
- v2.0.0 (2026-06-30) — nueva formula de calidad (report 2046): pesos 60/40
(completeness/validity) con renormalizacion por aplicabilidad; se elimina la
dimension `consistency`-como-informatividad y el doble castigo de
`mostly_null`; los outliers/constantes/ids salen del score a `observations`;
validity mide conformidad real (parse rate / match rate / tipo nativo). Salida
ampliada con `dimensions`, `applicable` y `observations`.
- v1.0.0 — version inicial: pesos 50/30/20 (completeness/validity/consistency),
los outliers penalizaban validity (con bug de escala) y consistency penalizaba
informatividad.
@@ -1,34 +1,78 @@
"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.
Funcion pura: dado el perfil de una columna producido por el grupo de
capacidad `eda` (p.ej. summarize_table_duckdb), calcula un score agregado
de calidad junto a su desglose en completeness / validity / consistency y
una lista de issues legibles. No realiza I/O ni muta el input.
capacidad `eda` (p.ej. summarize_table_duckdb / profile_table), calcula un
score agregado de calidad junto a su desglose por dimension y dos listas
legibles separadas: `issues` (defectos de calidad reales que SI bajan el
score) y `observations` (señales analiticas que NO bajan el score). No
realiza I/O ni muta el input.
Modelo (DAMA-DMBOK / ISO 8000), ver report 2046:
- Solo entran en el score las dimensiones medibles automaticamente desde el
perfil, sin fuente externa de verdad: completeness y validity por columna.
- Renormalizacion por aplicabilidad: si una dimension no es medible en la
columna (texto libre sin semantica -> validity no aplica; columna 100% nula
-> validity no medible), se excluye y los pesos se renormalizan sobre las
aplicables. Una columna ni se premia ni se castiga por algo no medible.
- Sin doble conteo: la falta de datos cuenta solo en completeness (se elimino
el antiguo castigo extra de `mostly_null` sobre validity).
- Los OUTLIERS NO bajan la calidad. Un valor extremo puede ser real y
correcto; detectar atipicos es analisis de la distribucion, no un juicio de
coreccion. Outliers, columnas constantes e identificadores de alta
cardinalidad pasan a `observations`, nunca a `issues`.
"""
# Pesos base de las dimensiones de columna (se renormalizan por aplicabilidad).
_W_COMPLETENESS = 0.6
_W_VALIDITY = 0.4
# Tipos inferidos cuyo almacen garantiza la conformidad de tipo (validity=1.0)
# cuando NO vienen de una promocion de texto (en cuyo caso manda validity_rate).
_NATIVE_TYPED = ("numeric", "integer", "float", "datetime", "date", "boolean", "bool")
def column_quality_score(col: dict) -> dict:
"""Calcula un score de calidad de datos 0-100 para un ColumnProfile.
El score pondera tres dimensiones:
- completeness (0.5): proporcion de valores no nulos.
- validity (0.3): ausencia de outliers / heuristicas de validez.
- consistency (0.2): la columna aporta informacion (no constante, no ruido).
El score combina solo dimensiones de calidad medibles desde el perfil, con
renormalizacion por aplicabilidad:
- completeness (peso base 0.6, siempre aplica): proporcion de valores
presentes = 1 - null_pct. En texto, las celdas vacias (`empty_count`)
tambien cuentan como faltantes.
- validity (peso base 0.4, cuando hay un criterio de validacion real):
fraccion de valores no nulos conformes a su tipo/semantica. Tipo nativo
numerico/fecha/bool = 1.0; texto promovido a numero/fecha = parse rate
(`validity_rate`); texto con `semantic_type` regexable = `match_rate`;
texto libre o columna 100% nula = NO aplicable (renormaliza a solo
completeness).
Los outliers, columnas constantes, identificadores y asimetria fuerte NO
bajan el score: se devuelven en `observations`.
Args:
col: ColumnProfile dict del grupo eda. Se leen las claves de forma
defensiva con .get(...) y se tolera que muchas vengan en None.
Claves relevantes: null_pct, inferred_type, semantic_type,
unique_pct, flags (list[str]), numeric ({outlier_pct, ...}|None),
match_rate (opcional).
Claves relevantes: null_pct (0-1), n_rows, empty_count,
inferred_type, semantic_type, validity_rate (0-1, lo expone
profile_table al promocionar texto a numero/fecha), match_rate
(0-1), unique_pct (0-1), flags (list[str], reconoce
"constant"/"possible_id"/"high_cardinality"), numeric
({outlier_pct: 0-100, skew, ...}|None).
Returns:
dict con:
score (float, 0-100, redondeado a 1 decimal),
completeness (float, 0-1),
validity (float, 0-1),
consistency (float, 0-1),
issues (list[str]) descripciones legibles de los problemas.
score (float 0-100, redondeado a 1 decimal),
completeness (float 0-1),
validity (float 0-1 | None si no aplicable),
dimensions ({completeness, validity}),
applicable (list[str] de dimensiones que entraron en el score),
issues (list[str]) SOLO defectos de calidad (nulos, vacios,
valores no conformes a su tipo/semantica),
observations (list[str]) señales analiticas que NO bajan el score
(outliers, columna constante, posible id, asimetria).
"""
if not isinstance(col, dict):
col = {}
@@ -39,103 +83,153 @@ def column_quality_score(col: dict) -> dict:
flags = set(flags)
issues: list[str] = []
observations: list[str] = []
inferred_type = col.get("inferred_type") or ""
semantic_type = col.get("semantic_type") or ""
# --- completeness -------------------------------------------------
null_pct = col.get("null_pct")
if null_pct is None:
null_pct = 0.0
try:
null_pct = float(null_pct)
except (TypeError, ValueError):
null_pct = 0.0
null_pct = _clamp(null_pct, 0.0, 1.0)
# Falta de datos = nulos + (en texto) celdas vacias. Es el unico sitio
# donde la falta de datos cuenta: nunca se duplica en validity.
null_pct = _clamp(_num(col.get("null_pct"), 0.0), 0.0, 1.0)
completeness = 1.0 - null_pct
if null_pct > 0:
issues.append(f"{round(null_pct * 100)}% nulos")
issues.append(f"{_pct(null_pct)} nulos")
# --- validity -----------------------------------------------------
validity = 1.0
inferred_type = col.get("inferred_type") or ""
empty_frac = 0.0
n_rows = col.get("n_rows")
empty_count = col.get("empty_count")
if (
isinstance(n_rows, (int, float)) and not isinstance(n_rows, bool) and n_rows > 0
and isinstance(empty_count, (int, float)) and not isinstance(empty_count, bool)
and empty_count > 0
):
empty_frac = _clamp(float(empty_count) / float(n_rows), 0.0, 1.0)
completeness = _clamp(completeness - empty_frac, 0.0, 1.0)
issues.append(f"{_pct(empty_frac)} vacíos")
numeric = col.get("numeric")
is_numeric = inferred_type in ("integer", "float", "numeric") or isinstance(numeric, dict)
if isinstance(numeric, dict):
outlier_pct = numeric.get("outlier_pct")
if outlier_pct is not None:
try:
outlier_pct = float(outlier_pct)
except (TypeError, ValueError):
outlier_pct = 0.0
outlier_pct = _clamp(outlier_pct, 0.0, 1.0)
if outlier_pct > 0:
penalty = min(outlier_pct, 0.3)
validity -= penalty
issues.append(f"{round(outlier_pct * 100)}% outliers")
# semantic_type declarado pero con baja tasa de match (si la conocemos).
semantic_type = col.get("semantic_type") or ""
match_rate = col.get("match_rate")
if semantic_type and match_rate is not None:
try:
match_rate = float(match_rate)
except (TypeError, ValueError):
match_rate = None
if match_rate is not None:
match_rate = _clamp(match_rate, 0.0, 1.0)
if match_rate < 1.0:
shortfall = 1.0 - match_rate
validity -= 0.5 * shortfall
issues.append(
f"semantic_type '{semantic_type}' con baja coincidencia "
f"({round(match_rate * 100)}%)"
)
if "mostly_null" in flags:
validity *= 0.5
issues.append("mayoritariamente nula")
validity = _clamp(validity, 0.0, 1.0)
# --- consistency --------------------------------------------------
consistency = 1.0
if "constant" in flags:
consistency = 0.3
issues.append("columna constante")
# --- validity (con renormalizacion por aplicabilidad) -------------
# None = no medible -> se excluye del score (no penaliza ni premia).
validity = None
if completeness <= 0.0:
# Columna 100% faltante: no hay valores no nulos sobre los que medir
# conformidad. validity no aplica -> el score sale solo de completeness
# (= 0). Es el peor defecto de calidad posible.
validity = None
else:
unique_pct = col.get("unique_pct")
if unique_pct is not None:
try:
unique_pct = float(unique_pct)
except (TypeError, ValueError):
unique_pct = None
if (
inferred_type == "text"
validity_rate = col.get("validity_rate")
match_rate = col.get("match_rate")
if validity_rate is not None:
# Texto promovido a numero/fecha: parse rate real de la muestra.
v = _num(validity_rate, None)
if v is not None:
validity = _clamp(v, 0.0, 1.0)
if validity < 1.0:
kind = (
"número" if inferred_type == "numeric"
else "fecha" if inferred_type == "datetime"
else inferred_type or "su tipo"
)
issues.append(
f"{_pct(1.0 - validity)} no parsea al tipo {kind}"
)
elif inferred_type in _NATIVE_TYPED:
# Tipo nativo garantizado por el almacen: no hay valores que no
# parseen. validity = 1.0 (no se confunde con tener outliers).
validity = 1.0
elif semantic_type and match_rate is not None:
v = _num(match_rate, None)
if v is not None:
validity = _clamp(v, 0.0, 1.0)
if validity < 1.0:
issues.append(
f"{_pct(1.0 - validity)} no casa con el "
f"formato «{semantic_type}»"
)
else:
# Texto libre / categorica sin semantica: no hay criterio honesto
# de validez. No aplica.
validity = None
# --- observations (NO bajan el score) -----------------------------
numeric = col.get("numeric")
if isinstance(numeric, dict):
# outlier_pct viene en escala 0-100 desde describe_numeric (z-score>3).
outlier_pct = _num(numeric.get("outlier_pct"), None)
if outlier_pct is not None and outlier_pct >= 0.05:
observations.append(
f"{_pct(outlier_pct / 100.0)} de valores atípicos (z-score>3): "
"revisar si son errores u observaciones legítimas"
)
skew = _num(numeric.get("skew"), None)
if skew is not None and abs(skew) >= 1.0:
observations.append(
f"asimetría fuerte (skew={round(skew, 2)}): considerar "
"re-expresión antes de modelar"
)
if "constant" in flags:
observations.append(
"columna constante: aporta poca información para el análisis"
)
unique_pct = _num(col.get("unique_pct"), None)
is_id = (
"possible_id" in flags
or "high_cardinality" in flags
or (
inferred_type in ("text", "categorical")
and unique_pct is not None
and _clamp(unique_pct, 0.0, 1.0) > 0.9
):
consistency = 0.6
issues.append("posible id de alta cardinalidad")
consistency = _clamp(consistency, 0.0, 1.0)
# --- score agregado ----------------------------------------------
score = round(
100.0 * (0.5 * completeness + 0.3 * validity + 0.2 * consistency),
1,
)
)
if is_id:
observations.append(
"valores casi únicos: posible identificador (no es un defecto de calidad)"
)
# Silencia warnings sobre la variable de tipo no usada.
_ = is_numeric
# --- score agregado con renormalizacion ---------------------------
applicable = ["completeness"]
num = _W_COMPLETENESS * completeness
den = _W_COMPLETENESS
if validity is not None:
applicable.append("validity")
num += _W_VALIDITY * validity
den += _W_VALIDITY
score = round(100.0 * num / den, 1) if den > 0 else 0.0
return {
"score": score,
"completeness": completeness,
"validity": validity,
"consistency": consistency,
"dimensions": {"completeness": completeness, "validity": validity},
"applicable": applicable,
"issues": issues,
"observations": observations,
}
def _pct(frac: float) -> str:
"""Formatea una fraccion 0-1 como porcentaje honesto: «N%» si >=1%, «0.N%»
por debajo (para no mostrar «0%» cuando hay un defecto real pequeño)."""
p = frac * 100.0
if p >= 1.0:
return f"{round(p)}%"
return f"{p:.1f}%"
def _num(x, default):
"""Convierte x a float; devuelve `default` si es None o no parseable."""
if x is None:
return default
if isinstance(x, bool):
return default
try:
return float(x)
except (TypeError, ValueError):
return default
def _clamp(x: float, lo: float, hi: float) -> float:
"""Recorta x al rango [lo, hi]."""
if x < lo:
@@ -1,4 +1,12 @@
"""Tests para column_quality_score."""
"""Tests para column_quality_score (nueva fórmula, report 2046).
Verifica las invariantes de la fórmula de calidad:
- completeness (0.6) + validity (0.4) con renormalización por aplicabilidad.
- Los OUTLIERS no bajan el score (van a observations, no a issues).
- Columnas constantes e ids no bajan el score (observations).
- Sin doble conteo de la falta de datos.
- all-null -> score 0; función pura (no muta el input).
"""
import os
import sys
@@ -9,11 +17,11 @@ from column_quality_score import column_quality_score
def _clean_numeric_col() -> dict:
"""ColumnProfile de una columna numerica sana, sin problemas."""
"""ColumnProfile de una columna numérica nativa sana, sin problemas."""
return {
"name": "edad",
"physical_type": "INTEGER",
"inferred_type": "integer",
"inferred_type": "numeric",
"semantic_type": "",
"count": 1000,
"n_rows": 1000,
@@ -28,85 +36,163 @@ def _clean_numeric_col() -> dict:
}
# --------------------------------------------------------------------------- #
# Golden
# --------------------------------------------------------------------------- #
def test_clean_column_high_score():
out = column_quality_score(_clean_numeric_col())
assert out["score"] > 90
assert out["score"] == 100.0
assert out["completeness"] == 1.0
assert out["validity"] == 1.0
assert out["consistency"] == 1.0
assert out["applicable"] == ["completeness", "validity"]
assert out["issues"] == []
assert out["observations"] == []
def test_half_null_lowers_completeness_and_score():
def test_weights_60_40_native_type():
"""30% nulos en numérica nativa: score = 100*(0.6*0.7 + 0.4*1.0) = 82."""
col = _clean_numeric_col()
col["null_count"] = 500
col["null_pct"] = 0.5
clean_score = column_quality_score(_clean_numeric_col())["score"]
col["null_pct"] = 0.30
col["null_count"] = 300
out = column_quality_score(col)
assert out["completeness"] == 0.5
assert out["score"] < clean_score
assert any("nulos" in issue for issue in out["issues"])
assert out["completeness"] == 0.7
assert out["validity"] == 1.0
assert out["score"] == 82.0
assert any("nulos" in i for i in out["issues"])
def test_constant_column_flags_issue():
# --------------------------------------------------------------------------- #
# Outliers FUERA del score
# --------------------------------------------------------------------------- #
def test_outliers_do_not_penalize_score():
"""Columna con outliers pero sin nulos -> score máximo; outliers en observations."""
col = _clean_numeric_col()
col["numeric"] = {"outlier_pct": 18.0, "skew": 0.2} # 18% atípicos (escala 0-100)
out = column_quality_score(col)
assert out["score"] == 100.0 # los outliers NO bajan la calidad
assert out["validity"] == 1.0
# No aparecen como problema de calidad...
assert not any("atípic" in i or "outlier" in i for i in out["issues"])
# ...sino como observación analítica.
assert any("atípic" in o for o in out["observations"])
def test_nulls_lower_score_more_than_outliers():
"""Vacíos sí penalizan; outliers no: comparar las dos columnas."""
con_nulos = _clean_numeric_col()
con_nulos["null_pct"] = 0.30
con_outliers = _clean_numeric_col()
con_outliers["numeric"] = {"outlier_pct": 30.0}
assert column_quality_score(con_nulos)["score"] < \
column_quality_score(con_outliers)["score"]
# --------------------------------------------------------------------------- #
# Validity: aplicabilidad y renormalización
# --------------------------------------------------------------------------- #
def test_validity_from_parse_rate_lowers_score():
"""Numérica como texto con 20% basura: validity=0.8 -> score=92."""
col = {
"name": "precio_txt", "inferred_type": "numeric", "semantic_type": "decimal",
"null_pct": 0.0, "validity_rate": 0.80, "flags": [], "numeric": None,
}
out = column_quality_score(col)
assert out["validity"] == 0.8
assert out["score"] == 92.0 # 100*(0.6 + 0.4*0.8)
assert any("no parsea" in i for i in out["issues"])
def test_validity_from_match_rate():
"""Texto con semantic_type y 5% no conforme: validity=0.95."""
col = {
"name": "email", "inferred_type": "text", "semantic_type": "email",
"null_pct": 0.0, "match_rate": 0.95, "unique_pct": 0.5, "flags": [],
}
out = column_quality_score(col)
assert out["validity"] == 0.95
assert out["score"] == 98.0 # 100*(0.6 + 0.4*0.95)
assert any("no casa" in i for i in out["issues"])
def test_free_text_renormalizes_to_completeness_only():
"""Texto libre sin semántica: validity no aplica -> score = 100*completeness."""
col = {
"name": "comentario", "inferred_type": "text", "semantic_type": "",
"null_pct": 0.30, "unique_pct": 0.5, "flags": [], "numeric": None,
}
out = column_quality_score(col)
assert out["validity"] is None
assert out["applicable"] == ["completeness"]
assert out["completeness"] == 0.7
assert out["score"] == 70.0 # renormalizado a solo completeness
# --------------------------------------------------------------------------- #
# Casos límite (report §4.6)
# --------------------------------------------------------------------------- #
def test_all_null_column_scores_zero():
col = _clean_numeric_col()
col["null_pct"] = 1.0
col["null_count"] = 1000
out = column_quality_score(col)
assert out["completeness"] == 0.0
assert out["validity"] is None # no medible sin valores no nulos
assert out["score"] == 0.0
def test_constant_column_scores_full_and_is_observation():
"""Columna constante: dato válido y completo -> score 100; baja info = observación."""
col = _clean_numeric_col()
col["flags"] = ["constant"]
col["distinct_count"] = 1
col["unique_pct"] = 0.001
out = column_quality_score(col)
assert out["consistency"] == 0.3
assert any("constante" in issue for issue in out["issues"])
assert out["score"] == 100.0 # NO se castiga la baja informatividad
assert not any("constante" in i for i in out["issues"])
assert any("constante" in o for o in out["observations"])
def test_high_cardinality_id_scores_full_and_is_observation():
"""Id de alta cardinalidad: unicidad perfecta -> score 100; posible id = observación."""
col = {
"name": "uuid", "inferred_type": "text", "semantic_type": "",
"null_pct": 0.0, "unique_pct": 0.99, "flags": ["possible_id"],
"numeric": None,
}
out = column_quality_score(col)
assert out["score"] == 100.0
assert not any("identificador" in i for i in out["issues"])
assert any("identificador" in o for o in out["observations"])
def test_mostly_null_no_double_counts_validity():
"""85% nulos: solo completeness penaliza; validity nativa sigue 1.0 (sin doble castigo)."""
col = _clean_numeric_col()
col["null_pct"] = 0.85
col["flags"] = ["mostly_null"]
out = column_quality_score(col)
assert out["validity"] == 1.0 # ya no se multiplica por 0.5
# score = 100*(0.6*0.15 + 0.4*1.0) = 49
assert out["score"] == 49.0
assert not any("mayoritariamente" in o for o in out["observations"])
# --------------------------------------------------------------------------- #
# Robustez
# --------------------------------------------------------------------------- #
def test_empty_dict_does_not_crash():
out = column_quality_score({})
assert isinstance(out["score"], float)
assert out["completeness"] == 1.0
assert 0.0 <= out["score"] <= 100.0
assert isinstance(out["issues"], list)
def test_outliers_penalize_validity():
col = _clean_numeric_col()
col["numeric"] = {"outlier_pct": 0.2}
out = column_quality_score(col)
assert out["validity"] < 1.0
assert any("outliers" in issue for issue in out["issues"])
def test_mostly_null_flag_halves_validity():
col = _clean_numeric_col()
col["null_pct"] = 0.85
col["flags"] = ["mostly_null"]
out = column_quality_score(col)
assert out["validity"] == 0.5
assert any("mayoritariamente nula" in issue for issue in out["issues"])
def test_high_cardinality_text_flagged_as_id():
col = {
"name": "uuid",
"inferred_type": "text",
"semantic_type": "",
"null_pct": 0.0,
"unique_pct": 0.99,
"flags": [],
"numeric": None,
}
out = column_quality_score(col)
assert out["consistency"] < 1.0
assert any("alta cardinalidad" in issue for issue in out["issues"])
assert isinstance(out["observations"], list)
def test_none_values_treated_defensively():
col = {
"name": "x",
"inferred_type": None,
"semantic_type": None,
"null_pct": None,
"unique_pct": None,
"flags": None,
"numeric": None,
"name": "x", "inferred_type": None, "semantic_type": None,
"null_pct": None, "unique_pct": None, "flags": None, "numeric": None,
}
out = column_quality_score(col)
assert out["completeness"] == 1.0
@@ -0,0 +1,107 @@
---
name: detect_declared_keys_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict"
description: "Detecta las claves DECLARADAS (constraints reales) de un schema DuckDB leyendo la table function duckdb_constraints(): extrae PRIMARY KEY, FOREIGN KEY y UNIQUE (ignora NOT NULL y CHECK) y las devuelve normalizadas con sus columnas, y para las FK con su tabla y columnas referenciadas. Con table=None procesa todas las tablas; con table='X' filtra a PK/UNIQUE de X y a FK cuyo origen es X (case-sensitive). A diferencia de infer_fk_containment_duckdb (que INFIERE FKs candidatas por containment de valores cuando el schema no las declara), esta funcion devuelve las relaciones de clave REALES del schema. Estilo dict-no-throw: nunca lanza. Parte del grupo eda (relaciones de clave)."
tags: [eda, duckdb, datascience, relations, primary-key, foreign-key, schema, exploratory-data-analysis]
params:
- name: db_path
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via duckdb_query_readonly; no se crea). Un path inexistente devuelve {status:'error', ...}."
- name: table
desc: "Si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea `table` (no la referenciada). None (default) devuelve los constraints de todas las tablas. La comparacion es case-sensitive (nombres tal cual los devuelve DuckDB)."
output: "dict dict-no-throw. En exito {status:'ok', primary_keys:[{table:str, columns:[str,...]}, ...], foreign_keys:[{table:str, columns:[str,...], referenced_table:str, referenced_columns:[str,...]}, ...], unique:[{table:str, columns:[str,...]}, ...], tables:[str,...]} donde tables es la lista ordenada de tablas (origen) que poseen al menos un constraint PK/FK/UNIQUE emitido. Solo se emiten constraints de clave: NOT NULL y CHECK se ignoran. En error {status:'error', error:str}."
uses_functions: [duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_detecta_pks_y_fk", "test_golden_ignora_not_null_y_check", "test_edge_filtra_por_tabla_orders", "test_edge_filtra_por_tabla_customers", "test_edge_unique_declarado", "test_edge_sin_constraints_listas_vacias", "test_error_db_inexistente_no_lanza", "test_shape_resultado"]
test_file_path: "python/functions/datascience/detect_declared_keys_duckdb_test.py"
file_path: "python/functions/datascience/detect_declared_keys_duckdb.py"
---
## Ejemplo
```python
import sys, os, duckdb
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import detect_declared_keys_duckdb
# Base de ejemplo en /tmp: orders.customer_id -> customers.id (FK declarada)
path = "/tmp/declared_keys_demo.duckdb"
if os.path.exists(path):
os.remove(path)
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders("
" id INTEGER PRIMARY KEY,"
" customer_id INTEGER REFERENCES customers(id),"
" amt DOUBLE)"
)
con.close()
res = detect_declared_keys_duckdb(path)
if res["status"] == "ok":
for pk in res["primary_keys"]:
print(f"PK {pk['table']}({', '.join(pk['columns'])})")
for fk in res["foreign_keys"]:
print(f"FK {fk['table']}({', '.join(fk['columns'])}) -> "
f"{fk['referenced_table']}({', '.join(fk['referenced_columns'])})")
# PK customers(id)
# PK orders(id)
# FK orders(customer_id) -> customers(id)
else:
print("error:", res["error"])
# Filtrar a una tabla concreta (PK/UNIQUE de orders + FK con origen orders):
solo_orders = detect_declared_keys_duckdb(path, table="orders")
print(solo_orders["tables"]) # ['orders']
```
## Cuando usarla
- Cuando exploras un esquema DuckDB y quieres mostrar las relaciones de clave REALES (PK/FK/UNIQUE) que el schema ha declarado, sin inferir nada.
- Como paso del capitulo RELACIONES del grupo `eda`: primero mira las claves declaradas con esta funcion; si el schema no declara FKs, complementa con `infer_fk_containment_duckdb` (inferencia por containment).
- Antes de documentar o migrar un esquema, para listar el contrato de integridad referencial que el motor ya conoce.
- Para validar que las constraints que esperas (esa FK que creaste con `REFERENCES`) realmente estan declaradas en la base materializada.
## Gotchas
- **Impura**: lee de disco via la primitiva read-only `duckdb_query_readonly` (no crea ni modifica la base). El `db_path` debe existir; un path inexistente devuelve `{status:'error'}` (read_only NO crea la base).
- **Requiere `duckdb_constraints()`**: usa la table function `duckdb_constraints()`, disponible en DuckDB modernos (verificado en 1.5.2). En versiones antiguas sin esa funcion, la query falla y se devuelve `{status:'error'}`.
- **Solo claves DECLARADAS**: devuelve lo que el schema declaro con `PRIMARY KEY` / `FOREIGN KEY (... REFERENCES ...)` / `UNIQUE`. Una tabla materializada con `CREATE TABLE AS SELECT` NO lleva constraints — para esos casos no habra claves que mostrar y hay que INFERIRLAS (`infer_fk_containment_duckdb`).
- **NOT NULL y CHECK se ignoran**: `duckdb_constraints()` tambien emite filas `NOT NULL` (DuckDB genera una por cada columna PK) y `CHECK`; esta funcion las descarta y solo conserva PK/FK/UNIQUE.
- **Nombres case-sensitive**: el filtro `table='Orders'` no casa con una tabla `orders`. Se comparan los nombres tal cual los devuelve DuckDB.
- **FK atribuida al origen**: una FOREIGN KEY se atribuye a su tabla ORIGEN (el `table` de la entrada), no a la referenciada. El filtro `table='X'` trae las FK cuyo origen es X, no las que apuntan a X.
- **`tables` = tablas dueñas de constraints emitidos**: la lista `tables` contiene solo las tablas que poseen al menos un PK/FK/UNIQUE en el resultado (su campo `table`), ordenadas. No incluye tablas referenciadas que no tengan constraint propio en la salida.
- **Columnas como listas**: `constraint_column_names` y `referenced_column_names` son columnas LIST de DuckDB; en 1.5.2 llegan como listas Python. La funcion las normaliza a listas de strings con una red de seguridad por si llegaran como string.
## Notas
`duckdb_constraints()` devuelve una fila por constraint con los campos
`table_name`, `constraint_type`, `constraint_column_names`, `referenced_table`,
`referenced_column_names`. Mapeo a la salida:
```text
PRIMARY KEY -> primary_keys[]: {table, columns}
UNIQUE -> unique[]: {table, columns}
FOREIGN KEY -> foreign_keys[]: {table, columns, referenced_table, referenced_columns}
NOT NULL -> ignorado
CHECK -> ignorado
```
Para una FK, `referenced_table` y `referenced_column_names` vienen poblados; para
PK/UNIQUE, `referenced_table` es NULL y `referenced_column_names` una lista vacia.
Complementa a `infer_fk_containment_duckdb`: esta funcion devuelve las relaciones
de clave REALES del schema (declaradas); la otra INFIERE FKs candidatas por
containment de valores cuando el schema no las declaro. En el capitulo RELACIONES
de AutomaticEDA se usan en orden: primero las declaradas, luego la inferencia como
respaldo.
@@ -0,0 +1,127 @@
"""detect_declared_keys_duckdb — lee las claves DECLARADAS de un schema DuckDB.
Funcion impura: lee de disco a traves de la primitiva read-only del grupo
`duckdb` (duckdb_query_readonly). Pertenece al grupo de capacidad `eda`
(relaciones de clave): a diferencia de infer_fk_containment_duckdb, que INFIERE
FOREIGN KEYs candidatas por containment de valores, esta funcion devuelve las
constraints REALES que el schema ha declarado (PRIMARY KEY / FOREIGN KEY /
UNIQUE) leyendo la table function `duckdb_constraints()`.
Es la pieza del capitulo RELACIONES de AutomaticEDA que muestra las relaciones de
clave reales cuando existen frente a la inferencia, que se usa cuando el schema
no las declaro.
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
devuelve {status:'error', error:str}.
"""
from infra import duckdb_query_readonly
def _as_list(value) -> list:
"""Normaliza el valor de una columna LIST de DuckDB a una lista de strings.
En DuckDB 1.5.2, `constraint_column_names` y `referenced_column_names` llegan
ya como listas Python a traves de duckdb_query_readonly. Este helper es solo
una red de seguridad: si por cualquier motivo llegara como string (p.ej. la
representacion `[id, customer_id]`), la parsea de forma defensiva.
"""
if value is None:
return []
if isinstance(value, (list, tuple)):
return [str(v) for v in value]
if isinstance(value, str):
s = value.strip()
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
if not s.strip():
return []
return [
part.strip().strip("'\"")
for part in s.split(",")
if part.strip().strip("'\"")
]
return [str(value)]
def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict:
"""Detecta las claves PRIMARY KEY / FOREIGN KEY / UNIQUE declaradas en DuckDB.
Lee la table function `duckdb_constraints()` y extrae solo las constraints de
clave (PRIMARY KEY, FOREIGN KEY, UNIQUE), ignorando NOT NULL y CHECK.
Args:
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only; no se
crea). Un path inexistente devuelve {status:'error', ...} sin lanzar.
table: si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY
y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea
`table`. None (default) devuelve los constraints de todas las tablas.
La comparacion de nombres es case-sensitive (tal cual los devuelve
DuckDB).
Returns:
dict dict-no-throw. En exito:
{status:'ok',
primary_keys:[{table:str, columns:[str, ...]}, ...],
foreign_keys:[{table:str, columns:[str, ...],
referenced_table:str,
referenced_columns:[str, ...]}, ...],
unique:[{table:str, columns:[str, ...]}, ...],
tables:[str, ...]} # tablas (origen) con algun PK/FK/UNIQUE emitido
En error (sin lanzar): {status:'error', error:str}.
"""
try:
sql = (
"SELECT table_name, constraint_type, constraint_column_names, "
"referenced_table, referenced_column_names FROM duckdb_constraints()"
)
res = duckdb_query_readonly(db_path, sql)
if res["status"] != "ok":
return {"status": "error", "error": res["error"]}
primary_keys = []
foreign_keys = []
unique = []
tables = set()
for row in res["rows"]:
ctype = row["constraint_type"]
tname = row["table_name"]
# Filtro por tabla origen: para PK/FK/UNIQUE el dueño del constraint es
# `table_name`. Una FK se atribuye a su tabla origen (no a la
# referenciada), igual que el filtro pide.
if table is not None and tname != table:
continue
cols = _as_list(row["constraint_column_names"])
if ctype == "PRIMARY KEY":
primary_keys.append({"table": tname, "columns": cols})
tables.add(tname)
elif ctype == "UNIQUE":
unique.append({"table": tname, "columns": cols})
tables.add(tname)
elif ctype == "FOREIGN KEY":
foreign_keys.append(
{
"table": tname,
"columns": cols,
"referenced_table": row["referenced_table"],
"referenced_columns": _as_list(
row["referenced_column_names"]
),
}
)
tables.add(tname)
# NOT NULL y CHECK se ignoran: no son relaciones de clave.
return {
"status": "ok",
"primary_keys": primary_keys,
"foreign_keys": foreign_keys,
"unique": unique,
"tables": sorted(tables),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,167 @@
"""Tests para detect_declared_keys_duckdb."""
import duckdb
import pytest
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
@pytest.fixture
def db(tmp_path):
"""DuckDB temporal con claves declaradas.
- customers(id PRIMARY KEY, name)
- orders(id PRIMARY KEY, customer_id REFERENCES customers(id), amt)
Esto declara dos PRIMARY KEY (customers.id, orders.id) y una FOREIGN KEY
(orders.customer_id -> customers.id). DuckDB ademas genera constraints
NOT NULL para las columnas PK, que la funcion debe ignorar.
"""
path = str(tmp_path / "keys_test.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders("
" id INTEGER PRIMARY KEY,"
" customer_id INTEGER REFERENCES customers(id),"
" amt DOUBLE"
")"
)
con.close()
return path
def _pk_for(res, table):
"""Devuelve la entrada primary_keys cuya tabla es `table`, o None."""
for pk in res["primary_keys"]:
if pk["table"] == table:
return pk
return None
def test_golden_detecta_pks_y_fk(db):
"""Golden: detecta las dos PK y la FK declaradas, con valores concretos."""
res = detect_declared_keys_duckdb(db)
assert res["status"] == "ok"
# PRIMARY KEY de customers y de orders.
pk_customers = _pk_for(res, "customers")
pk_orders = _pk_for(res, "orders")
assert pk_customers is not None
assert pk_customers["columns"] == ["id"]
assert pk_orders is not None
assert pk_orders["columns"] == ["id"]
# FOREIGN KEY orders.customer_id -> customers.id.
assert len(res["foreign_keys"]) == 1
fk = res["foreign_keys"][0]
assert fk["table"] == "orders"
assert fk["columns"] == ["customer_id"]
assert fk["referenced_table"] == "customers"
assert fk["referenced_columns"] == ["id"]
# tables incluye ambas (origen de algun constraint).
assert res["tables"] == ["customers", "orders"]
def test_golden_ignora_not_null_y_check(db):
"""NOT NULL (auto-generado por las PK) no aparece como clave."""
res = detect_declared_keys_duckdb(db)
assert res["status"] == "ok"
# Solo 2 PK reales (no las NOT NULL que DuckDB genera por cada columna PK).
assert len(res["primary_keys"]) == 2
# No hay UNIQUE declarado en este schema.
assert res["unique"] == []
def test_edge_filtra_por_tabla_orders(db):
"""Edge table='orders': PK de orders + su FK; NO la PK de customers."""
res = detect_declared_keys_duckdb(db, table="orders")
assert res["status"] == "ok"
# Solo la PK de orders.
assert len(res["primary_keys"]) == 1
assert res["primary_keys"][0]["table"] == "orders"
assert res["primary_keys"][0]["columns"] == ["id"]
# La PK de customers NO esta.
assert _pk_for(res, "customers") is None
# La FK de orders si esta (origen = orders).
assert len(res["foreign_keys"]) == 1
assert res["foreign_keys"][0]["table"] == "orders"
assert res["foreign_keys"][0]["referenced_table"] == "customers"
# tables solo contiene orders (la dueña de los constraints emitidos).
assert res["tables"] == ["orders"]
def test_edge_filtra_por_tabla_customers(db):
"""Edge table='customers': solo su PK; ninguna FK (orders queda fuera)."""
res = detect_declared_keys_duckdb(db, table="customers")
assert res["status"] == "ok"
assert len(res["primary_keys"]) == 1
assert res["primary_keys"][0]["table"] == "customers"
assert res["foreign_keys"] == []
assert res["tables"] == ["customers"]
def test_edge_unique_declarado(tmp_path):
"""Edge: una constraint UNIQUE declarada aparece en `unique`."""
path = str(tmp_path / "unique_test.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE products(sku INTEGER UNIQUE, name TEXT)")
con.close()
res = detect_declared_keys_duckdb(path)
assert res["status"] == "ok"
assert len(res["unique"]) == 1
assert res["unique"][0]["table"] == "products"
assert res["unique"][0]["columns"] == ["sku"]
assert res["primary_keys"] == []
assert res["foreign_keys"] == []
assert res["tables"] == ["products"]
def test_edge_sin_constraints_listas_vacias(tmp_path):
"""Edge: tabla sin PK/FK/UNIQUE -> todas las listas vacias, status ok."""
path = str(tmp_path / "no_keys.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE log(a INTEGER, b INTEGER)")
con.close()
res = detect_declared_keys_duckdb(path)
assert res["status"] == "ok"
assert res["primary_keys"] == []
assert res["foreign_keys"] == []
assert res["unique"] == []
assert res["tables"] == []
def test_error_db_inexistente_no_lanza(tmp_path):
"""Error: db_path inexistente -> status error, sin lanzar excepcion."""
path = str(tmp_path / "does_not_exist.duckdb")
res = detect_declared_keys_duckdb(path)
assert res["status"] == "error"
assert isinstance(res["error"], str)
assert res["error"] != ""
def test_shape_resultado(db):
"""El retorno tiene exactamente las claves esperadas."""
res = detect_declared_keys_duckdb(db)
assert set(res.keys()) == {
"status",
"primary_keys",
"foreign_keys",
"unique",
"tables",
}
for pk in res["primary_keys"]:
assert set(pk.keys()) == {"table", "columns"}
for fk in res["foreign_keys"]:
assert set(fk.keys()) == {
"table",
"columns",
"referenced_table",
"referenced_columns",
}
@@ -0,0 +1,85 @@
---
name: pptx_link_run_to_slide
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def pptx_link_run_to_slide(run, source_slide, target_slide) -> bool"
description: "Convierte un run de texto de python-pptx en un hyperlink INTERNO 'ir a la diapositiva'. python-pptx soporta run.hyperlink.address para URLs externas pero NO para saltar a otra slide del mismo deck; esta función crea ese salto manipulando el XML: añade una relación slide->slide (RT.SLIDE) y un <a:hlinkClick> con action='ppaction://hlinksldjump' y el r:id de la relación, insertado como primer hijo del <a:rPr> del run (orden del schema CT_TextCharacterProperties). Idempotente (elimina un hlinkClick previo antes de insertar). Al pulsar el texto en PowerPoint o visores compatibles se navega a target_slide. Motor python-pptx. No lanza nunca: cualquier excepción -> return False."
tags: [eda, pptx, hyperlink, slide-jump, navigation, glossary, automatic-eda, python-pptx, xml, datascience, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["python-pptx"]
params:
- name: run
desc: "el pptx.text.text._Run cuyo texto se vuelve clicable. Debe pertenecer a un run real (expone ._r, el elemento <a:r>). Un objeto sin ._r hace que la función devuelva False sin lanzar."
- name: source_slide
desc: "la Slide que contiene el run. Su part recibe la relación slide->slide (relate_to con RELATIONSHIP_TYPE.SLIDE); el r:id resultante se referencia en el hlinkClick."
- name: target_slide
desc: "la Slide de destino del salto. Debe pertenecer al MISMO Presentation que source_slide para que la relación interna sea válida."
output: "bool. True si se aplicó el hyperlink interno (relación creada + <a:hlinkClick> insertado en el rPr del run); False si algo lo impidió (run inválido, slides de presentaciones distintas, etc.). Nunca lanza."
tested: true
tests: ["test_golden_run_se_vuelve_salto_a_otra_slide", "test_idempotente_reaplica_sin_duplicar_hlinkclick", "test_error_path_run_invalido_devuelve_false_sin_lanzar"]
test_file_path: "python/functions/datascience/pptx_link_run_to_slide_test.py"
file_path: "python/functions/datascience/pptx_link_run_to_slide.py"
---
## Ejemplo
```python
from pptx import Presentation
from pptx.util import Inches
from pptx.oxml.ns import qn
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
prs = Presentation()
blank = prs.slide_layouts[6] # layout en blanco
slide0 = prs.slides.add_slide(blank)
slide1 = prs.slides.add_slide(blank) # destino del salto (p.ej. el glosario)
box = slide0.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
run = box.text_frame.paragraphs[0].add_run()
run.text = "ir al glosario"
ok = pptx_link_run_to_slide(run, slide0, slide1)
print(ok) # -> True
# El run quedó con <a:rPr><a:hlinkClick action="ppaction://hlinksldjump" r:id="rIdN"/></a:rPr>
hlink = run._r.get_or_add_rPr().find(qn("a:hlinkClick"))
print(hlink.get("action")) # -> ppaction://hlinksldjump
prs.save("deck_con_salto.pptx")
```
## Cuando usarla
Cuando construyas un deck PPTX con **navegación interna** y quieras que un texto salte a
otra diapositiva al pulsarlo: un **glosario clicable** (cada término enlaza a su slide de
definición), un **índice/tabla de contenidos navegable**, botones "volver a la portada", o
referencias cruzadas entre capítulos. Es la pieza que `python-pptx` no cubre de fábrica —
úsala sobre los runs ya creados por renderers como `render_automatic_eda_pptx` del grupo
`eda` para enriquecer el deck con saltos sin reescribir el XML a mano cada vez.
## Gotchas
- **Impura**: muta el XML del run y crea una relación nueva en el part de `source_slide`.
- **Solo navega en visores que respetan `ppaction://hlinksldjump`**: PowerPoint y la
mayoría de visores compatibles lo siguen; algunos visores web/ligeros lo ignoran (el
texto se ve igual pero no salta).
- **Mismo Presentation**: `source_slide` y `target_slide` deben pertenecer al mismo deck.
Si son de presentaciones distintas, la relación interna no es válida y el salto no
funcionará (la función puede devolver True por crear la relación, pero el resultado en
el visor no será el esperado).
- **El `<a:hlinkClick>` vive en el `<a:rPr>` del run**, no como hijo directo del `<a:r>`.
Para localizarlo: `run._r.get_or_add_rPr().find(qn("a:hlinkClick"))` (un `find` sobre
`run._r` devuelve `None` porque solo mira hijos directos del `<a:r>`).
- **Idempotente**: si el run ya tenía un `hlinkClick` (p.ej. una URL externa o un salto
previo), se elimina antes de insertar el nuevo — un run tiene como mucho un click-link.
- **Nunca lanza**: cualquier excepción (run sin `._r`, slides incompatibles, etc.) se
traga y devuelve `False`. Comprobar el booleano si el salto es crítico.
- **Dependencia python-pptx**: declarada en `python/pyproject.toml`. Tests con
`~/fn_registry/python/.venv/bin/python3` (tiene `python-pptx` instalado).
@@ -0,0 +1,50 @@
"""Convierte un run de texto de python-pptx en un hyperlink interno "ir a la diapositiva".
python-pptx expone ``run.hyperlink.address`` para URLs externas, pero NO ofrece una
API pública para saltar a otra diapositiva del mismo deck. Esta función crea ese salto
interno manipulando el XML: añade una relación ``slide -> slide`` y un
``<a:hlinkClick>`` con la acción ``ppaction://hlinksldjump`` en el run, de modo que al
pulsar el texto en PowerPoint (o en visores que respetan esa acción) se navega a la
diapositiva de destino.
"""
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from pptx.oxml.ns import qn
def pptx_link_run_to_slide(run, source_slide, target_slide) -> bool:
"""Convierte un run de texto en un hyperlink interno "ir a la diapositiva".
Añade una relación ``slide -> slide`` desde la slide origen al part de la slide
destino y crea un ``<a:hlinkClick>`` con ``action="ppaction://hlinksldjump"`` como
primer hijo del ``<a:rPr>`` del run (orden válido del schema
``CT_TextCharacterProperties``). La operación es idempotente: un ``hlinkClick``
previo en el mismo run se elimina antes de insertar el nuevo.
Args:
run: el ``pptx.text.text._Run`` cuyo texto se vuelve clicable.
source_slide: la ``Slide`` que contiene el run.
target_slide: la ``Slide`` de destino del salto.
Returns:
True si se aplicó el hyperlink; False si algo impidió aplicarlo (no lanza).
"""
try:
rId = source_slide.part.relate_to(target_slide.part, RT.SLIDE)
rPr = run._r.get_or_add_rPr()
# Elimina un hlinkClick previo si lo hubiera (idempotente).
for existing in rPr.findall(qn("a:hlinkClick")):
rPr.remove(existing)
hlink = rPr.makeelement(
qn("a:hlinkClick"),
{
qn("r:id"): rId,
"action": "ppaction://hlinksldjump",
},
)
# a:hlinkClick debe ir como primer hijo de rPr
# (orden del schema CT_TextCharacterProperties).
rPr.insert(0, hlink)
return True
except Exception:
return False
@@ -0,0 +1,73 @@
"""Tests for pptx_link_run_to_slide — salto interno run -> diapositiva.
Self-contained: construye una Presentation en memoria con dos slides en blanco,
un textbox con un run en la slide 0, y verifica que la función inyecta un
``<a:hlinkClick>`` con ``action="ppaction://hlinksldjump"`` y un ``r:id`` que
resuelve al part de la slide 1.
"""
import pytest
pytest.importorskip("pptx")
from pptx import Presentation # noqa: E402
from pptx.oxml.ns import qn # noqa: E402
from pptx.util import Inches # noqa: E402
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide # noqa: E402
def _two_slide_deck_with_run():
prs = Presentation()
blank = prs.slide_layouts[6] # layout en blanco
slide0 = prs.slides.add_slide(blank)
slide1 = prs.slides.add_slide(blank)
box = slide0.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = box.text_frame
para = tf.paragraphs[0]
run = para.add_run()
run.text = "ir al glosario"
return prs, slide0, slide1, run
def test_golden_run_se_vuelve_salto_a_otra_slide():
prs, slide0, slide1, run = _two_slide_deck_with_run()
ok = pptx_link_run_to_slide(run, slide0, slide1)
assert ok is True
# El hlinkClick es hijo del rPr del run (orden del schema
# CT_TextCharacterProperties), no hijo directo del <a:r>.
rPr = run._r.get_or_add_rPr()
hlink = rPr.find(qn("a:hlinkClick"))
assert hlink is not None
assert hlink.get("action") == "ppaction://hlinksldjump"
rId = hlink.get(qn("r:id"))
assert rId, "el hlinkClick debe llevar un r:id no vacío"
# El rId debe existir en las relaciones de la slide origen y apuntar
# al part de la slide destino.
rels = slide0.part.rels
assert rId in rels
assert rels[rId].target_part is slide1.part
def test_idempotente_reaplica_sin_duplicar_hlinkclick():
prs, slide0, slide1, run = _two_slide_deck_with_run()
assert pptx_link_run_to_slide(run, slide0, slide1) is True
assert pptx_link_run_to_slide(run, slide0, slide1) is True
rPr = run._r.get_or_add_rPr()
hlinks = rPr.findall(qn("a:hlinkClick"))
assert len(hlinks) == 1
def test_error_path_run_invalido_devuelve_false_sin_lanzar():
prs, slide0, slide1, _run = _two_slide_deck_with_run()
# Un objeto sin ._r ni soporte de relación -> la función no lanza, devuelve False.
ok = pptx_link_run_to_slide(object(), slide0, slide1)
assert ok is False
@@ -0,0 +1,91 @@
---
name: suggest_intratable_fk_candidates
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def suggest_intratable_fk_candidates(profile: dict, max_candidates: int = 20) -> list"
description: "Sobre el TableProfile de UNA tabla (el dict de profile_table), sugiere por heuristica de nombre + cardinalidad que columnas PARECEN una clave foranea hacia otra tabla, cuando no hay relaciones inter-tabla que medir (una sola tabla). Es una SUGERENCIA, no una afirmacion: el ref_table_guess es el stem del nombre (customer_id -> customer) y NO confirma containment. Pura: solo lee el dict, sin I/O; nunca lanza (devuelve [])."
tags: [eda, datascience, relationships, foreign-key, fk, heuristic, schema, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: profile
desc: "TableProfile (dict que produce profile_table / summarize_table_*). Se leen de forma defensiva `columns` (lista de ColumnProfile con name/inferred_type/physical_type/distinct_count/unique_pct/flags), `n_rows` (int) y `key_candidates` (lista de nombres de columna ya candidatos a PK, que se excluyen). Si no es dict o no trae columns -> []."
- name: max_candidates
desc: "Tope de sugerencias devueltas (default 20). Las columnas candidatas se ordenan por distinct_count descendente (mas informativas primero) antes de cortar a este maximo."
output: "list (posiblemente vacia) de dicts, uno por columna sugerida, con claves: `column` (nombre), `ref_table_guess` (tabla conjeturada por el stem del nombre, p.ej. customer_id -> 'customer'), `reason` (frase humana que deja claro que es heuristica sin confirmar containment), `distinct_count` (int|None), `unique_pct` (float|None, fraccion 0-1 tal como viene del profile), `inferred_type` (str), `physical_type` (str). Nunca lanza."
tested: true
tests: ["test_golden_customer_id_detectado_otras_no", "test_camelcase_albumid_detectado", "test_constante_status_id_no_aparece", "test_profile_vacio_y_none_devuelven_lista_vacia", "test_category_id_casi_unico_parece_pk_no_aparece", "test_ref_table_guess_multitoken_y_orden_por_distinct", "test_max_candidates_corta_la_lista", "test_id_generico_solo_nunca_es_fk"]
test_file_path: "python/functions/datascience/suggest_intratable_fk_candidates_test.py"
file_path: "python/functions/datascience/suggest_intratable_fk_candidates.py"
---
## Ejemplo
```python
from datascience import suggest_intratable_fk_candidates
# TableProfile de UNA tabla (tipo titanic): customer_id es FK N:1; id es la PK;
# amount es una medida float; name es categorica sin sufijo de id.
profile = {
"n_rows": 891,
"key_candidates": ["id"],
"columns": [
{"name": "id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 891, "unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "customer_id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 137, "unique_pct": 0.15, "flags": []},
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 400, "unique_pct": 0.45, "flags": []},
{"name": "name", "inferred_type": "categorical", "physical_type": "VARCHAR",
"distinct_count": 700, "unique_pct": 0.78, "flags": []},
],
}
out = suggest_intratable_fk_candidates(profile)
[c["column"] for c in out] # -> ["customer_id"]
out[0]["ref_table_guess"] # -> "customer"
out[0]["reason"]
# -> "el nombre termina en '_id' y es N:1 (137 valores distintos < 891 filas):
# parece (heuristica por nombre, sin confirmar containment) una referencia a
# una tabla «customer»"
```
## Cuando usarla
Cuando el EDA tiene SOLO UNA tabla y, por tanto, no se puede inferir una FK
inter-tabla por containment (no hay otra tabla cuyos valores contener). Es el plan B
del capitulo RELACIONES de AutomaticEDA: en vez de medir solapamiento de valores
entre tablas (lo correcto cuando hay varias, ver `infer_fk_containment_duckdb` /
`build_join_graph`), conjetura por el NOMBRE de la columna (`<algo>_id`) y por su
CARDINALIDAD N:1 que columnas parecen apuntar a una entidad externa. Usala para
enriquecer el reporte con "estas columnas parecen referencias a otras tablas" sin
prometer que esa tabla exista. NO la uses si tienes varias tablas: ahi mide
containment de verdad.
## Gotchas
- Es **heuristica**, no una verdad: produce **falsos positivos** (una columna
`period_id` que en realidad es un codigo libre, no una FK) y **falsos negativos**
(una FK que no se llama `*_id`, p.ej. `parent`, `owner`, `sku`). No la trates como
una afirmacion de esquema.
- `ref_table_guess` es una **conjetura por el nombre** (el stem sin el sufijo id):
`customer_id` -> `customer`, `AlbumId` -> `album`, `manager_staff_id` ->
`manager_staff`. Puede no coincidir con el nombre real de la tabla (plurales,
prefijos, alias). Es una pista, no un join garantizado.
- **NO confirma containment**: no comprueba que los valores de la columna existan en
ninguna otra tabla (no puede — solo recibe el perfil de una tabla). Para confirmar
una FK real con varias tablas usa `infer_fk_containment_duckdb`.
- Excluye deliberadamente: el `id`/`Id`/`ID` generico a secas (suele ser la PK
propia, no una referencia), las columnas constantes, las que parecen unicas
(`unique_pct >= 0.99`, mas PK que FK) y los tipos no-clave (float/decimal son
medidas; date/time/timestamp y boolean no son claves). En camelCase, `paid`,
`valid`, `grid` (con `id` en minuscula y sin separador) NO se confunden con FK.
- `unique_pct` se interpreta como **fraccion 0-1** (tal como la emite el profile), no
como porcentaje 0-100.
@@ -0,0 +1,202 @@
"""suggest_intratable_fk_candidates — heuristica de FK intra-tabla del grupo `eda`.
Sobre el TableProfile de UNA tabla (el dict que produce ``profile_table``), sugiere
por heuristica de NOMBRE + CARDINALIDAD que columnas PARECEN una clave foranea hacia
otra tabla, util cuando no hay relaciones inter-tabla disponibles (una sola tabla y,
por tanto, sin containment cruzado que medir). Es una SUGERENCIA, no una afirmacion:
no confirma que exista la tabla referida ni que los valores esten contenidos en ella.
La consume el capitulo RELACIONES de AutomaticEDA cuando solo hay una tabla.
Funcion PURA: solo lee el dict (lectura defensiva con ``.get``), no hace I/O y nunca
lanza por inputs raros (devuelve ``[]``).
"""
# inferred_type que es compatible con una clave foranea (entero/categorico).
_FK_INFERRED_OK = {"numeric", "categorical", "integer"}
# Prefijos de physical_type que admiten ser clave foranea (enteros, texto, uuid).
_FK_PHYSICAL_PREFIXES = (
"int", "bigint", "smallint", "tinyint", "hugeint", "uint",
"varchar", "text", "char", "bpchar", "string", "uuid",
)
# Prefijos de physical_type que EXCLUYEN ser clave foranea: medidas en coma flotante
# (float/double/decimal/numeric/real), temporales (date/time/timestamp/interval) y
# boolean. Se comprueban ANTES que las senales positivas (la exclusion gana: una
# columna numeric con physical DOUBLE es una medida, no una FK).
_FK_PHYSICAL_EXCLUDE = (
"float", "double", "decimal", "numeric", "real",
"date", "time", "timestamp", "interval",
"bool",
)
def _fk_name_signal(name):
"""Detecta el sufijo de clave foranea en el nombre y devuelve ``(stem, sufijo)``.
Reconoce ``<algo>_id`` (snake), ``<Algo>Id`` y ``<algo>ID`` (camel). NO reconoce
el ``id``/``Id``/``ID`` generico a secas (suele ser la PK propia de la tabla, no
una referencia). En camelCase la ``I`` mayuscula marca el limite de palabra, asi
que ``paid``/``valid``/``grid`` (``id`` en minuscula y sin separador) NO matchean.
El ``stem`` se devuelve en minusculas y sirve de ``ref_table_guess`` (la tabla a
la que probablemente apunta): ``customer_id`` -> ``"customer"``, ``AlbumId`` ->
``"album"``, ``manager_staff_id`` -> ``"manager_staff"``. Devuelve ``None`` si no
hay senal de nombre.
"""
if not isinstance(name, str):
return None
raw = name.strip()
if not raw:
return None
# Snake: termina en "_id" (indiferente a mayusculas en la parte "id").
if raw.lower().endswith("_id"):
stem = raw[:-3].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "_id")
# Camel todo-mayuscula: "...ID" (p.ej. customerID).
if raw.endswith("ID"):
stem = raw[:-2].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "ID")
# Camel: "...Id" (p.ej. AlbumId).
if raw.endswith("Id"):
stem = raw[:-2].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "Id")
return None
def _fk_type_compatible(col):
"""True si el tipo de la columna admite ser clave foranea.
Compatible si el ``physical_type`` NO es una medida flotante, una temporal ni
boolean, Y ademas (``inferred_type`` en {numeric, categorical, integer} O el
``physical_type`` empieza por entero/varchar/text/char/uuid). La comparacion es
indistinta a mayusculas/minusculas.
"""
phys = (col.get("physical_type") or "").strip().lower()
inferred = (col.get("inferred_type") or "").strip().lower()
# Exclusion por tipo fisico (gana sobre cualquier senal positiva).
for bad in _FK_PHYSICAL_EXCLUDE:
if phys.startswith(bad):
return False
# Senal positiva por tipo inferido.
if inferred in _FK_INFERRED_OK:
return True
# Senal positiva por tipo fisico (entero/texto/uuid).
for good in _FK_PHYSICAL_PREFIXES:
if phys.startswith(good):
return True
return False
def suggest_intratable_fk_candidates(profile: dict, max_candidates: int = 20) -> list:
"""Sugiere columnas que parecen una FK intra-tabla por nombre + cardinalidad.
Heuristica (no afirma nada): una columna es candidata a clave foranea si su nombre
tiene sufijo de id con stem no vacio (``<algo>_id`` / ``<Algo>Id`` / ``<algo>ID``,
NUNCA el ``id`` generico), no es ya candidata a PK, no es constante, tiene
cardinalidad alta pero por debajo del numero de filas (N:1, no unica) y un tipo
compatible con clave (entero/categorico/texto/uuid; nunca float/fecha/boolean).
Args:
profile: TableProfile (dict de ``profile_table``). Se leen, de forma
defensiva, ``columns`` (lista de ColumnProfile), ``n_rows`` y
``key_candidates`` (nombres de columna ya candidatos a PK).
max_candidates: tope de sugerencias devueltas (default 20). Las columnas se
ordenan por ``distinct_count`` descendente (mas informativas primero)
antes de cortar.
Returns:
list de dicts (posiblemente vacia), uno por columna sugerida, con claves:
``column``, ``ref_table_guess`` (stem del nombre), ``reason`` (frase humana),
``distinct_count``, ``unique_pct`` (fraccion 0-1 tal como viene del profile),
``inferred_type``, ``physical_type``. Nunca lanza: si ``profile`` no es dict o
no hay columnas, devuelve ``[]``.
"""
if not isinstance(profile, dict):
return []
columns = profile.get("columns")
if not isinstance(columns, list):
return []
n_rows = profile.get("n_rows")
has_n_rows = (
isinstance(n_rows, int) and not isinstance(n_rows, bool) and n_rows > 0
)
key_candidates = profile.get("key_candidates")
if not isinstance(key_candidates, (list, tuple, set)):
key_candidates = []
key_set = set(key_candidates)
out = []
for col in columns:
if not isinstance(col, dict):
continue
name = col.get("name")
# 1) Senal de nombre: sufijo de id con stem no vacio.
signal = _fk_name_signal(name)
if signal is None:
continue
ref_guess, suffix = signal
# 2) No es ya candidata a PK (clave primaria de la propia tabla).
if name in key_set:
continue
# 3) No constante y con >= 2 valores distintos.
flags = col.get("flags") or []
if "constant" in flags:
continue
dc = col.get("distinct_count")
if not (isinstance(dc, int) and not isinstance(dc, bool) and dc >= 2):
continue
# 4) Cardinalidad alta pero < n_rows (no es PK) y no parece unica.
if has_n_rows and dc >= n_rows:
continue
unique_pct = col.get("unique_pct")
has_unique = (
isinstance(unique_pct, (int, float)) and not isinstance(unique_pct, bool)
)
if has_unique and unique_pct >= 0.99:
continue
# 5) Tipo compatible con clave foranea (entero/categorico/texto; no medida).
if not _fk_type_compatible(col):
continue
out.append(
{
"column": name,
"ref_table_guess": ref_guess,
"reason": _build_reason(suffix, dc, n_rows if has_n_rows else None, ref_guess),
"distinct_count": dc,
"unique_pct": float(unique_pct) if has_unique else None,
"inferred_type": col.get("inferred_type") or "",
"physical_type": col.get("physical_type") or "",
}
)
# Mas informativas primero (mayor cardinalidad), luego corte.
out.sort(key=lambda d: d.get("distinct_count") or 0, reverse=True)
return out[: max(0, int(max_candidates))]
def _build_reason(suffix, dc, n_rows, ref_guess):
"""Frase humana que deja claro que la sugerencia es heuristica, no confirmada."""
if n_rows is not None:
card = f"es N:1 ({dc} valores distintos < {n_rows} filas)"
else:
card = f"tiene {dc} valores distintos que se repiten (cardinalidad N:1)"
return (
f"el nombre termina en '{suffix}' y {card}: parece (heuristica por nombre, "
f"sin confirmar containment) una referencia a una tabla «{ref_guess}»"
)
@@ -0,0 +1,157 @@
"""Tests para suggest_intratable_fk_candidates (funcion pura, sin I/O)."""
from suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
def _col(name, inferred_type="numeric", physical_type="BIGINT", distinct_count=10,
unique_pct=0.1, flags=None):
"""Construye un ColumnProfile minimo a mano (el dict que emite profile_table)."""
return {
"name": name,
"inferred_type": inferred_type,
"physical_type": physical_type,
"semantic_type": "",
"distinct_count": distinct_count,
"unique_pct": unique_pct,
"null_count": 0,
"null_pct": 0.0,
"flags": list(flags) if flags else [],
}
def test_golden_customer_id_detectado_otras_no():
# Tabla tipo titanic: customer_id es FK N:1; id es la PK; amount es medida;
# name es categorica sin sufijo de id. Solo customer_id debe aparecer.
profile = {
"n_rows": 891,
"key_candidates": ["id"],
"columns": [
_col("id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=891, unique_pct=1.0, flags=["possible_id"]),
_col("customer_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=137, unique_pct=0.15, flags=[]),
_col("amount", inferred_type="numeric", physical_type="DOUBLE",
distinct_count=400, unique_pct=0.45),
_col("name", inferred_type="categorical", physical_type="VARCHAR",
distinct_count=700, unique_pct=0.78),
],
}
out = suggest_intratable_fk_candidates(profile)
assert isinstance(out, list)
assert [c["column"] for c in out] == ["customer_id"]
cand = out[0]
assert cand["ref_table_guess"] == "customer"
assert cand["distinct_count"] == 137
assert cand["unique_pct"] == 0.15
assert cand["inferred_type"] == "numeric"
assert cand["physical_type"] == "BIGINT"
# La razon deja claro que es heuristica + cita el sufijo y la tabla.
assert "customer" in cand["reason"]
assert "_id" in cand["reason"]
def test_camelcase_albumid_detectado():
# AlbumId (camelCase, VARCHAR) -> detectada, ref_table_guess "album".
profile = {
"n_rows": 3503,
"key_candidates": ["TrackId"],
"columns": [
_col("AlbumId", inferred_type="categorical", physical_type="VARCHAR",
distinct_count=347, unique_pct=0.10),
],
}
out = suggest_intratable_fk_candidates(profile)
# TrackId es PK candidata (en key_candidates), AlbumId no -> AlbumId aparece.
assert [c["column"] for c in out] == ["AlbumId"]
assert out[0]["ref_table_guess"] == "album"
def test_constante_status_id_no_aparece():
# status_id constante (flag "constant", distinct_count 1) NO es FK util.
profile = {
"n_rows": 1000,
"key_candidates": [],
"columns": [
_col("status_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=1, unique_pct=0.001, flags=["constant"]),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
def test_profile_vacio_y_none_devuelven_lista_vacia():
# Lectura defensiva: ni {} ni None lanzan; devuelven [].
assert suggest_intratable_fk_candidates({}) == []
assert suggest_intratable_fk_candidates(None) == []
# profile sin columns o con columns no-lista tampoco lanza.
assert suggest_intratable_fk_candidates({"n_rows": 10}) == []
assert suggest_intratable_fk_candidates({"columns": "no-soy-lista"}) == []
def test_category_id_casi_unico_parece_pk_no_aparece():
# unique_pct 0.999 -> parece PK (no N:1) -> NO se sugiere como FK.
profile = {
"n_rows": 891,
"key_candidates": [],
"columns": [
_col("category_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=890, unique_pct=0.999),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
def test_ref_table_guess_multitoken_y_orden_por_distinct():
# manager_staff_id conserva los underscores del stem -> "manager_staff".
# Ademas, con varias candidatas, se ordenan por distinct_count descendente.
profile = {
"n_rows": 10000,
"key_candidates": ["staff_id"], # staff_id es PK aqui, no debe aparecer
"columns": [
_col("staff_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=10000, unique_pct=1.0, flags=["possible_id"]),
_col("store_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=2, unique_pct=0.0002),
_col("manager_staff_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=40, unique_pct=0.004),
],
}
out = suggest_intratable_fk_candidates(profile)
cols = [c["column"] for c in out]
# staff_id excluida (PK); las otras dos ordenadas por distinct desc.
assert cols == ["manager_staff_id", "store_id"]
refs = {c["column"]: c["ref_table_guess"] for c in out}
assert refs["manager_staff_id"] == "manager_staff"
assert refs["store_id"] == "store"
def test_max_candidates_corta_la_lista():
# max_candidates limita el numero de sugerencias devueltas.
profile = {
"n_rows": 10000,
"key_candidates": [],
"columns": [
_col("a_id", distinct_count=300, unique_pct=0.03),
_col("b_id", distinct_count=200, unique_pct=0.02),
_col("c_id", distinct_count=100, unique_pct=0.01),
],
}
out = suggest_intratable_fk_candidates(profile, max_candidates=2)
assert [c["column"] for c in out] == ["a_id", "b_id"]
def test_id_generico_solo_nunca_es_fk():
# 'id'/'Id'/'ID' a secas (sin stem) jamas se sugieren como FK.
profile = {
"n_rows": 500,
"key_candidates": [],
"columns": [
_col("id", distinct_count=500, unique_pct=1.0),
_col("Id", distinct_count=120, unique_pct=0.24),
_col("ID", distinct_count=80, unique_pct=0.16),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
@@ -3,7 +3,7 @@ name: summarize_table_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def summarize_table_duckdb(db_path: str, table: str, high_card_ratio: float = 0.9) -> dict"
description: "Perfila una tabla DuckDB en una sola pasada SQL (SUMMARIZE, push-down sin traer filas a RAM) y devuelve el esqueleto de un TableProfile con el perfil base por columna. Corazon del grupo eda: base barata sobre la que otras funciones anaden lo estadistico fino (skew/kurtosis/histograma sobre muestra)."
@@ -64,6 +64,7 @@ else:
- **`distinct_count` exacto para tablas <=200k filas, aproximado+capado por encima**: `SUMMARIZE` usa HyperLogLog (`approx_unique`), que SOBREESTIMA y en tablas pequenas puede reportar mas distintos que filas (inflando `unique_pct` por encima de 1.0 y disparando flags `possible_id` falsos). Por eso, para `n_rows <= 200000` la funcion calcula `COUNT(DISTINCT)` EXACTO en una sola query combinada (barata) y usa ese valor. Para tablas mas grandes mantiene `approx_unique` pero lo CAPA a `n_rows` (`distinct_count = min(approx_unique, n_rows)`). En ambos casos `unique_pct = min(distinct_count / n_rows, 1.0)`, asi que `distinct_count` nunca supera las filas ni `unique_pct` pasa de 1.0. Los flags `possible_id` / `high_cardinality` derivan de ese `distinct_count` ya corregido (exacto y fiable por debajo de 200k filas; aproximado y conservador por encima).
- **`SUMMARIZE` NO da skew, kurtosis ni histograma**, ni percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates ni quality_score. Esas claves quedan en `None`/`[]` a proposito: las rellena otra funcion del grupo `eda` sobre una muestra. El sub-dict `numeric` solo trae min, max, mean, std, p25, p50, p75.
- **`SUMMARIZE.count` es el total de filas, no el no-nulo**: la funcion deriva el `count` no-nulo del ColumnProfile como `n_rows - null_count` (con `null_count` redondeado de `null_percentage`).
- **`duplicate_rows`/`duplicate_pct` se pueblan push-down** (desde v1.1.0) con `count(*)` sobre `SELECT DISTINCT *` (sin traer filas a RAM): `duplicate_rows = n_rows - filas_distintas`, `duplicate_pct` en fraccion 0-1. Habilitan la dimension de unicidad de registro del score de dataset (`profile_table` paso 6). Si la tabla tiene tipos no comparables con `DISTINCT` (BLOB/LIST/MAP) la query degrada y ambas vuelven a `None` (renormaliza el score a solo `cell_quality`).
- **min/max/avg/std/q25/q50/q75 vienen como strings** desde DuckDB; se convierten a float (None si la columna no es numerica).
- **Requiere DuckDB 1.5.2** (columnas de `SUMMARIZE` validadas con esa version: column_name, column_type, min, max, approx_unique, avg, std, q25, q50, q75, count, null_percentage).
- **El identificador de tabla se interpola** (no parametrizable en `SUMMARIZE`): por eso se valida contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de citarlo. Un nombre invalido (p.ej. con `;` o espacios) devuelve `{status:'error'}` sin tocar la base.
@@ -196,6 +196,21 @@ def summarize_table_duckdb(
sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0
)
# Unicidad de registro: filas duplicadas via COUNT de filas distintas
# push-down (DISTINCT *), sin traer filas a RAM. Habilita la dimension
# de uniqueness del score de dataset (1 - duplicate_pct). Degrada a None
# si la tabla tiene tipos no comparables con DISTINCT (BLOB/LIST/MAP).
duplicate_rows = None
duplicate_pct = None
if n_rows > 0:
dup_res = duckdb_query_readonly(
db_path, f"SELECT count(*) AS c FROM (SELECT DISTINCT * FROM {quoted})"
)
if dup_res["status"] == "ok" and dup_res["rows"]:
distinct_rows = int(dup_res["rows"][0]["c"])
duplicate_rows = max(0, n_rows - distinct_rows)
duplicate_pct = duplicate_rows / n_rows # fraccion 0-1
profile = {
"table": table,
"source": "duckdb",
@@ -203,8 +218,8 @@ def summarize_table_duckdb(
"n_rows": n_rows,
"n_cols": len(columns),
"size_bytes": None,
"duplicate_rows": None,
"duplicate_pct": None,
"duplicate_rows": duplicate_rows,
"duplicate_pct": duplicate_pct,
"constant_cols": constant_cols,
"all_null_cols": all_null_cols,
"null_cell_pct": null_cell_pct,
@@ -54,6 +54,30 @@ def test_shape_y_metadatos_tabla(db):
assert profile["correlations"] is None
def test_duplicate_pct_sin_duplicados(db):
"""Tabla con todas las filas distintas: duplicate_pct = 0, no None."""
profile = summarize_table_duckdb(db, "ventas")["profile"]
assert profile["duplicate_rows"] == 0
assert profile["duplicate_pct"] == 0.0
def test_duplicate_pct_con_duplicados(tmp_path):
"""Filas repetidas: duplicate_rows/duplicate_pct se pueblan push-down."""
path = str(tmp_path / "dups.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE t (a INTEGER, b VARCHAR)")
# 5 filas, 2 de ellas idénticas a otras -> 2 duplicadas sobre 5 = 0.4.
con.execute(
"INSERT INTO t VALUES "
"(1,'x'), (2,'y'), (1,'x'), (3,'z'), (2,'y')"
)
con.close()
profile = summarize_table_duckdb(path, "t")["profile"]
assert profile["n_rows"] == 5
assert profile["duplicate_rows"] == 2
assert profile["duplicate_pct"] == 0.4
def test_column_profile_shape(db):
profile = summarize_table_duckdb(db, "ventas")["profile"]
by_name = {c["name"]: c for c in profile["columns"]}
+10 -1
View File
@@ -4,7 +4,7 @@ kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
version: "1.1.0"
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, emit_automatic: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla."
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
@@ -114,3 +114,12 @@ para auditar la calidad de una tabla ya productiva. Reemplaza orquestar a mano
Formatos exoticos pueden descartarse silenciosamente del calculo numerico.
- `db_path` debe existir: DuckDB read-only NO crea la base. El muestreo usa el
sandbox por defecto de `duckdb_query_readonly` (sin acceso a FS/red).
- **Score de calidad (report 2046, desde v1.1.0).** Paso 5: cada columna recibe
`quality_score` de `column_quality_score` con la formula 60/40
(completeness/validity); al promocionar texto a numero/fecha se expone
`col["validity_rate"]` (parse rate de la muestra) para alimentar la dimension
validity. Paso 6: el score de dataset NO es la media simple — es
`100 * (0.85*cell_quality + 0.15*row_uniqueness)`, donde
`cell_quality = media(score_col/100)` y `row_uniqueness = 1 - duplicate_pct`.
Si `duplicate_pct` es `None` (backend sin calcularlo) el score se renormaliza
a solo `cell_quality`. Los outliers NO bajan el score (van a `observations`).
+51 -2
View File
@@ -477,9 +477,18 @@ def profile_table(
if vals and (len(ok) / len(vals)) >= _PROMOTE_MIN_PARSE:
col["inferred_type"] = "numeric"
inferred = "numeric"
# Tasa de parseo real de la muestra: alimenta la
# dimension validity de column_quality_score (fraccion
# de valores conformes al tipo numerico promovido).
col["validity_rate"] = len(ok) / len(vals)
elif semantic in _DATETIME_SEMANTIC:
col["inferred_type"] = "datetime"
inferred = "datetime"
# Tasa de parseo de la muestra a fecha (mismo papel que el
# parse rate numerico) para la dimension validity.
parsed_dt = [_to_ordinal_days(v) for v in vals]
ok_dt = [d for d in parsed_dt if d is not None]
col["validity_rate"] = (len(ok_dt) / len(vals)) if vals else None
# 4) Enriquecer segun el inferred_type final.
if inferred == "numeric":
@@ -506,11 +515,36 @@ def profile_table(
# 5) Score de calidad por columna.
col["quality_score"] = column_quality_score(col).get("score")
# 6) Score agregado de la tabla (media de columnas).
# 6) Score agregado de la tabla (report 2046): NO media simple.
# cell_quality = media de los scores de columna, en [0,1].
# row_uniqueness = 1 - duplicate_pct (unicidad de registro).
# score = 100 * (0.85*cell_quality + 0.15*row_uniqueness).
# Renormaliza a solo cell_quality si duplicate_pct no se pudo calcular.
scores = [
c["quality_score"] for c in cols if c.get("quality_score") is not None
]
prof["quality_score"] = round(sum(scores) / len(scores), 1) if scores else None
if scores:
cell_quality = (sum(scores) / len(scores)) / 100.0
dup_pct = prof.get("duplicate_pct")
if dup_pct is not None:
try:
d = float(dup_pct)
except (TypeError, ValueError):
d = None
else:
d = None
if d is not None:
# Tolerar escala 0-100 por si algun backend la entrega asi.
if d > 1.0:
d = d / 100.0
row_uniqueness = max(0.0, min(1.0, 1.0 - d))
prof["quality_score"] = round(
100.0 * (0.85 * cell_quality + 0.15 * row_uniqueness), 1
)
else:
prof["quality_score"] = round(100.0 * cell_quality, 1)
else:
prof["quality_score"] = None
# 7) Candidatos a clave.
key_candidates = []
@@ -536,6 +570,21 @@ def profile_table(
type_breakdown[it] += 1
prof["type_breakdown"] = type_breakdown
# 8.1) Primeras filas crudas (df.head) para el capitulo OVERVIEW del motor
# AutomaticEDA: una muestra SELECT col1,col2,... LIMIT 10 alineada por fila.
# Se reusa _sample_rows (mismo lector read-only). Estilo dict-no-throw: si
# falla, head_rows queda None y el capitulo degrada a su nota honesta. El
# capitulo lo recoge via profile["head_rows"]; build_eda_render_ctx ademas
# lo replica en ctx["head_rows"] cuando se construye el contexto de render.
try:
head_names = [c.get("name") for c in cols if c.get("name")]
head_rows = _sample_rows(_q, table, head_names, 10)
prof["head_rows"] = [
dict(r) for r in head_rows if isinstance(r, dict)
] or None
except Exception: # noqa: BLE001
prof["head_rows"] = None
# 8.5) Matriz de correlacion/asociacion sobre una muestra de filas
# alineadas. Elige la metrica por par de tipos (Pearson/Spearman,
# Cramer's V/Theil's U, correlation ratio, MI) via association_matrix.
@@ -4,9 +4,9 @@ kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = True, run_series: bool = True, run_llm: bool = False, out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
version: "1.1.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = None, run_models: bool = None, run_series: bool = None, run_llm: bool = None, profile_level: str = \"standard\", out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. El parametro profile_level es un preset de consumo CPU/LLM (lite/standard/full) que mapea a los flags run_models/run_series/run_llm/sample; un flag explicito siempre prima sobre el preset. lite=bajo consumo (sin LLM, sin serie, modelos solo PCA+normalidad sin KMeans/IsolationForest, sample reducido); standard=comportamiento historico; full=standard+narrativa LLM. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions:
- profile_table_py_pipelines
@@ -31,13 +31,15 @@ params:
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado y muestreo."
- name: sample
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default 5000."
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default None => lo fija el preset de profile_level (lite=2000, standard/full=5000). Un valor explicito prima sobre el preset."
- name: run_models
desc: "Si True (default) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA."
desc: "Corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA. Default None => lo fija el preset (True en los tres niveles); en lite los modelos se limitan a PCA+normalidad. Un valor explicito prima sobre el preset."
- name: run_series
desc: "Si True (default) calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries (la grafica de evolucion sale de los datos crudos del ctx aunque sea False)."
desc: "Calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries. Default None => lo fija el preset (standard/full=True, lite=False). Un valor explicito prima sobre el preset."
- name: run_llm
desc: "Si True (default False) hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red."
desc: "Hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red. Default None => lo fija el preset (full=True, lite/standard=False). Un valor explicito prima sobre el preset."
- name: profile_level
desc: "Preset de consumo CPU/LLM (default 'standard'). Mapea a defaults de run_models/run_series/run_llm/sample; un flag explicito SIEMPRE prima. 'lite'=bajo consumo (run_llm=False, run_series=False, sample=2000, modelos solo PCA+normalidad sin KMeans/IsolationForest); 'standard'=comportamiento historico (modelos completos, serie, sin LLM); 'full'=standard+narrativa LLM. Un nivel desconocido cae a 'standard'."
- name: out_dir
desc: "Directorio de salida (se crea si no existe). Default 'reports'."
- name: basename
@@ -52,14 +54,21 @@ output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None,
```python
from pipelines.render_automatic_eda import render_automatic_eda
# Tabla DuckDB con categoricas + fecha + numericas: informe completo a reports/.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
run_models=True, run_series=True, out_dir="reports")
# Informe completo a reports/ (standard = comportamiento por defecto historico).
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", out_dir="reports")
print(r["status"], r["pdf_path"], r["pptx_path"], r["n_pages"], r["n_slides"])
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 14 16
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 37 39
# Con narrativa LLM (titulos de segmento, descripcion geografica, etc.):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", run_llm=True)
# Bajo consumo (CPU/LLM): vistazo rapido y barato — sin LLM, sin serie, modelos
# solo PCA + normalidad (sin KMeans/IsolationForest), sample reducido.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="lite")
# Maximo: standard + narrativa LLM por capitulo (titulos de segmento, etc.).
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="full")
# Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM:
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
profile_level="lite", run_llm=True) # el LLM SI se ejecuta
```
## Cuando usarla
@@ -72,20 +81,41 @@ llama a los dos renderers": este pipeline orquesta `profile_table` ->
entregable para compartir un EDA, o como el motor detras de `profile_table(
emit_automatic=True)` y del skill `/eda`.
Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) usa
`profile_level="lite"`: evita KMeans + IsolationForest (lo caro en CPU), la serie
temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo.
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
- `db_path` debe existir: DuckDB read-only no crea la base.
- `run_models=True` y `run_series=True` por defecto encarecen el perfil (PCA/
KMeans/IsolationForest + ADF/KPSS/STL por columna). Para un informe mas barato
ponlos a False: los capitulos modelos/timeseries se omiten o se reducen, pero
el resto del informe sale igual.
- `run_llm=True` hace llamadas de red (interpretacion del perfil + narrativa por
capitulo). Sin red, dejalo en False: los capitulos siguen completos con su
derivacion cuantitativa (titulos de segmento derivados, nota geografica
derivada, seleccion de agregaciones cuantitativa).
- **Precedencia de flags vs preset**: `profile_level` solo fija los DEFAULTS de
`run_models`/`run_series`/`run_llm`/`sample` (los que quedan en None). Cualquiera
de esos flags pasado explicito gana al preset. Ej: `profile_level="lite",
run_llm=True` ejecuta el LLM pese a que lite lo apaga por defecto.
- **lite y la seleccion de features de modelo**: en lite los modelos (PCA +
normalidad) corren sobre la muestra numerica cruda (`ctx['raw_numeric']`), sin la
poda fina de features que aplica el modo standard (que excluye ids enteros y
columnas de baja cardinalidad antes de PCA/KMeans). Es el coste de mantener el
cableado minimo y barato; para el analisis fino de modelos usa standard/full.
- `profile_level="standard"`/`"full"` corren PCA/KMeans/IsolationForest +
ADF/KPSS/STL por columna (caro). Para un informe mas barato usa `"lite"` (o pon
los flags a False a mano): los capitulos modelos/timeseries se reducen pero el
resto del informe sale igual.
- `run_llm=True` (preset full o flag explicito) hace llamadas de red
(interpretacion del perfil + narrativa por capitulo). Sin red, usa lite/standard:
los capitulos siguen completos con su derivacion cuantitativa.
- El PPTX requiere `python-pptx`; si no esta instalado, `pptx_path` sera None y
`pptx_note` lo explica (el PDF se emite igual).
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla
entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria).
## Capability growth log
- v1.1.0 (2026-06-30) — anade el parametro `profile_level` (lite/standard/full),
preset de consumo CPU/LLM que mapea a los flags run_models/run_series/run_llm/
sample. lite limita los modelos a PCA+normalidad (cableado a run_eda_models con
run_kmeans=False/run_isolation=False) y apaga LLM/serie. Cambio aditivo y
retro-compatible: sin profile_level el comportamiento es identico al de v1.0.0.
@@ -34,21 +34,62 @@ from datascience import (
build_eda_render_ctx,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
run_eda_models,
)
from pipelines.profile_table import profile_table
# Tokens de almacenamiento por backend (para la portada del informe).
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}
# Presets de consumo CPU/LLM: cada profile_level fija SOLO los DEFAULTS de los
# flags que controlan el coste (un flag explícito del caller siempre prima sobre
# el preset). model_opts != None marca el camino "modelos baratos" (lite): los
# modelos NO los corre profile_table (que ejecutaría KMeans + IsolationForest),
# sino run_eda_models con esa granularidad, de modo que el coste CPU de los
# multivariantes nunca se paga. model_opts None => modelos completos como hasta
# ahora (profile_table los corre con todos los algoritmos).
_PROFILE_PRESETS = {
# Bajo consumo: sin LLM, sin serie, sample reducido y modelos limitados a
# PCA + normalidad (sin KMeans ni IsolationForest, lo caro en CPU). Vistazo
# rápido y barato de una tabla.
"lite": {
"run_models": True,
"run_series": False,
"run_llm": False,
"sample": 2000,
"model_opts": {"run_kmeans": False, "run_isolation": False},
},
# Default: idéntico al comportamiento histórico del pipeline (modelos
# completos, serie temporal, sin LLM, sample 5000).
"standard": {
"run_models": True,
"run_series": True,
"run_llm": False,
"sample": 5000,
"model_opts": None,
},
# Máximo: standard + narrativa LLM (interpretación del perfil y de los
# capítulos modelos/geospatial/agregacion). Es la única parte que gasta
# tokens del modelo.
"full": {
"run_models": True,
"run_series": True,
"run_llm": True,
"sample": 5000,
"model_opts": None,
},
}
def render_automatic_eda(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = True,
run_series: bool = True,
run_llm: bool = False,
sample: int = None,
run_models: bool = None,
run_series: bool = None,
run_llm: bool = None,
profile_level: str = "standard",
out_dir: str = "reports",
basename: str = None,
ctx_extra: dict = None,
@@ -60,19 +101,39 @@ def render_automatic_eda(
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres".
sample: máximo de filas/valores muestreados por columna para el perfil
y para los datos crudos del ctx (LIMIT). Default 5000.
run_models: si True (default) corre los modelos baratos
y para los datos crudos del ctx (LIMIT). Default None => lo fija el
preset de profile_level (lite=2000, standard/full=5000).
run_models: corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad). Necesario para que el
capítulo `modelos` pinte los clusters sobre el plano PCA.
run_series: si True (default) calcula el análisis de serie temporal por
capítulo `modelos` pinte los clusters sobre el plano PCA. Default
None => lo fija el preset (True en los tres niveles); en `lite` los
modelos se limitan a PCA + normalidad (ver profile_level).
run_series: calcula el análisis de serie temporal por
columna numérica. Necesario para el análisis del capítulo
`timeseries` (la gráfica de evolución sale de los datos crudos del
ctx aunque run_series sea False).
run_llm: si True (default False) hace la interpretación LLM del perfil y
ctx aunque run_series sea False). Default None => lo fija el preset
(standard/full=True, lite=False).
run_llm: hace la interpretación LLM del perfil y
ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
agregacion (títulos de segmento, descripción de la zona, selección de
agregaciones). Con False esos capítulos usan su derivación
cuantitativa (siguen completos, sin llamadas de red).
cuantitativa (siguen completos, sin llamadas de red). Default None =>
lo fija el preset (full=True, lite/standard=False).
profile_level: preset de consumo CPU/LLM. Mapea a defaults de los flags
anteriores; un flag explícito SIEMPRE prima sobre el preset (el
preset solo fija el default cuando el flag se deja en None):
- "lite" bajo consumo: run_llm=False, run_series=False,
sample=2000 y modelos limitados a **PCA + normalidad** (SIN KMeans
ni IsolationForest, que es lo caro en CPU). Pensado para un vistazo
rápido y barato. El capítulo `modelos` sale con PCA + normalidad,
sin el scatter de clusters.
- "standard" (default): comportamiento histórico modelos completos
(PCA/KMeans/IsolationForest/normalidad), serie temporal, sin LLM.
- "full" standard + narrativa LLM (run_llm=True).
Ejemplo de precedencia: profile_level="lite" con run_llm=True
explícito => el LLM se ejecuta (el flag explícito gana al preset).
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_<table>_<timestamp>".
@@ -90,6 +151,24 @@ def render_automatic_eda(
En error: {"status": "error", "error": str}.
"""
try:
# 0) Resolución del preset: el profile_level fija los DEFAULTS de los
# flags de coste; cualquier flag que el caller haya pasado explícito
# (!= None) prima sobre el preset. Un profile_level desconocido cae a
# "standard" (comportamiento histórico), sin lanzar.
preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
sample = preset["sample"] if sample is None else sample
run_models = preset["run_models"] if run_models is None else run_models
run_series = preset["run_series"] if run_series is None else run_series
run_llm = preset["run_llm"] if run_llm is None else run_llm
model_opts = preset["model_opts"]
# En el camino "modelos baratos" (lite) profile_table NO corre los
# modelos: los ejecuta este pipeline con run_eda_models y la granularidad
# del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
# En standard/full profile_table los corre completos como siempre.
lite_models = bool(run_models) and model_opts is not None
pt_run_models = bool(run_models) and not lite_models
# 1) Perfil base + modelos/serie opcionales. No escribe report propio
# (write_report=False): este pipeline emite su propio par PDF/PPTX.
pres = profile_table(
@@ -97,7 +176,7 @@ def render_automatic_eda(
table,
backend=backend,
sample=sample,
run_models=run_models,
run_models=pt_run_models,
run_llm=run_llm,
run_series=run_series,
emit_pdf=False,
@@ -131,6 +210,28 @@ def render_automatic_eda(
base_ctx=base_ctx,
)
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
# IsolationForest). profile_table no corrió los modelos; aquí se corren
# con run_eda_models reusando la muestra numérica alineada por fila que
# build_eda_render_ctx ya trajo en ctx['raw_numeric'] (no se reimplementa
# la lógica de los modelos: se delega en run_eda_models con la
# granularidad del preset).
if lite_models:
raw_numeric = ctx.get("raw_numeric") if isinstance(ctx, dict) else None
if isinstance(raw_numeric, dict) and raw_numeric:
model_input = {
col: {"values": vals, "type": "numeric"}
for col, vals in raw_numeric.items()
}
prof["models"] = run_eda_models(model_input, **model_opts)
# Quita raw_numeric del ctx para que el capítulo `modelos` NO
# reproyecte clusters KMeans en vivo (project_clusters_2d ejecuta
# KMeans): en lite ese coste se evita. geo_points ya quedó derivado
# en ctx por build_eda_render_ctx, así que el capítulo geospatial no
# se ve afectado.
if isinstance(ctx, dict):
ctx.pop("raw_numeric", None)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -89,3 +89,170 @@ def test_pipeline_bad_db_degrades_without_raising(tmp_path):
out_dir=str(tmp_path / "o"))
assert r["status"] == "error"
assert "error" in r
# --------------------------------------------------------------------------- #
# profile_level: preset de bajo consumo CPU/LLM.
# --------------------------------------------------------------------------- #
def _make_db_models(path):
"""DB con >=2 numéricas continuas (alta cardinalidad, 3 clusters gaussianos).
El DB `sales` de _make_db solo deja UNA columna de modelo tras la selección de
features (units es baja cardinalidad, lat/lon discretizadas), insuficiente para
PCA/KMeans/IsolationForest (necesitan >=2). Este DB tiene 3 numéricas
continuas con estructura de clusters para que el modo completo ejecute los
multivariantes.
"""
import random
from datetime import date, timedelta
con = duckdb.connect(path)
con.execute(
"CREATE TABLE pts (d DATE, grp VARCHAR, x1 DOUBLE, x2 DOUBLE, x3 DOUBLE)"
)
random.seed(42)
centers = [(0.0, 0.0, 0.0), (10.0, 10.0, 10.0), (20.0, 5.0, 15.0)]
d0 = date(2024, 1, 1)
rows = []
for i in range(150):
cx, cy, cz = centers[i % 3]
rows.append((
d0 + timedelta(days=i), f"g{i % 3}",
round(cx + random.gauss(0, 1.0), 4),
round(cy + random.gauss(0, 1.0), 4),
round(cz + random.gauss(0, 1.0), 4),
))
con.executemany("INSERT INTO pts VALUES (?,?,?,?,?)", rows)
con.close()
def test_profile_level_lite_skips_expensive_models(tmp_path):
"""lite: el bloque models trae PCA + normalidad pero NO KMeans/IsolationForest.
Demuestra (DoD bajo consumo) que el camino lite no ejecuta los modelos caros
en CPU ni la capa LLM ni la serie temporal: prof['models'] queda con pca y
normality poblados y kmeans/outliers a None, prof['llm'] y prof['series'] a
None, y el capítulo `modelos` se renderiza igualmente (con PCA, sin clusters).
"""
import json
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", profile_level="lite",
out_dir=out, basename="lite")
assert r["status"] == "ok", r.get("error")
models = (r["profile"] or {}).get("models") or {}
assert models.get("pca") is not None, "lite debe traer PCA"
assert models.get("normality") is not None, "lite debe traer normalidad"
assert models.get("kmeans") is None, "lite NO debe ejecutar KMeans"
assert models.get("outliers") is None, "lite NO debe ejecutar IsolationForest"
assert (r["profile"] or {}).get("llm") is None, "lite NO debe llamar al LLM"
assert (r["profile"] or {}).get("series") is None, "lite NO debe calcular serie"
# El capítulo modelos sigue presente (lo puebla el PCA), sin clusters KMeans.
with open(r["manifest_path"], encoding="utf-8") as fh:
man = json.load(fh)
assert "modelos" in (man.get("chapters") or {})
def test_profile_level_standard_runs_full_models(tmp_path):
"""standard (default): modelos completos (KMeans + IsolationForest) y serie."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", profile_level="standard",
out_dir=out, basename="std")
assert r["status"] == "ok", r.get("error")
models = (r["profile"] or {}).get("models") or {}
assert models.get("pca") is not None
assert models.get("kmeans") is not None, "standard debe ejecutar KMeans"
assert models.get("outliers") is not None, "standard debe ejecutar IsolationForest"
assert (r["profile"] or {}).get("series") is not None, "standard calcula serie"
def _patch_pipeline_internals(monkeypatch, captured):
"""Stub de las dependencias del pipeline para tests de resolución de flags.
Sustituye profile_table / build_eda_render_ctx / renderers por stubs rápidos
sin red ni matplotlib, capturando los kwargs con los que se invocan. Permite
verificar la PRECEDENCIA flag-explícito-sobre-preset sin ejecutar el EDA real.
"""
import pipelines.render_automatic_eda as mod
def fake_profile_table(db_path, table, **kw):
captured["run_llm"] = kw.get("run_llm")
captured["run_models"] = kw.get("run_models")
captured["run_series"] = kw.get("run_series")
captured["sample"] = kw.get("sample")
return {"status": "ok", "profile": {"columns": []}}
def fake_ctx(db_path, table, prof, **kw):
captured["base_ctx"] = kw.get("base_ctx")
return {}
monkeypatch.setattr(mod, "profile_table", fake_profile_table)
monkeypatch.setattr(mod, "build_eda_render_ctx", fake_ctx)
monkeypatch.setattr(mod, "render_automatic_eda_pdf",
lambda *a, **k: {"path": "x.pdf", "n_pages": 1,
"manifest_path": "m.json"})
monkeypatch.setattr(mod, "render_automatic_eda_pptx",
lambda *a, **k: {"path": "x.pptx", "n_slides": 1})
def test_explicit_flag_overrides_preset(monkeypatch):
"""Precedencia: profile_level='lite' con run_llm=True explícito → LLM activo.
El flag explícito del caller gana al default del preset. Se verifica tanto en
el flag que llega a profile_table (run_llm=True profile_table llamará al
LLM) como en el base_ctx (run_cluster_llm=True narrativa LLM por capítulo).
"""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="lite", run_llm=True)
assert captured["run_llm"] is True, "flag explícito debe primar sobre preset lite"
assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True
def test_full_preset_enables_llm(monkeypatch):
"""full: el preset resuelve run_llm=True y activa la narrativa LLM en el ctx."""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="full")
assert captured["run_llm"] is True
assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True
def test_no_profile_level_defaults_to_standard(monkeypatch):
"""Retro-compat: sin profile_level ni flags, el comportamiento es el histórico.
standard = run_models True, run_series True, run_llm False, sample 5000. Es el
mismo default que tenía el pipeline antes de introducir profile_level (cambio
aditivo: las llamadas existentes no cambian de comportamiento).
"""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t") # sin profile_level ni flags de coste
assert captured["run_models"] is True
assert captured["run_series"] is True
assert captured["run_llm"] is False
assert captured["sample"] == 5000
def test_lite_preset_defaults(monkeypatch):
"""lite por defecto: run_llm/run_series False y sample reducido a 2000."""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="lite")
assert captured["run_llm"] is False
assert captured["run_series"] is False
assert captured["sample"] == 2000
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"polars>=1.40.1",
"pymeshlab>=2025.7.post1",
"pymssql>=2.3.13",
"pymupdf>=1.28.0",
"pypdf>=6.10.0",
"pyproj>=3.7.2",
"python-docx>=1.2.0",