Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c1b7dd0f3 |
@@ -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.
|
||||
@@ -72,10 +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 .draw_join_graph_figure import draw_join_graph_figure
|
||||
from .render_paper_pdf import render_paper_pdf
|
||||
|
||||
__all__ = [
|
||||
"draw_join_graph_figure",
|
||||
"render_paper_pdf",
|
||||
"suggest_intratable_fk_candidates",
|
||||
"detect_time_column",
|
||||
"extract_timeseries_raw",
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,214 +0,0 @@
|
||||
"""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_col→to_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_col→to_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.")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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,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 , 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 )
|
||||
#
|
||||
# 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.
|
||||
# 
|
||||
# # 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 `` 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
|
||||
``; 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 ````; 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: 
|
||||
_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 ```` 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"
|
||||
"\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"])
|
||||
@@ -34,7 +34,6 @@ from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
from .load_folder_to_duckdb import load_folder_to_duckdb
|
||||
from .imap_connect import imap_connect
|
||||
from .imap_list_mailboxes import imap_list_mailboxes
|
||||
from .imap_search import imap_search
|
||||
@@ -51,7 +50,6 @@ __all__ = [
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"load_folder_to_duckdb",
|
||||
"duckdb_upsert",
|
||||
"pg_insert_rows",
|
||||
"pg_apply_sql",
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,175 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,73 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,115 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,366 +0,0 @@
|
||||
"""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/nº 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, nº 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)}
|
||||
@@ -1,188 +0,0 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user