Files
fn_registry/python/functions/datascience/build_eda_render_ctx.py
T
egutierrez f3d427d9e4 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>
2026-06-30 16:08:41 +02:00

201 lines
9.4 KiB
Python

"""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