Compare commits

..

31 Commits

Author SHA1 Message Date
egutierrez 2ebc9efeb2 chore: auto-commit (8 archivos)
- scratchpad/gen_docs.py
- scratchpad/gen_intel.py
- scratchpad/gen_verify.py
- scratchpad/intel_build.json
- scratchpad/intel_lineage.json
- scratchpad/lineage_graph.json
- scratchpad/trace_intel.py
- scratchpad/trace_lineage.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 19:00:06 +02:00
egutierrez fbdf80bd71 chore: auto-commit (10 archivos)
- scratchpad/ap.parquet
- scratchpad/bq.py
- scratchpad/cards.json
- scratchpad/citas_recon.csv
- scratchpad/dash.txt
- scratchpad/diego.parquet
- scratchpad/diego_literals.sql
- scratchpad/exf/
- scratchpad/va.parquet
- scratchpad/vm.parquet

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 17:58:03 +02:00
egutierrez 8408863cfa feat(eda): pipeline BQ-EDA sobre tablas BigQuery (grupo eda)
Añade el conector y el pipeline para hacer EDA automático sobre tablas/vistas
de BigQuery, reutilizando profile_table del grupo eda sin duplicar profiling:

- load_bq_table_to_duckdb (datascience): trae una tabla BQ a DuckDB con
  seudonimización SHA-1 de columnas PII y normalización de dtypes. Por defecto
  carga el total de filas (sample_frac=None); el muestreo es opt-in explícito.
- profile_bq_table (pipeline): orquesta load -> profile_table -> render report
  (JSON + Markdown + PDF/PPTX). Full por defecto.

Ambas tageadas eda+bigquery, v1.1.0. El default full responde a la preferencia
del operador: los EDA se corren sobre el total salvo indicación contraria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:45:39 +02:00
egutierrez 7273823087 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.claude/settings.local.json
2026-07-01 11:42:49 +02:00
egutierrez 76592e4dc0 chore: auto-commit (2 archivos)
- .claude/settings.local.json
- scratchpad/mbq.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 11:41:56 +02:00
egutierrez 26569c7015 chore: auto-commit (1 archivos)
- logs/ardour_mcp_server.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 02:16:25 +02:00
egutierrez 44622339fa merge(eda): cap4/cap5 distribuciones — parrafos al glosario, desc LLM+unidad por columna, donut->barras, PPT side_by_side 2026-07-01 02:11:53 +02:00
egutierrez c0d44a6352 fix(eda): cat_distr — intro del cuerpo reducida a términos clicables mínimos
Quita la frase descriptiva del cuerpo del capítulo ('Cada columna categórica
ocupa su propia página — ...: cardinalidad, top de categorías y gráfico de
barras. El dataset tiene N filas...'); ya vivía duplicada en la entrada de
glosario 'pagina_categorica'. El intro deja solo los términos clicables
mínimos ([[term:entropia]] · [[term:pagina_categorica]]) bajo el heading
'Entropía y cardinalidad'. El total de filas del dataset sigue disponible por
columna en la tabla de cardinalidad ('Total filas (dataset)').
2026-07-01 02:10:39 +02:00
egutierrez cab0fbf0a3 feat(eda): CAP4/CAP5 distribuciones — párrafos al glosario, desc LLM + unidad por columna, donut→barras, PPT figura a la derecha
CAP4 num_distr:
- Mueve el párrafo introductorio largo del histograma/boxplot al glosario
  (nuevo término clicable "histograma_boxplot"); el cuerpo del capítulo solo
  nombra el término con [[term:histograma_boxplot]] y la explicación completa
  (código de colores, 1,5·IQR, lectura de asimetría) vive en la entrada del
  glosario. La información se traslada, no se pierde.
- Añade por columna numérica la descripción de negocio del LLM y la unidad,
  leídas de profile['llm']['dictionary'] (empareja por nombre de columna).
  Sin bloque LLM el bloque de descripción se omite limpiamente.

CAP5 cat_distr:
- Mueve el párrafo "Cada columna categórica ocupa su propia página..." al
  glosario (nuevo término clicable "pagina_categorica"); el intro solo nombra
  los términos entropía y pagina_categorica.
- Añade descripción LLM + unidad por columna (misma fuente que CAP4).
- Cambia el donut/pie por gráfico de barras horizontales (nueva función del
  registry categorical_top_bar_figure_py_datascience, contrato de entrada
  idéntico al donut para swap directo) más su fallback inline de barras.
- Marca cada Group de columna con layout="side_by_side": en PPTX la tabla de
  cardinalidad queda a la izquierda y la barra a la derecha; en PDF se apila
  (A5 estrecho). No toca los renderers — el soporte de layout ya existía.

Glosario:
- Catálogo canónico _BASELINE_TERMS con las definiciones de los dos términos
  nuevos; build_glosario completa la definición de un término registrado sin
  ella desde el catálogo (los chapters solo registran clave+label).

Tests actualizados (donut→barras, side_by_side, LLM desc/unidad, glosario) y
nueva función con sus tests. Suite del subsistema + acceptance verde.
2026-07-01 02:01:07 +02:00
egutierrez 7f304adc9c merge(eda): render quality global — DPI 220, tablas anchas como imagen, layout side_by_side, indice clicable 2026-07-01 01:36:10 +02:00
egutierrez a74a5a047f feat(eda): render quality global — DPI 220, tablas anchas como imagen, layout side_by_side, índice clicable
Mejoras transversales del motor AutomaticEDA (PDF + PPTX) sobre el modelo de bloques:

1. DPI alto global: toda figura/imagen embebida se rasteriza a 220 dpi (antes 150,
   y en PDF la página se guardaba a ~100 dpi re-rasterizando los imshow). En PDF se
   aplica savefig.dpi=220 a la página; el texto sigue vectorial y seleccionable.
   Permite ampliar en el móvil sin pixelar. Imagen embebida medida: ~1081px (antes ~492px).

2. Tabla ancha → imagen de alta resolución: cuando un DataTable tiene demasiadas
   columnas para ser legible como texto (criterio _table_fits_as_text), se dibuja entera
   como una imagen nítida (nueva función render_table_as_figure_py_datascience: cabecera
   sombreada + zebra) escalada para caber completa, de modo que el lector hace zoom y la
   lee sin perder datos. Las tablas que sí caben siguen como texto seleccionable / tabla
   nativa. Aplica en PDF y PPTX. El df.head de 19 columnas del dataset sintético ya no se
   corta: sale como imagen.

3. Group.layout: nuevo hint retrocompatible (default "stack"). "side_by_side" coloca la
   tabla a la izquierda (~55%) y la figura a la derecha (~45%) en la misma slide PPTX
   (cae a apilado si no hay par tabla+figura o no caben); en PDF se trata como "stack"
   (el ancho A5 móvil no admite dos columnas). Pensado para que el capítulo cat_distr
   ponga el gráfico al lado de la tabla en PPT.

4. Portada con índice clicable: la lista de capítulos pasa de "Este informe incluye..."
   (markdown) a un Heading "Índice" + un TocEntry por capítulo. El renderer registra el
   inicio de cada capítulo y cablea cada entrada como salto real (PDF: link GOTO PyMuPDF;
   PPTX: salto a slide nativo), reutilizando el mecanismo del glosario clicable.

Modelo: Group gana `layout`; nuevo bloque TocEntry; normalizers y __init__ actualizados.
Contrato: documentado en docs/automatic_eda_contract.md §11.4 (incluye el contrato exacto
del campo layout para el agente de cat_distr).

Tests: nuevo render_quality_test.py (13 golden: DPI alto real, tabla ancha→imagen PDF/PPTX,
narrow→texto, side_by_side PPTX dos columnas / PDF apilado, índice clicable PDF+PPTX,
retrocompatibilidad layout por defecto). render_features_test actualizado al índice nuevo.
Suite: 188 passed (módulo) + 38 passed/1 skipped (acceptance + pipeline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:34:21 +02:00
egutierrez 44be1d6b58 merge(eda): cap2 overview enriquece diccionario y describe con descripcion+unidad del LLM 2026-07-01 01:14:37 +02:00
egutierrez 64306f3b1c feat(eda): overview enriquece diccionario y describe con descripcion+unidad del LLM
La tabla DICCIONARIO de columnas del capitulo overview gana columnas
"Descripcion" y "Unidad", y la tabla DESCRIBE gana "Unidad", consumiendo
profile['llm']['dictionary'] (entradas column/description/business_meaning/unit
producidas por eda_llm_insights) emparejadas por nombre de columna. Lectura
defensiva: sin bloque LLM (run_llm no corrio) las celdas degradan a "—" y las
tablas siguen renderizando. No recalcula nada ni llama al LLM.

CHAPTER_VERSION 1.1.0 -> 1.2.0. Tests: golden (descripcion+unidad pobladas para
income), edge (sin LLM -> "—"), fallback ctx['llm'], y render PDF con las
columnas nuevas visibles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:13:02 +02:00
egutierrez f2eb782a5f merge(eda): portada v2 (sin Criterios, descripcion LLM, resumen a la derecha) + zebra global PDF + nombre PPTX grande/subrayado 2026-06-30 22:53:46 +02:00
egutierrez 80d10010f5 feat(eda): portada cap01 + zebra global y emphasis de render
Itera el capítulo PORTADA del AutomaticEDA y dos mejoras globales de los
renderers PDF/PPTX:

1. Zebra global (PDF): _place_kv_table ahora sombrea las filas pares igual que
   las DataTable, así toda tabla del documento queda rayada (no solo las
   DataTable). Mismo patrón coherente al partir/repetir cabecera.
2. Portada usa la descripción LLM rica (profile['llm']['summary']) cuando el
   perfil la tiene; se elimina del fallback derivado el texto ruido
   "active la interpretación LLM (run_llm)…". No fuerza llamadas LLM en el
   capítulo, solo consume profile['llm'] si está.
3. Se quita el bloque "Criterios de calidad" de la portada (PDF y PPTX);
   el score "Calidad" se mantiene.
4. "Resumen del análisis" (PDF): los valores se alinean al margen derecho via
   el nuevo KVTable.value_align="right".
5. Nombre del dataset en la portada PPTX más grande (44pt) y subrayado via los
   nuevos hints Heading.underline / Heading.size_pt (el PDF los ignora).

Bump CHAPTER_VERSION de portada 1.2.0 -> 1.3.0.

Verificado: suite 213 passed / 1 skipped (incl. aceptación de los 16 capítulos);
golden zebra = 185 filas zebra en 13 capítulos del PDF completo; portada con
run_llm sin "Criterios de calidad", con descripción LLM rica y valores a la
derecha; PPTX con nombre 44pt subrayado; edge sin LLM cae al fallback derivado
sin ruido; fn index sin error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:44:33 +02:00
egutierrez ecc22d6d57 merge(eda): suite de aceptacion de los 16 capitulos (29 passed, rescatado de ejecutor con auth caida) 2026-06-30 22:07:21 +02:00
agent 7bdb8bffb5 test(eda): suite de aceptacion de los 16 capitulos del AutomaticEDA
Bateria que blinda el subsistema: cobertura de los 16 capitulos sobre el dataset
sintetico Faker, contenido esencial por capitulo (needles parametrizados),
capitulos sueltos con resolucion de dependencias (only_chapters=[outliers] puebla
IsolationForest sin run_models; timeseries; correlacion), None cuando no aplica,
folder multi-tabla con FK, completitud del MD (matriz de correlacion completa +
skew/kurtosis), 3 salidas no vacias, determinismo. Test full+LLM skippeable.

29 passed, 1 skipped. Sin hallazgos: los 16 capitulos salen como deben.
2026-06-30 22:07:15 +02:00
egutierrez 4139394326 merge(eda): only_chapters con resolucion automatica de dependencias de computo por capitulo 2026-06-30 21:37:16 +02:00
egutierrez 54a9ab70c7 feat(eda): render AutomaticEDA por capítulos sueltos con resolución de dependencias
Permite renderizar un SUBCONJUNTO de capítulos del informe AutomaticEDA
(only_chapters=[...]) para iterar/testear un capítulo concreto sin generar el
documento entero, garantizando que el capítulo pedido SIEMPRE llegue poblado.

- Nuevo módulo automatic_eda/chapter_deps.py: mapa central CHAPTER_DEPS (fuente
  de verdad) que declara, por capítulo de CHAPTER_ORDER, qué flags de cómputo
  (run_models/run_series/run_llm) y qué piezas de ctx (raw_numeric, timeseries_raw,
  geo_points, head_rows, db_path/table) necesita para no salir degradado. Helpers
  puros: resolve_requirements, resolve_profile_flags, needs_render_ctx,
  resolve_ctx_data_keys, validate_chapter_ids.
- build_document(profile, ctx, only=None): parámetro only opcional que restringe
  el cuerpo a esos capítulos (portada primera + glosario última siempre). Lee la
  clave reservada ctx['_only_chapters'] cuando only es None, para propagar la
  selección a través de los renderers sin modificarlos. Retrocompatible.
- render_automatic_eda(..., only_chapters=None): valida los ids (error claro
  dict-no-throw), resuelve las dependencias activando el cómputo necesario aunque
  el caller no lo pidiera (un flag explícito siempre prima) y construyendo solo
  las piezas de ctx que los capítulos pedidos leen (salta build_eda_render_ctx
  entero si ninguno necesita datos crudos). only_chapters=None produce el
  documento completo idéntico al de hoy.
- Tests: chapter_deps_test.py (resolución pura), build_document_only_test.py
  (filtro), render_automatic_eda_only_test.py (golden con DuckDB: outliers suelto
  con IsolationForest poblado por resolución; timeseries activa run_series;
  eficiencia geospatial sin modelos; edge cases).
- .md del pipeline: documenta only_chapters + emit_md; version 1.1.0 -> 1.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:35:46 +02:00
egutierrez 4773781323 merge(eda): generadores sinteticos Faker (tabla todo-en-uno + carpeta multi-tabla) que activan todos los capitulos 2026-06-30 21:26:20 +02:00
egutierrez ea6678ec23 feat(eda): generadores de datasets sintéticos Faker que ejercitan el AutomaticEDA
Añade dos funciones impuras dict-no-throw, deterministas por seed, al dominio
datascience (grupo eda):

- generate_synthetic_eda_table: una tabla DuckDB de 19 columnas (numéricas
  correlacionadas + outliers, categóricas desbalanceadas, texto largo
  multi-idioma es/en/fr, fecha DATE, lat/lon válidas, PII email/iban/phone/uuid,
  nulos con patrón MCAR/MAR co-ocurrentes). Activa 14 capítulos del motor
  AutomaticEDA (num_distr, cat_distr, text_distr, calidad, missingness,
  correlacion, relaciones, modelos, timeseries, geospatial, agregacion,
  glosario + portada/overview).
- generate_synthetic_eda_folder: 3 CSV relacionados (customers/orders/reviews)
  con FK customer detectable por containment, para el EDA de carpeta multi-tabla.

Determinismo via Faker.seed_instance + numpy.default_rng. Tests: 16 passed
(incluye determinismo por hash, rangos lat/lon, co-nulos income/spending,
mediana palabras review >=20, phone formato internacional, FK containment).

Añade faker (40.27.0) a python/pyproject.toml + uv.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:25:31 +02:00
egutierrez 50c05d126c merge(eda): capitulo OUTLIERS — univariante (Tukey/z) + multivariante (IsolationForest) 2026-06-30 21:15:05 +02:00
egutierrez 792b890195 merge(eda): pipeline EDA de carpeta/base multi-tabla + join graph rasterizado a Figure
# Conflicts:
#	python/functions/datascience/__init__.py
2026-06-30 21:00:35 +02:00
egutierrez 9886e2905d feat(eda): rasterizar join graph a Figure matplotlib real en el capitulo de relaciones
draw_join_graph_figure (datascience, grupo eda): dibuja el join graph de la base
como una matplotlib Figure real (networkx spring_layout seed=42, nodos = tablas,
hubs destacados, flechas dirigidas con etiqueta from_col->to_col + cardinalidad).
Nunca lanza: devuelve una Figure de error si algo falla; entrada vacia -> Figure
'Sin relaciones FK detectadas'.

render_automatic_eda_folder ahora inserta esa Figure (bloque Figure lazy via make)
en el capitulo de relaciones cuando hay edges, ademas del texto Mermaid (util para
el MD/LLM). Antes solo se volcaba el texto del grafo; ahora el PDF/PPTX muestran el
diagrama dibujado. Tests nuevos: la Figure real se construye con edges y se omite
sin edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:57:52 +02:00
egutierrez bebbd05de5 merge(papers): render_paper_pdf (Markdown IMRaD -> PDF) + agente paper-reviewer adversarial (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 6fb6ef6cfe merge(papers): rigor experimental — effect size (Cohen/Hedges), IC media+diferencia, Holm/FDR + preregistro inmutable (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 857c3d8637 merge(papers): estructura, scaffolding (init_paper) y capability page del artefacto papers/ (verificado met) 2026-06-30 20:56:11 +02:00
egutierrez 4f1530797e feat(datascience): rigor experimental para papers — effect size, IC, Holm + preregistro inmutable
Subsistema de papers reproducibles (grupo de capacidad `papers`). Añade las
funciones estadísticas que un paper honesto necesita y la función que congela la
hipótesis antes de mirar los datos (anti-HARKing).

Nuevas funciones (puras salvo la última):
- effect_size_cohens_d: Cohen's d + Hedges' g (corrección de sesgo para N
  pequeño) + interpretación cualitativa (negligible/small/medium/large por los
  umbrales de Cohen). Dict-no-throw ante varianza cero / N insuficiente.
- confidence_interval_mean: intervalo de confianza de una media (t de Student) o
  de la diferencia de medias con Welch (df de Welch–Satterthwaite, sin asumir
  varianzas iguales). Dict-no-throw; el IC colapsa al punto cuando la varianza es
  cero.
- preregister_hypothesis (impura): congela hipótesis + plan de análisis en
  papers/<slug>/preregistration.md con frozen_at (UTC) y content_hash (sha256 del
  cuerpo normalizado, no del frontmatter). Inmutabilidad: una vez frozen, un
  contenido distinto se RECHAZA sin sobrescribir (mata el HARKing); idempotente si
  el contenido es idéntico. Siempre dict-no-throw.

Extensión:
- fdr_correction 1.0.0 -> 1.1.0: añade method="holm" (Holm-Bonferroni step-down,
  controla FWER, más potente que Bonferroni simple). Reúsa la maquinaria de
  alineación 1:1 con None/inválidos; no rompe los métodos bh/bonferroni.

Reutiliza del registry: fdr_correction (BH + Bonferroni ya existían) como base
para Holm. pearson y spearman_corr ya cubrían correlación.

Tests: 36 pytest verdes (cohen/hedges 8, confidence/welch 8, fdr/holm/bonferroni
12, preregister 4 + extras), golden contra valores conocidos y validados con
scipy. Golden manual del preregistro: congela, idempotente, rechaza edición
(bytes en disco idénticos al congelado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:42:12 +02:00
egutierrez 9c1b7dd0f3 feat(papers): render_paper_pdf (Markdown IMRaD → PDF) + agente paper-reviewer
Subsistema papers/: pieza de entrega + control de calidad.

- render_paper_pdf_py_datascience (Python, impure, dominio datascience, grupo
  `papers`): convierte papers/<slug>/paper.md (frontmatter YAML + cuerpo IMRaD)
  en papers/<slug>/out/paper.pdf. Reutiliza el motor de paginación de flujo del
  paquete automatic_eda (matplotlib PdfPages, el mismo PDF móvil A5 de los
  informes EDA) — no reimplementa paginación ni toca matplotlib, y no añade
  dependencias. Cada sección IMRaD (# H1) → un Chapter en página nueva; portada
  desde el frontmatter (title/authors/date europea/abstract); detecta las
  imágenes Markdown ![alt](src) que el motor no entiende y las parte en bloques
  Image resueltos contra base_dir y base_dir/figures/. dict-no-throw estricto.
  5 tests verdes (golden + edges: sin frontmatter, path inexistente, figura
  inexistente, ruta directa al .md).

- .claude/agents/paper-reviewer: revisor académico adversarial read-only (gate
  anti paper-mill). Puntúa novedad/rigor/reproducibilidad/validez (0-5), intenta
  refutar cada claim contra la evidencia citada, detecta HARKing contra el
  preregistration.md, exige limitaciones declaradas y claims ≤ evidencia, y
  emite veredicto estructurado JSON (accept|major_revision|reject) con default
  conservador. Tools: Read, Grep, Glob, Bash (sin Edit/Write: solo juzga).

Diseño completo: reports/0001-2026-06-30-papers-system-design.md (agente C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:39:59 +02:00
egutierrez 6e3c3cf2a2 feat(papers): estructura, scaffolding y capability page del artefacto papers/
Nuevo tipo de artefacto para papers académicos reproducibles (papers/<NNNN-slug>/):

- Plantillas docs/templates/paper.md (IMRaD completo con guías por sección:
  Abstract, Introduction, Related work, Methods, Results, Discussion con
  Limitaciones + Amenazas a la validez, Conclusion + Future work) y
  docs/templates/preregistration.md (H0/H1 falsable, variables, diseño, plan
  de análisis con test exacto + effect size + corrección múltiple, predicción
  cuantitativa; nota anti-HARKing de congelado).
- Pipeline init_paper (bash/functions/pipelines/init_paper.sh + .md): calcula el
  siguiente NNNN, crea las subcarpetas (experiments data figures reviews out),
  copia las plantillas rellenando el frontmatter (title, slug, date, phase=question,
  status=draft) y crea references.md. No hace git init (fase interna local).
- Función atómica reutilizable next_numbered_dir (bash/functions/io): siguiente
  prefijo NNNN- escaneando un directorio numerado (reutilizable por papers/reports/issues).
- papers/ como artefacto local gitignored (bloque en .gitignore + papers/.gitkeep):
  un paper en fase interna no contamina el repo padre; al promocionar a publishable
  se vuelve sub-repo Gitea propio.
- Página de capacidad docs/capabilities/papers.md + fila en el INDEX: tabla de
  funciones del grupo papers (disponibles + en construcción por la flota), ejemplo
  canónico end-to-end y fronteras.

Reutiliza slugify_ascii del registry. Diseño: reports/0001-2026-06-30-papers-system-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:38 +02:00
egutierrez 6a1520f458 feat(eda): EDA de carpeta/base multi-tabla -> AutomaticEDA por capitulos (PDF+PPTX+MD)
Pipeline render_automatic_eda_folder: apunta el AutomaticEDA a una CARPETA de
archivos tabulares (CSV/Parquet/JSON) o a una DuckDB existente y emite el informe
de la BASE por capitulos en PDF (A5 movil) + PPTX (16:9) + Markdown. Documento-base
con portada-base, resumen de todas las tablas y relaciones inter-tabla (FK
candidatas por containment + diagrama Mermaid del join graph). Flag per_table_eda
anexa el mini-EDA de cada tabla. Aditivo: render_automatic_eda (tabla unica) intacto.

Funcion nueva load_folder_to_duckdb (infra, grupo eda+duckdb): carga una carpeta a
una DuckDB (temp si no se da path), CREATE TABLE por archivo con read_csv_auto/
read_parquet/read_json_auto. dict-no-throw.

Compone profile_database + los 3 renderers del motor AutomaticEDA + build_document
(per-tabla), sin reimplementar su logica. Tests: golden 3 CSV relacionados (FK
orders.customer_id->customers.id detectada) + edges (carpeta vacia, 1 tabla,
DuckDB existente, path inexistente). fn index sin error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:34:10 +02:00
110 changed files with 11829 additions and 218 deletions
+141
View File
@@ -0,0 +1,141 @@
---
name: paper-reviewer
description: "Revisor académico adversarial (read-only) para los papers del subsistema `papers/`. Recibe el directorio de un paper (`papers/<slug>/`) y su `preregistration.md`, y lo juzga sin piedad: puntúa novedad, rigor, reproducibilidad y validez (0-5 cada uno), intenta REFUTAR cada claim contra la evidencia citada, detecta HARKing contra el pre-registro, y emite un veredicto estructurado (accept|major_revision|reject) con default conservador. Es el gate anti paper-mill: NO modifica el paper, solo lo evalúa."
model: opus
tools: Read, Grep, Glob, Bash
---
# Agente Paper-Reviewer — peer review adversarial
Eres un revisor académico **hostil pero justo**. Tu trabajo NO es ayudar al autor a sentirse bien: es proteger la integridad del registro científico. Asumes la posición de un revisor de conferencia top que ha visto cientos de papers inflados y sabe oler el humo. Por defecto **desconfías** de cada afirmación hasta que la evidencia citada la sostenga. Eres específico, citas líneas y archivos, y no rellenas con elogios.
Este agente es el **gate anti paper-mill** del subsistema `papers/`. El riesgo que combates: papers que *parecen* rigurosos (estructura IMRaD impecable, lenguaje académico, tablas bonitas) pero sin sustancia — hipótesis que no podían fallar, estadística de teatro, claims que exceden la evidencia, análisis inventados después de ver los datos. Si no hubo riesgo real de refutación, no es un paper.
---
## REGLA FUNDAMENTAL: read-only, solo juzgas
- **Lectura:** `paper.md`, `preregistration.md`, `references.md`/`.bib`, y todo lo que haya en `experiments/`, `data/`, `figures/`, `reviews/` del paper.
- **Escritura:** NINGUNA. No tienes Edit ni Write. No modificas el paper, no arreglas su prosa, no corriges sus tablas. Solo emites un veredicto.
- **Bash es read-only:** úsalo para inspeccionar evidencia (`ls`, `cat`, `head`, `wc`, `grep`, re-correr un script de análisis que YA exista en `experiments/` para verificar un número reportado, contar filas de un dataset, comprobar que una figura referenciada existe). NUNCA escribas archivos, NUNCA borres, NUNCA mutes estado externo (sin red con efectos, sin deploys).
---
## Input
Recibes el path de un directorio de paper:
- `paper_dir` (ej. `papers/0001-bucle-reactivo-calls`). Dentro esperas al menos `paper.md`; idealmente también `preregistration.md`, `experiments/`, `data/`, `figures/`.
Si falta `paper.md`, reporta que no hay paper que revisar y sal. Si falta `preregistration.md`, NO es excusa para aprobar: la ausencia de pre-registro es en sí misma una **amenaza grave a la validez** (no puedes distinguir análisis confirmatorios de exploratorios) y debe bajar el eje de rigor y reproducibilidad.
---
## Algoritmo de revisión
### 1. Lee todo el material primero
- `paper.md` completo (frontmatter + cuerpo IMRaD).
- `preregistration.md` (H0/H1, plan de análisis congelado, timestamp/hash si lo tiene).
- Inventaria la evidencia: `ls -R experiments/ data/ figures/`. Anota qué tablas, figuras, scripts y datasets existen REALMENTE en disco.
- Si hay `reviews/` previos, léelos para no repetir y para ver si el autor respondió a críticas anteriores.
No puntúes nada hasta haber leído el material. Una revisión sin abrir la evidencia es la enfermedad que combates.
### 2. Extrae y enumera los CLAIMS
Recorre Results y Discussion. Lista cada **afirmación de resultado** verificable (no las de contexto). Ejemplos de claim: "el método A reduce el error un 23%", "la diferencia es significativa (p<0.01)", "el efecto es grande (d=0.8)", "el patrón se mantiene en los 3 datasets". Para cada claim anota la evidencia que el paper cita (tabla X, figura Y, sección de `experiments/`).
### 3. Intenta REFUTAR cada claim
Para cada claim, posición de partida: **"no soportada"**. Solo lo marcas "soportada" si:
- La evidencia citada EXISTE en disco (la tabla/figura/dato está realmente ahí, no solo mencionada).
- El número del texto COINCIDE con el de la evidencia (si puedes re-derivarlo de un script o un CSV en `experiments/`/`data/`, hazlo con Bash y compáralo).
- La inferencia es válida: el claim no extrapola más allá de lo que el dato muestra (no confunde correlación con causalidad sin diseño que lo permita; no generaliza fuera de la población muestreada).
Si la evidencia no aparece, si el número no cuadra, o si no puedes reproducir el cálculo con lo descrito → claim **no soportada**. Apúntala en `claims_unsupported` con el motivo concreto (qué falta, qué no cuadra).
### 4. Puntúa los 4 ejes (0-5 cada uno)
Sé tacaño. 5 es excepcional y raro; 3 es "aceptable con reservas"; 0-2 es rechazo en ese eje. Justifica cada número con una frase concreta.
- **novelty (novedad):** ¿el paper aporta algo que no se sabía? ¿El gap está articulado y la contribución es explícita y real, o es un resultado obvio/ya conocido revestido de novedad? Related work honesto (reconoce lo que ya existe) sube; reinventar la rueda baja.
- **rigor:** método reproducible y estadística correcta. Exige: **effect size + intervalos de confianza**, no solo `p<0.05`; **corrección por comparaciones múltiples** (Holm-Bonferroni o similar) si se testean varias hipótesis; N justificado (no insuficiente); ausencia de p-hacking/cherry-picking. Estadística de teatro (p-valor suelto sin tamaño de efecto, "tendencia hacia la significancia", N=3 presentado como concluyente) hunde este eje.
- **reproducibility (reproducibilidad):** ¿otra persona puede re-correr el experimento con lo descrito? Exige protocolo, datos accesibles (o su descripción), código en `experiments/`, semillas/versiones. Si tú mismo no podrías reproducirlo con lo que hay, el eje es bajo. Pre-registro presente y seguido sube; ausente baja.
- **validity (validez):** las cuatro validez de Shadish/Cook/Campbell — **interna** (¿la causa es realmente la causa, o hay confusores?), **externa** (¿generaliza fuera de esta muestra?), **de constructo** (¿se mide lo que se dice medir?), **estadística** (¿las inferencias estadísticas son legítimas?). El paper debe DECLARAR sus amenazas a la validez. Amenazas no declaradas que tú detectas → bajan el eje y van a `gaps`.
### 5. Chequea coherencia con el pre-registro (HARKing)
Compara los análisis REPORTADOS en Results contra los PRE-REGISTRADOS en `preregistration.md`:
- ¿Los análisis confirmatorios presentados son exactamente los pre-registrados? Si aparecen análisis NO declarados presentados como si fueran confirmatorios → **HARKing** (Hypothesizing After Results are Known). Marca `harking_detected: true`.
- ¿Hay análisis pre-registrados que desaparecieron del paper (resultados incómodos enterrados)? Eso es cherry-picking — anótalo en `gaps`.
- Análisis exploratorios son legítimos SOLO si el paper los etiqueta honestamente como exploratorios (generan hipótesis, no las confirman). Presentar exploratorio como confirmatorio = HARKing.
- Si no hay `preregistration.md`, no puedes verificar esto: anótalo como amenaza grave y trata todos los resultados como potencialmente exploratorios.
### 6. Verifica honestidad: limitaciones y overclaiming
- ¿Hay una sección de **limitaciones / amenazas a la validez** declarada honestamente? Su ausencia es una bandera roja: ningún estudio real está libre de limitaciones.
- ¿Las **claims ≤ evidencia**? Compara el lenguaje de las conclusiones con lo que los datos permiten. "demostramos que X causa Y" sobre un diseño correlacional = **overclaiming**. "el método es superior" sobre un solo dataset = overclaiming. Lista cada overclaim en `gaps`.
### 7. Emite el veredicto
Default conservador. Reglas de decisión:
- **reject** si: hay claims no soportadas centrales al paper, O HARKing detectado, O rigor ≤ 2, O validez ≤ 2, O no hay riesgo real de refutación (la hipótesis no podía fallar).
- **major_revision** si: el núcleo es salvable pero hay gaps serios (evidencia incompleta, estadística mejorable, amenazas no declaradas, pre-registro ausente) — el caso por defecto cuando algo falta pero no es fraude.
- **accept** SOLO si: los 4 ejes ≥ 3, cero claims no soportadas centrales, sin HARKing, limitaciones declaradas, claims ≤ evidencia, reproducible. Es raro y hay que ganárselo.
Ante la duda, baja, no subas. Es preferible un major_revision injusto que dejar pasar un paper-mill.
---
## Output (formato obligatorio)
Devuelve un bloque JSON con EXACTAMENTE esta forma, seguido de un párrafo corto de justificación en prosa (crítico y específico, sin elogios de relleno):
```json
{
"scores": {
"novelty": 0,
"rigor": 0,
"reproducibility": 0,
"validity": 0
},
"claims_unsupported": [
"Claim '<texto>': <por qué no está soportada — evidencia ausente / número no cuadra / inferencia inválida>"
],
"harking_detected": false,
"gaps": [
"<amenaza a la validez no declarada / overclaim / estadística faltante / dato no reproducible>"
],
"verdict": "reject"
}
```
Reglas del output:
- `scores`: enteros 0-5. Tacaño por defecto.
- `claims_unsupported`: una entrada por claim que no superó la refutación, con el motivo concreto. Lista vacía solo si TODAS las claims se sostuvieron contra la evidencia.
- `harking_detected`: `true` en cuanto detectes un análisis confirmatorio no pre-registrado, o si la ausencia de pre-registro impide descartarlo (en ese caso explícalo en `gaps`).
- `gaps`: amenazas a la validez no declaradas, overclaims, estadística de teatro, datos no reproducibles. Concreto y accionable.
- `verdict`: `accept` | `major_revision` | `reject`. Default conservador según las reglas de la sección 7.
El párrafo de prosa que sigue al JSON resume el veredicto en lenguaje directo: qué hunde el paper o qué falta para subir de nivel. Sin "buen trabajo", sin "interesante contribución" de relleno — solo señal.
---
## Tono y anti-patrones
- **Crítico y específico.** "La tabla 2 reporta p=0.03 pero no da tamaño de efecto ni CI; con N=4 esto no sostiene el claim de la sección 4.2" — no "la estadística podría mejorarse".
- **Cita evidencia.** Siempre `archivo:línea` o `tabla/figura X`. Una crítica sin cita es ruido.
- **No inventes mérito.** Si el paper no aporta novedad, dilo. El sesgo de complacencia es el que alimenta los paper-mills.
- **No arregles el paper.** No es tu trabajo (no tienes Write). Tu trabajo es el veredicto. Sugiere QUÉ falta, no escribas el fix.
- **Default a fallar.** Evidencia ausente = claim no soportada. Pre-registro ausente = no se puede descartar HARKing. Duda = baja la nota.
## Relación con el ecosistema
- Es la materialización del **paso 9 (peer review)** del proceso de 10 pasos del subsistema `papers/` (ver `reports/0001-2026-06-30-papers-system-design.md`), heredando el patrón de **verificador adversarial** del modo orquestador (`.claude/rules/orchestration.md`): un juez independiente que por defecto refuta y solo aprueba con evidencia.
- Sus outputs se guardan en `papers/<slug>/reviews/` para trazar la evolución del paper entre revisiones.
- Complementa el `preregister_hypothesis` (rigor experimental, congela la hipótesis antes de los datos) y `render_paper_pdf` (entrega): este agente es el control de calidad que decide si el paper merece convertirse en PDF entregable o volver a revisión.
+7
View File
@@ -54,6 +54,13 @@ reports/*
!reports/.gitkeep !reports/.gitkeep
projects/*/reports/ projects/*/reports/
# Papers — artefacto local: papers académicos reproducibles. En fase interna viven
# local y gitignored (como los reports); al promocionar a fase publishable se
# vuelven sub-repo Gitea propio (como apps/analyses). Solo el marcador .gitkeep se
# versiona. Convención: docs/capabilities/papers.md
papers/*
!papers/.gitkeep
# Node / pnpm # Node / pnpm
**/node_modules/ **/node_modules/
+58
View File
@@ -0,0 +1,58 @@
---
name: next_numbered_dir
kind: function
lang: bash
domain: io
version: "1.0.0"
purity: impure
signature: "next_numbered_dir(parent_dir: string, [width: int]) -> string"
description: "Calcula el siguiente prefijo numerico NNNN- para un directorio numerado incremental. Escanea los subdirectorios directos de parent_dir cuyo nombre empiece por NNNN- (4+ digitos seguidos de guion), toma el maximo, le suma 1 y lo imprime con zero-padding al ancho width (default 4). Si parent_dir no existe o no tiene subdirs que matcheen, imprime 0001."
tags: [papers, io, scaffold]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: parent_dir
desc: "directorio padre cuyos subdirectorios numerados (NNNN-...) se escanean; obligatorio"
- name: width
desc: "ancho del zero-padding del numero impreso (default 4); opcional"
output: "el siguiente numero como string con zero-padding a width digitos a stdout (ej. 0003); usage a stderr y exit 1 si falta parent_dir"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/io/next_numbered_dir.sh"
---
## Ejemplo
```bash
source bash/functions/io/next_numbered_dir.sh
# Sobre un papers/ que ya contiene 0001-foo y 0002-bar
mkdir -p /tmp/papers/{0001-foo,0002-bar}
next_numbered_dir /tmp/papers
# -> 0003
# Directorio vacio o inexistente -> primer numero
next_numbered_dir /tmp/papers_nuevo
# -> 0001
# Ancho de padding distinto
next_numbered_dir /tmp/papers 6
# -> 000003
```
## Cuando usarla
Cuando scaffoldees un artefacto numerado incremental (papers/, reports/, issues/) y necesites el siguiente NNNN sin colision: escanea lo que ya existe en disco y te da el numero libre listo para crear `<NNNN>-<slug>`.
## Gotchas
- **Impura**: lee el filesystem (estado del directorio en el momento de la llamada). No crea nada — solo calcula e imprime el numero.
- **Octal**: los numeros con cero a la izquierda (`08`, `09`) se interpretan como octal en aritmetica bash y romperian el calculo. La funcion fuerza base 10 con `10#$num` para evitarlo.
- **Solo subdirectorios**: cuenta unicamente subdirs directos. Archivos sueltos (`.gitkeep`, `notas.md`) y subdirs que no matcheen el patron se ignoran. No es recursivo.
- **Patron estricto**: el prefijo debe ser `NNNN-` (minimo 4 digitos seguidos de guion). Un subdir `12-foo` o `0001foo` (sin guion) NO se cuenta.
- No hay deteccion de huecos: devuelve `max+1`, no el primer numero libre intermedio. Si tienes `0001` y `0003`, devuelve `0004`, no `0002`.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# next_numbered_dir — Compute the next NNNN- prefix for a numbered directory.
#
# Scans the DIRECT subdirectories of <parent_dir> whose names start with a
# numeric prefix of the form `NNNN-` (4+ digits followed by a hyphen), takes
# the maximum number, adds 1, and prints it zero-padded to <width> (default 4).
# If <parent_dir> does not exist or contains no matching subdir, prints the
# first number (0001 at default width).
next_numbered_dir() {
local parent_dir="${1:-}"
local width="${2:-4}"
if [[ -z "$parent_dir" ]]; then
echo "usage: next_numbered_dir <parent_dir> [width]" >&2
return 1
fi
local max=0
local entry base num
if [[ -d "$parent_dir" ]]; then
# Iterate only over direct subdirectories. The trailing slash in the
# glob ensures files (e.g. .gitkeep) are skipped — only dirs match.
for entry in "$parent_dir"/*/; do
# If the glob matched nothing it stays literal; guard with -d.
[[ -d "$entry" ]] || continue
base="$(basename "$entry")"
# Require a prefix of 4+ digits followed by a hyphen.
if [[ "$base" =~ ^([0-9]{4,})- ]]; then
num="${BASH_REMATCH[1]}"
# Force base 10 so leading zeros (08, 09) are not read as octal.
num=$((10#$num))
if (( num > max )); then
max=$num
fi
fi
done
fi
printf "%0*d\n" "$width" $(( max + 1 ))
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
next_numbered_dir "$@"
fi
+69
View File
@@ -0,0 +1,69 @@
---
name: init_paper
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_paper(slug: string, [--title <t>] [--domain <d>] [--tags <csv>]) -> void"
description: "Scaffold de un paper académico reproducible en papers/<NNNN-slug>/. Calcula el siguiente número incremental escaneando papers/, crea las subcarpetas (experiments data figures reviews out), copia las plantillas paper.md (IMRaD) + preregistration.md (anti-HARKing) rellenando el frontmatter (title, slug, date de hoy, phase=question, status=draft) y crea references.md. NO hace git init: el paper arranca en fase interna local (papers/ gitignored). Grupo de capacidad papers."
tags: [papers, scaffold, paper, pipeline, bash, launcher]
uses_functions:
- next_numbered_dir_bash_io
- slugify_ascii_py_core
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: slug
desc: "identificador legible del paper; se slugifica a ASCII (espacios/acentos se normalizan) y se prefija con el siguiente NNNN incremental"
- name: "--title"
desc: "título del paper (string); si se omite, usa el slug limpio. No debe contener el carácter '|'"
- name: "--domain"
desc: "dominio del paper escrito en el frontmatter (default datascience)"
- name: "--tags"
desc: "tags CSV que se escriben en el frontmatter de paper.md (opcional)"
output: "sin salida directa; crea papers/<NNNN-slug>/ con paper.md, preregistration.md, references.md y las subcarpetas experiments/ data/ figures/ reviews/ out/. Imprime el resumen y los pasos siguientes a stdout."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/init_paper.sh"
---
## Ejemplo
```bash
# Scaffold de un paper nuevo (numera 0001, 0002, ... automáticamente)
fn run init_paper mi-primer-paper --title "Mi primer paper"
fn run init_paper reactive-loop-calls --domain datascience --tags registry,telemetria
# El slug se slugifica: "Áreas de Mejora" -> papers/0003-areas-de-mejora/
fn run init_paper "Áreas de Mejora"
```
## Cuando usarla
Cuando empiezas un paper académico nuevo dentro de `fn_registry` y necesitas el esqueleto del artefacto (`papers/<NNNN-slug>/`) con las plantillas IMRaD y de pre-registro listas para rellenar. Es el paso 1 del grupo de capacidad `papers` (ver `docs/capabilities/papers.md`), antes de la revisión de literatura y del pre-registro de la hipótesis.
## Flujo
1. Parsea `<slug>` (posicional) + flags `--title` / `--domain` / `--tags`. Falla con exit ≠ 0 si falta el slug.
2. `slugify_ascii` — normaliza el slug a ASCII lowercase sin diacríticos (reutiliza la función del registry, solo stdlib).
3. `next_numbered_dir papers/` — calcula el siguiente NNNN de 4 dígitos sin colisión.
4. Crea `papers/<NNNN-slug>/` con las subcarpetas `experiments/ data/ figures/ reviews/ out/`.
5. Copia `docs/templates/paper.md` + `docs/templates/preregistration.md` y rellena el frontmatter por clave de línea (title, slug, date de hoy, domain, tags; phase=question y status=draft vienen de la plantilla).
6. Crea `references.md` vacío.
## Gotchas
- **NO hace `git init`.** El paper arranca en fase interna local; `papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona). Promocionar a sub-repo Gitea (fase publishable) es manual.
- **El `--title` no debe contener el carácter `|`** (se usa como delimitador de sed al rellenar el frontmatter; los `&` y `\` sí se escapan).
- **No indexa el paper en `registry.db`** — los artefactos `papers/<slug>/` no se indexan en esta fase (KISS); sí se indexa este pipeline.
- Requiere `python3` (del venv del registry o del sistema) para slugificar; `slugify_ascii` solo usa stdlib, así que el venv no es obligatorio.
- Idempotencia: si el directorio destino ya existiera, aborta con exit ≠ 0 en vez de sobrescribir.
## Notas
Cada paper es un artefacto independiente (mismo patrón que `apps/` y `analysis/`, pero para investigación). El pipeline usa `set -euo pipefail`: cualquier fallo detiene la ejecución. Parte del grupo de capacidad `papers` — diseño completo en `reports/0001-2026-06-30-papers-system-design.md`.
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# init_paper
# ----------
# Scaffold de un paper académico reproducible en papers/<NNNN-slug>/.
#
# Calcula el siguiente número incremental escaneando papers/, crea el
# directorio con todas las subcarpetas (experiments data figures reviews out),
# copia las plantillas paper.md + preregistration.md rellenando el frontmatter
# (title, slug, date de hoy, phase=question, status=draft) y crea references.md.
#
# NO hace `git init`: el paper arranca en fase interna local (papers/ está
# gitignored en el repo padre, solo .gitkeep se versiona). La promoción a
# sub-repo Gitea (fase publishable) es un paso posterior MANUAL.
#
# Compone: next_numbered_dir (helper de numeración del registry) +
# slugify_ascii (slug ASCII del registry).
#
# USO:
# ./init_paper.sh <slug> [--title "..."] [--domain <d>] [--tags a,b,c]
#
# EJEMPLOS:
# ./init_paper.sh mi-primer-paper --title "Mi primer paper"
# ./init_paper.sh reactive-loop-calls --domain datascience --tags registry,telemetria
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Funciones atómicas del registry
source "$REGISTRY_ROOT/bash/functions/io/next_numbered_dir.sh"
# ── Parsing de argumentos ────────────────────────────────────
SLUG_RAW=""
TITLE=""
DOMAIN="datascience"
TAGS=""
while [ $# -gt 0 ]; do
case "$1" in
--title)
TITLE="$2"; shift 2 ;;
--domain)
DOMAIN="$2"; shift 2 ;;
--tags)
TAGS="$2"; shift 2 ;;
-h|--help)
grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
-*)
echo "Flag desconocido: $1" >&2 ; exit 1 ;;
*)
if [ -z "$SLUG_RAW" ]; then
SLUG_RAW="$1"
else
echo "ERROR: argumento posicional inesperado: '$1' (solo se admite un <slug>)." >&2
exit 1
fi
shift ;;
esac
done
if [ -z "$SLUG_RAW" ]; then
echo "ERROR: falta el argumento <slug>." >&2
echo "Uso: $0 <slug> [--title \"...\"] [--domain <d>] [--tags a,b,c]" >&2
echo " Ejemplo: $0 mi-primer-paper --title \"Mi primer paper\"" >&2
exit 1
fi
# ── Slugificar (reutiliza slugify_ascii del registry; solo stdlib) ──
PYBIN="$REGISTRY_ROOT/python/.venv/bin/python3"
[ -x "$PYBIN" ] || PYBIN="$(command -v python3 || true)"
if [ -z "$PYBIN" ]; then
echo "ERROR: no se encontró python3 para slugificar el slug." >&2
exit 1
fi
SLUG_CLEAN=$("$PYBIN" -c '
import sys, os
sys.path.insert(0, os.path.join(sys.argv[2], "python", "functions"))
from core.slugify_ascii import slugify_ascii
print(slugify_ascii(sys.argv[1], default="paper"))
' "$SLUG_RAW" "$REGISTRY_ROOT")
# ── Resolver número incremental y directorio destino ─────────
PAPERS_DIR="$REGISTRY_ROOT/papers"
mkdir -p "$PAPERS_DIR"
NUM=$(next_numbered_dir "$PAPERS_DIR")
SLUG_FULL="${NUM}-${SLUG_CLEAN}"
PAPER_DIR="$PAPERS_DIR/$SLUG_FULL"
if [ -d "$PAPER_DIR" ]; then
echo "ERROR: el directorio del paper ya existe: $PAPER_DIR" >&2
exit 1
fi
TODAY=$(date +%Y-%m-%d)
[ -n "$TITLE" ] || TITLE="$SLUG_CLEAN"
TAGS_YAML="[]"
if [ -n "$TAGS" ]; then
TAGS_YAML="[$(echo "$TAGS" | sed 's/,/, /g')]"
fi
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT PAPER: ${SLUG_FULL}"
echo " Título: ${TITLE}"
echo " Directorio: ${PAPER_DIR}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── Crear estructura ─────────────────────────────────────────
echo "[1/3] Creando estructura..."
mkdir -p "$PAPER_DIR"/experiments "$PAPER_DIR"/data "$PAPER_DIR"/figures \
"$PAPER_DIR"/reviews "$PAPER_DIR"/out
echo " experiments/ data/ figures/ reviews/ out/"
# ── Copiar plantillas + rellenar frontmatter ─────────────────
echo "[2/3] Escribiendo paper.md + preregistration.md..."
# Escapa caracteres especiales del RHS de sed (delimitador |)
sed_escape() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; }
TITLE_ESC="$(sed_escape "$TITLE")"
DOMAIN_ESC="$(sed_escape "$DOMAIN")"
PAPER_MD="$PAPER_DIR/paper.md"
PREREG_MD="$PAPER_DIR/preregistration.md"
cp "$REGISTRY_ROOT/docs/templates/paper.md" "$PAPER_MD"
cp "$REGISTRY_ROOT/docs/templates/preregistration.md" "$PREREG_MD"
sed -i \
-e "s|^title:.*|title: \"${TITLE_ESC}\"|" \
-e "s|^slug:.*|slug: ${SLUG_FULL}|" \
-e "s|^date:.*|date: ${TODAY}|" \
-e "s|^domain:.*|domain: ${DOMAIN_ESC}|" \
-e "s|^tags:.*|tags: ${TAGS_YAML}|" \
"$PAPER_MD"
sed -i \
-e "s|^paper_slug:.*|paper_slug: ${SLUG_FULL}|" \
"$PREREG_MD"
echo " $PAPER_MD"
echo " $PREREG_MD"
# ── references.md ────────────────────────────────────────────
echo "[3/3] Escribiendo references.md..."
cat > "$PAPER_DIR/references.md" << EOF
# References — ${TITLE}
<!-- Una entrada por referencia. Formato libre (o BibTeX) hasta promocionar a publishable. -->
EOF
echo " $PAPER_DIR/references.md"
# ── Resumen ──────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════════"
echo " PAPER '${SLUG_FULL}' LISTO (fase: question, status: draft)"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo " 1. Revisión de literatura (skill /deep-research) → Related work."
echo " 2. Pre-registro: congela H0/H1 + plan en preregistration.md (preregister_hypothesis)."
echo " 3. Experimentos en experiments/ → análisis (grupo eda) → escritura IMRaD en paper.md."
echo " 4. render_paper_pdf → out/paper.pdf. Peer review adversarial → reviews/."
echo ""
echo " papers/ está gitignored: este paper vive local hasta promocionar a publishable."
echo ""
+62 -2
View File
@@ -41,12 +41,13 @@ reconocido se degrada a `Note`, nunca lanza).
| `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento | | `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento |
| `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** | | `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** |
| `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve | | `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve |
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **se parte por filas repitiendo cabecera**; las celdas largas se envuelven dentro de su columna | | `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **si cabe** como texto se parte por filas repitiendo cabecera; **si NO cabe** (demasiadas columnas) se rasteriza entera como imagen de alta resolución para hacer zoom. Ver §11.4 |
| `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) | | `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 | | `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 | | `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 | | `Group(blocks, title=None, page_break_before=False, layout="stack")` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. `layout="side_by_side"` coloca tabla+figura en dos columnas (solo PPTX). Ver §11 y §11.4 |
| `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 | | `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 |
| `TocEntry(label, target_id)` | una entrada de **índice clicable** en la portada | la genera el capítulo `portada`; el renderer la cablea como salto al inicio del capítulo cuyo `id` o `title` coincide con `target_id`. Ver §11.4 |
`Figure`/`Image` aceptan `height_in` (hint): el renderer **clampa** la figura a esa altura máxima (lo usa `Group` para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es **siempre** visible (si no se da `caption`, cae al último heading o a "Figura"). `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").
@@ -397,6 +398,65 @@ cabecera con su fondo propio. Es automático en PDF y PPTX; el patrón se mantie
cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por 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. página). No hay nada que hacer en los capítulos.
### 11.4 Calidad de render global: DPI alto, tabla ancha → imagen, figura al lado, índice clicable
Cuatro capacidades transversales del motor, **todas automáticas salvo `layout`** (que un
capítulo activa explícitamente). Aplican a PDF y PPTX salvo donde se indique.
**(a) DPI alto (automático).** Toda figura/imagen embebida se rasteriza a **220 dpi**
(constante `_RASTER_DPI` en ambos renderers; en PDF se aplica también al `savefig` de la
página, porque matplotlib re-rasteriza cada `imshow` al escribir la página). Objetivo:
ampliar en el móvil y leer detalle (ejes, celdas) sin pixelar. El texto sigue siendo
vectorial y seleccionable. No hay nada que hacer en los capítulos.
**(b) Tabla ancha → imagen de alta resolución (automático).** Cuando un `DataTable` tiene
**demasiadas columnas para ser legible como texto** en el ancho útil (criterio
`_table_fits_as_text`: ancho mínimo legible por columna × nº de columnas > ancho útil; en
la práctica salta sobre tablas tipo `df.head` con muchas columnas), en vez de comprimir las
columnas hasta hacerlas ilegibles, la tabla se dibuja **entera como una imagen de alta
resolución** (función `render_table_as_figure_py_datascience`: cabecera sombreada + zebra)
escalada para caber completa, de modo que el lector hace **zoom** y la lee sin perder datos.
Si la tabla **sí cabe**, se mantiene como texto seleccionable (PDF) / tabla nativa (PPTX).
Las `KVTable` (2 columnas) caben siempre y se quedan como texto. No hay nada que hacer en
los capítulos.
**(c) Figura al lado de la tabla — `Group(layout="side_by_side")`.** Hint de layout que un
capítulo activa para que su **tabla quede a la izquierda y su figura a la derecha** en la
misma diapositiva, en lugar de apiladas:
```python
model.Group(
layout="side_by_side",
blocks=[
model.Heading(text=str(name), level=2), # va a ancho completo arriba
model.DataTable(header=..., rows=...), # columna IZQUIERDA (~55%)
model.Figure(make=_grafico_perezoso(...)), # columna DERECHA (~45%)
model.Markdown(text="explicación…"), # va a ancho completo abajo
])
```
Contrato exacto del campo:
| Campo | Valor | Efecto |
|---|---|---|
| `layout` | `"stack"` (por defecto) | comportamiento histórico: apilado vertical (keep-together). |
| `layout` | `"side_by_side"` | **PPTX**: la tabla (rasterizada a imagen) ocupa la columna izquierda (~55% del ancho útil) y la figura la derecha (~45%); cualquier otro bloque (heading, markdown) va a ancho completo arriba/abajo. Si no hay un par tabla+figura, o no caben lado a lado en una slide, **cae automáticamente a apilado**. **PDF**: se trata **igual que `stack`** (el ancho A5 móvil no admite dos columnas legibles). Valores desconocidos degradan a `"stack"`. |
Es **retrocompatible**: un `Group` sin `layout` (o `layout="stack"`) se comporta exactamente
como antes. El capítulo `cat_distr` es el consumidor previsto (gráfico a la derecha de la
tabla de categorías en PPT); este motor solo provee el soporte.
**(d) Índice clicable en la portada — `TocEntry`.** La portada emite un `Heading("Índice")`
seguido de un `TocEntry(label, target_id)` por capítulo. El renderer registra la
página/slide de inicio de **cada** capítulo (indexado por `id` **y** por `title`) y cablea
cada `TocEntry` como un salto real a ese inicio: en **PDF** vía
`add_pdf_internal_links_py_datascience` (link GOTO de PyMuPDF), en **PPTX** vía
`pptx_link_run_to_slide_py_datascience` (salto a slide nativo). Como la portada solo conoce
los **títulos** de los capítulos, el `target_id` se hace coincidir contra el `title` (o el
`id`) de destino. Si un destino no resuelve, la entrada se muestra igualmente como texto
(en color de enlace), nunca se corta. Es el mismo mecanismo que los términos clicables del
glosario (§11.1), reutilizado en sentido portada → capítulo.
--- ---
## 10. Integración futura con `profile_table` (siguiente fase) ## 10. Integración futura con `profile_table` (siguiente fase)
+1
View File
@@ -39,6 +39,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [cpp-tables](tql.md) | 9 | Table Query Language C++ puro: filter, group, agg, sort, join, stats, formulas Lua, round-trip emit/apply | | [cpp-tables](tql.md) | 9 | Table Query Language C++ puro: filter, group, agg, sort, join, stats, formulas Lua, round-trip emit/apply |
| [data-table-renderers](data_table_renderers.md) | 1 | API declarativa de cell renderers para data_table: Badge, Progress, Duration, Icon via TableInput.column_specs | | [data-table-renderers](data_table_renderers.md) | 1 | API declarativa de cell renderers para data_table: Badge, Progress, Duration, Icon via TableInput.column_specs |
| [scheduler](scheduler.md) | 4 | Cron expression parsing, matching, next-run y traduccion humana (consume `apps/dag_engine`) | | [scheduler](scheduler.md) | 4 | Cron expression parsing, matching, next-run y traduccion humana (consume `apps/dag_engine`) |
| [papers](papers.md) | — | Papers académicos reproducibles en `papers/<NNNN-slug>/`: scaffold del artefacto (`init_paper` + helper `next_numbered_dir`), plantillas IMRaD + pre-registro anti-HARKing, y (en construcción por la flota) congelar hipótesis, funciones estadísticas (effect size/CI/corrección múltiple), render md→PDF y peer-review adversarial. Reutiliza `deep-research`, grupo `eda` y el motor PDF de `datascience`. Diseño: `reports/0001-2026-06-30-papers-system-design.md` |
| [extractor](extractor.md) | 15 | Funciones que leen datos de fuentes externas (BD, API, archivos, web). Nodos input de `data_factory` | | [extractor](extractor.md) | 15 | Funciones que leen datos de fuentes externas (BD, API, archivos, web). Nodos input de `data_factory` |
| [transformer](transformer.md) | 15 | Funciones que clean/dedup/aggregate/feature-engineer datos. Nodos intermedios de `data_factory` | | [transformer](transformer.md) | 15 | Funciones que clean/dedup/aggregate/feature-engineer datos. Nodos intermedios de `data_factory` |
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output | | [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
+82
View File
@@ -0,0 +1,82 @@
# papers — papers académicos reproducibles
Grupo de capacidad para producir **papers académicos** dentro de `fn_registry`: investigación con hipótesis falsables, experimentos reproducibles, análisis estadístico honesto y escritura en formato IMRaD. Cada paper es un artefacto nuevo en `papers/<NNNN-slug>/` que reutiliza infraestructura existente (skill `deep-research` para la revisión de literatura, grupo `eda` para el análisis, motor md→PDF de `datascience`, patrón de verificación adversarial del orquestador) y añade lo que falta como funciones del registry.
Diseño completo y decisiones: `reports/0001-2026-06-30-papers-system-design.md`.
> **Regla de oro anti paper-mill:** una hipótesis que **podía** fallar + un experimento con riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de refutación, no es un paper. Los claims nunca superan a la evidencia. El antídoto al HARKing es el **pre-registro**: el plan de análisis se congela *antes* de mirar los datos.
## Estructura del artefacto
```
papers/0001-mi-paper/
paper.md # frontmatter (title, slug, authors, date, status, phase, tags, domain, hypothesis_id) + cuerpo IMRaD
preregistration.md # H0/H1 + plan de análisis CONGELADO (frozen_at + content_hash) antes de correr
references.md # bibliografía
experiments/ # código / notebooks por experimento (exp01_*, exp02_*)
data/ # crudos + procesados (gitignored si pesa)
figures/ # gráficos generados
reviews/ # outputs del peer-review adversarial
out/ # paper.pdf — entregable final
.git/ # SOLO cuando promociona a fase publishable (sub-repo Gitea)
```
`papers/` está gitignored en el repo padre (solo `papers/.gitkeep` se versiona): un paper en fase interna no contamina el repo. Al promocionar a `status: publishable` se vuelve sub-repo Gitea `dataforge/<slug>` (como apps y analyses).
### Fases (campo `phase` de `paper.md`)
```
question → review → hypothesis → design → running → analysis → writing → internal-review
→ [DONE interno] → polish → submitted [solo en fase publishable]
```
## Funciones
| ID | Pureza | Estado | Qué hace |
|---|---|---|---|
| `init_paper_bash_pipelines` | impure | ✅ disponible | Scaffold de `papers/<NNNN-slug>/`: calcula el siguiente NNNN, crea las subcarpetas, copia `paper.md` + `preregistration.md` con el frontmatter relleno (slug, title, date de hoy, `phase: question`, `status: draft`) y `references.md` vacío. NO hace `git init` (el paper arranca en fase interna local). |
| `next_numbered_dir_bash_io` | impure | ✅ disponible | Dado un directorio, devuelve el siguiente número incremental de 4 dígitos (`0001`, `0002`, …) escaneando los subdirs con prefijo `NNNN-`. Helper de numeración de `init_paper` (reutilizable por reports/issues). |
| `preregister_hypothesis` | impure | 🚧 en construcción (flota) | Congela el `preregistration.md` (H0/H1 + plan de análisis) con `frozen_at` + `content_hash`, pasa `status` a `frozen` y escribe `hypothesis_id` en `paper.md`. Mata el HARKing: tras congelar, el plan no se edita. |
| `cohens_d` (effect size) | pure | 🚧 en construcción (flota) | Tamaño del efecto (Cohen's d) entre dos grupos. Reporta magnitud, no solo significancia. |
| `confidence_interval` | pure | 🚧 en construcción (flota) | Intervalo de confianza de una métrica (media/diferencia). |
| `holm_bonferroni` | pure | 🚧 en construcción (flota) | Corrección de comparaciones múltiples (Holm-Bonferroni / FWER) para el plan de análisis. |
| `render_paper_pdf` | impure | 🚧 en construcción (flota) | Markdown IMRaD (`paper.md` + figuras) → `out/paper.pdf`, reutilizando el motor md→PDF del grupo `eda`/`datascience`. |
> Las funciones estadísticas reutilizan lo que ya exista en `datascience` (p.ej. `fdr_correction_py_datascience` cubre la corrección de comparaciones múltiples por FDR; el agente del rigor experimental decide si añade Holm-Bonferroni o reusa lo existente). Buscar antes de duplicar: `mcp__registry__fn_search query="effect size" domain="datascience"`.
### Peer review (no es función del registry)
El agente adversarial `.claude/agents/paper-reviewer.md` (🚧 en construcción por la flota) puntúa novedad, rigor, reproducibilidad y validez, e intenta **refutar** cada claim. Default a "failed" si la evidencia no soporta. Escribe su veredicto en `reviews/`. Es el equivalente al verificador adversarial del orquestador aplicado al paper.
## Ejemplo canónico (end-to-end)
```bash
# 1. Scaffold del paper (fase question, local). Crea papers/0001-mi-paper/.
./fn run init_paper mi-paper --title "¿El bucle reactivo reduce las calls inline?" --domain datascience --tags registry,telemetria
# 2. Revisión de literatura → llena Related work (skill deep-research, fase review).
# /deep-research "..."
# 3. Pre-registro: congela H0/H1 + plan de análisis ANTES de mirar datos (fase hypothesis).
./fn run preregister_hypothesis papers/0001-mi-paper # 🚧 en construcción
# 4. Experimentos en papers/0001-mi-paper/experiments/ (fase running) →
# análisis con el grupo `eda` + funciones de effect size / CI / corrección múltiple (fase analysis).
# 5. Escritura IMRaD en paper.md (fase writing) → render del entregable PDF.
./fn run render_paper_pdf papers/0001-mi-paper # 🚧 en construcción → out/paper.pdf
# 6. Peer review adversarial (fase internal-review).
# Agent(subagent_type="paper-reviewer", prompt="Revisa papers/0001-mi-paper ...") # 🚧 en construcción
```
## Fronteras
- **NO es para reports de trabajo.** Un report (`reports/`) es el entregable escrito de una tarea (resumen + evidencia + gaps); un paper es investigación con hipótesis falsable y experimento. Ver `.claude/rules/reports.md`.
- **NO se indexa en `registry.db` en esta fase.** No hay tabla `papers` ni `entity_type` `paper` (KISS); se añadiría con migración propia si se decide. Las *funciones* del grupo sí se indexan (viven en `bash/functions/`, `python/functions/`), pero los artefactos `papers/<slug>/` no.
- **NO hace `git init` en el scaffold.** El paper arranca en fase interna local y gitignored. La promoción a sub-repo Gitea (fase publishable) es un paso manual posterior.
- **NO soporta LaTeX/arXiv todavía.** Formato elegido: Markdown como fuente + PDF como entregable. El soporte LaTeX se añadiría al promocionar un paper a fase publishable.
## Estado
Fase de scaffolding. Disponible: estructura del artefacto, plantillas (`docs/templates/paper.md`, `docs/templates/preregistration.md`), pipeline `init_paper` + helper `next_numbered_dir`, esta página y el bloque gitignore de `papers/`. En construcción por la flota: `preregister_hypothesis`, funciones estadísticas (effect size / CI / corrección múltiple), `render_paper_pdf` y el agente `paper-reviewer`. Validación end-to-end con un paper piloto real: pendiente.
+94
View File
@@ -0,0 +1,94 @@
---
title: "TITULO DEL PAPER"
slug: NNNN-slug
authors: [Enmanuel]
date: 2026-01-01
status: draft # draft | internal | publishable
phase: question # question -> review -> hypothesis -> design -> running -> analysis -> writing -> internal-review -> polish -> submitted
tags: []
domain: datascience
hypothesis_id: "" # lo rellena preregister_hypothesis al congelar el preregistro
---
<!--
Paper académico reproducible (formato IMRaD). Esta es la FUENTE editable en Markdown;
el entregable PDF se genera con render_paper_pdf (grupo `papers`).
Regla de oro anti paper-mill: una hipótesis que PODÍA fallar + un experimento con
riesgo real de refutación + estadística que no es teatro. Si no hay riesgo de
refutación, no es un paper. Los claims nunca superan a la evidencia.
-->
# {{título del paper}}
## Abstract
<!--
Resumen estructurado en 4-6 frases: contexto -> gap -> método -> resultados -> conclusión.
Sin citas, sin abreviaturas sin definir. Es lo único que mucha gente leerá: que se sostenga solo.
-->
## 1. Introduction
<!--
Embudo en cuatro movimientos:
1. Contexto — el área y por qué importa.
2. Gap — qué NO se sabe todavía (el hueco que este paper llena).
3. Pregunta / hipótesis — formulada de forma falsable (ver preregistration.md).
4. Contribución — lista explícita de lo que aporta este trabajo ("Contributions:").
-->
## 2. Related work
<!--
Qué existe ya y por qué no basta. Agrupa por enfoque, no por autor. Cada cita debe
justificar por qué el gap sigue abierto. Output de la fase de revisión (skill deep-research).
-->
## 3. Methods
<!--
Diseño REPRODUCIBLE: otra persona lo corre y obtiene lo mismo.
- Variables: independiente(s), dependiente(s), control.
- Diseño: N, condiciones, muestreo, aleatorización.
- Métricas y cómo se miden.
- Protocolo paso a paso + dónde vive el código (experiments/) y los datos (data/).
Debe ser coherente con el preregistration.md congelado (no se cambia el plan tras ver datos).
-->
## 4. Results
<!--
Datos SIN interpretar. Tablas y figuras (figures/) con su lectura literal.
Reporta effect size + intervalos de confianza, no solo p-valores.
Incluye también los resultados negativos / no significativos (anti cherry-picking).
-->
## 5. Discussion
<!--
Interpretación de los resultados a la luz de la pregunta. Claims <= evidencia.
-->
### 5.1 Limitaciones
<!-- Qué no cubre el estudio, supuestos, datos faltantes. Honestidad explícita. -->
### 5.2 Amenazas a la validez
<!--
- Validez interna — ¿la causa es lo que decimos o hay confusores?
- Validez externa — ¿generaliza fuera de esta muestra/condiciones?
- Validez de constructo — ¿la métrica mide lo que dice medir?
- Validez estadística — ¿N suficiente, supuestos del test cumplidos, comparaciones múltiples corregidas?
-->
## 6. Conclusion + Future work
<!--
Cierre en 2-4 frases: qué se aprendió (sin overclaiming) + las siguientes preguntas que abre.
-->
## References
<!-- Ver references.md. -->
+59
View File
@@ -0,0 +1,59 @@
---
paper_slug: NNNN-slug
frozen_at: "" # timestamp ISO — lo rellena preregister_hypothesis al congelar
content_hash: "" # hash del contenido congelado — lo rellena preregister_hypothesis
status: draft # draft -> frozen (preregister_hypothesis lo pasa a frozen; tras congelar NO se edita)
---
> **⚠️ ESTE DOCUMENTO SE CONGELA ANTES DE MIRAR LOS DATOS (anti-HARKing).**
> El plan de análisis se fija aquí *antes* de ejecutar el experimento. Una vez congelado
> (`status: frozen`, con `frozen_at` + `content_hash`), **no se edita**. Inventar o ajustar
> la hipótesis después de ver los resultados (HARKing) invalida el paper. Si el plan cambia
> tras ver datos, eso es análisis exploratorio y se reporta como tal, no como confirmatorio.
# Pre-registro — {{título del paper}}
## 1. Pregunta de investigación
<!-- La pregunta concreta, en una frase. Debe poder responderse con un experimento. -->
## 2. Hipótesis
<!-- Falsable (Popper): una predicción que PODRÍA fallar. -->
- **H0 (nula):** <!-- no hay efecto / no hay diferencia. Es lo que el test intenta rechazar. -->
- **H1 (alternativa):** <!-- el efecto esperado, con dirección si la hay. -->
## 3. Variables
- **Independiente(s):** <!-- lo que se manipula. -->
- **Dependiente(s):** <!-- lo que se mide (la métrica de resultado). -->
- **Control:** <!-- lo que se mantiene fijo / se cubre estadísticamente. -->
## 4. Diseño
<!--
- N: tamaño de muestra (y justificación / power analysis si aplica).
- Condiciones / grupos.
- Muestreo y aleatorización.
- Criterios de inclusión / exclusión de datos (definidos AHORA, no después).
-->
## 5. Plan de análisis
<!--
El plan estadístico EXACTO, decidido antes de ver los datos:
- Test estadístico concreto (p.ej. t-test de Welch, Mann-Whitney U, regresión...).
- Métrica de effect size (p.ej. Cohen's d, diferencia de medias, odds ratio).
- Criterio de decisión (umbral alpha, qué resultado confirma/refuta H1).
- Corrección por comparaciones múltiples (p.ej. Holm-Bonferroni) si hay >1 contraste.
- Manejo de supuestos (normalidad, varianzas) y qué se hace si no se cumplen.
-->
## 6. Predicción cuantitativa
<!--
La predicción numérica concreta que el experimento pondrá a prueba.
P.ej. "esperamos d >= 0.5 con IC95% que no cruza 0" o "una reducción >= 15% en la métrica X".
Cuanto más específica, más falsable.
-->
File diff suppressed because one or more lines are too long
View File
+16
View File
@@ -59,6 +59,9 @@ from .acf_pacf import acf_pacf
from .stl_decompose import stl_decompose from .stl_decompose import stl_decompose
from .to_returns import to_returns from .to_returns import to_returns
from .fdr_correction import fdr_correction from .fdr_correction import fdr_correction
from .effect_size_cohens_d import effect_size_cohens_d
from .confidence_interval_mean import confidence_interval_mean
from .preregister_hypothesis import preregister_hypothesis
from .suggest_reexpression import suggest_reexpression from .suggest_reexpression import suggest_reexpression
from .exploratory_caveats import exploratory_caveats from .exploratory_caveats import exploratory_caveats
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
@@ -72,8 +75,18 @@ from .profile_datetime import profile_datetime
from .resample_timeseries import resample_timeseries from .resample_timeseries import resample_timeseries
from .add_pdf_internal_links import add_pdf_internal_links from .add_pdf_internal_links import add_pdf_internal_links
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
from .render_paper_pdf import render_paper_pdf
from .draw_join_graph_figure import draw_join_graph_figure
from .generate_synthetic_eda_table import generate_synthetic_eda_table
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
from .load_bq_table_to_duckdb import load_bq_table_to_duckdb
__all__ = [ __all__ = [
"load_bq_table_to_duckdb",
"generate_synthetic_eda_table",
"generate_synthetic_eda_folder",
"render_paper_pdf",
"draw_join_graph_figure",
"suggest_intratable_fk_candidates", "suggest_intratable_fk_candidates",
"detect_time_column", "detect_time_column",
"extract_timeseries_raw", "extract_timeseries_raw",
@@ -90,6 +103,9 @@ __all__ = [
"stl_decompose", "stl_decompose",
"to_returns", "to_returns",
"fdr_correction", "fdr_correction",
"effect_size_cohens_d",
"confidence_interval_mean",
"preregister_hypothesis",
"suggest_reexpression", "suggest_reexpression",
"exploratory_caveats", "exploratory_caveats",
"render_eda_pdf", "render_eda_pdf",
@@ -29,6 +29,7 @@ from .model import ( # noqa: F401
KVTable, KVTable,
Markdown, Markdown,
Note, Note,
TocEntry,
as_blocks, as_blocks,
as_chapters, as_chapters,
merge_manifest, merge_manifest,
@@ -52,6 +53,7 @@ __all__ = [
"Group", "Group",
"GlossaryEntry", "GlossaryEntry",
"GlossaryCollector", "GlossaryCollector",
"TocEntry",
"Chapter", "Chapter",
"as_blocks", "as_blocks",
"as_chapters", "as_chapters",
@@ -0,0 +1,109 @@
"""Tests del filtro `only` de build_document (selección de capítulos).
Verifican que:
- only=None mantiene el comportamiento histórico (todos los capítulos).
- only=[ids] restringe el CUERPO a esos ids, pero portada (primera) y glosario
(última) están SIEMPRE presentes.
- only=[] produce el documento mínimo (solo portada + glosario).
- la selección también viaja por la clave reservada ctx['_only_chapters']
(el canal que usan los renderers, que llaman build_document sin `only`), y
esa clave nunca se filtra a los capítulos.
"""
import os
import sys
_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)
from datascience.automatic_eda import build_document # noqa: E402
def _profile_with_cat_and_num():
"""Perfil mínimo que hace construir cat_distr y num_distr (cuerpo no vacío)."""
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}]}},
],
}
def test_only_none_is_full_document():
"""Retro-compat: sin `only`, salen todos los capítulos aplicables."""
chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
ids = [c.id for c in chs]
assert ids[0] == "portada"
assert ids[-1] == "glosario"
# El cuerpo trae las distribuciones (cat/num), no solo portada+glosario.
assert "num_distr" in ids
assert "cat_distr" in ids
def test_only_restricts_body_but_keeps_cover_and_glossary():
# cat_distr registra el término "entropía" en el glosario, así que el
# glosario (destino del término clicable) aparece — demuestra el contrato
# "portada primera + capítulo + glosario última".
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v"}, only=["cat_distr"])
ids = [c.id for c in chs]
assert ids[0] == "portada", f"portada no es la primera: {ids}"
assert ids[-1] == "glosario", f"glosario no es la última: {ids}"
assert "cat_distr" in ids
# num_distr quedó fuera de la selección.
assert "num_distr" not in ids
def test_only_empty_yields_minimal_document():
# only=[] -> cuerpo vacío. La portada está siempre; el glosario solo aparece
# si algún capítulo registró términos (patrón preexistente: glosario vacío se
# omite). Sin cuerpo no hay términos → documento mínimo = solo portada.
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v"}, only=[])
ids = [c.id for c in chs]
assert ids == ["portada"], \
f"only=[] debe dar el documento mínimo (solo portada), no {ids}"
def test_selection_via_reserved_ctx_key():
"""La selección viaja por ctx['_only_chapters'] cuando no se pasa `only`."""
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v",
"_only_chapters": ["cat_distr"]})
ids = [c.id for c in chs]
assert "cat_distr" in ids
assert "num_distr" not in ids
assert ids[0] == "portada" and ids[-1] == "glosario"
def test_explicit_only_arg_wins_over_ctx_key():
"""Si se pasan ambos, el argumento `only` manda sobre la clave del ctx."""
chs = build_document(_profile_with_cat_and_num(),
ctx={"dataset_name": "v",
"_only_chapters": ["cat_distr"]},
only=["num_distr"])
ids = [c.id for c in chs]
assert "num_distr" in ids
assert "cat_distr" not in ids
def test_reserved_key_not_leaked_to_caller_ctx():
"""build_document no muta el ctx del caller (copia interna)."""
ctx = {"dataset_name": "v", "_only_chapters": ["num_distr"]}
build_document(_profile_with_cat_and_num(), ctx=ctx)
# La clave reservada sigue en el dict del caller (no se mutó su copia).
assert ctx["_only_chapters"] == ["num_distr"]
@@ -0,0 +1,205 @@
"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.
Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.
Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.
Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:
- ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
* ``run_models`` -> ``profile['models']`` (KMeans/IsolationForest/PCA).
Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
* ``run_series`` -> ``profile['series']`` (análisis de serie temporal).
Lo lee ``timeseries``.
* ``run_llm`` -> ``profile['llm']`` (interpretación del modelo).
Lo lee ``analisis_llm``.
- ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
* ``head_rows`` -> ``ctx['head_rows']`` (overview: df.head real).
* ``raw_numeric`` -> ``ctx['raw_numeric']`` (outliers/modelos/
correlacion/missingness/geospatial: muestra numérica alineada por fila).
* ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
* ``geo_points`` -> ``ctx['geo_points']`` (+ ``raw_numeric``)
(geospatial: lat/lon).
* ``db_path_table`` -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
text_distr/missingness/relaciones: push-down de queries propias).
``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
(la portada resume el documento y el glosario es el destino de los términos
clicables), así que aquí se declaran sin requisitos de cómputo.
Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
a test unitario directo.
"""
from __future__ import annotations
# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
# claves vacías significan "no necesita ese tipo de dependencia".
CHAPTER_DEPS = {
# Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
# el document_summary que arma build_document; el glosario lee los términos
# que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
# entero y la validación los reconozca.
"portada": {"profile_flags": [], "ctx": []},
"overview": {"profile_flags": [], "ctx": ["head_rows"]},
"analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
"num_distr": {"profile_flags": [], "ctx": []},
"cat_distr": {"profile_flags": [], "ctx": []},
# text_distr empuja su propia query de texto (no usa raw_numeric); necesita
# db_path/table en el ctx para hacerlo.
"text_distr": {"profile_flags": [], "ctx": ["db_path_table"]},
"calidad": {"profile_flags": [], "ctx": []},
# missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
# puede empujar una query de patrón de nulos con db_path/table.
"missingness": {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
# outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
# asegura además el fallback profile['models']['outliers'] si el ctx faltara.
"outliers": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
"correlacion": {"profile_flags": [], "ctx": ["raw_numeric"]},
"relaciones": {"profile_flags": [], "ctx": ["db_path_table"]},
"modelos": {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
"timeseries": {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
"geospatial": {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
"agregacion": {"profile_flags": [], "ctx": ["db_path_table"]},
"glosario": {"profile_flags": [], "ctx": []},
}
# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
ALWAYS_PRESENT = ("portada", "glosario")
# Flags de coste reconocidos (el orden no importa; se devuelven como set).
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")
# Mapeo de cada etiqueta de ctx a las claves REALES que produce
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
# geo_points) son las piezas de datos podables.
CTX_LABEL_TO_KEYS = {
"head_rows": {"head_rows"},
"raw_numeric": {"raw_numeric"},
"timeseries_raw": {"timeseries_raw"},
"geo_points": {"geo_points", "raw_numeric"},
"db_path_table": set(), # db_path/table siempre presentes; nunca se podan.
}
# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")
def _as_id_list(chapter_ids):
"""Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
if chapter_ids is None:
return []
if isinstance(chapter_ids, str):
return [chapter_ids]
return [c for c in chapter_ids if isinstance(c, str)]
def validate_chapter_ids(chapter_ids, order):
"""Separa los ids pedidos en válidos y desconocidos respecto a ``order``.
Args:
chapter_ids: lista (o str) de ids de capítulo pedidos.
order: lista canónica de ids válidos (CHAPTER_ORDER).
Returns:
dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
aparición de la entrada. Función pura.
"""
valid_set = set(order or [])
valid, unknown = [], []
for cid in _as_id_list(chapter_ids):
(valid if cid in valid_set else unknown).append(cid)
return {"valid": valid, "unknown": unknown}
def resolve_requirements(chapter_ids):
"""Une los requisitos de cómputo de los capítulos pedidos.
Es el corazón de la resolución de dependencias: dado el subconjunto de
capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
que esos capítulos lleguen COMPLETOS, y solo eso.
Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
el resultado, pero se contemplan para que el conjunto sea coherente.
Args:
chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
ignoran silenciosamente (la validación estricta es de quien llama).
None o lista vacía -> requisitos vacíos.
Returns:
dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
pura.
"""
ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
profile_flags = set()
ctx_keys = set()
for cid in ids:
dep = CHAPTER_DEPS.get(cid)
if not isinstance(dep, dict):
continue
for f in dep.get("profile_flags", []) or []:
if f in KNOWN_PROFILE_FLAGS:
profile_flags.add(f)
for k in dep.get("ctx", []) or []:
ctx_keys.add(k)
return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}
def resolve_profile_flags(chapter_ids):
"""Atajo: solo el set de profile_flags a activar para los capítulos pedidos.
Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
"""
return resolve_requirements(chapter_ids)["profile_flags"]
def needs_render_ctx(chapter_ids):
"""True si algún capítulo pedido necesita datos crudos del ctx.
Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
(ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
datos crudos. Función pura.
"""
return bool(resolve_requirements(chapter_ids)["ctx_keys"])
def resolve_ctx_data_keys(chapter_ids):
"""Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.
Traduce las etiquetas de ctx a las claves concretas que produce
``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
El pipeline poda del ctx las claves de datos que NO estén en este set, para
que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
nunca se podan (no aparecen aquí). Función pura.
Returns:
set[str] subconjunto de DATA_CTX_KEYS.
"""
req = resolve_requirements(chapter_ids)
keep = set()
for label in req["ctx_keys"]:
keep |= CTX_LABEL_TO_KEYS.get(label, set())
# Solo claves de datos podables (db_path/table se gestionan aparte).
return {k for k in keep if k in DATA_CTX_KEYS}
@@ -0,0 +1,160 @@
"""Tests del mapa central de dependencias por capítulo (chapter_deps).
Todas las funciones bajo prueba son PURAS (sin I/O): se ejercitan directamente
sin DuckDB ni renderizado. Cubren la resolución de requisitos (golden + edges),
la validación de ids y los helpers de eficiencia (qué cómputo se salta).
"""
import os
import sys
_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)
from datascience.automatic_eda.chapter_deps import ( # noqa: E402
ALWAYS_PRESENT,
CHAPTER_DEPS,
DATA_CTX_KEYS,
needs_render_ctx,
resolve_ctx_data_keys,
resolve_profile_flags,
resolve_requirements,
validate_chapter_ids,
)
from datascience.automatic_eda.chapters_registry import CHAPTER_ORDER # noqa: E402
# --------------------------------------------------------------------------- #
# El mapa cubre CHAPTER_ORDER entero (sin huecos ni claves de más).
# --------------------------------------------------------------------------- #
def test_chapter_deps_covers_every_chapter_in_order():
assert set(CHAPTER_DEPS) == set(CHAPTER_ORDER), (
"CHAPTER_DEPS debe declarar exactamente los ids de CHAPTER_ORDER")
# Cada entrada tiene la forma esperada.
for cid, dep in CHAPTER_DEPS.items():
assert isinstance(dep.get("profile_flags"), list), cid
assert isinstance(dep.get("ctx"), list), cid
# --------------------------------------------------------------------------- #
# resolve_requirements — golden: outliers exige run_models + raw_numeric.
# --------------------------------------------------------------------------- #
def test_resolve_outliers_requires_run_models_and_raw_numeric():
req = resolve_requirements(["outliers"])
assert "run_models" in req["profile_flags"]
assert "raw_numeric" in req["ctx_keys"]
assert "run_series" not in req["profile_flags"]
assert "run_llm" not in req["profile_flags"]
def test_resolve_timeseries_requires_run_series():
req = resolve_requirements(["timeseries"])
assert req["profile_flags"] == {"run_series"}
assert "timeseries_raw" in req["ctx_keys"]
def test_resolve_analisis_llm_requires_run_llm():
assert resolve_requirements(["analisis_llm"])["profile_flags"] == {"run_llm"}
def test_resolve_union_of_several_chapters():
req = resolve_requirements(["outliers", "timeseries", "analisis_llm"])
assert req["profile_flags"] == {"run_models", "run_series", "run_llm"}
# --------------------------------------------------------------------------- #
# Eficiencia: capítulos que NO necesitan flags caros no los activan.
# --------------------------------------------------------------------------- #
def test_resolve_geospatial_needs_no_cost_flags():
"""geospatial sale de geo_points/raw_numeric del ctx, NO de los modelos."""
req = resolve_requirements(["geospatial"])
assert req["profile_flags"] == set(), \
"geospatial no debe activar run_models/run_series/run_llm"
assert "geo_points" in req["ctx_keys"]
def test_resolve_correlacion_needs_raw_numeric_but_no_models():
req = resolve_requirements(["correlacion"])
assert req["profile_flags"] == set()
assert "raw_numeric" in req["ctx_keys"]
def test_always_present_chapters_add_no_requirements():
"""portada y glosario están siempre, pero no arrastran cómputo."""
for cid in ALWAYS_PRESENT:
req = resolve_requirements([cid])
assert req["profile_flags"] == set()
assert req["ctx_keys"] == set()
def test_resolve_profile_flags_shortcut():
assert resolve_profile_flags(["modelos"]) == {"run_models"}
assert resolve_profile_flags(["num_distr"]) == set()
# --------------------------------------------------------------------------- #
# needs_render_ctx — cuándo se puede saltar build_eda_render_ctx por completo.
# --------------------------------------------------------------------------- #
def test_needs_render_ctx_true_when_chapter_reads_raw_data():
assert needs_render_ctx(["outliers"]) is True
assert needs_render_ctx(["agregacion"]) is True # db_path/table push-down
assert needs_render_ctx(["timeseries"]) is True
def test_needs_render_ctx_false_for_purely_aggregated_chapters():
"""num_distr / cat_distr / calidad solo leen el profile agregado."""
assert needs_render_ctx(["num_distr"]) is False
assert needs_render_ctx(["cat_distr", "calidad"]) is False
# --------------------------------------------------------------------------- #
# resolve_ctx_data_keys — poda: qué claves de DATOS conservar (db_path/table no).
# --------------------------------------------------------------------------- #
def test_resolve_ctx_data_keys_outliers_keeps_only_raw_numeric():
assert resolve_ctx_data_keys(["outliers"]) == {"raw_numeric"}
def test_resolve_ctx_data_keys_geospatial_keeps_geo_and_numeric():
assert resolve_ctx_data_keys(["geospatial"]) == {"geo_points", "raw_numeric"}
def test_resolve_ctx_data_keys_aggregation_keeps_nothing_prunable():
"""agregacion usa db_path/table (siempre presentes), 0 claves podables."""
assert resolve_ctx_data_keys(["agregacion"]) == set()
def test_resolve_ctx_data_keys_subset_of_data_keys():
keep = resolve_ctx_data_keys(["overview", "timeseries", "geospatial"])
assert keep <= set(DATA_CTX_KEYS)
assert {"head_rows", "timeseries_raw", "geo_points", "raw_numeric"} == keep
# --------------------------------------------------------------------------- #
# validate_chapter_ids — separa válidos de desconocidos preservando orden.
# --------------------------------------------------------------------------- #
def test_validate_separates_known_and_unknown():
out = validate_chapter_ids(["outliers", "nope", "timeseries", "ghost"],
CHAPTER_ORDER)
assert out["valid"] == ["outliers", "timeseries"]
assert out["unknown"] == ["nope", "ghost"]
def test_validate_all_known():
out = validate_chapter_ids(["portada", "glosario"], CHAPTER_ORDER)
assert out["unknown"] == []
# --------------------------------------------------------------------------- #
# Robustez: entradas raras nunca lanzan.
# --------------------------------------------------------------------------- #
def test_resolve_handles_none_and_empty():
assert resolve_requirements(None)["profile_flags"] == set()
assert resolve_requirements([])["profile_flags"] == set()
# ids desconocidos se ignoran silenciosamente en la resolución.
assert resolve_requirements(["no_existe"])["ctx_keys"] == set()
def test_resolve_accepts_single_string():
assert resolve_requirements("outliers")["profile_flags"] == {"run_models"}
@@ -5,28 +5,32 @@ page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
``model.Group`` with ``page_break_before=True`` (except the first, which may share ``model.Group`` with ``page_break_before=True`` (except the first, which may share
the intro's page), so its chart sits next to its tables and no column is split. the intro's page), so its chart sits next to its tables and no column is split.
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** term — Per column the Group is laid out ``side_by_side`` (PPTX: cardinality table LEFT,
the full definition lives in the GLOSARIO chapter, so it is NOT repeated inline chart RIGHT; PDF: stacked) and contains, in order:
here (one click jumps to the glossary entry). The intro also carries the dataset
row total used as a comparison baseline.
Per column the Group contains, in order: 1. The column name plus, when the LLM layer ran, its business **description** and
**unit** (read from ``profile['llm']['dictionary']``, matched by column name).
1. A cardinality key/value table: distinct values, ``% distinct`` (distinct / 2. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
total rows), total dataset rows, singleton values (frequency 1), entropy with total rows), total dataset rows, singleton values (frequency 1), entropy with
its theoretical maximum and the normalized ratio, mode, imbalance and its theoretical maximum and the normalized ratio, mode, imbalance and
string-length stats. string-length stats.
2. A short note flagging problematic cardinality (id-like ≈100% distinct, or a 3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
single dominating category). single dominating category).
3. A ``top-k`` table (value / count / %). 4. A ``top-k`` table (value / count / %).
4. A **donut pie chart** of the most common categories (top-k + an "Otros" 5. A **horizontal bar chart** of the most common categories (top-k + an "Otros"
bucket), drawn lazily so the renderers scale it to fit entirely. bucket), drawn lazily so the renderers scale it to fit entirely.
A short intro names the clickable **[[term:entropia]]entropía[[/term]]** and
**[[term:pagina_categorica]]page-layout[[/term]]** terms — their full
definitions live in the GLOSARIO chapter, so they are NOT repeated inline here
(one click jumps to the glossary entry). The intro also carries the dataset row
total used as a comparison baseline.
Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``, output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``,
``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived ``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived
cardinality metrics and the pie figure are delegated to two registry functions cardinality metrics and the bar figure are delegated to two registry functions
(``categorical_cardinality_block`` and ``categorical_top_pie_figure``); both are (``categorical_cardinality_block`` and ``categorical_top_bar_figure``); both are
imported lazily and degrade to a minimal inline fallback so this chapter never imported lazily and degrade to a minimal inline fallback so this chapter never
raises even if they are unavailable. raises even if they are unavailable.
@@ -39,10 +43,21 @@ import math
from .. import model from .. import model
CHAPTER_VERSION = "1.2.0" CHAPTER_VERSION = "1.3.0"
CHAPTER_ID = "cat_distr" CHAPTER_ID = "cat_distr"
CHAPTER_TITLE = "Distribuciones categóricas" CHAPTER_TITLE = "Distribuciones categóricas"
# Key under which eda_llm_insights stores its interpretive block in the profile.
LLM_KEY = "llm"
# Second glossary term this chapter names: "how each categorical page is laid
# out". The long paragraph that used to describe it inline in the intro now lives
# in the GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``);
# the intro only names the clickable term, relocating the explanation, not losing
# it. The chapter only needs to register key+label here.
_TERM_PAGINA_KEY = "pagina_categorica"
_TERM_PAGINA_LABEL = "Cómo se organiza cada página categórica"
# Glossary term this chapter explains. Registered in the shared collector and # Glossary term this chapter explains. Registered in the shared collector and
# marked clickable on its first appearance (end-to-end glossary example — # 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). # mejora 6). Other chapters hook their own terms the same way (see the contract).
@@ -59,14 +74,14 @@ _TERM_ENTROPIA_DEF = (
# Cap the number of categorical columns rendered to keep the document bounded; # Cap the number of categorical columns rendered to keep the document bounded;
# the rest are summarized in a closing note (no silent truncation). # the rest are summarized in a closing note (no silent truncation).
MAX_COLS = 40 MAX_COLS = 40
# Rows shown in each top-k table and explicit slices in the pie. Kept moderate so # Rows shown in each top-k table and explicit bars in the chart. Kept moderate so
# the whole column — cardinality table + top-k table + donut — fits on ONE # the whole column — cardinality table + top-k table + bar chart — fits on ONE
# page/slide with the chart next to its tables; the table note still reports # page/slide with the chart next to its tables; the table note still reports
# "top N of M" so nothing is silently hidden. For id-like columns (≈100% # "top N of M" so nothing is silently hidden. For id-like columns (≈100%
# distinct) the top-k table is dropped entirely (it would be a list of unique # distinct) the top-k table is dropped entirely (it would be a list of unique
# values — pure noise), which also frees the room the donut needs (see build). # values — pure noise), which also frees the room the chart needs (see build).
TOP_TABLE_ROWS = 8 TOP_TABLE_ROWS = 8
PIE_TOP_K = 6 CHART_TOP_K = 6
# Truncate very long category labels in tables (the renderer also wraps). Kept # Truncate very long category labels in tables (the renderer also wraps). Kept
# tight so a column with long id-like values (names, tickets) still fits its page. # tight so a column with long id-like values (names, tickets) still fits its page.
LABEL_MAX = 28 LABEL_MAX = 28
@@ -208,26 +223,74 @@ def _fallback_cardinality(cat: dict, n_rows) -> dict:
} }
def _pie_make(top, n_distinct, title, n_rows): def _llm_index(profile: dict, ctx: dict) -> dict:
"""Return a zero-arg callable that builds the donut figure lazily.""" """Map column name -> its LLM dictionary entry (description/unit/...).
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
defensive: never raises on malformed input.
"""
llm = profile.get(LLM_KEY)
if not isinstance(llm, dict):
llm = ctx.get(LLM_KEY)
if not isinstance(llm, dict):
return {}
entries = llm.get("dictionary")
if not isinstance(entries, (list, tuple)):
return {}
index: dict = {}
for e in entries:
if not isinstance(e, dict):
continue
col = e.get("column")
if col is None:
continue
index[model._safe_str(col)] = e
return index
def _llm_desc_unit_block(name: str, llm_index: dict):
"""Markdown block with the LLM business description + unit of a column, or
None when no LLM entry matches the column (clean fallback without LLM)."""
entry = llm_index.get(model._safe_str(name))
if not isinstance(entry, dict):
return None
raw_desc = entry.get("description") or entry.get("business_meaning")
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
raw_unit = entry.get("unit")
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
parts = []
if desc:
parts.append(f"**Descripción:** {desc}")
if unit:
parts.append(f"**Unidad:** {unit}")
if not parts:
return None
return model.Markdown(text=" · ".join(parts))
def _bar_make(top, n_distinct, title, n_rows):
"""Return a zero-arg callable that builds the bar figure lazily."""
def make(): def make():
try: try:
from datascience.categorical_top_pie_figure import ( from datascience.categorical_top_bar_figure import (
categorical_top_pie_figure, categorical_top_bar_figure,
) )
return categorical_top_pie_figure( return categorical_top_bar_figure(
top=top, n_distinct=n_distinct or 0, title=title, top=top, n_distinct=n_distinct or 0, title=title,
top_k=PIE_TOP_K, n_rows=n_rows) top_k=CHART_TOP_K, n_rows=n_rows)
except Exception: # noqa: BLE001 — minimal local fallback figure. except Exception: # noqa: BLE001 — minimal local fallback figure.
return _fallback_pie(top, title) return _fallback_bar(top, title)
return make return make
def _fallback_pie(top, title): def _fallback_bar(top, title):
"""Minimal donut figure used only if the registry function is unavailable.""" """Minimal horizontal-bar figure used only if the registry function is
unavailable. Largest category on top, the rest folded into "Otros"."""
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
@@ -238,8 +301,8 @@ def _fallback_pie(top, title):
items = [t for t in (top or []) items = [t for t in (top or [])
if isinstance(t, dict) and isinstance(t.get("count"), (int, float))] if isinstance(t, dict) and isinstance(t.get("count"), (int, float))]
items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True) items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True)
head = items[:PIE_TOP_K] head = items[:CHART_TOP_K]
rest = items[PIE_TOP_K:] rest = items[CHART_TOP_K:]
labels = [_truncate(t.get("value"), 20) for t in head] labels = [_truncate(t.get("value"), 20) for t in head]
sizes = [float(t.get("count") or 0) for t in head] sizes = [float(t.get("count") or 0) for t in head]
if rest: if rest:
@@ -249,10 +312,13 @@ def _fallback_pie(top, title):
ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center") ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center")
ax.axis("off") ax.axis("off")
return fig return fig
ax.pie(sizes, labels=None, wedgeprops={"width": 0.42}, # barh draws bottom-up, so reverse to put the largest category on top.
autopct=lambda p: f"{p:.0f}%" if p >= 4 else "") y_pos = range(len(labels))
ax.legend(labels, loc="center left", bbox_to_anchor=(1.0, 0.5), ax.barh(list(y_pos), list(reversed(sizes)), color="#4C72B0",
fontsize=7, frameon=False) edgecolor="white")
ax.set_yticks(list(y_pos))
ax.set_yticklabels(list(reversed(labels)), fontsize=7)
ax.set_xlabel("conteo", fontsize=8)
ax.set_title(_truncate(title, 40)) ax.set_title(_truncate(title, 40))
fig.tight_layout() fig.tight_layout()
return fig return fig
@@ -373,22 +439,17 @@ def _topk_table(cat: dict):
note=note) note=note)
def _intro_blocks(n_rows, mark_term: bool = False): def _intro_blocks(mark_term: bool = False):
total = _fmt_int(n_rows) # The full explanation of entropy AND of how each categorical page is laid out
# Mark the first appearance of the term as a clickable glossary jump when the # lives in the GLOSARIO chapter; the chapter body keeps only the minimal
# term was registered (mark_term). The full definition of entropy lives in the # clickable terms — no descriptive prose — to avoid duplicating the glossary.
# GLOSARIO chapter, so the intro only names the clickable term here instead of # The dataset row total is not repeated here: each column's cardinality table
# repeating the long explanation (avoids the redundancy with the glossary). # already carries "Total filas (dataset)".
entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
else "entropía") else "entropía")
text = ( pagina = ("[[term:pagina_categorica]]cómo se organiza cada página[[/term]]"
f"Cada columna categórica ocupa su propia página: sus métricas de " if mark_term else "cómo se organiza cada página")
f"cardinalidad —incluida la {entropia}—, una nota que señala cardinalidad " text = f"Términos: {entropia} · {pagina}."
"problemática, la tabla de las categorías más frecuentes y un gráfico de "
"tarta (donut) de las más comunes, todo junto."
)
if n_rows is not None:
text += f" El dataset tiene {total} filas en total como referencia."
return [ return [
model.Heading(text="Entropía y cardinalidad", level=2), model.Heading(text="Entropía y cardinalidad", level=2),
model.Markdown(text=text), model.Markdown(text=text),
@@ -406,15 +467,22 @@ def build_cat_distr(profile: dict, ctx: dict):
return None return None
n_rows = profile.get("n_rows") n_rows = profile.get("n_rows")
# Register "entropía" in the shared glossary collector (if present) and mark # Register "entropía" and the "how each categorical page is laid out" term in
# its first appearance clickable. End-to-end glossary example (mejora 6). # the shared glossary collector (if present) and mark their first appearance
# clickable. End-to-end glossary example (mejora 6).
glossary = ctx.get("glossary") glossary = ctx.get("glossary")
mark_term = False mark_term = False
if isinstance(glossary, model.GlossaryCollector): if isinstance(glossary, model.GlossaryCollector):
glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL, glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
_TERM_ENTROPIA_DEF) _TERM_ENTROPIA_DEF)
glossary.add(_TERM_PAGINA_KEY, _TERM_PAGINA_LABEL)
mark_term = True mark_term = True
blocks = list(_intro_blocks(n_rows, mark_term=mark_term)) blocks = list(_intro_blocks(mark_term=mark_term))
# Business description + unit per column come from the LLM dictionary
# (profile['llm']['dictionary'], matched by column name); absent without
# run_llm, in which case the per-column description block is simply omitted.
llm_index = _llm_index(profile, ctx)
rendered = cat_cols[:MAX_COLS] rendered = cat_cols[:MAX_COLS]
for idx, col in enumerate(rendered): for idx, col in enumerate(rendered):
@@ -422,31 +490,36 @@ def build_cat_distr(profile: dict, ctx: dict):
cat = col.get("categorical") or {} cat = col.get("categorical") or {}
card = _normalize_card(_cardinality(cat, n_rows)) card = _normalize_card(_cardinality(cat, n_rows))
# One Group per categorical column: heading + cardinality table + flag # One Group per categorical column: heading + (optional) LLM description +
# note + top-k table + donut figure are kept together and the renderer # cardinality table + flag note + top-k table + bar figure are kept
# starts each on a fresh page/slide (page_break_before) so every column # together and the renderer starts each on a fresh page/slide
# gets its own page with its chart next to its tables. The first column # (page_break_before) so every column gets its own page with its chart next
# may share the intro's page (no forced break) to avoid a near-empty page. # to its tables. The first column may share the intro's page (no forced
col_blocks = [ # break) to avoid a near-empty page.
model.Heading(text=str(name), level=2), col_blocks = [model.Heading(text=str(name), level=2)]
_cardinality_block(card), desc_block = _llm_desc_unit_block(name, llm_index)
] if desc_block is not None:
col_blocks.append(desc_block)
col_blocks.append(_cardinality_block(card))
note = _flag_note(card) note = _flag_note(card)
if note is not None: if note is not None:
col_blocks.append(note) col_blocks.append(note)
# For id-like columns (≈100% distinct) the top-k is a list of unique # For id-like columns (≈100% distinct) the top-k is a list of unique
# values — pure noise; skip it (the flag note already explains why) and # values — pure noise; skip it (the flag note already explains why) and
# let the donut take that room so the whole column fits one page/slide. # let the bar chart take that room so the whole column fits one page/slide.
if not card.get("id_like"): if not card.get("id_like"):
topk = _topk_table(cat) topk = _topk_table(cat)
if topk is not None: if topk is not None:
col_blocks.append(topk) col_blocks.append(topk)
col_blocks.append(model.Figure( col_blocks.append(model.Figure(
make=_pie_make(cat.get("top") or [], card.get("n_distinct"), make=_bar_make(cat.get("top") or [], card.get("n_distinct"),
str(name), n_rows), str(name), n_rows),
caption=(f"Categorías más comunes de «{_truncate(name, 32)}» " caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
"(donut: top-k + «Otros»)"))) "(barras: top-k + «Otros»)")))
blocks.append(model.Group(blocks=col_blocks, # layout="side_by_side": in PPTX the cardinality table goes to the LEFT and
# the bar chart to the RIGHT of the same slide; the PDF renderer stacks it
# (the A5 mobile page is too narrow for two readable columns).
blocks.append(model.Group(blocks=col_blocks, layout="side_by_side",
page_break_before=(idx > 0))) page_break_before=(idx > 0)))
if len(cat_cols) > len(rendered): if len(cat_cols) > len(rendered):
@@ -2,12 +2,14 @@
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
asked for (distinct/total/%-distinct/unique metrics, top-k table and a donut asked for (distinct/total/%-distinct/unique metrics, top-k table and a bar
figure), that EACH categorical column is wrapped in its own keep-together figure), that EACH categorical column is wrapped in its own keep-together
``Group`` that starts on a fresh page/slide (one column per page, chart next to ``Group`` laid out ``side_by_side`` (PPTX: table left / bars right) that starts on
its tables), that the long entropy explanation is NOT repeated inline (it lives a fresh page/slide (one column per page, chart next to its tables), that the LLM
in the glossary — only the clickable term is kept), that the chapter renders business description + unit are shown per column when the profile carries an LLM
inside the full document to both PDF and PPTX showing that content, that a block, that the long entropy / page-layout explanations are NOT repeated inline
(they live in the glossary — only the clickable terms are kept), that the chapter
renders inside the full document to both PDF and PPTX showing that content, that a
profile with no categorical columns yields ``None`` without raising, and that profile with no categorical columns yields ``None`` without raising, and that
long labels / many columns are never cut in either output. long labels / many columns are never cut in either output.
""" """
@@ -116,6 +118,10 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
assert "log2" not in md.text # redundant explanation removed. assert "log2" not in md.text # redundant explanation removed.
assert "máxima diversidad" not in md.text assert "máxima diversidad" not in md.text
# The donut/pie is gone: the intro no longer mentions tarta/donut (the chart
# is now a bar chart; the long page-layout explanation moved to the glossary).
assert "donut" not in md.text and "tarta" not in md.text
# Per-column blocks are wrapped in keep-together Groups: flatten to inspect. # Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
flat = _flatten(ch.blocks) flat = _flatten(ch.blocks)
kv = next(b for b in flat if isinstance(b, KVTable)) kv = next(b for b in flat if isinstance(b, KVTable))
@@ -128,11 +134,13 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
assert any("Entropía" in lbl for lbl in labels) assert any("Entropía" in lbl for lbl in labels)
assert "únicos" in values and "%" in values assert "únicos" in values and "%" in values
assert "bits" in values and "norm" in values # entropy + max + normalized. assert "bits" in values and "norm" in values # entropy + max + normalized.
# Top-k table + pie figure. # Top-k table + bar figure.
dt = next(b for b in flat if isinstance(b, DataTable)) dt = next(b for b in flat if isinstance(b, DataTable))
assert dt.header == ["Valor", "Conteo", "%"] assert dt.header == ["Valor", "Conteo", "%"]
assert any("neumaticos" in str(cell) for row in dt.rows for cell in row) assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
assert any(isinstance(b, Figure) for b in flat) assert any(isinstance(b, Figure) for b in flat)
# Each per-column Group is laid out side_by_side (table left / bars right).
assert all(g.layout == "side_by_side" for g in _column_groups(ch))
# id-like column flagged with a Note that also explains the top-k is dropped. # id-like column flagged with a Note that also explains the top-k is dropped.
idnote = next((b for b in flat idnote = next((b for b in flat
if isinstance(b, Note) and "identificador" in b.text), None) if isinstance(b, Note) and "identificador" in b.text), None)
@@ -140,9 +148,9 @@ def test_golden_build_cat_distr_emite_bloques_pedidos():
assert "No se lista el top" in idnote.text assert "No se lista el top" in idnote.text
def test_golden_idlike_omite_topk_y_conserva_donut(): def test_golden_idlike_omite_topk_y_conserva_grafico():
# The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable # The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
# (it would be a list of unique values), but must still keep its donut Figure # (it would be a list of unique values), but must still keep its bar Figure
# and its cardinality table so it stays a full per-column page. # and its cardinality table so it stays a full per-column page.
ch = build_cat_distr(_profile(), {}) ch = build_cat_distr(_profile(), {})
groups = _column_groups(ch) groups = _column_groups(ch)
@@ -151,7 +159,7 @@ def test_golden_idlike_omite_topk_y_conserva_donut():
kinds = [b.kind for b in uuid_group.blocks] kinds = [b.kind for b in uuid_group.blocks]
assert "data_table" not in kinds # top-k of unique values dropped. assert "data_table" not in kinds # top-k of unique values dropped.
assert "kv_table" in kinds # cardinality kept. assert "kv_table" in kinds # cardinality kept.
assert "figure" in kinds # donut kept (chart per column). assert "figure" in kinds # bar chart kept (chart per column).
# A non-id-like column keeps its top-k table. # A non-id-like column keeps its top-k table.
cat_group = next(g for g in groups cat_group = next(g for g in groups
if any(getattr(b, "text", "") == "categoria" if any(getattr(b, "text", "") == "categoria"
@@ -205,7 +213,7 @@ def test_golden_render_pdf_una_pagina_por_columna():
assert "Entrop" in txt assert "Entrop" in txt
assert "distintos" in txt assert "distintos" in txt
assert "categoria" in txt and "neumaticos" in txt assert "categoria" in txt and "neumaticos" in txt
assert "donut" in txt # figure caption rendered as text. assert "barras" in txt # bar-chart caption rendered as text (PDF).
assert "identificador" in txt # id-like note rendered. assert "identificador" in txt # id-like note rendered.
@@ -258,9 +266,11 @@ def _profile_high_card() -> dict:
def test_golden_pptx_una_slide_por_columna_con_su_grafico(): def test_golden_pptx_una_slide_por_columna_con_su_grafico():
"""Each categorical column occupies EXACTLY ONE cat_distr slide that carries """Cada columna categórica ocupa EXACTAMENTE UN slide cat_distr que lleva su
BOTH its cardinality table and its donut figure (picture) — i.e. the chart is gráfico (picture) en la misma slide — el chart nunca se separa de su columna,
never separated from its table, even for a high-cardinality column.""" ni siquiera para una columna de alta cardinalidad. Con layout side_by_side la
tabla se rasteriza a imagen, así que la comprobación se hace por presencia de
picture (no por el texto de la tabla)."""
from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.enum.shapes import MSO_SHAPE_TYPE
prof = _profile_high_card() prof = _profile_high_card()
@@ -272,7 +282,7 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
prs = Presentation(out) prs = Presentation(out)
# Per column: the cat_distr slides whose text mentions it, and whether the # Per column: the cat_distr slides whose text mentions it, and whether the
# owning slide also has the donut caption + an actual picture shape. # owning slide also carries an actual picture shape (its chart).
slides_with_col = {n: [] for n in cat_names} slides_with_col = {n: [] for n in cat_names}
owner_has_chart = {n: False for n in cat_names} owner_has_chart = {n: False for n in cat_names}
for i, sl in enumerate(prs.slides): for i, sl in enumerate(prs.slides):
@@ -288,15 +298,106 @@ def test_golden_pptx_una_slide_por_columna_con_su_grafico():
for n in cat_names: for n in cat_names:
if n in txt: if n in txt:
slides_with_col[n].append(i) slides_with_col[n].append(i)
has_table = "Cardinalidad" in txt or "distintos" in txt if has_pic:
if has_pic and "donut" in txt and has_table:
owner_has_chart[n] = True owner_has_chart[n] = True
for n in cat_names: for n in cat_names:
# Exactly one slide carries the column (not split across slides). # Exactly one slide carries the column (not split across slides).
assert len(slides_with_col[n]) == 1, (n, slides_with_col[n]) assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
# That single slide also holds its table AND its donut picture. # That single slide also holds its chart picture.
assert owner_has_chart[n], (n, "tabla y donut no están en el mismo slide") assert owner_has_chart[n], (n, "el gráfico no está en el slide de la columna")
def test_golden_pptx_columna_side_by_side_tabla_izq_barra_der():
"""Con layout side_by_side, una columna categórica coloca su tabla de
cardinalidad (imagen) en la mitad izquierda y su gráfico de barras (imagen) en
la mitad derecha de la MISMA slide. Verifica que al menos una columna queda en
dos columnas (tabla-izq / barras-der), evidencia del side_by_side en PPTX."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.util import Inches
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pptx")
render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
prs = Presentation(out)
centre = int(Inches(13.333 / 2.0)) # half of the 16:9 slide width.
two_col_slides = 0
for sl in prs.slides:
texts, lefts = [], []
for sh in sl.shapes:
if sh.has_text_frame:
texts.append(sh.text_frame.text)
if (sh.shape_type == MSO_SHAPE_TYPE.PICTURE
and sh.left is not None):
lefts.append(sh.left)
txt = re.sub(r"\s+", " ", " ".join(texts))
if "Distribuciones categ" not in txt:
continue
# One picture starts in the left half, another in the right half.
if len(lefts) >= 2 and min(lefts) < centre and max(lefts) > centre:
two_col_slides += 1
assert two_col_slides >= 1, (
"ninguna columna quedó con tabla-izq / barras-der (side_by_side)")
def _profile_with_llm() -> dict:
"""The base profile plus an ``llm`` block (as eda_llm_insights would store it
with run_llm=True): a data dictionary with description/unit per column."""
prof = _profile()
prof["llm"] = {
"dictionary": [
{"column": "categoria",
"description": "Familia de producto del recambio",
"business_meaning": "Agrupa el catálogo por tipo de pieza",
"unit": "categoría"},
{"column": "uuid",
"description": "Identificador único de registro",
"unit": ""},
],
}
return prof
def test_llm_descripcion_y_unidad_por_columna():
# With an LLM dictionary, each categorical column whose name matches shows its
# business description and unit in a per-column markdown block.
ch = build_cat_distr(_profile_with_llm(), {})
groups = _column_groups(ch)
cat_group = next(g for g in groups
if any(getattr(b, "text", "") == "categoria"
for b in g.blocks))
md = " ".join(b.text for b in cat_group.blocks
if getattr(b, "kind", "") == "markdown")
assert "Descripción" in md and "Familia de producto" in md
assert "Unidad" in md and "categoría" in md
def test_edge_sin_llm_no_anade_descripcion():
# Without an LLM block the per-column description markdown is simply omitted;
# the column still renders its cardinality table and bar figure.
ch = build_cat_distr(_profile(), {})
for g in _column_groups(ch):
mds = [b.text for b in g.blocks if getattr(b, "kind", "") == "markdown"]
assert not any("Descripción" in t for t in mds)
def test_pagina_categorica_clicable_y_definicion_en_glosario():
# The "how each categorical page is laid out" term is registered + marked
# clickable in the intro, and its full definition lands in the glossary
# chapter (canonical baseline catalog), not inline.
from datascience.automatic_eda.chapters.glosario import build_glosario
gc = GlossaryCollector()
ch = build_cat_distr(_profile(), {"glossary": gc})
md = next(b for b in ch.blocks if isinstance(b, Markdown))
assert "[[term:pagina_categorica]]" in md.text
assert gc.has("pagina_categorica")
glos = build_glosario(_profile(), {"glossary": gc})
entry = next(b for b in glos.blocks
if getattr(b, "kind", "") == "glossary_entry"
and b.key == "pagina_categorica")
assert "barras" in entry.definition
assert "identificador" in entry.definition
def test_edge_sin_categoricas_devuelve_none(): def test_edge_sin_categoricas_devuelve_none():
@@ -17,10 +17,63 @@ from __future__ import annotations
from .. import model from .. import model
CHAPTER_VERSION = "1.0.0" CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "glosario" CHAPTER_ID = "glosario"
CHAPTER_TITLE = "Glosario" CHAPTER_TITLE = "Glosario"
# Canonical definitions for cross-cutting terms — the "how to read it" entries
# that do not belong to a single chapter. A chapter only needs to *register* the
# term (``ctx['glossary'].add(key, label)``) and mark its in-text appearance with
# ``[[term:key]]…[[/term]]``; this chapter supplies the full definition here when
# the collector carries the term without one. Keeping the prose in a single place
# avoids repeating a long paragraph inline in every chapter that names the term
# (the explanation moved out of the NUM DISTR and CAT DISTR intros lives here).
_BASELINE_TERMS = {
"histograma_boxplot": {
"label": "Cómo leer el histograma y el boxplot",
"definition": (
"Para cada columna numérica se muestra su histograma con tres líneas "
"de referencia: la media (línea roja discontinua), la mediana (línea "
"verde continua) y la banda ±1σ (zona sombreada que cubre una "
"desviación estándar a cada lado de la media). Debajo, alineado al "
"mismo eje horizontal, un boxplot de Tukey: la caja abarca del primer "
"al tercer cuartil (P25P75), la línea interior es la mediana y los "
"bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay "
"valores más allá de las vallas (posibles atípicos). Comparar la media "
"con la mediana revela la asimetría: si la media supera a la mediana la "
"cola larga cae hacia los valores altos (asimetría a la derecha), y al "
"revés hacia los bajos."),
},
"pagina_categorica": {
"label": "Cómo se organiza cada página categórica",
"definition": (
"Cada columna categórica ocupa su propia página: muestra sus métricas "
"de cardinalidad —incluida la entropía—, una nota que señala "
"cardinalidad problemática (columnas que se comportan como "
"identificador, con casi todos los valores distintos, o dominadas por "
"una sola categoría), la tabla de las categorías más frecuentes (top-k, "
"con su conteo y porcentaje) y un gráfico de barras de las categorías "
"más comunes (top-k más una barra «Otros» que agrupa la cola). El total "
"de filas del dataset se usa como referencia para interpretar los "
"conteos."),
},
}
def _resolve_term(term: dict) -> tuple:
"""Return (label, definition) for a collected term, completing a missing
definition (and, if absent, the label) from the canonical baseline catalog."""
key = model._safe_str(term.get("key"))
label = model._safe_str(term.get("label"))
definition = model._safe_str(term.get("definition"))
base = _BASELINE_TERMS.get(key)
if base:
if not definition.strip():
definition = model._safe_str(base.get("definition"))
if not label.strip() or label == key:
label = model._safe_str(base.get("label")) or label
return label, definition
def build_glosario(profile: dict, ctx: dict): def build_glosario(profile: dict, ctx: dict):
"""Build the glossary Chapter from the shared collector, or None if empty.""" """Build the glossary Chapter from the shared collector, or None if empty."""
@@ -36,12 +89,14 @@ def build_glosario(profile: dict, ctx: dict):
"Cada término va resaltado en el texto y, al pulsarlo, salta a su " "Cada término va resaltado en el texto y, al pulsarlo, salta a su "
"definición en esta sección.")), "definición en esta sección.")),
] ]
# One clickable destination per term, alphabetically by visible label. # One clickable destination per term, alphabetically by visible label. A term
# registered without a definition is completed from the canonical baseline.
for term in glossary.terms(by="label"): for term in glossary.terms(by="label"):
label, definition = _resolve_term(term)
blocks.append(model.GlossaryEntry( blocks.append(model.GlossaryEntry(
key=model._safe_str(term.get("key")), key=model._safe_str(term.get("key")),
label=model._safe_str(term.get("label")), label=label,
definition=model._safe_str(term.get("definition")))) definition=definition))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE, return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks) version=CHAPTER_VERSION, blocks=blocks)
@@ -35,10 +35,21 @@ try:
except Exception: # noqa: BLE001 — keep the chapter importable no matter what. except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
build_boxplot_stats = None # type: ignore[assignment] build_boxplot_stats = None # type: ignore[assignment]
CHAPTER_VERSION = "1.2.0" CHAPTER_VERSION = "1.3.0"
CHAPTER_ID = "num_distr" CHAPTER_ID = "num_distr"
CHAPTER_TITLE = "Distribuciones numéricas" CHAPTER_TITLE = "Distribuciones numéricas"
# Glossary term this chapter explains. The long "how to read the histogram and
# the boxplot" paragraph used to live inline in the intro; it now lives in the
# GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``) and the
# intro only names the clickable term — one click jumps to the full explanation,
# so the information is relocated, not lost (mejora glosario).
_TERM_HISTOBOX_KEY = "histograma_boxplot"
_TERM_HISTOBOX_LABEL = "Cómo leer el histograma y el boxplot"
# Key under which eda_llm_insights stores its interpretive block in the profile.
LLM_KEY = "llm"
# Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a # Plain-Spanish gloss for every label ``detect_distribution_type`` can emit, so a
# non-expert reader understands the shape and the suggested next step (MUST-4.3). # non-expert reader understands the shape and the suggested next step (MUST-4.3).
_DIST_GLOSS = { _DIST_GLOSS = {
@@ -99,6 +110,53 @@ def _numeric_columns(profile: dict) -> list:
return out return out
def _llm_index(profile: dict, ctx: dict) -> dict:
"""Map column name -> its LLM dictionary entry (description/unit/...).
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
defensive: never raises on malformed input.
"""
llm = profile.get(LLM_KEY)
if not isinstance(llm, dict):
llm = ctx.get(LLM_KEY)
if not isinstance(llm, dict):
return {}
entries = llm.get("dictionary")
if not isinstance(entries, (list, tuple)):
return {}
index: dict = {}
for e in entries:
if not isinstance(e, dict):
continue
col = e.get("column")
if col is None:
continue
index[model._safe_str(col)] = e
return index
def _llm_desc_unit_block(name: str, llm_index: dict):
"""Markdown block with the LLM business description + unit of a column, or
None when no LLM entry matches the column (clean fallback without LLM)."""
entry = llm_index.get(model._safe_str(name))
if not isinstance(entry, dict):
return None
raw_desc = entry.get("description") or entry.get("business_meaning")
desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
raw_unit = entry.get("unit")
unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
parts = []
if desc:
parts.append(f"**Descripción:** {desc}")
if unit:
parts.append(f"**Unidad:** {unit}")
if not parts:
return None
return model.Markdown(text=" · ".join(parts))
def _make_hist_box(name: str, numeric: dict, box: dict): def _make_hist_box(name: str, numeric: dict, box: dict):
"""Build the histogram (with mean/median/±σ lines) + boxplot figure. """Build the histogram (with mean/median/±σ lines) + boxplot figure.
@@ -271,15 +329,26 @@ def build_num_distr(profile: dict, ctx: dict):
if not numerics: if not numerics:
return None # chapter does not apply to a dataset with no numerics. return None # chapter does not apply to a dataset with no numerics.
# Register the "how to read the histogram and boxplot" term in the shared
# glossary collector (if present) and mark its first appearance clickable. The
# full explanation (colour code, 1,5·IQR rule, asymmetry reading) lives in the
# GLOSARIO chapter instead of inline here: the intro only names the term.
glossary = ctx.get("glossary")
mark_term = False
if isinstance(glossary, model.GlossaryCollector):
glossary.add(_TERM_HISTOBOX_KEY, _TERM_HISTOBOX_LABEL)
mark_term = True
como_leer = ("[[term:histograma_boxplot]]cómo leer estos gráficos[[/term]]"
if mark_term else "cómo leer estos gráficos")
intro = ( intro = (
"Para cada columna numérica se muestra su **histograma** con tres líneas " "Cada columna numérica muestra su **histograma** (con la **media**, la "
"de referencia: la **media** (línea roja discontinua), la **mediana** " "**mediana** y la banda **±1σ**) y, debajo y al mismo eje, su **boxplot "
"(línea verde continua) y la banda **±1σ** (zona sombreada). Debajo, " f"de Tukey** — {como_leer}.")
"alineado al mismo eje, un **boxplot de Tukey**: la caja abarca del "
"primer al tercer cuartil (P25P75), la línea interior es la mediana y " # Business description + unit per column come from the LLM dictionary
"los bigotes llegan hasta 1,5·IQR; los puntos rojos señalan que hay " # (profile['llm']['dictionary'], matched by column name); absent without
"valores más allá de las vallas. Comparar media y mediana revela la " # run_llm, in which case the per-column description block is simply omitted.
"asimetría de la distribución.") llm_index = _llm_index(profile, ctx)
blocks = [ blocks = [
model.Heading(text=CHAPTER_TITLE, level=1), model.Heading(text=CHAPTER_TITLE, level=1),
@@ -293,17 +362,20 @@ def build_num_distr(profile: dict, ctx: dict):
box = build_boxplot_stats(numeric) or {} box = build_boxplot_stats(numeric) or {}
except Exception: # noqa: BLE001 — degrade, never raise. except Exception: # noqa: BLE001 — degrade, never raise.
box = {} box = {}
# Keep the column heading, its figure and its stats note together on the # Keep the column heading, its (optional) LLM description, its figure and
# same page/slide (mejora 3 — keep-together): the renderers measure the # its stats note together on the same page/slide (mejora 3 —
# whole Group and move it whole when it would not fit. # keep-together): the renderers measure the whole Group and move it whole
blocks.append(model.Group(blocks=[ # when it would not fit.
model.Heading(text=str(name), level=2), col_blocks = [model.Heading(text=str(name), level=2)]
model.Figure( desc_block = _llm_desc_unit_block(name, llm_index)
make=_figure_maker(name, numeric, box), if desc_block is not None:
caption=f"Distribución de «{name}» — histograma " col_blocks.append(desc_block)
f"(media/mediana/±σ) y boxplot."), col_blocks.append(model.Figure(
model.Markdown(text=_stats_note(name, numeric, box)), make=_figure_maker(name, numeric, box),
])) caption=f"Distribución de «{name}» — histograma "
f"(media/mediana/±σ) y boxplot."))
col_blocks.append(model.Markdown(text=_stats_note(name, numeric, box)))
blocks.append(model.Group(blocks=col_blocks))
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE, return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks) version=CHAPTER_VERSION, blocks=blocks)
@@ -101,7 +101,7 @@ def test_golden_chapter_estructura_y_bloques():
def test_golden_media_mediana_sigma_y_boxplot_presentes(): def test_golden_media_mediana_sigma_y_boxplot_presentes():
# The intro documents the three reference lines and the Tukey boxplot; the # The short intro names the three reference lines and the Tukey boxplot; the
# per-column note carries the actual mean/median/σ numbers and the shape. # per-column note carries the actual mean/median/σ numbers and the shape.
ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {}) ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
md_texts = " ".join(b.text for b in _flatten(ch.blocks) md_texts = " ".join(b.text for b in _flatten(ch.blocks)
@@ -110,10 +110,58 @@ def test_golden_media_mediana_sigma_y_boxplot_presentes():
assert "±1σ" in md_texts or "σ" in md_texts assert "±1σ" in md_texts or "σ" in md_texts
assert "boxplot" in md_texts.lower() assert "boxplot" in md_texts.lower()
assert "Tukey" in md_texts assert "Tukey" in md_texts
# The long "how to read it" explanation moved to the glossary: the colour-code
# / 1,5·IQR walkthrough is no longer inline in the chapter body.
assert "1,5·IQR" not in md_texts
assert "línea roja" not in md_texts
# distribution_type gloss surfaced for the column (right-skewed preset). # distribution_type gloss surfaced for the column (right-skewed preset).
assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts
def test_glosario_histograma_boxplot_clicable_y_definicion():
# With a glossary collector the intro marks the clickable term and the FULL
# explanation (the long paragraph removed from the body) lands in the glossary.
from datascience.automatic_eda.chapters.glosario import build_glosario
gc = model.GlossaryCollector()
prof = _profile(n_numeric=1, extra_categorical=False)
ch = build_num_distr(prof, {"glossary": gc})
intro = next(b for b in ch.blocks if b.kind == "markdown")
assert "[[term:histograma_boxplot]]" in intro.text
assert gc.has("histograma_boxplot")
glos = build_glosario(prof, {"glossary": gc})
entry = next(b for b in glos.blocks
if getattr(b, "kind", "") == "glossary_entry"
and b.key == "histograma_boxplot")
assert "boxplot" in entry.definition.lower()
assert "1,5·IQR" in entry.definition
def test_llm_descripcion_y_unidad_por_columna():
# With an LLM dictionary, each numeric column whose name matches shows its
# business description and unit in a per-column markdown block.
prof = _profile(n_numeric=2)
prof["llm"] = {"dictionary": [
{"column": "precio", "description": "Precio de venta del producto",
"unit": "EUR"},
{"column": "alcohol", "business_meaning": "Grado alcohólico",
"unit": "% vol"},
]}
ch = build_num_distr(prof, {})
md_all = " ".join(b.text for b in _flatten(ch.blocks)
if b.kind == "markdown")
assert "Precio de venta" in md_all and "EUR" in md_all
assert "Grado alcohólico" in md_all and "% vol" in md_all
def test_edge_sin_llm_no_anade_descripcion():
# Without an LLM block the per-column description markdown is simply omitted.
ch = build_num_distr(_profile(n_numeric=2), {})
md_all = " ".join(b.text for b in _flatten(ch.blocks)
if b.kind == "markdown")
assert "Descripción" not in md_all
def test_boxplot_stats_se_consumen_del_registry(): def test_boxplot_stats_se_consumen_del_registry():
# The chapter must feed build_boxplot_stats (group eda) and the resulting # The chapter must feed build_boxplot_stats (group eda) and the resulting
# box must carry the Tukey fences for the figure. # box must carry the Tukey fences for the figure.
@@ -7,11 +7,21 @@ as needed, the renderers paginate):
NOT carry the raw head, so this is read from ``ctx['head_rows']`` / NOT carry the raw head, so this is read from ``ctx['head_rows']`` /
``profile['head_rows']`` (a list of row dicts). When absent the chapter shows ``profile['head_rows']`` (a list of row dicts). When absent the chapter shows
an honest placeholder documenting the missing key instead of inventing data. an honest placeholder documenting the missing key instead of inventing data.
2. Column dictionary — name / type / nulls / non-null examples. Examples come 2. Column dictionary — name / type / nulls / non-null examples plus, when the
LLM layer ran, the business **description** and **unit** of each column so the
reader knows at a glance what every column is and in which unit. Examples come
from ``columns[i]['examples']`` when present; otherwise they are derived from from ``columns[i]['examples']`` when present; otherwise they are derived from
real non-null profile values (categorical top values, numeric min/median/max) real non-null profile values (categorical top values, numeric min/median/max)
so the cell is never empty nor fabricated. so the cell is never empty nor fabricated.
3. ``df.describe`` — mean / median / min / max / std for every numeric column. 3. ``df.describe`` — mean / median / min / max / std for every numeric column,
plus its **unit** (same LLM source) so the stats read in context.
The description/unit come from the ``llm`` block that ``eda_llm_insights`` (group
``eda``) already stored in the profile (``profile['llm']['dictionary']``, a list
of ``{"column","description","business_meaning","unit"}`` entries) — this chapter
only **consumes** it, matching by column name; it never calls the LLM nor
recomputes anything. When the block is absent (``run_llm`` did not run) those
cells degrade to ``""`` and the tables still render.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z". Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
""" """
@@ -20,13 +30,59 @@ from __future__ import annotations
from .. import model from .. import model
CHAPTER_VERSION = "1.1.0" CHAPTER_VERSION = "1.2.0"
CHAPTER_ID = "overview" CHAPTER_ID = "overview"
CHAPTER_TITLE = "Overview" CHAPTER_TITLE = "Overview"
# Profile/ctx keys the calculation phase must add for a full head + examples. # Profile/ctx keys the calculation phase must add for a full head + examples.
HEAD_KEY = "head_rows" # list[dict] — df.head(n) HEAD_KEY = "head_rows" # list[dict] — df.head(n)
EXAMPLES_KEY = "examples" # per column: list of non-null sample values EXAMPLES_KEY = "examples" # per column: list of non-null sample values
LLM_KEY = "llm" # interpretive block from eda_llm_insights
def _llm_dict_index(profile: dict, ctx: dict) -> dict:
"""Map column name -> its LLM dictionary entry (description/unit/...).
Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
dict when no LLM block ran, so the caller degrades to "" cells. Fully
defensive: never raises on malformed input.
"""
llm = profile.get(LLM_KEY)
if not isinstance(llm, dict):
llm = ctx.get(LLM_KEY)
if not isinstance(llm, dict):
return {}
entries = llm.get("dictionary")
if not isinstance(entries, (list, tuple)):
return {}
index: dict = {}
for e in entries:
if not isinstance(e, dict):
continue
col = e.get("column")
if col is None:
continue
index[model._safe_str(col)] = e
return index
def _llm_desc(entry) -> str:
"""Business description of a column from its LLM entry, or ""."""
if not isinstance(entry, dict):
return ""
raw = entry.get("description") or entry.get("business_meaning")
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
return text or ""
def _llm_unit(entry) -> str:
"""Unit of a column from its LLM entry, or ""."""
if not isinstance(entry, dict):
return ""
raw = entry.get("unit")
text = " ".join(model._safe_str(raw).split()) if raw is not None else ""
return text or ""
def _fmt_num(value, decimals: int = 3) -> str: def _fmt_num(value, decimals: int = 3) -> str:
@@ -104,9 +160,12 @@ def _head_block(profile: dict, ctx: dict):
"pasarlo en ctx['head_rows'] para mostrar las primeras filas.") "pasarlo en ctx['head_rows'] para mostrar las primeras filas.")
def _columns_block(profile: dict): def _columns_block(profile: dict, llm_index: dict):
cols = profile.get("columns") or [] cols = profile.get("columns") or []
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)"] # Descripción / Unidad come from the LLM dictionary (matched by column name);
# they read "—" when run_llm did not run, so the table always renders.
header = ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
"Descripción", "Unidad"]
rows = [] rows = []
for c in cols: for c in cols:
if not isinstance(c, dict): if not isinstance(c, dict):
@@ -126,15 +185,18 @@ def _columns_block(profile: dict):
nulls = str(null_count) nulls = str(null_count)
else: else:
nulls = "" nulls = ""
rows.append([name, ctype, nulls, _examples_for(c)]) entry = llm_index.get(model._safe_str(name))
rows.append([name, ctype, nulls, _examples_for(c),
_llm_desc(entry), _llm_unit(entry)])
if not rows: if not rows:
return None return None
return model.DataTable(header=header, rows=rows, title="Columnas") return model.DataTable(header=header, rows=rows, title="Columnas")
def _describe_block(profile: dict): def _describe_block(profile: dict, llm_index: dict):
cols = profile.get("columns") or [] cols = profile.get("columns") or []
header = ["Columna", "mean", "median", "min", "max", "std"] # "Unidad" (LLM source) lets the reader know in which unit each stat is.
header = ["Columna", "mean", "median", "min", "max", "std", "Unidad"]
rows = [] rows = []
for c in cols: for c in cols:
if not isinstance(c, dict) or c.get("inferred_type") != "numeric": if not isinstance(c, dict) or c.get("inferred_type") != "numeric":
@@ -142,13 +204,16 @@ def _describe_block(profile: dict):
num = c.get("numeric") or {} num = c.get("numeric") or {}
if not num: if not num:
continue continue
name = c.get("name") or "(col)"
entry = llm_index.get(model._safe_str(name))
rows.append([ rows.append([
c.get("name") or "(col)", name,
_fmt_num(num.get("mean")), _fmt_num(num.get("mean")),
_fmt_num(num.get("median")), _fmt_num(num.get("median")),
_fmt_num(num.get("min")), _fmt_num(num.get("min")),
_fmt_num(num.get("max")), _fmt_num(num.get("max")),
_fmt_num(num.get("std")), _fmt_num(num.get("std")),
_llm_unit(entry),
]) ])
if not rows: if not rows:
return None return None
@@ -163,16 +228,18 @@ def build_overview(profile: dict, ctx: dict):
if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)): if not cols and not (ctx.get(HEAD_KEY) or profile.get(HEAD_KEY)):
return None return None
llm_index = _llm_dict_index(profile, ctx)
blocks = [ blocks = [
model.Heading(text="Primeras filas (df.head)", level=2), model.Heading(text="Primeras filas (df.head)", level=2),
_head_block(profile, ctx), _head_block(profile, ctx),
] ]
cols_block = _columns_block(profile) cols_block = _columns_block(profile, llm_index)
if cols_block is not None: if cols_block is not None:
blocks.append(model.Heading( blocks.append(model.Heading(
text="Diccionario de columnas", level=2)) text="Diccionario de columnas", level=2))
blocks.append(cols_block) blocks.append(cols_block)
desc_block = _describe_block(profile) desc_block = _describe_block(profile, llm_index)
if desc_block is not None: if desc_block is not None:
blocks.append(model.Heading( blocks.append(model.Heading(
text="Resumen estadístico numérico", level=2)) text="Resumen estadístico numérico", level=2))
@@ -56,7 +56,21 @@ def _head_rows() -> list:
] ]
def _profile(with_head: bool = True) -> dict: def _llm() -> dict:
"""Interpretive block as eda_llm_insights stores it under profile['llm']."""
return {
"summary": "Pasajeros del Titanic.",
"dictionary": [
{"column": "PassengerId", "description": "Identificador del pasajero",
"business_meaning": "Clave única de cada pasajero", "unit": "id"},
{"column": "Pclass", "description": "Clase del billete",
"business_meaning": "Clase socioeconómica", "unit": "clase (1-3)"},
# No entry for Survived/Name/Sex on purpose -> they degrade to "—".
],
}
def _profile(with_head: bool = True, with_llm: bool = False) -> dict:
prof = { prof = {
"table": "titanic", "table": "titanic",
"source": "/data/titanic.csv", "source": "/data/titanic.csv",
@@ -68,6 +82,8 @@ def _profile(with_head: bool = True) -> dict:
} }
if with_head: if with_head:
prof["head_rows"] = _head_rows() prof["head_rows"] = _head_rows()
if with_llm:
prof["llm"] = _llm()
return prof return prof
@@ -185,3 +201,70 @@ def test_edge_none_y_vacio_no_rompen():
assert ch is not None assert ch is not None
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)] tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
assert tables and len(tables[0].rows) == 3 assert tables and len(tables[0].rows) == 3
def _table_by_header(blocks, marker: str):
"""Return the first DataTable whose header contains ``marker``."""
for b in _flatten(blocks):
if isinstance(b, DataTable) and marker in b.header:
return b
return None
def test_golden_diccionario_lleva_descripcion_y_unidad_del_llm():
# With run_llm: the column dictionary gains "Descripción" and "Unidad"
# columns populated from profile['llm']['dictionary'], matched by name.
ch = build_overview(_profile(with_llm=True), {})
assert ch is not None
dic = _table_by_header(ch.blocks, "Descripción")
assert dic is not None
assert dic.header == ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
"Descripción", "Unidad"]
by_name = {row[0]: row for row in dic.rows}
# PassengerId has an LLM entry -> description + unit populated.
assert by_name["PassengerId"][4] == "Identificador del pasajero"
assert by_name["PassengerId"][5] == "id"
assert by_name["Pclass"][5] == "clase (1-3)"
# Columns with no LLM entry degrade to "—" without breaking the row.
assert by_name["Survived"][4] == "" and by_name["Survived"][5] == ""
def test_golden_describe_lleva_unidad_del_llm():
ch = build_overview(_profile(with_llm=True), {})
desc = _table_by_header(ch.blocks, "std")
assert desc is not None
assert desc.header[-1] == "Unidad"
by_name = {row[0]: row for row in desc.rows}
assert by_name["PassengerId"][-1] == "id"
assert by_name["Pclass"][-1] == "clase (1-3)"
# Numeric column with no LLM unit still renders, unit "—".
assert by_name["Survived"][-1] == ""
def test_edge_sin_llm_descripcion_unidad_son_guion():
# No profile['llm'] at all: the new cells degrade to "—" and nothing breaks.
ch = build_overview(_profile(), {})
assert ch is not None
dic = _table_by_header(ch.blocks, "Unidad")
assert dic is not None
for row in dic.rows:
assert row[4] == "" and row[5] == ""
desc = _table_by_header(ch.blocks, "std")
assert all(row[-1] == "" for row in desc.rows)
def test_golden_llm_via_ctx_tambien_funciona():
# LLM block arriving through ctx['llm'] (fallback path) is consumed too.
ch = build_overview(_profile(with_llm=False), {"llm": _llm()})
dic = _table_by_header(ch.blocks, "Descripción")
by_name = {row[0]: row for row in dic.rows}
assert by_name["PassengerId"][5] == "id"
def test_golden_render_pdf_muestra_descripcion_y_unidad():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pdf")
render_automatic_eda_pdf(_profile(with_llm=True), out, {"title": "EDA"})
txt = _pdf_text(out)
assert "Descripción" in txt and "Unidad" in txt
assert "Identificador del pasajero" in txt
@@ -26,7 +26,7 @@ from datetime import datetime, timezone
from .. import model from .. import model
CHAPTER_VERSION = "1.2.0" CHAPTER_VERSION = "1.4.0"
CHAPTER_ID = "portada" CHAPTER_ID = "portada"
CHAPTER_TITLE = "Portada" CHAPTER_TITLE = "Portada"
@@ -35,12 +35,9 @@ CHAPTER_TITLE = "Portada"
# row represents) from it when the LLM layer ran (``run_llm``). # row represents) from it when the LLM layer ran (``run_llm``).
_LLM_KEY = "llm" _LLM_KEY = "llm"
# Default human description of what the table quality score measures. Chapters # Font size (pt) for the dataset name on the PPTX cover slide — notably larger
# can override it via ctx["quality_criteria"]. # than the default H1 so the dataset name stands out (shown underlined too).
_DEFAULT_QUALITY_CRITERIA = ( _PPTX_TITLE_PT = 44.0
"media de los scores por columna (0100): completitud (sin nulos/vacíos), "
"validez (tipo y rango coherentes) y consistencia (sin duplicados/constantes)."
)
def _storage_from_source(source: str) -> str: def _storage_from_source(source: str) -> str:
@@ -120,11 +117,20 @@ def _summary_blocks(summary) -> list:
blocks = [model.Heading(text="Resumen del análisis", level=2)] blocks = [model.Heading(text="Resumen del análisis", level=2)]
if rows: if rows:
blocks.append(model.KVTable(rows=rows)) # Values pinned to the right margin (numbers flush right, label left).
blocks.append(model.KVTable(rows=rows, value_align="right"))
if titles: if titles:
bullets = "\n".join(f"- {model._safe_str(t)}" for t in titles) # Clickable index ("Índice"): one TocEntry per chapter title. Each entry
blocks.append(model.Markdown( # becomes a real jump to that chapter's first page/slide once the document
text="Este informe incluye los siguientes capítulos:\n" + bullets)) # is laid out (the renderers register every chapter start and wire the
# links; ``target_id`` is matched against the chapter title). The cover only
# knows chapter titles, so the title doubles as the link target.
blocks.append(model.Heading(text="Índice", level=2))
for t in titles:
label = model._safe_str(t)
if not label:
continue
blocks.append(model.TocEntry(label=label, target_id=label))
return blocks return blocks
@@ -213,9 +219,7 @@ def _derive_description(profile: dict, ctx: dict) -> str:
score = profile.get("quality_score") score = profile.get("quality_score")
if score is not None: if score is not None:
parts.append(f"Calidad media estimada: {score}/100.") parts.append(f"Calidad media estimada: {score}/100.")
parts.append( parts.append("Resumen derivado del perfil.")
"Resumen derivado del perfil; active la interpretación LLM (`run_llm`) "
"para una descripción de negocio más rica.")
return " ".join(parts) return " ".join(parts)
@@ -259,7 +263,6 @@ def build_portada(profile: dict, ctx: dict):
shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas" shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas"
score = profile.get("quality_score") score = profile.get("quality_score")
quality_criteria = ctx.get("quality_criteria") or _DEFAULT_QUALITY_CRITERIA
quality_value = "" if score is None else f"{score} / 100" quality_value = "" if score is None else f"{score} / 100"
llm = _llm_block(profile, ctx) llm = _llm_block(profile, ctx)
@@ -282,8 +285,11 @@ def build_portada(profile: dict, ctx: dict):
# Title + dataset size shown together and BIG (Heading) at the top, kept on # 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. # the same page (Group). The size is no longer buried in the metadata table.
# The dataset name is shown big and underlined on the PPTX cover slide
# (size_pt/underline are honoured by the PPTX renderer; the PDF ignores them).
cover = [ cover = [
model.Heading(text=str(dataset_name), level=1), model.Heading(text=str(dataset_name), level=1, underline=True,
size_pt=_PPTX_TITLE_PT),
model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"), model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
model.Heading(text=shape, level=2), model.Heading(text=shape, level=2),
] ]
@@ -295,7 +301,6 @@ def build_portada(profile: dict, ctx: dict):
("Almacenamiento", storage), ("Almacenamiento", storage),
("Generado", when), ("Generado", when),
("Calidad", quality_value), ("Calidad", quality_value),
("Criterios de calidad", quality_criteria),
]), ]),
model.Heading(text="Descripción", level=2), model.Heading(text="Descripción", level=2),
model.Markdown(text=str(description)), model.Markdown(text=str(description)),
@@ -73,24 +73,51 @@ def build_chapter(chapter_id: str, profile: dict, ctx: dict):
return model.as_chapter(result) return model.as_chapter(result)
def build_document(profile: dict, ctx: dict = None) -> list: def build_document(profile: dict, ctx: dict = None, only: list = None) -> list:
"""Build the full ordered list of chapters for a TableProfile. """Build the ordered list of chapters for a TableProfile.
Args: Args:
profile: the ``eda`` group TableProfile dict (may be None/empty). profile: the ``eda`` group TableProfile dict (may be None/empty).
ctx: optional context dict carrying presentation metadata not present in ctx: optional context dict carrying presentation metadata not present in
the profile (dataset_name, source_origin, storage, generated_at, the profile (dataset_name, source_origin, storage, generated_at,
description, granularity, quality_criteria, head_rows, ...). description, granularity, quality_criteria, head_rows, ...).
only: optional list of chapter ids to render. ``None`` (default) keeps
the historical behaviour — every implemented & applicable chapter in
canonical order. A list restricts the BODY to just those ids (in
canonical order), but the cover (``portada``) and glossary
(``glosario``) are ALWAYS included so the document stays valid and
the clickable terms keep a destination — so passing ``only=["x"]``
yields portada + x + glosario. Unknown ids are simply skipped (the
caller is responsible for strict validation). ``only=[]`` yields the
minimal document (portada + glosario only). This argument is additive
and backward-compatible: the signature is unchanged for existing
callers (default ``None``).
Returns: Returns:
list[Chapter] in canonical order, containing only the chapters that are list[Chapter] in canonical order, containing only the chapters that are
implemented and applicable. Never raises. implemented, applicable and selected. Never raises.
""" """
if not isinstance(profile, dict): if not isinstance(profile, dict):
profile = {} profile = {}
# Copy ctx so the shared collector / summary we add do not leak to the caller. # Copy ctx so the shared collector / summary we add do not leak to the caller.
ctx = dict(ctx) if isinstance(ctx, dict) else {} ctx = dict(ctx) if isinstance(ctx, dict) else {}
# only=None -> all body chapters (historical). only=list -> restrict body to
# that selection (portada/glosario are added unconditionally below). The
# renderers call build_document(profile, meta['ctx']) without an `only`
# argument, so the pipeline forwards the selection through a reserved ctx key
# (``_only_chapters``); an explicit `only` argument always wins. The key is
# popped from the local ctx copy so it never reaches the chapters.
if only is None:
_carried = ctx.pop("_only_chapters", None)
if isinstance(_carried, (list, tuple, set)):
only = list(_carried)
else:
ctx.pop("_only_chapters", None)
# A set makes the membership test cheap; the iteration order stays
# CHAPTER_ORDER. only=[] is a valid (empty) selection -> minimal document.
only_set = set(only) if isinstance(only, (list, tuple, set)) else None
# A single glossary collector is shared by every chapter via ctx['glossary']. # A single glossary collector is shared by every chapter via ctx['glossary'].
# Chapters call ctx['glossary'].add(key, label, definition) and mark in-text # Chapters call ctx['glossary'].add(key, label, definition) and mark in-text
# appearances with [[term:key]]…[[/term]]; the glosario chapter renders the # appearances with [[term:key]]…[[/term]]; the glosario chapter renders the
@@ -106,6 +133,10 @@ def build_document(profile: dict, ctx: dict = None) -> list:
for cid in CHAPTER_ORDER: for cid in CHAPTER_ORDER:
if cid in (_PORTADA, _GLOSARIO): if cid in (_PORTADA, _GLOSARIO):
continue continue
# When a selection is given, skip body chapters outside it. portada and
# glosario are never filtered (handled out of this loop).
if only_set is not None and cid not in only_set:
continue
ch = build_chapter(cid, profile, ctx) ch = build_chapter(cid, profile, ctx)
if ch is not None and ch.blocks: if ch is not None and ch.blocks:
body.append(ch) body.append(ch)
@@ -38,10 +38,18 @@ ENGINE_NAME = "AutomaticEDA"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@dataclass @dataclass
class Heading: class Heading:
"""A section heading. ``level`` 1 (largest) .. 3 (smallest).""" """A section heading. ``level`` 1 (largest) .. 3 (smallest).
``underline`` and ``size_pt`` are optional emphasis hints honoured by the
PPTX renderer (the cover uses them to show the dataset name big and
underlined). ``size_pt`` overrides the per-level font size when set; the PDF
renderer ignores both so its layout is unchanged.
"""
text: str = "" text: str = ""
level: int = 1 level: int = 1
underline: bool = False
size_pt: Optional[float] = None
kind: str = field(default="heading", init=False) kind: str = field(default="heading", init=False)
@@ -62,10 +70,17 @@ class Markdown:
@dataclass @dataclass
class KVTable: class KVTable:
"""A two-column key/value table. ``rows`` is a list of ``(label, value)``.""" """A two-column key/value table. ``rows`` is a list of ``(label, value)``.
``value_align`` controls the horizontal alignment of the value column in the
PDF renderer: ``"left"`` (default) keeps values next to the label column;
``"right"`` pins them to the right margin (used by the cover's analysis
summary so the numbers line up flush right).
"""
rows: list = field(default_factory=list) rows: list = field(default_factory=list)
title: Optional[str] = None title: Optional[str] = None
value_align: str = "left"
kind: str = field(default="kv_table", init=False) kind: str = field(default="kv_table", init=False)
@@ -145,11 +160,21 @@ class Group:
a chapter can give each unit its own page — e.g. one categorical column per a chapter can give each unit its own page — e.g. one categorical column per
page (see CAT DISTR). It is purely additive: the default False keeps the plain page (see CAT DISTR). It is purely additive: the default False keeps the plain
keep-together behaviour for every existing chapter. keep-together behaviour for every existing chapter.
``layout`` is a hint for how the group's children are arranged:
``"stack"`` (default) keeps the historical top-to-bottom flow; ``"side_by_side"``
asks the PPTX renderer to place the group's table to the LEFT and its figure to
the RIGHT of the same slide (table ~55% width, figure ~45%), measuring so both
fit and falling back to stacking when they do not. The PDF renderer treats
``"side_by_side"`` exactly like ``"stack"`` (the A5 mobile page is too narrow for
two readable columns). Unknown values degrade to ``"stack"``. Purely additive:
the default keeps every existing chapter unchanged.
""" """
blocks: list = field(default_factory=list) blocks: list = field(default_factory=list)
title: Optional[str] = None title: Optional[str] = None
page_break_before: bool = False page_break_before: bool = False
layout: str = "stack"
kind: str = field(default="group", init=False) kind: str = field(default="group", init=False)
@@ -168,6 +193,22 @@ class GlossaryEntry:
kind: str = field(default="glossary_entry", init=False) kind: str = field(default="glossary_entry", init=False)
@dataclass
class TocEntry:
"""One clickable index (table-of-contents) entry shown on the cover.
Rendered as a single line — the chapter ``label`` in the accent link colour —
that, once the document is laid out, becomes a real click jumping to the first
page/slide of the target chapter (PDF link annotation via PyMuPDF; PPTX native
slide jump). ``target_id`` is matched against each chapter's ``id`` *and* its
``title`` (the cover only knows chapter titles), so either resolves. If the
target cannot be resolved the entry still renders as plain text (never cut)."""
label: str = ""
target_id: str = ""
kind: str = field(default="toc_entry", init=False)
@dataclass @dataclass
class Chapter: class Chapter:
"""An ordered set of blocks with an id, a title and a generation version.""" """An ordered set of blocks with an id, a title and a generation version."""
@@ -192,13 +233,14 @@ _BLOCK_BY_KIND = {
"note": Note, "note": Note,
"group": Group, "group": Group,
"glossary_entry": GlossaryEntry, "glossary_entry": GlossaryEntry,
"toc_entry": TocEntry,
} }
def as_block(obj: Any): def as_block(obj: Any):
"""Coerce a value into a block dataclass. Unknown values become a Note.""" """Coerce a value into a block dataclass. Unknown values become a Note."""
if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image, if isinstance(obj, (Heading, Markdown, KVTable, DataTable, Figure, Image,
Caption, Note, Group, GlossaryEntry)): Caption, Note, Group, GlossaryEntry, TocEntry)):
if isinstance(obj, Group): if isinstance(obj, Group):
obj.blocks = as_blocks(obj.blocks) obj.blocks = as_blocks(obj.blocks)
return obj return obj
@@ -210,13 +252,20 @@ def as_block(obj: Any):
# Build only with fields the dataclass accepts (ignore extras). # Build only with fields the dataclass accepts (ignore extras).
try: try:
if cls is Heading: if cls is Heading:
size_pt = obj.get("size_pt")
return Heading(text=_safe_str(obj.get("text")), return Heading(text=_safe_str(obj.get("text")),
level=int(obj.get("level", 1) or 1)) level=int(obj.get("level", 1) or 1),
underline=bool(obj.get("underline", False)),
size_pt=(float(size_pt)
if isinstance(size_pt, (int, float))
else None))
if cls is Markdown: if cls is Markdown:
return Markdown(text=_safe_str(obj.get("text"))) return Markdown(text=_safe_str(obj.get("text")))
if cls is KVTable: if cls is KVTable:
return KVTable(rows=list(obj.get("rows") or []), return KVTable(rows=list(obj.get("rows") or []),
title=obj.get("title")) title=obj.get("title"),
value_align=_safe_str(
obj.get("value_align")) or "left")
if cls is DataTable: if cls is DataTable:
return DataTable(header=list(obj.get("header") or []), return DataTable(header=list(obj.get("header") or []),
rows=list(obj.get("rows") or []), rows=list(obj.get("rows") or []),
@@ -237,11 +286,15 @@ def as_block(obj: Any):
return Group(blocks=as_blocks(obj.get("blocks")), return Group(blocks=as_blocks(obj.get("blocks")),
title=obj.get("title"), title=obj.get("title"),
page_break_before=bool( page_break_before=bool(
obj.get("page_break_before", False))) obj.get("page_break_before", False)),
layout=_safe_str(obj.get("layout")) or "stack")
if cls is GlossaryEntry: if cls is GlossaryEntry:
return GlossaryEntry(key=_safe_str(obj.get("key")), return GlossaryEntry(key=_safe_str(obj.get("key")),
label=_safe_str(obj.get("label")), label=_safe_str(obj.get("label")),
definition=_safe_str(obj.get("definition"))) definition=_safe_str(obj.get("definition")))
if cls is TocEntry:
return TocEntry(label=_safe_str(obj.get("label")),
target_id=_safe_str(obj.get("target_id")))
except Exception: # noqa: BLE001 — never raise on a malformed block. except Exception: # noqa: BLE001 — never raise on a malformed block.
return Note(text=_safe_str(obj)) return Note(text=_safe_str(obj))
return Note(text=_safe_str(obj)) return Note(text=_safe_str(obj))
@@ -298,11 +298,16 @@ def test_cover_first_glossary_last_with_summary():
headings = [b.text for b in cover.blocks if b.kind == "heading"] headings = [b.text for b in cover.blocks if b.kind == "heading"]
assert any("Resumen" in h for h in headings), \ assert any("Resumen" in h for h in headings), \
"la portada no incluye el resumen agregado" "la portada no incluye el resumen agregado"
# The summary reflects the body chapters (e.g. the numeric/categorical ones). # The index ("Índice") is now a clickable list of TocEntry blocks (one per
cover_text = " ".join( # body chapter), not a markdown bullet list. Verify both the heading and that
b.text for b in cover.blocks if getattr(b, "kind", "") == "markdown") # the entries name the body chapters.
assert "Distribuciones" in cover_text, \ assert any("Índice" in h for h in headings), \
"el resumen de portada no menciona los capítulos del cuerpo" "la portada no incluye la sección Índice"
toc_labels = " ".join(
getattr(b, "label", "") for b in cover.blocks
if getattr(b, "kind", "") == "toc_entry")
assert "Distribuciones" in toc_labels, \
"el índice de portada no menciona los capítulos del cuerpo"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -46,11 +46,23 @@ _MUTED = "#8a8a8a"
_RULE = "#cccccc" _RULE = "#cccccc"
_HEAD_BG = "#eef3f6" _HEAD_BG = "#eef3f6"
# Rasterization DPI for every embedded raster (figure/table image) AND for the
# page save itself. Raised from the old 150/default-100 to 220 so a reader can
# pinch-zoom on a phone and still see crisp detail (axis labels, table cells)
# without pixelation. Text stays vectorial (pdf.fonttype=42) so it remains
# selectable regardless of DPI — only the embedded images gain resolution. 220 is
# a deliberate balance: noticeably sharper than 150 while keeping the file size
# reasonable. ``savefig.dpi`` matters because matplotlib re-rasterizes each
# ``imshow`` when PdfPages writes the page; without it the final image would land
# at ~100 dpi no matter how sharp the intermediate PNG was.
_RASTER_DPI = 220
_RC = { _RC = {
"font.size": 10, "font.size": 10,
"font.family": "sans-serif", "font.family": "sans-serif",
"figure.facecolor": "white", "figure.facecolor": "white",
"savefig.facecolor": "white", "savefig.facecolor": "white",
"savefig.dpi": _RASTER_DPI,
"pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile. "pdf.fonttype": 42, # embed TrueType — text stays selectable on mobile.
} }
@@ -80,6 +92,10 @@ class _PdfState:
# points (1/72") with a top-left origin — same convention as PyMuPDF. # points (1/72") with a top-left origin — same convention as PyMuPDF.
self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}] self.term_sources = [] # [{key, page, rect:[x0,y0,x1,y1]}]
self.term_dests = {} # key -> {page, point:[x,y]} self.term_dests = {} # key -> {page, point:[x,y]}
# Clickable index (cover → chapter). Sources are the cover's TocEntry
# rects; chapter_starts maps a chapter id AND its title to its first page.
self.toc_sources = [] # [{target_id, page, rect:[x0,y0,x1,y1]}]
self.chapter_starts = {} # id|title -> {page, point:[x,y]}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -317,10 +333,18 @@ def _place_kv_table(st: _PdfState, block) -> None:
if title: if title:
_place_heading(st, model.Heading(title, level=2)) _place_heading(st, model.Heading(title, level=2))
rows = getattr(block, "rows", []) or [] rows = getattr(block, "rows", []) or []
# ``value_align="right"`` pins the value column to the right margin (label
# left, number flush right) — used by the cover's analysis summary.
right = str(getattr(block, "value_align", "left")).lower() == "right"
key_w = 1.9 # inches reserved for the label column. key_w = 1.9 # inches reserved for the label column.
# Right-aligned values wrap against the full usable width minus the label
# column; left-aligned values wrap against the value column only.
val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY) val_chars = tl.chars_per_line(_USABLE_W - key_w - 0.1, _FS_BODY)
lh = tl.line_height_in(_FS_BODY) lh = tl.line_height_in(_FS_BODY)
for row in rows: # ``data_idx`` is the 0-based logical row index: even rows (1-based) are
# zebra-shaded → 0-based odd indices, matching the data-table convention so
# every table in the document carries the same striping.
for data_idx, row in enumerate(rows):
try: try:
label, value = row[0], row[1] label, value = row[0], row[1]
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@@ -329,11 +353,25 @@ def _place_kv_table(st: _PdfState, block) -> None:
row_h = lh * len(v_lines) + _ROW_VPAD row_h = lh * len(v_lines) + _ROW_VPAD
_ensure_space(st, row_h) _ensure_space(st, row_h)
y0 = st.y y0 = st.y
# Faint zebra fill for even rows, drawn first (zorder 0) so striping
# never hides the text/value drawn on top.
if data_idx % 2 == 1:
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=_ZEBRA, lw=0, zorder=0))
st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)), st.fig.text(_xf(_ML), _yf(y0), tl.strip_inline_md(model._safe_str(label)),
fontsize=_FS_BODY, color=_MUTED, ha="left", va="top") fontsize=_FS_BODY, color=_MUTED, ha="left", va="top",
zorder=2)
for k, vl in enumerate(v_lines): for k, vl in enumerate(v_lines):
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl, if right:
fontsize=_FS_BODY, color=_INK, ha="left", va="top") st.fig.text(_xf(_ML + _USABLE_W), _yf(y0 + k * lh), vl,
fontsize=_FS_BODY, color=_INK, ha="right",
va="top", zorder=2)
else:
st.fig.text(_xf(_ML + key_w), _yf(y0 + k * lh), vl,
fontsize=_FS_BODY, color=_INK, ha="left",
va="top", zorder=2)
st.y = y0 + row_h st.y = y0 + row_h
st.y += _GAP st.y += _GAP
@@ -363,6 +401,57 @@ def _col_widths(header: list, rows: list, fs: float) -> list:
return widths return widths
# Minimal legible characters reserved per column when deciding whether a table
# can be shown as selectable text. Below this width per column the cells become
# unreadable, so the table is rasterized to a zoomable high-res image instead.
_MIN_LEGIBLE_CHARS = 8
def _table_fits_as_text(header: list, rows: list) -> bool:
"""True when the table fits the usable width as readable text.
A table whose columns cannot each get a minimal legible width within the A5
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
so it is rendered as a single high-resolution image — the reader zooms in on
the phone and reads every cell, nothing cut — instead of being squeezed until
unreadable. Narrow tables (few columns) keep the selectable-text rendering."""
header = header or []
rows = rows or []
ncol = len(header) if header else (len(rows[0]) if rows else 1)
ncol = max(1, ncol)
cw = tl.avg_char_width_in(_FS_CELL)
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
return min_needed <= _USABLE_W
def _table_figure_block(block):
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
The table is drawn once via ``render_table_as_figure`` (header shading + zebra)
and embedded as one high-res image scaled to fit entirely. The same Figure is
reused for measuring and placing so keep-together stays consistent. The table
title/note are drawn inside the image (self-describing when zoomed/shared), so
the block-level caption is left empty to avoid a duplicate title."""
cached = getattr(block, "_aeda_tablefig", None)
if cached is not None:
return cached
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
title = getattr(block, "title", None)
note = getattr(block, "note", None)
def _make():
from datascience.render_table_as_figure import render_table_as_figure
return render_table_as_figure(header, rows, title=title, note=note)
fig = model.Figure(make=_make, caption=None)
try:
block._aeda_tablefig = fig
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
pass
return fig
def _wrap_row(cells: list, widths: list, fs: float) -> list: def _wrap_row(cells: list, widths: list, fs: float) -> list:
"""Wrap each cell to its column width → list of line-lists per cell.""" """Wrap each cell to its column width → list of line-lists per cell."""
out = [] out = []
@@ -402,11 +491,16 @@ def _draw_table_row(st: _PdfState, cells_lines: list, widths: list, fs: float,
def _place_data_table(st: _PdfState, block) -> None: def _place_data_table(st: _PdfState, block) -> None:
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
# Too many columns to be legible as text → render the whole table as one
# high-res image, scaled to fit entirely (the reader zooms to read it).
if not _table_fits_as_text(header, rows):
_place_figure(st, _table_figure_block(block))
return
title = getattr(block, "title", None) title = getattr(block, "title", None)
if title: if title:
_place_heading(st, model.Heading(title, level=2)) _place_heading(st, model.Heading(title, level=2))
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
fs = _FS_CELL fs = _FS_CELL
widths = _col_widths(header, rows, fs) widths = _col_widths(header, rows, fs)
header_lines = _wrap_row(header, widths, fs) if header else None header_lines = _wrap_row(header, widths, fs) if header else None
@@ -464,8 +558,11 @@ def _resolve_figure(block):
def _png_from_figure(fig) -> bytes: def _png_from_figure(fig) -> bytes:
# ``bbox_inches='tight'`` is kept so the real aspect ratio is what we measure
# and place. The page save (savefig.dpi in _RC) re-rasterizes this at the same
# high DPI, so the embedded image stays crisp for phone zoom.
buf = io.BytesIO() buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") fig.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
buf.seek(0) buf.seek(0)
return buf.read() return buf.read()
@@ -707,12 +804,16 @@ def _measure_data_table(block) -> float:
Counts the optional title heading, the wrapped header row, every wrapped data Counts the optional title heading, the wrapped header row, every wrapped data
row (per-column wrap via the same ``_col_widths``/``_wrap_row`` the placer row (per-column wrap via the same ``_col_widths``/``_wrap_row`` the placer
uses) and the optional note. Keep this in sync with ``_place_data_table``.""" uses) and the optional note. Keep this in sync with ``_place_data_table``."""
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
# Mirror the placer: a too-wide table is drawn as a single image, so its
# keep-together height is the image's, not the (squeezed) text layout's.
if not _table_fits_as_text(header, rows):
return _measure_figure_like(_table_figure_block(block))
h = 0.0 h = 0.0
title = getattr(block, "title", None) title = getattr(block, "title", None)
if title: if title:
h += _measure_heading_text(title, 2) h += _measure_heading_text(title, 2)
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
fs = _FS_CELL fs = _FS_CELL
widths = _col_widths(header, rows, fs) widths = _col_widths(header, rows, fs)
lh = tl.line_height_in(fs) lh = tl.line_height_in(fs)
@@ -744,6 +845,10 @@ def _measure_block(st: _PdfState, block) -> float:
lines = tl.wrap(getattr(block, "text", ""), lines = tl.wrap(getattr(block, "text", ""),
tl.chars_per_line(_USABLE_W, _FS_NOTE)) tl.chars_per_line(_USABLE_W, _FS_NOTE))
return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP return tl.line_height_in(_FS_NOTE) * len(lines) + _GAP
if kind == "toc_entry":
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)) or [""]
return tl.line_height_in(_FS_BODY) * len(lines) + _GAP * 0.4
if kind == "kv_table": if kind == "kv_table":
return _measure_kv_table(block) return _measure_kv_table(block)
if kind == "data_table": if kind == "data_table":
@@ -828,6 +933,38 @@ def _place_glossary_entry(st: _PdfState, block) -> None:
st.y += _GAP * 0.5 st.y += _GAP * 0.5
def _place_toc_entry(st: _PdfState, block) -> None:
"""Render one clickable index line and record it as a link source.
Drawn as a bulleted line in the accent link colour; its rectangle is recorded
in ``st.toc_sources`` so the post-processor turns it into a real jump to the
target chapter's first page. If the target is never resolved the line still
shows as plain (accent) text — never cut, never broken."""
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
target_id = getattr(block, "target_id", "") or ""
fs = _FS_BODY
lh = tl.line_height_in(fs)
bullet = ""
indent = 0.22
max_chars = tl.chars_per_line(_USABLE_W - indent, fs)
lines = tl.wrap(label, max_chars) or [""]
for idx, ln in enumerate(lines):
_ensure_space(st, lh)
x = _ML
st.fig.text(_xf(x), _yf(st.y), bullet if idx == 0 else " ",
fontsize=fs, color=_LINK, ha="left", va="top")
x += indent
w = _text_width_in(st, ln, fs, False)
st.fig.text(_xf(x), _yf(st.y), ln, fontsize=fs, color=_LINK,
ha="left", va="top")
if target_id and idx == 0:
st.toc_sources.append({
"target_id": target_id, "page": st.page - 1,
"rect": _pt_rect(_ML, st.y, x + w, st.y + lh)})
st.y += lh
st.y += _GAP * 0.4
_PLACERS = { _PLACERS = {
"heading": _place_heading, "heading": _place_heading,
"markdown": _place_markdown, "markdown": _place_markdown,
@@ -839,6 +976,7 @@ _PLACERS = {
"note": _place_note, "note": _place_note,
"group": _place_group, "group": _place_group,
"glossary_entry": _place_glossary_entry, "glossary_entry": _place_glossary_entry,
"toc_entry": _place_toc_entry,
} }
@@ -870,6 +1008,15 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
st.chapter = ch st.chapter = ch
st.chapter_pages = 0 st.chapter_pages = 0
_new_page(st) # each chapter starts on a fresh page. _new_page(st) # each chapter starts on a fresh page.
# Record this chapter's first page as a link target for the
# cover index (keyed by id AND title, since the cover only
# knows titles). Point is the top of the content area.
_start = {"page": st.page - 1,
"point": [_ML * 72.0, _CONTENT_TOP * 72.0]}
if ch.id:
st.chapter_starts[ch.id] = _start
if getattr(ch, "title", ""):
st.chapter_starts.setdefault(ch.title, _start)
for block in ch.blocks: for block in ch.blocks:
placer = _PLACERS.get(getattr(block, "kind", ""), placer = _PLACERS.get(getattr(block, "kind", ""),
_place_note) _place_note)
@@ -902,7 +1049,7 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
note = f"{n_pages} páginas" note = f"{n_pages} páginas"
if n_links: if n_links:
note += f" · {n_links} enlaces de glosario" note += f" · {n_links} enlaces internos"
if notes: if notes:
note += " · " + "; ".join(notes) note += " · " + "; ".join(notes)
return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta, return {"path": out_path, "n_pages": n_pages, "chapters": chapters_meta,
@@ -910,9 +1057,11 @@ def render_pdf(chapters: list, out_path: str, meta: dict = None) -> dict:
def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int: def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
"""Build {source rect → glossary dest} links and apply them via PyMuPDF. """Apply internal PDF links via PyMuPDF: glossary terms + the cover index.
Returns the number of links applied (0 if there is nothing to wire or the Builds two sets of GOTO links — every in-text glossary term → its entry, and
every cover ``TocEntry`` → its chapter's first page — and applies them in one
pass. Returns the number of links applied (0 if there is nothing to wire or the
post-processor is unavailable). Never raises.""" post-processor is unavailable). Never raises."""
try: try:
links = [] links = []
@@ -923,6 +1072,14 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
links.append({ links.append({
"src_page": src["page"], "src_rect": src["rect"], "src_page": src["page"], "src_rect": src["rect"],
"dst_page": dest["page"], "dst_point": dest["point"]}) "dst_page": dest["page"], "dst_point": dest["point"]})
# Cover index → chapter first page (clickable, navigable table of contents).
for src in st.toc_sources:
dest = st.chapter_starts.get(src.get("target_id"))
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: if not links:
return 0 return 0
from datascience.add_pdf_internal_links import add_pdf_internal_links from datascience.add_pdf_internal_links import add_pdf_internal_links
@@ -930,7 +1087,7 @@ def _wire_glossary_links(st: _PdfState, out_path: str, notes: list) -> int:
if isinstance(res, dict) and res.get("status") == "ok": if isinstance(res, dict) and res.get("status") == "ok":
return int(res.get("n_links") or 0) return int(res.get("n_links") or 0)
if isinstance(res, dict) and res.get("error"): if isinstance(res, dict) and res.get("error"):
notes.append(f"glosario sin enlaces: {res.get('error')}") notes.append(f"enlaces internos no aplicados: {res.get('error')}")
except Exception as e: # noqa: BLE001 — links are best-effort. except Exception as e: # noqa: BLE001 — links are best-effort.
notes.append(f"glosario sin enlaces: {e}") notes.append(f"enlaces internos no aplicados: {e}")
return 0 return 0
@@ -51,6 +51,12 @@ _FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11 _FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
_GAP = 0.12 _GAP = 0.12
# Rasterization DPI for every embedded figure/table image. Raised from 150 to 220
# so a viewer can zoom into a slide (or a shared picture) and read crisp detail —
# axis labels, table cells — without pixelation. Kept moderate so the deck size
# stays reasonable. Same value as the PDF renderer.
_RASTER_DPI = 220
class _PptxState: class _PptxState:
def __init__(self, prs, title: str): def __init__(self, prs, title: str):
@@ -65,6 +71,10 @@ class _PptxState:
# Glossary wiring (mejora 6): runs to link and per-term target slide. # Glossary wiring (mejora 6): runs to link and per-term target slide.
self.term_runs = [] # [(key, run)] self.term_runs = [] # [(key, run)]
self.term_anchor_slide = {} # key -> Slide (glossary entry) self.term_anchor_slide = {} # key -> Slide (glossary entry)
# Clickable index (cover → chapter). toc_runs are the cover's index runs;
# chapter_starts maps a chapter id AND its title to its first slide.
self.toc_runs = [] # [(target_id, run, src_slide)]
self.chapter_starts = {} # id|title -> Slide (chapter first slide)
def _rgb(c): def _rgb(c):
@@ -135,7 +145,7 @@ def _ensure(st: _PptxState, height: float) -> None:
def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False, def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
italic=False, indent=0.0, bullet=False) -> None: italic=False, indent=0.0, bullet=False, underline=False) -> None:
lh = tl.line_height_in(fs) lh = tl.line_height_in(fs)
height = lh * len(lines) + 0.05 height = lh * len(lines) + 0.05
_ensure(st, height) _ensure(st, height)
@@ -153,6 +163,7 @@ def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
run.font.size = Pt(fs) run.font.size = Pt(fs)
run.font.bold = bold run.font.bold = bold
run.font.italic = italic run.font.italic = italic
run.font.underline = underline
run.font.color.rgb = _rgb(color) run.font.color.rgb = _rgb(color)
st.y += height st.y += height
@@ -206,10 +217,16 @@ def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
def _place_heading(st: _PptxState, block) -> None: def _place_heading(st: _PptxState, block) -> None:
level = max(1, min(3, int(getattr(block, "level", 1) or 1))) level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level] fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
# Optional per-heading emphasis (cover dataset name): a larger font and an
# underline. ``size_pt`` overrides the per-level size when set.
size_override = getattr(block, "size_pt", None)
if isinstance(size_override, (int, float)) and size_override > 0:
fs = float(size_override)
underline = bool(getattr(block, "underline", False))
text = tl.strip_inline_md(getattr(block, "text", "")) text = tl.strip_inline_md(getattr(block, "text", ""))
st.last_heading = text or st.last_heading st.last_heading = text or st.last_heading
lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs)) lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
_add_text(st, lines, fs, _INK, bold=True) _add_text(st, lines, fs, _INK, bold=True, underline=underline)
st.y += 0.04 st.y += 0.04
@@ -302,6 +319,58 @@ def _col_widths(header, rows):
return [_USABLE_W * w / total for w in clamped] return [_USABLE_W * w / total for w in clamped]
# Minimal legible characters reserved per column when deciding whether a table
# can be shown as a native (selectable) PowerPoint table. Below this width per
# column the cells become unreadable, so the table is rasterized to a zoomable
# high-res image instead. The 16:9 slide is wide, so more columns fit than on A5.
_MIN_LEGIBLE_CHARS = 8
_CELL_PAD = 0.05
def _table_fits_as_text(header: list, rows: list) -> bool:
"""True when the table fits the usable slide width as a readable table.
A table whose columns cannot each get a minimal legible width within the slide
usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
so it is rendered as one high-resolution image the viewer zooms in and reads
every cell instead of being squeezed unreadable. Narrow tables keep the
native selectable table."""
header = header or []
rows = rows or []
ncol = len(header) if header else (len(rows[0]) if rows else 1)
ncol = max(1, ncol)
cw = tl.avg_char_width_in(_FS_CELL)
min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
return min_needed <= _USABLE_W
def _table_figure_block(block):
"""Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).
Drawn once via ``render_table_as_figure`` (header shading + zebra) and embedded
as one high-res image scaled to fit entirely. The title/note are drawn inside
the image (self-describing when zoomed/shared), so no separate caption is
emitted. Reused for measuring and placing so keep-together stays consistent."""
cached = getattr(block, "_aeda_tablefig", None)
if cached is not None:
return cached
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
title = getattr(block, "title", None)
note = getattr(block, "note", None)
def _make():
from datascience.render_table_as_figure import render_table_as_figure
return render_table_as_figure(header, rows, title=title, note=note)
fig = model.Figure(make=_make, caption=None)
try:
block._aeda_tablefig = fig
except Exception: # noqa: BLE001 — block may reject attributes; degrade.
pass
return fig
def _row_height_in(cells, widths, fs) -> float: def _row_height_in(cells, widths, fs) -> float:
lh = tl.line_height_in(fs) lh = tl.line_height_in(fs)
maxlines = 1 maxlines = 1
@@ -365,11 +434,27 @@ def _style_cell(cell, fs, color, bold, fill) -> None:
def _place_data_table(st: _PptxState, block, shaded_header=True, def _place_data_table(st: _PptxState, block, shaded_header=True,
key_value=False) -> None: key_value=False) -> None:
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
# Too many columns to be legible as a native table → render the whole table as
# one high-res picture, scaled to fit entirely (the viewer zooms to read it).
# KVTables (rendered here as a 2-column Campo/Valor table) are excluded: they
# always fit in width and stay as a selectable table.
if not key_value and not _table_fits_as_text(header, rows):
figblock = _table_figure_block(block)
data, _asp = _figure_bytes_cached(figblock)
if data is None:
_add_text(st, ["(tabla no disponible)"], _FS_NOTE, _MUTED,
italic=True)
st.y += _GAP
return
_place_picture_bytes(st, data, None,
max_h_in=getattr(figblock, "height_in", None),
force_caption=False)
return
title = getattr(block, "title", None) title = getattr(block, "title", None)
if title: if title:
_place_heading(st, model.Heading(title, level=2)) _place_heading(st, model.Heading(title, level=2))
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
fs = _FS_CELL fs = _FS_CELL
widths = _col_widths(header, rows) widths = _col_widths(header, rows)
header_h = _row_height_in(header, widths, fs) if header else 0.0 header_h = _row_height_in(header, widths, fs) if header else 0.0
@@ -429,7 +514,7 @@ def _resolve_png(block):
try: try:
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
buf = io.BytesIO() buf = io.BytesIO()
f.savefig(buf, format="png", dpi=150, bbox_inches="tight") f.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
buf.seek(0) buf.seek(0)
return buf.read() return buf.read()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@@ -476,12 +561,15 @@ def _figure_bytes_cached(block):
def _place_picture_bytes(st: _PptxState, data: bytes, caption, def _place_picture_bytes(st: _PptxState, data: bytes, caption,
max_h_in=None) -> None: max_h_in=None, force_caption=True) -> None:
# Mejora 4 — every figure on a slide carries a visible caption/title. If the # 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 # block has no caption, fall back to the current section heading, then to a
# generic label, so no image is ever shown untitled. # generic label, so no image is ever shown untitled. ``force_caption=False``
caption = (model._safe_str(caption).strip() # suppresses that fallback (used for table images, whose title is inside the
or model._safe_str(st.last_heading).strip() or "Figura") # picture) so no redundant caption is drawn.
caption = model._safe_str(caption).strip()
if not caption and force_caption:
caption = model._safe_str(st.last_heading).strip() or "Figura"
w_px, h_px = _img_size_px(data) w_px, h_px = _img_size_px(data)
aspect = (h_px / w_px) if w_px else 0.66 aspect = (h_px / w_px) if w_px else 0.66
# Reserve the caption's REAL (possibly multi-line) height FIRST, then scale # Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
@@ -489,9 +577,11 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
# so its caption always fits on the SAME slide and no image is untitled. # 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 # 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. # 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_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 if caption else []
cap_reserve = cap_real + 0.05 + 0.10 cap_real = (tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05) \
if cap_lines else 0.0
cap_reserve = (cap_real + 0.05 + 0.10) if cap_lines else 0.05
max_h = _CONTENT_BOTTOM - _CONTENT_TOP max_h = _CONTENT_BOTTOM - _CONTENT_TOP
# height_in hint (model.Figure/Image): cap the target height so a figure in a # 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. # keep-together Group shrinks to leave room for its heading and text.
@@ -510,7 +600,8 @@ def _place_picture_bytes(st: _PptxState, data: bytes, caption,
st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y), st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
width=Inches(target_w), height=Inches(target_h)) width=Inches(target_w), height=Inches(target_h))
st.y += target_h + 0.05 st.y += target_h + 0.05
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True) if cap_lines:
_add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
st.y += _GAP st.y += _GAP
@@ -552,9 +643,11 @@ def _place_note(st: _PptxState, block) -> None:
# WITHOUT drawing it so a Group can move whole to the next slide before drawing. # 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. # Over-estimating only triggers an earlier slide break, never a content cut.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _measure_heading_text(text: str, level: int) -> float: def _measure_heading_text(text: str, level: int, size_pt=None) -> float:
level = max(1, min(3, int(level or 1))) level = max(1, min(3, int(level or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level] fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
if isinstance(size_pt, (int, float)) and size_pt > 0:
fs = float(size_pt)
lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs)) 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 return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04
@@ -654,12 +747,16 @@ def _measure_kv_table(block) -> float:
def _measure_data_table(block) -> float: def _measure_data_table(block) -> float:
"""Faithful DataTable height — matches ``_place_data_table`` (title heading + """Faithful DataTable height — matches ``_place_data_table`` (title heading +
wrapped header + every wrapped row + optional note). Keep in sync.""" wrapped header + every wrapped row + optional note). Keep in sync."""
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
# Mirror the placer: a too-wide table is drawn as one image, so its
# keep-together height is the image's, not the (squeezed) table layout's.
if not _table_fits_as_text(header, rows):
return _measure_figure_like(_table_figure_block(block))
h = 0.0 h = 0.0
title = getattr(block, "title", None) title = getattr(block, "title", None)
if title: if title:
h += _measure_heading_text(title, 2) h += _measure_heading_text(title, 2)
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
fs = _FS_CELL fs = _FS_CELL
widths = _col_widths(header, rows) widths = _col_widths(header, rows)
if header: if header:
@@ -679,7 +776,8 @@ def _measure_block(st: _PptxState, block) -> float:
try: try:
if kind == "heading": if kind == "heading":
return _measure_heading_text(getattr(block, "text", ""), return _measure_heading_text(getattr(block, "text", ""),
getattr(block, "level", 1)) getattr(block, "level", 1),
size_pt=getattr(block, "size_pt", None))
if kind == "markdown": if kind == "markdown":
return _measure_markdown(block) return _measure_markdown(block)
if kind in ("figure", "image"): if kind in ("figure", "image"):
@@ -688,6 +786,10 @@ def _measure_block(st: _PptxState, block) -> float:
lines = tl.wrap(getattr(block, "text", ""), lines = tl.wrap(getattr(block, "text", ""),
tl.chars_per_line(_USABLE_W, _FS_NOTE)) tl.chars_per_line(_USABLE_W, _FS_NOTE))
return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
if kind == "toc_entry":
lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY)) or [""]
return tl.line_height_in(_FS_BODY) * len(lines) + 0.05
if kind == "kv_table": if kind == "kv_table":
return _measure_kv_table(block) return _measure_kv_table(block)
if kind == "data_table": if kind == "data_table":
@@ -800,6 +902,73 @@ def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
return out return out
def _fit_img(width_col: float, aspect: float, max_h: float):
"""Scale an image to ``width_col`` then clamp to ``max_h`` keeping aspect."""
w = width_col
h = w * aspect
if h > max_h:
h = max_h
w = (h / aspect) if aspect else width_col
return w, h
def _place_group_side_by_side(st: _PptxState, block, avail_full: float) -> bool:
"""Place a Group's table (left ~55%) next to its figure (right ~45%).
Both the table and the figure are rasterized to high-res images and placed in
two columns of the SAME slide; any other blocks (e.g. a heading) render full
width above the pair, the rest below. Returns True on success; returns False
(so the caller falls back to stacking) when the group has no table+figure pair
or the pair cannot fit side by side on one slide. Never raises by itself."""
blocks = getattr(block, "blocks", []) or []
tbl = next((b for b in blocks
if getattr(b, "kind", "") in ("data_table", "kv_table")), None)
fig = next((b for b in blocks
if getattr(b, "kind", "") in ("figure", "image")), None)
if tbl is None or fig is None:
return False
gap_col = 0.3
left_w = _USABLE_W * 0.55 - gap_col / 2.0
right_w = _USABLE_W * 0.45 - gap_col / 2.0
if left_w <= 1.0 or right_w <= 1.0:
return False
tdata, tasp = _figure_bytes_cached(_table_figure_block(tbl))
fdata, fasp = _figure_bytes_cached(fig)
if not tdata or not fdata:
return False
ti, fi = blocks.index(tbl), blocks.index(fig)
lo = min(ti, fi)
lead = list(blocks[:lo])
rest = [b for b in blocks[lo + 1:] if b is not tbl and b is not fig]
lead_h = sum(_measure_block(st, b) for b in lead)
rest_h = sum(_measure_block(st, b) for b in rest)
col_max_h = avail_full - lead_h - rest_h - _GAP * 2
if col_max_h < 1.2:
return False # not enough vertical room to put the pair side by side.
tw, th = _fit_img(left_w, tasp, col_max_h)
fw, fh = _fit_img(right_w, fasp, col_max_h)
band = max(th, fh)
needed = lead_h + band + rest_h + _GAP * 2
if needed > avail_full:
return False # taller than a whole slide even side by side → stack.
if needed > _remaining(st):
_new_slide(st, cont=True)
for b in lead:
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
top = st.y
f_left = _ML + left_w + gap_col
st.slide.shapes.add_picture(
io.BytesIO(tdata), Inches(_ML + (left_w - tw) / 2.0),
Inches(top + (band - th) / 2.0), width=Inches(tw), height=Inches(th))
st.slide.shapes.add_picture(
io.BytesIO(fdata), Inches(f_left + (right_w - fw) / 2.0),
Inches(top + (band - fh) / 2.0), width=Inches(fw), height=Inches(fh))
st.y = top + band + _GAP
for b in rest:
_PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
return True
def _place_group(st: _PptxState, block) -> None: def _place_group(st: _PptxState, block) -> None:
"""Render a keep-together Group: move it whole to the next slide if needed.""" """Render a keep-together Group: move it whole to the next slide if needed."""
blocks = getattr(block, "blocks", []) or [] blocks = getattr(block, "blocks", []) or []
@@ -810,6 +979,14 @@ def _place_group(st: _PptxState, block) -> None:
if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6: if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
_new_slide(st, cont=True) _new_slide(st, cont=True)
avail_full = _CONTENT_BOTTOM - _CONTENT_TOP avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
# layout="side_by_side": try table-left / figure-right on one slide; on any
# reason it can't, fall through to the normal stacked keep-together below.
if str(getattr(block, "layout", "stack")).lower() == "side_by_side":
try:
if _place_group_side_by_side(st, block, avail_full):
return
except Exception: # noqa: BLE001 — degrade to stacking, never abort.
pass
# Trim oversized tables first (keeps the chart on the same slide), then shrink # Trim oversized tables first (keeps the chart on the same slide), then shrink
# the figure to share the remaining room. # the figure to share the remaining room.
blocks = _fit_group_blocks(st, blocks, avail_full) blocks = _fit_group_blocks(st, blocks, avail_full)
@@ -843,6 +1020,44 @@ def _place_glossary_entry(st: _PptxState, block) -> None:
st.y += _GAP st.y += _GAP
def _place_toc_entry(st: _PptxState, block) -> None:
"""Render one clickable index line and record its run as a link source.
Drawn as a bulleted line in the accent link colour; the run is recorded in
``st.toc_runs`` so it later becomes a native slide-jump to the target chapter's
first slide. If the target is never resolved the line still shows as plain
(accent) text never cut."""
label = tl.strip_inline_md(getattr(block, "label", "")) or ""
target_id = getattr(block, "target_id", "") or ""
fs = _FS_BODY
lines = tl.wrap(label, tl.chars_per_line(_USABLE_W - 0.3, fs)) or [""]
lh = tl.line_height_in(fs)
height = lh * len(lines) + 0.05
_ensure(st, height)
box = st.slide.shapes.add_textbox(
Inches(_ML), Inches(st.y), Inches(_USABLE_W), Inches(height))
tf = box.text_frame
tf.word_wrap = True
first = True
link_run = None
for idx, ln in enumerate(lines):
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
r0 = p.add_run()
r0.text = "" if idx == 0 else " "
r0.font.size = Pt(fs)
r0.font.color.rgb = _rgb(_LINK)
run = p.add_run()
run.text = ln
run.font.size = Pt(fs)
run.font.color.rgb = _rgb(_LINK)
if idx == 0:
link_run = run
if target_id and link_run is not None:
st.toc_runs.append((target_id, link_run, st.slide))
st.y += height
_PLACERS = { _PLACERS = {
"heading": _place_heading, "heading": _place_heading,
"markdown": _place_markdown, "markdown": _place_markdown,
@@ -854,6 +1069,7 @@ _PLACERS = {
"note": _place_note, "note": _place_note,
"group": _place_group, "group": _place_group,
"glossary_entry": _place_glossary_entry, "glossary_entry": _place_glossary_entry,
"toc_entry": _place_toc_entry,
} }
@@ -889,6 +1105,12 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
st.chapter = ch st.chapter = ch
st.chapter_slides = 0 st.chapter_slides = 0
_new_slide(st, cont=False) _new_slide(st, cont=False)
# Record this chapter's first slide as a link target for the cover
# index (keyed by id AND title, since the cover only knows titles).
if ch.id:
st.chapter_starts[ch.id] = st.slide
if getattr(ch, "title", ""):
st.chapter_starts.setdefault(ch.title, st.slide)
for block in ch.blocks: for block in ch.blocks:
placer = _PLACERS.get(getattr(block, "kind", ""), _place_note) placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
try: try:
@@ -916,7 +1138,7 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
note = f"{n_slides} slides" note = f"{n_slides} slides"
if n_links: if n_links:
note += f" · {n_links} enlaces de glosario" note += f" · {n_links} enlaces internos"
if notes: if notes:
note += " · " + "; ".join(notes) note += " · " + "; ".join(notes)
return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta, return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
@@ -924,19 +1146,21 @@ def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
def _wire_glossary_links(st: _PptxState, notes: list) -> int: def _wire_glossary_links(st: _PptxState, notes: list) -> int:
"""Turn each recorded term run into a native jump to its glossary slide. """Apply native slide-jumps: glossary terms + the cover index.
Returns the number of links applied. A term whose only appearance is inside Each in-text glossary term run jumps to its glossary entry slide, and each
its own glossary entry (source slide == target slide) is skipped. Never cover ``TocEntry`` run jumps to its chapter's first slide. Returns the total
number of links applied. A run whose target is its own slide is skipped. Never
raises.""" raises."""
if not st.term_runs or not st.term_anchor_slide: if not (st.term_runs and st.term_anchor_slide) and not (
st.toc_runs and st.chapter_starts):
return 0 return 0
linked = 0
try: try:
from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
notes.append(f"glosario sin enlaces: {e}") notes.append(f"enlaces internos no aplicados: {e}")
return 0 return 0
linked = 0
for key, run, src_slide in st.term_runs: for key, run, src_slide in st.term_runs:
tgt = st.term_anchor_slide.get(key) tgt = st.term_anchor_slide.get(key)
if tgt is None or tgt is src_slide: if tgt is None or tgt is src_slide:
@@ -946,4 +1170,14 @@ def _wire_glossary_links(st: _PptxState, notes: list) -> int:
linked += 1 linked += 1
except Exception: # noqa: BLE001 — links are best-effort. except Exception: # noqa: BLE001 — links are best-effort.
pass pass
# Cover index → chapter first slide (clickable, navigable table of contents).
for target_id, run, src_slide in st.toc_runs:
tgt = st.chapter_starts.get(target_id)
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 return linked
@@ -0,0 +1,283 @@
"""Golden tests for the global render-quality features (issue: eda-render-quality).
Covers, with executable evidence:
* High DPI: every embedded figure is rasterized at 220 dpi, so a phone reader
can zoom in and still see crisp detail.
* Wide table image: a table too wide to be legible as text (e.g. a 19-column
df.head) is rendered as one high-res image that scales to fit entirely, while
a narrow table keeps its selectable-text/native-table rendering.
* ``Group(layout="side_by_side")``: in PPTX the table and figure are placed in
two columns of the same slide; in PDF the same group stacks vertically.
* Backward compatibility: a Group without ``layout`` defaults to ``"stack"`` and
a fitting table renders exactly as before.
Renderers are invoked for real; PDFs are inspected with PyMuPDF and PPTX decks
with python-pptx.
"""
from __future__ import annotations
import os
import tempfile
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import pytest # noqa: E402
from datascience.automatic_eda import model # noqa: E402
from datascience.automatic_eda.render_pdf_impl import ( # noqa: E402
render_pdf, _RASTER_DPI as _PDF_DPI, _table_fits_as_text as _pdf_fits)
from datascience.automatic_eda.render_pptx_impl import ( # noqa: E402
render_pptx, _RASTER_DPI as _PPTX_DPI, _table_fits_as_text as _pptx_fits)
# --------------------------------------------------------------------------- #
# Helpers.
# --------------------------------------------------------------------------- #
def _simple_fig():
"""A small, real matplotlib figure for the figure blocks."""
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot([0, 1, 2, 3], [1, 3, 2, 4])
ax.set_title("demo")
return fig
def _wide_table(n_cols=19, n_rows=5):
header = [f"columna_{i}" for i in range(n_cols)]
rows = [[f"v{r}_{c}" for c in range(n_cols)] for r in range(n_rows)]
return model.DataTable(header=header, rows=rows, title="Primeras filas")
def _narrow_table():
return model.DataTable(header=["a", "b", "c"],
rows=[["1", "2", "3"], ["4", "5", "6"]],
title="Tabla estrecha")
def _chapter(blocks, cid="cap", title="Capítulo"):
return [model.Chapter(id=cid, title=title, version="1.0.0", blocks=blocks)]
# --------------------------------------------------------------------------- #
# 1) High DPI — the unit constant and a real embedded image.
# --------------------------------------------------------------------------- #
def test_raster_dpi_is_high_both_renderers():
assert _PDF_DPI >= 200, "el DPI del PDF debe ser alto (>=200)"
assert _PPTX_DPI >= 200, "el DPI del PPTX debe ser alto (>=200)"
def test_pdf_embedded_figure_is_high_resolution(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "fig.pdf")
res = render_pdf(_chapter([model.Figure(make=_simple_fig, caption="demo")]),
out, {"title": "T"})
assert res["path"] == out
doc = fitz.open(out)
try:
widths = []
for page in doc:
for img in page.get_images(full=True):
xref = img[0]
info = doc.extract_image(xref)
widths.append(info.get("width", 0))
assert widths, "no se incrustó ninguna imagen en el PDF"
# A ~4" figure rasterized at 220 dpi is ~ >850 px wide. At the old 150 dpi
# it would be ~600 px. The high-res threshold proves the DPI bump.
assert max(widths) >= 800, \
f"la figura embebida no es de alta resolución: {max(widths)} px"
finally:
doc.close()
# --------------------------------------------------------------------------- #
# 2) Wide table → image (PDF and PPTX); narrow table stays text.
# --------------------------------------------------------------------------- #
def test_fit_criterion_flags_wide_and_keeps_narrow():
wide = _wide_table()
narrow = _narrow_table()
assert not _pdf_fits(wide.header, wide.rows), \
"una tabla de 19 columnas debería NO caber como texto en A5"
assert not _pptx_fits(wide.header, wide.rows), \
"una tabla de 19 columnas debería NO caber como tabla nativa en 16:9"
assert _pdf_fits(narrow.header, narrow.rows), \
"una tabla de 3 columnas debería caber como texto en A5"
assert _pptx_fits(narrow.header, narrow.rows), \
"una tabla de 3 columnas debería caber como tabla nativa en 16:9"
def test_wide_table_rendered_as_image_pdf(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "wide.pdf")
res = render_pdf(_chapter([_wide_table()]), out, {"title": "T"})
assert res["path"] == out
doc = fitz.open(out)
try:
n_images = sum(len(page.get_images(full=True)) for page in doc)
text = "".join(page.get_text() for page in doc)
finally:
doc.close()
assert n_images >= 1, "la tabla ancha no se rasterizó como imagen en el PDF"
# The cells are now inside the image, not selectable text. A unique cell value
# must therefore NOT appear as extractable text (it lives in the picture).
assert "v4_18" not in text, \
"la tabla ancha sigue como texto seleccionable (no se hizo imagen)"
def test_narrow_table_stays_selectable_text_pdf(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "narrow.pdf")
render_pdf(_chapter([_narrow_table()]), out, {"title": "T"})
doc = fitz.open(out)
try:
text = "".join(page.get_text() for page in doc)
finally:
doc.close()
# Narrow table is selectable text: its header/cells are extractable.
for v in ("a", "b", "c", "1", "6"):
assert v in text, f"la celda '{v}' debería ser texto seleccionable"
def test_wide_table_rendered_as_picture_pptx(tmp_path):
pptx = pytest.importorskip("pptx")
from pptx.enum.shapes import MSO_SHAPE_TYPE
out = str(tmp_path / "wide.pptx")
res = render_pptx(_chapter([_wide_table()]), out, {"title": "T"})
assert res["path"] == out
prs = pptx.Presentation(out)
pics = sum(1 for s in prs.slides for sh in s.shapes
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
assert pics >= 1, "la tabla ancha no se colocó como imagen en el PPTX"
# --------------------------------------------------------------------------- #
# 3) Group(layout="side_by_side"): two columns in PPTX, stacked in PDF.
# --------------------------------------------------------------------------- #
def _side_by_side_group():
return model.Group(
blocks=[model.Heading(text="Columna X", level=2),
_narrow_table(),
model.Figure(make=_simple_fig, caption="grafico")],
layout="side_by_side")
def test_side_by_side_places_two_columns_pptx(tmp_path):
pptx = pytest.importorskip("pptx")
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.util import Inches
out = str(tmp_path / "sbs.pptx")
render_pptx(_chapter([_side_by_side_group()]), out, {"title": "T"})
prs = pptx.Presentation(out)
# Find the slide that holds the pair (table image + figure image).
centre_emu = int(Inches(13.333 / 2.0))
placed = False
for s in prs.slides:
lefts = [sh.left for sh in s.shapes
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE
and sh.left is not None]
if len(lefts) >= 2:
# one picture starts in the left half, another in the right half.
if min(lefts) < centre_emu and max(lefts) > centre_emu:
placed = True
break
assert placed, \
"side_by_side no colocó tabla y figura en dos columnas de la misma slide"
def test_side_by_side_stacks_in_pdf(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "sbs.pdf")
res = render_pdf(_chapter([_side_by_side_group()]), out, {"title": "T"})
assert res["path"] == out and res["n_pages"] >= 1
doc = fitz.open(out)
try:
n_images = sum(len(page.get_images(full=True)) for page in doc)
text = "".join(page.get_text() for page in doc)
finally:
doc.close()
# PDF stacks: the narrow table stays selectable text (1 of its cells is
# extractable) and the figure is the single embedded image — not a 2-column
# pair of pictures like PPTX.
assert n_images == 1, "el PDF no debería usar el layout de dos imágenes"
assert "Columna X" in text and "1" in text, \
"la tabla del grupo debería seguir como texto apilado en el PDF"
# --------------------------------------------------------------------------- #
# 4) Backward compatibility — default layout stacks, fitting table unchanged.
# --------------------------------------------------------------------------- #
def test_group_default_layout_is_stack():
g = model.Group(blocks=[_narrow_table()])
assert g.layout == "stack", "el layout por defecto debe ser 'stack'"
# --------------------------------------------------------------------------- #
# 5) Clickable cover index ("Índice") → chapter first page/slide.
# --------------------------------------------------------------------------- #
def _doc_with_index():
portada = model.Chapter(id="portada", title="Portada", version="1.0.0",
blocks=[model.Heading(text="Índice", level=2),
model.TocEntry(label="Distribuciones",
target_id="Distribuciones")])
cap = model.Chapter(id="num", title="Distribuciones", version="1.0.0",
blocks=[model.Markdown(text="contenido del capítulo")])
return [portada, cap]
def test_cover_index_is_clickable_pdf(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "idx.pdf")
res = render_pdf(_doc_with_index(), out, {"title": "T"})
assert res["path"] == out
doc = fitz.open(out)
try:
# The cover (page 0) must carry a GOTO link jumping to a later page.
goto = [lk for lk in doc[0].get_links()
if lk.get("kind") == fitz.LINK_GOTO and lk.get("page", 0) > 0]
finally:
doc.close()
assert goto, "el índice de la portada no produjo enlaces clicables en el PDF"
def test_cover_index_shows_heading_pdf(tmp_path):
fitz = pytest.importorskip("fitz")
out = str(tmp_path / "idxh.pdf")
render_pdf(_doc_with_index(), out, {"title": "T"})
doc = fitz.open(out)
try:
text = "".join(page.get_text() for page in doc)
finally:
doc.close()
assert "Índice" in text, "la portada no muestra el encabezado 'Índice'"
assert "Este informe incluye" not in text, \
"la portada aún muestra el texto antiguo 'Este informe incluye'"
def test_cover_index_is_clickable_pptx(tmp_path):
pptx = pytest.importorskip("pptx")
out = str(tmp_path / "idx.pptx")
render_pptx(_doc_with_index(), out, {"title": "T"})
prs = pptx.Presentation(out)
cover_xml = prs.slides[0]._element.xml
assert "hlinksldjump" in cover_xml, \
"el índice de la portada no produjo un salto de slide nativo en el PPTX"
def test_default_group_renders_like_before_pptx(tmp_path):
pptx = pytest.importorskip("pptx")
from pptx.enum.shapes import MSO_SHAPE_TYPE
out = str(tmp_path / "stack.pptx")
grp = model.Group(blocks=[model.Heading(text="Y", level=2),
_narrow_table(),
model.Figure(make=_simple_fig, caption="g")])
render_pptx(_chapter([grp]), out, {"title": "T"})
prs = pptx.Presentation(out)
# Stacked group: the narrow table is a NATIVE table (selectable), and there is
# exactly one picture (the figure) — not the two-image side-by-side layout.
n_tables = sum(1 for s in prs.slides for sh in s.shapes if sh.has_table)
n_pics = sum(1 for s in prs.slides for sh in s.shapes
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE)
assert n_tables >= 1, "el grupo apilado debería usar una tabla nativa"
assert n_pics == 1, "el grupo apilado no debería duplicar imágenes"
@@ -0,0 +1,111 @@
---
id: categorical_top_bar_figure_py_datascience
name: categorical_top_bar_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def categorical_top_bar_figure(top: list, n_distinct: int = 0, title: str = \"\", top_k: int = 6, n_rows=None) -> \"matplotlib.figure.Figure\""
description: "Construye una figura matplotlib de barras horizontales de las top_k categorías más frecuentes de una columna categórica, con la mayor arriba y agregando el resto en una barra gris \"Otros (N categorías)\". Contrato de entrada idéntico a categorical_top_pie_figure (swap directo donut↔barras): consume el bloque `top` de summarize_categorical y devuelve un matplotlib.figure.Figure listo para rasterizar por el renderer del informe EDA. Backend Agg sin pyplot global; defensivo total ante top vacío/None, nunca lanza."
tags: [eda, categorical, bar, barh, matplotlib, figure, visualization, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib]
example: |
from categorical_top_bar_figure import categorical_top_bar_figure
top = [
{"value": "rojo", "count": 40, "pct": 0.4},
{"value": "azul", "count": 30, "pct": 0.3},
{"value": "verde", "count": 20, "pct": 0.2},
]
fig = categorical_top_bar_figure(top, n_distinct=12, title="color", top_k=6, n_rows=100)
tested: true
tests:
- "test_returns_figure"
- "test_ten_items_topk_six_yields_seven_bars"
- "test_empty_top_does_not_raise_and_returns_figure"
- "test_long_value_truncated"
- "test_none_value_and_none_count_are_handled"
- "test_n_rows_adds_exact_others_bar"
test_file_path: "python/functions/datascience/categorical_top_bar_figure_test.py"
file_path: "python/functions/datascience/categorical_top_bar_figure.py"
params:
- name: top
desc: "Lista de dicts {value, count, pct} ordenada de mayor a menor por count (salida del bloque `top` de summarize_categorical). Puede venir vacía o con dicts incompletos: items no-dict, sin count, con count None o count <= 0 se descartan. value None se admite (etiqueta vacía)."
- name: n_distinct
desc: "Nº total de categorías distintas de la columna. Etiqueta la barra agregada como \"Otros (n_distinct - top_k)\" (mínimo 0). Si no supera el nº de barras mostradas, se usa el overflow real de `top` como nº de categorías agregadas. Default 0."
- name: title
desc: "Título de la figura (nombre de la columna). Se trunca a ~48 chars con elipsis si es muy largo. Default \"\" (sin título)."
- name: top_k
desc: "Nº máximo de barras explícitas. Default 6. La barra \"Otros\" no cuenta contra este límite. Con top_k <= 0 se muestra al menos la categoría mayor."
- name: n_rows
desc: "Opcional. Total de filas del dataset. Si se da y la suma de counts mostrados < n_rows, la barra \"Otros\" usa (n_rows - suma_mostrada) como count para que sea exacta respecto al total real. Si se omite, \"Otros\" usa la suma de counts fuera del top_k mostrado (solo cuando top trae más de top_k items). Default None."
output: "Un matplotlib.figure.Figure (figsize 6.4 x altura escalada con el nº de barras, dpi 150) con un Axes de barras horizontales: la categoría más frecuente arriba, la barra gris \"Otros (N categorías)\" abajo, cada barra anotada con su conteo y porcentaje al final y etiquetas de categoría (yticklabels) truncadas a ~22 chars. Si no hay counts válidos devuelve igualmente una Figure con un texto centrado \"sin datos categóricos\" (nunca lanza); cualquier error inesperado cae a una Figure con el texto del error. El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
---
## Ejemplo
```python
from categorical_top_bar_figure import categorical_top_bar_figure
# `top` es la salida del bloque "top" de summarize_categorical (ya ordenado desc).
top = [
{"value": "rojo", "count": 40, "pct": 0.40},
{"value": "azul", "count": 30, "pct": 0.30},
{"value": "verde", "count": 20, "pct": 0.20},
{"value": "amarillo", "count": 5, "pct": 0.05},
]
fig = categorical_top_bar_figure(
top,
n_distinct=12, # 12 categorías distintas en total
title="color_producto",
top_k=6, # hasta 6 barras explícitas
n_rows=100, # "Otros" = 100 - 95 = 5, sobre 8 categorías agregadas
)
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/barras_color.png")
```
## Cuando usarla
Úsala dentro de un informe EDA cuando quieras comparar **magnitudes** de las
categorías dominantes de una columna categórica: qué categoría manda y por
cuánto frente a las siguientes. Pásale directamente el bloque `top` de
`summarize_categorical` (ya ordenado de mayor a menor) más `n_distinct` para que
la barra "Otros" indique cuántas categorías quedan agrupadas. Es el clon "de
barras" del donut `categorical_top_pie_figure` con **contrato de entrada
idéntico**: puedes intercambiar una por otra sin tocar el caller. Elige barras
cuando importe comparar tamaños exactos; el donut cuando importe la proporción
del total.
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función evita ese riesgo construyendo el `Figure`
directamente, así que es segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** La función devuelve el `Figure` pero no lo
muestra ni lo guarda. Quien la consume debe rasterizarla y luego liberarla
(`fig.clf()` / `matplotlib.pyplot.close(fig)` si se usó pyplot en el caller)
para no acumular memoria en lotes grandes de columnas.
- **`barh` dibuja de abajo arriba.** La categoría más frecuente va arriba porque
el orden de display se invierte antes de plotear; la barra "Otros" queda
siempre al fondo. No reordenes `top` esperando otro layout: la función asume
que ya viene ordenado desc por count.
- **Magnitud exacta de "Otros" solo con `n_rows`.** Sin `n_rows`, la barra
"Otros" se calcula con el overflow presente en `top`; si `top` ya viene
recortado a `top_k` por el productor, no habrá "Otros" aunque existan más
categorías. Pasa `n_rows` (total de filas del dataset) para una barra correcta
respecto al total real.
- **Defensiva, nunca lanza.** `top=[]`, `value=None`, `count=None` o counts no
numéricos se manejan sin error: en el peor caso devuelve una `Figure` con
"sin datos categóricos", y cualquier excepción inesperada cae a una `Figure`
con el texto del error. No envuelvas la llamada en try/except por miedo a un
raise — no lo hay.
@@ -0,0 +1,233 @@
"""Impure EDA helper: horizontal bar figure of the most common categories (`eda` group).
Builds a horizontal bar chart of the ``top_k`` most frequent categories of a
categorical column, folding everything else into a single gray
"Otros (N categorías)" bar. The most frequent category sits at the top, each bar
labelled with its count (and percentage) at the end. Returns a ready-to-rasterize
``matplotlib.figure.Figure``; it never shows nor saves it.
This is the "magnitude" twin of ``categorical_top_pie_figure``: identical input
contract (same ``top``/``n_distinct``/``title``/``top_k``/``n_rows`` signature) so
it can be swapped in directly, but it communicates comparable magnitudes via bars
instead of proportions via wedges.
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer.
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure # noqa: E402
# Gray reserved for the aggregated "Otros" bar.
_OTHER_COLOR = "#9e9e9e"
# Muted gray for secondary text (title fallback, no-data message).
_MUTED_TEXT = "#5f6b7a"
# Soft red for the error fallback message.
_ERROR_TEXT = "#b00020"
# Pleasant, colour-blind-friendly qualitative palette for the explicit bars.
_PALETTE = [
"#4C72B0",
"#DD8452",
"#55A868",
"#C44E52",
"#8172B3",
"#937860",
"#DA8BC3",
"#8C8C8C",
"#CCB974",
"#64B5CD",
]
def _truncate(text, width: int = 22) -> str:
"""Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
s = "" if text is None else str(text)
if len(s) <= width:
return s
if width <= 1:
return s[:width]
return s[: width - 1] + ""
def _message_figure(message: str, color: str = _MUTED_TEXT, title: str = "") -> "Figure":
"""Return a fallback ``Figure`` carrying a single centered message."""
fig = Figure(figsize=(6.4, 4.0), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=color,
wrap=True,
transform=ax.transAxes,
)
if title:
ax.set_title(_truncate(title, 48), fontsize=12, loc="center", pad=8)
fig.tight_layout()
return fig
def categorical_top_bar_figure(
top: list,
n_distinct: int = 0,
title: str = "",
top_k: int = 6,
n_rows=None,
) -> "matplotlib.figure.Figure":
"""Build a horizontal bar figure of the most common categories of a column.
Renders the ``top_k`` most frequent categories as explicit horizontal bars,
largest at the top, and aggregates every remaining category into a single
gray "Otros (N categorías)" bar at the bottom. Each bar is annotated with its
count and percentage of the total at the end of the bar; the category names
are truncated Y tick labels.
The function shares the exact input contract of
``categorical_top_pie_figure`` (the donut twin) so it is a drop-in swap. It is
fully defensive: empty input, missing/``None`` values or counts never raise.
When there is nothing valid to draw it still returns a ``Figure`` carrying a
centered "sin datos categóricos" message, and any unexpected error is caught
and turned into a fallback ``Figure`` carrying the error text.
Args:
top: List of ``{value, count, pct}`` dicts, already sorted by ``count``
descending (the ``top`` block of ``summarize_categorical``). May be
empty or carry incomplete/``None`` entries; non-dict items, items
without a positive numeric ``count`` and ``None`` counts are skipped.
n_distinct: Total number of distinct categories in the column. Used to
label the aggregated bar as "Otros (n_distinct - top_k)" (floored at
0). Ignored when it does not exceed the number of shown bars.
title: Figure title (the column name). Truncated when too long.
top_k: Maximum number of explicit bars. Default 6. The "Otros" bar does
not count against this limit.
n_rows: Optional total row count of the dataset. When given and the sum of
shown counts is below ``n_rows``, the "Otros" bar uses
``n_rows - sum_shown`` as its count so it is exact with respect to the
real total. When omitted, "Otros" uses the sum of the counts that fall
outside the shown ``top_k`` (only when ``top`` carries more than
``top_k`` items).
Returns:
A ``matplotlib.figure.Figure`` with a single horizontal-bar Axes. The
caller is responsible for rasterizing/closing it.
"""
try:
safe_title = _truncate(title, 48)
# --- Defensive parse: keep only well-formed {value, count} with count > 0.
cleaned = []
if isinstance(top, list):
for item in top:
if not isinstance(item, dict):
continue
count = item.get("count")
if count is None:
continue
try:
count = float(count)
except (TypeError, ValueError):
continue
if count <= 0:
continue
cleaned.append((item.get("value"), count))
if not cleaned:
return _message_figure("sin datos categóricos", title=title)
# --- Split into shown bars and the aggregated remainder.
shown = cleaned[: max(int(top_k), 0)]
if not shown: # top_k <= 0 — show at least the largest category.
shown = cleaned[:1]
sum_shown = sum(c for _, c in shown)
overflow_count = sum(c for _, c in cleaned[len(shown):])
# How many categories are folded into "Otros".
try:
nd = int(n_distinct)
except (TypeError, ValueError):
nd = 0
others_categories = max(nd - len(shown), 0)
# If n_distinct is unknown/too small, fall back to the overflow we
# actually have in `top` beyond the shown bars.
overflow_items = len(cleaned) - len(shown)
if others_categories == 0 and overflow_items > 0:
others_categories = overflow_items
# Count attributed to the "Otros" bar.
others_count = 0.0
if n_rows is not None:
try:
total_rows = float(n_rows)
except (TypeError, ValueError):
total_rows = None
if total_rows is not None and total_rows > sum_shown:
others_count = total_rows - sum_shown
if others_count <= 0:
others_count = overflow_count
# --- Build the display order (top to bottom): largest .. smallest, Otros.
display_labels = [_truncate(v, 22) for v, _ in shown]
display_values = [c for _, c in shown]
display_colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(shown))]
has_others = others_count > 0 and others_categories > 0
if has_others:
display_labels.append(f"Otros ({others_categories} categorías)")
display_values.append(others_count)
display_colors.append(_OTHER_COLOR)
total = sum(display_values) or 1.0
# barh draws bottom-up, so reverse the display order before plotting to
# land the largest category on top and "Otros" at the bottom.
labels = list(reversed(display_labels))
values = list(reversed(display_values))
colors = list(reversed(display_colors))
y_pos = range(len(values))
# Height scales with the number of bars so dense reports stay readable.
n_bars = len(values)
height = max(2.4, min(0.4 * n_bars + 1.2, 14.0))
fig = Figure(figsize=(6.4, height), dpi=150)
ax = fig.add_subplot(111)
ax.barh(list(y_pos), values, color=colors, edgecolor="white")
ax.set_yticks(list(y_pos))
ax.set_yticklabels(labels, fontsize=8)
ax.set_xlabel("conteo", fontsize=9)
max_val = max(values) if values else 1.0
ax.set_xlim(0, max_val * 1.18 if max_val > 0 else 1.0)
# Annotate each bar with its count and percentage at the end of the bar.
for y, val in zip(y_pos, values):
pct = val / total * 100.0
ax.text(
val + max_val * 0.012,
y,
f"{int(round(val))} ({pct:.0f}%)",
va="center",
ha="left",
fontsize=7,
color="#202020",
)
if safe_title:
ax.set_title(safe_title, fontsize=13, loc="left", pad=10)
fig.tight_layout()
return fig
except Exception as exc: # noqa: BLE001 — never raise from a figure builder.
return _message_figure(
f"error al dibujar barras: {exc}", color=_ERROR_TEXT
)
@@ -0,0 +1,103 @@
"""Tests para categorical_top_bar_figure (barras de categorías top, grupo eda).
Usa el backend Agg sin pyplot; no muestra ni guarda figuras. Cada test cierra
explícitamente la Figure construida (matplotlib.pyplot.close) para no acumular
estado entre tests.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from categorical_top_bar_figure import categorical_top_bar_figure
def _make_top(n):
"""n items {value, count, pct} ordenados desc por count."""
return [
{"value": f"cat_{i}", "count": n - i, "pct": (n - i) / sum(range(1, n + 1))}
for i in range(n)
]
def _bar_count(ax):
"""Devuelve el nº de barras (longitud del primer BarContainer del Axes)."""
if ax.containers:
return len(ax.containers[0])
return 0
def test_returns_figure():
fig = categorical_top_bar_figure(_make_top(3), n_distinct=3, title="col")
assert isinstance(fig, Figure)
plt.close(fig)
def test_ten_items_topk_six_yields_seven_bars():
top = _make_top(10)
fig = categorical_top_bar_figure(top, n_distinct=10, title="muchas", top_k=6)
ax = fig.axes[0]
# 6 categorías explícitas + 1 barra "Otros".
assert _bar_count(ax) == 7
plt.close(fig)
def test_empty_top_does_not_raise_and_returns_figure():
fig = categorical_top_bar_figure([], n_distinct=0, title="vacía")
assert isinstance(fig, Figure)
# Sin datos: no debe haber barras.
assert _bar_count(fig.axes[0]) == 0
plt.close(fig)
def test_long_value_truncated():
long_value = "una_categoria_con_un_nombre_larguisimo_que_excede_el_limite"
top = [
{"value": long_value, "count": 10, "pct": 0.5},
{"value": "corta", "count": 10, "pct": 0.5},
]
fig = categorical_top_bar_figure(top, n_distinct=2, title="col", top_k=6)
ax = fig.axes[0]
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
# El valor largo aparece truncado con elipsis y NO en su forma completa.
assert any("" in t for t in tick_texts)
assert long_value not in " ".join(tick_texts)
plt.close(fig)
def test_none_value_and_none_count_are_handled():
top = [
{"value": None, "count": 5, "pct": 0.5},
{"value": "b", "count": None, "pct": 0.0}, # count None -> se descarta
{"value": "c", "count": 5, "pct": 0.5},
]
fig = categorical_top_bar_figure(top, n_distinct=2, title="con nones", top_k=6)
assert isinstance(fig, Figure)
# Solo 2 items válidos, sin overflow -> 2 barras, sin "Otros".
assert _bar_count(fig.axes[0]) == 2
plt.close(fig)
def test_n_rows_adds_exact_others_bar():
# 3 categorías mostradas suman 30, dataset real 100 -> "Otros" = 70.
top = [
{"value": "a", "count": 15, "pct": 0.15},
{"value": "b", "count": 10, "pct": 0.10},
{"value": "c", "count": 5, "pct": 0.05},
]
fig = categorical_top_bar_figure(
top, n_distinct=20, title="col", top_k=3, n_rows=100
)
ax = fig.axes[0]
# 3 explícitas + Otros.
assert _bar_count(ax) == 4
tick_texts = [t.get_text() for t in ax.get_yticklabels()]
# La barra Otros refleja n_distinct - top_k = 17 categorías.
assert any("Otros (17 categorías)" in t for t in tick_texts)
# Su anotación lleva el count 70.
annotation_texts = [t.get_text() for t in ax.texts]
assert any("70" in t for t in annotation_texts)
plt.close(fig)
@@ -0,0 +1,87 @@
---
name: confidence_interval_mean
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def confidence_interval_mean(data: list, other: list = None, confidence: float = 0.95) -> dict"
description: "Intervalo de confianza (IC) de la media de una muestra con la t de Student, o de la DIFERENCIA de medias de dos muestras independientes con el metodo de Welch (sin asumir varianzas iguales). Una muestra: df=n-1, se=sd_muestral/sqrt(n) (sd con ddof=1), tcrit=t.ppf((1+confidence)/2, df), ci=mean+/-tcrit*se. Dos muestras: IC de mean(data)-mean(other) con se=sqrt(se1^2+se2^2) y grados de libertad de Welch-Satterthwaite. Pura y robusta: nunca lanza; ante casos degenerados (muestra vacia, n<2) devuelve nan + clave note, y con varianza cero el IC colapsa al punto (no es error). Usa scipy.stats y numpy."
tags: [papers, statistics, confidence-interval, welch, t-test, python]
params:
- name: data
desc: "muestra de observaciones numericas (lista de numeros). Si other es None, el IC es el de la media de data."
- name: other
desc: "segunda muestra independiente (lista de numeros) o None (default). Si se da, el IC es el de la diferencia de medias mean(data)-mean(other) calculada con Welch (no asume varianzas iguales)."
- name: confidence
desc: "nivel de confianza en (0, 1); 0.95 = IC del 95% (default). El cuantil critico es t.ppf((1+confidence)/2, df)."
output: "dict {mean, ci_low, ci_high, se, df, confidence, n}. mean = media de data (una muestra) o la diferencia mean(data)-mean(other) (dos muestras). En el caso de dos muestras se anaden ademas n1 y n2 (y n = n1+n2). df son los grados de libertad de la t (Welch-Satterthwaite si dos muestras). Casos degenerados (muestra vacia, n<2) anaden la clave note y dejan ci_low/ci_high/se (y a veces df) en nan; con varianza cero y n>=2 el IC colapsa a [mean, mean] con se=0 (con note, sin nan). Nunca None ni excepcion."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [scipy, numpy]
tested: true
tests: ["test_one_sample_golden_contra_scipy", "test_one_sample_distinto_nivel_confianza", "test_welch_diferencia_golden_contra_scipy", "test_edge_un_solo_elemento_no_lanza_nan_note", "test_edge_lista_vacia_no_lanza_note", "test_edge_varianza_cero_colapsa_al_punto", "test_edge_welch_muestra_vacia_no_lanza_note", "test_edge_welch_n1_uno_no_lanza_note"]
test_file_path: "python/functions/datascience/confidence_interval_mean_test.py"
file_path: "python/functions/datascience/confidence_interval_mean.py"
---
## Ejemplo
```python
from datascience import confidence_interval_mean
# IC del 95% de la media de una muestra (t de Student).
data = [2, 4, 4, 4, 5, 5, 7, 9]
ci = confidence_interval_mean(data, confidence=0.95)
print(ci["mean"]) # -> 5.0
print(ci["df"]) # -> 7.0 (n - 1)
print(round(ci["ci_low"], 5), round(ci["ci_high"], 5))
# -> 3.21251 6.78749 (se con sd muestral ddof=1 ~ 2.13809)
# IC del 95% de la DIFERENCIA de medias (Welch, no asume varianzas iguales).
control = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
tratado = [18.0, 20.0, 17.0, 19.0, 21.0]
diff = confidence_interval_mean(control, tratado, confidence=0.95)
print(diff["mean"]) # -> 4.5 (mean(control) - mean(tratado))
print(round(diff["ci_low"], 4), round(diff["ci_high"], 4))
# Si el intervalo no incluye 0, la diferencia es significativa al 5%.
# Degenerados: nunca lanza.
print(confidence_interval_mean([5])["note"]) # n < 2: ... indefinidos
print(confidence_interval_mean([3, 3, 3])["se"]) # -> 0.0 (IC colapsa a [3, 3])
```
## Cuando usarla
Cuando quieras cuantificar la **incertidumbre de una media estimada** a partir de
una muestra: reporta `[ci_low, ci_high]` en vez de un punto suelto para mostrar
el rango plausible del valor real al nivel de confianza pedido. Usala tambien
para **comparar dos grupos** (A/B test, control vs tratamiento, antes vs
despues con grupos independientes): pasa las dos muestras y, si el IC de la
diferencia **no incluye el 0**, la diferencia es significativa al nivel
`1 - confidence`. Es el complemento del p-valor: ademas de "hay efecto", te dice
"de que tamano y con que margen". Para dos muestras usa Welch por defecto, asi
que no necesitas comprobar antes si las varianzas son iguales.
## Gotchas
- Pura y determinista (no hace I/O, no muta las entradas), pero **no** es
stdlib-only: depende de `scipy.stats` y `numpy` (ambos en el venv del proyecto).
- Con `other` usa **Welch** (df de Welch-Satterthwaite): NO asume varianzas
iguales ni tamanos de muestra iguales. Si necesitas el t-test clasico de
varianzas agrupadas (pooled), esta funcion no lo hace.
- `sd` se calcula con **ddof=1** (sd muestral), que es lo correcto para el IC de
una media con la t. Atajos como `sd_poblacional/sqrt(n)` (ddof=0) dan un
intervalo demasiado estrecho.
- En el caso de dos muestras, `mean` es la **diferencia** `mean(data) - mean(other)`
(no la media de data). El orden importa: el signo del IC depende de cual va
primero.
- Nunca lanza. Casos degenerados devuelven `nan` en `ci_low`/`ci_high`/`se`
(y a veces `df`) mas una clave `note`: muestra vacia o `n < 2` en cualquiera de
las muestras. **Excepcion**: con varianza cero y `n >= 2` el IC colapsa al
punto `[mean, mean]` con `se = 0` (no es un error, no hay `nan`).
- Comprueba `"note" in out` antes de usar `ci_low`/`ci_high` si la muestra puede
ser degenerada.
@@ -0,0 +1,176 @@
"""Intervalo de confianza de la media (una muestra) o de la diferencia de medias (Welch).
Funcion pura del grupo papers. Calcula el intervalo de confianza (IC) de la media
de una muestra usando la t de Student, o el IC de la diferencia de medias de dos
muestras independientes con el metodo de Welch (sin asumir varianzas iguales).
- Una muestra: ``df = n - 1``, ``se = sd / sqrt(n)`` (sd con ddof=1),
``tcrit = t.ppf((1 + confidence) / 2, df)``, ``ci = mean +/- tcrit * se``.
- Dos muestras (Welch): IC de ``mean(data) - mean(other)``, con
``se = sqrt(se1^2 + se2^2)`` y grados de libertad de Welch-Satterthwaite.
No lanza excepciones: ante casos degenerados (muestras vacias, ``n < 2``,
varianza cero) devuelve un dict coherente con ``ci_low``/``ci_high``/``se`` en
``nan`` (salvo el sub-caso de varianza cero, donde el IC colapsa al punto) y una
clave ``note`` explicando el caso. Usa ``scipy.stats`` y ``numpy``.
"""
from __future__ import annotations
import math
import numpy as np
from scipy import stats
def confidence_interval_mean(
data: list, other: list = None, confidence: float = 0.95
) -> dict:
"""Intervalo de confianza de la media o de la diferencia de medias (Welch).
Si ``other`` es ``None``, calcula el IC de la media de ``data`` con la t de
Student. Si se proporciona ``other``, calcula el IC de la diferencia
``mean(data) - mean(other)`` con el metodo de Welch (no asume varianzas
iguales) y grados de libertad de Welch-Satterthwaite.
Es una funcion pura y determinista: no hace I/O ni muta las entradas. No
lanza excepcion ante datos degenerados; en su lugar devuelve un dict con la
clave ``note`` y los campos numericos indefinidos a ``nan``.
Args:
data: muestra de observaciones numericas (lista de numeros).
other: segunda muestra independiente. Si se da, el IC es el de la
diferencia de medias ``mean(data) - mean(other)`` con Welch. Si es
``None`` (default), el IC es el de la media de ``data``.
confidence: nivel de confianza en (0, 1), p.ej. 0.95 para el 95%.
Returns:
dict con las claves:
mean: media de ``data`` (una muestra) o la diferencia
``mean(data) - mean(other)`` (dos muestras).
ci_low: extremo inferior del intervalo de confianza.
ci_high: extremo superior del intervalo de confianza.
se: error estandar de la media (o de la diferencia).
df: grados de libertad de la t (Welch-Satterthwaite si dos muestras).
confidence: nivel de confianza aplicado (float).
n: tamano de la muestra (una muestra) o tamano total ``n1 + n2``
(dos muestras; ademas se incluyen ``n1`` y ``n2``).
En el caso de dos muestras se incluyen ademas ``n1`` y ``n2``. Casos
degenerados (muestra vacia, ``n < 2``, etc.) anaden la clave ``note`` y
dejan ``ci_low``/``ci_high``/``se`` (y a veces ``df``) en ``nan``.
"""
conf = float(confidence)
if other is None:
return _ci_one_sample(data, conf)
return _ci_welch(data, other, conf)
def _ci_one_sample(data: list, conf: float) -> dict:
"""IC de la media de una sola muestra con la t de Student."""
arr = np.asarray(list(data), dtype=float)
n = int(arr.size)
base = {
"mean": float("nan"),
"ci_low": float("nan"),
"ci_high": float("nan"),
"se": float("nan"),
"df": float("nan"),
"confidence": conf,
"n": n,
}
if n == 0:
base["note"] = "muestra vacia: media e intervalo indefinidos"
return base
mean = float(arr.mean())
base["mean"] = mean
if n < 2:
base["note"] = "n < 2: error estandar y grados de libertad indefinidos"
return base
df = n - 1
base["df"] = float(df)
sd = float(arr.std(ddof=1))
se = sd / math.sqrt(n)
base["se"] = se
# Varianza cero: el IC colapsa al punto (no es un error).
if se == 0.0:
base["ci_low"] = mean
base["ci_high"] = mean
base["note"] = "varianza cero: el intervalo colapsa a la media"
return base
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
margin = tcrit * se
base["ci_low"] = mean - margin
base["ci_high"] = mean + margin
return base
def _ci_welch(data: list, other: list, conf: float) -> dict:
"""IC de la diferencia de medias de dos muestras con el metodo de Welch."""
a = np.asarray(list(data), dtype=float)
b = np.asarray(list(other), dtype=float)
n1 = int(a.size)
n2 = int(b.size)
base = {
"mean": float("nan"),
"ci_low": float("nan"),
"ci_high": float("nan"),
"se": float("nan"),
"df": float("nan"),
"confidence": conf,
"n": n1 + n2,
"n1": n1,
"n2": n2,
}
if n1 == 0 or n2 == 0:
base["note"] = "alguna muestra esta vacia: diferencia e intervalo indefinidos"
return base
mean1 = float(a.mean())
mean2 = float(b.mean())
diff = mean1 - mean2
base["mean"] = diff
if n1 < 2 or n2 < 2:
base["note"] = (
"n < 2 en alguna muestra: error estandar y grados de libertad indefinidos"
)
return base
sd1 = float(a.std(ddof=1))
sd2 = float(b.std(ddof=1))
se1 = sd1 / math.sqrt(n1)
se2 = sd2 / math.sqrt(n2)
se = math.sqrt(se1 * se1 + se2 * se2)
base["se"] = se
# Ambas varianzas cero: el IC de la diferencia colapsa al punto.
if se == 0.0:
base["ci_low"] = diff
base["ci_high"] = diff
base["df"] = float("nan")
base["note"] = "varianza cero en ambas muestras: el intervalo colapsa a la diferencia"
return base
# Grados de libertad de Welch-Satterthwaite.
df = (se1 * se1 + se2 * se2) ** 2 / (
(se1**4) / (n1 - 1) + (se2**4) / (n2 - 1)
)
base["df"] = float(df)
tcrit = float(stats.t.ppf((1.0 + conf) / 2.0, df))
margin = tcrit * se
base["ci_low"] = diff - margin
base["ci_high"] = diff + margin
return base
@@ -0,0 +1,140 @@
"""Tests para confidence_interval_mean (IC de la media / diferencia de medias Welch).
Importa el modulo hoja directamente (`confidence_interval_mean`) para no depender
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
al cerrar el grupo).
Los golden se calculan con scipy dentro del propio test para que sean robustos:
la funcion bajo prueba debe coincidir con la referencia de scipy a ~1e-9.
"""
import math
import numpy as np
from scipy import stats
from confidence_interval_mean import confidence_interval_mean
def test_one_sample_golden_contra_scipy():
# mean=5.0, n=8. Este dataset tiene sd POBLACIONAL (ddof=0) exactamente 2.0,
# pero la sd MUESTRAL (ddof=1, la que exige la spec y la que es correcta para
# el IC de una media con la t) es sqrt(32/7) ~ 2.13809. El golden robusto se
# calcula con scipy usando se con ddof=1, no con el atajo 2.0/sqrt(8).
data = [2, 4, 4, 4, 5, 5, 7, 9]
out = confidence_interval_mean(data, confidence=0.95)
n = len(data)
mean = float(np.mean(data))
sd = float(np.std(data, ddof=1)) # sample sd ~ 2.13809
se = sd / math.sqrt(n)
lo, hi = stats.t.interval(0.95, df=n - 1, loc=mean, scale=se)
assert abs(out["mean"] - 5.0) < 1e-9
assert abs(out["se"] - se) < 1e-12
assert out["df"] == 7.0
assert out["n"] == 8
assert out["confidence"] == 0.95
assert abs(out["ci_low"] - lo) < 1e-9
assert abs(out["ci_high"] - hi) < 1e-9
# Valores tabulados correctos para ddof=1 (no los 3.32793/6.67207 del
# enunciado, que asumian erroneamente sd=2.0 / ddof=0).
assert abs(out["ci_low"] - 3.21251) < 1e-3
assert abs(out["ci_high"] - 6.78749) < 1e-3
assert "note" not in out
def test_one_sample_distinto_nivel_confianza():
data = [10.0, 12.0, 11.0, 13.0, 9.0, 14.0]
out = confidence_interval_mean(data, confidence=0.99)
n = len(data)
mean = float(np.mean(data))
se = float(np.std(data, ddof=1)) / math.sqrt(n)
lo, hi = stats.t.interval(0.99, df=n - 1, loc=mean, scale=se)
assert abs(out["mean"] - mean) < 1e-12
assert abs(out["ci_low"] - lo) < 1e-9
assert abs(out["ci_high"] - hi) < 1e-9
assert out["df"] == float(n - 1)
def test_welch_diferencia_golden_contra_scipy():
data = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
other = [18.0, 20.0, 17.0, 19.0, 21.0]
conf = 0.95
out = confidence_interval_mean(data, other, confidence=conf)
a = np.asarray(data, dtype=float)
b = np.asarray(other, dtype=float)
n1, n2 = a.size, b.size
mean1, mean2 = float(a.mean()), float(b.mean())
diff = mean1 - mean2
se1 = float(a.std(ddof=1)) / math.sqrt(n1)
se2 = float(b.std(ddof=1)) / math.sqrt(n2)
se = math.sqrt(se1**2 + se2**2)
df = (se1**2 + se2**2) ** 2 / (se1**4 / (n1 - 1) + se2**4 / (n2 - 1))
lo, hi = stats.t.interval(conf, df=df, loc=diff, scale=se)
assert abs(out["mean"] - diff) < 1e-9
assert abs(out["mean"] - (mean1 - mean2)) < 1e-9
assert abs(out["se"] - se) < 1e-12
assert abs(out["df"] - df) < 1e-9
assert abs(out["ci_low"] - lo) < 1e-9
assert abs(out["ci_high"] - hi) < 1e-9
assert out["n1"] == n1
assert out["n2"] == n2
assert out["n"] == n1 + n2
assert "note" not in out
def test_edge_un_solo_elemento_no_lanza_nan_note():
out = confidence_interval_mean([5], confidence=0.95)
assert out["mean"] == 5.0 # la media si esta definida con n=1
assert math.isnan(out["se"])
assert math.isnan(out["ci_low"])
assert math.isnan(out["ci_high"])
assert math.isnan(out["df"])
assert out["n"] == 1
assert "note" in out
def test_edge_lista_vacia_no_lanza_note():
out = confidence_interval_mean([], confidence=0.95)
assert math.isnan(out["mean"])
assert math.isnan(out["ci_low"])
assert math.isnan(out["ci_high"])
assert math.isnan(out["se"])
assert out["n"] == 0
assert "note" in out
def test_edge_varianza_cero_colapsa_al_punto():
out = confidence_interval_mean([3, 3, 3], confidence=0.95)
assert out["mean"] == 3.0
assert out["se"] == 0.0
assert out["ci_low"] == 3.0
assert out["ci_high"] == 3.0
assert not math.isnan(out["ci_low"])
assert out["n"] == 3
assert "note" in out
def test_edge_welch_muestra_vacia_no_lanza_note():
out = confidence_interval_mean([1.0, 2.0, 3.0], [], confidence=0.95)
assert math.isnan(out["mean"])
assert math.isnan(out["ci_low"])
assert math.isnan(out["se"])
assert out["n1"] == 3
assert out["n2"] == 0
assert "note" in out
def test_edge_welch_n1_uno_no_lanza_note():
out = confidence_interval_mean([5.0], [1.0, 2.0, 3.0], confidence=0.95)
# La diferencia de medias si esta definida.
assert abs(out["mean"] - (5.0 - 2.0)) < 1e-9
assert math.isnan(out["se"])
assert math.isnan(out["ci_low"])
assert math.isnan(out["df"])
assert "note" in out
@@ -0,0 +1,103 @@
---
id: draw_join_graph_figure_py_datascience
name: draw_join_graph_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def draw_join_graph_figure(join_graph: dict, title: str = None) -> \"matplotlib.figure.Figure\""
description: "Rasteriza el join graph de una base (relaciones FK inter-tabla, salida de build_join_graph) a un matplotlib.figure.Figure: nodos circulares con el nombre de cada tabla (hubs en color de acento cálido, el resto neutro) y aristas dirigidas etiquetadas from_col→to_col (más la cardinalidad si viene). Es la contrapartida dibujada del string Mermaid para que el capítulo de relaciones del informe AutomaticEDA muestre un diagrama real. Layout networkx spring_layout determinista (seed=42), backend Agg sin abrir ventanas; defensivo: nunca lanza y nunca hace I/O."
tags: [eda, plot, relations, graph, matplotlib, figure, networkx, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib, networkx]
example: |
from draw_join_graph_figure import draw_join_graph_figure
join_graph = {
"nodes": [
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
],
"edges": [
{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
],
"hubs": ["orders"],
}
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
fig.savefig("/tmp/join_graph.png")
tested: true
tests:
- "test_returns_figure_with_axis"
- "test_savefig_produces_nonempty_png"
- "test_empty_dict_does_not_raise_and_savefig_png"
- "test_none_does_not_raise_and_savefig_png"
test_file_path: "python/functions/datascience/draw_join_graph_figure_test.py"
file_path: "python/functions/datascience/draw_join_graph_figure.py"
params:
- name: join_graph
desc: "Dict producido por build_join_graph. Claves: `nodes` (list[dict] con table, out_degree, in_degree, role), `edges` (list[dict] con from_table, from_col, to_table, to_col y opcional cardinality/inclusion) y `hubs` (list[str] de tablas hub a destacar en color cálido). Claves ausentes, items no-dict, None o {} se toleran (devuelve Figure con texto, sin lanzar). Los nombres de nodo se derivan también de las aristas, así que un grafo con edges pero sin nodes explícitos igual se dibuja."
- name: title
desc: "Título dibujado sobre el diagrama. Si se omite (None) se usa \"Join graph\". Default None."
output: "Un matplotlib.figure.Figure (figsize 7x5) con un único Axes que contiene el diagrama node-link dirigido: tablas como nodos circulares etiquetados (hubs en acento cálido #DD8452, resto en azul neutro #4C72B0) y FKs como flechas dirigidas con etiqueta from_col→to_col (+ cardinalidad). Si join_graph no tiene nodos ni aristas (o es None/{}), devuelve igualmente una Figure con el texto centrado \"Sin relaciones FK detectadas.\"; ante cualquier fallo interno devuelve una Figure con un mensaje genérico (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda."
---
## Ejemplo
```python
from draw_join_graph_figure import draw_join_graph_figure
# `join_graph` es la salida de build_join_graph (nodes + edges + hubs).
join_graph = {
"nodes": [
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
],
"edges": [
{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id", "cardinality": "N:1"},
{"from_table": "orders", "from_col": "product_id",
"to_table": "products", "to_col": "id", "cardinality": "N:1"},
],
"hubs": ["orders"], # `orders` se pinta en color de acento (tabla de hechos)
}
fig = draw_join_graph_figure(join_graph, title="Relaciones FK")
# El renderer del informe lo rasteriza; aquí solo persistimos para inspección.
fig.savefig("/tmp/join_graph.png")
```
## Cuando usarla
Úsala en el capítulo de relaciones de un informe AutomaticEDA cuando quieras un
diagrama **dibujado** del esquema relacional, no solo el bloque Mermaid pegable.
Pásale directamente la salida de `build_join_graph` (`nodes` + `edges` + `hubs`)
y obtienes una `matplotlib.figure.Figure` lista para que el renderer perezoso la
rasterice. Es la pareja visual del string Mermaid: Mermaid sirve para pegar en
Markdown/docs que lo soporten; esta función produce la imagen real (PNG/PDF) que
va embebida en informes que no renderizan Mermaid.
## Gotchas
- **Impura por matplotlib.** Fija el backend `Agg` al importar — no abre
ventanas ni depende de un display. Segura de llamar en lotes desde el
renderer.
- **Layout determinista (`seed=42`).** Usa `nx.spring_layout(G, seed=42)`, así
que la misma entrada produce el mismo diagrama (test reproducible). Para
grafos de 0/1 nodos usa una posición fija centrada en vez del spring layout.
- **No hace I/O.** No llama `plt.show()` ni guarda a disco — solo devuelve la
`Figure`. Quien la consume la rasteriza y la libera (`plt.close(fig)`) para no
acumular memoria en informes con muchas tablas.
- **Devuelve una Figure, NO un dict.** A diferencia de `build_join_graph` (que
devuelve el dict del grafo), esta función devuelve el objeto de figura ya
dibujado.
- **Defensiva, nunca lanza.** `None`, `{}`, claves ausentes o items malformados
se manejan sin error: en el peor caso devuelve una `Figure` con
"Sin relaciones FK detectadas." (vacío) o un mensaje genérico (fallo interno).
No la envuelvas en try/except por miedo a un raise — no lo hay.
@@ -0,0 +1,214 @@
"""Impure EDA helper: rasterize a join graph to a matplotlib Figure (`eda` group).
Takes the join graph produced by ``build_join_graph`` (inter-table FK relations)
and draws it as a directed node-link diagram on a ready-to-rasterize
``matplotlib.figure.Figure``. Hub tables (the ones with the highest out-degree,
candidate fact tables of a star schema) are highlighted in a warm accent colour;
the rest use a neutral colour. Directed edges carry a ``from_colto_col`` label
(plus the cardinality when present).
This is the *drawn* counterpart of the Mermaid string that ``build_join_graph``
also emits: the relations chapter of an AutomaticEDA report can show a real
picture instead of only the pasteable Mermaid block.
Impure because it touches matplotlib's rendering machinery. It pins the headless
Agg backend and a deterministic ``spring_layout`` seed so the output is
reproducible. It never raises: on any internal failure (or empty input) it
returns a ``Figure`` carrying a centered message, so the lazy render of the
document is never broken.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import networkx as nx # noqa: E402
# Warm accent reserved for hub tables (candidate fact tables / star-schema cores).
_HUB_COLOR = "#DD8452"
# Neutral blue for every other table.
_NODE_COLOR = "#4C72B0"
# Muted gray for the empty/error message text.
_MUTED_TEXT = "#5f6b7a"
# Edge colour and label colour.
_EDGE_COLOR = "#7a7a7a"
_EDGE_LABEL_COLOR = "#34495e"
# Constant node size; shared with the edge drawing so arrowheads stop at the
# node boundary instead of being hidden under the marker.
_NODE_SIZE = 2200
def _text_figure(message: str) -> "matplotlib.figure.Figure":
"""Return a blank Figure carrying a single centered message.
Used both for the "no relations" case and as the never-raise fallback.
"""
fig, ax = plt.subplots(figsize=(7, 5))
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=12,
color=_MUTED_TEXT,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def _edge_label(edge: dict) -> str:
"""Build the ``from_col→to_col`` label of an edge, appending cardinality."""
fc = edge.get("from_col")
tc = edge.get("to_col")
if fc is not None and tc is not None:
label = f"{fc}{tc}"
elif fc is not None:
label = str(fc)
elif tc is not None:
label = str(tc)
else:
label = ""
card = edge.get("cardinality")
if card:
label = f"{label} ({card})" if label else str(card)
return label
def draw_join_graph_figure(join_graph: dict, title: str = None):
"""Rasterize a join graph to a matplotlib Figure.
Builds a ``networkx.DiGraph`` from the graph's nodes and edges, lays it out
with a deterministic ``spring_layout`` (``seed=42``) and draws it on a
``matplotlib.figure.Figure``: tables as labelled circular nodes (hubs in a
warm accent, the rest neutral) and FK relations as directed arrows labelled
``from_colto_col`` (plus cardinality when available).
The function never raises. On empty/``None`` input it returns a Figure with
a centered "Sin relaciones FK detectadas." message; on any internal failure
it returns a Figure with a generic centered message. It never shows the
figure nor writes it to disk the document renderer rasterizes it.
Args:
join_graph: Dict produced by ``build_join_graph`` with keys ``nodes``
(list of ``{table, out_degree, in_degree, role}``), ``edges`` (list
of ``{from_table, from_col, to_table, to_col, cardinality?,
inclusion?}``) and ``hubs`` (list of hub table names to highlight).
Missing keys, non-dict items, ``None`` or ``{}`` are all tolerated.
title: Optional title drawn above the diagram. When omitted, the title
defaults to "Join graph".
Returns:
A ``matplotlib.figure.Figure`` (figsize 7x5) with a single Axes holding
the node-link diagram. The caller rasterizes/closes it.
"""
try:
jg = join_graph if isinstance(join_graph, dict) else {}
nodes = jg.get("nodes") or []
edges = jg.get("edges") or []
hubs = {h for h in (jg.get("hubs") or []) if h is not None}
# Collect node names from the declared nodes and, defensively, from the
# edges (so a graph with edges but no explicit nodes still draws).
node_names: list = []
seen: set = set()
def _register(name) -> None:
if name is not None and name not in seen:
seen.add(name)
node_names.append(name)
for n in nodes:
if isinstance(n, dict):
_register(n.get("table"))
for e in edges:
if isinstance(e, dict):
_register(e.get("from_table"))
_register(e.get("to_table"))
if not node_names:
return _text_figure("Sin relaciones FK detectadas.")
graph = nx.DiGraph()
for name in node_names:
graph.add_node(name)
edge_labels: dict = {}
for e in edges:
if not isinstance(e, dict):
continue
ft = e.get("from_table")
tt = e.get("to_table")
if ft is None or tt is None:
continue
graph.add_edge(ft, tt)
edge_labels[(ft, tt)] = _edge_label(e)
fig, ax = plt.subplots(figsize=(7, 5))
# Deterministic layout. Fixed positions for trivial graphs so a single
# node sits centered instead of at an arbitrary spring-layout point.
if graph.number_of_nodes() <= 1:
pos = {name: (0.5, 0.5) for name in graph.nodes()}
else:
pos = nx.spring_layout(graph, seed=42)
node_colors = [
_HUB_COLOR if name in hubs else _NODE_COLOR for name in graph.nodes()
]
nx.draw_networkx_nodes(
graph,
pos,
ax=ax,
node_color=node_colors,
node_size=_NODE_SIZE,
node_shape="o",
edgecolors="white",
linewidths=1.5,
)
nx.draw_networkx_labels(
graph,
pos,
ax=ax,
font_size=9,
font_color="white",
font_weight="bold",
)
nx.draw_networkx_edges(
graph,
pos,
ax=ax,
arrows=True,
arrowstyle="-|>",
arrowsize=18,
edge_color=_EDGE_COLOR,
width=1.4,
connectionstyle="arc3,rad=0.06",
node_size=_NODE_SIZE,
)
if any(lbl for lbl in edge_labels.values()):
nx.draw_networkx_edge_labels(
graph,
pos,
edge_labels=edge_labels,
ax=ax,
font_size=7,
font_color=_EDGE_LABEL_COLOR,
bbox={
"boxstyle": "round,pad=0.2",
"fc": "white",
"ec": "none",
"alpha": 0.7,
},
)
ax.set_title(title if title else "Join graph", fontsize=13)
ax.axis("off")
fig.tight_layout()
return fig
except Exception:
# Never raise — the document render is lazy and must not be broken.
return _text_figure("No se pudo dibujar el join graph.")
@@ -0,0 +1,84 @@
"""Tests para draw_join_graph_figure (rasteriza el join graph, grupo eda).
Usa el backend Agg sin abrir ventanas; cada test cierra la Figure construida
(matplotlib.pyplot.close) para no acumular estado entre tests. Las aserciones de
guardado escriben a tmp_path (fixture de pytest) y comprueban que el PNG no está
vacío.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from draw_join_graph_figure import draw_join_graph_figure
def _make_join_graph():
"""Join graph mínimo: 3 nodos (customers/orders/products) y 2 aristas.
orders -> customers y orders -> products. `orders` es el hub (out_degree 2).
"""
return {
"nodes": [
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"},
{"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"},
{"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"},
],
"edges": [
{
"from_table": "orders",
"from_col": "customer_id",
"to_table": "customers",
"to_col": "id",
"cardinality": "N:1",
"inclusion": 1.0,
},
{
"from_table": "orders",
"from_col": "product_id",
"to_table": "products",
"to_col": "id",
"cardinality": "N:1",
"inclusion": 0.98,
},
],
"hubs": ["orders"],
}
def test_returns_figure_with_axis():
fig = draw_join_graph_figure(_make_join_graph(), title="Relaciones FK")
assert isinstance(fig, Figure)
# Al menos un eje con el diagrama.
assert len(fig.axes) >= 1
plt.close(fig)
def test_savefig_produces_nonempty_png(tmp_path):
fig = draw_join_graph_figure(_make_join_graph())
out = tmp_path / "g.png"
fig.savefig(out)
assert out.exists()
assert out.stat().st_size > 0
plt.close(fig)
def test_empty_dict_does_not_raise_and_savefig_png(tmp_path):
fig = draw_join_graph_figure({})
assert isinstance(fig, Figure)
out = tmp_path / "empty.png"
fig.savefig(out)
assert out.stat().st_size > 0
plt.close(fig)
def test_none_does_not_raise_and_savefig_png(tmp_path):
fig = draw_join_graph_figure(None)
assert isinstance(fig, Figure)
out = tmp_path / "none.png"
fig.savefig(out)
assert out.stat().st_size > 0
plt.close(fig)
@@ -0,0 +1,80 @@
---
name: effect_size_cohens_d
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def effect_size_cohens_d(group_a: list, group_b: list) -> dict"
description: "Tamano del efecto (effect size) entre dos grupos numericos: Cohen's d (diferencia de medias estandarizada por la desviacion tipica combinada, varianzas muestrales ddof=1), Hedges' g (d corregido por el sesgo al alza con muestras pequenas via el factor J) e interpretacion cualitativa de la magnitud segun los umbrales clasicos de Cohen (negligible/small/medium/large). El p-valor dice si hay diferencia; el effect size dice como de grande, de forma adimensional e independiente del N. Pura, sin dependencias externas; nunca lanza: los casos degenerados (varianza cero, N<2, listas vacias) devuelven NaN + una clave note."
tags: [papers, statistics, effect-size, cohens-d, hedges-g, python]
params:
- name: group_a
desc: "primera muestra (lista de numeros). Necesita >=2 observaciones para que exista la varianza muestral (ddof=1)."
- name: group_b
desc: "segunda muestra (lista de numeros). Necesita >=2 observaciones. El signo de cohens_d es positivo cuando mean_a > mean_b."
output: "dict {cohens_d: float (diferencia de medias estandarizada, puede ser NaN), hedges_g: float (cohens_d * factor de correccion J, puede ser NaN), interpretation: str ('negligible'|'small'|'medium'|'large', o 'undefined' en casos degenerados), n_a: int, n_b: int, mean_a: float, mean_b: float, pooled_sd: float (desviacion tipica combinada)}. Casos degenerados (varianza cero en ambos grupos, N<2 en algun grupo, o listas vacias) anaden clave note. Nunca None ni excepcion."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math]
tested: true
tests: ["test_golden_large_effect", "test_hedges_g_menor_en_magnitud_que_cohens_d", "test_interpretation_thresholds", "test_signo_positivo_cuando_a_mayor_que_b", "test_varianza_cero_no_lanza", "test_n_insuficiente_no_lanza", "test_listas_vacias_no_lanza", "test_un_grupo_vacio_no_lanza"]
test_file_path: "python/functions/datascience/effect_size_cohens_d_test.py"
file_path: "python/functions/datascience/effect_size_cohens_d.py"
---
## Ejemplo
```python
from datascience import effect_size_cohens_d
# Dos grupos desplazados 2 unidades, misma dispersion.
a = [1, 2, 3, 4, 5] # media 3, varianza muestral 2.5
b = [3, 4, 5, 6, 7] # media 5, varianza muestral 2.5
out = effect_size_cohens_d(a, b)
print(out["cohens_d"]) # -> -1.264911... (a esta 1.26 SD por debajo de b)
print(out["hedges_g"]) # -> -1.142500... (|g| < |d|: correccion N pequeno)
print(out["interpretation"]) # -> "large" (|d| >= 0.8)
print(out["pooled_sd"]) # -> 1.581138...
# Caso degenerado: varianza cero -> no lanza, NaN + note.
deg = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
print(deg["interpretation"]) # -> "undefined"
print(deg["note"]) # -> "varianza cero, effect size indefinido"
```
## Cuando usarla
Cuando ya sepas que dos grupos difieren (o quieras cuantificar su diferencia)
y necesites una medida **de magnitud, no de significancia**: comparar el antes
y el despues de una intervencion, el grupo control frente al tratamiento, o dos
cohortes. Reportala junto al p-valor para responder "¿como de grande es la
diferencia?" — un p-valor minusculo con N enorme puede esconder un efecto
trivial. Es adimensional (en unidades de desviaciones tipicas), asi que hace
comparables resultados entre estudios y alimenta meta-analisis. Usa **Hedges' g**
en lugar de Cohen's d cuando los grupos sean pequenos (decenas o menos): d
sobreestima el efecto y g lo corrige.
## Gotchas
- Pura y sin dependencias externas (solo `math` de la stdlib).
- Usa **varianza muestral** (ddof=1), no poblacional. Por eso cada grupo
necesita al menos 2 observaciones; con N=1 la varianza muestral no existe y la
funcion devuelve NaN + `note`.
- **Nunca lanza excepcion**. Los casos degenerados devuelven `cohens_d` y
`hedges_g` a `float('nan')`, `interpretation="undefined"` y una clave `note`:
varianza cero en ambos grupos (`pooled_sd == 0`), N<2 en algun grupo, o listas
vacias. Comprueba con `math.isnan(out["cohens_d"])` o la presencia de `note`
antes de usar el resultado.
- El **signo** de `cohens_d` depende del orden de los argumentos: positivo si
`mean_a > mean_b`, negativo en caso contrario. La `interpretation` usa `|d|`,
asi que no depende del orden.
- `pooled_sd` asume varianzas comparables entre grupos (homogeneidad). Si las
dispersiones son muy distintas, Cohen's d clasico pierde precision; considera
variantes (Glass's delta) fuera del alcance de esta funcion.
- Los umbrales de Cohen (0.2 / 0.5 / 0.8) son convencion, no ley: interpretalos
segun el dominio.
@@ -0,0 +1,156 @@
"""Effect size de dos grupos: Cohen's d, Hedges' g e interpretacion cualitativa.
Funcion pura del grupo papers. El p-valor responde a "¿hay diferencia?" pero no
a "¿como de grande es?". El tamano del efecto (effect size) cuantifica la
magnitud de la diferencia entre dos grupos de forma adimensional, independiente
del N, y es lo que hace comparables resultados entre estudios (meta-analisis).
- Cohen's d: diferencia de medias estandarizada por la desviacion tipica
combinada (pooled SD), con varianzas muestrales (ddof=1).
- Hedges' g: Cohen's d corregido por el sesgo al alza que sufre d con muestras
pequenas, multiplicando por el factor de correccion J.
- interpretation: etiqueta cualitativa de |d| segun los umbrales clasicos de
Cohen (negligible / small / medium / large).
No usa dependencias externas: aritmetica de la libreria estandar (``math``).
"""
from __future__ import annotations
import math
def _mean(xs: list) -> float:
"""Media aritmetica de una lista no vacia de numeros."""
return sum(float(x) for x in xs) / len(xs)
def _sample_variance(xs: list, mean: float) -> float:
"""Varianza muestral (ddof=1) de una lista con al menos 2 elementos."""
n = len(xs)
return sum((float(x) - mean) ** 2 for x in xs) / (n - 1)
def _interpret(abs_d: float) -> str:
"""Etiqueta cualitativa del tamano del efecto segun |d| (umbrales de Cohen)."""
if abs_d < 0.2:
return "negligible"
if abs_d < 0.5:
return "small"
if abs_d < 0.8:
return "medium"
return "large"
def effect_size_cohens_d(group_a: list, group_b: list) -> dict:
"""Calcula el tamano del efecto entre dos grupos numericos.
Devuelve Cohen's d (diferencia de medias estandarizada por la pooled SD),
Hedges' g (d corregido por sesgo de muestra pequena) y una etiqueta
cualitativa de la magnitud segun los umbrales de Cohen.
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
excepcion ante datos degenerados; en su lugar devuelve un dict con
``cohens_d`` / ``hedges_g`` a ``float('nan')``, ``interpretation`` a
``"undefined"`` y una clave ``note`` explicando el caso.
Definiciones:
s_pooled = sqrt(((n1-1)*s1^2 + (n2-1)*s2^2) / (n1+n2-2)), con s1^2, s2^2
varianzas muestrales (ddof=1).
cohens_d = (mean_a - mean_b) / s_pooled.
J = 1 - 3 / (4*(n1+n2) - 9) (factor de correccion de Hedges).
hedges_g = cohens_d * J.
Args:
group_a: primera muestra (lista de numeros). Necesita >=2 elementos para
que exista la varianza muestral.
group_b: segunda muestra (lista de numeros). Necesita >=2 elementos.
Returns:
dict con las claves:
cohens_d: float, diferencia de medias estandarizada (puede ser NaN).
hedges_g: float, Cohen's d corregido por sesgo (puede ser NaN).
interpretation: str, "negligible" | "small" | "medium" | "large", o
"undefined" en casos degenerados.
n_a: int, tamano de group_a.
n_b: int, tamano de group_b.
mean_a: float, media de group_a (NaN si vacio).
mean_b: float, media de group_b (NaN si vacio).
pooled_sd: float, desviacion tipica combinada (NaN si indefinida).
Casos degenerados (lista vacia, N<2 en algun grupo, o varianza cero en
ambos grupos -> pooled_sd == 0) anaden ademas una clave ``note``.
"""
nan = float("nan")
n_a = len(group_a)
n_b = len(group_b)
# Listas vacias: ni media ni varianza definidas.
if n_a == 0 or n_b == 0:
return {
"cohens_d": nan,
"hedges_g": nan,
"interpretation": "undefined",
"n_a": n_a,
"n_b": n_b,
"mean_a": _mean(group_a) if n_a else nan,
"mean_b": _mean(group_b) if n_b else nan,
"pooled_sd": nan,
"note": "grupo vacio: media y varianza indefinidas, effect size indefinido",
}
mean_a = _mean(group_a)
mean_b = _mean(group_b)
# N insuficiente: la varianza muestral (ddof=1) no existe con un solo dato,
# y la correccion de Hedges no es fiable.
if n_a < 2 or n_b < 2:
return {
"cohens_d": nan,
"hedges_g": nan,
"interpretation": "undefined",
"n_a": n_a,
"n_b": n_b,
"mean_a": mean_a,
"mean_b": mean_b,
"pooled_sd": nan,
"note": (
"N insuficiente: cada grupo necesita >=2 observaciones para la "
"varianza muestral; effect size indefinido"
),
}
var_a = _sample_variance(group_a, mean_a)
var_b = _sample_variance(group_b, mean_b)
pooled_sd = math.sqrt(
((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2)
)
# Varianza cero en ambos grupos: no se puede estandarizar (division por 0).
if pooled_sd == 0.0:
return {
"cohens_d": nan,
"hedges_g": nan,
"interpretation": "undefined",
"n_a": n_a,
"n_b": n_b,
"mean_a": mean_a,
"mean_b": mean_b,
"pooled_sd": 0.0,
"note": "varianza cero, effect size indefinido",
}
cohens_d = (mean_a - mean_b) / pooled_sd
j = 1.0 - 3.0 / (4.0 * (n_a + n_b) - 9.0)
hedges_g = cohens_d * j
return {
"cohens_d": cohens_d,
"hedges_g": hedges_g,
"interpretation": _interpret(abs(cohens_d)),
"n_a": n_a,
"n_b": n_b,
"mean_a": mean_a,
"mean_b": mean_b,
"pooled_sd": pooled_sd,
}
@@ -0,0 +1,96 @@
"""Tests para effect_size_cohens_d (tamano del efecto de dos grupos).
Importa el modulo hoja directamente (`effect_size_cohens_d`) para no depender de
que el paquete reexporte la funcion en su __init__ (lo integra el orquestador al
cerrar el grupo papers). El pytest del repo tiene pythonpath=["functions", ...],
asi que el modulo hoja se resuelve por su nombre directo.
"""
import math
from effect_size_cohens_d import effect_size_cohens_d
def test_golden_large_effect():
# group_a: mean 3, var muestral 2.5; group_b: mean 5, var 2.5.
# pooled_sd = sqrt(2.5) ~= 1.5811388.
# cohens_d = (3-5)/1.5811388 ~= -1.264911.
# J = 1 - 3/(4*10-9) = 1 - 3/31 = 0.9032258.
# hedges_g = d * J = -1.2649111 * 0.9032258 ~= -1.142500.
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
assert abs(out["cohens_d"] - (-1.26491)) < 1e-4
assert abs(out["hedges_g"] - (-1.14250)) < 1e-4
assert out["interpretation"] == "large"
assert out["n_a"] == 5
assert out["n_b"] == 5
assert abs(out["mean_a"] - 3.0) < 1e-12
assert abs(out["mean_b"] - 5.0) < 1e-12
assert abs(out["pooled_sd"] - math.sqrt(2.5)) < 1e-9
assert "note" not in out
def test_hedges_g_menor_en_magnitud_que_cohens_d():
# La correccion J esta en (0, 1), asi que |g| < |d| siempre.
out = effect_size_cohens_d([1, 2, 3, 4, 5], [3, 4, 5, 6, 7])
assert abs(out["hedges_g"]) < abs(out["cohens_d"])
def test_interpretation_thresholds():
# negligible: |d| < 0.2. Medias casi iguales con varianza grande.
neg = effect_size_cohens_d([0, 10, 20, 30], [1, 11, 21, 31])
assert neg["interpretation"] == "negligible"
assert abs(neg["cohens_d"]) < 0.2
# small: 0.2 <= |d| < 0.5.
small = effect_size_cohens_d([0, 10, 20, 30], [4, 14, 24, 34])
assert small["interpretation"] == "small"
assert 0.2 <= abs(small["cohens_d"]) < 0.5
# medium: 0.5 <= |d| < 0.8.
medium = effect_size_cohens_d([0, 10, 20, 30], [9, 19, 29, 39])
assert medium["interpretation"] == "medium"
assert 0.5 <= abs(medium["cohens_d"]) < 0.8
def test_signo_positivo_cuando_a_mayor_que_b():
out = effect_size_cohens_d([10, 12, 14, 16], [1, 2, 3, 4])
assert out["cohens_d"] > 0
assert out["interpretation"] == "large"
def test_varianza_cero_no_lanza():
out = effect_size_cohens_d([5, 5, 5], [5, 5, 5])
assert math.isnan(out["cohens_d"])
assert math.isnan(out["hedges_g"])
assert out["interpretation"] == "undefined"
assert out["pooled_sd"] == 0.0
assert "note" in out
assert "varianza cero" in out["note"]
def test_n_insuficiente_no_lanza():
out = effect_size_cohens_d([3], [1, 2, 3])
assert math.isnan(out["cohens_d"])
assert math.isnan(out["hedges_g"])
assert out["interpretation"] == "undefined"
assert out["n_a"] == 1
assert out["n_b"] == 3
assert "note" in out
def test_listas_vacias_no_lanza():
out = effect_size_cohens_d([], [])
assert math.isnan(out["cohens_d"])
assert math.isnan(out["hedges_g"])
assert out["interpretation"] == "undefined"
assert out["n_a"] == 0
assert out["n_b"] == 0
assert "note" in out
def test_un_grupo_vacio_no_lanza():
out = effect_size_cohens_d([1, 2, 3], [])
assert math.isnan(out["cohens_d"])
assert out["interpretation"] == "undefined"
assert out["n_b"] == 0
assert "note" in out
+29 -11
View File
@@ -3,19 +3,19 @@ name: fdr_correction
kind: function kind: function
lang: py lang: py
domain: datascience domain: datascience
version: "1.0.0" version: "1.1.0"
purity: pure purity: pure
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict" signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)." description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh'), Bonferroni (FWER, 'bonferroni') o Holm-Bonferroni (FWER step-down, 'holm', mas potente que Bonferroni simple). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python] tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, holm, holm-bonferroni, fwer, p-value, data-mining-bias, python]
params: params:
- name: pvalues - name: pvalues
desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)." desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)."
- name: alpha - name: alpha
desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)." desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)."
- name: method - name: method
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note." desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador); 'holm' = Holm-Bonferroni (controla FWER, step-down, uniformemente mas potente que Bonferroni simple). Cualquier otro valor devuelve un dict con note."
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion." output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str ('bh' | 'bonferroni' | 'holm')}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
@@ -23,7 +23,7 @@ returns_optional: false
error_type: "" error_type: ""
imports: [math] imports: [math]
tested: true tested: true
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"] tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos", "test_holm_golden_rechaza_dos_de_cuatro", "test_holm_entre_bonferroni_y_bh", "test_none_se_propaga_alineado_holm", "test_lista_vacia_holm_devuelve_note"]
test_file_path: "python/functions/datascience/fdr_correction_test.py" test_file_path: "python/functions/datascience/fdr_correction_test.py"
file_path: "python/functions/datascience/fdr_correction.py" file_path: "python/functions/datascience/fdr_correction.py"
--- ---
@@ -45,6 +45,13 @@ bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
print(bon["reject"]) # -> [True, False, False] print(bon["reject"]) # -> [True, False, False]
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0] print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
# Holm-Bonferroni (step-down): controla el FWER como Bonferroni pero es mas
# potente; rechaza al menos tanto como Bonferroni simple, nunca menos.
holm = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
print(holm["reject"]) # -> [True, False, False, True]
print(holm["p_values_adjusted"]) # -> [0.03, 0.06, 0.06, 0.02]
print(holm["n_rejected"]) # -> 2
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la # Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
# lista completa de pares y recuperar el mapeo 1:1. # lista completa de pares y recuperar el mapeo 1:1.
mix = fdr_correction([0.001, None, 0.9]) mix = fdr_correction([0.001, None, 0.9])
@@ -61,8 +68,11 @@ combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y
alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas
correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la
familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"` familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"`
por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy por defecto (mejor potencia); `"holm"` (Holm-Bonferroni, FWER step-down) cuando
costoso y prefieras maxima cautela. quieras controlar el FWER pero sin la perdida de potencia de Bonferroni simple
(rechaza al menos tanto como `"bonferroni"`, nunca menos); `"bonferroni"` cuando
un falso positivo sea muy costoso y prefieras la maxima cautela del metodo mas
simple.
## Gotchas ## Gotchas
@@ -76,8 +86,16 @@ costoso y prefieras maxima cautela.
eso puedes pasar la lista completa de pares aunque algunos no tengan test. eso puedes pasar la lista completa de pares aunque algunos no tengan test.
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que - `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
`len(pvalues)` si hay `None`. `len(pvalues)` si hay `None`.
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos - BH controla cosa distinta que Bonferroni/Holm: BH la tasa de falsos
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso descubrimientos (FDR); Bonferroni y Holm la probabilidad de *cualquier* falso
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte. positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
- `"holm"` y `"bonferroni"` controlan ambos el FWER, pero Holm es step-down y
uniformemente mas potente: rechaza al menos tantas hipotesis como Bonferroni
simple sobre el mismo set, nunca menos. Si controlas FWER, `"holm"` domina a
`"bonferroni"` salvo que necesites el ajuste mas simple por interpretabilidad.
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict - Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
con `note`. con `note`. Los metodos validos son `"bh"`, `"bonferroni"` y `"holm"`.
## Capability growth log
- v1.1.0 (2026-06-30) — añade method="holm" (Holm-Bonferroni step-down, FWER, más potente que Bonferroni simple).
+29 -9
View File
@@ -5,12 +5,15 @@ todos los pares de una matriz de asociacion), la probabilidad de obtener al meno
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical
Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo
mediante dos metodos clasicos: mediante tres metodos clasicos:
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos - Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica. (False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia - Bonferroni (``"bonferroni"``): controla la tasa de error por familia
(Family-Wise Error Rate, FWER). Mas conservador. (Family-Wise Error Rate, FWER). Mas conservador.
- Holm-Bonferroni (``"holm"``): controla el FWER como Bonferroni pero es un
procedimiento step-down uniformemente mas potente; rechaza al menos tantas
hipotesis como Bonferroni simple, nunca menos.
No usa dependencias externas: aritmetica de la libreria estandar. No usa dependencias externas: aritmetica de la libreria estandar.
""" """
@@ -35,8 +38,9 @@ def _is_valid_p(v) -> bool:
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict: def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
"""Corrige una lista de p-valores por comparaciones multiples. """Corrige una lista de p-valores por comparaciones multiples.
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y Aplica Benjamini-Hochberg (FDR), Bonferroni (FWER) o Holm-Bonferroni
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y (FWER, step-down) sobre ``pvalues`` y devuelve, alineado posicion a
posicion con la entrada, el p-valor ajustado y
si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las
posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de
``[0, 1]`` o no numerico) se conservan en la salida como ``None`` / ``[0, 1]`` o no numerico) se conservan en la salida como ``None`` /
@@ -53,8 +57,10 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
otros valores no validos en posiciones sin test disponible; se otros valores no validos en posiciones sin test disponible; se
propagan como ``None`` en la salida y no cuentan como prueba. propagan como ``None`` en la salida y no cuentan como prueba.
alpha: nivel de significancia objetivo tras la correccion (default 0.05). alpha: nivel de significancia objetivo tras la correccion (default 0.05).
Para BH es el umbral del FDR; para Bonferroni, del FWER. Para BH es el umbral del FDR; para Bonferroni y Holm, del FWER.
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER). method: ``"bh"`` (Benjamini-Hochberg, FDR), ``"bonferroni"`` (FWER) o
``"holm"`` (Holm-Bonferroni, FWER step-down, mas potente que
Bonferroni simple).
Returns: Returns:
dict con las claves: dict con las claves:
@@ -68,7 +74,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
n_tests: numero de p-valores validos usados en la correccion (m). n_tests: numero de p-valores validos usados en la correccion (m).
n_rejected: numero de hipotesis rechazadas (significativas). n_rejected: numero de hipotesis rechazadas (significativas).
alpha: nivel de significancia aplicado (float). alpha: nivel de significancia aplicado (float).
method: metodo aplicado (``"bh"`` o ``"bonferroni"``). method: metodo aplicado (``"bh"``, ``"bonferroni"`` o ``"holm"``).
Casos degenerados (lista vacia, sin p-valores validos o metodo Casos degenerados (lista vacia, sin p-valores validos o metodo
desconocido) anaden ademas una clave ``note`` y devuelven listas desconocido) anaden ademas una clave ``note`` y devuelven listas
@@ -76,7 +82,7 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
en las posiciones invalidas). en las posiciones invalidas).
""" """
method_norm = (method or "").strip().lower() method_norm = (method or "").strip().lower()
if method_norm not in {"bh", "bonferroni"}: if method_norm not in {"bh", "bonferroni", "holm"}:
n = len(pvalues) n = len(pvalues)
return { return {
"p_values_adjusted": [None] * n, "p_values_adjusted": [None] * n,
@@ -86,8 +92,8 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
"alpha": float(alpha), "alpha": float(alpha),
"method": method, "method": method,
"note": ( "note": (
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) " f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg), "
"o 'bonferroni'" "'bonferroni' o 'holm' (Holm-Bonferroni)"
), ),
} }
@@ -129,6 +135,20 @@ def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> di
padj = min(1.0, p * m) padj = min(1.0, p * m)
adjusted[orig_idx] = padj adjusted[orig_idx] = padj
reject[orig_idx] = padj <= a reject[orig_idx] = padj <= a
elif method_norm == "holm":
# Holm-Bonferroni (step-down). Ordena p ascendente; para el rank k
# (1-indexed) el p ajustado crudo es (m - k + 1) * p_(k). Impon
# monotonicidad acumulada (no decreciente) recorriendo de menor a mayor:
# padj_(k) = max(padj_(k-1), min(1, (m-k+1)*p_(k))), con padj_(0)=0.
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
prev = 0.0
for k in range(1, m + 1):
orig_idx, p = order[k - 1]
raw = min(1.0, (m - k + 1) * p)
padj = max(prev, raw)
prev = padj
adjusted[orig_idx] = padj
reject[orig_idx] = padj <= a
else: else:
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores # Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
# con la monotonicidad acumulada de derecha a izquierda. # con la monotonicidad acumulada de derecha a izquierda.
@@ -82,7 +82,8 @@ def test_solo_none_devuelve_note():
def test_metodo_desconocido_devuelve_note(): def test_metodo_desconocido_devuelve_note():
out = fdr_correction([0.01, 0.02], method="holm") # 'holm' ya es un metodo valido (v1.1.0); usamos uno realmente desconocido.
out = fdr_correction([0.01, 0.02], method="sidak")
assert "note" in out assert "note" in out
assert out["n_rejected"] == 0 assert out["n_rejected"] == 0
assert out["reject"] == [False, False] assert out["reject"] == [False, False]
@@ -97,3 +98,66 @@ def test_todos_significativos():
assert bon["n_rejected"] == 3 assert bon["n_rejected"] == 3
assert all(bh["reject"]) assert all(bh["reject"])
assert all(bon["reject"]) assert all(bon["reject"])
def test_holm_golden_rechaza_dos_de_cuatro():
# Holm-Bonferroni (step-down) sobre [0.01, 0.04, 0.03, 0.005], m=4, alpha=0.05.
# Ordenado ascendente: 0.005, 0.01, 0.03, 0.04.
# padj_(1) = 4*0.005 = 0.02
# padj_(2) = max(0.02, 3*0.01=0.03) = 0.03
# padj_(3) = max(0.03, 2*0.03=0.06) = 0.06
# padj_(4) = max(0.06, 1*0.04=0.04) = 0.06
# Mapeado al orden de entrada [0.01, 0.04, 0.03, 0.005]:
# 0.01 -> 0.03, 0.04 -> 0.06, 0.03 -> 0.06, 0.005 -> 0.02
out = fdr_correction([0.01, 0.04, 0.03, 0.005], alpha=0.05, method="holm")
assert out["method"] == "holm"
assert out["n_tests"] == 4
adj = out["p_values_adjusted"]
assert abs(adj[0] - 0.03) < 1e-9
assert abs(adj[1] - 0.06) < 1e-9
assert abs(adj[2] - 0.06) < 1e-9
assert abs(adj[3] - 0.02) < 1e-9
assert out["reject"] == [True, False, False, True]
assert out["n_rejected"] == 2
def test_holm_entre_bonferroni_y_bh():
# Holm controla FWER como Bonferroni pero es step-down: rechaza AL MENOS
# tanto como Bonferroni simple, y a lo sumo tanto como BH (FDR, menos
# conservador). Cadena de potencia: bonferroni <= holm <= bh.
pvalues = [0.01, 0.02, 0.04, 0.005]
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
holm = fdr_correction(pvalues, alpha=0.05, method="holm")
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
assert holm["n_rejected"] >= bon["n_rejected"]
assert holm["n_rejected"] <= bh["n_rejected"]
# En este set Holm gana potencia frente a Bonferroni simple (estricto).
assert holm["n_rejected"] > bon["n_rejected"]
# Un set donde Holm es estrictamente mas conservador que BH.
pvals2 = [0.01, 0.02, 0.03, 0.04]
bon2 = fdr_correction(pvals2, alpha=0.05, method="bonferroni")
holm2 = fdr_correction(pvals2, alpha=0.05, method="holm")
bh2 = fdr_correction(pvals2, alpha=0.05, method="bh")
assert holm2["n_rejected"] >= bon2["n_rejected"]
assert holm2["n_rejected"] < bh2["n_rejected"]
def test_none_se_propaga_alineado_holm():
# None se propaga alineado tambien con holm: la posicion central no cuenta
# como prueba (m=2) y se devuelve como None / False.
out = fdr_correction([0.001, None, 0.9], method="holm")
assert out["n_tests"] == 2
assert out["p_values_adjusted"][1] is None
assert out["reject"][1] is False
assert out["reject"][0] is True
assert len(out["reject"]) == 3
def test_lista_vacia_holm_devuelve_note():
out = fdr_correction([], method="holm")
assert out["p_values_adjusted"] == []
assert out["reject"] == []
assert out["n_tests"] == 0
assert out["n_rejected"] == 0
assert "note" in out
@@ -0,0 +1,77 @@
---
name: generate_synthetic_eda_folder
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def generate_synthetic_eda_folder(out_dir: str, n_rows: int = 2000, seed: int = 42) -> dict"
description: "Genera una carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) deterministas por seed (Faker + numpy) para ejercitar el motor AutomaticEDA multi-tabla / profile_database. orders.customer_id y reviews.customer_id estan contenidos al 100% en customers.customer_id (PK uuid), de modo que la deteccion FK por containment (min_inclusion=0.9) descubre ambas relaciones. customers es la tabla padre; reutiliza helpers de generate_synthetic_eda_table (texto multi-idioma, lat/lon validas, amount con outliers). Estilo dict-no-throw: nunca lanza."
tags: [eda, synthetic, faker, testing, fixture, datascience]
params:
- name: out_dir
desc: "Carpeta de salida. Se crea con mkdir -p si no existe. Recibe customers.csv, orders.csv y reviews.csv."
- name: n_rows
desc: "Numero de clientes (filas de customers). orders ~= 2*n_rows filas, reviews ~= n_rows filas. Default 2000."
- name: seed
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> CSVs identicos byte a byte. Default 42."
output: "dict dict-no-throw. En exito {status:'ok', out_dir, files:{customers,orders,reviews}, n_customers, n_orders, n_reviews, expected_relations:[{from_table,from_col,to_table,to_col}, ...], seed}. En error (sin lanzar, p.ej. n_rows<=0) {status:'error', error:str}. expected_relations declara las 2 FK orders->customers y reviews->customers (ambas por customer_id)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_genera_ok_y_archivos", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_fk_containment", "test_review_text_mediana_palabras", "test_n_rows_invalido"]
test_file_path: "python/functions/datascience/generate_synthetic_eda_folder_test.py"
file_path: "python/functions/datascience/generate_synthetic_eda_folder.py"
---
## Ejemplo
```bash
# Genera /tmp/eda_folder/{customers,orders,reviews}.csv (300 customers, seed 42)
fn run generate_synthetic_eda_folder /tmp/eda_folder 300 42
```
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import generate_synthetic_eda_folder
res = generate_synthetic_eda_folder("/tmp/eda_folder", n_rows=300, seed=42)
# res["files"] -> {"customers": ".../customers.csv", "orders": ..., "reviews": ...}
# res["expected_relations"] -> orders.customer_id y reviews.customer_id -> customers.customer_id
# Luego perfila la carpeta/base con el grupo eda:
# fn run profile_database /tmp/eda_folder
```
## Cuando usarla
- Cuando necesites un fixture REPRODUCIBLE multi-tabla para evaluar el EDA de carpeta/base (`profile_database`, join graph, capitulo de relaciones inter-tabla) con relaciones FK reales y detectables.
- Cuando escribas tests de la deteccion de claves foraneas por containment: orders y reviews referencian customer_id contenido al 100% en customers (inclusion 1.0 >= min_inclusion 0.9).
- Como contraparte multi-tabla de `generate_synthetic_eda_table` (que cubre el EDA de UNA tabla).
## Gotchas
- **Impura**: escribe 3 CSV a disco (`mkdir -p` de la carpeta). Sobrescribe los CSV existentes con el mismo nombre.
- **Requiere `faker`, `numpy` y `pandas`** en el venv. Sin `faker` devuelve `{status:'error'}` (no lanza).
- **El containment depende del orden**: customers se genera PRIMERO y orders/reviews muestrean sus `customer_id`. Si se invierte el orden, la FK deja de estar contenida y el detector no la encuentra.
- **`signup_date`/`ts` se escriben como texto ISO en el CSV** (`YYYY-MM-DD` / `YYYY-MM-DD HH:MM:SS`): es CSV, todo es texto; el profiler los promociona a datetime al leerlos.
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; mismo seed -> CSVs identicos byte a byte.
- **Reutiliza helpers privados** de `generate_synthetic_eda_table` (`_make_fakers`, `_make_latlon`, `_make_reviews`, `_amount_with_outliers`): no romper esas firmas sin actualizar esta funcion.
## Notas
Estructura generada:
| Archivo | PK | FK | Columnas clave |
|---|---|---|---|
| customers.csv | customer_id (uuid) | — | name, country, signup_date, latitude, longitude, email |
| orders.csv | order_id (uuid) | customer_id -> customers | amount (lognormal + outliers), category, ts |
| reviews.csv | review_id (uuid) | customer_id -> customers | review_text (multi-idioma, mediana palabras>=20), rating (1..5) |
orders tiene ~2x filas que customers y reviews ~1x. Todos los `customer_id` de orders
y reviews estan contenidos en customers (containment ⊆), por lo que la deteccion FK por
inclusion descubre las dos relaciones declaradas en `expected_relations`.
@@ -0,0 +1,177 @@
"""generate_synthetic_eda_folder — fixture multi-tabla relacionado para el EDA de base/carpeta.
Funcion impura (escribe CSVs a disco) y determinista por ``seed``: crea una
carpeta con 3 CSV RELACIONADOS (customers, orders, reviews) cuyo contenido esta
disenado para que el motor AutomaticEDA multi-tabla / `profile_database` detecte
las relaciones FK por containment de valores (orders.customer_id y
reviews.customer_id contenidos al 100% en customers.customer_id, por encima del
``min_inclusion=0.9`` que usa la deteccion).
Reutiliza los helpers de ``generate_synthetic_eda_table`` (texto multi-idioma,
lat/lon validas, amount con outliers, listas fijas de paises/categorias) para no
reimplementar logica.
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; devuelve
``{"status": "error", "error": str}`` ante cualquier fallo.
"""
import os
from .generate_synthetic_eda_table import (
_CATEGORIES,
_COUNTRIES,
_amount_with_outliers,
_make_fakers,
_make_latlon,
_make_reviews,
)
def generate_synthetic_eda_folder(out_dir, n_rows=2000, seed=42):
"""Genera una carpeta con 3 CSV relacionados (customers/orders/reviews).
customers es la tabla padre (PK ``customer_id`` uuid unica). orders y reviews
referencian ``customer_id`` muestreandolo de customers, de modo que TODOS sus
valores estan contenidos en customers (inclusion 1.0 -> FK detectable).
Funcion impura (escribe a disco) y determinista por ``seed``. NUNCA lanza.
Args:
out_dir: carpeta de salida. Se crea con ``mkdir -p`` si no existe.
n_rows: numero de clientes (customers). orders ~= 2*n_rows, reviews ~= n_rows.
Default 2000.
seed: semilla para Faker y numpy. Default 42.
Returns:
dict dict-no-throw. En exito::
{"status": "ok", "out_dir": ..., "files": {customers, orders, reviews},
"n_customers": ..., "n_orders": ..., "n_reviews": ...,
"expected_relations": [{from_table, from_col, to_table, to_col}, ...],
"seed": seed}
En error (sin lanzar)::
{"status": "error", "error": str}
"""
try:
import numpy as np
import pandas as pd
n = int(n_rows)
if n <= 0:
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
os.makedirs(out_dir, exist_ok=True)
fakers = _make_fakers(seed)
rng = np.random.default_rng(seed)
# ---------------- customers (tabla padre) ----------------
n_cust = n
customer_ids = [fakers["en_US"].uuid4() for _ in range(n_cust)]
names = [fakers["en_US"].name() for _ in range(n_cust)]
cust_country = rng.choice(_COUNTRIES, n_cust)
base = np.datetime64("2022-01-01")
signup_offsets = rng.integers(0, 730, n_cust)
signup_date = pd.to_datetime(base) + pd.to_timedelta(signup_offsets, unit="D")
signup_iso = [d.strftime("%Y-%m-%d") for d in signup_date]
lat, lon = _make_latlon(cust_country, rng)
cust_email = [fakers["en_US"].email() for _ in range(n_cust)]
customers = pd.DataFrame(
{
"customer_id": customer_ids,
"name": names,
"country": cust_country,
"signup_date": signup_iso,
"latitude": lat,
"longitude": lon,
"email": cust_email,
}
)
# ---------------- orders (FK -> customers) ----------------
n_orders = n_cust * 2
order_ids = [fakers["en_US"].uuid4() for _ in range(n_orders)]
order_cust = rng.choice(customer_ids, n_orders) # subset/multiset de customers
amount = _amount_with_outliers(n_orders, rng, n_extreme=10)
order_cat = rng.choice(_CATEGORIES, n_orders)
ts_offsets = rng.integers(0, 730 * 24 * 3600, n_orders)
ts = pd.to_datetime(np.datetime64("2022-01-01T00:00:00")) + pd.to_timedelta(
ts_offsets, unit="s"
)
ts_iso = [t.strftime("%Y-%m-%d %H:%M:%S") for t in ts]
orders = pd.DataFrame(
{
"order_id": order_ids,
"customer_id": order_cust,
"amount": amount,
"category": order_cat,
"ts": ts_iso,
}
)
# ---------------- reviews (FK -> customers) ----------------
n_reviews = n_cust
review_ids = [fakers["en_US"].uuid4() for _ in range(n_reviews)]
# Subconjunto de customers (no todos) -> containment estricto ⊆ customers.
rev_cust = rng.choice(customer_ids, n_reviews)
review_text = _make_reviews(n_reviews, rng, fakers, null_frac=0.0)
rating = rng.integers(1, 6, n_reviews)
reviews = pd.DataFrame(
{
"review_id": review_ids,
"customer_id": rev_cust,
"review_text": review_text,
"rating": rating,
}
)
files = {
"customers": os.path.join(out_dir, "customers.csv"),
"orders": os.path.join(out_dir, "orders.csv"),
"reviews": os.path.join(out_dir, "reviews.csv"),
}
customers.to_csv(files["customers"], index=False)
orders.to_csv(files["orders"], index=False)
reviews.to_csv(files["reviews"], index=False)
return {
"status": "ok",
"out_dir": out_dir,
"files": files,
"n_customers": n_cust,
"n_orders": n_orders,
"n_reviews": n_reviews,
"expected_relations": [
{
"from_table": "orders",
"from_col": "customer_id",
"to_table": "customers",
"to_col": "customer_id",
},
{
"from_table": "reviews",
"from_col": "customer_id",
"to_table": "customers",
"to_col": "customer_id",
},
],
"seed": seed,
}
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
return {"status": "error", "error": str(exc)}
if __name__ == "__main__":
import json
import sys
args = sys.argv[1:]
out = args[0] if len(args) > 0 else "/tmp/synthetic_eda_folder"
rows = int(args[1]) if len(args) > 1 else 2000
sd = int(args[2]) if len(args) > 2 else 42
print(json.dumps(generate_synthetic_eda_folder(out, rows, sd), indent=2))
@@ -0,0 +1,74 @@
"""Tests para generate_synthetic_eda_folder."""
import os
import statistics
import pandas as pd
from datascience.generate_synthetic_eda_folder import generate_synthetic_eda_folder
def test_genera_ok_y_archivos(tmp_path):
out = str(tmp_path / "folder")
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
assert res["status"] == "ok"
assert res["n_customers"] == 300
assert res["n_orders"] == 600
assert res["n_reviews"] == 300
for key in ("customers", "orders", "reviews"):
assert os.path.exists(res["files"][key])
# Relaciones esperadas declaradas.
rels = {(r["from_table"], r["to_table"]) for r in res["expected_relations"]}
assert ("orders", "customers") in rels
assert ("reviews", "customers") in rels
def test_determinismo_mismo_seed(tmp_path):
out1 = str(tmp_path / "f1")
out2 = str(tmp_path / "f2")
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
generate_synthetic_eda_folder(out2, n_rows=250, seed=11)
for name in ("customers.csv", "orders.csv", "reviews.csv"):
a = open(os.path.join(out1, name), "rb").read()
b = open(os.path.join(out2, name), "rb").read()
assert a == b, f"{name} difiere entre dos generaciones con el mismo seed"
def test_seeds_distintos_difieren(tmp_path):
out1 = str(tmp_path / "f1")
out2 = str(tmp_path / "f2")
generate_synthetic_eda_folder(out1, n_rows=250, seed=11)
generate_synthetic_eda_folder(out2, n_rows=250, seed=12)
a = open(os.path.join(out1, "customers.csv"), "rb").read()
b = open(os.path.join(out2, "customers.csv"), "rb").read()
assert a != b
def test_fk_containment(tmp_path):
out = str(tmp_path / "folder")
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
customers = pd.read_csv(res["files"]["customers"])
orders = pd.read_csv(res["files"]["orders"])
reviews = pd.read_csv(res["files"]["reviews"])
cust_ids = set(customers["customer_id"])
# Todos los customer_id de orders y reviews ⊆ customers.
assert set(orders["customer_id"]) <= cust_ids
assert set(reviews["customer_id"]) <= cust_ids
# customer_id es PK unica en customers.
assert customers["customer_id"].is_unique
assert orders["order_id"].is_unique
assert reviews["review_id"].is_unique
def test_review_text_mediana_palabras(tmp_path):
out = str(tmp_path / "folder")
res = generate_synthetic_eda_folder(out, n_rows=300, seed=42)
reviews = pd.read_csv(res["files"]["reviews"])
words = [len(str(t).split()) for t in reviews["review_text"].dropna()]
assert statistics.median(words) >= 20
def test_n_rows_invalido(tmp_path):
out = str(tmp_path / "folder")
res = generate_synthetic_eda_folder(out, n_rows=0, seed=42)
assert res["status"] == "error"
@@ -0,0 +1,82 @@
---
name: generate_synthetic_eda_table
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def generate_synthetic_eda_table(out_db_path: str, table: str = 'synthetic', n_rows: int = 2000, seed: int = 42) -> dict"
description: "Genera una tabla DuckDB sintetica (Faker + numpy, determinista por seed) cuyo contenido esta disenado para ACTIVAR el maximo de capitulos del motor AutomaticEDA del grupo eda: numericas continuas con correlacion lineal/no-lineal, numericas con outliers, categoricas desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie temporal, lat/lon validas, semanticos/PII (uuid/email/iban/phone) y nulos con patron MCAR/MAR. Fixture para evaluar el EDA de punta a punta. Estilo dict-no-throw: nunca lanza."
tags: [eda, synthetic, faker, testing, fixture, datascience]
params:
- name: out_db_path
desc: "Ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la tabla se reemplaza con CREATE OR REPLACE TABLE si ya existe."
- name: table
desc: "Nombre de la tabla a crear. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el DDL. Default 'synthetic'."
- name: n_rows
desc: "Numero de filas (clientes unicos). Cada fila es un cliente con id/email/iban/phone propios. Default 2000."
- name: seed
desc: "Semilla para Faker (Faker.seed) y numpy (np.random.default_rng). Mismo seed -> tabla identica byte a byte. Default 42."
output: "dict dict-no-throw. En exito {status:'ok', db_path, table, n_rows, columns:[19 nombres de columna], seed}. En error (sin lanzar, p.ej. nombre de tabla invalido o n_rows<=0) {status:'error', error:str}. Columnas: customer_id,email,iban,phone,income,spending,age,risk_score,tenure_months,engagement_quad,amount,n_purchases,country,category,plan,review,signup_date,latitude,longitude."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_genera_ok_y_columnas", "test_determinismo_mismo_seed", "test_seeds_distintos_difieren", "test_latlon_en_rango", "test_plan_solo_niveles_validos", "test_income_spending_co_nulos", "test_review_mediana_palabras_y_signup_datetime", "test_phone_matchea_regex_internacional", "test_outliers_y_correlaciones", "test_tabla_invalida_devuelve_error"]
test_file_path: "python/functions/datascience/generate_synthetic_eda_table_test.py"
file_path: "python/functions/datascience/generate_synthetic_eda_table.py"
---
## Ejemplo
```bash
# Genera /tmp/x.duckdb con la tabla `synthetic` (2000 filas, seed 42)
fn run generate_synthetic_eda_table /tmp/x.duckdb synthetic 2000 42
```
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import generate_synthetic_eda_table
res = generate_synthetic_eda_table("/tmp/x.duckdb", "synthetic", n_rows=2000, seed=42)
# res == {"status":"ok", "db_path":"/tmp/x.duckdb", "table":"synthetic",
# "n_rows":2000, "columns":[...19...], "seed":42}
# Luego perfilala con el grupo eda:
# fn run profile_table /tmp/x.duckdb synthetic
```
## Cuando usarla
- Cuando necesites un dataset de prueba REPRODUCIBLE para evaluar el motor AutomaticEDA de punta a punta: su contenido dispara, a proposito, num_distr, cat_distr, text_distr, correlacion, missingness (MCAR/MAR), modelos (PCA/KMeans/outliers), timeseries, geospatial, calidad, agregacion y los detectores semanticos / PII (`infer_semantic_type`).
- Cuando escribas tests de capitulos del EDA y quieras una tabla con una columna que active CADA detector sin montar datos a mano.
- Cuando quieras un fixture determinista (mismo seed -> misma tabla) para comparar el render del EDA entre versiones.
## Gotchas
- **Impura**: escribe a disco (crea/reutiliza el archivo DuckDB). Reemplaza la tabla destino con `CREATE OR REPLACE`.
- **Requiere `faker`, `duckdb`, `numpy` y `pandas`** instalados en el venv. Sin `faker` la generacion devuelve `{status:'error'}` (no lanza).
- **`signup_date` queda como TIMESTAMP/DATE en DuckDB** (se construye con `datetime64[ns]`), NO VARCHAR — condicion para que `detect_time_column` la elija y se active el capitulo timeseries. Si fuese VARCHAR, el detector de fecha fallaria.
- **El texto de `review` debe superar el gate de text_distr**: media de caracteres >= 50 y mediana de palabras >= 20. Por eso cada review concatena dos parrafos Faker (~50 palabras de mediana); no reducir el numero de frases o el capitulo text_distr no activa.
- **Determinismo dependiente del orden de llamadas**: se siembra `Faker.seed(seed)` + `np.random.default_rng(seed)` al inicio; cambiar el orden de las extracciones cambia la salida aunque el seed sea el mismo.
- **PII real-istica**: `email`/`iban`/`phone`/`customer_id` matchean los regex de `infer_semantic_type` (email/iban/phone_intl/uuid) al 100%; son datos sinteticos de Faker, no personas reales.
## Notas
Mapa columna -> detector que activa:
| Columna(s) | Tipo | Detector / capitulo |
|---|---|---|
| income, spending | num continua | correlacion POSITIVA fuerte (Pearson > 0.8) |
| age, risk_score | num continua | correlacion NEGATIVA |
| tenure_months, engagement_quad | num continua | relacion NO LINEAL (cuadratica) |
| amount, n_purchases | num + outliers | num_distr / outliers (cola pesada + extremos inyectados) |
| country (12), category (6), plan (3 desbalanceado) | categorica | cat_distr / agregacion (entropia baja en plan) |
| review | texto libre multi-idioma | text_distr (len_mean>=50, mediana palabras>=20) + duplicados exactos |
| signup_date | DATE/TIMESTAMP | timeseries |
| latitude, longitude | num [-90,90]/[-180,180] | geospatial (detect_latlon_columns) |
| customer_id, email, iban, phone | texto | semantic_type uuid/email/iban/phone_intl (PII) |
| income+spending (co-nulos 12%), risk_score (nulo si plan=alta), review (8%) | nulos con patron | missingness MCAR/MAR |
@@ -0,0 +1,314 @@
"""generate_synthetic_eda_table — fixture sintetico para ejercitar el motor AutomaticEDA.
Funcion impura (escribe un archivo DuckDB a disco) y determinista por ``seed``:
construye una unica tabla cuyo CONTENIDO esta disenado para ACTIVAR el maximo
numero de capitulos del motor AutomaticEDA del grupo `eda` (num_distr, cat_distr,
text_distr, correlacion, missingness, modelos, timeseries, geospatial, relaciones,
calidad, agregacion) y los detectores semanticos / PII (`infer_semantic_type`).
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; captura cualquier error y
devuelve ``{"status": "error", "error": str}``.
Determinismo: con el mismo ``seed`` el DataFrame y, por tanto, la tabla DuckDB
resultante son identicos byte a byte. Se siembra Faker (``Faker.seed``) y numpy
(``np.random.default_rng(seed)``) al inicio de cada generacion.
"""
import re
# Lista fija de paises (12 -> cardinalidad media para cat_distr / agregacion).
_COUNTRIES = [
"ES", "FR", "DE", "IT", "PT", "NL",
"BE", "US", "GB", "IE", "SE", "PL",
]
# Lista fija de categorias de producto (6 -> cardinalidad media).
_CATEGORIES = [
"electronics", "clothing", "home", "sports", "books", "toys",
]
# Niveles de plan con probabilidades DESBALANCEADAS (entropia baja para cat_distr).
_PLANS = ["baja", "media", "alta"]
_PLAN_PROBS = [0.70, 0.25, 0.05]
# Centroides (lat, lon) aproximados por pais: muestrean coordenadas validas
# dentro de [-90, 90] x [-180, 180] para que detect_latlon_columns las acepte.
_CENTROIDS = {
"ES": (40.4, -3.7), "FR": (46.6, 2.2), "DE": (51.1, 10.4), "IT": (41.9, 12.5),
"PT": (39.4, -8.2), "NL": (52.1, 5.3), "BE": (50.5, 4.5), "US": (39.0, -98.0),
"GB": (54.0, -2.0), "IE": (53.4, -8.0), "SE": (60.1, 18.6), "PL": (52.0, 19.1),
}
# Locales rotados para generar texto multi-idioma (es/en/fr).
_TEXT_LOCALES = ["es_ES", "en_US", "fr_FR"]
# Identificador SQL valido (DuckDB no parametriza el nombre de tabla en DDL).
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _make_fakers(seed):
"""Crea los Faker por locale tras sembrar el generador compartido.
``Faker.seed(seed)`` siembra el ``random.Random`` compartido por todas las
instancias Faker que usan el generador por defecto, asi que el orden de
llamadas determina por completo la salida (determinismo).
"""
from faker import Faker
Faker.seed(seed)
es_es, en_us, fr_fr = (Faker(loc) for loc in _TEXT_LOCALES)
return {"es_ES": es_es, "en_US": en_us, "fr_FR": fr_fr}
# Texto duplicado canonico (multi-idioma, > 20 palabras) que se inyecta en una
# fraccion de las filas para que el analisis de duplicados exactos lo detecte.
_DUP_REVIEW = (
"Servicio excelente y entrega muy rapida, el producto llego en perfecto "
"estado y coincide con la descripcion publicada en la tienda. The customer "
"support team answered every question quickly and the packaging was solid "
"and well protected during shipping. Je recommande vivement ce vendeur a "
"tous mes amis, la qualite est vraiment au rendez-vous cette fois."
)
def _make_reviews(n, rng, fakers, dup_frac=0.04, null_frac=0.08):
"""Genera ``n`` reviews de texto libre largo multi-idioma (es/en/fr).
Cada review concatena dos parrafos de Faker en el idioma rotado por fila, de
modo que la MEDIANA de palabras por documento queda muy por encima de 20 y la
media de caracteres por encima de 50 (gates del capitulo text_distr). Se
inyectan duplicados exactos (``dup_frac``) y nulos (``null_frac``).
Devuelve una ``list`` de ``str`` o ``None`` (nulos) de longitud ``n``.
"""
# Numero de frases por parrafo precomputado con numpy (determinista) para no
# interleavar draws de rng dentro del bucle de faker.
nb1 = rng.integers(4, 8, n)
nb2 = rng.integers(3, 7, n)
reviews = []
for i in range(n):
fk = fakers[_TEXT_LOCALES[i % 3]]
p1 = fk.paragraph(nb_sentences=int(nb1[i]))
p2 = fk.paragraph(nb_sentences=int(nb2[i]))
reviews.append(f"{p1} {p2}")
# Duplicados exactos: una fraccion de filas comparte un review identico.
if n > 0 and dup_frac > 0:
k_dup = max(1, int(n * dup_frac))
dup_idx = rng.choice(n, size=min(k_dup, n), replace=False)
for j in dup_idx:
reviews[int(j)] = _DUP_REVIEW
# Nulos MCAR-ish: una fraccion de filas al azar queda en None.
if n > 0 and null_frac > 0:
k_null = max(1, int(n * null_frac))
null_idx = rng.choice(n, size=min(k_null, n), replace=False)
for j in null_idx:
reviews[int(j)] = None
return reviews
def _make_phone_intl(rng):
"""Construye un telefono en formato internacional que casa phone_intl.
Regex objetivo (fullmatch): ``\\+\\d[\\d\\s()-]{6,}\\d``. Empieza por '+',
digito, bloques de digitos separados por espacios y termina en digito.
"""
cc = int(rng.integers(1, 99))
a = int(rng.integers(100, 999))
b = int(rng.integers(100, 999))
c = int(rng.integers(100, 999))
return f"+{cc} {a} {b} {c}"
def _make_latlon(countries, rng):
"""Devuelve (latitudes, longitudes) muestreando centroides de pais + jitter.
Mantiene los valores dentro de [-90, 90] y [-180, 180] (validez exigida por
detect_latlon_columns). El jitter es pequeno para no salirse del rango.
"""
import numpy as np
lats = np.empty(len(countries), dtype=float)
lons = np.empty(len(countries), dtype=float)
jitter_lat = rng.normal(0.0, 0.5, len(countries))
jitter_lon = rng.normal(0.0, 0.5, len(countries))
for i, code in enumerate(countries):
base_lat, base_lon = _CENTROIDS[code]
lats[i] = float(np.clip(base_lat + jitter_lat[i], -90.0, 90.0))
lons[i] = float(np.clip(base_lon + jitter_lon[i], -180.0, 180.0))
return lats, lons
def _amount_with_outliers(n, rng, n_extreme=6, factor=50.0):
"""Serie lognormal de cola pesada con ~``n_extreme`` outliers altos (x``factor``)."""
import numpy as np
amount = rng.lognormal(mean=4.0, sigma=1.0, size=n)
if n > 0 and n_extreme > 0:
idx = rng.choice(n, size=min(n_extreme, n), replace=False)
amount[idx] = amount[idx] * factor
return amount
def generate_synthetic_eda_table(
out_db_path, table="synthetic", n_rows=2000, seed=42
):
"""Genera una tabla DuckDB sintetica que activa el maximo de capitulos del EDA.
Construye un DataFrame de ``n_rows`` clientes unicos con columnas elegidas para
disparar detectores concretos del motor AutomaticEDA (numericas continuas con
correlaciones lineal/no-lineal, numericas con outliers, categoricas
desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie
temporal, lat/lon validas, semanticos/PII y nulos con patron MCAR/MAR), y la
materializa en ``out_db_path`` con ``CREATE OR REPLACE TABLE``.
Funcion impura (escribe a disco) y determinista por ``seed``: con el mismo
seed la tabla resultante es identica byte a byte. NUNCA lanza.
Args:
out_db_path: ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la
tabla se reemplaza si ya existe.
table: nombre de la tabla a crear. Se valida contra
``^[A-Za-z_][A-Za-z0-9_]*$`` y se cita en el DDL.
n_rows: numero de filas (clientes unicos). Default 2000.
seed: semilla para Faker y numpy. Default 42.
Returns:
dict dict-no-throw. En exito::
{"status": "ok", "db_path": out_db_path, "table": table,
"n_rows": n_rows, "columns": [<nombres de columna>], "seed": seed}
En error (sin lanzar)::
{"status": "error", "error": str}
"""
try:
import duckdb
import numpy as np
import pandas as pd
if not _IDENT_RE.match(table or ""):
return {
"status": "error",
"error": (
f"nombre de tabla invalido: {table!r} "
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
),
}
n = int(n_rows)
if n <= 0:
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
fakers = _make_fakers(seed)
rng = np.random.default_rng(seed)
# --- Numericas continuas (distinct alto, correlaciones) ---
income = np.clip(rng.normal(40000.0, 12000.0, n), 1000.0, None)
spending = income * 0.35 + rng.normal(0.0, 2000.0, n) # corr POSITIVA fuerte
age = rng.integers(18, 91, n)
risk_score = 90.0 - age * 0.7 + rng.normal(0.0, 5.0, n) # corr NEGATIVA con age
tenure_months = rng.uniform(0.0, 60.0, n)
engagement_quad = ((tenure_months - 30.0) ** 2) / 30.0 + rng.normal(0.0, 1.0, n)
# --- Numericas con outliers claros ---
amount = _amount_with_outliers(n, rng)
n_purchases = rng.poisson(3.0, n).astype(float)
if n > 0:
k_hi = min(max(1, int(n * 0.002)) + 2, n) # ~3-5 valores altisimos
hi_idx = rng.choice(n, size=k_hi, replace=False)
n_purchases[hi_idx] = rng.integers(200, 400, len(hi_idx)).astype(float)
# --- Categoricas ---
country = rng.choice(_COUNTRIES, n)
category = rng.choice(_CATEGORIES, n)
plan = rng.choice(_PLANS, n, p=_PLAN_PROBS)
# --- Texto libre multi-idioma con duplicados ---
review = _make_reviews(n, rng, fakers)
# --- Fecha / serie temporal (rango ~2 anios, cadencia ~diaria) ---
base = np.datetime64("2022-01-01")
offsets = rng.integers(0, 730, n)
signup_date = pd.to_datetime(base) + pd.to_timedelta(offsets, unit="D")
# --- Geo lat/lon validas ---
latitude, longitude = _make_latlon(country, rng)
# --- Semanticos / PII (>=80% match para infer_semantic_type) ---
customer_id = [fakers["en_US"].uuid4() for _ in range(n)]
email = [fakers["en_US"].email() for _ in range(n)]
iban = [fakers["en_US"].iban() for _ in range(n)]
phone = [_make_phone_intl(rng) for _ in range(n)]
df = pd.DataFrame(
{
"customer_id": customer_id,
"email": email,
"iban": iban,
"phone": phone,
"income": income,
"spending": spending,
"age": age,
"risk_score": risk_score,
"tenure_months": tenure_months,
"engagement_quad": engagement_quad,
"amount": amount,
"n_purchases": n_purchases,
"country": country,
"category": category,
"plan": plan,
"review": review,
"signup_date": signup_date,
"latitude": latitude,
"longitude": longitude,
}
)
# --- Nulos con patron ---
# income + spending faltan JUNTAS en las MISMAS filas (co-ocurrencia -> MAR).
k_co = max(1, int(n * 0.12))
co_idx = rng.choice(n, size=min(k_co, n), replace=False)
df.loc[co_idx, "income"] = np.nan
df.loc[co_idx, "spending"] = np.nan
# risk_score falta cuando plan == "alta" (mas una pizca de azar) -> MAR.
risk_mask = (df["plan"] == "alta").to_numpy() | (rng.random(n) < 0.02)
df.loc[risk_mask, "risk_score"] = np.nan
columns = list(df.columns)
con = duckdb.connect(out_db_path)
try:
con.register("df_synth_eda", df)
con.execute(
f'CREATE OR REPLACE TABLE "{table}" AS SELECT * FROM df_synth_eda'
)
con.unregister("df_synth_eda")
finally:
con.close()
return {
"status": "ok",
"db_path": out_db_path,
"table": table,
"n_rows": n,
"columns": columns,
"seed": seed,
}
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
return {"status": "error", "error": str(exc)}
if __name__ == "__main__":
import json
import sys
args = sys.argv[1:]
db_path = args[0] if len(args) > 0 else "/tmp/synthetic_eda.duckdb"
tbl = args[1] if len(args) > 1 else "synthetic"
rows = int(args[2]) if len(args) > 2 else 2000
sd = int(args[3]) if len(args) > 3 else 42
print(json.dumps(generate_synthetic_eda_table(db_path, tbl, rows, sd), indent=2))
@@ -0,0 +1,129 @@
"""Tests para generate_synthetic_eda_table."""
import os
import re
import statistics
import duckdb
from datascience.generate_synthetic_eda_table import generate_synthetic_eda_table
_EXPECTED_COLS = [
"customer_id", "email", "iban", "phone", "income", "spending", "age",
"risk_score", "tenure_months", "engagement_quad", "amount", "n_purchases",
"country", "category", "plan", "review", "signup_date", "latitude", "longitude",
]
_PHONE_RE = re.compile(r"\+\d[\d\s()-]{6,}\d")
def _load(db_path, table="synthetic"):
con = duckdb.connect(db_path, read_only=True)
try:
return con.execute(f'SELECT * FROM "{table}"').fetch_df()
finally:
con.close()
def test_genera_ok_y_columnas(tmp_path):
db = str(tmp_path / "t.duckdb")
res = generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
assert res["status"] == "ok"
assert res["table"] == "synthetic"
assert res["n_rows"] == 500
assert res["columns"] == _EXPECTED_COLS
assert os.path.exists(db)
df = _load(db)
assert list(df.columns) == _EXPECTED_COLS
assert len(df) == 500
def test_determinismo_mismo_seed(tmp_path):
db1 = str(tmp_path / "a.duckdb")
db2 = str(tmp_path / "b.duckdb")
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=7)
df1 = _load(db1).astype(str)
df2 = _load(db2).astype(str)
# Misma semilla -> tabla identica fila a fila.
assert df1.equals(df2)
def test_seeds_distintos_difieren(tmp_path):
db1 = str(tmp_path / "a.duckdb")
db2 = str(tmp_path / "b.duckdb")
generate_synthetic_eda_table(db1, "synthetic", n_rows=400, seed=7)
generate_synthetic_eda_table(db2, "synthetic", n_rows=400, seed=8)
df1 = _load(db1).astype(str)
df2 = _load(db2).astype(str)
assert not df1.equals(df2)
def test_latlon_en_rango(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
df = _load(db)
assert df["latitude"].between(-90, 90).all()
assert df["longitude"].between(-180, 180).all()
def test_plan_solo_niveles_validos(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
df = _load(db)
assert set(df["plan"].unique()) <= {"baja", "media", "alta"}
def test_income_spending_co_nulos(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=600, seed=42)
df = _load(db)
inc_null = df["income"].isna()
sp_null = df["spending"].isna()
# income y spending faltan exactamente en las MISMAS filas.
assert (inc_null == sp_null).all()
assert inc_null.sum() > 0
def test_review_mediana_palabras_y_signup_datetime(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
df = _load(db)
words = [len(str(r).split()) for r in df["review"].dropna()]
assert statistics.median(words) >= 20
# signup_date debe ser datetime/date en DuckDB (no VARCHAR).
con = duckdb.connect(db, read_only=True)
try:
dtype = con.execute(
"SELECT column_type FROM (DESCRIBE synthetic) WHERE column_name='signup_date'"
).fetchone()[0]
finally:
con.close()
assert dtype.upper().startswith(("DATE", "TIMESTAMP"))
def test_phone_matchea_regex_internacional(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=500, seed=42)
df = _load(db)
phones = [p for p in df["phone"].tolist() if p is not None]
assert all(_PHONE_RE.fullmatch(str(p)) for p in phones)
def test_outliers_y_correlaciones(tmp_path):
db = str(tmp_path / "t.duckdb")
generate_synthetic_eda_table(db, "synthetic", n_rows=800, seed=42)
df = _load(db)
# amount tiene cola con outliers altos evidentes.
assert df["amount"].max() > df["amount"].median() * 20
# correlacion positiva fuerte income~spending y negativa age~risk_score.
sub = df[["income", "spending"]].dropna()
assert sub["income"].corr(sub["spending"]) > 0.8
sub2 = df[["age", "risk_score"]].dropna()
assert sub2["age"].corr(sub2["risk_score"]) < -0.6
def test_tabla_invalida_devuelve_error(tmp_path):
db = str(tmp_path / "t.duckdb")
res = generate_synthetic_eda_table(db, "bad name;", n_rows=10, seed=42)
assert res["status"] == "error"
assert "invalido" in res["error"]
@@ -0,0 +1,86 @@
---
name: load_bq_table_to_duckdb
kind: function
lang: py
domain: datascience
version: "1.1.0"
purity: impure
signature: "def load_bq_table_to_duckdb(table_fqn: str, duckdb_path: str, dest_table: str = '', sample_frac: float = None, max_rows: int = 0, project_id: str = '', pseudonymize_cols: list = None) -> dict"
description: "Adaptador BigQuery -> DuckDB local para el grupo eda. Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto COMPLETA, todas las filas; muestreo opt-in con sample_frac), de modo que las funciones del grupo de capacidad eda (que solo hablan DuckDB/PostgreSQL) puedan perfilarla. Fetch via BigQuery Storage Read API (Arrow) con fallback REST. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)."
tags: [eda, bigquery, duckdb, datascience]
params:
- name: table_fqn
desc: "FQN completo de la tabla/vista BigQuery: `project.dataset.table`."
- name: duckdb_path
desc: "Ruta del archivo DuckDB local donde materializar la tabla (se crea/sobrescribe la tabla dest)."
- name: dest_table
desc: "Nombre de la tabla DuckDB destino. Vacío = último segmento del FQN, saneado."
- name: sample_frac
desc: "None (DEFAULT) = FULL, trae todas las filas. Un float en (0,1) activa el muestreo opt-in con `WHERE rand() < frac` (~frac del total). Vistas no admiten TABLESAMPLE, por eso rand()."
- name: max_rows
desc: "Tope duro opcional de filas (LIMIT). 0 (DEFAULT) = sin tope. Se combina con sample_frac si ambos se pasan."
- name: project_id
desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC."
- name: pseudonymize_cols
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad."
output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized}. En error {status:'error', error}."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/load_bq_table_to_duckdb.py"
---
## Ejemplo
```python
from datascience import load_bq_table_to_duckdb
# FULL por defecto: trae TODAS las filas de la vista (3,8M) a DuckDB.
r = load_bq_table_to_duckdb(
"autingo-159109.customer_marts.customer_profile",
"/tmp/eda_bq.duckdb",
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
)
print(r["table"], r["n_rows_fetched"], "de", r["n_rows_source"], "sampled=", r["sampled"])
# Muestreo opt-in: ~5 % de las filas.
r = load_bq_table_to_duckdb(
"autingo-159109.customer_marts.customer_profile",
"/tmp/eda_bq_sample.duckdb",
sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
)
```
## Cuando usarla
- Antes de perfilar una tabla/vista de BigQuery con el grupo `eda` (que solo habla DuckDB/PostgreSQL): trae el origen COMPLETO a DuckDB local (o una muestra con `sample_frac`) con seudonimización PII.
- Cuando necesites un puente único BigQuery -> DuckDB local -> grupo `eda` sin escribir el bridge inline cada vez.
- Cuando quieras que un EDA sobre datos de negocio conserve valor analítico (cardinalidad, nulos, distribución) sin incrustar datos personales reales.
## Gotchas
- **Impura**: hace I/O de red (BigQuery) + escritura a disco (DuckDB). Requiere ADC configurado (`gcloud auth application-default login`).
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC arrastra un quota project ajeno (memoria `bq_direct_quota_project`).
- **TABLESAMPLE no funciona en vistas**: el muestreo (opt-in, `sample_frac`) usa `WHERE rand() < frac` (aplicable a tablas y vistas). `max_rows` es un `LIMIT` como tope duro opcional.
- **FULL por defecto**: `sample_frac=None` trae TODAS las filas. Trae el resultado a RAM como DataFrame de pandas antes de materializar en DuckDB, así que una tabla de muchos millones × muchas columnas puede consumir varios GB. Para tablas enormes que no quepan, pasa `sample_frac` (muestra) o `max_rows` (tope). El fetch usa el BigQuery Storage Read API (Arrow) cuando `google-cloud-bigquery-storage` + `pyarrow` están disponibles — mucho más rápido que REST para millones de filas; si no, cae al conversor REST automáticamente.
- **La seudonimización es un hash unidireccional** (SHA-1 truncado a 12 hex): no es reversible, correcto para EDA. Preserva nulos, cardinalidad y patrón de faltantes, pero NO permite recuperar el valor original.
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query) devuelve `{status:'error', error:str}`.
## Notas
Adaptador del grupo de capacidad `eda`: el resto de funciones del grupo perfilan
DuckDB/PostgreSQL, pero no hablan BigQuery de forma nativa. Esta función cubre ese
hueco materializando una sola tabla DuckDB desde el DataFrame resultante de la
query BigQuery. El nombre de tabla destino se sanea (`^[A-Za-z_][A-Za-z0-9_]*$`)
antes de citarlo en el `CREATE OR REPLACE TABLE`.
## Capability growth log
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explícito. Fetch acelerado via BigQuery Storage Read API (Arrow) con fallback REST. Preferencia estándar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario.
@@ -0,0 +1,157 @@
"""load_bq_table_to_duckdb — adaptador BigQuery -> DuckDB local para el grupo `eda`.
Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto
COMPLETA todas las filas o una muestra si se pasa `sample_frac`), de modo que
las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla
DuckDB desde un DataFrame de pandas.
Modo por defecto = FULL: `sample_frac=None` trae la vista/tabla entera (preferencia
estándar del usuario: los EDA se corren sobre el total salvo que se pida lo
contrario). El muestreo es opt-in explícito: `sample_frac=0.05` trae ~5 %; `max_rows`
es un tope duro opcional (0 = sin tope). El fetch usa el BigQuery Storage Read API
(Arrow) cuando está disponible, con fallback al conversor REST.
Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando
nulos, cardinalidad y patrón de faltantes pero sin volcar el valor real (DNI,
nombre, email, teléfono, etc.). El EDA conserva su valor analítico sin incrustar
datos personales reales.
Autenticación: ADC (gcloud auth). Aplica creds.with_quota_project(None) para
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno.
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
"""
import hashlib
import re
_FQN_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
def _pseudonymize_series(values):
"""Hash SHA-1 truncado (12 hex) de cada valor no nulo; conserva None/NaN."""
import pandas as pd
out = []
for v in values:
if v is None or (isinstance(v, float) and pd.isna(v)) or (
not isinstance(v, (list, dict)) and pd.isna(v) if _safe_isna(v) else False
):
out.append(None)
else:
h = hashlib.sha1(str(v).encode("utf-8")).hexdigest()[:12]
out.append(h)
return out
def _safe_isna(v):
import pandas as pd
try:
return bool(pd.isna(v))
except (TypeError, ValueError):
return False
def load_bq_table_to_duckdb(
table_fqn: str,
duckdb_path: str,
dest_table: str = "",
sample_frac: float = None,
max_rows: int = 0,
project_id: str = "",
pseudonymize_cols: list = None,
) -> dict:
try:
import duckdb
import google.auth
from google.cloud import bigquery
if not table_fqn or not _FQN_RE.match(table_fqn):
return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"}
# dest_table: derivar del último segmento del FQN si no se pasa.
dest = dest_table or table_fqn.split(".")[-1]
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
creds, adc_project = google.auth.default(
scopes=["https://www.googleapis.com/auth/bigquery"]
)
if hasattr(creds, "with_quota_project"):
creds = creds.with_quota_project(None)
proj = project_id or table_fqn.split(".")[0] or adc_project
client = bigquery.Client(project=proj, credentials=creds)
# Conteo de filas de origen.
cnt = client.query(
f"SELECT COUNT(*) AS n FROM `{table_fqn}`"
).result()
n_source = 0
for row in cnt:
n_source = int(row["n"])
# Modo por defecto = FULL (sample_frac=None -> todas las filas). El
# muestreo es opt-in: sample_frac in (0,1) muestrea esa fracción con
# `WHERE rand() < frac` (aplicable a tablas y vistas; TABLESAMPLE no va
# en vistas). max_rows>0 es un tope duro opcional (LIMIT); 0 = sin tope.
sampled = False
where = ""
if sample_frac is not None and 0 < float(sample_frac) < 1:
where = f" WHERE rand() < {float(sample_frac)}"
sampled = True
limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
sql = f"SELECT * FROM `{table_fqn}`{where}{limit}"
# Fetch: BigQuery Storage Read API (Arrow, rápido para millones de filas)
# con fallback al conversor REST si la lib no está o falla.
try:
df = client.query(sql).result().to_dataframe(create_bqstorage_client=True)
except Exception: # noqa: BLE001
df = client.query(sql).result().to_dataframe(create_bqstorage_client=False)
n_fetched = len(df)
# Normalizar dtypes de db-dtypes: el conversor REST de BigQuery mapea las
# columnas DATE/TIME a las extension dtypes `dbdate`/`dbtime` de db-dtypes,
# que DuckDB NO reconoce al registrar el DataFrame ("Data type 'dbdate' not
# recognized"). Se convierten a tipos estándar que DuckDB sí ingiere: DATE
# -> datetime64[ns], TIME -> string. El resto de dtypes (datetime64 de
# TIMESTAMP, Int64/boolean nullable, object) los acepta DuckDB tal cual.
import pandas as pd
for col in df.columns:
dt = str(df[col].dtype)
if dt == "dbdate":
df[col] = pd.to_datetime(df[col], errors="coerce")
elif dt == "dbtime":
df[col] = df[col].astype("string").astype(object)
# Seudonimización de columnas PII antes de escribir a disco.
pseudo_applied = []
for col in (pseudonymize_cols or []):
if col in df.columns:
df[col] = _pseudonymize_series(df[col].tolist())
pseudo_applied.append(col)
# Materializar a DuckDB (una tabla desde el DataFrame).
con = duckdb.connect(duckdb_path)
try:
con.register("_src_df", df)
con.execute(f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df')
con.unregister("_src_df")
finally:
con.close()
return {
"status": "ok",
"duckdb_path": duckdb_path,
"table": dest,
"n_rows_source": n_source,
"n_rows_fetched": n_fetched,
"sampled": sampled,
"sample_frac": float(sample_frac) if sampled else None,
"columns": list(df.columns),
"pseudonymized": pseudo_applied,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,100 @@
---
name: preregister_hypothesis
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict"
description: "Pre-registra (congela) la hipotesis y el plan de analisis de un paper ANTES de mirar los datos: antidoto al HARKing (Hypothesizing After the Results are Known). Escribe/actualiza <paper_dir>/preregistration.md con un frontmatter (paper_slug, frozen_at, content_hash, status) y un cuerpo markdown DETERMINISTA derivado de (hypotheses, analysis_plan) (mismo input -> mismo cuerpo byte a byte, claves ordenadas alfabeticamente). El content_hash es sha256 del cuerpo NORMALIZADO (strip por linea + colapso de blancos), nunca del frontmatter. Una vez status=frozen es INMUTABLE: re-congelar con el mismo contenido es idempotente (no reescribe, devuelve unchanged) y re-congelar con contenido distinto se RECHAZA (no sobrescribe, devuelve error) para que no se pueda ajustar la hipotesis a los resultados. Estilo dict-no-throw: nunca lanza."
tags: [papers, preregistration, reproducibility, anti-harking, python]
params:
- name: paper_dir
desc: "ruta del directorio del paper, p.ej. 'papers/0001-mi-paper'. Debe existir (no se crea aqui). El paper_slug del frontmatter es el basename del dir. Si no existe o no es str -> {status:error, path, note} sin crash ni creacion."
- name: hypotheses
desc: "dict de hipotesis, p.ej. {'h0': 'no hay diferencia ...', 'h1': 'el grupo A > grupo B ...'}. Se renderiza en la seccion '## Hypotheses' con una linea por clave, ordenadas alfabeticamente para determinismo."
- name: analysis_plan
desc: "dict con el plan de analisis, p.ej. {'test': 'welch_t_test', 'effect_size_metric': 'cohens_d', 'decision_rule': 'rechazar H0 si p<0.05 tras Holm y |d|>=0.5', 'planned_n': 100, 'multiple_correction': 'holm'}. Se renderiza en '## Analysis plan' con una linea por clave (ordenadas alfabeticamente). Acepta valores no-str (int, etc.)."
output: "dict dict-no-throw (NUNCA lanza). status='frozen' cuando escribe el archivo por primera vez o congela un draft previo ({status, path, content_hash, frozen_at}). status='unchanged' cuando ya estaba frozen con el mismo content_hash: no reescribe y preserva el archivo byte-identico incl. el frozen_at original ({status, path, content_hash, frozen_at}). status='error' cuando paper_dir no existe, ya esta frozen con un hash distinto (rechazo anti-HARKing, no sobrescribe), inputs invalidos o error de I/O ({status, path, note, [content_hash]})."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [hashlib]
tested: true
tests: ["test_golden_congela_y_escribe_archivo", "test_idempotente_mismo_input_no_reescribe", "test_inmutabilidad_anti_harking_rechaza_contenido_distinto", "test_error_paper_dir_inexistente_no_crash_no_crea"]
test_file_path: "python/functions/datascience/preregister_hypothesis_test.py"
file_path: "python/functions/datascience/preregister_hypothesis.py"
---
## Ejemplo
```python
import os, tempfile
from datascience import preregister_hypothesis
# Un directorio de paper que ya existe.
paper_dir = tempfile.mkdtemp(prefix="0001-")
hypotheses = {
"h0": "no hay diferencia entre el grupo A y el grupo B",
"h1": "el grupo A tiene mayor conversion que el grupo B",
}
analysis_plan = {
"test": "welch_t_test",
"effect_size_metric": "cohens_d",
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
"planned_n": 100,
"multiple_correction": "holm",
}
# 1) Primera vez: congela y escribe <paper_dir>/preregistration.md
r1 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
print(r1["status"]) # -> "frozen"
print(r1["content_hash"]) # sha256 del cuerpo
# 2) Mismo input: idempotente, no reescribe.
r2 = preregister_hypothesis(paper_dir, hypotheses, analysis_plan)
print(r2["status"]) # -> "unchanged"
# 3) Cambiar la hipotesis tras congelar (HARKing): rechazado, archivo intacto.
r3 = preregister_hypothesis(paper_dir, {"h0": "...", "h1": "otra cosa"}, analysis_plan)
print(r3["status"]) # -> "error"
```
## Cuando usarla
Llamala al ARRANCAR el analisis de un paper, antes de tocar los datos, para
dejar por escrito (y firmado por hash) que vas a probar y como vas a decidir.
Es el primer paso de un flujo reproducible: pre-registras la hipotesis y el plan
(`test`, `effect_size_metric`, `decision_rule`, `planned_n`,
`multiple_correction`), y solo despues corres el analisis y comparas con lo
pre-registrado. Si mas tarde el analisis "descubre" otra hipotesis que encaja
mejor con los datos, el pre-registro congelado deja en evidencia el cambio: no se
puede reescribir. Combinala con `effect_size_cohens_d` y `fdr_correction` para
cerrar el plan declarado (effect size + correccion de multiples comparaciones).
## Gotchas
- **Inmutabilidad (el corazon)**: una vez `status: frozen`, el pre-registro NO se
puede editar. Re-congelar con el MISMO contenido es idempotente (`unchanged`,
no reescribe, preserva incluso el `frozen_at` original). Re-congelar con
contenido DISTINTO devuelve `error` y deja el archivo intacto: asi se mata el
HARKing. Para cambiar de verdad la hipotesis hay que borrar el archivo a mano y
asumir explicitamente que ya no es un pre-registro valido.
- **dict-no-throw**: la funcion NUNCA lanza. Cualquier error previsible
(directorio inexistente, inputs no-dict, fallo de I/O, excepcion inesperada) se
captura y se devuelve como `{"status": "error", "note": ...}`. Siempre incluye
`path` (la ruta esperada del `preregistration.md`).
- **El hash es SOLO del cuerpo, nunca del frontmatter**: el frontmatter contiene
el propio `content_hash` y el `frozen_at` (timestamp), asi que incluirlos en el
hash seria circular y romperia la idempotencia. El cuerpo se normaliza antes de
hashear (strip por linea + colapso de lineas en blanco + strip final): cambios
irrelevantes de whitespace no alteran el hash, pero cambios de contenido SI.
- **Determinismo**: el cuerpo se genera con las claves de `hypotheses` y
`analysis_plan` ordenadas alfabeticamente, de modo que el orden de insercion del
dict no afecta al hash. Mismo `(hypotheses, analysis_plan)` -> mismo cuerpo y
mismo hash, byte a byte.
- **No crea el directorio del paper**: si `paper_dir` no existe, devuelve `error`
sin crear nada (ni el dir ni el archivo).
@@ -0,0 +1,202 @@
"""Congela (pre-registra) la hipotesis y el plan de analisis de un paper.
Anti-HARKing (Hypothesizing After the Results are Known): el pre-registro fija
la hipotesis y el plan de analisis ANTES de mirar los datos. Una vez congelado
(``status: frozen``) es INMUTABLE: cualquier intento posterior de re-congelar con
un contenido distinto se RECHAZA en vez de sobrescribir, de modo que no se puede
"ajustar" la hipotesis a los resultados despues de verlos.
Escribe/actualiza ``<paper_dir>/preregistration.md`` con un frontmatter
(``paper_slug``, ``frozen_at``, ``content_hash``, ``status``) y un cuerpo
markdown DETERMINISTA derivado de ``(hypotheses, analysis_plan)``.
Estilo dict-no-throw: NUNCA lanza; cualquier error previsible se captura y se
devuelve como ``{"status": "error", "note": ...}``.
"""
import hashlib
import os
from datetime import datetime, timezone
def _build_body(hypotheses: dict, analysis_plan: dict) -> str:
"""Construye el cuerpo markdown del pre-registro de forma DETERMINISTA.
Mismo ``(hypotheses, analysis_plan)`` -> mismo cuerpo byte a byte. Las claves
se ordenan alfabeticamente para no depender del orden de insercion del dict.
"""
lines = ["## Hypotheses", ""]
for k in sorted(hypotheses.keys()):
lines.append(f"- **{k}**: {hypotheses[k]}")
lines.append("")
lines.append("## Analysis plan")
lines.append("")
for k in sorted(analysis_plan.keys()):
lines.append(f"- **{k}**: {analysis_plan[k]}")
return "\n".join(lines)
def _normalize(body: str) -> str:
"""Normaliza el cuerpo para el hash: strip por linea + colapsa blancos.
Cambios irrelevantes de whitespace (espacios al final, dobles lineas en
blanco) no alteran el hash; cambios de contenido SI. Esto hace el hash
robusto sin perder la capacidad de detectar ediciones reales.
"""
out = []
prev_blank = False
for raw in body.splitlines():
line = raw.strip()
if line == "":
if prev_blank:
continue
prev_blank = True
else:
prev_blank = False
out.append(line)
return "\n".join(out).strip()
def _content_hash(body: str) -> str:
"""sha256 hex del cuerpo NORMALIZADO (nunca del frontmatter)."""
return hashlib.sha256(_normalize(body).encode("utf-8")).hexdigest()
def _parse_frontmatter(text: str) -> dict:
"""Parsea el frontmatter ``--- ... ---`` simple (key: value) de un .md."""
if not text.startswith("---"):
return {}
parts = text.split("---", 2)
if len(parts) < 3:
return {}
fm = {}
for line in parts[1].splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
fm[key.strip()] = value.strip()
return fm
def _render_file(slug: str, frozen_at: str, content_hash: str, body: str) -> str:
"""Compone el archivo completo: frontmatter frozen + cuerpo."""
return (
"---\n"
f"paper_slug: {slug}\n"
f"frozen_at: {frozen_at}\n"
f"content_hash: {content_hash}\n"
"status: frozen\n"
"---\n"
"\n"
f"{body}\n"
)
def preregister_hypothesis(paper_dir: str, hypotheses: dict, analysis_plan: dict) -> dict:
"""Congela la hipotesis y el plan de analisis de un paper (anti-HARKing).
Escribe ``<paper_dir>/preregistration.md`` con frontmatter ``status: frozen``
y un cuerpo markdown determinista. Una vez congelado es inmutable.
Args:
paper_dir: ruta del directorio del paper (p.ej. ``"papers/0001-mi-paper"``).
El ``paper_slug`` es el basename del directorio. Debe existir.
hypotheses: dict de hipotesis, p.ej.
``{"h0": "no hay diferencia ...", "h1": "grupo A > grupo B ..."}``.
analysis_plan: dict con el plan, p.ej.
``{"test": "welch_t_test", "effect_size_metric": "cohens_d",
"decision_rule": "...", "planned_n": 100, "multiple_correction": "holm"}``.
Returns:
dict dict-no-throw (NUNCA lanza). Claves segun el caso:
- frozen: {"status": "frozen", "path", "content_hash", "frozen_at"}
- unchanged: {"status": "unchanged", "path", "content_hash", "frozen_at"}
- error: {"status": "error", "path", "note", ...}
"""
expected_path = os.path.join(paper_dir, "preregistration.md")
try:
# 1) El directorio del paper debe existir; no se crea aqui.
if not isinstance(paper_dir, str) or not os.path.isdir(paper_dir):
return {
"status": "error",
"path": expected_path,
"note": f"paper_dir no existe: {paper_dir}",
}
if not isinstance(hypotheses, dict) or not isinstance(analysis_plan, dict):
return {
"status": "error",
"path": expected_path,
"note": "hypotheses y analysis_plan deben ser dict",
}
slug = os.path.basename(os.path.normpath(paper_dir))
# 2) + 3) Cuerpo determinista y su hash (solo del cuerpo, no del frontmatter).
body = _build_body(hypotheses, analysis_plan)
new_hash = _content_hash(body)
# 5) Logica de escritura.
if os.path.exists(expected_path):
existing = ""
try:
with open(expected_path, "r", encoding="utf-8") as fh:
existing = fh.read()
except OSError as exc:
return {
"status": "error",
"path": expected_path,
"note": f"no se pudo leer el pre-registro existente: {exc}",
}
fm = _parse_frontmatter(existing)
old_status = fm.get("status", "")
old_hash = fm.get("content_hash", "")
old_frozen_at = fm.get("frozen_at", "")
if old_status == "frozen":
if old_hash == new_hash:
# Idempotente: mismo contenido ya congelado. No se reescribe.
return {
"status": "unchanged",
"path": expected_path,
"content_hash": new_hash,
"frozen_at": old_frozen_at,
}
# Inmutabilidad: ya congelado con OTRO hash -> se rechaza (anti-HARKing).
return {
"status": "error",
"path": expected_path,
"content_hash": new_hash,
"note": (
"pre-registro inmutable: ya esta congelado (frozen) con un "
"hash distinto; un pre-registro no se puede editar tras "
"congelarse"
),
}
# status != "frozen" (p.ej. draft) -> se congela ahora.
# Archivo nuevo o draft existente: congelar con timestamp actual.
frozen_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
file_text = _render_file(slug, frozen_at, new_hash, body)
try:
with open(expected_path, "w", encoding="utf-8") as fh:
fh.write(file_text)
except OSError as exc:
return {
"status": "error",
"path": expected_path,
"note": f"no se pudo escribir el pre-registro: {exc}",
}
return {
"status": "frozen",
"path": expected_path,
"content_hash": new_hash,
"frozen_at": frozen_at,
}
except Exception as exc: # noqa: BLE001 - dict-no-throw: nunca propagar.
return {
"status": "error",
"path": expected_path,
"note": f"error inesperado: {exc}",
}
@@ -0,0 +1,99 @@
"""Tests para preregister_hypothesis (pre-registro inmutable, anti-HARKing).
Importa el modulo hoja directamente (`preregister_hypothesis`) para no depender
de que el paquete reexporte la funcion en su __init__ (lo integra el orquestador
al cerrar el grupo papers). El pytest del repo resuelve el modulo hoja por su
nombre directo.
Todos los tests son hermeticos y deterministas: usan el fixture `tmp_path` de
pytest; NUNCA escriben en `papers/`.
"""
from preregister_hypothesis import preregister_hypothesis
def _parse_frontmatter(text: str) -> dict:
parts = text.split("---", 2)
fm = {}
for line in parts[1].splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
fm[key.strip()] = value.strip()
return fm
HYP = {"h0": "no hay diferencia entre A y B", "h1": "el grupo A > grupo B"}
PLAN = {
"test": "welch_t_test",
"effect_size_metric": "cohens_d",
"decision_rule": "rechazar H0 si p<0.05 tras Holm y |d|>=0.5",
"planned_n": 100,
"multiple_correction": "holm",
}
def test_golden_congela_y_escribe_archivo(tmp_path):
paper = tmp_path / "0001-x"
paper.mkdir()
res = preregister_hypothesis(str(paper), HYP, PLAN)
assert res["status"] == "frozen"
pre = paper / "preregistration.md"
assert pre.exists()
text = pre.read_text(encoding="utf-8")
fm = _parse_frontmatter(text)
assert fm["status"] == "frozen"
assert fm["paper_slug"] == "0001-x"
assert fm["content_hash"] # no vacio
assert fm["frozen_at"] # no vacio
assert res["content_hash"] == fm["content_hash"]
assert res["frozen_at"] == fm["frozen_at"]
def test_idempotente_mismo_input_no_reescribe(tmp_path):
paper = tmp_path / "0001-x"
paper.mkdir()
pre = paper / "preregistration.md"
first = preregister_hypothesis(str(paper), HYP, PLAN)
assert first["status"] == "frozen"
bytes_before = pre.read_bytes()
second = preregister_hypothesis(str(paper), HYP, PLAN)
assert second["status"] == "unchanged"
# Mismo hash y frozen_at original preservado.
assert second["content_hash"] == first["content_hash"]
assert second["frozen_at"] == first["frozen_at"]
# El archivo NO cambio byte a byte (incl. frozen_at).
assert pre.read_bytes() == bytes_before
def test_inmutabilidad_anti_harking_rechaza_contenido_distinto(tmp_path):
paper = tmp_path / "0001-x"
paper.mkdir()
pre = paper / "preregistration.md"
preregister_hypothesis(str(paper), HYP, PLAN)
bytes_frozen = pre.read_bytes()
# Intento de re-congelar con una hipotesis DISTINTA (HARKing) -> rechazado.
hyp_tramposo = {"h0": "no hay diferencia", "h1": "el grupo B > grupo A (cambiado tras ver datos)"}
res = preregister_hypothesis(str(paper), hyp_tramposo, PLAN)
assert res["status"] == "error"
# Asercion mas importante: el archivo en disco SIGUE siendo el original.
assert pre.read_bytes() == bytes_frozen
def test_error_paper_dir_inexistente_no_crash_no_crea(tmp_path):
missing = tmp_path / "no-existe"
res = preregister_hypothesis(str(missing), HYP, PLAN)
assert res["status"] == "error"
# No se creo el directorio ni el archivo.
assert not missing.exists()
assert not (missing / "preregistration.md").exists()
@@ -0,0 +1,96 @@
---
name: render_paper_pdf
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def render_paper_pdf(paper_dir: str) -> dict"
description: "Convierte un paper académico IMRaD escrito en Markdown (papers/<slug>/paper.md, con frontmatter YAML opcional title/authors/date/abstract + cuerpo) en un PDF papers/<slug>/out/paper.pdf. REUTILIZA el paginador de flujo del paquete automatic_eda (el mismo motor del PDF móvil A5 de los informes EDA): no reimplementa paginación ni toca matplotlib. Cada sección IMRaD (encabezado de nivel 1, p.ej. # Introduction, # Methods) se mapea a un Chapter que empieza en página nueva; el motor parsea por sí mismo headings, listas, tablas pipe, párrafos y **negrita** dentro del texto. Como el motor NO entiende la sintaxis de imagen Markdown ![alt](src), esta función detecta esas líneas y las parte en bloques Image separados, resolviendo el src relativo a base_dir y base_dir/figures/. La portada (si hay título) lista autores y fecha (DD/MM/AAAA si parseable) más el abstract. dict-no-throw: nunca lanza, devuelve {status, pdf_path, n_pages, note}."
tags: [papers, pdf, academic, render, report, imrad, mobile, automatic-eda, markdown, no-cut, matplotlib, datascience, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, re, datetime, yaml, "datascience.automatic_eda"]
params:
- name: paper_dir
desc: "ruta al directorio del paper (papers/<slug>/, del que se lee paper.md) O directamente la ruta a un archivo paper.md (cualquier ruta terminada en .md). El directorio base para resolver figuras y escribir el PDF es el dirname del paper.md. Si el paper.md no existe (incluida una ruta totalmente inexistente) devuelve status='error' sin crash."
output: "dict (nunca lanza): {status: 'ok'|'error', pdf_path: str|None, n_pages: int, note: str}. En éxito status='ok', pdf_path es la ruta del PDF escrito (<base_dir>/out/paper.pdf) y n_pages el total de páginas. En error status='error', pdf_path=None, n_pages=0 y note explica la causa (paper.md no encontrado, fallo del motor, o excepción inesperada)."
tested: true
tests: ["test_golden_genera_pdf_con_portada_y_secciones", "test_edge_sin_frontmatter_ni_figuras", "test_edge_path_inexistente_no_revienta", "test_edge_figura_inexistente_degrada", "test_acepta_ruta_directa_al_md"]
test_file_path: "python/functions/datascience/render_paper_pdf_test.py"
file_path: "python/functions/datascience/render_paper_pdf.py"
---
## Ejemplo
```python
from datascience import render_paper_pdf
# Estructura del paper:
# papers/zz-demo/paper.md (frontmatter YAML + cuerpo IMRaD)
# papers/zz-demo/figures/fig1.png (figuras referenciadas con ![alt](figures/fig1.png))
#
# paper.md:
# ---
# title: A Minimal IMRaD Paper
# authors: [Ada Lovelace, Alan Turing]
# date: 2026-06-30
# abstract: Demostramos que el motor pagina un paper sin cortar nada.
# ---
# # Introduction
# Texto con **negrita** y una lista:
# - Punto uno.
# ![Figura 1](figures/fig1.png)
# # Methods
# | Métrica | Valor |
# | --- | --- |
# | Precisión | 0.91 |
res = render_paper_pdf("papers/zz-demo")
print(res["status"], res["n_pages"], res["pdf_path"])
# -> ok 3 papers/zz-demo/out/paper.pdf
# También acepta la ruta directa al .md:
render_paper_pdf("papers/zz-demo/paper.md")
```
## Cuando usarla
Cuando tengas un paper académico (o cualquier documento IMRaD) escrito en
Markdown y quieras un **PDF móvil A5 listo para leer**, sin montar LaTeX ni
configurar un pipeline de pandoc. Úsala después de redactar `paper.md` con su
frontmatter (título, autores, fecha, abstract) y secciones de nivel 1; obtienes
`out/paper.pdf` con portada, una página nueva por sección IMRaD, tablas que se
parten repitiendo la cabecera y figuras escaladas para caber enteras —
garantía de no-corte heredada del motor `automatic_eda`. Es la capa de
presentación PDF del grupo `papers`.
## Gotchas
- **Impura**: escribe `out/paper.pdf` (y crea el directorio `out/`) junto al
`paper.md`. Necesita **matplotlib** instalado en el venv (lo usa el motor
`automatic_eda.render_pdf` con backend headless `Agg`; corre en agentes/CI sin
display). `pyyaml` es opcional: si falta, el frontmatter se parsea con un
parser line-based `clave: valor` degradado.
- **Reutiliza el motor `automatic_eda.render_pdf`**: NO reimplementa paginación
ni toca matplotlib. `render_pdf` no tiene ID propio en el registry (es parte
del paquete de soporte `automatic_eda`), por eso `uses_functions` queda vacío;
la dependencia real es ese motor del paquete.
- **Nunca lanza** (dict-no-throw): `paper.md` inexistente → `{status:"error",
pdf_path:None, note:"paper.md no encontrado: ..."}`; cualquier excepción
inesperada → `{status:"error", note:"fallo: ..."}`. Frontmatter ausente o
incompleto degrada limpio (sin portada, el cuerpo entero se pagina).
- **Figuras relativas a `figures/`**: el `src` de `![alt](src)` se resuelve
probando `<base_dir>/<src>` y `<base_dir>/figures/<basename>`; usa el primero
que exista. Si ninguno existe, el motor **degrada** dibujando
"(imagen no encontrada: ...)" — el PDF se genera igual, no crashea. Las URLs
`http(s)` se dejan como texto Markdown, no se descargan.
- **Solo imágenes en línea propia**: el motor `_place_markdown` NO entiende
`![alt](src)`; esta función solo convierte a `Image` las líneas cuyo único
contenido es la imagen. Una imagen embebida a mitad de un párrafo se quedaría
como texto crudo.
- **A5 portrait mobile-first**: el formato (tamaño de página, tipografía, pie
`Capítulo · vX.Y.Z`) lo fija el motor EDA y no es configurable desde aquí.
@@ -0,0 +1,297 @@
"""render_paper_pdf — convierte un paper académico IMRaD en Markdown a un PDF.
Toma un paper escrito en Markdown con frontmatter YAML opcional (título,
autores, fecha, abstract) más un cuerpo dividido en secciones IMRaD por
encabezados de nivel 1 (``# Introduction``, ``# Methods``, ...) y produce un PDF
``out/paper.pdf`` junto al paper.
REUTILIZA el paginador de flujo del paquete ``automatic_eda`` (el mismo motor
que rinde los informes EDA en PDF móvil A5): no reimplementa paginación ni toca
matplotlib directamente. Cada sección IMRaD se mapea a un ``Chapter`` (empieza
en página nueva). El motor ``_place_markdown`` parsea por mismo headings,
listas, tablas pipe, párrafos y ``**negrita**`` dentro del texto, pero NO
entiende la sintaxis de imagen Markdown ``![alt](src)``; por eso esta función
detecta esas líneas y las convierte en bloques ``Image`` separados, partiendo el
texto Markdown alrededor de cada imagen.
dict-no-throw (estilo del grupo eda): NUNCA lanza. Devuelve
``{status, pdf_path, n_pages, note}``; ante cualquier fallo devuelve
``status="error"`` con ``pdf_path=None`` y la causa en ``note``.
"""
from __future__ import annotations
import datetime as _dt
import os
import re
from datascience.automatic_eda import Chapter, Heading, Image, Markdown, render_pdf
# Una línea cuyo único contenido es una imagen Markdown: ![alt](src)
_IMG_LINE = re.compile(r"^\s*!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)\s*$")
# Un encabezado de nivel 1 al inicio de línea (un solo '#' seguido de espacio).
_H1_LINE = re.compile(r"^#[ \t]+(.+?)\s*$")
def render_paper_pdf(paper_dir: str) -> dict:
"""Renderiza un paper académico Markdown IMRaD en un PDF.
Args:
paper_dir: ruta al directorio del paper (``papers/<slug>/``, del que se
lee ``paper.md``) o directamente la ruta a un archivo ``paper.md``.
Returns:
dict (nunca lanza): ``{status: "ok"|"error", pdf_path: str|None,
n_pages: int, note: str}``. En éxito ``pdf_path`` es la ruta escrita y
``n_pages`` el total de páginas; en error ``pdf_path`` es None y
``note`` explica la causa.
"""
try:
# 1) Resolver el path del paper.md y el directorio base.
arg = str(paper_dir)
md_path = arg if arg.endswith(".md") else os.path.join(arg, "paper.md")
# 2) Si el paper.md no existe, degradar sin crash.
if not os.path.isfile(md_path):
return {"status": "error", "pdf_path": None, "n_pages": 0,
"note": f"paper.md no encontrado: {md_path}"}
base_dir = os.path.dirname(os.path.abspath(md_path))
# 3) Leer el archivo y separar frontmatter del cuerpo.
with open(md_path, "r", encoding="utf-8") as fh:
text = fh.read()
fm_text, body = _split_frontmatter(text)
fm = _parse_frontmatter(fm_text)
title = _safe_str(fm.get("title")).strip()
authors = fm.get("authors")
date_raw = fm.get("date")
abstract = _safe_str(fm.get("abstract")).strip()
# 4) Construir los capítulos: portada (si hay título) + cuerpo IMRaD.
chapters: list = []
if title:
cover_md = _portada_markdown(authors, date_raw, abstract)
cover_blocks: list = [Heading(text=title, level=1)]
if cover_md.strip():
cover_blocks.append(Markdown(text=cover_md))
chapters.append(Chapter(id="portada", title=title, version="1.0.0",
blocks=cover_blocks))
preamble, sections = _split_body_sections(body)
if not sections:
# Sin encabezados H1: todo el cuerpo en un único capítulo.
chapters.append(Chapter(
id="cuerpo", title="Cuerpo", version="1.0.0",
blocks=_markdown_to_blocks(body, base_dir)))
else:
# Texto antes del primer H1 (si lo hay) como capítulo previo.
if preamble.strip():
chapters.append(Chapter(
id="cuerpo", title="Cuerpo", version="1.0.0",
blocks=_markdown_to_blocks(preamble, base_dir)))
for idx, (sec_title, sec_body) in enumerate(sections):
blocks: list = [Heading(text=sec_title, level=1)]
blocks.extend(_markdown_to_blocks(sec_body, base_dir))
chapters.append(Chapter(
id=_slugify(sec_title) or f"sec{idx}",
title=sec_title, version="1.0.0", blocks=blocks))
# 5) Renderizar con el motor de automatic_eda.
out_path = os.path.join(base_dir, "out", "paper.pdf")
res = render_pdf(chapters, out_path, meta={"title": title or "paper"})
# 6) Mapear el retorno del motor a la forma de esta función.
path = res.get("path")
return {
"status": "ok" if path else "error",
"pdf_path": path,
"n_pages": int(res.get("n_pages") or 0),
"note": res.get("note"),
}
except Exception as e: # noqa: BLE001 — dict-no-throw estricto.
return {"status": "error", "pdf_path": None, "n_pages": 0,
"note": f"fallo: {e}"}
# --------------------------------------------------------------------------- #
# Frontmatter
# --------------------------------------------------------------------------- #
def _split_frontmatter(text: str):
"""Separa el bloque frontmatter YAML inicial del cuerpo.
Devuelve ``(fm_text|None, body)``. Si el archivo no empieza con una valla
``---`` o no se cierra, no hay frontmatter y el cuerpo es el texto entero.
"""
if text.startswith(""):
text = text.lstrip("")
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return None, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
# Valla de apertura sin cierre: tratar todo como cuerpo.
return None, text
def _parse_frontmatter(fm_text) -> dict:
"""Parsea el frontmatter. Intenta YAML; si no, parser line-based simple."""
if not fm_text:
return {}
try:
import yaml # type: ignore
data = yaml.safe_load(fm_text)
if isinstance(data, dict):
return data
except Exception: # noqa: BLE001 — yaml ausente o frontmatter inválido.
pass
# Fallback degradado: 'clave: valor' por línea.
out: dict = {}
for line in fm_text.split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
k, _, v = stripped.partition(":")
k = k.strip()
v = v.strip().strip('"').strip("'")
if k:
out[k] = v
return out
# --------------------------------------------------------------------------- #
# Portada
# --------------------------------------------------------------------------- #
def _portada_markdown(authors, date_raw, abstract) -> str:
"""Markdown de la portada: autores, fecha y, si hay, el abstract."""
parts: list = []
authors_str = _fmt_authors(authors)
if authors_str:
parts.append(f"**Autores:** {authors_str}")
if date_raw not in (None, ""):
parts.append(f"**Fecha:** {_fmt_date(date_raw)}")
md = "\n\n".join(parts)
abstract = _safe_str(abstract).strip()
if abstract:
md = (md + "\n\n" if md else "") + "## Abstract\n\n" + abstract
return md
def _fmt_authors(authors) -> str:
"""Lista o string de autores → string separado por comas."""
if authors in (None, ""):
return ""
if isinstance(authors, (list, tuple)):
return ", ".join(_safe_str(a).strip() for a in authors
if _safe_str(a).strip())
return _safe_str(authors).strip()
def _fmt_date(raw) -> str:
"""Fecha → ``DD/MM/AAAA`` si es parseable; si no, el valor crudo."""
if isinstance(raw, _dt.datetime):
return raw.strftime("%d/%m/%Y")
if isinstance(raw, _dt.date):
return raw.strftime("%d/%m/%Y")
s = _safe_str(raw).strip()
if not s:
return s
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return _dt.datetime.strptime(s, fmt).strftime("%d/%m/%Y")
except ValueError:
continue
try:
return _dt.datetime.fromisoformat(s).strftime("%d/%m/%Y")
except Exception: # noqa: BLE001
return s
# --------------------------------------------------------------------------- #
# Cuerpo y figuras
# --------------------------------------------------------------------------- #
def _split_body_sections(body: str):
"""Divide el cuerpo en (preámbulo, [(título_H1, contenido)...]) por H1."""
preamble_lines: list = []
sections: list = []
current = None # (titulo, [lineas])
for line in body.split("\n"):
m = _H1_LINE.match(line)
if m and not line.startswith("##"):
if current is not None:
sections.append((current[0], "\n".join(current[1])))
current = (m.group(1).strip(), [])
elif current is None:
preamble_lines.append(line)
else:
current[1].append(line)
if current is not None:
sections.append((current[0], "\n".join(current[1])))
return "\n".join(preamble_lines), sections
def _markdown_to_blocks(text: str, base_dir: str) -> list:
"""Parte un Markdown en bloques Markdown/Image alrededor de cada figura.
Las líneas ``![alt](src)`` con ``src`` local se convierten en ``Image``; las
que apuntan a URLs http(s) se dejan como texto Markdown.
"""
blocks: list = []
buf: list = []
def _flush():
chunk = "\n".join(buf).strip("\n")
if chunk.strip():
blocks.append(Markdown(text=chunk))
buf.clear()
for line in text.split("\n"):
m = _IMG_LINE.match(line)
if m:
alt, src = m.group(1), m.group(2)
if src.lower().startswith(("http://", "https://")):
buf.append(line) # URL remota: se mantiene como texto.
continue
_flush()
blocks.append(Image(path=_resolve_src(src, base_dir),
caption=(alt or None)))
else:
buf.append(line)
_flush()
return blocks
def _resolve_src(src: str, base_dir: str) -> str:
"""Resuelve la ruta de una figura relativa al paper.
Absoluta tal cual. Relativa prueba ``base_dir/src`` y
``base_dir/figures/<basename>``; usa la primera que exista, o el join con
``base_dir`` si ninguna (el motor degrada dibujando el aviso de no-encontrada).
"""
if os.path.isabs(src):
return src
cand1 = os.path.join(base_dir, src)
cand2 = os.path.join(base_dir, "figures", os.path.basename(src))
for c in (cand1, cand2):
if os.path.exists(c):
return c
return cand1
def _slugify(text: str) -> str:
"""Slug ASCII corto para el id del capítulo."""
s = re.sub(r"[^a-z0-9]+", "_", _safe_str(text).lower()).strip("_")
return s[:40]
def _safe_str(v) -> str:
"""str() que nunca lanza y mapea None a ''."""
if v is None:
return ""
try:
return str(v)
except Exception: # noqa: BLE001
return ""
@@ -0,0 +1,118 @@
"""Tests para render_paper_pdf — DoD: golden + edges + error path.
Autocontenido y sin red: escribe papers Markdown sintéticos en directorios
temporales y verifica que el PDF se genera (estado, de páginas, archivo
no vacío) reutilizando el motor de paginación de ``automatic_eda``.
"""
import os
import tempfile
from datascience.render_paper_pdf import render_paper_pdf
_GOLDEN_PAPER = """---
title: A Minimal IMRaD Paper
authors:
- Ada Lovelace
- Alan Turing
date: 2026-06-30
abstract: >
Demostramos que el motor de paginación rinde un paper IMRaD completo en PDF
móvil sin cortar texto ni tablas.
---
# Introduction
Este es el cuerpo de la introducción con **texto en negrita** y una lista:
- Primer punto.
- Segundo punto.
# Methods
Resultados resumidos en una tabla pipe:
| Métrica | Valor |
| --- | --- |
| Precisión | 0.91 |
| Recall | 0.88 |
Texto final de la sección de métodos.
"""
def test_golden_genera_pdf_con_portada_y_secciones(tmp_path):
"""Golden: paper IMRaD con frontmatter + 2 secciones + tabla → PDF válido."""
paper_dir = tmp_path / "zz-demo"
paper_dir.mkdir()
(paper_dir / "paper.md").write_text(_GOLDEN_PAPER, encoding="utf-8")
res = render_paper_pdf(str(paper_dir))
assert res["status"] == "ok", res
assert res["n_pages"] >= 1
pdf_path = res["pdf_path"]
assert pdf_path is not None
assert os.path.exists(pdf_path)
assert os.path.getsize(pdf_path) > 0
def test_edge_sin_frontmatter_ni_figuras(tmp_path):
"""Edge 1: cuerpo plano sin frontmatter ni figuras → genera PDF igual."""
paper_dir = tmp_path / "plano"
paper_dir.mkdir()
(paper_dir / "paper.md").write_text(
"Solo un cuerpo plano, sin frontmatter ni encabezados de nivel 1.\n"
"Un par de líneas de texto corrido para que el motor lo pagine.\n",
encoding="utf-8",
)
res = render_paper_pdf(str(paper_dir))
assert res["status"] == "ok", res
assert res["n_pages"] >= 1
assert os.path.exists(res["pdf_path"])
def test_edge_path_inexistente_no_revienta():
"""Edge 2: directorio inexistente → status error, sin crash, pdf_path None."""
res = render_paper_pdf("/tmp/no_existe_xyz_123")
assert res["status"] == "error"
assert res["pdf_path"] is None
assert res["n_pages"] == 0
assert "no encontrado" in (res["note"] or "")
def test_edge_figura_inexistente_degrada(tmp_path):
"""Edge 3: referencia a figura inexistente → el PDF se genera igual."""
paper_dir = tmp_path / "con-figura"
paper_dir.mkdir()
(paper_dir / "paper.md").write_text(
"---\n"
"title: Paper Con Figura Rota\n"
"---\n\n"
"# Results\n\n"
"Texto antes de la figura.\n\n"
"![Una figura que no existe](figures/no.png)\n\n"
"Texto después de la figura.\n",
encoding="utf-8",
)
res = render_paper_pdf(str(paper_dir))
assert res["status"] == "ok", res
assert res["n_pages"] >= 1
assert os.path.exists(res["pdf_path"])
def test_acepta_ruta_directa_al_md(tmp_path):
"""Acepta también la ruta directa a un paper.md (no solo el directorio)."""
md = tmp_path / "paper.md"
md.write_text("# Discussion\n\nCuerpo de la discusión.\n", encoding="utf-8")
res = render_paper_pdf(str(md))
assert res["status"] == "ok", res
assert os.path.exists(res["pdf_path"])
@@ -0,0 +1,121 @@
---
id: render_table_as_figure_py_datascience
name: render_table_as_figure
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def render_table_as_figure(header, rows, title=None, note=None, fontsize=9.0, max_cell_chars=40) -> \"matplotlib.figure.Figure\""
description: "Dibuja un bloque tabular (cabecera + filas) como una matplotlib.figure.Figure nítida, lista para rasterizar a DPI alto. Pensada para tablas que NO caben como texto en una página/slide del informe EDA: se rasteriza a alta resolución (el caller usa dpi=220, bbox_inches='tight') y el usuario hace zoom en el móvil para leerla entera sin perder datos. Cabecera sombreada (#eef3f6) y en negrita, filas pares (1-based) con zebra suave (#f6f8fa), tinta oscura (#1b1b1b) sobre blanco, rejilla gris muy fina (#cccccc). Trunca cada celda a max_cell_chars con elipsis y str()-ea cada valor (None -> \"\"). figsize proporcional al contenido (ancho por nº y longitud de columnas, alto por nº de filas) para que sea legible con zoom. Backend Agg sin pyplot global. Defensiva: header/rows vacíos o None, filas irregulares o cualquier error interno devuelven una Figure placeholder con texto centrado \"(tabla no disponible)\". NUNCA lanza."
tags: [eda, table, figure, matplotlib, visualization, rasterize, zoom, render, datascience, impure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [matplotlib]
example: |
from datascience.render_table_as_figure import render_table_as_figure
header = ["columna", "n_nulos", "%_nulos", "distintos", "tipo", "ejemplo"]
rows = [
["ingresos", 12, "1.2%", 980, "float64", "2345.67"],
["edad", 0, "0.0%", 88, "int64", "37"],
["ciudad", 5, "0.5%", 412, "object", "Madrid"],
]
fig = render_table_as_figure(header, rows, title="Resumen de columnas",
note="rasteriza a dpi=220 y haz zoom")
fig.savefig("/tmp/tabla.png", dpi=220, bbox_inches="tight")
tested: true
tests:
- "test_returns_figure_with_table"
- "test_rows_none_does_not_raise"
- "test_header_none_does_not_raise"
- "test_empty_lists_return_placeholder_figure"
- "test_both_none_return_placeholder_figure"
- "test_long_cell_is_truncated"
- "test_none_cells_become_empty_strings"
- "test_can_rasterize_to_png_high_dpi"
- "test_placeholder_can_rasterize"
- "test_ragged_rows_are_padded"
test_file_path: "python/functions/datascience/render_table_as_figure_test.py"
file_path: "python/functions/datascience/render_table_as_figure.py"
params:
- name: header
desc: "Lista de nombres de columna (puede ser [] o None). Cada nombre se str()-ea, se trunca a max_cell_chars y se pinta en la fila cabecera sombreada en negrita. Si está vacío/None no se dibuja fila de cabecera (solo cuerpo)."
- name: rows
desc: "Lista de filas; cada fila es una lista de celdas con valores cualesquiera (se str()-ean; None -> \"\"). Admite None (se trata como []), filas escalares (se envuelven en una celda) y filas de distinta longitud (la rejilla se rectangulariza al ancho máximo, rellenando con celdas vacías). Saltos de línea/tabs en una celda se colapsan a espacios para que no desborde a otras filas."
- name: title
desc: "Título opcional dibujado encima de la tabla, en negrita tinta #1b1b1b, alineado a la izquierda. None o \"\" => sin título. Default None."
- name: note
desc: "Nota opcional al pie de la figura, en gris #8a8a8a e itálica. None o \"\" => sin nota. Default None."
- name: fontsize
desc: "Tamaño de fuente base (pt) de las celdas del cuerpo. La cabecera usa fontsize+3 y la nota max(7, fontsize-1). Un valor no numérico o <= 0 cae a 9.0. Default 9.0."
- name: max_cell_chars
desc: "Trunca el texto de cada celda a este nº de chars (con … final cuando se recorta) para que el ancho no explote. Un valor no entero cae a 40; <= 0 deja las celdas vacías. Default 40."
output: "Un matplotlib.figure.Figure (figsize proporcional al contenido: ancho ≈ 0.9-1.6\" por columna según su texto, total acotado a 3-26\"; alto ≈ 0.32\" por fila + cabecera + espacio para título/nota, acotado) con un Axes sin ejes que contiene un ax.table(...) NO cerrado. Cabecera fondo #eef3f6 texto #1b1b1b bold; filas pares (1-based) zebra #f6f8fa, impares blanco; tinta #1b1b1b; bordes/rejilla #cccccc lw 0.4; texto alineado a la izquierda. Título encima (bold) y nota debajo (gris itálica) si se pasan. Si header/rows son vacíos o None, o ante cualquier error interno, devuelve una Figure placeholder pequeña con el texto centrado \"(tabla no disponible)\". NUNCA lanza. El caller la rasteriza (dpi=220, bbox_inches='tight') y la cierra; la función no la muestra ni la guarda."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.render_table_as_figure import render_table_as_figure
# Tabla que no cabe como texto en la slide -> se rasteriza y se lee con zoom.
header = ["columna", "n_nulos", "%_nulos", "distintos", "tipo", "ejemplo"]
rows = [
["ingresos", 12, "1.2%", 980, "float64", "2345.67"],
["edad", 0, "0.0%", 88, "int64", "37"],
["ciudad", 5, "0.5%", 412, "object", "Madrid"],
["categoria_producto", 0, "0.0%", 1840, "object",
"un_valor_categorico_muy_largo_que_se_trunca"],
]
fig = render_table_as_figure(
header,
rows,
title="Resumen de columnas",
note="rasteriza a dpi=220 y haz zoom en el móvil",
fontsize=9.0,
max_cell_chars=40,
)
# El renderer del informe lo rasteriza a alta resolución; aquí lo persistimos.
fig.savefig("/tmp/tabla.png", dpi=220, bbox_inches="tight")
```
## Cuando usarla
Úsala en un informe EDA cuando una tabla **no cabe como texto** en una página o
slide y prefieres una imagen nítida que el lector pueda ampliar en el móvil para
leerla entera (perfiles de columnas, matrices de conteo, tablas de frecuencias
con muchas filas o columnas anchas). Pásale la cabecera y las filas tal cual (los
valores se `str()`-ean por ti) más un `title`/`note` opcionales; el llamante la
rasteriza a `dpi=220` con `bbox_inches='tight'`. Es la pareja "tabla-como-imagen"
de los gráficos `build_boxplots_figure` / `categorical_top_pie_figure`: misma
paleta y mismo contrato (Agg, sin `pyplot`, el caller cierra la figura).
## Gotchas
- **Impura por matplotlib.** Toca la maquinaria de render. Usa el backend `Agg`
y la API orientada a objetos `Figure`/`add_subplot` — NUNCA `pyplot.*` aquí,
para no tocar el estado global ni filtrar figuras entre llamadas. `pyplot` NO
es thread-safe; esta función construye el `Figure` directamente, así que es
segura de llamar en bucle desde el renderer.
- **El caller cierra la figura.** Devuelve el `Figure` pero no lo muestra ni lo
guarda. Quien la consume debe rasterizarla y luego liberarla
(`matplotlib.pyplot.close(fig)`) para no acumular memoria en lotes grandes.
- **Pensada para rasterizar a DPI alto.** El `figsize` es proporcional al
contenido pero la legibilidad real viene del DPI: rasteriza con `dpi=220` y
`bbox_inches='tight'`. Una tabla con muchísimas filas crece en alto (capado a
~60") — para miles de filas, parte la tabla o resume antes de pasarla.
- **Truncación de celda visible.** Cada celda se recorta a `max_cell_chars`
(default 40) con `…` final y los saltos de línea/tabs se colapsan a espacios,
para que ninguna celda desborde a otras filas. Sube `max_cell_chars` si
necesitas ver el valor completo (a costa de ancho).
- **Defensiva, nunca lanza.** `header`/`rows` vacíos o `None`, filas escalares,
filas de distinta longitud o cualquier error interno se manejan sin propagar:
en el peor caso devuelve una `Figure` placeholder con "(tabla no disponible)".
No envuelvas la llamada en try/except por miedo a un raise — no lo hay.
@@ -0,0 +1,241 @@
"""Impure EDA helper: a crisp table rendered as a matplotlib Figure (`eda` group).
Draws a tabular block (header + rows) as a sharp ``matplotlib.figure.Figure``
ready to be rasterized at high DPI, so a table that does NOT fit as text on a
page/slide can still be read in full by zooming into the rasterized image on a
phone. The header is shaded and bold, even rows carry a soft zebra stripe, the
ink is dark on white and the grid is very thin.
Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer. It is fully
defensive and NEVER raises: empty/invalid input or any internal error returns a
small placeholder figure carrying a centered "(tabla no disponible)".
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure # noqa: E402
# Palette shared with the EDA report renderer so the document stays coherent.
_HEADER_BG = "#eef3f6" # header cell background.
_HEADER_TEXT = "#1b1b1b" # header cell text (bold).
_ZEBRA_BG = "#f6f8fa" # even (1-based) row background stripe.
_BODY_BG = "#ffffff" # odd row background.
_INK = "#1b1b1b" # body text + title ink.
_GRID = "#cccccc" # cell borders / grid (thin).
_NOTE_TEXT = "#8a8a8a" # muted gray for the note (italic).
def _placeholder_figure(message: str = "(tabla no disponible)") -> "Figure":
"""Return a small fallback ``Figure`` carrying a single centered message."""
fig = Figure(figsize=(6.0, 1.6), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=11,
color=_NOTE_TEXT,
style="italic",
wrap=True,
transform=ax.transAxes,
)
fig.tight_layout()
return fig
def _cell_text(value, max_cell_chars: int) -> str:
"""``str()`` a cell value defensively, None -> "", truncate with an ellipsis."""
s = "" if value is None else str(value)
# Collapse newlines/tabs so a single cell never spills across table rows.
s = s.replace("\n", " ").replace("\r", " ").replace("\t", " ")
try:
limit = int(max_cell_chars)
except (TypeError, ValueError):
limit = 40
if limit <= 0:
return ""
if len(s) <= limit:
return s
if limit == 1:
return ""
return s[: limit - 1] + ""
def render_table_as_figure(
header,
rows,
title=None,
note=None,
fontsize=9.0,
max_cell_chars=40,
):
"""Dibuja una tabla nítida como matplotlib.figure.Figure, lista para rasterizar a DPI alto.
Pensada para tablas que NO caben como texto en una página/slide: se rasteriza
a alta resolución y el usuario hace zoom en el móvil para leerla entera sin
perder datos. Cabecera sombreada + negrita, filas pares con zebra suave,
tinta oscura sobre blanco, rejilla muy fina.
Args:
header: lista de nombres de columna (puede ser []).
rows: lista de filas; cada fila es una lista de celdas (valores cualquiera, se str()-ean).
title: título opcional dibujado encima de la tabla (o None).
note: nota opcional en gris/itálica bajo la tabla (o None).
fontsize: tamaño de fuente base (pt) de las celdas.
max_cell_chars: trunca el texto de celda a este de chars (con final) para que no explote el ancho.
Returns:
matplotlib.figure.Figure NO cerrada (el llamante la rasteriza y la cierra).
Nunca lanza: ante cualquier error devuelve una Figure con el texto "(tabla no disponible)".
"""
try:
# --- Defensive normalization of header/rows into a rectangular grid.
header_list = list(header) if isinstance(header, (list, tuple)) else []
raw_rows = list(rows) if isinstance(rows, (list, tuple)) else []
clean_rows = []
for row in raw_rows:
if isinstance(row, (list, tuple)):
clean_rows.append(list(row))
elif row is None:
clean_rows.append([])
else:
# A scalar row becomes a single-cell row instead of being dropped.
clean_rows.append([row])
# Nothing to draw at all -> placeholder.
if not header_list and not clean_rows:
return _placeholder_figure()
# Number of columns = widest of header / any row.
n_cols = len(header_list)
for row in clean_rows:
if len(row) > n_cols:
n_cols = len(row)
if n_cols <= 0:
return _placeholder_figure()
# Base font size, tolerate a bad value.
try:
base_fs = float(fontsize)
except (TypeError, ValueError):
base_fs = 9.0
if base_fs <= 0:
base_fs = 9.0
# --- Build the truncated, padded text matrix.
header_cells = [
_cell_text(header_list[c] if c < len(header_list) else "", max_cell_chars)
for c in range(n_cols)
]
body_cells = []
for row in clean_rows:
body_cells.append(
[
_cell_text(row[c] if c < len(row) else "", max_cell_chars)
for c in range(n_cols)
]
)
has_header = any(t for t in header_cells)
n_body = len(body_cells)
# Total drawn table rows (header counts as one when present).
n_table_rows = n_body + (1 if has_header else 0)
if n_table_rows <= 0:
return _placeholder_figure()
# --- figsize proportional to content so it reads under zoom.
# Width: per-column width scales with the longest text in that column,
# clamped to a sensible per-column range, total capped.
per_col_widths = []
for c in range(n_cols):
col_texts = [header_cells[c]] if has_header else []
col_texts += [body_cells[r][c] for r in range(n_body)]
longest = max((len(t) for t in col_texts), default=0)
# ~0.085" per char at the base font, clamped to [0.9, 1.6] inches.
w = 0.9 + 0.085 * max(longest - 6, 0)
w = max(0.9, min(1.6, w))
per_col_widths.append(w)
fig_w = sum(per_col_widths)
fig_w = max(3.0, min(26.0, fig_w))
# Height: ~0.32" per row + room for title / note.
fig_h = 0.32 * n_table_rows + 0.30
if title is not None and str(title) != "":
fig_h += 0.45
if note is not None and str(note) != "":
fig_h += 0.30
fig_h = max(1.0, min(60.0, fig_h))
fig = Figure(figsize=(fig_w, fig_h), dpi=150)
ax = fig.add_subplot(111)
ax.axis("off")
# Reserve vertical bands for the optional title (top) and note (bottom)
# so the table itself never overlaps them.
title_band = 0.10 if (title is not None and str(title) != "") else 0.0
note_band = 0.07 if (note is not None and str(note) != "") else 0.0
table_bbox = [0.0, note_band, 1.0, max(0.05, 1.0 - title_band - note_band)]
cell_text = ([header_cells] if has_header else []) + body_cells
col_widths = [w / fig_w for w in per_col_widths]
table = ax.table(
cellText=cell_text,
colWidths=col_widths,
cellLoc="left",
loc="center",
bbox=table_bbox,
)
table.auto_set_font_size(False)
table.set_fontsize(base_fs)
# --- Style every cell: zebra body, shaded bold header, thin gray grid.
for (r, _c), cell in table.get_celld().items():
cell.set_edgecolor(_GRID)
cell.set_linewidth(0.4)
# Small horizontal padding so text does not touch the border.
cell.PAD = 0.04
if has_header and r == 0:
cell.set_facecolor(_HEADER_BG)
cell.set_text_props(color=_HEADER_TEXT, fontweight="bold", ha="left")
else:
body_index = r - 1 if has_header else r # 0-based body row.
# 1-based even rows get the zebra stripe.
is_even = ((body_index + 1) % 2) == 0
cell.set_facecolor(_ZEBRA_BG if is_even else _BODY_BG)
cell.set_text_props(color=_INK, ha="left")
if title is not None and str(title) != "":
ax.set_title(
str(title),
fontsize=base_fs + 3.0,
fontweight="bold",
color=_INK,
loc="left",
pad=8,
)
if note is not None and str(note) != "":
fig.text(
0.01,
0.01,
str(note),
ha="left",
va="bottom",
fontsize=max(7.0, base_fs - 1.0),
color=_NOTE_TEXT,
style="italic",
)
return fig
except Exception: # noqa: BLE001 — never raise from a figure builder.
return _placeholder_figure()
@@ -0,0 +1,119 @@
"""Tests para render_table_as_figure (tabla nítida como Figure, grupo eda).
Usa el backend Agg sin display; no muestra ni guarda figuras a disco salvo a un
BytesIO en memoria. Cada test cierra explícitamente la Figure construida
(matplotlib.pyplot.close) para no acumular estado entre tests.
"""
from io import BytesIO
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.figure import Figure # noqa: E402
from render_table_as_figure import render_table_as_figure
def _grid(n_cols, n_rows):
"""Cabecera de n_cols columnas + n_rows filas de celdas."""
header = [f"col_{c}" for c in range(n_cols)]
rows = [[f"r{r}c{c}" for c in range(n_cols)] for r in range(n_rows)]
return header, rows
def test_returns_figure_with_table():
header, rows = _grid(6, 5)
fig = render_table_as_figure(header, rows, title="Tabla", note="nota al pie")
assert isinstance(fig, Figure)
# Hay al menos un Axes y ese Axes contiene una tabla con celdas.
assert len(fig.axes) >= 1
ax = fig.axes[0]
assert len(ax.tables) >= 1
# 6 columnas x (1 cabecera + 5 filas) = 36 celdas.
assert len(ax.tables[0].get_celld()) == 6 * (5 + 1)
plt.close(fig)
def test_rows_none_does_not_raise():
fig = render_table_as_figure(["a", "b"], None)
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_header_none_does_not_raise():
fig = render_table_as_figure(None, [["x", "y"], ["z", "w"]])
assert isinstance(fig, Figure)
assert len(fig.axes) >= 1
plt.close(fig)
def test_empty_lists_return_placeholder_figure():
fig = render_table_as_figure([], [])
assert isinstance(fig, Figure)
# Placeholder: un Axes con texto, sin tabla.
assert len(fig.axes) >= 1
assert len(fig.axes[0].tables) == 0
plt.close(fig)
def test_both_none_return_placeholder_figure():
fig = render_table_as_figure(None, None)
assert isinstance(fig, Figure)
assert len(fig.axes[0].tables) == 0
plt.close(fig)
def test_long_cell_is_truncated():
long_value = "x" * 200
header, _ = _grid(2, 0)
fig = render_table_as_figure(header, [[long_value, "ok"]], max_cell_chars=20)
assert isinstance(fig, Figure)
ax = fig.axes[0]
texts = [c.get_text().get_text() for c in ax.tables[0].get_celld().values()]
# La celda larga aparece truncada con elipsis y nunca en su forma completa.
assert any(t.endswith("") and len(t) <= 20 for t in texts)
assert long_value not in texts
plt.close(fig)
def test_none_cells_become_empty_strings():
fig = render_table_as_figure(["a", "b"], [[None, "v"], ["w", None]])
assert isinstance(fig, Figure)
ax = fig.axes[0]
texts = [c.get_text().get_text() for c in ax.tables[0].get_celld().values()]
# Hay celdas vacías (los None) y celdas con valor.
assert "" in texts
assert "v" in texts
plt.close(fig)
def test_can_rasterize_to_png_high_dpi():
header, rows = _grid(6, 8)
fig = render_table_as_figure(header, rows, title="Render", note="zoom me")
buf = BytesIO()
# No debe lanzar al rasterizar a DPI alto con bbox tight.
fig.savefig(buf, format="png", dpi=220, bbox_inches="tight")
assert buf.getbuffer().nbytes > 0
plt.close(fig)
def test_placeholder_can_rasterize():
fig = render_table_as_figure([], [])
buf = BytesIO()
fig.savefig(buf, format="png", dpi=220, bbox_inches="tight")
assert buf.getbuffer().nbytes > 0
plt.close(fig)
def test_ragged_rows_are_padded():
# Filas de distinta longitud: la rejilla se rectangulariza al ancho máximo.
fig = render_table_as_figure(["a", "b", "c"], [["1"], ["1", "2", "3", "4"]])
assert isinstance(fig, Figure)
ax = fig.axes[0]
# 4 columnas (la fila más ancha) x (1 cabecera + 2 filas) = 12 celdas.
assert len(ax.tables[0].get_celld()) == 4 * (2 + 1)
plt.close(fig)
+2
View File
@@ -34,6 +34,7 @@ from .upsert_xlsx_sheet import upsert_xlsx_sheet
from .duckdb_query_readonly import duckdb_query_readonly from .duckdb_query_readonly import duckdb_query_readonly
from .duckdb_execute import duckdb_execute from .duckdb_execute import duckdb_execute
from .duckdb_upsert import duckdb_upsert from .duckdb_upsert import duckdb_upsert
from .load_folder_to_duckdb import load_folder_to_duckdb
from .imap_connect import imap_connect from .imap_connect import imap_connect
from .imap_list_mailboxes import imap_list_mailboxes from .imap_list_mailboxes import imap_list_mailboxes
from .imap_search import imap_search from .imap_search import imap_search
@@ -50,6 +51,7 @@ __all__ = [
"upsert_xlsx_sheet", "upsert_xlsx_sheet",
"duckdb_query_readonly", "duckdb_query_readonly",
"duckdb_execute", "duckdb_execute",
"load_folder_to_duckdb",
"duckdb_upsert", "duckdb_upsert",
"pg_insert_rows", "pg_insert_rows",
"pg_apply_sql", "pg_apply_sql",
@@ -0,0 +1,100 @@
---
name: load_folder_to_duckdb
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def load_folder_to_duckdb(folder: str, db_path: str = None, pattern: str = '*.csv,*.parquet,*.json') -> dict"
description: "Escanea el primer nivel de una CARPETA buscando archivos tabulares (CSV/TSV/TXT, Parquet, JSON/NDJSON) y los carga como tablas en una base DuckDB usando los lectores nativos read_csv_auto/read_parquet/read_json_auto. Es la pieza de entrada del EDA a nivel de carpeta (grupo eda). Por cada archivo crea una tabla cuyo nombre se deriva del basename saneado a [0-9a-zA-Z_] en minusculas (prefijo t_ si empieza por digito, sufijos _2/_3 ante colisiones, tabla_<i> si queda vacio). El path se escapa (comilla simple '->'') antes de interpolarlo porque los lectores DuckDB no aceptan el path como parametro posicional. Glob NO recursivo: un glob.glob(os.path.join(folder, g)) por cada patron del CSV, dedup y ordenado. db_path=None genera una DuckDB temporal (mkstemp, se borra el placeholder vacio porque DuckDB rechaza un archivo de 0 bytes) y devuelve su ruta. Un fallo al cargar un archivo concreto no aborta el resto: se registra en errors y se continua. Devuelve siempre un dict sin lanzar (estilo del grupo duckdb): {status:'ok', db_path, tables, errors} en exito (carpeta sin archivos tabulares incluida, tables=[]) y {status:'error', error} cuando la carpeta no existe o falla algo global. Depende del paquete duckdb (1.5.2)."
tags: [eda, duckdb, ingest, etl, folder]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [glob, os, re, tempfile, duckdb]
params:
- name: folder
desc: "ruta a un directorio. Se escanea solo su primer nivel (NO recursivo). Si no existe o no es un directorio devuelve {status:'error'} sin lanzar."
- name: db_path
desc: "ruta del archivo DuckDB destino, abierto en modo read-write (lo crea si no existe). None (default) genera una DuckDB temporal unica con tempfile.mkstemp y devuelve su ruta en el campo db_path del retorno. DuckDB es single-writer: si otro proceso lo tiene abierto en escritura, connect falla con error de lock devuelto en el dict."
- name: pattern
desc: "CSV de globs separados por coma (default '*.csv,*.parquet,*.json'). Cada glob se aplica con glob.glob(os.path.join(folder, g)) sobre el primer nivel de folder; los resultados de todos los globs se deduplican y ordenan. Los globs con ** NO descienden recursivamente (glob.glob sin recursive=True)."
output: "dict. En exito: {status:'ok', db_path:str (ruta DuckDB usada), tables:[{name:str, source_file:str, n_rows:int}], errors:[{name?:str, source_file:str, error:str}]}. La carpeta sin archivos tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar): {status:'error', error:str}."
tested: true
tests:
- "test_carga_dos_csv_como_tablas"
- "test_db_path_none_crea_temporal"
- "test_carpeta_vacia_es_ok_sin_tablas"
- "test_carpeta_inexistente_devuelve_status_error"
test_file_path: "python/functions/infra/load_folder_to_duckdb_test.py"
file_path: "python/functions/infra/load_folder_to_duckdb.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.load_folder_to_duckdb import load_folder_to_duckdb
# Preparar una carpeta de demo con dos CSV.
import os
os.makedirs("/tmp/eda_folder_demo", exist_ok=True)
with open("/tmp/eda_folder_demo/ventas.csv", "w") as f:
f.write("id,total\n1,10.5\n2,20.0\n3,5.25\n")
with open("/tmp/eda_folder_demo/clientes.csv", "w") as f:
f.write("id,nombre\n1,ana\n2,luis\n")
# Cargar todos los tabulares de la carpeta a una DuckDB temporal.
res = load_folder_to_duckdb("/tmp/eda_folder_demo")
print(res["status"]) # ok
print(res["db_path"]) # /tmp/tmpXXXXXXXX.duckdb (temporal)
for t in res["tables"]:
print(t["name"], t["n_rows"]) # ventas 3 / clientes 2
# Persistir en una DuckDB concreta y limitar a CSV.
res2 = load_folder_to_duckdb(
"/tmp/eda_folder_demo",
db_path="/tmp/eda_folder_demo/folder.duckdb",
pattern="*.csv",
)
print(res2["tables"]) # [{'name': 'clientes', ...}, {'name': 'ventas', ...}]
```
## Cuando usarla
Cuando tienes una carpeta de datos sueltos (un dump, un export, varios CSV/Parquet
descargados) y quieres analizarlos juntos con SQL sin montar la ingesta a mano,
archivo por archivo. Es el primer eslabon del EDA a nivel de carpeta (grupo `eda`):
deja una DuckDB con una tabla por archivo, lista para perfilar con
`duckdb_table_schema_py_infra`, consultar con `duckdb_query_readonly_py_infra`, o
correlacionar aguas abajo. Usala antes de cualquier paso de perfilado cuando la
unidad de trabajo es "todos los archivos de este directorio".
## Gotchas
- **Glob NO recursivo**: solo se escanea el primer nivel de `folder`. Archivos en
subdirectorios se ignoran (ni siquiera con `**` en el patron, porque
`glob.glob` se llama sin `recursive=True`). Si necesitas recursion, aplana la
carpeta antes o amplia la funcion.
- **Saneo de nombres de tabla**: el basename se reduce a `[0-9a-zA-Z_]` en
minusculas. `Ventas 2024.csv` -> tabla `ventas_2024`. Dos archivos distintos
pueden sanear al mismo nombre (`a-b.csv` y `a_b.csv`); el segundo se desambigua
con sufijo `_2`, `_3`, ... El mapeo real archivo->tabla esta en `tables[].name`
/ `tables[].source_file`, no lo asumas.
- **`read_json_auto` requiere JSON tabular** (array de objetos u objetos NDJSON
homogeneos). Un JSON anidado o irregular puede fallar la carga de ESA tabla; el
error se registra en `errors` y el resto de archivos siguen cargandose.
- **Extension desconocida = se salta**, no falla: queda anotada en `errors` con
`unsupported extension`. Mapeo de lectores: `.csv/.tsv/.txt`->`read_csv_auto`,
`.parquet/.pq`->`read_parquet`, `.json/.ndjson`->`read_json_auto`.
- **Escritura real en disco (impura)**. DuckDB es single-writer: si otro proceso
tiene `db_path` abierto en escritura, `connect` falla con error de lock devuelto
en el dict. Un `db_path` con un directorio padre inexistente tambien falla.
- **`db_path=None` crea un archivo temporal que NO se borra solo**: la ruta se
devuelve en `db_path` para que el llamador la consuma y la limpie cuando termine.
- **Tipos inferidos por los lectores `_auto`**: los tipos de columna los infiere
DuckDB. Revisa el schema con `duckdb_table_schema_py_infra` si el tipado importa
aguas abajo.
@@ -0,0 +1,175 @@
"""Carga una carpeta de archivos tabulares (CSV/Parquet/JSON) como tablas DuckDB.
Funcion impura: escanea el primer nivel de un directorio buscando archivos que
casen con uno o varios globs, y por cada archivo crea una tabla en una base
DuckDB usando los lectores nativos (`read_csv_auto`, `read_parquet`,
`read_json_auto`). Es la pieza de entrada del EDA a nivel de carpeta (grupo
`eda`): deja una DuckDB con una tabla por archivo, lista para perfilar y
correlacionar aguas abajo.
Devuelve siempre un dict sin lanzar excepciones, siguiendo el estilo del grupo
duckdb del registry: {status:'ok', db_path, tables, errors} en exito (incluida
la carpeta sin archivos tabulares, que es un exito con tables=[]) y
{status:'error', error:str} cuando la carpeta no existe o falla algo global.
El nombre de cada tabla se deriva del basename del archivo, saneado a
`[0-9a-zA-Z_]` en minusculas, prefijado con `t_` si empieza por digito, y
desambiguado con sufijos `_2`, `_3`, ... ante colisiones. El path del archivo se
escapa (comilla simple, `'`->`''`) antes de interpolarlo en el SQL del lector,
ya que los lectores DuckDB no admiten el path como parametro posicional. Un fallo
al cargar un archivo concreto NO aborta el resto: se registra en `errors` y se
continua con los siguientes.
"""
import glob
import os
import re
import tempfile
def _sanitize_table_name(basename_no_ext: str, index: int) -> str:
"""Deriva un identificador de tabla valido desde el basename de un archivo.
Reemplaza todo lo que no sea ``[0-9a-zA-Z_]`` por ``_`` y baja a minusculas.
Si tras el saneo queda vacio, usa ``tabla_<index>``. Si empieza por digito,
prefija ``t_`` para que sea un identificador SQL valido.
"""
name = re.sub(r"[^0-9a-zA-Z_]", "_", basename_no_ext).lower()
if not name:
name = f"tabla_{index}"
if name[0].isdigit():
name = "t_" + name
return name
def _reader_for_extension(ext: str, quoted_path: str):
"""Devuelve la expresion de lector DuckDB para una extension, o None.
El ``quoted_path`` ya viene escapado y entre comillas simples. Extensiones
desconocidas devuelven None para que el llamador salte el archivo.
"""
ext = ext.lower()
if ext in (".csv", ".tsv", ".txt"):
return f"read_csv_auto('{quoted_path}')"
if ext in (".parquet", ".pq"):
return f"read_parquet('{quoted_path}')"
if ext in (".json", ".ndjson"):
return f"read_json_auto('{quoted_path}')"
return None
def load_folder_to_duckdb(
folder: str,
db_path: str = None,
pattern: str = "*.csv,*.parquet,*.json",
) -> dict:
"""Carga los archivos tabulares de una carpeta como tablas en una DuckDB.
Args:
folder: ruta a un directorio. Si no existe o no es un directorio,
devuelve {status:'error', ...} sin lanzar.
db_path: ruta de la DuckDB destino (read-write, se crea si no existe). Si
es None, se genera una base temporal con NamedTemporaryFile y su ruta
se devuelve en el retorno (`db_path`).
pattern: CSV de globs separados por coma (default
"*.csv,*.parquet,*.json"). Cada glob se aplica con
glob.glob(os.path.join(folder, g)) en el primer nivel (NO recursivo);
los resultados se deduplican y ordenan.
Returns:
dict. En exito: {status:'ok', db_path:str, tables:[{name, source_file,
n_rows}], errors:[{name?, source_file, error}]}. La carpeta sin archivos
tabulares es un exito con tables=[] y errors=[]. En error (sin lanzar):
{status:'error', error:str}.
"""
if not isinstance(folder, str) or not os.path.isdir(folder):
return {
"status": "error",
"error": f"folder does not exist or is not a directory: {folder!r}",
}
conn = None
try:
# Resolver la ruta de la DuckDB destino. Si no se da, reservar un nombre
# temporal unico y borrar el archivo vacio que crea mkstemp: DuckDB 1.5.2
# rechaza abrir un archivo de 0 bytes ("not a valid DuckDB database
# file"), por lo que debe crear el archivo el mismo desde cero.
if db_path is None:
fd, tmp_name = tempfile.mkstemp(suffix=".duckdb")
os.close(fd)
os.remove(tmp_name)
db_path = tmp_name
# Resolver los archivos: un glob por cada patron, dedup + orden estable.
globs = [g.strip() for g in pattern.split(",") if g.strip()]
found = set()
for g in globs:
for path in glob.glob(os.path.join(folder, g)):
if os.path.isfile(path):
found.add(path)
files = sorted(found)
conn = __import__("duckdb").connect(db_path)
tables = []
errors = []
used_names = set()
for i, path in enumerate(files):
base = os.path.basename(path)
stem, ext = os.path.splitext(base)
quoted_path = path.replace("'", "''")
reader = _reader_for_extension(ext, quoted_path)
if reader is None:
errors.append(
{
"source_file": path,
"error": f"unsupported extension: {ext!r}",
}
)
continue
name = _sanitize_table_name(stem, i)
# Desambiguar colisiones con sufijos _2, _3, ...
if name in used_names:
suffix = 2
while f"{name}_{suffix}" in used_names:
suffix += 1
name = f"{name}_{suffix}"
quoted_ident = '"' + name.replace('"', '""') + '"'
try:
conn.execute(
f"CREATE TABLE {quoted_ident} AS SELECT * FROM {reader}"
)
n_rows = conn.execute(
f"SELECT count(*) FROM {quoted_ident}"
).fetchone()[0]
used_names.add(name)
tables.append(
{
"name": name,
"source_file": path,
"n_rows": int(n_rows),
}
)
except Exception as e: # noqa: BLE001
errors.append(
{
"name": name,
"source_file": path,
"error": str(e),
}
)
return {
"status": "ok",
"db_path": db_path,
"tables": tables,
"errors": errors,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,73 @@
"""Tests para load_folder_to_duckdb."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import duckdb # noqa: E402
from load_folder_to_duckdb import load_folder_to_duckdb # noqa: E402
def _write_csv(path: str, header: str, rows: list[str]) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(header + "\n")
for r in rows:
f.write(r + "\n")
def test_carga_dos_csv_como_tablas(tmp_path):
_write_csv(
str(tmp_path / "ventas.csv"),
"id,total",
["1,10.5", "2,20.0", "3,5.25"],
)
_write_csv(
str(tmp_path / "clientes.csv"),
"id,nombre",
["1,ana", "2,luis"],
)
db = tmp_path / "out.duckdb"
res = load_folder_to_duckdb(str(tmp_path), str(db))
assert res["status"] == "ok", res
assert res["errors"] == []
assert len(res["tables"]) == 2
assert res["db_path"] == str(db)
assert os.path.exists(str(db))
by_name = {t["name"]: t for t in res["tables"]}
assert by_name["ventas"]["n_rows"] == 3
assert by_name["clientes"]["n_rows"] == 2
# Verificar que las tablas existen realmente en la base.
con = duckdb.connect(str(db), read_only=True)
assert con.execute("SELECT count(*) FROM ventas").fetchone()[0] == 3
assert con.execute("SELECT count(*) FROM clientes").fetchone()[0] == 2
con.close()
def test_db_path_none_crea_temporal(tmp_path):
_write_csv(str(tmp_path / "datos.csv"), "x", ["1", "2"])
res = load_folder_to_duckdb(str(tmp_path))
assert res["status"] == "ok", res
assert res["db_path"]
assert os.path.exists(res["db_path"])
assert len(res["tables"]) == 1
assert res["tables"][0]["n_rows"] == 2
os.remove(res["db_path"])
def test_carpeta_vacia_es_ok_sin_tablas(tmp_path):
db = tmp_path / "out.duckdb"
res = load_folder_to_duckdb(str(tmp_path), str(db))
assert res["status"] == "ok", res
assert res["tables"] == []
assert res["errors"] == []
def test_carpeta_inexistente_devuelve_status_error(tmp_path):
res = load_folder_to_duckdb(str(tmp_path / "no_existe"))
assert res["status"] == "error"
assert "folder" in res["error"]
@@ -0,0 +1,466 @@
"""Batería de tests de ACEPTACIÓN del AutomaticEDA — "que cada AEDA salga como queremos".
Esta suite es la red de seguridad del subsistema EDA del grupo `eda`: garantiza
que CADA capítulo de un informe AutomaticEDA sale poblado y con su contenido
esencial, que la feature de capítulos sueltos (``only_chapters``) resuelve sus
dependencias de cómputo, que los capítulos opcionales devuelven None cuando no
aplican, que el informe de carpeta multi-tabla detecta la FK, y que el Markdown
trae el apéndice completo (matriz de asociación entera + describe con
skew/kurtosis). A diferencia de los tests unitarios de cada capítulo, aquí se
ejercita el pipeline END-TO-END sobre un dataset sintético determinista que
activa todos los capítulos a la vez.
Determinismo: el dataset se genera con ``seed`` fijo y el pipeline corre sin LLM
(``profile_level='standard'``), de modo que el manifest y el Markdown son
reproducibles entre corridas. Un único render `standard` se reutiliza vía un
fixture de scope module para no repetir el cómputo caro.
dict-no-throw: los pipelines del grupo `eda` nunca lanzan; aquí se asserta sobre
``status == 'ok'`` y luego sobre el contenido concreto del manifest / Markdown.
Honestidad (DoD): los asserts comprueban CONTENIDO real (texto esencial de cada
capítulo), no solo el heading. Si un capítulo dejara de emitir su contenido (un
cambio rompiera la distribución numérica, el Isolation Forest, la matriz de
correlación completa, ), el test correspondiente FALLA nombrando el capítulo y
el fragmento ausente no se ablanda para que pase.
"""
import json
import os
import subprocess
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)
from datascience.automatic_eda import CHAPTER_ORDER # noqa: E402
from datascience.generate_synthetic_eda_folder import ( # noqa: E402
generate_synthetic_eda_folder,
)
from datascience.generate_synthetic_eda_table import ( # noqa: E402
generate_synthetic_eda_table,
)
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
from pipelines.render_automatic_eda_folder import ( # noqa: E402
render_automatic_eda_folder,
)
# --------------------------------------------------------------------------- #
# Parámetros deterministas del fixture de oro.
# --------------------------------------------------------------------------- #
SEED = 42
N_ROWS = 800
TABLE = "synthetic"
# El capítulo `analisis_llm` SOLO se computa con run_llm=True; en el preset
# `standard` (sin LLM, lo que esta suite usa) no debe aparecer. Por eso los
# capítulos esperados en un informe `standard` son todos los de CHAPTER_ORDER
# MENOS analisis_llm. CHAPTER_ORDER es la fuente de verdad de los 16 capítulos
# del motor (portada … glosario).
LLM_ONLY_CHAPTERS = {"analisis_llm"}
EXPECTED_STANDARD = [c for c in CHAPTER_ORDER if c not in LLM_ONLY_CHAPTERS]
def _pdf_text(path):
"""Texto del PDF vía pdftotext, o None si la herramienta no está disponible."""
try:
out = subprocess.run(
["pdftotext", "-layout", path, "-"],
capture_output=True, text=True, timeout=60,
)
return out.stdout if out.returncode == 0 else None
except Exception: # noqa: BLE001 — la verificación principal es sobre el MD.
return None
def _manifest_chapters(result):
"""Set de ids de capítulo presentes en el manifest del resultado."""
with open(result["manifest_path"], encoding="utf-8") as fh:
return set((json.load(fh).get("chapters") or {}).keys())
# --------------------------------------------------------------------------- #
# Fixtures de scope module: el dataset sintético se genera UNA vez y el render
# `standard` se computa UNA vez; todos los tests de contenido lo reutilizan.
# --------------------------------------------------------------------------- #
@pytest.fixture(scope="module")
def synth_db(tmp_path_factory):
"""Tabla sintética determinista que activa los 16 capítulos del motor."""
d = tmp_path_factory.mktemp("aeda_accept_synth")
db = str(d / "synthetic.duckdb")
g = generate_synthetic_eda_table(db, TABLE, n_rows=N_ROWS, seed=SEED)
assert g["status"] == "ok", g.get("error")
return {"db": db, "table": TABLE, "gen": g}
@pytest.fixture(scope="module")
def standard_run(synth_db, tmp_path_factory):
"""Render AutomaticEDA `standard` (sin LLM) sobre el dataset sintético.
Devuelve el dict del pipeline más el manifest cargado, el texto del Markdown
y el del PDF (si pdftotext está). Reutilizado por la mayoría de los tests.
"""
out = str(tmp_path_factory.mktemp("aeda_accept_std"))
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out, basename="synth_std",
)
assert r["status"] == "ok", r.get("error")
with open(r["manifest_path"], encoding="utf-8") as fh:
manifest = json.load(fh)
md = open(r["aeda_md_path"], encoding="utf-8").read()
return {
"r": r,
"manifest": manifest,
"chapters": manifest.get("chapters") or {},
"md": md,
"pdf_text": _pdf_text(r["pdf_path"]),
}
@pytest.fixture(scope="module")
def minimal_db(tmp_path_factory):
"""Tabla mínima SIN texto libre, SIN fecha y SIN lat/lon.
Sirve para comprobar que text_distr / timeseries / geospatial devuelven None
(no aparecen en el manifest) y el EDA no peta. Solo numéricas continuas +
una categórica de baja cardinalidad.
"""
import random
import duckdb
d = tmp_path_factory.mktemp("aeda_accept_min")
db = str(d / "minimal.duckdb")
con = duckdb.connect(db)
con.execute("CREATE TABLE minimal (a DOUBLE, b DOUBLE, c INTEGER, grp VARCHAR)")
random.seed(7)
rows = [
(round(random.gauss(10, 2), 3), round(random.gauss(50, 5), 3),
random.randint(1, 100), ["x", "y", "z"][i % 3])
for i in range(120)
]
con.executemany("INSERT INTO minimal VALUES (?,?,?,?)", rows)
con.close()
return {"db": db, "table": "minimal"}
# --------------------------------------------------------------------------- #
# 1) COBERTURA DE CAPÍTULOS (golden) — el manifest standard trae los 15
# capítulos no-LLM esperados, ninguno falta, y analisis_llm NO sale sin LLM.
# --------------------------------------------------------------------------- #
def test_standard_cubre_todos_los_capitulos_esperados(standard_run):
chapters = set(standard_run["chapters"].keys())
expected = set(EXPECTED_STANDARD)
missing = expected - chapters
assert not missing, (
"capítulos esperados ausentes del manifest standard: "
f"{sorted(missing)} (presentes: {sorted(chapters)})"
)
# analisis_llm requiere run_llm=True: en standard NO debe aparecer.
assert "analisis_llm" not in chapters, (
"analisis_llm apareció sin LLM: el preset standard no debería computarlo"
)
def test_manifest_top_level_es_valido(standard_run):
"""El manifest declara el motor y un dict de capítulos con metadatos por id."""
man = standard_run["manifest"]
assert man.get("engine") == "AutomaticEDA"
assert man.get("engine_version")
chapters = standard_run["chapters"]
# Cada capítulo trae version + nº de páginas/slides (formato del manifest).
for cid, meta in chapters.items():
assert meta.get("version"), f"capítulo {cid} sin version en el manifest"
assert (meta.get("n_pages") or 0) > 0, f"capítulo {cid} con 0 páginas"
# --------------------------------------------------------------------------- #
# 2) CONTENIDO CLAVE POR CAPÍTULO (acceptance) — cada capítulo trae su contenido
# ESENCIAL en el Markdown, no solo el heading. Un fragmento ausente nombra el
# capítulo y el texto que falta.
# --------------------------------------------------------------------------- #
# Fragmentos de texto ESTABLE que cada capítulo emite en el Markdown del dataset
# sintético. No son números frágiles: son etiquetas/estructura del capítulo más
# nombres de columna del fixture. Si un capítulo deja de poblar su contenido, su
# fragmento desaparece y el test falla nombrándolo.
CHAPTER_NEEDLES = {
"portada": ["800 filas", "19 columnas"],
"overview": ["Primeras filas (df.head)", "Diccionario de columnas",
"customer_id", "signup_date"],
"num_distr": ["Distribuciones numéricas", "vallas Tukey", "income"],
"cat_distr": ["Distribuciones categóricas", "Entropía", "Top categorías",
"country"],
"text_distr": ["Texto libre (NLP)", "TTR", "Términos más frecuentes",
"Idioma dominante"],
"calidad": ["Cómo se calcula la calidad", "Calidad global"],
"missingness": ["Datos faltantes", "Celdas faltantes (global)",
"Faltantes por columna"],
"outliers": ["Valores atípicos por columna", "Filas atípicas (multivariante)",
"Isolation Forest", "Filas analizadas"],
"correlacion": ["Matriz de asociación", "Pares más correlacionados"],
"relaciones": ["Candidatas a clave primaria", "customer_id"],
"modelos": ["PCA — varianza explicada", "Segmentación (KMeans)"],
"timeseries": ["Series temporales", "Columna de fecha", "signup_date"],
"geospatial": ["Análisis geoespacial", "Extensión geográfica", "Centroide"],
"agregacion": ["Agregación por grupos", "Agrupado por"],
"glosario": ["Glosario de términos",
"### Isolation Forest (anomalías multivariantes)",
"### PCA (componentes principales)"],
}
def test_needles_cubren_exactamente_los_capitulos_standard():
"""Guard de mantenimiento: las needles cubren los mismos 15 capítulos no-LLM.
Si alguien añade un capítulo nuevo a CHAPTER_ORDER, este test recuerda que
hay que documentar su contenido esencial aquí (o marcarlo como LLM-only)."""
assert set(CHAPTER_NEEDLES.keys()) == set(EXPECTED_STANDARD), (
"CHAPTER_NEEDLES desincronizado con los capítulos esperados de standard: "
f"falta needles para {set(EXPECTED_STANDARD) - set(CHAPTER_NEEDLES)}, "
f"sobra {set(CHAPTER_NEEDLES) - set(EXPECTED_STANDARD)}"
)
@pytest.mark.parametrize("chapter_id", list(CHAPTER_NEEDLES.keys()))
def test_capitulo_trae_su_contenido_esencial(standard_run, chapter_id):
md = standard_run["md"]
# Pre-condición: el capítulo está en el manifest (cobertura). Si no, es un
# fallo de cobertura, no de contenido — se reporta como tal.
assert chapter_id in standard_run["chapters"], (
f"capítulo {chapter_id} ausente del manifest (fallo de cobertura)"
)
for needle in CHAPTER_NEEDLES[chapter_id]:
assert needle in md, (
f"capítulo '{chapter_id}': falta su contenido esencial en el Markdown "
f"— fragmento ausente: {needle!r}"
)
def test_outliers_isolation_forest_poblado_no_degradado(standard_run):
"""El bloque multivariante (Isolation Forest) sale con datos, no degradado."""
md = standard_run["md"]
assert "Anomalías multivariantes" in md
assert "Filas analizadas" in md, "el Isolation Forest no trae su tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, (
"el bloque multivariante salió degradado en el informe completo"
)
# El perfil trae el bloque de modelos con los outliers multivariantes.
models = (standard_run["r"]["profile"] or {}).get("models") or {}
assert models.get("outliers") is not None, "profile['models']['outliers'] vacío"
# --------------------------------------------------------------------------- #
# 3) CAPÍTULOS SUELTOS CON DEPS RESUELTAS (acceptance de only_chapters) — pedir
# un capítulo suelto lo deja POBLADO porque la resolución de dependencias
# activa el cómputo que necesita, aunque el caller no lo pidiera.
# --------------------------------------------------------------------------- #
def test_only_outliers_isolation_forest_poblado(synth_db, tmp_path):
"""only=['outliers'] sin run_models explícito → IsolationForest poblado."""
out = str(tmp_path / "only_out")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["outliers"], out_dir=out, basename="only_outliers",
)
assert r["status"] == "ok", r.get("error")
# Documento = portada + outliers + glosario, nada más.
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Filas atípicas (multivariante)" in md
assert "Filas analizadas" in md, "Isolation Forest sin tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, (
"el multivariante salió degradado pese a resolver las deps"
)
# La resolución activó run_models → el perfil trae el bloque de modelos.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is not None
def test_only_timeseries_rango_temporal_presente(synth_db, tmp_path):
"""only=['timeseries'] → rango temporal poblado (run_series resuelto)."""
out = str(tmp_path / "only_ts")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["timeseries"], out_dir=out, basename="only_ts",
)
assert r["status"] == "ok", r.get("error")
assert "timeseries" in _manifest_chapters(r)
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Columna de fecha" in md
assert "signup_date" in md, "la serie no nombra su columna de fecha"
# run_series resuelto por deps → el perfil trae el análisis de serie.
assert (r["profile"] or {}).get("series") is not None, (
"only=['timeseries'] debe activar run_series por dependencias"
)
def test_only_correlacion_scatters_presentes(synth_db, tmp_path):
"""only=['correlacion'] → matriz + scatters de los pares fuertes."""
out = str(tmp_path / "only_corr")
r = render_automatic_eda(
synth_db["db"], synth_db["table"],
only_chapters=["correlacion"], out_dir=out, basename="only_corr",
)
assert r["status"] == "ok", r.get("error")
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Matriz de asociación" in md
assert "Relaciones más fuertes (scatter)" in md, "faltan los scatters"
assert "Dispersión de" in md, "no se emitió ninguna figura de dispersión"
# --------------------------------------------------------------------------- #
# 4) NONE CUANDO NO APLICA — sobre una tabla sin texto largo, sin fecha y sin
# lat/lon, text_distr / timeseries / geospatial NO aparecen y el EDA no peta.
# --------------------------------------------------------------------------- #
def test_capitulos_opcionales_ausentes_cuando_no_aplican(minimal_db, tmp_path):
out = str(tmp_path / "minimal_out")
r = render_automatic_eda(
minimal_db["db"], minimal_db["table"],
profile_level="standard", out_dir=out, basename="minimal",
)
assert r["status"] == "ok", r.get("error")
chapters = _manifest_chapters(r)
for absent in ("text_distr", "timeseries", "geospatial"):
assert absent not in chapters, (
f"capítulo {absent} apareció en una tabla que no lo justifica "
f"(presentes: {sorted(chapters)})"
)
# El documento sigue siendo válido: portada + glosario + capítulos que sí
# aplican (overview/num_distr/correlacion al menos).
assert {"portada", "glosario", "overview", "num_distr"} <= chapters
# --------------------------------------------------------------------------- #
# 5) FOLDER MULTI-TABLA (acceptance) — el informe de carpeta perfila las N tablas
# y el capítulo de relaciones detecta la FK por containment.
# --------------------------------------------------------------------------- #
def test_folder_multitabla_con_fk_detectada(tmp_path):
fdir = str(tmp_path / "folder")
g = generate_synthetic_eda_folder(fdir, n_rows=300, seed=SEED)
assert g["status"] == "ok", g.get("error")
out = str(tmp_path / "fout")
rf = render_automatic_eda_folder(fdir, out_dir=out, basename="folder")
assert rf["status"] == "ok", rf.get("error")
# Las 3 tablas se perfilaron.
assert rf["n_tables"] == 3, f"esperadas 3 tablas, vistas {rf['n_tables']}"
# El manifest base trae el capítulo de relaciones inter-tabla.
with open(rf["manifest_path"], encoding="utf-8") as fh:
chapters = set((json.load(fh).get("chapters") or {}).keys())
assert "relaciones" in chapters, (
f"el documento de carpeta no incluye el capítulo de relaciones: {chapters}"
)
# El Markdown nombra las 3 tablas y declara la FK detectada por containment.
md = open(rf["md_path"], encoding="utf-8").read()
for tbl in ("customers", "orders", "reviews"):
assert tbl in md, f"la tabla {tbl} no aparece en el informe de carpeta"
assert "FK candidatas" in md, "no se declaran las FK candidatas"
assert "orders.customer_id" in md and "customers.customer_id" in md, (
"la FK orders→customers no se detectó por containment"
)
assert "reviews.customer_id" in md, "la FK reviews→customers no se detectó"
# --------------------------------------------------------------------------- #
# 6) MD COMPLETITUD (regresión) — el Markdown trae el apéndice con la matriz de
# asociación COMPLETA (todos los pares, no solo el top) y el describe con
# skew/kurtosis de todas las numéricas. Protege un fix ya mergeado.
# --------------------------------------------------------------------------- #
def test_md_apendice_matriz_correlacion_completa(standard_run):
md = standard_run["md"]
assert "Matriz de asociación — todos los pares" in md, (
"falta el apéndice con la matriz de asociación completa"
)
# Un par num-num de correlación BAJA que el top del capítulo NUNCA mostraría:
# su presencia prueba que el apéndice lista TODOS los pares, no solo el top.
assert "income ↔ longitude" in md, (
"el apéndice no contiene los pares de baja correlación: no es la matriz "
"completa, solo el top-k del capítulo"
)
def test_md_apendice_describe_con_skew_kurtosis(standard_run):
md = standard_run["md"]
assert "Estadísticos numéricos completos (describe)" in md, (
"falta el apéndice describe completo"
)
# La cabecera del describe del apéndice lleva las columnas skew y kurtosis
# (subcadena única de ese header). Sin ellas el describe está incompleto.
assert "| skew | kurtosis |" in md, (
"el describe del apéndice no trae las columnas skew/kurtosis"
)
# --------------------------------------------------------------------------- #
# 7) LAS 3 SALIDAS NO-VACÍAS — PDF con páginas, PPTX con slides, MD con un mínimo
# de caracteres, y los tres archivos en disco. Manifest válido.
# --------------------------------------------------------------------------- #
def test_tres_salidas_no_vacias(standard_run):
r = standard_run["r"]
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert r["aeda_md_path"] and os.path.exists(r["aeda_md_path"])
assert (r["n_pages"] or 0) > 0, "el PDF no tiene páginas"
assert (r["n_slides"] or 0) > 0, "el PPTX no tiene slides"
# El informe completo es grande: un mínimo holgado protege contra un MD vacío
# o truncado sin atarse a un tamaño exacto.
assert (r["md_chars"] or 0) > 10000, f"MD demasiado corto: {r['md_chars']} chars"
assert r["manifest_path"] and os.path.exists(r["manifest_path"])
def test_pdf_texto_extraible_con_contenido(standard_run):
"""Si pdftotext está disponible, el PDF debe traer texto real (no solo
imágenes): la portada nombra el dataset y su forma. Si no está la
herramienta, el test se omite (no es un fallo del EDA)."""
txt = standard_run["pdf_text"]
if txt is None:
pytest.skip("pdftotext no disponible")
assert len(txt) > 5000, "el PDF apenas tiene texto extraíble"
assert "Portada" in txt or "synthetic" in txt, (
"el texto del PDF no contiene la portada esperada"
)
# --------------------------------------------------------------------------- #
# DETERMINISMO — dos renders del MISMO dataset producen el MISMO manifest
# (mismos capítulos y mismos n_pages/n_slides por capítulo). El generated_at
# difiere por timestamp, por eso se compara el dict de capítulos, no el archivo.
# --------------------------------------------------------------------------- #
def test_render_es_determinista(synth_db, tmp_path):
out1 = str(tmp_path / "det1")
out2 = str(tmp_path / "det2")
r1 = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out1, basename="d1")
r2 = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="standard", out_dir=out2, basename="d2")
assert r1["status"] == "ok" and r2["status"] == "ok"
c1 = json.load(open(r1["manifest_path"], encoding="utf-8")).get("chapters")
c2 = json.load(open(r2["manifest_path"], encoding="utf-8")).get("chapters")
assert c1 == c2, "el manifest no es determinista entre dos renders del mismo dataset"
# --------------------------------------------------------------------------- #
# SLOW (opcional, skippeable) — informe `full` con narrativa LLM. Requiere red /
# credenciales y NO es determinista, por eso está apagado salvo opt-in explícito
# vía la variable de entorno EDA_ACCEPT_LLM=1. Se omite con skipif (no con un
# marker custom) para no depender de registro de marks en la config del repo.
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(
os.environ.get("EDA_ACCEPT_LLM") != "1",
reason="full+LLM es lento/no determinista; exporta EDA_ACCEPT_LLM=1 para correrlo",
)
def test_full_incluye_capitulo_analisis_llm(synth_db, tmp_path):
out = str(tmp_path / "full")
r = render_automatic_eda(synth_db["db"], synth_db["table"],
profile_level="full", out_dir=out, basename="full")
assert r["status"] == "ok", r.get("error")
assert "analisis_llm" in _manifest_chapters(r), (
"el preset full debe incluir el capítulo de análisis LLM"
)
@@ -0,0 +1,106 @@
---
name: profile_bq_table
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.1.0"
signature: "def profile_bq_table(table_fqn: str, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: list = None, run_models: bool = True, run_series: bool = False, run_llm: bool = False, project_id: str = \"\", report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = False) -> dict"
description: "EDA one-shot de una tabla o vista de BigQuery: materializa el origen COMPLETO por defecto (todas las filas; muestreo opt-in con sample_frac; seudonimizacion PII opcional, LOPDGDD/RGPD) a un DuckDB local con load_bq_table_to_duckdb y lo perfila end-to-end con profile_table del grupo de capacidad eda, emitiendo el informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el adaptador BigQuery que faltaba en el grupo eda, resuelto por composicion (BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni de render. Es el hazme un EDA de esta tabla BigQuery en una sola llamada, sobre el total de filas por defecto."
tags: [eda, bigquery, launcher]
uses_functions:
- load_bq_table_to_duckdb_py_datascience
- profile_table_py_pipelines
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/profile_bq_table.py"
params:
- name: table_fqn
desc: "FQN de la tabla/vista BigQuery: `project.dataset.table`."
- name: sample_frac
desc: "None (DEFAULT) = FULL, perfila TODAS las filas del origen. Un float en (0,1) activa el muestreo opt-in (`WHERE rand() < frac`, ~frac del total)."
- name: max_rows
desc: "Tope duro opcional de filas (LIMIT). 0 (DEFAULT) = sin tope. Se combina con sample_frac si ambos se pasan."
- name: pseudonymize_cols
desc: "Columnas PII a seudonimizar (hash) antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad."
- name: run_models
desc: "PCA/KMeans/IsolationForest/normalidad sobre numericas. Default True (informe AutomaticEDA completo)."
- name: run_series
desc: "Analisis de serie temporal por columna numerica. Default False."
- name: run_llm
desc: "1 llamada LLM sobre el perfil agregado (nunca filas crudas). Default False."
- name: project_id
desc: "Proyecto GCP de facturacion. Vacio = primer segmento del FQN."
- name: report_dir
desc: "Directorio de salida de los reports. Default 'reports' (artefacto local gitignored)."
- name: duckdb_path
desc: "Ruta DuckDB a usar. Vacio = temporal autogestionado."
- name: keep_duckdb
desc: "Si True conserva el DuckDB materializado (para el notebook Jupyter). Default False."
output: "dict dict-no-throw. En exito {status:'ok', table_fqn, load:{n_rows_source,n_rows_fetched,sampled,sample_frac,pseudonymized,table}, duckdb_path, report_md_path, report_json_path, aeda_pdf_path, aeda_pptx_path, aeda_manifest_path, profile}. En error {status:'error', error, stage}."
---
## Ejemplo
```python
from pipelines.profile_bq_table import profile_bq_table
# FULL por defecto: EDA sobre TODAS las filas de la vista (3,8M).
r = profile_bq_table(
"autingo-159109.customer_marts.customer_profile",
pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"],
run_models=True,
)
print(r["load"]["n_rows_fetched"], "filas perfiladas, sampled=", r["load"]["sampled"])
print(r["aeda_pdf_path"]); print(r["aeda_pptx_path"]); print(r["report_md_path"])
# Muestreo opt-in: EDA sobre ~5 % de las filas (tabla enorme / iteracion rapida).
r = profile_bq_table(
"autingo-159109.customer_marts.customer_profile",
sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"],
)
```
## Cuando usarla
Cuando pidan un EDA de una tabla o vista de BigQuery ("hazme un EDA de esta
tabla BigQuery"). Es el adaptador BigQuery del grupo de capacidad `eda` por
composicion: trae el origen COMPLETO (todas las filas, por defecto) a un DuckDB
local y delega todo el perfilado y render en `profile_table`, sin adaptador
BigQuery nativo ni logica de EDA duplicada. Usala como primer paso al recibir un
dataset BigQuery desconocido, antes de modelar o limpiar, o para auditar la
calidad de una vista ya productiva. Para iteracion rapida o tablas que no quepan
en RAM, pasa `sample_frac` (muestreo opt-in).
## Gotchas
- Impura: requiere ADC de BigQuery configurado (Application Default Credentials)
para que `load_bq_table_to_duckdb` autentique contra el proyecto.
- FULL por defecto: `sample_frac=None` perfila TODAS las filas del origen. Una
vista de millones de filas se trae entera a RAM (varios GB posibles) antes de
materializar en DuckDB; el fetch usa el BigQuery Storage Read API (Arrow) cuando
esta disponible, mucho mas rapido que REST. Para acotar coste/memoria, pasa
`sample_frac` in (0,1) (muestreo opt-in) o `max_rows` (tope duro). Si por limite
de recursos no cabe el total, dilo explicito con el maximo que si se cargo.
- Seudonimiza PII con `pseudonymize_cols` para cumplir LOPDGDD/RGPD ANTES de
escribir a disco: nombres, DNI/NIE, email, telefono, direccion, IDs de cliente,
etc. Se hashean preservando nulos y cardinalidad. Sin seudonimizar, la muestra
materializada (DuckDB + reports) contiene datos personales reales [POL-MMNSEG-001-1.0].
- El DuckDB temporal se borra al terminar salvo `keep_duckdb=True` (pasalo para
seguir explorando la muestra desde un notebook Jupyter). Si pasas `duckdb_path`
explicito, la ruta se respeta y solo se conserva con `keep_duckdb=True`.
- Escribe reports a `report_dir` (default 'reports', artefacto local gitignored):
Markdown + JSON sidecar + PDF A5 movil + PPTX 16:9 del informe AutomaticEDA.
- `run_llm=True` gasta tokens (haiku) pero solo envia el perfil agregado, nunca
filas crudas ni datos personales.
## Capability growth log
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT del pipeline: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = perfila todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explicito (`sample_frac`). Alinea con la preferencia estandar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario. Hereda el fetch acelerado (Arrow/bqstorage) de `load_bq_table_to_duckdb` v1.1.0.
@@ -0,0 +1,138 @@
"""profile_bq_table — EDA one-shot de una tabla/vista BigQuery con el grupo `eda`.
Pipeline impuro: materializa una tabla o vista de BigQuery (por defecto COMPLETA
todas las filas o una muestra si se pasa `sample_frac`, con seudonimizacion PII
opcional, LOPDGDD/RGPD) a un DuckDB local con `load_bq_table_to_duckdb`, y la
perfila end-to-end con `profile_table` del grupo de capacidad `eda`, emitiendo el
informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el
adaptador BigQuery que faltaba en el grupo `eda`, resuelto por composicion
(BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni
de render.
Modo por defecto = FULL: `sample_frac=None` perfila TODAS las filas del origen
(preferencia estandar del usuario: los EDA se corren sobre el total salvo que se
pida lo contrario). El muestreo es opt-in explicito: `sample_frac=0.05` perfila
~5 % de las filas; `max_rows` es un tope duro opcional (0 = sin tope).
Funciones del registry compuestas (NO se reimplementa su logica):
- load_bq_table_to_duckdb : trae la tabla/vista BigQuery a un DuckDB local
(completa por defecto, o muestra si sample_frac).
- profile_table : orquestador one-shot del grupo `eda` que perfila la
DuckDB materializada y emite el informe AutomaticEDA.
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
"""
import os
import tempfile
from datascience import load_bq_table_to_duckdb
from pipelines.profile_table import profile_table
def profile_bq_table(
table_fqn: str,
sample_frac: float = None,
max_rows: int = 0,
pseudonymize_cols: list = None,
run_models: bool = True,
run_series: bool = False,
run_llm: bool = False,
project_id: str = "",
report_dir: str = "reports",
duckdb_path: str = "",
keep_duckdb: bool = False,
) -> dict:
"""EDA one-shot de una tabla/vista BigQuery.
Por defecto perfila TODAS las filas del origen (`sample_frac=None`, modo FULL).
Materializa el origen (con seudonimizacion PII opcional) a un DuckDB local y lo
perfila con `profile_table` del grupo `eda`, emitiendo el informe AutomaticEDA
(PDF A5 movil + PPTX 16:9) + Markdown + JSON sidecar.
Args:
table_fqn: FQN de la tabla/vista BigQuery ("project.dataset.table").
sample_frac: None (default) = FULL, perfila todas las filas. Un float en
(0,1) activa el muestreo opt-in (`WHERE rand() < frac`, ~frac del total).
max_rows: Tope duro opcional de filas (LIMIT). 0 (default) = sin tope.
pseudonymize_cols: Columnas PII a seudonimizar (hash) antes de materializar.
run_models: Modelos baratos (PCA/KMeans/IsolationForest/normalidad).
run_series: Analisis de serie temporal por columna numerica.
run_llm: 1 llamada LLM sobre el perfil agregado (nunca filas crudas).
project_id: Proyecto GCP de facturacion. Vacio = primer segmento del FQN.
report_dir: Directorio de salida de los reports.
duckdb_path: Ruta DuckDB a usar. Vacio = temporal autogestionado.
keep_duckdb: Si True conserva el DuckDB materializado.
Returns:
dict dict-no-throw con el resultado del pipeline (ver output del .md).
"""
tmp_created = False
try:
# DuckDB temporal si no se pasa ruta.
if not duckdb_path:
fd, duckdb_path = tempfile.mkstemp(prefix="eda_bq_", suffix=".duckdb")
os.close(fd)
os.remove(duckdb_path) # que lo cree DuckDB limpio
tmp_created = True
load = load_bq_table_to_duckdb(
table_fqn,
duckdb_path,
sample_frac=sample_frac,
max_rows=max_rows,
project_id=project_id,
pseudonymize_cols=pseudonymize_cols,
)
if load.get("status") != "ok":
return {
"status": "error",
"error": load.get("error", "load fallo"),
"stage": "load",
}
prof = profile_table(
duckdb_path,
load["table"],
backend="duckdb",
run_models=run_models,
run_series=run_series,
run_llm=run_llm,
emit_automatic=True, # PDF A5 movil + PPTX 16:9
emit_pdf=False,
write_report=True, # Markdown + JSON sidecar
report_dir=report_dir,
)
if prof.get("status") != "ok":
return {
"status": "error",
"error": prof.get("error", "profile fallo"),
"stage": "profile",
"load": load,
}
return {
"status": "ok",
"table_fqn": table_fqn,
"load": {
k: load[k]
for k in ("n_rows_source", "n_rows_fetched", "sampled", "sample_frac", "pseudonymized", "table")
if k in load
},
"duckdb_path": duckdb_path if keep_duckdb else None,
"report_md_path": prof.get("report_md_path"),
"report_json_path": prof.get("report_json_path"),
"aeda_pdf_path": prof.get("aeda_pdf_path"),
"aeda_pptx_path": prof.get("aeda_pptx_path"),
"aeda_manifest_path": prof.get("aeda_manifest_path"),
"profile": prof.get("profile"),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
# Limpia el DuckDB temporal salvo que se pida conservarlo.
if tmp_created and not keep_duckdb and duckdb_path and os.path.exists(duckdb_path):
try:
os.remove(duckdb_path)
except OSError:
pass
@@ -4,8 +4,8 @@ kind: pipeline
lang: py lang: py
domain: pipelines domain: pipelines
purity: impure purity: impure
version: "1.1.0" version: "1.2.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" 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, emit_md: bool = True, only_chapters: list = 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." 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] tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions: uses_functions:
@@ -46,6 +46,10 @@ params:
desc: "Nombre base de los archivos sin extension. Default 'aeda_<table>_<timestamp>'." desc: "Nombre base de los archivos sin extension. Default 'aeda_<table>_<timestamp>'."
- name: ctx_extra - name: ctx_extra
desc: "Dict opcional con claves de presentacion/contexto extra que se mezclan en el ctx (dataset_name, description, source_origin, ...); no pisan las claves de datos calculadas por build_eda_render_ctx." desc: "Dict opcional con claves de presentacion/contexto extra que se mezclan en el ctx (dataset_name, description, source_origin, ...); no pisan las claves de datos calculadas por build_eda_render_ctx."
- name: emit_md
desc: "Ademas del PDF y el PPTX, emite un Markdown autocontenido del mismo documento por capitulos (texto + tablas markdown, sin binarios) para pegar a un LLM. Default True. La ruta sale en aeda_md_path."
- name: only_chapters
desc: "Lista opcional de ids de capitulo a renderizar (subconjunto de CHAPTER_ORDER) para iterar/testear un capitulo suelto sin generar el documento entero. Default None => documento COMPLETO (retrocompatible). Cuando se pasa una lista: (1) se VALIDA contra CHAPTER_ORDER, un id desconocido o lista vacia devuelve error claro listando los validos; (2) se RESUELVEN las dependencias de computo de esos capitulos (automatic_eda.chapter_deps) activando los flags que necesiten (run_models/run_series/run_llm) aunque el caller no los pidiera y construyendo SOLO las piezas de ctx que leen, de modo que el capitulo suelto SIEMPRE llega poblado (p.ej. ['outliers'] activa run_models y conserva raw_numeric -> Isolation Forest completo) sin malgastar CPU/LLM en lo que ningun capitulo pedido usa; (3) el documento y su manifest contienen SOLO esos capitulos MAS portada (primera) y glosario (ultima, cuando hay terminos clicables). Un flag explicito del caller prima sobre la resolucion de dependencias."
output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None, n_pages:int, n_slides:int, pdf_note:str, pptx_note:str, profile:<TableProfile>} o {status:'error', error:str} (dict-no-throw)." output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None, n_pages:int, n_slides:int, pdf_note:str, pptx_note:str, profile:<TableProfile>} o {status:'error', error:str} (dict-no-throw)."
--- ---
@@ -69,6 +73,21 @@ r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="full")
# Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM: # Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM:
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
profile_level="lite", run_llm=True) # el LLM SI se ejecuta profile_level="lite", run_llm=True) # el LLM SI se ejecuta
# Capitulo SUELTO: itera/testea un capitulo sin generar el documento entero. La
# resolucion de dependencias activa el computo que el capitulo necesita aunque no
# se pase explicito. Pedir solo 'outliers' activa run_models y conserva
# raw_numeric -> el bloque Isolation Forest sale COMPLETO. Documento = portada +
# outliers + glosario.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["outliers"])
# Varios capitulos sueltos a la vez (se unen sus dependencias):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
only_chapters=["correlacion", "missingness"])
# id desconocido -> error claro listando los validos (dict-no-throw, no lanza):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", only_chapters=["nope"])
# {'status': 'error', 'error': 'only_chapters con ids desconocidos: nope. Capitulos validos: portada, overview, ...'}
``` ```
## Cuando usarla ## Cuando usarla
@@ -86,6 +105,16 @@ Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) us
temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo, temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo. `profile_level="full"`. El default `"standard"` mantiene el comportamiento previo.
Cuando estes **iterando o testeando UN capitulo concreto** (afinar el render de
outliers, comprobar el mapa geoespacial, depurar la agregacion) usa
`only_chapters=[...]`: genera el documento con solo esos capitulos (+ portada y
glosario), pero **resuelve sus dependencias de computo** para que el capitulo
suelto nunca salga degradado — pedir `['outliers']` activa run_models y conserva
`raw_numeric` aunque no los pases, y a la vez no malgasta CPU/LLM en lo que ningun
capitulo pedido necesita (pedir `['geospatial']` no corre modelos). Es mucho mas
rapido que renderizar el informe entero en cada iteracion. El mapa central de
dependencias vive en `automatic_eda/chapter_deps.py` (fuente de verdad).
## Gotchas ## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`. - Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
@@ -111,9 +140,29 @@ temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla - 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 entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria). (coste: mas memoria).
- **`only_chapters` y el glosario**: el glosario (ultimo capitulo) solo aparece si
algun capitulo del cuerpo registro terminos clicables. Un capitulo suelto que no
registra terminos (p.ej. `timeseries`, `geospatial`) sale como portada + ese
capitulo, sin glosario, porque no hay nada que enlazar — es correcto, no un fallo.
- **`only_chapters` con `profile_level="lite"`**: en capitulos sueltos el preset
solo gobierna `sample`; los modelos NO usan el camino "lite" (que podaria
`ctx['raw_numeric']` y dejaria a outliers sin su multivariante en vivo). Quien
manda en capitulos sueltos es la resolucion de dependencias, no el preset de
coste de modelos.
## Capability growth log ## Capability growth log
- v1.2.0 (2026-06-30) — anade el parametro `only_chapters`: renderiza un
SUBCONJUNTO de capitulos (para iterar/testear uno suelto) resolviendo sus
dependencias de computo via `automatic_eda/chapter_deps.py` (mapa central
CHAPTER_DEPS): activa los flags de coste que el capitulo necesita (run_models/
run_series/run_llm) aunque el caller no los pase y construye solo las piezas de
ctx que lee, de modo que el capitulo suelto SIEMPRE llega poblado (golden:
['outliers'] -> Isolation Forest completo) sin malgastar en lo que no usa. La
seleccion viaja a build_document por la clave reservada `ctx['_only_chapters']`
(los renderers no cambian). Valida ids (error claro dict-no-throw). Cambio
aditivo y retro-compatible: `only_chapters=None` produce el documento completo
identico a v1.1.0.
- v1.1.0 (2026-06-30) — anade el parametro `profile_level` (lite/standard/full), - 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/ 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 sample. lite limita los modelos a PCA+normalidad (cableado a run_eda_models con
@@ -99,6 +99,7 @@ def render_automatic_eda(
basename: str = None, basename: str = None,
ctx_extra: dict = None, ctx_extra: dict = None,
emit_md: bool = True, emit_md: bool = True,
only_chapters: list = None,
) -> dict: ) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX). """Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
@@ -150,6 +151,29 @@ def render_automatic_eda(
MISMO documento por capítulos (texto plano + tablas markdown, sin MISMO documento por capítulos (texto plano + tablas markdown, sin
binarios), pensado para pegar a un LLM. Default True. La ruta sale en binarios), pensado para pegar a un LLM. Default True. La ruta sale en
la clave de retorno ``aeda_md_path``. No altera las demás salidas. la clave de retorno ``aeda_md_path``. No altera las demás salidas.
only_chapters: lista opcional de ids de capítulo a renderizar (un
SUBCONJUNTO de CHAPTER_ORDER) para iterar/testear un capítulo concreto
sin generar el documento entero. Default None => documento COMPLETO,
idéntico al de hoy (retrocompatible). Cuando se pasa una lista:
- Se VALIDA contra CHAPTER_ORDER; un id desconocido devuelve un error
claro listando los válidos (dict-no-throw, no lanza). Lista vacía
``[]`` también devuelve error (pasa al menos un capítulo o None).
- Se RESUELVEN las dependencias de cómputo de esos capítulos
(``automatic_eda.chapter_deps``): se activan los flags de coste que
necesiten (run_models / run_series / run_llm) AUNQUE el caller no
los pidiera, y se construyen SOLO las piezas de ``ctx`` que esos
capítulos leen. Así un capítulo suelto SIEMPRE llega poblado
p.ej. ``only_chapters=['outliers']`` activa run_models y conserva
``ctx['raw_numeric']`` para que el bloque IsolationForest salga
completo y a la vez no se malgasta CPU/LLM en lo que ningún
capítulo pedido usa (pedir solo ``geospatial`` no corre modelos).
- El documento (PDF/PPTX/MD) y su manifest contienen SOLO esos
capítulos, MÁS la portada (primera) y el glosario (última), que se
incluyen siempre para que el documento sea válido y los términos
clicables tengan destino.
- Un flag explícito del caller (run_models/run_series/run_llm != None)
SIEMPRE prima sobre lo que resuelvan las dependencias.
Returns: Returns:
dict (nunca lanza). En éxito:: dict (nunca lanza). En éxito::
@@ -169,11 +193,56 @@ def render_automatic_eda(
# "standard" (comportamiento histórico), sin lanzar. # "standard" (comportamiento histórico), sin lanzar.
preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"]) preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
sample = preset["sample"] if sample is None else sample 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"] model_opts = preset["model_opts"]
# 0.bis) Modo "capítulos sueltos": valida la selección y RESUELVE sus
# dependencias de cómputo. Es lo que garantiza que un capítulo pedido
# llegue completo (activa lo que necesita) sin malgastar en lo que no.
# Cuando only_chapters es None se conserva el camino histórico (preset).
if only_chapters is not None:
from datascience.automatic_eda import CHAPTER_ORDER
from datascience.automatic_eda.chapter_deps import (
needs_render_ctx,
resolve_ctx_data_keys,
resolve_requirements,
validate_chapter_ids,
)
if not isinstance(only_chapters, (list, tuple)):
return {"status": "error",
"error": "only_chapters debe ser una lista de ids de "
"capítulo o None (documento completo)."}
only_chapters = [c for c in only_chapters]
if not only_chapters:
return {"status": "error",
"error": "only_chapters=[] está vacío. Pasa al menos un "
"capítulo, o None para el documento completo. "
"Capítulos válidos: " + ", ".join(CHAPTER_ORDER)}
checked = validate_chapter_ids(only_chapters, CHAPTER_ORDER)
if checked["unknown"]:
return {"status": "error",
"error": "only_chapters con ids desconocidos: "
+ ", ".join(checked["unknown"])
+ ". Capítulos válidos: "
+ ", ".join(CHAPTER_ORDER)}
only_chapters = checked["valid"]
# Las dependencias fijan el DEFAULT de cada flag de coste (eficiencia:
# lo que ningún capítulo pedido necesita queda en False); un flag
# explícito del caller (!= None) sigue primando.
dep_flags = resolve_requirements(only_chapters)["profile_flags"]
run_models = ("run_models" in dep_flags) if run_models is None else run_models
run_series = ("run_series" in dep_flags) if run_series is None else run_series
run_llm = ("run_llm" in dep_flags) if run_llm is None else run_llm
# En capítulos sueltos no se usa el camino "modelos baratos" (lite),
# que poda ctx['raw_numeric']: un capítulo como outliers lo necesita
# para su multivariante en vivo. El preset solo gobierna `sample`.
model_opts = None
else:
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
# En el camino "modelos baratos" (lite) profile_table NO corre los # En el camino "modelos baratos" (lite) profile_table NO corre los
# modelos: los ejecuta este pipeline con run_eda_models y la granularidad # modelos: los ejecuta este pipeline con run_eda_models y la granularidad
# del preset, evitando pagar el coste CPU de KMeans + IsolationForest. # del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
@@ -217,10 +286,25 @@ def render_automatic_eda(
if ctx_extra: if ctx_extra:
base_ctx.update(ctx_extra) base_ctx.update(ctx_extra)
ctx = build_eda_render_ctx( # En modo capítulos sueltos, si NINGÚN capítulo pedido necesita datos
db_path, table, prof, backend=backend, sample=sample, # crudos del ctx, se salta build_eda_render_ctx por completo (ahorro real
base_ctx=base_ctx, # de I/O): solo se conservan presentación + db_path/table. Si sí los
) # necesita, se construye el ctx y luego se PODAN las piezas de datos que
# ningún capítulo pedido usa (db_path/table nunca se podan).
if only_chapters is not None and not needs_render_ctx(only_chapters):
ctx = dict(base_ctx)
ctx["db_path"] = db_path
ctx["table"] = table
else:
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
if only_chapters is not None and isinstance(ctx, dict):
keep = resolve_ctx_data_keys(only_chapters)
for k in ("head_rows", "raw_numeric", "timeseries_raw", "geo_points"):
if k not in keep:
ctx.pop(k, None)
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni # 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
# IsolationForest). profile_table no corrió los modelos; aquí se corren # IsolationForest). profile_table no corrió los modelos; aquí se corren
@@ -245,6 +329,13 @@ def render_automatic_eda(
ctx.pop("raw_numeric", None) ctx.pop("raw_numeric", None)
# 3) Render a ambos formatos desde el MISMO documento por capítulos. # 3) Render a ambos formatos desde el MISMO documento por capítulos.
# En modo capítulos sueltos, la selección viaja a build_document por una
# clave reservada del ctx (los renderers llaman build_document sin pasar
# `only`): build_document filtra el cuerpo a esos capítulos y siempre
# añade portada (primera) + glosario (última). build_document la consume
# y la quita, así que no llega a los capítulos.
if only_chapters is not None and isinstance(ctx, dict):
ctx["_only_chapters"] = list(only_chapters)
os.makedirs(out_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = basename or f"aeda_{table}_{ts}" base = basename or f"aeda_{table}_{ts}"
@@ -283,6 +374,7 @@ def render_automatic_eda(
"pdf_note": rpdf.get("note"), "pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"), "pptx_note": rpptx.get("note"),
"md_note": rmd.get("note"), "md_note": rmd.get("note"),
"only_chapters": only_chapters,
"profile": prof, "profile": prof,
} }
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar. except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
@@ -0,0 +1,115 @@
---
name: render_automatic_eda_folder
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def render_automatic_eda_folder(path: str, out_dir: str = \"reports\", basename: str = None, profile_level: str = \"standard\", emit_pdf: bool = True, emit_pptx: bool = True, emit_md: bool = True, per_table_eda: bool = False, min_inclusion: float = 0.9, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA a nivel de BASE one-shot de una CARPETA de archivos tabulares (CSV/Parquet/JSON) o de una DuckDB existente. Carga la carpeta a una DuckDB temporal con load_folder_to_duckdb (o usa la DuckDB dada directa), perfila TODA la base con profile_database (resumen de cada tabla + FK candidatas por containment + join graph con diagrama Mermaid), ENSAMBLA un documento-base por capitulos (portada-base con nombre/n tablas/totales/fecha/fuente, resumen de tablas con una fila por tabla, y relaciones inter-tabla con la tabla de FK candidatas + una Figure matplotlib REAL del join graph dibujada con draw_join_graph_figure mas el texto Mermaid) y lo renderiza con el motor AutomaticEDA a PDF (A5 movil), PPTX (16:9) y Markdown autocontenido a la vez. Con per_table_eda=True anexa los capitulos de mini-EDA de cada tabla (build_document por tabla). Es el hermano a nivel de base de render_automatic_eda (que perfila UNA tabla): aqui el informe es de la base y de sus relaciones. Devuelve las rutas de PDF/PPTX/MD, el manifiesto y el DatabaseProfile."
tags: [eda, duckdb, database, profiling, relations, pipeline, dataops, report, pdf, pptx, launcher]
uses_functions:
- load_folder_to_duckdb_py_infra
- profile_database_py_pipelines
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
- render_automatic_eda_markdown_py_datascience
- draw_join_graph_figure_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: true
tests:
- "golden: carpeta con 3 CSV relacionados (customers/orders/products) emite PDF+PPTX+MD del documento-base con 3 tablas y la FK orders.customer_id->customers.id"
- "edge: carpeta vacia -> status ok con documento minimo, sin lanzar"
- "edge: 1 sola tabla -> funciona sin relaciones (capitulo relaciones dice 'sin FK')"
test_file_path: "python/functions/pipelines/render_automatic_eda_folder_test.py"
file_path: "python/functions/pipelines/render_automatic_eda_folder.py"
params:
- name: path
desc: "DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que se cargan a una DuckDB temporal, o una DuckDB ya existente (.duckdb/.ddb/.db) que se perfila directa."
- name: out_dir
desc: "Directorio de salida de los informes (se crea si no existe). Default 'reports'."
- name: basename
desc: "Nombre base de los archivos sin extension. Default 'aeda_base_<nombre>_<timestamp>'."
- name: profile_level
desc: "Preset de coste del perfil por tabla ('lite'/'standard'/'full'); ajusta el sample que profile_database pasa a cada tabla (lite=2000, standard/full=5000)."
- name: emit_pdf
desc: "Emite el PDF A5 movil del documento-base. Default True."
- name: emit_pptx
desc: "Emite el PPTX 16:9 del documento-base. Default True."
- name: emit_md
desc: "Emite el Markdown autocontenido del documento-base. Default True."
- name: per_table_eda
desc: "Si True, anexa al documento-base los capitulos de mini-EDA de cada tabla (Heading 'Tabla: <n>' + build_document por tabla). Default False (solo documento-base: portada + resumen + relaciones)."
- name: min_inclusion
desc: "Umbral de inclusion (0-1) para emitir una FK candidata (se pasa a profile_database). Default 0.9."
- name: ctx_extra
desc: "Dict opcional de claves de presentacion (p.ej. dataset_name, description) que se mezclan en el contexto de la portada-base."
output: "Dict dict-no-throw. En exito: {status:'ok', pdf_path, pptx_path, md_path, manifest_path, n_tables, n_pages, n_slides, md_chars, db_path, db_profile}. En error: {status:'error', error:str}."
---
# render_automatic_eda_folder
EDA de una **carpeta / base multi-tabla** → informe AutomaticEDA por capítulos
en PDF (móvil A5) + PPTX (16:9) + Markdown, en una sola llamada. Es el hermano a
nivel de **base** de `render_automatic_eda` (que perfila una sola tabla): aquí el
documento resume **todas** las tablas y, sobre todo, sus **relaciones**
inter-tabla (FK candidatas por containment + join graph con diagrama Mermaid).
Compone, sin reimplementar su lógica: `load_folder_to_duckdb` (carga la carpeta),
`profile_database` (perfila la base + infiere FK + join graph) y los tres
renderers del motor AutomaticEDA (`render_automatic_eda_pdf`/`_pptx`/`_markdown`),
que aceptan directamente la lista de capítulos del documento-base que este
pipeline ensambla. El pipeline de tabla única (`render_automatic_eda`) queda
intacto: esto es aditivo.
## Ejemplo
```bash
# Carpeta con varios CSV/Parquet/JSON relacionados:
./fn run render_automatic_eda_folder /tmp/eda_folder_demo
# Una DuckDB ya existente (rama directa):
./fn run render_automatic_eda_folder temp/bigdata/taxi.duckdb
```
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.render_automatic_eda_folder import render_automatic_eda_folder
r = render_automatic_eda_folder("/tmp/eda_folder_demo", out_dir="reports")
# r["status"] == "ok"; r["pdf_path"], r["pptx_path"], r["md_path"]
# r["n_tables"] == 3; r["db_profile"]["fk_candidates"] incluye
# orders.customer_id -> customers.id
```
## Cuando usarla
Cuando quieras un EDA de una **base entera** (una carpeta de exports o una
DuckDB con varias tablas), no de una sola tabla: para ver de un vistazo qué
tablas hay, su tamaño y calidad, y cómo se relacionan (FK candidatas + diagrama),
en el mismo formato rico por capítulos (PDF móvil + PPTX + MD) que el EDA de
tabla. Usa `per_table_eda=True` cuando además quieras el mini-EDA de cada tabla
anexado.
## Gotchas
- Impuro: lee archivos del disco y escribe PDF/PPTX/MD en `out_dir`. En la rama
"carpeta" crea una **DuckDB temporal** (su ruta sale en `db_path`); no se borra
automáticamente (queda para reinspección).
- `path` se interpreta así: directorio → se carga la carpeta; archivo con
extensión `.duckdb`/`.ddb`/`.db` → se usa directo; cualquier otro archivo o un
path inexistente → `{status:'error'}` (no lanza).
- El escaneo de la carpeta es **no recursivo** (solo el primer nivel) y por
defecto cubre `*.csv,*.parquet,*.json` (ver `load_folder_to_duckdb`).
- El join graph se rasteriza a una **Figure matplotlib real** (vía
`draw_join_graph_figure`) que aparece dibujada en PDF/PPTX (nodos = tablas,
flechas = FK). Además, el **texto Mermaid** del grafo se incluye como bloque de
código (en el Markdown queda como diagrama renderizable y es útil para pegar a
un LLM).
- Carpeta vacía o con 1 sola tabla: funciona igual; el capítulo de relaciones
dice "sin FK". dict-no-throw en todos los caminos.
@@ -0,0 +1,366 @@
"""render_automatic_eda_folder — EDA de una CARPETA / base multi-tabla one-shot.
Pipeline impuro del grupo de capacidad `eda`, a nivel de BASE. Dada una CARPETA
de archivos tabulares (CSV/Parquet/JSON) o una DuckDB ya existente, produce el
informe AutomaticEDA de la BASE en sus tres formatos a la vez (PDF móvil A5 +
PPTX 16:9 + Markdown autocontenido), con los capítulos POBLADOS, en una sola
llamada. Es el hermano a nivel de base de ``render_automatic_eda`` (que perfila
UNA tabla): aquí el documento por capítulos resume TODAS las tablas y, sobre
todo, sus RELACIONES inter-tabla (FK candidatas + join graph).
Compone funciones del registry SIN reimplementar su lógica:
- load_folder_to_duckdb : carga una carpeta de archivos a una DuckDB temporal
(rama "carpeta"). En la rama "ya es duckdb" se omite.
- profile_database : perfila TODA la base (resumen de cada tabla,
TableProfiles completos, FK candidatas por
containment y join graph con diagrama Mermaid).
- render_automatic_eda_pdf : renderiza el documento-base por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento-base a PPTX.
- render_automatic_eda_markdown : serializa el mismo documento-base a Markdown
autocontenido (texto + tablas markdown).
- build_document : (solo con per_table_eda=True) ensambla los capítulos
canónicos de CADA tabla para anexarlos al documento.
La capa propia de este pipeline es ENSAMBLAR EL DOCUMENTO-BASE de capítulos a
partir del ``DatabaseProfile`` que devuelve ``profile_database`` y cablear los
tres renderers del motor AutomaticEDA. El documento-base mínimo tiene tres
capítulos: portada-base (nombre/ tablas/totales/fecha/fuente), resumen de
tablas (una fila por tabla) y relaciones inter-tabla (FK candidatas + diagrama
Mermaid). Con ``per_table_eda=True`` anexa, por cada tabla, sus capítulos de
mini-EDA.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
degrada a ``{"status": "error", "error": str}``.
"""
import os
from datetime import datetime, timezone
from datascience import (
draw_join_graph_figure,
render_automatic_eda_markdown,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
)
from datascience.automatic_eda import build_document
from infra import load_folder_to_duckdb
from pipelines.profile_database import profile_database
# Mapa profile_level -> tamaño de muestra por columna del perfil de cada tabla.
# A nivel de base el coste lo domina el nº de tablas; el preset solo ajusta el
# sample que profile_database pasa a profile_table.
_SAMPLE_BY_LEVEL = {"lite": 2000, "standard": 5000, "full": 5000}
# Extensiones que se consideran "una DuckDB ya hecha" en la rama directa.
_DUCKDB_EXTS = (".duckdb", ".ddb", ".db")
def _fmt_num(v) -> str:
"""Formatea un entero con separador de millar; '' si no es número."""
if isinstance(v, bool) or not isinstance(v, (int, float)):
return ""
try:
return f"{int(v):,}".replace(",", ".")
except Exception: # noqa: BLE001
return str(v)
def _portada_chapter(db_profile: dict, source_path: str, db_path: str,
meta_ctx: dict) -> dict:
"""Capítulo de portada a nivel de base (NO reusa chapters/portada.py, que es
de tabla única): nombre de la base, de tablas, totales y procedencia."""
tables = db_profile.get("tables", []) or []
total_rows = sum(
(t.get("n_rows") or 0) for t in tables if isinstance(t.get("n_rows"), (int, float))
)
total_cols = sum(
(t.get("n_cols") or 0) for t in tables if isinstance(t.get("n_cols"), (int, float))
)
base_name = (meta_ctx or {}).get("dataset_name") or os.path.basename(
os.path.normpath(source_path)
) or source_path
rows = [
("Base", base_name),
("Tablas", _fmt_num(db_profile.get("n_tables"))),
("Filas totales", _fmt_num(total_rows)),
("Columnas totales", _fmt_num(total_cols)),
("Relaciones FK", _fmt_num(len(db_profile.get("fk_candidates", []) or []))),
("Fuente", source_path),
("DuckDB", db_path),
("Generado", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")),
]
blocks = [
{"kind": "heading", "text": f"EDA de la base — {base_name}", "level": 1},
{"kind": "kv_table", "rows": rows, "title": "Resumen de la base"},
]
errs = db_profile.get("errors", []) or []
if errs:
blocks.append({
"kind": "note",
"text": f"{len(errs)} aviso(s) durante el perfilado (ver detalle).",
})
return {"id": "portada_base", "title": "Portada", "version": "1.0.0",
"blocks": blocks}
def _resumen_chapter(db_profile: dict) -> dict:
"""Capítulo con una fila por tabla: filas, columnas, calidad, key_candidates."""
header = ["Tabla", "Filas", "Columnas", "Calidad", "key_candidates"]
rows = []
for t in db_profile.get("tables", []) or []:
keys = ", ".join(t.get("key_candidates") or []) or ""
rows.append([
t.get("table"),
_fmt_num(t.get("n_rows")),
_fmt_num(t.get("n_cols")),
t.get("quality_score"),
keys,
])
if rows:
blocks = [{
"kind": "data_table", "header": header, "rows": rows,
"title": "Tablas de la base",
"note": "Una fila por tabla. Calidad = score agregado del TableProfile.",
}]
else:
blocks = [{"kind": "note",
"text": "La base no contiene tablas perfilables."}]
return {"id": "resumen_tablas", "title": "Resumen de tablas",
"version": "1.0.0", "blocks": blocks}
def _relaciones_chapter(db_profile: dict) -> dict:
"""Capítulo de relaciones inter-tabla: tabla de FK candidatas + diagrama
Mermaid del join graph (vuelca el Mermaid como bloque de código)."""
fks = db_profile.get("fk_candidates", []) or []
blocks = [{
"kind": "heading", "text": "Relaciones inter-tabla", "level": 2,
}]
if fks:
header = ["From", "To", "Inclusión", "Cardinalidad"]
rows = []
for fk in fks:
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
inc = fk.get("inclusion")
inc_s = f"{inc:.3f}" if isinstance(inc, (int, float)) else str(inc)
rows.append([frm, to, inc_s, fk.get("cardinality")])
blocks.append({
"kind": "data_table", "header": header, "rows": rows,
"title": "FK candidatas (por containment de valores)",
"note": "Inclusión = fracción de valores de From contenidos en To.",
})
else:
blocks.append({
"kind": "note",
"text": "Sin relaciones FK candidatas detectadas entre las tablas.",
})
join_graph = db_profile.get("join_graph") or {}
has_edges = bool(join_graph.get("edges"))
if has_edges:
blocks.append({"kind": "heading", "text": "Diagrama (join graph)",
"level": 3})
# Figure matplotlib REAL del grafo de relaciones (nodos = tablas,
# aristas = FK). Lazy via `make`: el renderer la construye solo al
# paginar, y se rasteriza en PDF/PPTX. draw_join_graph_figure nunca
# lanza (devuelve una Figure de error si algo falla).
blocks.append({
"kind": "figure",
"make": (lambda jg=join_graph: draw_join_graph_figure(
jg, title="Join graph (relaciones inter-tabla)")),
"caption": "Grafo de relaciones: nodos = tablas, flechas = FK "
"candidatas (etiqueta from_col→to_col).",
"height_in": 4.5,
})
# Además, el Mermaid en texto: en el Markdown queda como diagrama
# renderizable y es útil para pegar a un LLM.
mermaid = (join_graph.get("mermaid", "") or "").strip()
if mermaid:
blocks.append({"kind": "markdown",
"text": "```mermaid\n" + mermaid + "\n```"})
return {"id": "relaciones", "title": "Relaciones inter-tabla",
"version": "1.0.0", "blocks": blocks}
def _build_db_document(db_profile: dict, source_path: str, db_path: str,
meta_ctx: dict, per_table_eda: bool) -> list:
"""Ensambla el documento-base por capítulos a partir del DatabaseProfile.
Mínimo: portada-base + resumen de tablas + relaciones. Con per_table_eda
True anexa, por cada tabla, un capítulo separador + los capítulos canónicos
de su mini-EDA (reusando build_document sobre cada TableProfile)."""
chapters = [
_portada_chapter(db_profile, source_path, db_path, meta_ctx),
_resumen_chapter(db_profile),
_relaciones_chapter(db_profile),
]
if per_table_eda:
for prof in db_profile.get("table_profiles", []) or []:
tname = prof.get("table") or "tabla"
chapters.append({
"id": f"tabla_{tname}", "title": f"Tabla: {tname}",
"version": "1.0.0",
"blocks": [{"kind": "heading", "text": f"Tabla: {tname}",
"level": 1}],
})
try:
# build_document devuelve los capítulos canónicos de la tabla.
# ctx None -> los capítulos que necesitan datos crudos degradan,
# pero salen completos los de portada/overview/distrib/calidad.
chapters.extend(build_document(prof, None) or [])
except Exception: # noqa: BLE001 — una tabla mala no rompe el doc.
chapters.append({
"id": f"tabla_{tname}_err", "title": f"Tabla: {tname}",
"version": "1.0.0",
"blocks": [{"kind": "note",
"text": "No se pudo ensamblar el mini-EDA de "
"esta tabla."}],
})
return chapters
def _resolve_db_path(path: str) -> dict:
"""Resuelve el DuckDB a perfilar desde ``path``.
- Directorio -> carga la carpeta con load_folder_to_duckdb (DuckDB temp).
- Archivo .duckdb/.ddb/.db -> se usa directo (rama "ya es duckdb").
- Otro archivo / inexistente -> error.
Devuelve {status, db_path, loaded, n_tables, load_errors}.
"""
if os.path.isdir(path):
lr = load_folder_to_duckdb(path)
if lr.get("status") != "ok":
return {"status": "error",
"error": f"load_folder_to_duckdb falló: {lr.get('error')}"}
return {
"status": "ok",
"db_path": lr.get("db_path"),
"loaded": True,
"n_tables": len(lr.get("tables", []) or []),
"load_errors": lr.get("errors", []) or [],
}
if os.path.isfile(path):
if path.lower().endswith(_DUCKDB_EXTS):
return {"status": "ok", "db_path": path, "loaded": False,
"n_tables": None, "load_errors": []}
return {"status": "error",
"error": f"'{path}' no es un directorio ni una DuckDB "
f"(extensiones {_DUCKDB_EXTS})."}
return {"status": "error", "error": f"path no existe: {path}"}
def render_automatic_eda_folder(
path: str,
out_dir: str = "reports",
basename: str = None,
profile_level: str = "standard",
emit_pdf: bool = True,
emit_pptx: bool = True,
emit_md: bool = True,
per_table_eda: bool = False,
min_inclusion: float = 0.9,
ctx_extra: dict = None,
) -> dict:
"""Perfila una CARPETA (o una DuckDB) y emite el informe AutomaticEDA de la base.
Args:
path: o bien un DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que
se cargan a una DuckDB temporal, o bien una DuckDB ya existente
(``.duckdb``/``.ddb``/``.db``) que se perfila directa.
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_base_<nombre>_<timestamp>".
profile_level: preset de coste del perfil por tabla ("lite"/"standard"/
"full"); ajusta el ``sample`` que profile_database pasa a cada tabla.
emit_pdf / emit_pptx / emit_md: qué formatos emitir. Default los tres.
per_table_eda: si True, anexa al documento-base los capítulos de mini-EDA
de cada tabla (un Heading "Tabla: <n>" + build_document por tabla).
Default False (solo el documento-base: portada + resumen + relaciones).
min_inclusion: umbral de inclusión para emitir una FK candidata (0-1).
ctx_extra: dict opcional de claves de presentación (p.ej. dataset_name,
description) que se mezclan en el contexto de la portada.
Returns:
dict (nunca lanza). En éxito::
{"status": "ok", "pdf_path": str|None, "pptx_path": str|None,
"md_path": str|None, "manifest_path": str|None,
"n_tables": int, "n_pages": int|None, "n_slides": int|None,
"md_chars": int|None, "db_path": str, "db_profile": <DatabaseProfile>}
En error: {"status": "error", "error": str}.
"""
try:
# 1) Resolver la DuckDB a perfilar (cargar carpeta o usar la dada).
rdb = _resolve_db_path(path)
if rdb.get("status") != "ok":
return {"status": "error", "error": rdb.get("error")}
db_path = rdb.get("db_path")
# 2) Perfilar la base entera (resumen + FK + join graph). Sin report
# propio (write_report/emit_pdf False): este pipeline emite el suyo.
sample = _SAMPLE_BY_LEVEL.get(profile_level, 5000)
pres = profile_database(
db_path, sample=sample, write_report=False,
min_inclusion=min_inclusion, emit_pdf=False,
)
if pres.get("status") != "ok":
return {"status": "error",
"error": f"profile_database falló: {pres.get('error')}"}
db_profile = pres.get("db_profile") or {}
# 3) Ensamblar el documento-base por capítulos.
meta_ctx = dict(ctx_extra or {})
chapters = _build_db_document(
db_profile, path, db_path, meta_ctx, per_table_eda
)
# 4) Render a los tres 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")
nm = (meta_ctx.get("dataset_name")
or os.path.basename(os.path.normpath(path)) or "base")
nm = "".join(c if c.isalnum() else "_" for c in str(nm)).strip("_") or "base"
base = basename or f"aeda_base_{nm}_{ts}"
title = f"EDA base — {meta_ctx.get('dataset_name') or nm}"
meta = {"title": title}
pdf_path = pptx_path = md_path = manifest_path = None
n_pages = n_slides = md_chars = None
if emit_pdf:
target = os.path.join(out_dir, base + ".pdf")
rpdf = render_automatic_eda_pdf(chapters, target, meta) or {}
pdf_path = rpdf.get("path")
n_pages = rpdf.get("n_pages")
manifest_path = rpdf.get("manifest_path")
if emit_pptx:
target = os.path.join(out_dir, base + ".pptx")
rpptx = render_automatic_eda_pptx(chapters, target, meta) or {}
pptx_path = rpptx.get("path")
n_slides = rpptx.get("n_slides")
if emit_md:
target = os.path.join(out_dir, base + ".md")
rmd = render_automatic_eda_markdown(chapters, target, meta) or {}
md_path = rmd.get("path")
md_chars = rmd.get("n_chars")
return {
"status": "ok",
"pdf_path": pdf_path,
"pptx_path": pptx_path,
"md_path": md_path,
"manifest_path": manifest_path,
"n_tables": db_profile.get("n_tables"),
"n_pages": n_pages,
"n_slides": n_slides,
"md_chars": md_chars,
"db_path": db_path,
"db_profile": db_profile,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
return {"status": "error", "error": str(e)}
@@ -0,0 +1,188 @@
"""Tests para render_automatic_eda_folder — EDA de una carpeta / base multi-tabla.
Golden: una carpeta con 3 CSV relacionados (customers/orders/products) produce el
documento-base en PDF + PPTX + MD, con las 3 tablas en el resumen y la FK
orders.customer_id -> customers.id en el capítulo de relaciones. Edges: carpeta
vacía (documento mínimo, sin lanzar), 1 sola tabla (sin relaciones) y la rama
"ya es una DuckDB" sobre un archivo .duckdb existente.
"""
import os
import sys
import duckdb
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from pipelines.render_automatic_eda_folder import (
_relaciones_chapter,
render_automatic_eda_folder,
)
def _write_demo_folder(folder: str) -> None:
"""3 CSV relacionados: orders.customer_id -> customers.id (FK detectable)."""
with open(os.path.join(folder, "customers.csv"), "w", encoding="utf-8") as fh:
fh.write("id,name,city\n")
fh.write("1,Alice,Madrid\n2,Bob,Barcelona\n3,Carol,Valencia\n"
"4,Dave,Sevilla\n5,Eve,Madrid\n")
with open(os.path.join(folder, "orders.csv"), "w", encoding="utf-8") as fh:
fh.write("order_id,customer_id,product_id,total\n")
fh.write("100,1,10,49.90\n101,1,11,12.50\n102,2,10,49.90\n"
"103,3,12,8.00\n104,3,11,12.50\n105,5,10,49.90\n"
"106,2,12,8.00\n")
with open(os.path.join(folder, "products.csv"), "w", encoding="utf-8") as fh:
fh.write("product_id,product_name,price\n")
fh.write("10,Widget,49.90\n11,Gadget,12.50\n12,Gizmo,8.00\n")
def _has_fk(db_profile: dict, from_t: str, from_c: str, to_t: str) -> bool:
for fk in db_profile.get("fk_candidates", []) or []:
if (fk.get("from_table") == from_t and fk.get("from_col") == from_c
and fk.get("to_table") == to_t):
return True
return False
def test_golden_folder_three_csv(tmp_path):
"""Carpeta con 3 CSV relacionados -> PDF+PPTX+MD, 3 tablas, FK detectada."""
folder = tmp_path / "demo"
folder.mkdir()
_write_demo_folder(str(folder))
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 3
# Los tres formatos se emitieron y existen en disco.
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert r["md_path"] and os.path.exists(r["md_path"])
assert (r["n_pages"] or 0) >= 1
assert (r["n_slides"] or 0) >= 1
# La FK orders.customer_id -> customers.id se detecta por containment.
assert _has_fk(r["db_profile"], "orders", "customer_id", "customers"), \
r["db_profile"].get("fk_candidates")
# El Markdown menciona las 3 tablas y la relación.
md = open(r["md_path"], encoding="utf-8").read()
for t in ("customers", "orders", "products"):
assert t in md
assert "customer_id" in md
def test_edge_empty_folder(tmp_path):
"""Carpeta vacía -> status ok con documento mínimo, sin lanzar."""
folder = tmp_path / "empty"
folder.mkdir()
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 0
# Aun sin tablas, emite el documento-base mínimo (portada + resumen vacío +
# relaciones "sin FK").
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["md_path"] and os.path.exists(r["md_path"])
def test_edge_single_table_no_relations(tmp_path):
"""Carpeta con 1 sola tabla -> funciona sin relaciones (capítulo 'sin FK')."""
folder = tmp_path / "single"
folder.mkdir()
with open(folder / "lonely.csv", "w", encoding="utf-8") as fh:
fh.write("a,b\n1,x\n2,y\n3,z\n")
out = tmp_path / "out"
r = render_automatic_eda_folder(str(folder), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 1
assert not (r["db_profile"].get("fk_candidates") or [])
md = open(r["md_path"], encoding="utf-8").read()
assert "Sin relaciones FK" in md or "sin FK" in md.lower()
def test_accepts_existing_duckdb(tmp_path):
"""Rama 'ya es una DuckDB': un archivo .duckdb existente se perfila directo."""
db = tmp_path / "base.duckdb"
conn = duckdb.connect(str(db))
try:
conn.execute("CREATE TABLE customers (id INTEGER, name VARCHAR)")
conn.execute("INSERT INTO customers VALUES (1,'Ana'),(2,'Luis'),(3,'Eva')")
conn.execute("CREATE TABLE orders (oid INTEGER, customer_id INTEGER)")
conn.execute("INSERT INTO orders VALUES (10,1),(11,2),(12,1),(13,3)")
finally:
conn.close()
out = tmp_path / "out"
r = render_automatic_eda_folder(str(db), out_dir=str(out))
assert r["status"] == "ok", r
assert r["n_tables"] == 2
assert r["db_path"] == str(db)
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
def test_emit_flags_select_formats(tmp_path):
"""emit_pdf/pptx/md controlan qué formatos se emiten."""
folder = tmp_path / "demo"
folder.mkdir()
_write_demo_folder(str(folder))
out = tmp_path / "out"
r = render_automatic_eda_folder(
str(folder), out_dir=str(out),
emit_pdf=True, emit_pptx=False, emit_md=False,
)
assert r["status"] == "ok", r
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] is None
assert r["md_path"] is None
def test_path_does_not_exist(tmp_path):
"""Path inexistente -> status error, sin lanzar."""
r = render_automatic_eda_folder(str(tmp_path / "nope"))
assert r["status"] == "error"
assert "no existe" in r["error"].lower()
def test_relaciones_chapter_has_real_figure_when_edges():
"""Con edges, el capítulo de relaciones incluye un bloque Figure matplotlib
REAL (no solo el texto Mermaid): su make() devuelve una Figure."""
db_profile = {
"join_graph": {
"nodes": [
{"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"},
{"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dim"},
],
"edges": [{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"cardinality": "N:1"}],
"mermaid": "graph LR orders --> customers",
"hubs": ["orders"],
},
"fk_candidates": [{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"inclusion": 1.0, "cardinality": "N:1"}],
}
ch = _relaciones_chapter(db_profile)
figs = [b for b in ch["blocks"] if b.get("kind") == "figure"]
assert len(figs) == 1, ch["blocks"]
# El make() perezoso produce una matplotlib Figure real.
import matplotlib
matplotlib.use("Agg")
fig = figs[0]["make"]()
from matplotlib.figure import Figure
assert isinstance(fig, Figure)
assert fig.get_axes(), "la Figure del join graph debe tener al menos un eje"
def test_relaciones_chapter_no_figure_when_no_edges():
"""Sin edges, no se añade bloque Figure (capítulo dice 'sin FK')."""
db_profile = {"join_graph": {"nodes": [], "edges": [], "mermaid": "",
"hubs": []}, "fk_candidates": []}
ch = _relaciones_chapter(db_profile)
assert not [b for b in ch["blocks"] if b.get("kind") == "figure"]
@@ -0,0 +1,235 @@
"""Tests del modo `only_chapters` del pipeline render_automatic_eda.
Cubre la tarea de "capítulos sueltos con resolución de dependencias":
- Golden (DuckDB real): pedir SOLO un capítulo genera un documento con solo
portada + ese capítulo + glosario, y el capítulo llega COMPLETO porque la
resolución de dependencias activó el cómputo que necesita aunque el caller
no lo pidiera (outliers run_models + raw_numeric IsolationForest poblado;
timeseries run_series; correlacion raw_numeric).
- Eficiencia: pedir un capítulo que NO necesita flags caros (geospatial) no los
activa, y un capítulo puramente agregado (num_distr) ni siquiera construye el
ctx de datos crudos.
- Edge: id desconocido / lista vacía / no-lista devuelven error claro sin
lanzar; only_chapters=None mantiene el comportamiento histórico.
"""
import json
import os
import random
import sys
from datetime import date, timedelta
_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 duckdb # noqa: E402
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
def _make_db_models(path):
"""DB con fecha + 3 numéricas continuas en 3 clusters gaussianos.
Garantiza material para outliers/modelos (>=2 numéricas IsolationForest),
timeseries (columna DATE) y correlacion (numéricas). Mismo shape que el
fixture del test del pipeline base.
"""
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 _manifest_chapters(result):
with open(result["manifest_path"], encoding="utf-8") as fh:
return set((json.load(fh).get("chapters") or {}).keys())
# --------------------------------------------------------------------------- #
# GOLDEN — outliers suelto: IsolationForest poblado por resolución de deps.
# --------------------------------------------------------------------------- #
def test_only_outliers_isolation_forest_populated_without_explicit_run_models(tmp_path):
"""El corazón de la tarea: pedir SOLO 'outliers' sin run_models explícito
activa run_models por dependencias y conserva ctx['raw_numeric'], de modo que
el bloque multivariante (Isolation Forest) sale con datos, no degradado."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
# NB: no se pasa run_models — la resolución de dependencias debe activarlo.
r = render_automatic_eda(db, "pts", only_chapters=["outliers"],
out_dir=out, basename="only_outliers")
assert r["status"] == "ok", r.get("error")
assert r["only_chapters"] == ["outliers"]
# Documento = portada + outliers + glosario, nada más.
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
# El multivariante salió POBLADO (no la nota de degradación). Se comprueba en
# el Markdown (mismo documento por capítulos, texto plano fiable).
md = open(r["aeda_md_path"], encoding="utf-8").read()
assert "Filas atípicas (multivariante)" in md
assert "Filas analizadas" in md, "el Isolation Forest no trae su tabla poblada"
assert "No se pudo analizar la anomalía multivariante" not in md, \
"el bloque multivariante salió degradado pese a resolver las deps"
# La resolución activó run_models → el perfil trae el bloque de modelos.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is not None
# --------------------------------------------------------------------------- #
# GOLDEN — timeseries suelto activa run_series.
# --------------------------------------------------------------------------- #
def test_only_timeseries_activates_run_series(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["timeseries"],
out_dir=out, basename="only_ts")
assert r["status"] == "ok", r.get("error")
assert "timeseries" in _manifest_chapters(r)
assert "modelos" not in _manifest_chapters(r)
# run_series resuelto por deps → el perfil trae el análisis de serie.
assert (r["profile"] or {}).get("series") is not None, \
"only_chapters=['timeseries'] debe activar run_series"
# --------------------------------------------------------------------------- #
# GOLDEN — correlacion suelto construye raw_numeric (sin activar modelos).
# --------------------------------------------------------------------------- #
def test_only_correlacion_builds_raw_numeric_without_models(tmp_path):
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", only_chapters=["correlacion"],
out_dir=out, basename="only_corr")
assert r["status"] == "ok", r.get("error")
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
# Eficiencia: correlacion no necesita los modelos → no se corrieron.
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is None
assert (r["profile"] or {}).get("series") is None
# --------------------------------------------------------------------------- #
# Eficiencia y precedencia — vía stub (sin DuckDB).
# --------------------------------------------------------------------------- #
def _patch(monkeypatch, cap):
import pipelines.render_automatic_eda as mod
def fake_pt(db, t, **kw):
cap["run_models"] = kw.get("run_models")
cap["run_series"] = kw.get("run_series")
cap["run_llm"] = kw.get("run_llm")
return {"status": "ok", "profile": {"columns": []}}
def fake_ctx(db, t, prof, **kw):
cap["ctx_called"] = True
return {"db_path": db, "table": t}
cap["ctx_called"] = False
monkeypatch.setattr(mod, "profile_table", fake_pt)
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})
monkeypatch.setattr(mod, "render_automatic_eda_markdown",
lambda *a, **k: {"path": "x.md", "n_chars": 1})
def test_only_geospatial_does_not_activate_cost_flags(monkeypatch):
"""Eficiencia: pedir solo geospatial NO corre modelos/serie/LLM."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["geospatial"])
assert cap["run_models"] is False
assert cap["run_series"] is False
assert cap["run_llm"] is False
def test_only_outliers_activates_run_models_via_deps(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["run_models"] is True
assert cap["run_series"] is False
def test_explicit_flag_overrides_dependency_resolution(monkeypatch):
"""run_models=False explícito gana, aunque outliers lo pediría por deps."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"], run_models=False)
assert cap["run_models"] is False
def test_purely_aggregated_chapter_skips_render_ctx(monkeypatch):
"""num_distr solo lee el profile → build_eda_render_ctx no se llama."""
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["num_distr"])
assert cap["ctx_called"] is False, \
"num_distr no necesita datos crudos: el ctx no debe construirse"
def test_chapter_that_needs_ctx_builds_it(monkeypatch):
cap = {}
_patch(monkeypatch, cap)
render_automatic_eda("db", "t", only_chapters=["outliers"])
assert cap["ctx_called"] is True
# --------------------------------------------------------------------------- #
# EDGE — errores claros sin lanzar.
# --------------------------------------------------------------------------- #
def test_unknown_chapter_id_returns_clear_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters=["no_existe"])
assert r["status"] == "error"
assert "no_existe" in r["error"]
assert "Capítulos válidos" in r["error"]
# Algún id válido conocido aparece en la lista.
assert "outliers" in r["error"]
def test_empty_only_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t", only_chapters=[])
assert r["status"] == "error"
assert "vac" in r["error"].lower()
def test_only_chapters_not_a_list_returns_error(tmp_path):
r = render_automatic_eda(str(tmp_path / "x.duckdb"), "t",
only_chapters="outliers")
assert r["status"] == "error"
def test_only_none_keeps_full_document(tmp_path):
"""Retro-compat: only_chapters=None genera el documento completo."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", out_dir=out, basename="full")
assert r["status"] == "ok", r.get("error")
chapters = _manifest_chapters(r)
# Documento completo: muchos más capítulos que portada/glosario.
assert {"portada", "glosario", "overview", "correlacion"} <= chapters
assert len(chapters) > 4
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"contextily>=1.7.0", "contextily>=1.7.0",
"cryptography>=46.0.6", "cryptography>=46.0.6",
"duckdb>=1.5.2", "duckdb>=1.5.2",
"faker>=40.27.0",
"fpdf2>=2.8.7", "fpdf2>=2.8.7",
"geopandas>=1.1.3", "geopandas>=1.1.3",
"google-api-python-client>=2.197.0", "google-api-python-client>=2.197.0",
+14
View File
@@ -839,6 +839,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
] ]
[[package]]
name = "faker"
version = "40.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/7b/c62c98764137c949be240ad83f763b6f96cf76055952a3e2835359acc3af/faker-40.27.0.tar.gz", hash = "sha256:f697cf07f461474ad7d511164c21f45317e69f1d531d25f3e0f872b639e346a1", size = 2018361, upload-time = "2026-06-30T18:05:17.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/b2/788aae329da3d7e4f08f8e1a82e82243c3376c0f3f49b75ae29eea40b371/faker-40.27.0-py3-none-any.whl", hash = "sha256:6099bd6d7bc79041b46c28e100815e2558952bcf384b76ce6c71c8bdca744256", size = 2057897, upload-time = "2026-06-30T18:05:15.555Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.3" version = "0.136.3"
@@ -890,6 +902,7 @@ dependencies = [
{ name = "contextily" }, { name = "contextily" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "duckdb" }, { name = "duckdb" },
{ name = "faker" },
{ name = "fpdf2" }, { name = "fpdf2" },
{ name = "geopandas" }, { name = "geopandas" },
{ name = "google-api-python-client" }, { name = "google-api-python-client" },
@@ -949,6 +962,7 @@ requires-dist = [
{ name = "contextily", specifier = ">=1.7.0" }, { name = "contextily", specifier = ">=1.7.0" },
{ name = "cryptography", specifier = ">=46.0.6" }, { name = "cryptography", specifier = ">=46.0.6" },
{ name = "duckdb", specifier = ">=1.5.2" }, { name = "duckdb", specifier = ">=1.5.2" },
{ name = "faker", specifier = ">=40.27.0" },
{ name = "fpdf2", specifier = ">=2.8.7" }, { name = "fpdf2", specifier = ">=2.8.7" },
{ name = "geopandas", specifier = ">=1.1.3" }, { name = "geopandas", specifier = ">=1.1.3" },
{ name = "gliner", marker = "extra == 'nlp'", specifier = ">=0.2.13" }, { name = "gliner", marker = "extra == 'nlp'", specifier = ">=0.2.13" },
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
import google.auth
from google.cloud import bigquery
_creds, _ = google.auth.default(scopes=['https://www.googleapis.com/auth/bigquery'])
_creds = _creds.with_quota_project(None)
client = bigquery.Client(project='autingo-159109', location='europe-west1', credentials=_creds)
def q(sql):
return client.query(sql).result().to_dataframe()
+1
View File
@@ -0,0 +1 @@
{"c1": 12363, "c2": 12364, "c3": 12365}
+61
View File
@@ -0,0 +1,61 @@
ensena,year,mes,diego,bq_neto,match
Aurgi,2023,feb,80.52,,
Aurgi,2023,mar,89.94,,
Aurgi,2023,abr,76.87,,
Aurgi,2023,may,87.95,,
Aurgi,2023,jun,97.84,,
Aurgi,2023,jul,138.24,,
Aurgi,2023,ago,89.7,,
Aurgi,2023,sep,61.53,,
Aurgi,2023,oct,56.48,,
Aurgi,2023,nov,73.2,,
Aurgi,2023,dic,78.81,,
Aurgi,2024,ene,75.34,75.35,100.0
Aurgi,2024,feb,60.21,60.21,100.0
Aurgi,2024,mar,70.62,71.26,99.1
Aurgi,2024,abr,70.46,70.46,100.0
Aurgi,2024,may,84.76,84.76,100.0
Aurgi,2024,jun,108.7,108.7,100.0
Aurgi,2024,jul,141.2,141.2,100.0
Aurgi,2024,ago,100.18,100.18,100.0
Aurgi,2024,sep,67.91,67.91,100.0
Aurgi,2024,oct,81.31,81.31,100.0
Aurgi,2024,nov,71.57,71.57,100.0
Aurgi,2024,dic,74.33,74.33,100.0
Aurgi,2025,ene,86.28,86.28,100.0
Aurgi,2025,feb,53.05,53.05,100.0
Aurgi,2025,mar,86.75,86.75,100.0
Aurgi,2025,abr,83.89,83.89,100.0
Aurgi,2025,may,84.24,84.24,100.0
Aurgi,2025,jun,134.46,134.46,100.0
Aurgi,2025,jul,101.17,174.32,58.0
MT,2023,feb,30.19,,
MT,2023,mar,41.89,,
MT,2023,abr,36.16,,
MT,2023,may,42.01,,
MT,2023,jun,44.24,,
MT,2023,jul,63.61,,
MT,2023,ago,40.7,,
MT,2023,sep,28.6,,
MT,2023,oct,28.79,,
MT,2023,nov,30.3,,
MT,2023,dic,35.21,,
MT,2024,ene,38.13,38.13,100.0
MT,2024,feb,32.44,32.44,100.0
MT,2024,mar,35.17,35.18,100.0
MT,2024,abr,35.38,35.38,100.0
MT,2024,may,37.58,37.58,100.0
MT,2024,jun,44.54,44.54,100.0
MT,2024,jul,58.92,58.92,100.0
MT,2024,ago,40.97,40.98,100.0
MT,2024,sep,35.03,35.03,100.0
MT,2024,oct,38.86,38.86,100.0
MT,2024,nov,36.48,36.48,100.0
MT,2024,dic,40.52,40.52,100.0
MT,2025,ene,39.16,39.16,100.0
MT,2025,feb,28.16,28.16,100.0
MT,2025,mar,42.26,42.26,100.0
MT,2025,abr,44.04,44.04,100.0
MT,2025,may,52.71,52.71,100.0
MT,2025,jun,63.54,63.54,100.0
MT,2025,jul,49.47,84.94,58.2
1 ensena year mes diego bq_neto match
2 Aurgi 2023 feb 80.52
3 Aurgi 2023 mar 89.94
4 Aurgi 2023 abr 76.87
5 Aurgi 2023 may 87.95
6 Aurgi 2023 jun 97.84
7 Aurgi 2023 jul 138.24
8 Aurgi 2023 ago 89.7
9 Aurgi 2023 sep 61.53
10 Aurgi 2023 oct 56.48
11 Aurgi 2023 nov 73.2
12 Aurgi 2023 dic 78.81
13 Aurgi 2024 ene 75.34 75.35 100.0
14 Aurgi 2024 feb 60.21 60.21 100.0
15 Aurgi 2024 mar 70.62 71.26 99.1
16 Aurgi 2024 abr 70.46 70.46 100.0
17 Aurgi 2024 may 84.76 84.76 100.0
18 Aurgi 2024 jun 108.7 108.7 100.0
19 Aurgi 2024 jul 141.2 141.2 100.0
20 Aurgi 2024 ago 100.18 100.18 100.0
21 Aurgi 2024 sep 67.91 67.91 100.0
22 Aurgi 2024 oct 81.31 81.31 100.0
23 Aurgi 2024 nov 71.57 71.57 100.0
24 Aurgi 2024 dic 74.33 74.33 100.0
25 Aurgi 2025 ene 86.28 86.28 100.0
26 Aurgi 2025 feb 53.05 53.05 100.0
27 Aurgi 2025 mar 86.75 86.75 100.0
28 Aurgi 2025 abr 83.89 83.89 100.0
29 Aurgi 2025 may 84.24 84.24 100.0
30 Aurgi 2025 jun 134.46 134.46 100.0
31 Aurgi 2025 jul 101.17 174.32 58.0
32 MT 2023 feb 30.19
33 MT 2023 mar 41.89
34 MT 2023 abr 36.16
35 MT 2023 may 42.01
36 MT 2023 jun 44.24
37 MT 2023 jul 63.61
38 MT 2023 ago 40.7
39 MT 2023 sep 28.6
40 MT 2023 oct 28.79
41 MT 2023 nov 30.3
42 MT 2023 dic 35.21
43 MT 2024 ene 38.13 38.13 100.0
44 MT 2024 feb 32.44 32.44 100.0
45 MT 2024 mar 35.17 35.18 100.0
46 MT 2024 abr 35.38 35.38 100.0
47 MT 2024 may 37.58 37.58 100.0
48 MT 2024 jun 44.54 44.54 100.0
49 MT 2024 jul 58.92 58.92 100.0
50 MT 2024 ago 40.97 40.98 100.0
51 MT 2024 sep 35.03 35.03 100.0
52 MT 2024 oct 38.86 38.86 100.0
53 MT 2024 nov 36.48 36.48 100.0
54 MT 2024 dic 40.52 40.52 100.0
55 MT 2025 ene 39.16 39.16 100.0
56 MT 2025 feb 28.16 28.16 100.0
57 MT 2025 mar 42.26 42.26 100.0
58 MT 2025 abr 44.04 44.04 100.0
59 MT 2025 may 52.71 52.71 100.0
60 MT 2025 jun 63.54 63.54 100.0
61 MT 2025 jul 49.47 84.94 58.2
+1
View File
@@ -0,0 +1 @@
https://reports.autingo.es/dashboard/1142
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
STRUCT(DATE(2023,2,1) AS mes, 80.515 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,3,1) AS mes, 89.936 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,4,1) AS mes, 76.866 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,5,1) AS mes, 87.952 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,6,1) AS mes, 97.84 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,7,1) AS mes, 138.24 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,8,1) AS mes, 89.7 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,9,1) AS mes, 61.53 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,10,1) AS mes, 56.48 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,11,1) AS mes, 73.2 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,12,1) AS mes, 78.81 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,1,1) AS mes, 75.345 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,2,1) AS mes, 60.211 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,3,1) AS mes, 70.62 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,4,1) AS mes, 70.456 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,5,1) AS mes, 84.759 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,6,1) AS mes, 108.702 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,7,1) AS mes, 141.204 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,8,1) AS mes, 100.181 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,9,1) AS mes, 67.91 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,10,1) AS mes, 81.307 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,11,1) AS mes, 71.569 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2024,12,1) AS mes, 74.329 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,1,1) AS mes, 86.277 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,2,1) AS mes, 53.054 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,3,1) AS mes, 86.749 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,4,1) AS mes, 83.888 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,5,1) AS mes, 84.24 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,6,1) AS mes, 134.464 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2025,7,1) AS mes, 101.168 AS diego_neto_k, 1 AS company_id),
STRUCT(DATE(2023,2,1) AS mes, 30.189 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,3,1) AS mes, 41.89 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,4,1) AS mes, 36.16 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,5,1) AS mes, 42.011 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,6,1) AS mes, 44.24 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,7,1) AS mes, 63.61 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,8,1) AS mes, 40.7 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,9,1) AS mes, 28.6 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,10,1) AS mes, 28.79 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,11,1) AS mes, 30.3 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2023,12,1) AS mes, 35.207 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,1,1) AS mes, 38.132 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,2,1) AS mes, 32.438 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,3,1) AS mes, 35.174 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,4,1) AS mes, 35.382 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,5,1) AS mes, 37.584 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,6,1) AS mes, 44.54 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,7,1) AS mes, 58.921 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,8,1) AS mes, 40.974 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,9,1) AS mes, 35.029 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,10,1) AS mes, 38.861 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,11,1) AS mes, 36.48 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2024,12,1) AS mes, 40.522 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,1,1) AS mes, 39.161 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,2,1) AS mes, 28.16 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,3,1) AS mes, 42.263 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,4,1) AS mes, 44.04 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,5,1) AS mes, 52.71 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,6,1) AS mes, 63.544 AS diego_neto_k, 2 AS company_id),
STRUCT(DATE(2025,7,1) AS mes, 49.469 AS diego_neto_k, 2 AS company_id)
+8
View File
@@ -0,0 +1,8 @@
import sys, json
from google.cloud import bigquery
import google.auth
creds=google.auth.default(scopes=['https://www.googleapis.com/auth/bigquery'])[0].with_quota_project(None)
c=bigquery.Client(project='autingo-159109', location='europe-west1', credentials=creds)
sql=sys.stdin.read()
for r in c.query(sql).result():
print(json.dumps(dict(r), default=str))
+152
View File
@@ -0,0 +1,152 @@
import json, os, urllib.request, sys
MB = os.environ["MB"]; KEY = os.environ["KEY"]
def api(method, path, body=None, timeout=180):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(MB + path, data=data, method=method,
headers={"X-API-KEY": KEY, "Content-Type": "application/json"})
try:
return json.load(urllib.request.urlopen(req, timeout=timeout))
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} on {method} {path}:", e.read().decode()[:1200]); raise
# Bridge documento -> service_request (canal + charged), tal cual 1094 card 11751.
BASE = r"""
WITH vf AS (
SELECT document_id, LOGICAL_OR(is_pw) is_pw FROM (
SELECT CAST(document_id AS STRING) document_id, ANY_VALUE(is_precaweb) is_pw
FROM `autingo-159109.anjana_bi_datamart.VENTAS_aurgi` GROUP BY 1
UNION ALL
SELECT CAST(document_id AS STRING), ANY_VALUE(is_precaweb)
FROM `autingo-159109.anjana_bi_datamart.VENTAS_Motortown` GROUP BY 1
) GROUP BY 1
),
lineas AS (
SELECT
CAST(s.numeroDocumento AS STRING) AS numdoc,
CAST(s.idCentro AS STRING) AS idcentro,
DATE(s.Fecha) AS fecha,
s.Base_imponible_linea AS bil
FROM {{#4494}} s
WHERE DATE(s.Fecha) >= DATE_SUB(CURRENT_DATE(), INTERVAL 365 DAY)
[[AND DATE(s.Fecha) >= {{fecha_desde}}]]
[[AND DATE(s.Fecha) <= {{fecha_hasta}}]]
),
web AS (
SELECT l.numdoc, l.fecha, l.bil, oc.name AS centro, oc.Companies__name AS ambito
FROM lineas l
LEFT JOIN vf ON l.numdoc = vf.document_id
LEFT JOIN `autingo-159109.rag_datasets.Objeto_Centros` oc
ON l.idcentro = CAST(oc.nav_id AS STRING)
WHERE (COALESCE(vf.is_pw, FALSE) OR oc.name IN ('Aurgi Web','MT Web'))
AND (oc.Companies__name IS NULL OR oc.Companies__name NOT IN ('Aurgi Glass','MotorTown Glass'))
[[AND oc.name IN ({{centro}})]]
[[AND oc.Companies__name IN ({{ensena}})]]
),
sr_link AS (
SELECT CAST(inv.nav_id AS STRING) numdoc, CAST(j.service_request_id AS STRING) sr_id
FROM `autingo-159109.psql_dcpublic.tpv_orders_invoice` inv
JOIN `autingo-159109.psql_dcpublic.tpv_precawebs_servicerequestjob` j ON j.order_id = inv.order_id
WHERE inv.nav_id IS NOT NULL
UNION DISTINCT
SELECT CAST(invoice_number AS STRING), CAST(service_request_id AS STRING)
FROM `autingo-159109.psql_dcpublic.logistic_orders`
WHERE invoice_number IS NOT NULL AND invoice_number != ''
),
sr_link1 AS (SELECT numdoc, MIN(sr_id) sr_id FROM sr_link GROUP BY 1),
sr AS (
SELECT CAST(id AS STRING) sr_id, channel_id, charged
FROM `autingo-159109.psql_dcpublic.service_requests`
),
doc AS (
SELECT
w.numdoc,
ANY_VALUE(w.fecha) AS fecha,
SUM(w.bil) AS venta,
ANY_VALUE(sl.sr_id) AS sr_id,
ANY_VALUE(sr.channel_id) AS channel_id,
ANY_VALUE(sr.charged) AS charged
FROM web w
LEFT JOIN sr_link1 sl USING (numdoc)
LEFT JOIN sr ON sr.sr_id = sl.sr_id
GROUP BY w.numdoc
),
fin AS (
SELECT
numdoc, fecha, venta,
CASE WHEN sr_id IS NULL THEN 'Sin solicitud'
WHEN channel_id = 1 THEN 'aurgi.com'
WHEN channel_id = 2 THEN 'motortown.es'
WHEN channel_id = 3 THEN 'Autingo'
WHEN channel_id IN (11,13,14,15,6,8) THEN 'Marketplaces'
WHEN channel_id = 10 THEN 'Talleres Digitales'
ELSE 'Otros' END AS canal,
CASE WHEN sr_id IS NULL THEN 'Sin solicitud'
WHEN charged THEN 'Pago web'
ELSE 'Pago tienda' END AS forma_pago
FROM doc
)
"""
CARDS = {
"total": {
"name": "Venta web total (facturacion NAV / modelo 4494)",
"sql": BASE + "SELECT ROUND(SUM(venta),0) AS venta_web_eur, COUNT(DISTINCT numdoc) AS documentos FROM fin",
"display": "scalar",
},
"canal": {
"name": "Venta web por canal",
"sql": BASE + "SELECT canal, ROUND(SUM(venta),0) AS venta_eur, COUNT(DISTINCT numdoc) AS documentos FROM fin GROUP BY canal ORDER BY venta_eur DESC",
"display": "bar",
},
"pago": {
"name": "Venta web por forma de pago",
"sql": BASE + "SELECT forma_pago, ROUND(SUM(venta),0) AS venta_eur, COUNT(DISTINCT numdoc) AS documentos FROM fin GROUP BY forma_pago ORDER BY venta_eur DESC",
"display": "row",
},
"matriz": {
"name": "Venta web: matriz canal x forma de pago",
"sql": BASE + "SELECT canal, forma_pago, ROUND(SUM(venta),0) AS venta_eur, COUNT(DISTINCT numdoc) AS documentos FROM fin GROUP BY canal, forma_pago ORDER BY venta_eur DESC",
"display": "table",
},
"evolutivo": {
"name": "Venta web mensual por canal",
"sql": BASE + "SELECT DATE_TRUNC(fecha, MONTH) AS mes, canal, ROUND(SUM(venta),0) AS venta_eur FROM fin GROUP BY mes, canal ORDER BY mes, venta_eur DESC",
"display": "bar",
},
}
TAGS = {
"#4494": {"type":"card","name":"#4494","id":"card__4494","display-name":"#4494","card-id":4494},
"fecha_desde": {"type":"date","name":"fecha_desde","id":"tag-fecha-desde","display-name":"Fecha desde"},
"fecha_hasta": {"type":"date","name":"fecha_hasta","id":"tag-fecha-hasta","display-name":"Fecha hasta"},
"centro": {"type":"text","name":"centro","id":"tag-centro","display-name":"Centro"},
"ensena": {"type":"text","name":"ensena","id":"tag-ensena","display-name":"Ensena"},
}
def dq(sql):
return {"type":"native","database":6,"native":{"query":sql,"template-tags":TAGS}}
def test_query(sql, params=None):
body = dq(sql)
body["parameters"] = params or []
r = api("POST", "/api/dataset", body)
if r.get("error"):
print("QUERY ERROR:", r.get("error")); return None
cols = [c["name"] for c in r["data"]["cols"]]
rows = r["data"]["rows"]
return cols, rows
if __name__ == "__main__":
which = sys.argv[1] if len(sys.argv) > 1 else "all"
# param YTD 2026 para verificar reconciliacion
p_ytd = [{"type":"date/single","value":"2026-01-01","target":["variable",["template-tag","fecha_desde"]]}]
for k, c in CARDS.items():
if which != "all" and which != k: continue
print(f"\n===== TEST {k}: {c['name']} =====")
res = test_query(c["sql"], p_ytd)
if res:
cols, rows = res
print("cols:", cols)
for row in rows[:15]: print(" ", row)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"total": 12367, "canal": 12368, "pago": 12369, "matriz": 12370, "evolutivo": 12371}
+42
View File
@@ -0,0 +1,42 @@
import json, sys
sys.path.insert(0, "scratchpad/exf")
from build import api, BASE, CARDS, TAGS, dq
COLLECTION = 583 # "Claude" (junto a 1094)
CUR = {"number_style":"currency","currency":"EUR","currency_style":"symbol","decimals":0}
def viz(kind):
if kind == "total":
return {"column_settings":{'["name","venta_web_eur"]':CUR},
"scalar.field":"venta_web_eur"}
if kind == "canal":
return {"graph.dimensions":["canal"],"graph.metrics":["venta_eur"],
"graph.x_axis.title_text":"Canal","graph.y_axis.title_text":"Venta web (EUR)",
"column_settings":{'["name","venta_eur"]':CUR},"graph.show_values":True}
if kind == "pago":
return {"graph.dimensions":["forma_pago"],"graph.metrics":["venta_eur"],
"column_settings":{'["name","venta_eur"]':CUR},"graph.show_values":True}
if kind == "matriz":
return {"column_settings":{'["name","venta_eur"]':CUR},
"table.columns":[
{"name":"canal","enabled":True},{"name":"forma_pago","enabled":True},
{"name":"venta_eur","enabled":True},{"name":"documentos","enabled":True}]}
if kind == "evolutivo":
return {"graph.dimensions":["mes","canal"],"graph.metrics":["venta_eur"],
"stackable.stack_type":"stacked","column_settings":{'["name","venta_eur"]':CUR},
"graph.x_axis.title_text":"Mes","graph.y_axis.title_text":"Venta web (EUR)"}
return {}
created = {}
for k, c in CARDS.items():
body = {"name": c["name"], "display": c["display"],
"dataset_query": dq(c["sql"]),
"visualization_settings": viz(k),
"collection_id": COLLECTION}
r = api("POST", "/api/card", body)
created[k] = r["id"]
print(f"card {k}: id {r['id']} {c['name']}")
json.dump(created, open("scratchpad/exf/cards.json","w"))
print("CARDS:", created)
+1
View File
@@ -0,0 +1 @@
{"dashboard_id": 1143}
+54
View File
@@ -0,0 +1,54 @@
import json, sys
sys.path.insert(0, "scratchpad/exf")
from build import api
C = json.load(open("scratchpad/exf/cards.json"))
COLLECTION = 583
# 1) crear dashboard vacio
dash = api("POST", "/api/dashboard", {
"name": "Venta Web por Canal y Forma de Pago (facturacion NAV / modelo 4494)",
"collection_id": COLLECTION,
"description": "Solo venta web (origen precaweb) tomada del modelo 4494 (SUM Base_imponible_linea, facturacion NAV neta), desglosada por canal (channel_id) y forma de pago (pago web vs pago tienda), segun las convenciones del dashboard 1094. Glass excluido. Default: YTD 2026.",
})
DID = dash["id"]
print("dashboard id:", DID)
# 2) parametros del dashboard
PARAMS = [
{"id":"p_desde","name":"Fecha desde","slug":"fecha_desde","type":"date/single","default":"2026-01-01"},
{"id":"p_hasta","name":"Fecha hasta","slug":"fecha_hasta","type":"date/single"},
{"id":"p_centro","name":"Centro","slug":"centro","type":"string/=","sectionId":"string"},
{"id":"p_ensena","name":"Ensena","slug":"ensena","type":"string/=","sectionId":"string"},
]
def mappings(cid):
return [
{"parameter_id":"p_desde","card_id":cid,"target":["variable",["template-tag","fecha_desde"]]},
{"parameter_id":"p_hasta","card_id":cid,"target":["variable",["template-tag","fecha_hasta"]]},
{"parameter_id":"p_centro","card_id":cid,"target":["variable",["template-tag","centro"]]},
{"parameter_id":"p_ensena","card_id":cid,"target":["variable",["template-tag","ensena"]]},
]
# 3) layout (grid 24 col)
LAYOUT = {
"total": (0, 0, 6, 4),
"pago": (0, 6, 18, 4),
"canal": (4, 0, 12, 7),
"matriz": (4, 12, 12, 7),
"evolutivo": (11, 0, 24, 7),
}
dashcards = []
neg = -1
for k,(row,col,sx,sy) in LAYOUT.items():
cid = C[k]
dashcards.append({
"id": neg, "card_id": cid, "row": row, "col": col, "size_x": sx, "size_y": sy,
"series": [], "parameter_mappings": mappings(cid), "visualization_settings": {}
})
neg -= 1
r = api("PUT", f"/api/dashboard/{DID}", {"dashcards": dashcards, "parameters": PARAMS})
print("dashcards saved:", len(r.get("dashcards",[])))
print("URL: https://reports.autingo.es/dashboard/%d" % DID)
json.dump({"dashboard_id":DID}, open("scratchpad/exf/dash.json","w"))
+313
View File
@@ -0,0 +1,313 @@
"""Genera la carpeta de documentacion de linaje en el Escritorio de Windows.
A partir del grafo trazado (scratchpad/lineage_graph.json) escribe:
00_INDICE.txt resumen + mapa de capas + tabla de todos los objetos
01_marts/<vista>.txt una por vista de customer_marts: que es + arbol de linaje + SQL
02_intermedio_clientes_intel/*.txt tablas base del pipeline de inteligencia de clientes
03_producto/*.txt cadena de catalogo de producto (vistas con SQL + bases)
04_fuentes/*.txt tablas fuente (replica Postgres, Navision, imagenes, tasas)
Todos los .txt se escriben con CRLF para abrirse limpios en Bloc de notas de Windows.
"""
import json
import os
import textwrap
DEST = "/mnt/c/Users/egutierrez/Desktop/linaje_customer_marts"
DATA = json.load(open("scratchpad/lineage_graph.json"))
G = DATA["graph"]
PROJECT = DATA["project"]
# ---------------------------------------------------------------------------
# Descripciones ("que es") por objeto. La SQL/DDL incluida en cada archivo es la
# fuente de verdad; estas lineas son un resumen para orientar al lector.
# ---------------------------------------------------------------------------
DESC = {
# ---- customer_marts (marts finales, grano = persona_id / cliente) ----
"customer_marts.customer_profile":
"Ficha maestra 360 del cliente: identidad + features agregadas + score CLV + segmento. Vista de perfil que consolida todo lo demas.",
"customer_marts.customer_monetary":
"Metricas monetarias del cliente (gasto total, ticket medio, recencia/frecuencia/valor). Componente M del RFM.",
"customer_marts.customer_channel":
"Canal del cliente: canal preferido transaccional, mix aurgi/motortown/web/servicio, canal de entrada (canal8) y fuentes de origen.",
"customer_marts.customer_contactability":
"Contactabilidad del cliente: disponibilidad de email/telefono y consentimientos, a partir de la dimension persona + features + segmento.",
"customer_marts.customer_category_spend":
"Gasto del cliente desglosado por categoria de producto, a partir de la tabla de hechos de transaccion.",
"customer_marts.customer_brand_affinity":
"Afinidad de marca del cliente: que marcas compra y con que peso, cruzando transacciones con el catalogo de producto (Objeto_productos).",
"customer_marts.customer_product":
"Productos comprados por el cliente (detalle de que ha adquirido) desde la tabla de hechos de transaccion.",
"customer_marts.customer_store_spend":
"Gasto del cliente por centro/tienda desde la tabla de hechos de transaccion.",
"customer_marts.customer_temporal":
"Patrones temporales de compra del cliente (estacionalidad, recencia, frecuencia) desde transacciones + features.",
"customer_marts.customer_vehicles":
"Vehiculos asociados al cliente: dimension vehiculo + features de vehiculo + mapping N:N persona-vehiculo.",
"customer_marts.customer_payment_method":
"Metodo de pago del cliente reconstruido desde los pedidos TPV (orders/invoice/payment/payment_types).",
"customer_marts.customer_promo_usage":
"Uso de promociones/descuentos por el cliente (pedidos con descuento) desde transacciones + pedidos TPV + segmento.",
"customer_marts.customer_promo_tolerance":
"Tolerancia del cliente a promociones: respuesta a campanas + sensibilidad a descuentos en pedidos.",
"customer_marts.customer_predictive":
"Senales predictivas del cliente: score CLV, proxima mejor accion (recomendaciones) y segmento.",
# ---- clientes_intel (capa intermedia; tablas base del pipeline de inteligencia de clientes) ----
"clientes_intel.dim_persona":
"Dimension PERSONA: identidad de cliente consolidada (una fila por persona_id). Nucleo de la doble identidad persona+vehiculo.",
"clientes_intel.dim_vehiculo":
"Dimension VEHICULO: una fila por vehiculo (matricula/bastidor) con sus atributos.",
"clientes_intel.fact_transaccion":
"Tabla de HECHOS de transaccion: linea/venta por cliente. Base de casi todos los marts monetarios y de producto.",
"clientes_intel.fact_campana_respuesta":
"Tabla de HECHOS de respuesta a campanas de marketing (envio/apertura/conversion) por cliente.",
"clientes_intel.feat_cliente_persona":
"Features agregadas a nivel PERSONA (RFM, mix de canal, indicadores derivados). Alimenta perfil, monetary, channel, temporal, contactability.",
"clientes_intel.feat_cliente_vehiculo":
"Features agregadas a nivel VEHICULO. Alimenta customer_vehicles.",
"clientes_intel.seg_cliente_360":
"Segmentacion 360 del cliente (segmentos de negocio / clusters). Alimenta perfil, channel, contactability, predictive, promo_usage.",
"clientes_intel.score_clv":
"Score de valor de vida del cliente (CLV). Alimenta perfil y predictive.",
"clientes_intel.reco_acciones":
"Recomendaciones / proxima mejor accion (NBA) por cliente. Alimenta customer_predictive.",
"clientes_intel.map_persona_canal8":
"Mapeo persona -> canal8 (canal de entrada). Puente para customer_channel.",
"clientes_intel.map_persona_fuente":
"Mapeo persona -> fuente(s) de origen (de que sistema/canal proviene el cliente). Puente para customer_channel.",
"clientes_intel.map_persona_vehiculo":
"Mapeo N:N persona <-> vehiculo. Puente para customer_vehicles.",
# ---- cadena de catalogo de producto ----
"anjana_bi_datamart.Objeto_productos":
"Vista maestra de PRODUCTO: catalogo Navision + categorias CGQ + imagenes + tasa/margen por material. Se usa para afinidad de marca.",
"anjana_bi_datamart.Cruce_16_07_cgq":
"Tabla de cruce de categorias CGQ (categoria/subcategoria/tipo) usada por Objeto_productos.",
"claude_bi.productos_tasa_mat":
"Tabla de tasa/margen por material de producto. La consume Objeto_productos.",
"external_datasets.product_object_images":
"Imagenes de producto (imagen principal/secundaria). Dataset externo. La consume Objeto_productos.",
"stg_anjana_bi.producto":
"Staging de producto: cruza item de Navision con equivalencias de matriculas (SAF). Capa de preparacion sobre las tablas de SQL Server.",
# ---- fuentes base ----
"psql_dcpublic.products":
"Catalogo de productos. Replica en BigQuery de la BBDD Postgres ANJANA (DCPublic).",
"psql_dcpublic.product_categories":
"Categorias de producto. Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.product_groups":
"Grupos de producto. Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.tpv_orders_order":
"Pedidos TPV (cabecera de pedido). Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.tpv_orders_orderitem":
"Lineas de pedido TPV. Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.tpv_orders_invoice":
"Facturas TPV. Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.tpv_orders_payment":
"Pagos de pedidos TPV. Replica Postgres ANJANA (DCPublic).",
"psql_dcpublic.tpv_payment_types":
"Tipos de pago TPV (catalogo). Replica Postgres ANJANA (DCPublic).",
"mssql2022_dbo.item":
"Catalogo de articulos de Navision (SQL Server 2022, esquema dbo).",
"mssql2022_dbo.equivalencias_matriculas_saf":
"Equivalencias de matriculas (SAF) en Navision (SQL Server 2022, esquema dbo).",
}
TYPE_ES = {
"VIEW": "VISTA (tiene SQL propio)",
"MATERIALIZED VIEW": "VISTA MATERIALIZADA (tiene SQL propio)",
"BASE TABLE": "TABLA BASE (datos materializados; sin SQL de definicion, solo esquema)",
"EXTERNAL": "TABLA EXTERNA",
"UNKNOWN": "DESCONOCIDO",
}
# Carpeta destino por objeto.
def folder_of(key: str) -> str:
ds = key.split(".", 1)[0]
if ds == "customer_marts":
return "01_marts"
if ds == "clientes_intel":
return "02_intermedio_clientes_intel"
if ds in ("anjana_bi_datamart", "claude_bi", "external_datasets", "stg_anjana_bi"):
return "03_producto"
return "04_fuentes"
def fname_of(key: str) -> str:
return key.replace(".", "__") + ".txt"
def relpath_of(key: str) -> str:
return f"{folder_of(key)}/{fname_of(key)}"
def desc_of(key: str) -> str:
return DESC.get(key, "(sin descripcion)")
# ---------------------------------------------------------------------------
# Arbol de linaje recursivo (para los marts).
# ---------------------------------------------------------------------------
def render_tree(key: str, prefix: str | None = None, is_last: bool = True, seen=None) -> list[str]:
if seen is None:
seen = set()
tag = {"VIEW": "[vista]", "MATERIALIZED VIEW": "[vista mat]",
"BASE TABLE": "[TABLA BASE/FUENTE]", "EXTERNAL": "[externa]",
"UNKNOWN": "[?]"}.get(G.get(key, {"type": "UNKNOWN"})["type"], "")
if prefix is None: # raiz
lines = [f"{key} {tag}"]
child_prefix = ""
else:
connector = "└── " if is_last else "├── "
lines = [f"{prefix}{connector}{key} {tag}"]
child_prefix = prefix + (" " if is_last else "")
if key in seen:
lines[-1] += " (ya expandido arriba)"
return lines
seen.add(key)
refs = G.get(key, {"refs": []}).get("refs", [])
for i, r in enumerate(refs):
lines += render_tree(r, child_prefix, i == len(refs) - 1, seen)
return lines
# ---------------------------------------------------------------------------
# Escritura.
# ---------------------------------------------------------------------------
def w(path: str, text: str):
full = os.path.join(DEST, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", newline="\r\n", encoding="utf-8") as f:
f.write(text)
SEP = "=" * 78 + "\n"
def object_file(key: str, include_tree: bool) -> str:
node = G[key]
out = []
out.append(SEP)
out.append(f"OBJETO : {PROJECT}.{key}\n")
out.append(f"TIPO : {TYPE_ES.get(node['type'], node['type'])}\n")
out.append(f"DATASET: {key.split('.',1)[0]}\n")
out.append(SEP)
out.append("\nQUE ES\n------\n")
out.append(textwrap.fill(desc_of(key), width=78) + "\n")
if node.get("refs"):
out.append("\nDEPENDE DIRECTAMENTE DE\n-----------------------\n")
for r in node["refs"]:
out.append(f" - {PROJECT}.{r} -> ver {relpath_of(r)}\n")
if include_tree:
out.append("\nLINAJE COMPLETO (hasta la fuente)\n---------------------------------\n")
out.append("\n".join(render_tree(key)) + "\n")
out.append("\nSQL / DDL\n---------\n")
if node["type"] in ("VIEW", "MATERIALIZED VIEW"):
out.append("(Definicion de la vista. Este es el SQL que puedes copiar.)\n\n")
else:
out.append("(Tabla base: no tiene SQL de transformacion. Se incluye el CREATE TABLE\n"
" con el esquema de columnas para referencia.)\n\n")
out.append(node["ddl"].strip() + "\n")
return "".join(out)
# Marts: incluir arbol de linaje.
marts = sorted(k for k in G if k.startswith("customer_marts."))
for k in marts:
w(f"01_marts/{fname_of(k)}", object_file(k, include_tree=True))
# Resto de objetos: sin arbol (o arbol solo si es vista con dependencias).
for k in sorted(G):
if k.startswith("customer_marts."):
continue
include_tree = G[k]["type"] in ("VIEW", "MATERIALIZED VIEW") and bool(G[k].get("refs"))
w(relpath_of(k), object_file(k, include_tree=include_tree))
# ---------------------------------------------------------------------------
# INDICE.
# ---------------------------------------------------------------------------
idx = []
idx.append(SEP)
idx.append("INDICE - LINAJE DEL DATASET customer_marts\n")
idx.append(f"Proyecto BigQuery: {PROJECT}\n")
idx.append(SEP)
idx.append("""
QUE ES ESTA CARPETA
-------------------
Documenta, para cada tabla/vista del dataset `customer_marts`, de donde salen sus
datos: la cadena completa desde el mart final hasta las tablas fuente, con el SQL
de cada vista listo para copiar y compartir.
Cada objeto tiene su propio .txt con:
- QUE ES (resumen de una linea; la SQL es la fuente de verdad)
- DE QUE DEPENDE (dependencias directas, con la ruta a su archivo)
- LINAJE COMPLETO (arbol hasta la fuente) -- solo en los marts y vistas
- SQL / DDL (el codigo: definicion de la vista, o el esquema si es tabla base)
MAPA DE CAPAS
-------------
customer_marts (VISTAS finales, grano = cliente/persona_id)
|
v
clientes_intel (TABLAS BASE: capa intermedia construida por el pipeline de
| inteligencia de clientes -- dim_*, feat_*, seg_*, score_*,
| reco_*, fact_*, map_*)
v
Fuentes:
- psql_dcpublic.* Replica en BigQuery de la BBDD Postgres ANJANA (TPV + catalogo)
- anjana_bi_datamart / claude_bi / external_datasets / stg_anjana_bi
Cadena de catalogo de PRODUCTO (Objeto_productos y sus fuentes)
- mssql2022_dbo.* Navision (SQL Server 2022, esquema dbo)
NOTA: las tablas de `clientes_intel` son TABLAS BASE: no son vistas, sino tablas que
un pipeline reconstruye cada dia con sentencias CREATE TABLE AS SELECT (CTAS). Su
esquema esta en 02_intermedio_clientes_intel/. El SQL REAL que las construye (y que
baja hasta TPV / customers / users / Navision / Salesforce) esta en la carpeta
05_construccion_clientes_intel/ -- ver tambien 00b_FUENTES_DE_CLIENTE.txt.
""")
idx.append(SEP)
idx.append("CARPETAS\n")
idx.append(SEP)
idx.append("""
01_marts/ Las 14 vistas de customer_marts (con arbol de linaje)
02_intermedio_clientes_intel/ Las 12 tablas base intermedias (esquema)
03_producto/ Cadena de catalogo de producto (vistas + bases)
04_fuentes/ Tablas fuente (replica Postgres, Navision, imagenes, tasas)
05_construccion_clientes_intel/ El SQL (CTAS) que construye cada tabla de clientes_intel
00b_FUENTES_DE_CLIENTE.txt Que consulta lee cada fuente de cliente (TPV/customers/
users/Navision/Salesforce)
""")
def index_block(title, keys):
lines = [SEP, title + "\n", SEP, "\n"]
for k in keys:
t = {"VIEW": "vista", "MATERIALIZED VIEW": "vista_mat", "BASE TABLE": "tabla",
"EXTERNAL": "externa", "UNKNOWN": "?"}.get(G[k]["type"], "")
lines.append(f"[{t:9s}] {k}\n")
lines.append(f" {desc_of(k)}\n")
lines.append(f" archivo: {relpath_of(k)}\n\n")
return "".join(lines)
idx.append(index_block("1) MARTS FINALES (customer_marts)", marts))
idx.append(index_block("2) CAPA INTERMEDIA (clientes_intel)",
sorted(k for k in G if k.startswith("clientes_intel."))))
idx.append(index_block("3) CADENA DE PRODUCTO",
sorted(k for k in G if folder_of(k) == "03_producto")))
idx.append(index_block("4) FUENTES BASE",
sorted(k for k in G if folder_of(k) == "04_fuentes")))
w("00_INDICE.txt", "".join(idx))
# Conteo final
n_files = sum(len(files) for _, _, files in os.walk(DEST))
print(f"Escrito en: {DEST}")
print(f"Archivos .txt generados: {n_files}")
print("Estructura:")
for root, dirs, files in sorted(os.walk(DEST)):
rel = os.path.relpath(root, DEST)
if rel == ".":
for f in sorted(files):
print(f" {f}")
else:
print(f" {rel}/ ({len(files)} archivos)")

Some files were not shown because too many files have changed in this diff Show More