Compare commits

...

6 Commits

Author SHA1 Message Date
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 a1e2e3567c merge: 4c cat_distr una hoja por columna (PDF+PPTX 1:1) + sin descripcion entropia redundante + page_break motor (verificado met) 2026-06-30 19:53:57 +02:00
egutierrez 9be84a48ea merge: 4c quitar definiciones redundantes con glosario en calidad/correlacion/modelos/agregacion/relaciones (links intactos, verificado met) 2026-06-30 19:24:22 +02:00
egutierrez fd63261444 refactor(eda): quitar definiciones inline redundantes con el glosario en 5 capítulos
Ahora que el AutomaticEDA tiene un capítulo GLOSARIO con las definiciones de los
términos técnicos (enganchados como links clicables desde el cuerpo), los
capítulos calidad/correlacion/modelos/agregacion/relaciones ya no repiten inline
esas explicaciones largas: se deja el TÉRMINO marcado (clicable, sigue saltando
al glosario) y se elimina el párrafo/oración de definición redundante. Los
HALLAZGOS y datos concretos del análisis se mantienen intactos; solo se quitan
las definiciones generales que el glosario ya cubre.

- calidad: _criteria_intro pasa de un bullet-list con las definiciones de
  completitud/validez/unicidad/calidad + fórmula renormalizada + párrafo de
  outliers a una frase que nombra las dimensiones, sus pesos (60/40) y el
  principio de outliers; los 4 términos siguen marcados.
- modelos: la nota de normalización deja de explicar la fórmula del z-score; la
  intro de PCA ya no define "componentes ortogonales ordenados por varianza"; la
  de KMeans quita "rango −1 a 1: cuanto más alto..." (silhouette); la sección de
  Isolation Forest quita la descripción de árboles/cortes/umbral. Términos
  marcados intactos.
- correlacion: la intro deja de describir cada método y consolida la duplicación
  signo/dirección; los 4 métodos + FDR siguen marcados.
