From f5b30b23dc5af0c96caafc4f3dde1e75d6efc8b1 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 30 Jun 2026 16:08:16 +0200 Subject: [PATCH 1/3] feat(eda): negrita inline real (**bold**) en renderers AutomaticEDA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El render de Markdown del motor AutomaticEDA quitaba los marcadores **negrita** sin aplicar estilo. Ahora los spans **bold**/__bold__ se renderizan en negrita real, de forma aditiva y sin romper el anti-corte: - text_layout.py: parse_inline_bold() tokeniza spans preservando el texto visible (== strip_inline_md) y wrap_rich() envuelve por palabras a max_chars conservando el flag de negrita por segmento (la anchura visible no cambia, así que la paginación es idéntica). - render_pdf_impl.py: _place_rich_lines() dibuja cada segmento con su fontweight avanzando x por el mismo grid de caracteres que usa el wrap (párrafos+bullets). - render_pptx_impl.py: _add_rich_text() usa runs nativos de python-pptx con font.bold por segmento (negrita real de PowerPoint). - bold_render_test.py: helpers puros (no-overflow, bold preservado, marcadores desbalanceados) + e2e que abre el .pptx y confirma un run con font.bold True. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../automatic_eda/bold_render_test.py | 113 ++++++++++++++ .../automatic_eda/render_pdf_impl.py | 50 +++++-- .../automatic_eda/render_pptx_impl.py | 51 ++++++- .../datascience/automatic_eda/text_layout.py | 138 ++++++++++++++++++ 4 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 python/functions/datascience/automatic_eda/bold_render_test.py diff --git a/python/functions/datascience/automatic_eda/bold_render_test.py b/python/functions/datascience/automatic_eda/bold_render_test.py new file mode 100644 index 00000000..dcd98628 --- /dev/null +++ b/python/functions/datascience/automatic_eda/bold_render_test.py @@ -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) diff --git a/python/functions/datascience/automatic_eda/render_pdf_impl.py b/python/functions/datascience/automatic_eda/render_pdf_impl.py index b7961b0c..fe8702ce 100644 --- a/python/functions/datascience/automatic_eda/render_pdf_impl.py +++ b/python/functions/datascience/automatic_eda/render_pdf_impl.py @@ -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 diff --git a/python/functions/datascience/automatic_eda/render_pptx_impl.py b/python/functions/datascience/automatic_eda/render_pptx_impl.py index 5494d604..db7d201a 100644 --- a/python/functions/datascience/automatic_eda/render_pptx_impl.py +++ b/python/functions/datascience/automatic_eda/render_pptx_impl.py @@ -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,22 +232,23 @@ 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)), - _FS_BODY, _INK) + _add_rich_text(st, tl.wrap_rich(text, tl.chars_per_line(_USABLE_W, _FS_BODY)), + _FS_BODY, _INK) i = j st.y += _GAP diff --git a/python/functions/datascience/automatic_eda/text_layout.py b/python/functions/datascience/automatic_eda/text_layout.py index dae00904..0d07d140 100644 --- a/python/functions/datascience/automatic_eda/text_layout.py +++ b/python/functions/datascience/automatic_eda/text_layout.py @@ -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. From f3d427d9e406db3c72d32fac6a78a42b89f9ad16 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 30 Jun 2026 16:08:41 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(eda):=20wiring=20AutomaticEDA=20?= =?UTF-8?q?=E2=80=94=20build=5Feda=5Frender=5Fctx=20+=20pipeline=20render?= =?UTF-8?q?=5Fautomatic=5Feda=20+=20profile=5Ftable(emit=5Fautomatic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conecta el motor AutomaticEDA con los datos crudos para que los 4 capítulos dependientes de ctx (modelos, timeseries, geospatial, agregacion) salgan POBLADOS en vez de degradar a una nota. - build_eda_render_ctx (datascience, impure, dict-no-throw): dado db_path+table y el TableProfile agregado, construye el ctx con los datos crudos que el perfil no incluye: raw_numeric {col:[float|None]} alineado por fila (modelos / geospatial), timeseries_raw {time_col,t,series} vía extract_timeseries_raw, geo_points {lats,lons} desde el par lat/lon detectado, y db_path/table para el groupby/pivot push-down de agregacion. Muestrea con LIMIT (no trae la tabla entera a RAM). Compone detect_time_column / extract_timeseries_raw / detect_latlon_columns / duckdb_query_readonly (imports lazy para evitar ciclo). - render_automatic_eda (pipeline): one-shot perfil -> ctx -> PDF + PPTX con los 11 capítulos poblados; devuelve rutas + manifest de versiones por capítulo. - profile_table: flag aditivo emit_automatic=True emite el AutomaticEDA PDF+PPTX además del flujo legacy (emit_pdf/render_eda_pdf intacto). Nuevas claves de retorno aeda_pdf_path / aeda_pptx_path / aeda_manifest_path. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/functions/datascience/__init__.py | 2 + .../datascience/build_eda_render_ctx.md | 114 ++++++++++ .../datascience/build_eda_render_ctx.py | 200 ++++++++++++++++++ .../datascience/build_eda_render_ctx_test.py | 153 ++++++++++++++ python/functions/pipelines/profile_table.md | 9 +- python/functions/pipelines/profile_table.py | 52 +++++ .../pipelines/render_automatic_eda.md | 91 ++++++++ .../pipelines/render_automatic_eda.py | 157 ++++++++++++++ .../pipelines/render_automatic_eda_test.py | 91 ++++++++ 9 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 python/functions/datascience/build_eda_render_ctx.md create mode 100644 python/functions/datascience/build_eda_render_ctx.py create mode 100644 python/functions/datascience/build_eda_render_ctx_test.py create mode 100644 python/functions/pipelines/render_automatic_eda.md create mode 100644 python/functions/pipelines/render_automatic_eda.py create mode 100644 python/functions/pipelines/render_automatic_eda_test.py diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index bdf596e7..a1e6331f 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -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", diff --git a/python/functions/datascience/build_eda_render_ctx.md b/python/functions/datascience/build_eda_render_ctx.md new file mode 100644 index 00000000..4f098383 --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx.md @@ -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': }. 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': } 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": }`. 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. diff --git a/python/functions/datascience/build_eda_render_ctx.py b/python/functions/datascience/build_eda_render_ctx.py new file mode 100644 index 00000000..efcda2cb --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx.py @@ -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": }`` 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 diff --git a/python/functions/datascience/build_eda_render_ctx_test.py b/python/functions/datascience/build_eda_render_ctx_test.py new file mode 100644 index 00000000..bf8aee7a --- /dev/null +++ b/python/functions/datascience/build_eda_render_ctx_test.py @@ -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 diff --git a/python/functions/pipelines/profile_table.md b/python/functions/pipelines/profile_table.md index eb8ce6d2..2808965a 100644 --- a/python/functions/pipelines/profile_table.md +++ b/python/functions/pipelines/profile_table.md @@ -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:, 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:, 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 diff --git a/python/functions/pipelines/profile_table.py b/python/functions/pipelines/profile_table.py index 8838a81e..8a0077af 100644 --- a/python/functions/pipelines/profile_table.py +++ b/python/functions/pipelines/profile_table.py @@ -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)} diff --git a/python/functions/pipelines/render_automatic_eda.md b/python/functions/pipelines/render_automatic_eda.md new file mode 100644 index 00000000..b157dfd2 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda.md @@ -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__'." + - 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:} 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). diff --git a/python/functions/pipelines/render_automatic_eda.py b/python/functions/pipelines/render_automatic_eda.py new file mode 100644 index 00000000..c0b58065 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda.py @@ -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_
_". + 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": } + + 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)} diff --git a/python/functions/pipelines/render_automatic_eda_test.py b/python/functions/pipelines/render_automatic_eda_test.py new file mode 100644 index 00000000..a463e4f7 --- /dev/null +++ b/python/functions/pipelines/render_automatic_eda_test.py @@ -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 From 437409641ca6a595e8b1507d50c3d3c43cf600e6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 30 Jun 2026 16:08:50 +0200 Subject: [PATCH 3/3] docs(eda): el skill /eda emite SIEMPRE PDF + PPTX con AutomaticEDA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actualiza el flujo del comando para que un EDA completo emita el informe AutomaticEDA en sus dos formatos (PDF A5 móvil + PPTX 16:9) con los 11 capítulos poblados, vía render_automatic_eda (o profile_table(emit_automatic=True)). El PDF legacy (emit_pdf/render_eda_pdf) queda como salida independiente opcional. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/eda.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/.claude/commands/eda.md b/.claude/commands/eda.md index 8ce31ef8..860c340f 100644 --- a/.claude/commands/eda.md +++ b/.claude/commands/eda.md @@ -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.