feat(eda): wiring AutomaticEDA — build_eda_render_ctx + pipeline render_automatic_eda + profile_table(emit_automatic)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 .render_automatic_eda_pptx import render_automatic_eda_pptx
|
||||||
from .detect_time_column import detect_time_column
|
from .detect_time_column import detect_time_column
|
||||||
from .extract_timeseries_raw import extract_timeseries_raw
|
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 .profile_datetime import profile_datetime
|
||||||
from .resample_timeseries import resample_timeseries
|
from .resample_timeseries import resample_timeseries
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"detect_time_column",
|
"detect_time_column",
|
||||||
"extract_timeseries_raw",
|
"extract_timeseries_raw",
|
||||||
|
"build_eda_render_ctx",
|
||||||
"profile_datetime",
|
"profile_datetime",
|
||||||
"resample_timeseries",
|
"resample_timeseries",
|
||||||
"render_automatic_eda_pdf",
|
"render_automatic_eda_pdf",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: pipelines
|
domain: pipelines
|
||||||
purity: impure
|
purity: impure
|
||||||
version: "1.0.0"
|
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."
|
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]
|
tags: [eda, duckdb, postgres, profiling, data-quality, pipeline, dataops, timeseries]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
@@ -26,6 +26,9 @@ uses_functions:
|
|||||||
- exploratory_caveats_py_datascience
|
- exploratory_caveats_py_datascience
|
||||||
- render_eda_markdown_py_datascience
|
- render_eda_markdown_py_datascience
|
||||||
- render_eda_pdf_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
|
- duckdb_query_readonly_py_infra
|
||||||
- pg_query_py_infra
|
- pg_query_py_infra
|
||||||
uses_types: []
|
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']."
|
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
|
- 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."
|
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
|
- 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."
|
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
|
- 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)."
|
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
|
## Ejemplo
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ from datascience import (
|
|||||||
acf_pacf,
|
acf_pacf,
|
||||||
adf_kpss_stationarity,
|
adf_kpss_stationarity,
|
||||||
association_matrix,
|
association_matrix,
|
||||||
|
build_eda_render_ctx,
|
||||||
column_quality_score,
|
column_quality_score,
|
||||||
describe_numeric,
|
describe_numeric,
|
||||||
eda_llm_insights,
|
eda_llm_insights,
|
||||||
exploratory_caveats,
|
exploratory_caveats,
|
||||||
infer_semantic_type,
|
infer_semantic_type,
|
||||||
|
render_automatic_eda_pdf,
|
||||||
|
render_automatic_eda_pptx,
|
||||||
render_eda_markdown,
|
render_eda_markdown,
|
||||||
render_eda_pdf,
|
render_eda_pdf,
|
||||||
run_eda_models,
|
run_eda_models,
|
||||||
@@ -385,6 +388,7 @@ def profile_table(
|
|||||||
run_llm: bool = False,
|
run_llm: bool = False,
|
||||||
run_series: bool = False,
|
run_series: bool = False,
|
||||||
emit_pdf: bool = False,
|
emit_pdf: bool = False,
|
||||||
|
emit_automatic: bool = False,
|
||||||
report_dir: str = "reports",
|
report_dir: str = "reports",
|
||||||
write_report: bool = True,
|
write_report: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -412,6 +416,15 @@ def profile_table(
|
|||||||
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
|
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
|
||||||
(legible en movil) del perfil junto al report markdown y devuelve su
|
(legible en movil) del perfil junto al report markdown y devuelve su
|
||||||
ruta en pdf_path.
|
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.
|
report_dir: directorio donde escribir los reports si write_report.
|
||||||
Default "reports". Se crea si no existe.
|
Default "reports". Se crea si no existe.
|
||||||
write_report: si True (default), escribe un report markdown + un JSON
|
write_report: si True (default), escribe un report markdown + un JSON
|
||||||
@@ -727,12 +740,51 @@ def profile_table(
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pdf_path = None
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"profile": prof,
|
"profile": prof,
|
||||||
"report_md_path": report_md_path,
|
"report_md_path": report_md_path,
|
||||||
"report_json_path": report_json_path,
|
"report_json_path": report_json_path,
|
||||||
"pdf_path": pdf_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
|
except Exception as e: # noqa: BLE001
|
||||||
return {"status": "error", "error": str(e)}
|
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
|
||||||
Reference in New Issue
Block a user