- agregacion: la intro quita la definición de pivot ("cruzan dos categóricas
  sobre una medida") y abrevia la selección de claves; groupby y pivot marcados.
- relaciones: la intro y la sección de candidatas/inter-tabla quitan las
  definiciones de PK ("identifica cada fila"), FK ("referencian a otra tabla") y
  containment ("valores contenidos en la clave de otra"); pk/fk/cardinalidad/
  containment siguen marcados.

Verificado sobre el EDA de titanic (run_models + run_llm, 48 págs): los 23 link
annotations término→glosario se conservan (PyMuPDF), el glosario mantiene las 20
definiciones, y el texto visible de los 5 capítulos baja un 34.7% en conjunto
(calidad −67%, modelos −33%, relaciones −19%, agregacion −15%, correlacion −8%).
Tests actualizados (calidad_test asertaba el texto viejo). Suite EDA + pipeline
verde (118 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:15:24 +02:00
egutierrez 4099d88eaf merge: 4b salida markdown del AutomaticEDA (render_md, render_automatic_eda emite aeda_md_path, verificado met) 2026-06-30 18:59:33 +02:00
egutierrez 48de3ce3da feat(eda): salida Markdown del AutomaticEDA para pegar a un LLM
Añade un tercer formato de salida al AutomaticEDA, junto al PDF y el PPTX:
un Markdown autocontenido del MISMO documento por capítulos
(chapters_registry.build_document), optimizado para incorporar a un LLM
(texto plano + tablas markdown reales, sin binarios incrustados).

- render_md_impl.render_md(chapters, out_path, meta): serializa los bloques
  del modelo (Heading/Markdown/KVTable/DataTable/Figure/Image/Caption/Note/
  Group/GlossaryEntry) a Markdown. Cabecera con metadatos + índice navegable
  con anclas GitHub; tablas volcadas enteras (el MD no pagina); marcadores de
  glosario eliminados conservando la negrita; glosario al final.
- Figuras: un LLM no ve la imagen, así que se prioriza texto + datos. Se emite
  el caption y, cuando la figura tiene barras (histograma), se extrae la tabla
  de bins (Desde/Hasta/Frecuencia) de los artistas matplotlib. La banda ±1σ
  (axvspan) se descarta por ancho para que no aparezca como un falso bin.
  PNG opcional vía meta['embed_figures'] (off por defecto → sin binarios).
- render_automatic_eda_markdown: función pública del registry (tag eda),
  espejo de render_automatic_eda_pdf/pptx, acepta lista de capítulos o un
  TableProfile (build_document). dict-no-throw.
- render_automatic_eda (pipeline): emite también el .md (emit_md=True por
  defecto, clave de retorno aeda_md_path). Cambio aditivo: PDF/PPTX/manifest
  siguen saliendo igual.

Tests: golden de todos los kinds + regresión del filtro de la banda ±1σ +
edge documento vacío + profile path. Suite del paquete y del pipeline verde
(122 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:52:08 +02:00
17 changed files with 1528 additions and 94 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.
+4
View File
@@ -64,6 +64,7 @@ from .exploratory_caveats import exploratory_caveats
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
from .render_automatic_eda_pdf import render_automatic_eda_pdf
from .render_automatic_eda_pptx import render_automatic_eda_pptx
from .render_automatic_eda_markdown import render_automatic_eda_markdown
from .detect_time_column import detect_time_column
from .extract_timeseries_raw import extract_timeseries_raw
from .build_eda_render_ctx import build_eda_render_ctx
@@ -71,8 +72,10 @@ from .profile_datetime import profile_datetime
from .resample_timeseries import resample_timeseries
from .add_pdf_internal_links import add_pdf_internal_links
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
from .render_paper_pdf import render_paper_pdf
__all__ = [
"render_paper_pdf",
"suggest_intratable_fk_candidates",
"detect_time_column",
"extract_timeseries_raw",
@@ -82,6 +85,7 @@ __all__ = [
"resample_timeseries",
"render_automatic_eda_pdf",
"render_automatic_eda_pptx",
"render_automatic_eda_markdown",
"decode_qr_image",
"adf_kpss_stationarity",
"acf_pacf",
@@ -36,6 +36,7 @@ from .model import ( # noqa: F401
from .chapters_registry import CHAPTER_ORDER, build_chapter, build_document # noqa: F401
from .render_pdf_impl import render_pdf # noqa: F401
from .render_pptx_impl import render_pptx # noqa: F401
from .render_md_impl import render_md # noqa: F401
__all__ = [
"ENGINE_NAME",
@@ -60,4 +61,5 @@ __all__ = [
"build_document",
"render_pdf",
"render_pptx",
"render_md",
]
@@ -561,13 +561,11 @@ def _intro_blocks(gloss=None, mark_term: bool = False) -> list:
t_groupby = _term(mark_term, "groupby", "**por grupos** (split-apply-combine)")
t_pivot = _term(mark_term, "pivot_table", "**tablas dinámicas** (pivot)")
text = (
f"Este capítulo analiza la tabla {t_groupby}: "
"elige las columnas categóricas más informativas por su cardinalidad "
"y relevancia, no todas contra todas, para no inflar comparaciones "
"espurias — y resume las variables numéricas dentro de cada grupo "
f"(conteo, media, mediana, desviación). Las {t_pivot} "
"cruzan dos categóricas sobre una medida, y los **gráficos de barras** "
"(siempre desde cero) comparan los grupos de un vistazo."
f"Este capítulo analiza la tabla {t_groupby}: elige las columnas "
"categóricas más informativas (por cardinalidad y relevancia, no todas "
"contra todas) y resume las variables numéricas dentro de cada grupo "
f"(conteo, media, mediana, desviación). Se añaden {t_pivot} y "
"**gráficos de barras** (siempre desde cero) para comparar los grupos."
)
return [model.Heading(text=CHAPTER_TITLE, level=1),
model.Markdown(text=text)]
@@ -3,12 +3,13 @@
Builds the quality chapter from a ``TableProfile`` of the ``eda`` group. The
chapter implements the quality model of report 2046:
1. **En qué se basa la calidad** — an intro paragraph explaining the two scored
1. **En qué se basa la calidad** — a concise intro naming the two scored
dimensions and their weights (completitud 60%, validez 40%) plus the
table-level row uniqueness, BEFORE any number, and stating explicitly that
outliers are reported as observations and do **not** lower the score. The
criteria terms (calidad de datos, completitud, validez, unicidad de registro)
are hooked into the shared glossary as clickable jumps.
table-level row uniqueness, BEFORE any number, and stating that outliers are
reported as observations and do **not** lower the score. The criteria terms
(calidad de datos, completitud, validez, unicidad de registro) are hooked
into the shared glossary as clickable jumps; their full definitions live in
the GLOSARIO chapter, not inline here.
2. **Scores por columna** — a table with, per column, the total quality score and
its breakdown into completeness / validity (no consistency dimension).
3. **Problemas de calidad** — a table listing ONLY real quality defects
@@ -309,30 +310,22 @@ def _term(key: str, label: str, mark: bool) -> str:
def _criteria_intro(mark: bool) -> str:
"""Intro paragraph explaining the two scored dimensions and the principle."""
"""Intro: how the score is composed, with every term marked clickable.
Concise on purpose: the definitions of each term (calidad de datos,
completitud, validez, unicidad de registro) now live in the GLOSARIO
chapter, so the body no longer repeats them — it only states how the score
is composed and keeps each term marked so it stays a clickable jump.
"""
calidad = _term("calidad_datos", "calidad de datos", mark)
completitud = _term("completitud", "Completitud (peso 60%)", mark)
validez = _term("validez", "Validez (peso 40%, cuando es medible)", mark)
completitud = _term("completitud", "completitud", mark)
validez = _term("validez", "validez", mark)
unicidad = _term("unicidad_registro", "unicidad de registro", mark)
return (
f"La {calidad} de cada columna es un score de 0 a 100 que combina solo "
"dimensiones medibles desde el perfil de la tabla, sin fuente externa "
"de verdad:\n\n"
f"- {completitud}: proporción de valores presentes (1 % de nulos; en "
"texto, las celdas vacías cuentan como faltantes). Los nulos y vacíos "
"bajan el score.\n"
f"- {validez}: proporción de valores que encajan con su tipo o formato "
"(un número que parsea, una fecha legible, un email con forma de email). "
"Si una columna es texto libre sin formato esperado, la validez no se "
"mide y el score se basa solo en la completitud.\n\n"
f"Score de columna = 100 × (0,6·completitud + 0,4·validez), "
"renormalizado cuando la validez no aplica. A nivel de tabla se añade "
f"la {unicidad} (1 % de filas duplicadas).\n\n"
"**Los valores atípicos (outliers) NO bajan la calidad.** Un valor "
"extremo puede ser real y correcto; detectar atípicos es parte del "
"análisis de la distribución, no un juicio de corrección. Por eso, junto "
"con las columnas constantes y los identificadores, se listan aparte "
"como **observaciones analíticas** que no afectan al score."
f"La {calidad} de cada columna es un score de 0 a 100 que combina "
f"{completitud} (peso 60%) y {validez} (peso 40%, cuando es medible); "
f"a nivel de tabla se añade la {unicidad}. Los valores atípicos no "
"bajan el score: se listan aparte como **observaciones analíticas**."
)
@@ -72,14 +72,16 @@ def test_golden_chapter_estructura_y_version():
assert "markdown" in kinds and "kv_table" in kinds and "data_table" in kinds
def test_golden_intro_explica_dos_dimensiones_y_pesos():
def test_golden_intro_nombra_dos_dimensiones_y_pesos():
# La intro nombra las dos dimensiones, sus pesos y la unicidad, pero ya NO
# repite sus definiciones largas: estas viven ahora en el capítulo GLOSARIO.
ch = build_calidad(_profile(), {})
intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
for needle in ("Completitud", "Validez", "60%", "40%",
for needle in ("completitud", "validez", "60%", "40%",
"unicidad de registro"):
assert needle in intro, f"falta {needle!r} en la intro de criterios"
# El principio: los outliers NO bajan la calidad.
assert "atípicos" in intro and "NO bajan" in intro
assert "atípicos" in intro and "no bajan" in intro
# Ya no se menciona la dimensión consistencia eliminada.
assert "20%" not in intro
@@ -356,12 +356,11 @@ def build_correlacion(profile: dict, ctx: dict):
t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
blocks.append(model.Markdown(text=(
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada a "
f"sus tipos ({t_pearson}/{t_spearman} entre numéricas — con **signo**; "
f"{t_cramers} entre categóricas; {t_corr_ratio} num-categórica; "
"información mutua como medida común no lineal). Sólo las correlaciones "
"**num-num** tienen dirección: por eso los pares **negativos** son siempre "
"num-num.")))
"Asociación entre columnas. Cada par se evalúa con la métrica adecuada "
f"a sus tipos: {t_pearson}/{t_spearman} (numéricas), {t_cramers} "
f"(categóricas), {t_corr_ratio} (num-categórica) e información mutua. "
"Sólo las correlaciones **num-num** llevan **signo** (dirección): por "
"eso los pares **negativos** son siempre num-num.")))
# 1) Association matrix (heatmap).
labels, trimmed = _ordered_labels(pairs)
@@ -6,15 +6,16 @@ normality}``). It renders, as structured markdown/tables/figures that the core
paginator never cuts:
1. **Normalization note** — every multivariate model below standardizes the
columns with z-score first; the chapter explains why (different scales would
otherwise dominate distance/variance).
columns with z-score first (the term is marked clickable; its definition
lives in the GLOSARIO chapter, not inline).
2. **PCA** — a scree plot (explained + cumulative variance, single Y axis) plus
variance and top-loadings tables.
3. **KMeans segments** — a PCA scatter **coloured by cluster** (its own
page/slide), the cluster-size table, and a per-cluster LLM micro-analysis
with a title for each segment.
4. **Isolation Forest outliers** — a short explanation of how anomalous rows are
isolated multivariately and how the threshold is chosen, plus the counts.
4. **Isolation Forest outliers** — the multivariate anomaly counts and decision
threshold (the method is marked clickable; its definition lives in the
GLOSARIO chapter, not inline).
5. **Normality** — per-column Jarque-Bera / D'Agostino / Shapiro verdicts.
The raw numeric data needed to colour the cluster scatter is **not** in the
@@ -314,12 +315,8 @@ def _normalization_intro(gloss=None, mark_term: bool = False) -> list:
text = (
"Estos modelos son **no supervisados**: buscan estructura latente sin "
"una variable objetivo. Antes de aplicarlos, todas las columnas "
f"numéricas se {zscore} (cada valor menos la media, dividido por la "
"desviación típica). Sin esta normalización, una variable con escala "
"grande (p.ej. ingresos en euros) dominaría las distancias y la varianza "
"frente a otra de escala pequeña (p.ej. un ratio entre 0 y 1), sesgando "
"tanto el PCA como el KMeans. Tras la estandarización todas las variables "
"pesan por igual."
f"numéricas se {zscore}, para que todas pesen por igual con "
"independencia de su escala."
)
return [model.Heading(text="Modelos no supervisados", level=1),
model.Markdown(text=text)]
@@ -334,11 +331,11 @@ def _pca_section(pca: dict, gloss=None, mark_term: bool = False) -> list:
n_used = pca.get("n_rows_used")
n_feat = pca.get("n_features")
intro = (
f"El {_term(mark_term, 'pca', 'PCA')} resume {_fmt_num(n_feat)} variables "
"numéricas en componentes ortogonales ordenados por la varianza que "
f"capturan ({_fmt_num(n_used)} filas usadas tras eliminar nulos). El "
"gráfico de sedimentación (scree) muestra cuánta varianza aporta cada "
"componente y su acumulado: un codo marca cuántos componentes bastan."
f"El {_term(mark_term, 'pca', 'PCA')} se aplica sobre "
f"{_fmt_num(n_feat)} variables numéricas ({_fmt_num(n_used)} filas "
"usadas tras eliminar nulos). El gráfico de sedimentación (scree) "
"muestra cuánta varianza aporta cada componente y su acumulado: un "
"codo marca cuántos componentes bastan."
)
blocks.append(model.Markdown(text=intro))
@@ -403,9 +400,8 @@ def _kmeans_section(kmeans: dict, projection: dict, titles,
t_sil = _term(mark_term, "silhouette", "*silhouette*")
intro = (
f"{t_kmeans} agrupa las filas en **{_fmt_num(best_k)} segmentos** "
f"elegidos automáticamente maximizando el coeficiente de {t_sil} "
f"(**{_fmt_num(sil)}**, rango 1 a 1: cuanto más alto, segmentos más "
"compactos y separados). Los segmentos se proyectan sobre el plano de "
f"elegidos automáticamente por el coeficiente de {t_sil} "
f"(**{_fmt_num(sil)}**). Los segmentos se proyectan sobre el plano de "
"los dos primeros componentes principales para visualizarlos."
)
blocks.append(model.Markdown(text=intro))
@@ -469,14 +465,10 @@ def _outliers_section(outliers: dict, gloss=None, mark_term: bool = False) -> li
level=2)]
isof = _term(mark_term, "isolation_forest", "**Isolation Forest**")
explain = (
f"{isof} detecta filas anómalas de forma *multivariante*: "
"construye árboles que parten el espacio con cortes aleatorios y mide "
"cuántos cortes hacen falta para aislar cada fila. Las filas raras "
"(combinaciones de valores poco frecuentes considerando **todas las "
"columnas a la vez**, no una sola) se aíslan con muy pocos cortes y "
"obtienen un score bajo. El **umbral** de decisión separa las filas "
"normales de las anómalas según la contaminación esperada del modelo: "
"una fila es outlier cuando su score queda por debajo de ese umbral."
f"{isof} marca filas anómalas de forma *multivariante*: combinaciones "
"de valores poco frecuentes considerando **todas las columnas a la "
"vez**, no una sola. La tabla resume cuántas se detectaron y el umbral "
"de decisión empleado."
)
blocks.append(model.Markdown(text=explain))
blocks.append(model.KVTable(rows=[
@@ -256,14 +256,14 @@ def _pk_candidates_section(profile: dict, mark: bool) -> list:
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
else "**clave primaria**")
intro = (
f"Estas columnas son **candidatas a {pk}**: su "
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y no "
"tienen nulos, así que cada valor identifica una fila distinta. Son "
"candidatas, no una clave declarada: la base no las marca como tal."
f"Columnas **candidatas a {pk}**: su "
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y "
"no tienen nulos. Son candidatas, no una clave declarada: la base no "
"las marca como tal."
if mark else
"Estas columnas son **candidatas a clave primaria**: su cardinalidad "
"iguala al número de filas y no tienen nulos, así que cada valor "
"identifica una fila distinta.")
"Columnas **candidatas a clave primaria**: su cardinalidad iguala al "
"número de filas y no tienen nulos. Son candidatas, no una clave "
"declarada.")
rows = []
for name in keys:
@@ -320,10 +320,10 @@ def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
blocks = [
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
model.Markdown(text=(
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se infieren "
f"por señal de nombre y por {containment}: una columna de una tabla cuyos "
"valores están contenidos en la clave de otra. No están declaradas por "
"la base; son la relación más probable según los datos.")),
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se "
f"infieren por señal de nombre y por {containment}. No están "
"declaradas por la base; son la relación más probable según los "
"datos.")),
]
shown = candidates[:MAX_FK_ROWS]
@@ -441,13 +441,12 @@ def _intro_blocks(mark: bool) -> list:
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
text = (
f"Este capítulo analiza las **relaciones de clave** de la tabla: qué columna "
f"identifica cada fila (la {pk}) y qué columnas referencian a otra tabla (las "
f"{fk}). Cuando la base las **declara** como restricciones del esquema, se "
"muestran tal cual; cuando no, se proponen las más probables a partir de los "
"datos —por inclusión de valores entre tablas (containment) o, en una sola "
"tabla, por una heurística de nombre y cardinalidad— siempre marcadas como "
"candidatas, nunca como hechos.")
f"Este capítulo analiza las **relaciones de clave** de la tabla: cuál es "
f"la {pk} y cuáles son las {fk}. Cuando la base las **declara** como "
"restricciones del esquema, se muestran tal cual; cuando no, se proponen "
"las más probables a partir de los datos —por containment entre tablas o, "
"en una sola tabla, por una heurística de nombre y cardinalidad— siempre "
"marcadas como candidatas, nunca como hechos.")
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]
@@ -0,0 +1,458 @@
"""AutomaticEDA Markdown serializer — one self-contained file to paste to an LLM.
Same document model as the PDF/PPTX renderers (an ordered list of
:class:`Chapter`, each a list of format-independent blocks) but emitted as plain
**Markdown** instead of a binary. The goal is different from the other two
renderers: a Markdown EDA is meant to be *pasted into an LLM*, so it prioritises
TEXT and DATA over visuals. Tables become Markdown tables (every row dumped, no
pagination — nothing is cut because there are no pages); a ``Figure`` becomes its
caption plus, when possible, the underlying bar/histogram data as a Markdown
table (an LLM cannot see the image); glossary term markers are stripped while
``**bold**`` is kept (it is valid Markdown).
dict-no-throw (the ``eda`` group style): :func:`render_md` never raises. On a
fatal error it returns ``{path: None, ...}`` with a ``note`` explaining why; a
malformed block degrades to a readable note rather than crashing the document.
"""
from __future__ import annotations
import os
import re
from . import model
# Glossary span markers (kept text, dropped markers). We intentionally do NOT use
# ``text_layout.strip_inline_md`` for Markdown blocks because that also removes
# ``**bold**`` — valid Markdown we want to preserve when pasting to an LLM.
_TERM_OPEN_RE = re.compile(r"\[\[term:[A-Za-z0-9_]+\]\]")
_MAX_BAR_ROWS = 100
# --------------------------------------------------------------------------- #
# Small helpers.
# --------------------------------------------------------------------------- #
def _clean_terms(s) -> str:
"""Drop glossary term markers, keeping the visible text (and any **bold**)."""
s = model._safe_str(s)
s = _TERM_OPEN_RE.sub("", s)
return s.replace("[[/term]]", "")
def _cell(v) -> str:
"""Render a value as a safe Markdown table cell.
Escapes pipes (``|`` -> ``\\|``) so they do not break the column layout and
folds newlines to ``<br>`` so a multi-line value stays inside one cell. None
becomes an empty string.
"""
s = model._safe_str(v)
s = s.replace("|", "\\|")
s = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
return s
def _slug(text: str) -> str:
"""GitHub-style heading anchor: lowercase, spaces->'-', drop other symbols."""
s = model._safe_str(text).strip().lower()
out = []
for ch in s:
if ch.isalnum():
out.append(ch)
elif ch in " -":
out.append("-")
# any other symbol is dropped.
slug = "".join(out)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-")
def _fmt_num(v) -> str:
"""Compact number for the figure data tables (ints as ints, else 4 sig figs)."""
try:
f = float(v)
except Exception: # noqa: BLE001
return model._safe_str(v)
if f != f: # NaN
return "NaN"
if f == int(f) and abs(f) < 1e15:
return str(int(f))
return f"{f:.4g}"
def _fmt_int(v) -> str:
try:
return str(int(v))
except Exception: # noqa: BLE001
return model._safe_str(v)
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
# --------------------------------------------------------------------------- #
# Document header (title + metadata blockquote + numbered index).
# --------------------------------------------------------------------------- #
def _meta_block(meta: dict) -> list:
"""Build the metadata lines for the header blockquote (omitting absentees)."""
ctx = meta.get("ctx") if isinstance(meta.get("ctx"), dict) else {}
lines: list = []
def add(label, value) -> None:
if value is None:
return
s = model._safe_str(value).strip()
if s and s.lower() != "none":
lines.append(f"**{label}:** {s}")
add("Dataset", ctx.get("dataset_name") or meta.get("dataset_name"))
add("Fuente", ctx.get("source_origin") or meta.get("source_origin"))
add("Almacenamiento", ctx.get("storage") or meta.get("storage"))
n_rows = ctx.get("n_rows", meta.get("n_rows"))
n_cols = ctx.get("n_cols", meta.get("n_cols"))
if n_rows is not None and n_cols is not None:
lines.append(
f"**Dimensiones:** {_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas")
add("Generado", meta.get("generated_at") or _now_iso())
lines.append(f"**Motor:** {model.ENGINE_NAME} v{model.ENGINE_VERSION}")
return lines
# --------------------------------------------------------------------------- #
# Per-block serializers. Each returns a Markdown string (no surrounding blanks;
# the caller separates blocks with a blank line).
# --------------------------------------------------------------------------- #
def _md_heading(block) -> str:
level = int(getattr(block, "level", 1) or 1)
hashes = "#" * min(level + 2, 6) # level1 -> ###; '#'/'##' reserved for doc/chapter.
text = _clean_terms(getattr(block, "text", "")).strip()
return f"{hashes} {text}"
def _md_markdown(block) -> str:
# Keep the text verbatim, dropping only glossary markers (keep **bold**).
return _clean_terms(getattr(block, "text", "")).rstrip("\n")
def _md_kv_table(block) -> str:
lines: list = []
title = getattr(block, "title", None)
if title:
lines.append(f"**{_clean_terms(title).strip()}**")
lines.append("")
lines.append("| Campo | Valor |")
lines.append("| --- | --- |")
for row in (getattr(block, "rows", []) or []):
try:
label, value = row[0], row[1]
except Exception: # noqa: BLE001
label, value = row, ""
lines.append(f"| {_cell(label)} | {_cell(value)} |")
return "\n".join(lines)
def _md_data_table(block) -> str:
lines: list = []
title = getattr(block, "title", None)
if title:
lines.append(f"**{_clean_terms(title).strip()}**")
lines.append("")
header = list(getattr(block, "header", []) or [])
rows = list(getattr(block, "rows", []) or [])
if not header:
ncol = max((len(r) for r in rows), default=1)
header = [f"col{i + 1}" for i in range(ncol)]
ncol = len(header)
lines.append("| " + " | ".join(_cell(h) for h in header) + " |")
lines.append("| " + " | ".join(["---"] * ncol) + " |")
for r in rows: # dump every row — no pagination, nothing cut.
cells = [_cell(r[c]) if c < len(r) else "" for c in range(ncol)]
lines.append("| " + " | ".join(cells) + " |")
note = getattr(block, "note", None)
if note:
lines.append("")
lines.append(f"*{_clean_terms(note).strip()}*")
return "\n".join(lines)
def _bars_table(bars: list) -> str:
"""Render extracted bar/histogram data as a Markdown table (Desde/Hasta/Frec)."""
lines = ["| Desde | Hasta | Frecuencia |", "| --- | --- | --- |"]
shown = bars[:_MAX_BAR_ROWS]
for x0, x1, h in shown:
lines.append(f"| {_fmt_num(x0)} | {_fmt_num(x1)} | {_fmt_num(h)} |")
out = "\n".join(lines)
extra = len(bars) - len(shown)
if extra > 0:
out += f"\n\n*… ({extra} filas más)*"
return out
def _extract_bars(fig) -> list:
"""Collect (x_from, x_to, height) of the rectangular bars of a matplotlib fig.
Histogram / bar-chart bars are ``matplotlib.patches.Rectangle`` with positive
width and height; spines, legends and zero-area artists are skipped. Never
raises — returns ``[]`` on any problem.
"""
bars: list = []
try:
for ax in fig.get_axes():
# Collect this axes' positive-area rectangles, then keep only the ones
# that look like actual histogram/bar bins. Reference shapes that
# matplotlib also stores in ``ax.patches`` — most notably the ``±1σ``
# band drawn by ``axvspan`` (a single rectangle far wider than a bin)
# and a lone Tukey boxplot box — would otherwise show up as fake
# "bins". A histogram axes has several near-equal-width bars, so we
# drop any rectangle whose width is more than twice the median width
# of that axes' rectangles (the σ-band spans many bins; uniform bins
# all sit at the median width and stay).
ax_bars: list = []
for patch in list(getattr(ax, "patches", []) or []):
try:
w = patch.get_width()
h = patch.get_height()
x = patch.get_x()
except Exception: # noqa: BLE001 — not a Rectangle-like patch.
continue
if w and w > 0 and h and h > 0:
ax_bars.append((x, x + w, h))
if len(ax_bars) >= 3:
widths = sorted(b[1] - b[0] for b in ax_bars)
median_w = widths[len(widths) // 2]
if median_w > 0:
ax_bars = [b for b in ax_bars
if (b[1] - b[0]) <= 2.0 * median_w]
bars.extend(ax_bars)
except Exception: # noqa: BLE001
return []
return bars
def _md_figure(block, meta: dict, out_path: str, counter: list) -> str:
"""Serialize a Figure prioritising TEXT + DATA (an LLM cannot see the image).
Emits the caption, then — if the matplotlib figure has bars — a Markdown table
of the underlying (Desde, Hasta, Frecuencia) values. Optionally (when
``meta['embed_figures']`` is True) also exports a PNG beside the .md and adds
an image link; off by default so the Markdown stays self-contained.
"""
caption = model._safe_str(getattr(block, "caption", "")).strip()
parts = [f"*Figura: {caption}*" if caption else "*Figura*"]
fig = None
try:
import matplotlib
matplotlib.use("Agg") # defensive: headless rasterization backend.
fig = getattr(block, "fig", None)
make = getattr(block, "make", None)
if fig is None and callable(make):
fig = make()
if fig is not None:
bars = _extract_bars(fig)
if bars:
parts.append(_bars_table(bars))
if meta.get("embed_figures"):
png = _embed_png(fig, out_path, counter)
if png:
parts.append(f"![{caption}]({png})")
except Exception: # noqa: BLE001 — a bad figure degrades to just its caption.
pass
finally:
if fig is not None:
try:
import matplotlib.pyplot as plt
plt.close(fig)
except Exception: # noqa: BLE001
pass
return "\n\n".join(parts)
def _embed_png(fig, out_path: str, counter: list) -> str:
"""Export the figure to ``<basename>_figN.png`` beside the .md; return its name."""
try:
counter[0] += 1
base = os.path.splitext(os.path.basename(out_path))[0] or "figura"
name = f"{base}_fig{counter[0]}.png"
path = os.path.join(os.path.dirname(os.path.abspath(out_path)), name)
fig.savefig(path, format="png", dpi=120, bbox_inches="tight")
return name
except Exception: # noqa: BLE001
return ""
def _md_image(block) -> str:
path = model._safe_str(getattr(block, "path", ""))
caption = model._safe_str(getattr(block, "caption", "")).strip()
out = f"![{caption}]({path})"
if caption:
out += f"\n\n*{caption}*"
return out
def _md_caption(block) -> str:
return f"*{_clean_terms(getattr(block, 'text', '')).strip()}*"
def _md_note(block) -> str:
text = _clean_terms(getattr(block, "text", "")).strip()
lines = text.split("\n")
return "\n".join((f"> {ln}" if ln.strip() else ">") for ln in lines)
def _md_group(block, meta: dict, out_path: str, counter: list) -> str:
parts: list = []
title = getattr(block, "title", None)
if title:
parts.append(f"### {_clean_terms(title).strip()}")
for b in (getattr(block, "blocks", []) or []):
try:
seg = _serialize_block(b, meta, out_path, counter)
except Exception: # noqa: BLE001
seg = ""
if seg:
parts.append(seg)
return "\n\n".join(parts)
def _md_glossary_entry(block) -> str:
label = (model._safe_str(getattr(block, "label", "")).strip()
or model._safe_str(getattr(block, "key", "")).strip())
definition = _clean_terms(getattr(block, "definition", "")).strip()
out = f"### {label}"
if definition:
out += f"\n\n{definition}"
return out
def _serialize_block(block, meta: dict, out_path: str, counter: list) -> str:
"""Dispatch a single block to its Markdown serializer. Unknown -> note."""
kind = getattr(block, "kind", "")
if kind == "heading":
return _md_heading(block)
if kind == "markdown":
return _md_markdown(block)
if kind == "kv_table":
return _md_kv_table(block)
if kind == "data_table":
return _md_data_table(block)
if kind == "figure":
return _md_figure(block, meta, out_path, counter)
if kind == "image":
return _md_image(block)
if kind == "caption":
return _md_caption(block)
if kind == "note":
return _md_note(block)
if kind == "group":
return _md_group(block, meta, out_path, counter)
if kind == "glossary_entry":
return _md_glossary_entry(block)
# Unknown content -> readable note (mirrors the model's defensive coercion).
return _md_note(model.Note(text=model._safe_str(block)))
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def render_md(chapters: list, out_path: str, meta: dict = None) -> dict:
"""Serialize a list of Chapters into a single self-contained Markdown file.
The output leads with ``# <title>``, a metadata blockquote and a numbered
``## Índice`` linking each chapter, then one ``## N. <title>`` section per
chapter with its blocks. Tables become Markdown tables (every row dumped),
figures become caption + underlying data table, glossary markers are stripped
while ``**bold**`` is kept. Designed to be pasted into an LLM.
Args:
chapters: a list of ``Chapter`` (dataclasses or dicts); normalized
defensively with ``model.as_chapters``.
out_path: filesystem path for the ``.md`` (parent dirs are created).
meta: optional dict. Recognised keys: ``title``, ``ctx`` (dict with
``dataset_name``/``source_origin``/``storage``/``n_rows``/``n_cols``),
``generated_at``, ``embed_figures`` (export PNGs beside the .md,
default False).
Returns:
dict (never raises): ``{path: str|None, n_chars: int,
chapters: list[{id, version}], note: str}``. On a fatal error ``path`` is
None and ``note`` explains why.
"""
meta = meta or {}
chapters = model.as_chapters(chapters)
title = model._safe_str(meta.get("title")) or model.ENGINE_NAME
# Edge: nothing to render -> a minimal but valid Markdown document.
if not chapters:
content = (f"# {title}\n\n"
"*(documento vacío — sin capítulos aplicables)*\n")
return _write(out_path, content, [], "documento vacío")
counter = [0] # document-wide figure counter for unique PNG names.
notes: list = []
segments: list = [f"# {title}"]
meta_lines = _meta_block(meta)
if meta_lines:
segments.append("\n".join(f"> {ln}" for ln in meta_lines))
# Numbered index. The anchor matches the chapter heading emitted below
# (``## N. <title>``) in GitHub slug style.
chap_heads = []
idx_lines = ["## Índice"]
for i, ch in enumerate(chapters, 1):
head_text = f"{i}. {model._safe_str(ch.title)}"
anchor = _slug(head_text)
chap_heads.append((head_text, anchor))
idx_lines.append(f"{i}. [{model._safe_str(ch.title)}](#{anchor})")
segments.append("\n".join(idx_lines))
chapters_meta = []
for i, ch in enumerate(chapters, 1):
segments.append("---")
head_text, _anchor = chap_heads[i - 1]
segments.append(f"## {head_text}")
blocks = list(ch.blocks or [])
# Omit a leading level-1 Heading that just repeats the chapter title.
if blocks:
b0 = blocks[0]
if (getattr(b0, "kind", "") == "heading"
and int(getattr(b0, "level", 1) or 1) == 1
and _clean_terms(getattr(b0, "text", "")).strip()
== model._safe_str(ch.title).strip()):
blocks = blocks[1:]
for block in blocks:
try:
seg = _serialize_block(block, meta, out_path, counter)
except Exception as e: # noqa: BLE001
seg = _md_note(model.Note(text=model._safe_str(block)))
notes.append(
f"bloque '{getattr(block, 'kind', '?')}' del capítulo "
f"'{ch.id}' degradado: {e}")
if seg:
segments.append(seg)
chapters_meta.append({"id": ch.id, "version": ch.version})
content = "\n\n".join(segments) + "\n"
note = f"{len(content)} caracteres"
if notes:
note += " · " + "; ".join(notes)
return _write(out_path, content, chapters_meta, note)
def _write(out_path: str, content: str, chapters_meta: list, note: str) -> dict:
"""Write the Markdown to disk (creating parents). dict-no-throw."""
try:
parent = os.path.dirname(os.path.abspath(out_path))
os.makedirs(parent, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh:
fh.write(content)
except Exception as e: # noqa: BLE001 — never raise from the writer.
return {"path": None, "n_chars": 0, "chapters": [],
"note": f"no se pudo escribir el Markdown: {e}"}
return {"path": out_path, "n_chars": len(content),
"chapters": chapters_meta, "note": note}
@@ -0,0 +1,89 @@
---
name: render_automatic_eda_markdown
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def render_automatic_eda_markdown(chapters_or_profile, out_path: str, meta: dict = None) -> dict"
description: "Renderiza un documento AutomaticEDA por CAPÍTULOS (modelo de bloques independiente del formato) en un único MARKDOWN autocontenido pensado para PEGAR A UN LLM. Acepta una lista de capítulos del modelo o directamente un TableProfile del grupo eda (construye los capítulos canónicos con build_document). Prioriza TEXTO + DATOS sobre lo visual: las tablas se vuelcan como tablas markdown con TODAS las filas (sin paginar — no hay páginas que cortar), una figura matplotlib se reduce a su caption más la tabla de datos subyacente (Desde/Hasta/Frecuencia de las barras del histograma) porque un LLM no ve la imagen, y los marcadores de glosario se eliminan conservando el **negrita**. Lleva cabecera (# título), bloque de metadatos en blockquote e índice numerado con anclas GitHub. Espejo de render_automatic_eda_pdf/render_automatic_eda_pptx pero SIN manifest (KISS, el markdown es un único artefacto de texto). dict-no-throw: nunca lanza, devuelve {path, n_chars, chapters, note}; en error fatal path es None y note explica la causa. Flag opcional meta['embed_figures'] exporta PNGs junto al .md (off por defecto)."
tags: [eda, markdown, render, report, llm, automatic-eda, chapters, versioned, no-cut, text, datascience, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, re, matplotlib, "datascience.automatic_eda"]
params:
- name: chapters_or_profile
desc: "una lista de capítulos del modelo AutomaticEDA (dataclasses Chapter o dicts {id,title,version,blocks}) O un TableProfile dict del grupo eda. Si es un TableProfile, los capítulos canónicos se construyen con build_document(profile, meta['ctx']). Bloques soportados: heading, markdown, kv_table, data_table, figure, image, caption, note, group, glossary_entry. Lectura defensiva: lo no reconocido se degrada a Note, nunca lanza."
- name: out_path
desc: "ruta del archivo .md de salida. Los directorios padre se crean si faltan. Directorio no escribible → {path:None, note:<causa>} sin lanzar."
- name: meta
desc: "dict opcional. Claves: title (título del documento), ctx (dict con dataset_name→Dataset, source_origin→Fuente, storage→Almacenamiento, n_rows/n_cols→Dimensiones; también lo consumen los builders de capítulo cuando se da un profile), generated_at (timestamp; si falta se genera ISO UTC), embed_figures (True para exportar PNGs <basename>_figN.png junto al .md; por defecto False y el markdown queda autocontenido)."
output: "dict (nunca lanza): {path: str|None, n_chars: int, chapters: list[{id,version}], note: str}. En error fatal (p.ej. directorio no escribible) path es None y note explica la causa. Un documento sin capítulos aplicables produce un markdown mínimo válido con 'documento vacío' y chapters=[]."
tested: true
tests: ["test_golden_bloques_sinteticos_serializa_todo_a_markdown", "test_edge_documento_vacio_no_revienta", "test_profile_path_construye_capitulos_y_escribe"]
test_file_path: "python/functions/datascience/render_automatic_eda_markdown_test.py"
file_path: "python/functions/datascience/render_automatic_eda_markdown.py"
---
## Ejemplo
```python
from datascience import render_automatic_eda_markdown
# Desde un TableProfile del grupo eda (mismo modelo que los renderers PDF/PPTX).
profile = {
"table": "ventas", "source": "/data/ventas.csv",
"n_rows": 1000, "n_cols": 2, "quality_score": 92.5,
"columns": [
{"name": "precio", "inferred_type": "numeric", "null_pct": 0.01,
"numeric": {"mean": 42.5, "median": 40.0, "min": 1.0, "max": 100.0,
"std": 12.3}},
{"name": "categoria", "inferred_type": "categorical", "null_pct": 0.0,
"categorical": {"top": [{"value": "neumaticos", "count": 500}]}},
],
}
res = render_automatic_eda_markdown(
profile, "reports/ventas_aeda.md",
{"title": "EDA — ventas",
"ctx": {"dataset_name": "Ventas", "source_origin": "ERP export",
"n_rows": 1000, "n_cols": 2}})
print(res["path"], res["n_chars"], res["chapters"])
# -> reports/ventas_aeda.md 4123 [{'id':'portada','version':'1.0.0'}, ...]
```
## Cuando usarla
Cuando quieras **pegar el EDA a un LLM** (ChatGPT, Claude, ...) o tenerlo en texto
plano versionable: mismo documento por capítulos que el PDF/PPTX, pero serializado a
Markdown sin binarios. Úsala como tercera salida junto a `render_automatic_eda_pdf`
(móvil) y `render_automatic_eda_pptx` (compartir) desde el MISMO modelo de capítulos.
A diferencia de esas dos, no hay páginas ni slides: todas las filas de cada tabla se
vuelcan (nada se corta) y cada figura se reduce a su caption + la tabla de datos
subyacente, que es lo que un LLM puede leer. Para añadir capítulos al documento, ver
`docs/capabilities/automatic_eda.md`.
## Gotchas
- **Impura**: escribe el `.md` en `out_path` (crea los directorios padre). Con
`meta['embed_figures']=True` además exporta un PNG `<basename>_figN.png` por figura
junto al `.md`; por defecto NO exporta nada y el markdown queda autocontenido.
- **Nunca lanza** (dict-no-throw): un bloque que falle se degrada a una nota y se anota
en `note`; el documento se escribe igual. Un profile/lista vacíos producen un markdown
mínimo válido con `*(documento vacío …)*` y `chapters=[]`.
- **Figuras = datos, no imagen**: un bloque `figure` se serializa como `*Figura: caption*`
más, si la figura matplotlib trae barras (histograma / barras), una tabla
`| Desde | Hasta | Frecuencia |` extraída de los `Rectangle` patches (máx 100 filas;
el resto se trunca con `*… (N filas más)*`). Si no hay barras o algo falla, solo sale
el caption. La figura se cierra (`plt.close`) tras leerla.
- **Glosario vs negrita**: se eliminan SOLO los marcadores de glosario
`[[term:key]]visible[[/term]]` (queda `visible`); el `**negrita**` markdown SE
CONSERVA (es válido). No se usa `strip_inline_md` aquí porque ese también quita el bold.
- **Anclas del índice**: el `## Índice` enlaza cada capítulo con un ancla estilo GitHub
del encabezado `## N. Título` (minúsculas, espacios→`-`, sin signos). Si dos capítulos
comparten título exacto sus anclas colisionan (caso raro; los capítulos canónicos tienen
títulos únicos).
- **Tablas**: las celdas escapan `|` (→ `\|`) y pliegan saltos de línea a `<br>` para no
romper la columna. No hay reparto por ancho — un LLM no lo necesita.
@@ -0,0 +1,55 @@
"""render_automatic_eda_markdown — chapter-based EDA report as one Markdown file.
Public ``eda``-group entry point that serializes an AutomaticEDA document (a list
of chapters, or an ``eda`` TableProfile from which the canonical chapters are
built) into a single self-contained Markdown file optimised to be **pasted into
an LLM**: plain text, Markdown tables (every row dumped — there are no pages to
cut), figures reduced to caption + underlying data, no binaries. It mirrors
``render_automatic_eda_pdf`` / ``render_automatic_eda_pptx`` but for text output;
unlike those it writes no manifest (KISS — Markdown is a single text artefact).
dict-no-throw: never raises. Returns ``{path, n_chars, chapters, note}``; on a
fatal error ``path`` is None and ``note`` explains why.
"""
from __future__ import annotations
from datascience.automatic_eda import build_document, render_md
from datascience.automatic_eda.model import as_chapter, as_chapters
def _coerce_chapters(chapters_or_profile, meta: dict) -> list:
"""Accept chapters OR an eda profile and return a list of Chapter."""
arg = chapters_or_profile
if isinstance(arg, (list, tuple)):
return as_chapters(list(arg))
if isinstance(arg, dict):
if "blocks" in arg and "columns" not in arg:
ch = as_chapter(arg)
return [ch] if ch is not None else []
return build_document(arg, (meta or {}).get("ctx"))
return []
def render_automatic_eda_markdown(chapters_or_profile, out_path: str,
meta: dict = None) -> dict:
"""Render an AutomaticEDA document into a single self-contained Markdown file.
Args:
chapters_or_profile: a list of chapters (``Chapter`` dataclasses or
dicts) or an ``eda`` TableProfile dict (chapters built via
``build_document(profile, meta['ctx'])``).
out_path: filesystem path for the ``.md`` (parent dirs are created).
meta: optional dict. Recognised keys: ``title``, ``ctx`` (dict with
``dataset_name``/``source_origin``/``storage``/``n_rows``/``n_cols``),
``generated_at``, ``embed_figures`` (export PNGs beside the .md,
default False — off keeps the Markdown self-contained).
Returns:
dict (never raises): ``{path: str|None, n_chars: int,
chapters: list[{id, version}], note: str}``. On a fatal error ``path`` is
None and ``note`` explains the cause.
"""
meta = dict(meta or {})
chapters = _coerce_chapters(chapters_or_profile, meta)
return render_md(chapters, out_path, meta)
@@ -0,0 +1,168 @@
"""Tests for render_automatic_eda_markdown — DoD: golden + edge + profile path.
Self-contained synthetic blocks (no DuckDB). Verifies every block kind serializes
to Markdown (heading, markdown with glossary+bold, kv/data tables, a figure whose
histogram bars become a data table, caption, note, group, glossary entry), that a
leading level-1 heading equal to the chapter title is omitted, that an empty
document degrades to a valid minimal Markdown without raising, and that passing a
minimal TableProfile builds chapters and writes the file.
"""
import os
import tempfile
from datascience.render_automatic_eda_markdown import render_automatic_eda_markdown
from datascience.automatic_eda.model import (
Caption, Chapter, DataTable, Figure, GlossaryEntry, Group, Heading, KVTable,
Markdown, Note,
)
def _hist_fig():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.hist([1, 1, 2, 2, 2, 3, 4, 4, 5, 5, 5, 5], bins=5)
return fig
def _chapters() -> list:
blocks = [
Heading("Demo", 1), # == chapter title -> omitted.
Heading("Seccion dos", 2), # -> ####
Markdown("Texto con [[term:ent]]entropia[[/term]] y **bold** aqui."),
KVTable(rows=[("Filas", 1000), ("Columnas", 5)], title="Resumen"),
DataTable(header=["col", "valor"],
rows=[["alpha", "111"], ["beta", "222"], ["gamma", "333"]],
title="Datos", note="nota inferior"),
Figure(make=_hist_fig, caption="Histograma demo"),
Caption("pie de figura"),
Note("una nota aparte"),
Group(title="Grupo X", blocks=[Markdown("dentro del grupo")]),
GlossaryEntry(key="ent", label="Entropia",
definition="Medida de incertidumbre."),
]
return [Chapter(id="demo", title="Demo", version="1.0.0", blocks=blocks)]
def _read(path: str) -> str:
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def test_golden_bloques_sinteticos_serializa_todo_a_markdown():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "demo.md")
res = render_automatic_eda_markdown(
_chapters(), out,
{"title": "EDA Demo",
"ctx": {"dataset_name": "Demo", "n_rows": 12, "n_cols": 2}})
assert res["path"] == out
assert os.path.exists(out)
assert res["n_chars"] > 0
assert res["chapters"] == [{"id": "demo", "version": "1.0.0"}]
content = _read(out)
# Document structure.
assert content.startswith("# ")
assert "## Índice" in content
# A Markdown table is present (header + separator row).
assert "| " in content and "| --- " in content
# DataTable values are all dumped.
for v in ("alpha", "111", "beta", "222", "gamma", "333"):
assert v in content
# Glossary markers stripped, bold kept.
assert "[[term" not in content
assert "[[/term]]" not in content
assert "**bold**" in content
assert "entropia" in content # visible glossary text preserved.
# Figure histogram bars became a data table.
assert "| Desde | Hasta | Frecuencia |" in content
# Glossary entry rendered as a level-3 heading.
assert "### Entropia" in content
# Level-2 heading -> ####.
assert "#### Seccion dos" in content
# Leading level-1 heading equal to the title was omitted.
assert "### Demo" not in content
# Group title rendered.
assert "### Grupo X" in content
def _hist_fig_with_span():
"""Histogram with a wide ``axvspan`` (±1σ band) over it.
Reproduces the num_distr figure shape: matplotlib keeps the span as a lone
Rectangle in ``ax.patches`` alongside the bin bars; it must NOT leak into the
extracted bins table as a fake bin (it is ~5x wider than a bin)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
data = [1, 1, 2, 2, 2, 3, 4, 4, 5, 5, 5, 5]
ax.hist(data, bins=5)
ax.axvspan(2.0, 4.0, alpha=0.2) # mean±σ band — a wide stray rectangle.
return fig
def test_figura_descarta_axvspan_de_la_tabla_de_bins():
"""The ±1σ band rectangle must not appear as a row in the bins table."""
blocks = [Figure(make=_hist_fig_with_span, caption="Hist con banda")]
chapters = [Chapter(id="f", title="Fig", version="1.0.0", blocks=blocks)]
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "fig.md")
render_automatic_eda_markdown(chapters, out, {"title": "T"})
content = _read(out)
assert "| Desde | Hasta | Frecuencia |" in content
# Extract the rows of the bins table: lines between the header/separator
# and the next blank line.
lines = content.splitlines()
hi = next(i for i, ln in enumerate(lines)
if ln.startswith("| Desde | Hasta | Frecuencia |"))
rows = []
for ln in lines[hi + 2:]: # skip header + separator
if not ln.startswith("|"):
break
rows.append(ln)
# 5 histogram bins, no extra wide span row.
assert len(rows) == 5, rows
# No row spans a width of ~2.0 (the axvspan from x=2 to x=4).
for ln in rows:
cells = [c.strip() for c in ln.strip("|").split("|")]
lo, hi_v = float(cells[0]), float(cells[1])
assert (hi_v - lo) < 1.5, f"wide span leaked: {ln}"
def test_edge_documento_vacio_no_revienta():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "empty.md")
res = render_automatic_eda_markdown([], out, {})
assert res["path"] == out
assert os.path.exists(out)
assert res["chapters"] == []
content = _read(out)
assert "documento vacío" in content
assert content.startswith("# ")
def test_profile_path_construye_capitulos_y_escribe():
profile = {
"table": "mini",
"source": "/data/mini.csv",
"n_rows": 10,
"n_cols": 1,
"quality_score": 88.0,
"columns": [
{"name": "x", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0,
"numeric": {"mean": 1.0, "median": 1.0, "min": 0.0, "max": 2.0,
"std": 0.5}},
],
}
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "mini.md")
res = render_automatic_eda_markdown(
profile, out, {"title": "Mini", "ctx": {"dataset_name": "Mini"}})
assert res["path"] == out # not None — no exception, file written.
assert os.path.exists(out)
assert res["n_chars"] > 0
@@ -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 sí 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, nº 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"])
@@ -1,9 +1,10 @@
"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX.
"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX + MD.
Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus dos formatos a la
vez (PDF móvil A5 + PPTX 16:9) con los 11 capítulos POBLADOS, en una sola
llamada. Compone, sin reimplementar su lógica, cuatro funciones del registry:
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus tres formatos a la
vez (PDF móvil A5 + PPTX 16:9 + Markdown autocontenido para pegar a un LLM) con
los capítulos POBLADOS, en una sola llamada. Compone, sin reimplementar su
lógica, varias funciones del registry:
- profile_table : perfila la tabla end-to-end (TableProfile agregado),
opcionalmente con modelos baratos y análisis de serie.
@@ -12,8 +13,11 @@ llamada. Compone, sin reimplementar su lógica, cuatro funciones del registry:
modelos/geo, timeseries_raw para series, geo_points
para el mapa, db_path/table para la agregación
push-down). Sin él, esos capítulos degradan.
- render_automatic_eda_pdf : renderiza el documento por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento a PPTX.
- render_automatic_eda_pdf : renderiza el documento por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento a PPTX.
- render_automatic_eda_markdown : serializa el mismo documento a Markdown
autocontenido (texto + tablas markdown, sin
binarios) para incorporar a un LLM.
El TableProfile agregado basta para portada/overview/distribuciones/calidad/
correlación, pero los capítulos `modelos`, `timeseries`, `geospatial` y
@@ -32,6 +36,7 @@ from datetime import datetime, timezone
from datascience import (
build_eda_render_ctx,
render_automatic_eda_markdown,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
run_eda_models,
@@ -93,6 +98,7 @@ def render_automatic_eda(
out_dir: str = "reports",
basename: str = None,
ctx_extra: dict = None,
emit_md: bool = True,
) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
@@ -140,13 +146,19 @@ def render_automatic_eda(
ctx_extra: dict opcional con claves de presentación/contexto extra que se
mezclan en el ctx (p.ej. dataset_name, description, source_origin).
No pisan las claves de datos calculadas por build_eda_render_ctx.
emit_md: además del PDF y el PPTX, emite un Markdown autocontenido del
MISMO documento por capítulos (texto plano + tablas markdown, sin
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.
Returns:
dict (nunca lanza). En éxito::
{"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>}
"aeda_md_path": str|None, "manifest_path": str|None,
"n_pages": int, "n_slides": int, "md_chars": int|None,
"pdf_note": str, "pptx_note": str, "md_note": str|None,
"profile": <TableProfile>}
En error: {"status": "error", "error": str}.
"""
@@ -243,15 +255,26 @@ def render_automatic_eda(
rpdf = render_automatic_eda_pdf(prof, pdf_path, meta) or {}
rpptx = render_automatic_eda_pptx(prof, pptx_path, meta) or {}
# Salida Markdown autocontenida (mismo documento por capítulos) para
# pegar a un LLM. Aditiva: no afecta a PDF/PPTX/manifest. dict-no-throw.
rmd = {}
md_path = None
if emit_md:
md_path = os.path.join(out_dir, base + ".md")
rmd = render_automatic_eda_markdown(prof, md_path, meta) or {}
return {
"status": "ok",
"pdf_path": rpdf.get("path"),
"pptx_path": rpptx.get("path"),
"aeda_md_path": rmd.get("path"),
"manifest_path": rpdf.get("manifest_path"),
"n_pages": rpdf.get("n_pages"),
"n_slides": rpptx.get("n_slides"),
"md_chars": rmd.get("n_chars"),
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"md_note": rmd.get("note"),
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.