merge: Fase 3 AutomaticEDA wiring (verificado met)

- build_eda_render_ctx: arma ctx (raw_numeric, timeseries_raw, geo_points, db_path+table) desde tabla DuckDB
- pipeline render_automatic_eda: perfila + ctx + build_document -> PDF + PPTX (11 capitulos poblados)
- profile_table: flag emit_automatic emite el report AutomaticEDA (PDF+PPT) sin romper render_eda_pdf
- text_layout: render real de **negrita** en PDF y PPTX
- .claude/commands/eda.md actualizado

Los 4 capitulos que degradaban (modelos/timeseries/geospatial/agregacion) ahora salen POBLADOS end-to-end.
This commit is contained in:
2026-06-30 16:19:52 +02:00
14 changed files with 1221 additions and 30 deletions
+20 -10
View File
@@ -25,9 +25,10 @@ Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar
- `--models``run_models=True` (PCA/KMeans/IsolationForest/normalidad).
- `--llm``run_llm=True` (1 call LLM sobre el perfil agregado).
- `--series``run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica).
- `--pdf``emit_pdf=True` (PDF A5 vertical legible en móvil).
- `--pdf``emit_pdf=True` (PDF A5 legacy de `render_eda_pdf`, legible en móvil).
- `--legacy-only` → emite SOLO el PDF legacy (sin AutomaticEDA), para casos en que solo se quiera el PDF rápido.
Por defecto, para un EDA "completo" cuando el usuario no especifica, activa `run_models`, `run_series` y `emit_pdf`; deja `run_llm` para cuando lo pida o cuando interese la interpretación semántica (es la única parte que gasta tokens del modelo).
Por defecto, **un EDA completo emite SIEMPRE el informe AutomaticEDA en sus dos formatos: PDF (A5 móvil) Y PPTX (16:9 para compartir)** con los 11 capítulos poblados (portada, overview, distribuciones, calidad, correlaciones, modelos, series, geoespacial, agregación, interpretación LLM). Usa el pipeline `render_automatic_eda` (o `profile_table(emit_automatic=True)`), que activa `run_models` y `run_series` para que los capítulos de modelos/series/geoespacial/agregación salgan poblados. Deja `run_llm` para cuando el usuario lo pida o interese la interpretación semántica + narrativa por capítulo (es la única parte que gasta tokens del modelo).
## Reglas duras
@@ -35,7 +36,7 @@ Por defecto, para un EDA "completo" cuando el usuario no especifica, activa `run
2. **CSV/Parquet/Excel** entran cargándolos antes a DuckDB (`read_csv_auto`/`read_parquet`/`read_xlsx`) — DuckDB es el motor por defecto. No traigas la tabla entera a RAM.
3. **Secretos**: si la fuente es un DSN PostgreSQL con credenciales, NO las imprimas en los reports ni en el notebook; resuélvelas vía `resolve_pg_dsn`/`pass` cuando aplique.
4. **El report es un artefacto local**: vive en `reports/` (gitignored), no se sube a Gitea ni se versiona. Compartir = pasar la ruta (regla `reports.md`).
5. **Entrega las 4 salidas**: JSON sidecar + Markdown + **PDF móvil** + **notebook Jupyter colaborativo ejecutado en vivo**.
5. **Entrega las salidas**: el informe **AutomaticEDA PDF + PPTX** (siempre, con `render_automatic_eda` / `emit_automatic=True`) + (opcional) JSON sidecar + Markdown + PDF legacy + **notebook Jupyter colaborativo ejecutado en vivo**. Comparte las rutas de PDF y PPTX.
## Paso 1 — Perfilar y escribir los reports
@@ -43,18 +44,26 @@ Una tabla (caso normal):
```bash
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
from pipelines.profile_table import profile_table
r = profile_table(
from pipelines.render_automatic_eda import render_automatic_eda
# Informe AutomaticEDA COMPLETO one-shot: perfil + ctx (datos crudos) + PDF + PPTX
# con los 11 capítulos poblados (clusters pintados, evolución temporal, mapa,
# tablas de agregación). run_llm=True añade la narrativa LLM por capítulo.
r = render_automatic_eda(
"/ruta/datos.duckdb", "ventas",
run_models=True, run_series=True, emit_pdf=True, run_llm=False,
run_models=True, run_series=True, run_llm=False, out_dir="reports",
)
print("status:", r["status"])
print("md: ", r["report_md_path"])
print("json: ", r["report_json_path"])
print("pdf: ", r["pdf_path"])
print("pdf: ", r["pdf_path"], "(", r["n_pages"], "págs )")
print("pptx: ", r["pptx_path"], "(", r["n_slides"], "slides )")
print("manifest:", r["manifest_path"])
PYEOF
```
Si además quieres el report Markdown + JSON sidecar y/o el PDF legacy junto al
AutomaticEDA, usa `profile_table(emit_automatic=True, emit_pdf=True, write_report=True)`:
emite todo a la vez (`report_md_path`, `report_json_path`, `pdf_path` legacy,
`aeda_pdf_path`, `aeda_pptx_path`, `aeda_manifest_path`).
Una base entera (todas las tablas + relaciones FK):
```bash
@@ -90,6 +99,7 @@ Sigue la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`:
## Notas
- El `TableProfile` lleva ahora, además del perfilado base y las correlaciones con FDR: `series` (por columna numérica, con `run_series`), `reexpression` por columna numérica (escalera de Tukey) y `caveats` (siempre, avisos exploratorios). El Markdown y el PDF renderizan estas secciones automáticamente cuando están presentes.
- El PDF (`emit_pdf`) está pensado para leerse en el móvil (A5 vertical, tipografía grande, gráficos Tufte). Se escribe junto al Markdown en `reports/`.
- El informe **AutomaticEDA** (`render_automatic_eda` / `emit_automatic=True`) emite el MISMO documento por capítulos a **PDF (A5 móvil)** y **PPTX (16:9)** con garantía de no-corte (texto envuelto, tablas partidas repitiendo cabecera, figuras escaladas) y negrita real (`**texto**`). Escribe `automatic_eda_manifest.json` con la versión de cada capítulo. Los capítulos modelos/series/geoespacial/agregación se pueblan con los datos crudos que `build_eda_render_ctx` muestrea de la base (no se traen tablas enteras a RAM).
- El PDF legacy (`emit_pdf`, `render_eda_pdf`) sigue disponible y es independiente del AutomaticEDA (A5 vertical, gráficos Tufte). Se escribe junto al Markdown en `reports/`.
- `run_series` ordena por la primera columna datetime si existe; si no, por el orden físico de filas. Necesita ≥8 puntos válidos por columna.
- Fuentes: DuckDB (CSV/Parquet/Excel cargados antes) y PostgreSQL (`backend="postgres"`). `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
+2
View File
@@ -65,12 +65,14 @@ from .render_automatic_eda_pdf import render_automatic_eda_pdf
from .render_automatic_eda_pptx import render_automatic_eda_pptx
from .detect_time_column import detect_time_column
from .extract_timeseries_raw import extract_timeseries_raw
from .build_eda_render_ctx import build_eda_render_ctx
from .profile_datetime import profile_datetime
from .resample_timeseries import resample_timeseries
__all__ = [
"detect_time_column",
"extract_timeseries_raw",
"build_eda_render_ctx",
"profile_datetime",
"resample_timeseries",
"render_automatic_eda_pdf",
@@ -0,0 +1,113 @@
"""Tests for inline-bold rendering (**bold**) in the AutomaticEDA engine.
Covers the pure helpers (parse_inline_bold / wrap_rich) and an end-to-end PPTX
check that a ``**bold**`` span is rendered with NATIVE PowerPoint bold
(``run.font.bold is True``) while no line overflows the wrap width (no-cut).
"""
import os
import sys
import pytest
# Make the engine importable as a package (datascience.automatic_eda).
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
from datascience.automatic_eda import model # noqa: E402
from datascience.automatic_eda import text_layout as tl # noqa: E402
from datascience.automatic_eda import render_pptx # noqa: E402
# --------------------------------------------------------------------------- #
# Pure helpers.
# --------------------------------------------------------------------------- #
def test_parse_inline_bold_marks_spans_and_preserves_visible_text():
src = "**Estacionariedad:** serie no estacionaria con `code` y normal."
segs = tl.parse_inline_bold(src)
# Visible text equals strip_inline_md (no characters lost, markers removed).
visible = "".join(s for s, _ in segs)
assert visible == tl.strip_inline_md(src)
# The span "Estacionariedad:" is flagged bold; the rest is not.
bold_text = "".join(s for s, b in segs if b)
assert "Estacionariedad:" in bold_text
assert "serie no estacionaria" not in bold_text
def test_parse_inline_bold_handles_unbalanced_markers():
# An unbalanced ** must not crash and must be stripped (matches strip_inline_md).
segs = tl.parse_inline_bold("texto **sin cierre aqui")
visible = "".join(s for s, _ in segs)
assert visible == "texto sin cierre aqui"
assert not any(b for _, b in segs) # nothing rendered bold.
def test_wrap_rich_never_overflows_and_keeps_bold():
text = ("**Segmento premium.** Clientes de alto gasto y baja frecuencia con "
"ticket medio elevado y recurrencia anual estable a lo largo del año.")
max_chars = 30
lines = tl.wrap_rich(text, max_chars)
# No visible line exceeds max_chars (no-cut: the renderer measures these).
for ln in lines:
visible = "".join(s for s, _ in ln)
assert len(visible) <= max_chars, f"línea desborda: {visible!r}"
# At least one segment is bold and it is the span content.
bold_segs = [s for ln in lines for s, b in ln if b]
assert any("Segmento premium." in s for s in bold_segs)
def test_wrap_rich_hard_splits_long_token():
long = "x" * 50
lines = tl.wrap_rich(f"**{long}**", 20)
for ln in lines:
assert len("".join(s for s, _ in ln)) <= 20
# The whole long token is preserved across the split lines.
joined = "".join(s for ln in lines for s, _ in ln)
assert joined == long
# --------------------------------------------------------------------------- #
# End-to-end: PPTX renders **bold** as a real bold run.
# --------------------------------------------------------------------------- #
def _has_pptx():
try:
import pptx # noqa: F401
return True
except Exception: # noqa: BLE001
return False
@pytest.mark.skipif(not _has_pptx(), reason="python-pptx no instalado")
def test_pptx_renders_bold_span_as_native_bold_run(tmp_path):
from pptx import Presentation
doc = [model.Chapter(
id="t", title="Negrita", version="1.0.0",
blocks=[model.Markdown(
text="Frase con **PALABRACLAVE** resaltada y texto normal después.")],
)]
out = str(tmp_path / "bold.pptx")
res = render_pptx(doc, out, {"title": "T"})
assert res.get("path") == out
assert os.path.exists(out)
prs = Presentation(out)
bold_texts = []
all_text = []
for slide in prs.slides:
for shape in slide.shapes:
if not shape.has_text_frame:
continue
for para in shape.text_frame.paragraphs:
for run in para.runs:
all_text.append(run.text)
if run.font.bold:
bold_texts.append(run.text)
# The bold span text appears in a run with font.bold True (native bold).
assert any("PALABRACLAVE" in t for t in bold_texts), \
f"no se encontró run bold con el span; bold={bold_texts}"
# And the surrounding plain text is NOT bold (markers did not bleed).
assert any("resaltada" in t for t in all_text)
assert not any("resaltada" in t for t in bold_texts)
@@ -169,6 +169,38 @@ def _place_text_lines(st: _PdfState, lines: list, fs: float, color: str,
st.y += lh
def _place_rich_lines(st: _PdfState, rich_lines: list, fs: float, color: str,
indent: float = 0.0, prefixes=None) -> None:
"""Draw pre-wrapped lines of styled segments (bold spans rendered bold).
Each line is ``[(text, is_bold), ...]``. Segments are placed left-to-right,
advancing x by the deterministic character grid (same metric the wrapper
used), so a bold span is rendered with ``fontweight='bold'`` without
changing the line's measured width — the no-cut guarantee is preserved.
``prefixes`` is an optional ``(first_line, other_lines)`` pair (e.g. a
bullet) drawn before the segments.
"""
lh = tl.line_height_in(fs)
cw = tl.avg_char_width_in(fs)
for idx, segs in enumerate(rich_lines):
_ensure_space(st, lh)
x = _ML + indent
if prefixes is not None:
prefix = prefixes[0] if idx == 0 else prefixes[1]
if prefix:
st.fig.text(_xf(x), _yf(st.y), prefix, fontsize=fs, color=color,
ha="left", va="top")
x += cw * len(prefix)
for seg_text, is_bold in segs:
if seg_text == "":
continue
st.fig.text(_xf(x), _yf(st.y), seg_text, fontsize=fs, color=color,
ha="left", va="top",
fontweight="bold" if is_bold else "normal")
x += cw * len(seg_text)
st.y += lh
def _place_markdown(st: _PdfState, block) -> None:
raw = getattr(block, "text", "") or ""
md_lines = str(raw).split("\n")
@@ -208,29 +240,25 @@ def _place_markdown(st: _PdfState, block) -> None:
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
content = tl.strip_inline_md(stripped[2:])
content = stripped[2:] # keep inline markers for bold rendering.
bullet_chars = tl.chars_per_line(_USABLE_W - 0.22, _FS_BODY)
wrapped = tl.wrap(content, bullet_chars)
first = True
for w in wrapped:
prefix = "" if first else " "
_place_text_lines(st, [prefix + w], _FS_BODY, _INK,
indent=0.0)
first = False
rich = tl.wrap_rich(content, bullet_chars)
_place_rich_lines(st, rich, _FS_BODY, _INK,
prefixes=("", " "))
i += 1
continue
# Plain paragraph (gather following plain lines into one paragraph).
para = [tl.strip_inline_md(stripped)]
para = [stripped] # keep inline markers; wrap_rich renders **bold**.
j = i + 1
while j < n:
nxt = md_lines[j].strip()
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
break
para.append(tl.strip_inline_md(nxt))
para.append(nxt)
j += 1
text = " ".join(para)
max_chars = tl.chars_per_line(_USABLE_W, _FS_BODY)
_place_text_lines(st, tl.wrap(text, max_chars), _FS_BODY, _INK)
_place_rich_lines(st, tl.wrap_rich(text, max_chars), _FS_BODY, _INK)
i = j
st.y += _GAP
@@ -151,6 +151,42 @@ def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
st.y += height
def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
indent=0.0, bullet=False) -> None:
"""Add pre-wrapped lines of styled segments as one paragraph per line.
Each line is ``[(text, is_bold), ...]``; every segment becomes its own run
so ``**bold**`` spans render with native PowerPoint bold (``run.font.bold``)
without affecting the measured height (one paragraph per pre-wrapped line).
"""
lh = tl.line_height_in(fs)
height = lh * len(rich_lines) + 0.05
_ensure(st, height)
box = st.slide.shapes.add_textbox(
Inches(_ML + indent), Inches(st.y), Inches(_USABLE_W - indent),
Inches(height))
tf = box.text_frame
tf.word_wrap = True
first = True
for segs in rich_lines:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
if bullet:
r0 = p.add_run()
r0.text = ""
r0.font.size = Pt(fs)
r0.font.color.rgb = _rgb(color)
for seg_text, is_bold in segs:
if seg_text == "":
continue
run = p.add_run()
run.text = seg_text
run.font.size = Pt(fs)
run.font.bold = bool(is_bold)
run.font.color.rgb = _rgb(color)
st.y += height
def _place_heading(st: _PptxState, block) -> None:
level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
@@ -196,21 +232,22 @@ def _place_markdown(st: _PptxState, block) -> None:
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
content = tl.strip_inline_md(stripped[2:])
lines = tl.wrap(content, tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
_add_text(st, lines, _FS_BODY, _INK, bullet=True)
content = stripped[2:] # keep inline markers for bold rendering.
rich = tl.wrap_rich(content,
tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
_add_rich_text(st, rich, _FS_BODY, _INK, bullet=True)
i += 1
continue
para = [tl.strip_inline_md(stripped)]
para = [stripped] # keep inline markers; wrap_rich renders **bold**.
j = i + 1
while j < n:
nxt = md_lines[j].strip()
if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
break
para.append(tl.strip_inline_md(nxt))
para.append(nxt)
j += 1
text = " ".join(para)
_add_text(st, tl.wrap(text, tl.chars_per_line(_USABLE_W, _FS_BODY)),
_add_rich_text(st, tl.wrap_rich(text, tl.chars_per_line(_USABLE_W, _FS_BODY)),
_FS_BODY, _INK)
i = j
st.y += _GAP
@@ -15,8 +15,15 @@ overflowing — that is wrapping, not loss: every character is still rendered.
from __future__ import annotations
import re
import textwrap
# Inline span markers: ``**bold**`` / ``__bold__`` (rendered bold) and
# `` `code` `` (markers removed, not styled). Matched non-greedily so the
# shortest balanced pair wins. Unbalanced leftovers are stripped afterwards so
# the visible text matches ``strip_inline_md`` exactly.
_INLINE_SPAN_RE = re.compile(r"(\*\*.+?\*\*|__.+?__|`.+?`)")
def avg_char_width_in(fontsize_pt: float) -> float:
"""Approximate average glyph width in inches for a sans-serif font.
@@ -84,6 +91,137 @@ def strip_inline_md(text: str) -> str:
return s
def _strip_leftover_markers(s: str) -> str:
"""Drop any unbalanced inline markers from a plain (non-span) fragment.
Keeps the visible text identical to :func:`strip_inline_md` even when a
``**`` / ``__`` / `` ` `` has no matching closing marker.
"""
for marker in ("**", "__", "`"):
s = s.replace(marker, "")
return s
def parse_inline_bold(text: str):
"""Split ``text`` into ``[(fragment, is_bold), ...]`` preserving order.
``**...**`` and ``__...__`` spans become bold fragments (markers removed);
`` `code` `` keeps its text without the backticks and is not bold; any other
text is emitted verbatim with unbalanced markers stripped. The concatenation
of all fragment texts equals :func:`strip_inline_md` of the input — so the
*visible* characters (and therefore line wrapping) are unchanged; only the
bold flag is added. Adjacent fragments of the same weight are merged.
"""
s = "" if text is None else str(text)
if not s:
return []
out = []
def _emit(fragment: str, bold: bool) -> None:
if fragment == "":
return
if out and out[-1][1] == bold:
out[-1] = (out[-1][0] + fragment, bold)
else:
out.append((fragment, bold))
pos = 0
for m in _INLINE_SPAN_RE.finditer(s):
if m.start() > pos:
_emit(_strip_leftover_markers(s[pos:m.start()]), False)
tok = m.group(0)
if tok.startswith("**") and tok.endswith("**"):
_emit(tok[2:-2], True)
elif tok.startswith("__") and tok.endswith("__"):
_emit(tok[2:-2], True)
else: # `code`
_emit(tok[1:-1], False)
pos = m.end()
if pos < len(s):
_emit(_strip_leftover_markers(s[pos:]), False)
return out
def _hard_split(word: str, max_chars: int):
"""Split a single long token into <= max_chars chunks (never loses chars)."""
return [word[i:i + max_chars] for i in range(0, len(word), max_chars)] or [""]
def wrap_rich(text: str, max_chars: int):
"""Word-wrap ``text`` to ``max_chars`` while preserving inline bold spans.
Returns ``list[list[(fragment, is_bold)]]`` — one inner list of styled
fragments per output line; concatenating an inner list's fragment texts is
the visible line. Wrapping is word-aware and hard-splits over-long tokens, so
no line exceeds ``max_chars`` (the renderers measure these very lines, so the
no-cut guarantee holds). Bold spans never widen a line: only the bold flag is
carried, the visible width is identical to :func:`wrap`.
"""
if max_chars < 1:
max_chars = 1
spans = parse_inline_bold(text)
if not spans:
return [[("", False)]]
# Flatten to (word, is_bold) tokens, honoring hard newlines as line breaks.
# A token list of None marks a forced line break.
tokens = [] # each: (word, bold) or ("\n", None)
for frag, bold in spans:
parts = frag.split("\n")
for pi, part in enumerate(parts):
if pi > 0:
tokens.append(("\n", None))
for word in part.split(" "):
if word == "":
continue
tokens.append((word, bold))
lines = [] # list[list[(seg, bold)]]
cur = [] # list[(word, bold)]
cur_len = 0
def _flush():
nonlocal cur, cur_len
# Merge adjacent same-weight words (with separating spaces) into segments.
merged = []
for k, (word, bold) in enumerate(cur):
piece = word if k == 0 else " " + word
if merged and merged[-1][1] == bold:
merged[-1] = (merged[-1][0] + piece, bold)
else:
merged.append((piece, bold))
lines.append(merged or [("", False)])
cur = []
cur_len = 0
for word, bold in tokens:
if bold is None: # forced newline
_flush()
continue
if len(word) > max_chars:
if cur:
_flush()
chunks = _hard_split(word, max_chars)
for ci, chunk in enumerate(chunks):
if ci < len(chunks) - 1:
lines.append([(chunk, bold)])
else:
cur = [(chunk, bold)]
cur_len = len(chunk)
continue
add = len(word) if cur_len == 0 else cur_len + 1 + len(word)
if cur_len != 0 and add > max_chars:
_flush()
cur = [(word, bold)]
cur_len = len(word)
else:
cur.append((word, bold))
cur_len = add
if cur:
_flush()
return lines or [[("", False)]]
def parse_md_table(lines: list):
"""Parse consecutive ``| a | b |`` lines into ``(header, rows)`` or None.
@@ -0,0 +1,114 @@
---
name: build_eda_render_ctx
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def build_eda_render_ctx(db_path: str, table: str, profile: dict, backend: str = 'duckdb', sample: int = 5000, base_ctx: dict = None) -> dict"
description: "Constructor del `ctx` de datos crudos del motor AutomaticEDA. Dado un db_path+table (DuckDB o Postgres) y el TableProfile AGREGADO ya calculado por profile_table, produce el dict ctx que los renderers (render_automatic_eda_pdf/_pptx -> build_document(profile, ctx)) pasan a los capitulos que necesitan DATOS CRUDOS no presentes en el perfil agregado: modelos (project_clusters_2d en vivo), timeseries, geospatial y agregacion (groupby/pivot push-down). NO trae tablas enteras a RAM: muestrea con LIMIT sample y delega el push-down de la serie en extract_timeseries_raw. Construye el lector read-only query_fn(sql)->dict igual que profile_table (closure sobre duckdb_query_readonly / pg_query). Estilo dict-no-throw del grupo eda: NUNCA lanza; si una pieza falla, degrada esa clave a ausente/[] y sigue. Devuelve el ctx dict directamente (NO un wrapper {status,...}); se pasa tal cual como meta={'ctx': <ese dict>}. Claves de datos que produce: raw_numeric (muestra cruda alineada por fila), timeseries_raw (fechas+series), geo_points (lats/lons) y db_path+table para el push-down de agregacion. Respeta base_ctx: parte de una copia y solo AÑADE las claves de datos; las de presentacion (dataset_name, source_origin, ...) no se pisan."
tags: [eda, datascience, automatic-eda, render, ctx, extraction, read-only, duckdb, postgres, python]
uses_functions: [detect_time_column_py_datascience, extract_timeseries_raw_py_datascience, detect_latlon_columns_py_datascience, duckdb_query_readonly_py_infra, pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: db_path
desc: "ruta al archivo DuckDB, o DSN PostgreSQL si backend='postgres'. Se guarda tal cual en ctx['db_path'] (el capitulo agregacion lo usa para el groupby/pivot push-down via DuckDB) y se inyecta en el closure query_fn. No se valida aqui: si la base no existe, las queries devuelven status error y las claves de datos se omiten."
- name: table
desc: "nombre de la tabla. Se escapa con comillas dobles en las queries (raw_numeric y timeseries) y se guarda en ctx['table']."
- name: profile
desc: "TableProfile AGREGADO producido por profile_table. Solo se lee su clave `columns` (lista de ColumnProfile dict con name / inferred_type / numeric.{min,max} / semantic_type). Lectura defensiva: si no es dict o no tiene columns, se trata como []. NO se traen las filas crudas de aqui — se muestrean de la base."
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el lector read-only del registry (duckdb_query_readonly / pg_query). Cualquier otro valor devuelve el base_ctx tal cual, SIN añadir claves de datos (ni siquiera db_path/table)."
- name: sample
desc: "maximo de filas a muestrear (clausula LIMIT) tanto para raw_numeric (una sola query SELECT de las numericas) como para timeseries_raw (max_rows de extract_timeseries_raw). Default 5000. Acota memoria y tiempo de render."
- name: base_ctx
desc: "dict opcional con claves de PRESENTACION ya preparadas (dataset_name, source_origin, ...). Se parte de una copia y NO se pisan sus claves; solo se añaden las de datos. Default None -> {}."
output: "El dict `ctx` directamente (NO un wrapper {status,...}); se pasa tal cual como meta={'ctx': <ese dict>} a render_automatic_eda_pdf/pptx. Nunca lanza. Para backends validos contiene SIEMPRE db_path + table, y opcionalmente: raw_numeric {col:[float|None,...]} (muestra cruda alineada por fila; omitida si no hay numericas o falla la query), timeseries_raw {time_col, t:[iso...], series:{col:[float|None,...]}} (solo si hay columna temporal + numericas y trae filas), geo_points {lats:[...], lons:[...]} (solo si se detecta par lat/lon y ambas estan en raw_numeric). Ante fallo global devuelve al menos {**base_ctx, 'db_path': db_path, 'table': table}. Backend desconocido -> base_ctx tal cual sin claves de datos."
tested: true
tests: ["test_db_path_y_table_en_ctx", "test_raw_numeric_con_columnas_numericas", "test_timeseries_raw_con_fecha", "test_geo_points_con_latlon", "test_sin_fecha_no_hay_timeseries", "test_base_ctx_preservado"]
test_file_path: "python/functions/datascience/build_eda_render_ctx_test.py"
file_path: "python/functions/datascience/build_eda_render_ctx.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import build_eda_render_ctx, render_automatic_eda_pdf
from datascience import profile_table # opcional: para obtener el TableProfile
# 1) Perfil agregado de la tabla (push-down, sin RAM).
prof = profile_table("data/ventas.duckdb", "ventas_geo", write_report=False)["profile"]
# 2) ctx de datos crudos para los capitulos (muestrea con LIMIT, no carga todo).
ctx = build_eda_render_ctx(
"data/ventas.duckdb", "ventas_geo", prof,
backend="duckdb", sample=5000,
base_ctx={"dataset_name": "Ventas con geolocalizacion"},
)
# ctx == {
# "dataset_name": "Ventas con geolocalizacion", # preservado del base_ctx
# "db_path": "data/ventas.duckdb", "table": "ventas_geo",
# "raw_numeric": {"ventas": [1200.5, ...], "lat": [40.41, ...], "lon": [-3.70, ...]},
# "timeseries_raw": {"time_col": "fecha", "t": ["2024-01-01", ...], "series": {...}},
# "geo_points": {"lats": [40.41, ...], "lons": [-3.70, ...]},
# }
# 3) Se entrega tal cual a los renderers via meta={"ctx": ctx}.
render_automatic_eda_pdf(prof, "reports/eda.pdf", meta={"ctx": ctx})
```
## Cuando usarla
Justo antes de renderizar un AutomaticEDA (PDF o PPTX), cuando ya tienes el
TableProfile AGREGADO de `profile_table` pero los capitulos de modelos,
timeseries, geospatial y agregacion necesitan DATOS CRUDOS que el perfil
agregado no lleva (la muestra numerica alineada por fila, la serie cronologica,
el par lat/lon, y el db_path/table para el push-down del groupby/pivot). Es el
puente entre el perfil agregado y `build_document(profile, ctx)`: una sola
llamada produce el `ctx` completo muestreando con `LIMIT` en vez de cargar la
tabla entera en memoria.
## Gotchas
- **Impura**: lee de la base de datos a traves de `query_fn` (closure sobre
`duckdb_query_readonly` / `pg_query`). No abre conexiones fuera de esos
wrappers del registry. Estilo dict-no-throw del grupo `eda`: NUNCA lanza; ante
cualquier fallo (query, deteccion, render de una clave) degrada esa clave a
ausente/`[]` y sigue. Ante un fallo global devuelve al menos
`{**base_ctx, "db_path": db_path, "table": table}`.
- **`error_type` en el frontmatter es `error_go_core` por convencion del
registry** (toda funcion impura debe declararlo y el indexer lo exige), pero el
codigo NO lanza esa excepcion: degrada al ctx parcial. Es metadata, no
comportamiento.
- **Devuelve el ctx dict directamente, NO un wrapper `{status,...}`**: a
diferencia de `extract_timeseries_raw` / `profile_table`, esta funcion es el
ultimo eslabon antes del render y su salida se pasa tal cual como
`meta={"ctx": <ese dict>}`. No envuelvas su retorno.
- **Backend desconocido**: con un `backend` que no sea `duckdb` ni `postgres`
devuelve el `base_ctx` tal cual, SIN claves de datos (ni siquiera
`db_path`/`table`). Comprueba el backend antes si dependes de esas claves.
- **Alineacion por fila de `raw_numeric`**: `raw_numeric[col]` tiene una entrada
por fila muestreada (un valor no convertible a float queda como `None`, no se
descarta la fila) porque `project_clusters_2d` descarta filas listwise: todas
las columnas deben tener la MISMA longitud. `geo_points` se construye desde
`raw_numeric` para heredar esa alineacion.
- **`geo_points` exige lat/lon en `raw_numeric`**: el par lat/lon solo se adjunta
si ambas columnas se detectaron (nombre+rango) Y figuran en `raw_numeric`
(es decir, son numericas en el perfil). Si la tabla guarda lat/lon como texto
no promovido a numeric, no apareceran; el capitulo geospatial sabe degradar.
- **`timeseries_raw` depende del orden del backend**: hereda el `ORDER BY
"time_col"` de `extract_timeseries_raw`. Si la columna temporal esta guardada
como texto no ordenable lexicograficamente (p.ej. `DD/MM/YYYY`), el orden no
sera el cronologico real — normaliza la columna a date/timestamp antes.
- **`LIMIT sample`**: con tablas grandes obtienes el primer tramo (raw_numeric
por orden fisico, timeseries por orden cronologico), no un muestreo uniforme.
Sube `sample` si necesitas mas cobertura.
- **No loguear los datos crudos**: `raw_numeric` / `timeseries_raw` /
`geo_points` pueden contener datos sensibles. En trazas usa solo conteos y
nombres de columna, no el ctx completo.
@@ -0,0 +1,200 @@
"""build_eda_render_ctx — constructor del `ctx` de datos crudos del motor AutomaticEDA.
Funcion impura (lee de la base de datos) del grupo de capacidad `eda`. Dado un
``db_path`` + ``table`` (DuckDB o PostgreSQL) y el ``TableProfile`` AGREGADO ya
calculado por ``profile_table``, produce el dict ``ctx`` que los renderers
(``render_automatic_eda_pdf`` / ``render_automatic_eda_pptx`` ->
``build_document(profile, ctx)``) pasan a los capitulos que necesitan DATOS
CRUDOS no presentes en el perfil agregado: modelos (``project_clusters_2d`` en
vivo), timeseries, geospatial y agregacion (groupby/pivot push-down).
NO trae tablas enteras a RAM: muestrea con ``LIMIT sample`` y, para la serie
temporal, delega el push-down en ``extract_timeseries_raw`` (una sola query
ordenada). El lector read-only ``query_fn(sql) -> dict`` se construye igual que
en ``profile_table`` (un closure sobre ``duckdb_query_readonly`` / ``pg_query``)
y nunca abre conexiones fuera de esos wrappers.
Estilo dict-no-throw del grupo `eda`: la funcion NUNCA lanza. Si una pieza falla
(query, deteccion, render de una clave), esa clave se degrada a ausente / lista
vacia y el resto del ctx se construye igual. Ante un fallo global devuelve al
menos ``{**base_ctx, "db_path": db_path, "table": table}``.
Claves de DATOS que produce (las consumen los capitulos):
- ``raw_numeric`` : {col: [float|None, ...]} muestra cruda de las columnas
numericas, ALINEADA POR FILA (una entrada por fila aunque
sea None). La leen modelos (clustering 2D en vivo) y
geospatial (lat/lon salen de aqui).
- ``timeseries_raw`` : {time_col, t: [iso...], series: {col: [float|None, ...]}}.
La lee el capitulo TIMESERIES.
- ``geo_points`` : {lats: [...], lons: [...]} listas alineadas (ya floats).
La lee el capitulo GEOSPATIAL.
- ``db_path``, ``table`` : los usa el capitulo AGREGACION para el groupby/pivot
push-down via DuckDB.
Las claves de PRESENTACION que traiga ``base_ctx`` (dataset_name, source_origin,
...) NO se pisan: esta funcion solo AÑADE las claves de datos sobre una copia.
"""
def _to_float(value):
"""Convierte un valor a float de forma defensiva. None si no es convertible.
Un bool es subclase de int en Python pero nunca es un valor numerico de
serie/coordenada valido, asi que se trata como None (mismo criterio que
extract_timeseries_raw / detect_latlon_columns).
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if not s:
return None
try:
return float(s)
except (TypeError, ValueError):
return None
def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None):
"""Construye el ctx de datos crudos para los renderers de AutomaticEDA.
Args:
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
Se guarda tal cual en ctx["db_path"] (el capitulo agregacion lo usa
para el push-down).
table: nombre de la tabla. Se escapa con comillas dobles en las queries y
se guarda en ctx["table"].
profile: TableProfile agregado producido por profile_table. Solo se lee
su clave ``columns`` (lista de ColumnProfile dict con name /
inferred_type / numeric.{min,max} / semantic_type). Lectura
defensiva: si no es dict o no tiene columns, se trata como [].
backend: "duckdb" (default) o "postgres". Selecciona el lector read-only
(duckdb_query_readonly / pg_query). Cualquier otro valor devuelve el
base_ctx tal cual, sin añadir claves de datos.
sample: maximo de filas a muestrear (clausula LIMIT) tanto para
raw_numeric como para timeseries_raw. Default 5000.
base_ctx: dict opcional con claves de presentacion ya preparadas
(dataset_name, source_origin, ...). Se parte de una copia y NO se
pisan sus claves; solo se añaden las de datos. Default None -> {}.
Returns:
El dict ``ctx`` directamente (NO un wrapper {status,...}): se pasa tal
cual como ``meta={"ctx": <ese dict>}`` a render_automatic_eda_pdf/pptx.
Nunca lanza. Claves que puede contener: raw_numeric, timeseries_raw,
geo_points (omitidas si no aplican o fallan), y siempre db_path + table
para backends validos.
"""
# Copia de base_ctx: nunca mutamos el dict del caller. Las claves de
# presentacion que ya traiga se conservan; las de datos se añaden encima.
ctx = dict(base_ctx) if isinstance(base_ctx, dict) else {}
try:
# 1) Lector read-only del backend activo, construido EXACTAMENTE como en
# profile_table (closure sobre el wrapper del registry). Imports perezosos
# dentro de la funcion: este modulo vive en el paquete `datascience`, asi
# que importar sus hermanas a nivel de modulo crearia un ciclo al cargar
# el __init__ del paquete. Lazy import rompe el ciclo y respeta el
# contrato (imports explicitos, sin `import *`).
if backend == "duckdb":
from infra import duckdb_query_readonly
def query_fn(sql):
return duckdb_query_readonly(db_path, sql)
elif backend == "postgres":
from infra import pg_query
def query_fn(sql):
return pg_query(db_path, sql)
else:
# Backend desconocido: devolver base_ctx tal cual, sin claves de datos.
return ctx
# 7) db_path + table SIEMPRE (para backends validos): el capitulo
# agregacion los necesita para el groupby/pivot push-down via DuckDB.
ctx["db_path"] = db_path
ctx["table"] = table
# 2) Columnas del perfil agregado (lectura defensiva).
cols = profile.get("columns") if isinstance(profile, dict) else None
cols = cols or []
# 3) Deteccion temporal/numerica con la funcion PURA del registry.
from datascience import detect_time_column
det = detect_time_column(cols)
time_col = det.get("time_col")
numeric_cols = det.get("numeric_cols") or []
# 4) raw_numeric: muestra de las columnas numericas CRUDAS, ALINEADAS POR
# FILA en UNA sola query. Cada columna queda con una entrada por fila
# (None si no parsea) para no desalinear filas: project_clusters_2d
# descarta filas listwise, asi que las listas deben tener igual longitud.
raw_numeric = {}
if numeric_cols:
try:
cols_sql = ", ".join(f'"{c}"' for c in numeric_cols)
sql = f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
q = query_fn(sql)
if isinstance(q, dict) and q.get("status") == "ok":
rows = q.get("rows", []) or []
raw_numeric = {c: [] for c in numeric_cols}
for row in rows:
for c in numeric_cols:
raw_numeric[c].append(_to_float(row.get(c)))
except Exception: # noqa: BLE001 - dict-no-throw: degradar la clave
raw_numeric = {}
if raw_numeric:
ctx["raw_numeric"] = raw_numeric
# 5) timeseries_raw: SOLO si hay columna temporal y numericas. Se delega
# el push-down en la funcion impura extract_timeseries_raw (una sola query
# ordenada cronologicamente). Solo se adjunta si trae filas.
if time_col and numeric_cols:
try:
from datascience import extract_timeseries_raw
ts = extract_timeseries_raw(
query_fn, table, time_col, numeric_cols, max_rows=sample
)
if (
isinstance(ts, dict)
and ts.get("status") == "ok"
and (ts.get("n") or 0) > 0
):
ctx["timeseries_raw"] = {
"time_col": ts["time_col"],
"t": ts["t"],
"series": ts["series"],
}
except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave
pass
# 6) geo_points: detecta el par lat/lon con la funcion PURA del registry.
# Solo se adjunta si AMBAS columnas estan en raw_numeric (ya floats,
# alineadas por fila). Si no hay par o no estan, se omite: el capitulo
# geospatial sabe degradar.
try:
from datascience import detect_latlon_columns
geo = detect_latlon_columns(cols)
lat_col = geo.get("lat_col")
lon_col = geo.get("lon_col")
if lat_col and lon_col and lat_col in raw_numeric and lon_col in raw_numeric:
ctx["geo_points"] = {
"lats": raw_numeric[lat_col],
"lons": raw_numeric[lon_col],
}
except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave
pass
return ctx
except Exception: # noqa: BLE001 - dict-no-throw global: nunca reventar.
# Fallback minimo: copia de base_ctx + db_path/table para que el capitulo
# agregacion siga teniendo lo imprescindible.
out = dict(base_ctx) if isinstance(base_ctx, dict) else {}
out["db_path"] = db_path
out["table"] = table
return out
@@ -0,0 +1,153 @@
"""Tests para build_eda_render_ctx.
Self-contained: crea un DuckDB temporal pequeño con una columna fecha, varias
numericas y un par lat/lon, construye un TableProfile minimo a mano (con la forma
de columnas del grupo `eda`: name / inferred_type / numeric.{min,max} /
semantic_type) y verifica que el ctx producido contiene las claves de datos que
consumen los capitulos del AutomaticEDA.
"""
import os
import sys
# El test importa funciones del registry como una app del registry: inserta el
# directorio raiz `python/functions` en sys.path y luego `from datascience import`.
_FUNCTIONS_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _FUNCTIONS_ROOT not in sys.path:
sys.path.insert(0, _FUNCTIONS_ROOT)
import duckdb # noqa: E402
from datascience import build_eda_render_ctx # noqa: E402
_TABLE = "ventas_geo"
# Filas: fecha creciente, 2 columnas numericas (ventas, unidades) y un par lat/lon
# (Madrid -> lat ~40, lon ~-3, dentro de [-90,90] y [-180,180]).
_ROWS = [
("2024-01-01", 1200.5, 12, 40.41, -3.70),
("2024-01-02", 980.0, 9, 41.38, 2.17),
("2024-01-03", 1500.25, 15, 37.39, -5.99),
("2024-01-04", 1100.0, 11, 39.47, -0.38),
("2024-01-05", 1750.75, 18, 43.26, -2.93),
]
def _make_db(tmp_path):
"""Crea un DuckDB temporal con la tabla de prueba y devuelve su ruta."""
db_path = os.path.join(str(tmp_path), "eda_ctx.duckdb")
con = duckdb.connect(db_path)
try:
con.execute(
f'CREATE TABLE "{_TABLE}" '
"(fecha DATE, ventas DOUBLE, unidades INTEGER, lat DOUBLE, lon DOUBLE)"
)
con.executemany(
f'INSERT INTO "{_TABLE}" VALUES (?, ?, ?, ?, ?)', _ROWS
)
finally:
con.close()
return db_path
def _profile_with_date():
"""TableProfile minimo con columna fecha + numericas + lat/lon."""
return {
"columns": [
{"name": "fecha", "inferred_type": "datetime", "semantic_type": "datetime_iso"},
{
"name": "ventas",
"inferred_type": "numeric",
"semantic_type": "decimal",
"numeric": {"min": 980.0, "max": 1750.75},
},
{
"name": "unidades",
"inferred_type": "numeric",
"semantic_type": "integer",
"numeric": {"min": 9, "max": 18},
},
{
"name": "lat",
"inferred_type": "numeric",
"semantic_type": "decimal",
"numeric": {"min": 37.39, "max": 43.26},
},
{
"name": "lon",
"inferred_type": "numeric",
"semantic_type": "decimal",
"numeric": {"min": -5.99, "max": 2.17},
},
]
}
def _profile_without_date():
"""Mismo perfil pero SIN columna temporal (solo numericas)."""
prof = _profile_with_date()
prof["columns"] = [c for c in prof["columns"] if c["name"] != "fecha"]
return prof
def test_db_path_y_table_en_ctx(tmp_path):
db_path = _make_db(tmp_path)
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date())
assert ctx["db_path"] == db_path
assert ctx["table"] == _TABLE
def test_raw_numeric_con_columnas_numericas(tmp_path):
db_path = _make_db(tmp_path)
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date())
raw = ctx["raw_numeric"]
# Las 4 columnas numericas (ventas, unidades, lat, lon), listas no vacias y
# alineadas por fila (misma longitud == nº de filas).
for col in ("ventas", "unidades", "lat", "lon"):
assert col in raw
assert len(raw[col]) == len(_ROWS)
assert raw["ventas"][0] == 1200.5
assert raw["unidades"][0] == 12.0 # int promovido a float
def test_timeseries_raw_con_fecha(tmp_path):
db_path = _make_db(tmp_path)
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date())
ts = ctx["timeseries_raw"]
assert ts["time_col"] == "fecha"
assert len(ts["t"]) == len(_ROWS) # fechas ISO no vacias
# Las numericas aparecen como series paralelas a t.
for col in ("ventas", "unidades", "lat", "lon"):
assert col in ts["series"]
assert len(ts["series"][col]) == len(_ROWS)
def test_geo_points_con_latlon(tmp_path):
db_path = _make_db(tmp_path)
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date())
geo = ctx["geo_points"]
assert len(geo["lats"]) == len(_ROWS)
assert len(geo["lons"]) == len(_ROWS)
# Listas alineadas, ya floats, leidas de raw_numeric.
assert geo["lats"][0] == 40.41
assert geo["lons"][0] == -3.70
def test_sin_fecha_no_hay_timeseries(tmp_path):
db_path = _make_db(tmp_path)
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_without_date())
assert "timeseries_raw" not in ctx
# raw_numeric y geo_points siguen presentes (no dependen de la fecha).
assert "raw_numeric" in ctx
assert "geo_points" in ctx
def test_base_ctx_preservado(tmp_path):
db_path = _make_db(tmp_path)
base = {"dataset_name": "ventas_geo_demo", "source_origin": "test"}
ctx = build_eda_render_ctx(db_path, _TABLE, _profile_with_date(), base_ctx=base)
# Las claves de presentacion del base_ctx no se pisan.
assert ctx["dataset_name"] == "ventas_geo_demo"
assert ctx["source_origin"] == "test"
# Y las de datos se añaden encima.
assert ctx["db_path"] == db_path
assert "raw_numeric" in ctx
+7 -2
View File
@@ -5,7 +5,7 @@ lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
signature: "def profile_table(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = False, run_llm: bool = False, run_series: bool = False, emit_pdf: bool = False, emit_automatic: bool = False, report_dir: str = \"reports\", write_report: bool = True) -> dict"
description: "Orquestador one-shot del grupo de capacidad eda: perfila UNA tabla (DuckDB o PostgreSQL) end-to-end componiendo las funciones del grupo (perfil base SQL + muestreo read-only + inferencia semantica + promocion de tipo + estadistica numerica/categorica + score de calidad + correlaciones con correccion FDR + re-expresion de Tukey + avisos exploratorios) y, opcional, modelos baratos (run_models), interpretacion LLM (run_llm) y analisis de serie temporal por columna (run_series: estacionariedad ADF+KPSS, ACF/PACF, STL, retornos). Emite el TableProfile completo mas (opcional) report markdown + JSON sidecar + PDF movil (emit_pdf). Es la composicion canonica para hazme un EDA de esta tabla."
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
uses_functions:
@@ -26,6 +26,9 @@ uses_functions:
- exploratory_caveats_py_datascience
- render_eda_markdown_py_datascience
- render_eda_pdf_py_datascience
- build_eda_render_ctx_py_datascience
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
- duckdb_query_readonly_py_infra
- pg_query_py_infra
uses_types: []
@@ -55,11 +58,13 @@ params:
desc: "Si True (default False) calcula por columna numerica un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF, STL y, si parece de niveles, retornos). Ordena por la primera columna datetime si existe; si no, por el orden fisico. Guardado en col['series'] y agregado en prof['series']."
- name: emit_pdf
desc: "Si True (default False) renderiza un PDF multipagina vertical (legible en movil) del perfil junto al report markdown y devuelve su ruta en pdf_path."
- name: emit_automatic
desc: "Si True (default False) emite ADEMAS el informe AutomaticEDA completo en PDF (A5 movil) Y PPTX (16:9) con los 11 capitulos del motor; construye el ctx de datos crudos con build_eda_render_ctx para que modelos/timeseries/geospatial/agregacion salgan poblados. Aditivo: no sustituye a emit_pdf. Rutas en aeda_pdf_path / aeda_pptx_path / aeda_manifest_path."
- name: report_dir
desc: "Directorio donde escribir los reports si write_report (y el PDF si emit_pdf). Default 'reports'. Se crea si no existe."
- name: write_report
desc: "Si True (default) escribe report markdown + JSON sidecar timestamped en report_dir; si False no toca disco y los paths markdown/json del retorno son None (emit_pdf es independiente)."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None} o {status:'error', error:str} (dict-no-throw)."
output: "dict {status:'ok', profile:<TableProfile enriquecido con quality_score, key_candidates, type_breakdown recalculado, correlaciones con FDR, reexpression por columna numerica, caveats, y (con run_series) series>, report_md_path:str|None, report_json_path:str|None, pdf_path:str|None, aeda_pdf_path:str|None, aeda_pptx_path:str|None, aeda_manifest_path:str|None (estos tres solo con emit_automatic)} o {status:'error', error:str} (dict-no-throw)."
---
## Ejemplo
@@ -32,11 +32,14 @@ from datascience import (
acf_pacf,
adf_kpss_stationarity,
association_matrix,
build_eda_render_ctx,
column_quality_score,
describe_numeric,
eda_llm_insights,
exploratory_caveats,
infer_semantic_type,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
render_eda_markdown,
render_eda_pdf,
run_eda_models,
@@ -385,6 +388,7 @@ def profile_table(
run_llm: bool = False,
run_series: bool = False,
emit_pdf: bool = False,
emit_automatic: bool = False,
report_dir: str = "reports",
write_report: bool = True,
) -> dict:
@@ -412,6 +416,15 @@ def profile_table(
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
(legible en movil) del perfil junto al report markdown y devuelve su
ruta en pdf_path.
emit_automatic: si True (default False) emite ademas el informe
AutomaticEDA COMPLETO en sus dos formatos (PDF A5 movil + PPTX 16:9)
con los 11 capitulos del motor por capitulos. Construye el contexto
de datos crudos con build_eda_render_ctx (raw_numeric para modelos/
geo, timeseries_raw para series, geo_points para el mapa, db_path/
table para la agregacion push-down) para que los capitulos modelos/
timeseries/geospatial/agregacion salgan poblados, no degradados. Es
ADITIVO: no sustituye a emit_pdf (render_eda_pdf). Sus rutas vuelven
en aeda_pdf_path / aeda_pptx_path / aeda_manifest_path.
report_dir: directorio donde escribir los reports si write_report.
Default "reports". Se crea si no existe.
write_report: si True (default), escribe un report markdown + un JSON
@@ -727,12 +740,51 @@ def profile_table(
except Exception: # noqa: BLE001
pdf_path = None
# Informe AutomaticEDA completo (PDF + PPTX por capitulos). Aditivo:
# convive con emit_pdf (render_eda_pdf) sin sustituirlo. Construye el ctx
# con los datos crudos para que modelos/timeseries/geospatial/agregacion
# salgan poblados; degrada por clave si build_eda_render_ctx falla.
aeda_pdf_path = None
aeda_pptx_path = None
aeda_manifest_path = None
if emit_automatic:
try:
os.makedirs(report_dir, exist_ok=True)
base_ctx = {
"dataset_name": table,
"source_origin": db_path,
"storage": "DuckDB" if backend == "duckdb" else (
"PostgreSQL" if backend == "postgres" else backend),
}
if run_llm:
base_ctx.update({"run_cluster_llm": True,
"run_geo_llm": True, "run_agg_llm": True})
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx)
meta = {"title": f"EDA — {table}", "ctx": ctx}
aeda_pdf_target = os.path.join(report_dir,
f"aeda_{table}_{ts}.pdf")
aeda_pptx_target = os.path.join(report_dir,
f"aeda_{table}_{ts}.pptx")
rpdf = render_automatic_eda_pdf(prof, aeda_pdf_target, meta) or {}
rpptx = render_automatic_eda_pptx(
prof, aeda_pptx_target, meta) or {}
aeda_pdf_path = rpdf.get("path")
aeda_pptx_path = rpptx.get("path")
aeda_manifest_path = rpdf.get("manifest_path")
except Exception: # noqa: BLE001
pass
return {
"status": "ok",
"profile": prof,
"report_md_path": report_md_path,
"report_json_path": report_json_path,
"pdf_path": pdf_path,
"aeda_pdf_path": aeda_pdf_path,
"aeda_pptx_path": aeda_pptx_path,
"aeda_manifest_path": aeda_manifest_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,91 @@
---
name: render_automatic_eda
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = True, run_series: bool = True, run_llm: bool = False, out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions:
- profile_table_py_pipelines
- build_eda_render_ctx_py_datascience
- render_automatic_eda_pdf_py_datascience
- render_automatic_eda_pptx_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: true
tests:
- "render end-to-end sobre DuckDB sintetico con categoricas + fecha + lat/lon emite PDF y PPTX con paginas/slides"
test_file_path: "python/functions/pipelines/render_automatic_eda_test.py"
file_path: "python/functions/pipelines/render_automatic_eda.py"
params:
- name: db_path
desc: "Ruta al archivo DuckDB (read-only, debe existir) o DSN PostgreSQL si backend='postgres'."
- name: table
desc: "Nombre de la tabla a perfilar e informar."
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado y muestreo."
- name: sample
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default 5000."
- name: run_models
desc: "Si True (default) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA."
- name: run_series
desc: "Si True (default) calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries (la grafica de evolucion sale de los datos crudos del ctx aunque sea False)."
- name: run_llm
desc: "Si True (default False) hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red."
- name: out_dir
desc: "Directorio de salida (se crea si no existe). Default 'reports'."
- name: basename
desc: "Nombre base de los archivos sin extension. Default 'aeda_<table>_<timestamp>'."
- name: ctx_extra
desc: "Dict opcional con claves de presentacion/contexto extra que se mezclan en el ctx (dataset_name, description, source_origin, ...); no pisan las claves de datos calculadas por build_eda_render_ctx."
output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None, n_pages:int, n_slides:int, pdf_note:str, pptx_note:str, profile:<TableProfile>} o {status:'error', error:str} (dict-no-throw)."
---
## Ejemplo
```python
from pipelines.render_automatic_eda import render_automatic_eda
# Tabla DuckDB con categoricas + fecha + numericas: informe completo a reports/.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
run_models=True, run_series=True, out_dir="reports")
print(r["status"], r["pdf_path"], r["pptx_path"], r["n_pages"], r["n_slides"])
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 14 16
# Con narrativa LLM (titulos de segmento, descripcion geografica, etc.):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", run_llm=True)
```
## Cuando usarla
Cuando quieras el informe AutomaticEDA COMPLETO (PDF + PPTX) de una tabla en una
sola llamada, con los capitulos de modelos, series, geoespacial y agregacion ya
poblados (no degradados). Es el reemplazo de "perfila + monta el ctx a mano +
llama a los dos renderers": este pipeline orquesta `profile_table` ->
`build_eda_render_ctx` -> `render_automatic_eda_pdf`/`_pptx`. Usalo como
entregable para compartir un EDA, o como el motor detras de `profile_table(
emit_automatic=True)` y del skill `/eda`.
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
- `db_path` debe existir: DuckDB read-only no crea la base.
- `run_models=True` y `run_series=True` por defecto encarecen el perfil (PCA/
KMeans/IsolationForest + ADF/KPSS/STL por columna). Para un informe mas barato
ponlos a False: los capitulos modelos/timeseries se omiten o se reducen, pero
el resto del informe sale igual.
- `run_llm=True` hace llamadas de red (interpretacion del perfil + narrativa por
capitulo). Sin red, dejalo en False: los capitulos siguen completos con su
derivacion cuantitativa (titulos de segmento derivados, nota geografica
derivada, seleccion de agregaciones cuantitativa).
- El PPTX requiere `python-pptx`; si no esta instalado, `pptx_path` sera None y
`pptx_note` lo explica (el PDF se emite igual).
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla
entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria).
@@ -0,0 +1,157 @@
"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX.
Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus dos formatos a la
vez (PDF móvil A5 + PPTX 16:9) con los 11 capítulos POBLADOS, en una sola
llamada. Compone, sin reimplementar su lógica, cuatro funciones del registry:
- profile_table : perfila la tabla end-to-end (TableProfile agregado),
opcionalmente con modelos baratos y análisis de serie.
- build_eda_render_ctx : construye el `ctx` con los DATOS CRUDOS que el
TableProfile agregado no incluye (raw_numeric para
modelos/geo, timeseries_raw para series, geo_points
para el mapa, db_path/table para la agregación
push-down). Sin él, esos capítulos degradan.
- render_automatic_eda_pdf : renderiza el documento por capítulos a PDF.
- render_automatic_eda_pptx : renderiza el mismo documento a PPTX.
El TableProfile agregado basta para portada/overview/distribuciones/calidad/
correlación, pero los capítulos `modelos`, `timeseries`, `geospatial` y
`agregacion` necesitan datos crudos (los clusters proyectados sobre el PCA, la
serie valor-vs-tiempo, los puntos lat/lon, las filas para el groupby/pivot).
`build_eda_render_ctx` los muestrea (LIMIT + push-down, sin traer la tabla
entera a RAM) y los entrega en `ctx`; este pipeline los pasa como `meta['ctx']`
a ambos renderers para que el informe salga completo.
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 (
build_eda_render_ctx,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
)
from pipelines.profile_table import profile_table
# Tokens de almacenamiento por backend (para la portada del informe).
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}
def render_automatic_eda(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = True,
run_series: bool = True,
run_llm: bool = False,
out_dir: str = "reports",
basename: str = None,
ctx_extra: dict = None,
) -> dict:
"""Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).
Args:
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres".
sample: máximo de filas/valores muestreados por columna para el perfil
y para los datos crudos del ctx (LIMIT). Default 5000.
run_models: si True (default) corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad). Necesario para que el
capítulo `modelos` pinte los clusters sobre el plano PCA.
run_series: si True (default) calcula el análisis de serie temporal por
columna numérica. Necesario para el análisis del capítulo
`timeseries` (la gráfica de evolución sale de los datos crudos del
ctx aunque run_series sea False).
run_llm: si True (default False) hace la interpretación LLM del perfil y
ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
agregacion (títulos de segmento, descripción de la zona, selección de
agregaciones). Con False esos capítulos usan su derivación
cuantitativa (siguen completos, sin llamadas de red).
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_<table>_<timestamp>".
ctx_extra: dict opcional con claves de presentación/contexto extra que se
mezclan en el ctx (p.ej. dataset_name, description, source_origin).
No pisan las claves de datos calculadas por build_eda_render_ctx.
Returns:
dict (nunca lanza). En éxito::
{"status": "ok", "pdf_path": str, "pptx_path": str,
"manifest_path": str|None, "n_pages": int, "n_slides": int,
"pdf_note": str, "pptx_note": str, "profile": <TableProfile>}
En error: {"status": "error", "error": str}.
"""
try:
# 1) Perfil base + modelos/serie opcionales. No escribe report propio
# (write_report=False): este pipeline emite su propio par PDF/PPTX.
pres = profile_table(
db_path,
table,
backend=backend,
sample=sample,
run_models=run_models,
run_llm=run_llm,
run_series=run_series,
emit_pdf=False,
write_report=False,
)
if pres.get("status") != "ok":
return {"status": "error",
"error": f"profile_table falló: {pres.get('error')}"}
prof = pres.get("profile") or {}
# 2) Contexto de presentación + datos crudos para los 4 capítulos que los
# necesitan. Las claves de presentación van en base_ctx; build_eda_render_ctx
# añade raw_numeric / timeseries_raw / geo_points / db_path / table.
base_ctx = {
"dataset_name": table,
"source_origin": db_path,
"storage": _STORAGE.get(backend, backend),
}
if run_llm:
# Activa la narrativa LLM de los capítulos que la soportan.
base_ctx.update({
"run_cluster_llm": True,
"run_geo_llm": True,
"run_agg_llm": True,
})
if ctx_extra:
base_ctx.update(ctx_extra)
ctx = build_eda_render_ctx(
db_path, table, prof, backend=backend, sample=sample,
base_ctx=base_ctx,
)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = basename or f"aeda_{table}_{ts}"
pdf_path = os.path.join(out_dir, base + ".pdf")
pptx_path = os.path.join(out_dir, base + ".pptx")
meta = {"title": f"EDA — {table}", "ctx": ctx}
rpdf = render_automatic_eda_pdf(prof, pdf_path, meta) or {}
rpptx = render_automatic_eda_pptx(prof, pptx_path, meta) or {}
return {
"status": "ok",
"pdf_path": rpdf.get("path"),
"pptx_path": rpptx.get("path"),
"manifest_path": rpdf.get("manifest_path"),
"n_pages": rpdf.get("n_pages"),
"n_slides": rpptx.get("n_slides"),
"pdf_note": rpdf.get("note"),
"pptx_note": rpptx.get("note"),
"profile": prof,
}
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
return {"status": "error", "error": str(e)}
@@ -0,0 +1,91 @@
"""Test del pipeline render_automatic_eda — EDA completo a PDF + PPTX.
Self-contained: crea un DuckDB temporal pequeño con categóricas + fecha + lat/lon
+ varias numéricas, corre el pipeline (sin LLM) y verifica que emite PDF y PPTX
con páginas/slides, manifest, y que los capítulos dependientes de ctx quedan
POBLADOS (sin la nota de degradación).
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..")) # python/functions
if _FUNCTIONS not in sys.path:
sys.path.insert(0, _FUNCTIONS)
import duckdb # noqa: E402
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
def _make_db(path):
con = duckdb.connect(path)
con.execute(
"CREATE TABLE sales (d DATE, region VARCHAR, channel VARCHAR, "
"amount DOUBLE, units INTEGER, lat DOUBLE, lon DOUBLE)"
)
from datetime import date, timedelta
regions = ["norte", "sur", "este"]
channels = ["web", "tienda"]
centers = {"norte": (43.0, -3.0), "sur": (37.0, -5.0), "este": (39.5, -0.4)}
rows = []
d0 = date(2024, 1, 1)
for i in range(180):
r = regions[i % 3]
ch = channels[i % 2]
clat, clon = centers[r]
rows.append((
d0 + timedelta(days=i), r, ch,
round(100 + (i % 7) * 13.5 + (5 if ch == "web" else 0), 2),
10 + (i % 5),
round(clat + (i % 3) * 0.1, 4),
round(clon + (i % 4) * 0.1, 4),
))
con.executemany("INSERT INTO sales VALUES (?,?,?,?,?,?,?)", rows)
con.close()
def test_pipeline_emits_pdf_and_pptx_with_chapters(tmp_path):
db = str(tmp_path / "sales.duckdb")
_make_db(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "sales", run_models=True, run_series=True,
run_llm=False, out_dir=out, basename="test_sales")
assert r["status"] == "ok", r.get("error")
# Both formats produced.
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
assert (r["n_pages"] or 0) > 0
assert (r["n_slides"] or 0) > 0
# Per-chapter manifest written next to the output.
assert r["manifest_path"] and os.path.exists(r["manifest_path"])
def test_pipeline_chapters_populated_not_degraded(tmp_path):
"""The 4 ctx-dependent chapters build with real data (no degradation note)."""
import json
db = str(tmp_path / "sales.duckdb")
_make_db(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "sales", run_models=True, run_series=True,
run_llm=False, out_dir=out, basename="t2")
assert r["status"] == "ok"
# The manifest lists the ctx-dependent chapters as actually rendered.
with open(r["manifest_path"], encoding="utf-8") as fh:
man = json.load(fh)
chapters = man.get("chapters") or {}
for cid in ("modelos", "timeseries", "geospatial", "agregacion"):
assert cid in chapters, f"capítulo {cid} ausente del manifest: {list(chapters)}"
def test_pipeline_bad_db_degrades_without_raising(tmp_path):
r = render_automatic_eda(str(tmp_path / "nope.duckdb"), "ghost",
out_dir=str(tmp_path / "o"))
assert r["status"] == "error"
assert "error" in r