feat(eda): pipeline BQ-EDA sobre tablas BigQuery (grupo eda)

Añade el conector y el pipeline para hacer EDA automático sobre tablas/vistas
de BigQuery, reutilizando profile_table del grupo eda sin duplicar profiling:

- load_bq_table_to_duckdb (datascience): trae una tabla BQ a DuckDB con
  seudonimización SHA-1 de columnas PII y normalización de dtypes. Por defecto
  carga el total de filas (sample_frac=None); el muestreo es opt-in explícito.
- profile_bq_table (pipeline): orquesta load -> profile_table -> render report
  (JSON + Markdown + PDF/PPTX). Full por defecto.

Ambas tageadas eda+bigquery, v1.1.0. El default full responde a la preferencia
del operador: los EDA se corren sobre el total salvo indicación contraria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:45:39 +02:00
parent 7273823087
commit 8408863cfa
5 changed files with 489 additions and 0 deletions
+2
View File
@@ -79,8 +79,10 @@ from .render_paper_pdf import render_paper_pdf
from .draw_join_graph_figure import draw_join_graph_figure
from .generate_synthetic_eda_table import generate_synthetic_eda_table
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
from .load_bq_table_to_duckdb import load_bq_table_to_duckdb
__all__ = [
"load_bq_table_to_duckdb",
"generate_synthetic_eda_table",
"generate_synthetic_eda_folder",
"render_paper_pdf",
@@ -0,0 +1,86 @@
---
name: load_bq_table_to_duckdb
kind: function
lang: py
domain: datascience
version: "1.1.0"
purity: impure
signature: "def load_bq_table_to_duckdb(table_fqn: str, duckdb_path: str, dest_table: str = '', sample_frac: float = None, max_rows: int = 0, project_id: str = '', pseudonymize_cols: list = None) -> dict"
description: "Adaptador BigQuery -> DuckDB local para el grupo eda. Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto COMPLETA, todas las filas; muestreo opt-in con sample_frac), de modo que las funciones del grupo de capacidad eda (que solo hablan DuckDB/PostgreSQL) puedan perfilarla. Fetch via BigQuery Storage Read API (Arrow) con fallback REST. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)."
tags: [eda, bigquery, duckdb, datascience]
params:
- name: table_fqn
desc: "FQN completo de la tabla/vista BigQuery: `project.dataset.table`."
- name: duckdb_path
desc: "Ruta del archivo DuckDB local donde materializar la tabla (se crea/sobrescribe la tabla dest)."
- name: dest_table
desc: "Nombre de la tabla DuckDB destino. Vacío = último segmento del FQN, saneado."
- name: sample_frac
desc: "None (DEFAULT) = FULL, trae todas las filas. Un float en (0,1) activa el muestreo opt-in con `WHERE rand() < frac` (~frac del total). Vistas no admiten TABLESAMPLE, por eso rand()."
- name: max_rows
desc: "Tope duro opcional de filas (LIMIT). 0 (DEFAULT) = sin tope. Se combina con sample_frac si ambos se pasan."
- name: project_id
desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC."
- name: pseudonymize_cols
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad."
output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized}. En error {status:'error', error}."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/load_bq_table_to_duckdb.py"
---
## Ejemplo
```python
from datascience import load_bq_table_to_duckdb
# FULL por defecto: trae TODAS las filas de la vista (3,8M) a DuckDB.
r = load_bq_table_to_duckdb(
"autingo-159109.customer_marts.customer_profile",
"/tmp/eda_bq.duckdb",
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
)
print(r["table"], r["n_rows_fetched"], "de", r["n_rows_source"], "sampled=", r["sampled"])
# Muestreo opt-in: ~5 % de las filas.
r = load_bq_table_to_duckdb(
"autingo-159109.customer_marts.customer_profile",
"/tmp/eda_bq_sample.duckdb",
sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
)
```
## Cuando usarla
- Antes de perfilar una tabla/vista de BigQuery con el grupo `eda` (que solo habla DuckDB/PostgreSQL): trae el origen COMPLETO a DuckDB local (o una muestra con `sample_frac`) con seudonimización PII.
- Cuando necesites un puente único BigQuery -> DuckDB local -> grupo `eda` sin escribir el bridge inline cada vez.
- Cuando quieras que un EDA sobre datos de negocio conserve valor analítico (cardinalidad, nulos, distribución) sin incrustar datos personales reales.
## Gotchas
- **Impura**: hace I/O de red (BigQuery) + escritura a disco (DuckDB). Requiere ADC configurado (`gcloud auth application-default login`).
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC arrastra un quota project ajeno (memoria `bq_direct_quota_project`).
- **TABLESAMPLE no funciona en vistas**: el muestreo (opt-in, `sample_frac`) usa `WHERE rand() < frac` (aplicable a tablas y vistas). `max_rows` es un `LIMIT` como tope duro opcional.
- **FULL por defecto**: `sample_frac=None` trae TODAS las filas. Trae el resultado a RAM como DataFrame de pandas antes de materializar en DuckDB, así que una tabla de muchos millones × muchas columnas puede consumir varios GB. Para tablas enormes que no quepan, pasa `sample_frac` (muestra) o `max_rows` (tope). El fetch usa el BigQuery Storage Read API (Arrow) cuando `google-cloud-bigquery-storage` + `pyarrow` están disponibles — mucho más rápido que REST para millones de filas; si no, cae al conversor REST automáticamente.
- **La seudonimización es un hash unidireccional** (SHA-1 truncado a 12 hex): no es reversible, correcto para EDA. Preserva nulos, cardinalidad y patrón de faltantes, pero NO permite recuperar el valor original.
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query) devuelve `{status:'error', error:str}`.
## Notas
Adaptador del grupo de capacidad `eda`: el resto de funciones del grupo perfilan
DuckDB/PostgreSQL, pero no hablan BigQuery de forma nativa. Esta función cubre ese
hueco materializando una sola tabla DuckDB desde el DataFrame resultante de la
query BigQuery. El nombre de tabla destino se sanea (`^[A-Za-z_][A-Za-z0-9_]*$`)
antes de citarlo en el `CREATE OR REPLACE TABLE`.
## Capability growth log
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explícito. Fetch acelerado via BigQuery Storage Read API (Arrow) con fallback REST. Preferencia estándar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario.
@@ -0,0 +1,157 @@
"""load_bq_table_to_duckdb — adaptador BigQuery -> DuckDB local para el grupo `eda`.
Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto
COMPLETA — todas las filas — o una muestra si se pasa `sample_frac`), de modo que
las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla
DuckDB desde un DataFrame de pandas.
Modo por defecto = FULL: `sample_frac=None` trae la vista/tabla entera (preferencia
estándar del usuario: los EDA se corren sobre el total salvo que se pida lo
contrario). El muestreo es opt-in explícito: `sample_frac=0.05` trae ~5 %; `max_rows`
es un tope duro opcional (0 = sin tope). El fetch usa el BigQuery Storage Read API
(Arrow) cuando está disponible, con fallback al conversor REST.
Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando
nulos, cardinalidad y patrón de faltantes pero sin volcar el valor real (DNI,
nombre, email, teléfono, etc.). El EDA conserva su valor analítico sin incrustar
datos personales reales.
Autenticación: ADC (gcloud auth). Aplica creds.with_quota_project(None) para
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno.
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
"""
import hashlib
import re
_FQN_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
def _pseudonymize_series(values):
"""Hash SHA-1 truncado (12 hex) de cada valor no nulo; conserva None/NaN."""
import pandas as pd
out = []
for v in values:
if v is None or (isinstance(v, float) and pd.isna(v)) or (
not isinstance(v, (list, dict)) and pd.isna(v) if _safe_isna(v) else False
):
out.append(None)
else:
h = hashlib.sha1(str(v).encode("utf-8")).hexdigest()[:12]
out.append(h)
return out
def _safe_isna(v):
import pandas as pd
try:
return bool(pd.isna(v))
except (TypeError, ValueError):
return False
def load_bq_table_to_duckdb(
table_fqn: str,
duckdb_path: str,
dest_table: str = "",
sample_frac: float = None,
max_rows: int = 0,
project_id: str = "",
pseudonymize_cols: list = None,
) -> dict:
try:
import duckdb
import google.auth
from google.cloud import bigquery
if not table_fqn or not _FQN_RE.match(table_fqn):
return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"}
# dest_table: derivar del último segmento del FQN si no se pasa.
dest = dest_table or table_fqn.split(".")[-1]
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
creds, adc_project = google.auth.default(
scopes=["https://www.googleapis.com/auth/bigquery"]
)
if hasattr(creds, "with_quota_project"):
creds = creds.with_quota_project(None)
proj = project_id or table_fqn.split(".")[0] or adc_project
client = bigquery.Client(project=proj, credentials=creds)
# Conteo de filas de origen.
cnt = client.query(
f"SELECT COUNT(*) AS n FROM `{table_fqn}`"
).result()
n_source = 0
for row in cnt:
n_source = int(row["n"])
# Modo por defecto = FULL (sample_frac=None -> todas las filas). El
# muestreo es opt-in: sample_frac in (0,1) muestrea esa fracción con
# `WHERE rand() < frac` (aplicable a tablas y vistas; TABLESAMPLE no va
# en vistas). max_rows>0 es un tope duro opcional (LIMIT); 0 = sin tope.
sampled = False
where = ""
if sample_frac is not None and 0 < float(sample_frac) < 1:
where = f" WHERE rand() < {float(sample_frac)}"
sampled = True
limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
sql = f"SELECT * FROM `{table_fqn}`{where}{limit}"
# Fetch: BigQuery Storage Read API (Arrow, rápido para millones de filas)
# con fallback al conversor REST si la lib no está o falla.
try:
df = client.query(sql).result().to_dataframe(create_bqstorage_client=True)
except Exception: # noqa: BLE001
df = client.query(sql).result().to_dataframe(create_bqstorage_client=False)
n_fetched = len(df)
# Normalizar dtypes de db-dtypes: el conversor REST de BigQuery mapea las
# columnas DATE/TIME a las extension dtypes `dbdate`/`dbtime` de db-dtypes,
# que DuckDB NO reconoce al registrar el DataFrame ("Data type 'dbdate' not
# recognized"). Se convierten a tipos estándar que DuckDB sí ingiere: DATE
# -> datetime64[ns], TIME -> string. El resto de dtypes (datetime64 de
# TIMESTAMP, Int64/boolean nullable, object) los acepta DuckDB tal cual.
import pandas as pd
for col in df.columns:
dt = str(df[col].dtype)
if dt == "dbdate":
df[col] = pd.to_datetime(df[col], errors="coerce")
elif dt == "dbtime":
df[col] = df[col].astype("string").astype(object)
# Seudonimización de columnas PII antes de escribir a disco.
pseudo_applied = []
for col in (pseudonymize_cols or []):
if col in df.columns:
df[col] = _pseudonymize_series(df[col].tolist())
pseudo_applied.append(col)
# Materializar a DuckDB (una tabla desde el DataFrame).
con = duckdb.connect(duckdb_path)
try:
con.register("_src_df", df)
con.execute(f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df')
con.unregister("_src_df")
finally:
con.close()
return {
"status": "ok",
"duckdb_path": duckdb_path,
"table": dest,
"n_rows_source": n_source,
"n_rows_fetched": n_fetched,
"sampled": sampled,
"sample_frac": float(sample_frac) if sampled else None,
"columns": list(df.columns),
"pseudonymized": pseudo_applied,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,106 @@
---
name: profile_bq_table
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.1.0"
signature: "def profile_bq_table(table_fqn: str, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: list = None, run_models: bool = True, run_series: bool = False, run_llm: bool = False, project_id: str = \"\", report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = False) -> dict"
description: "EDA one-shot de una tabla o vista de BigQuery: materializa el origen COMPLETO por defecto (todas las filas; muestreo opt-in con sample_frac; seudonimizacion PII opcional, LOPDGDD/RGPD) a un DuckDB local con load_bq_table_to_duckdb y lo perfila end-to-end con profile_table del grupo de capacidad eda, emitiendo el informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el adaptador BigQuery que faltaba en el grupo eda, resuelto por composicion (BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni de render. Es el hazme un EDA de esta tabla BigQuery en una sola llamada, sobre el total de filas por defecto."
tags: [eda, bigquery, launcher]
uses_functions:
- load_bq_table_to_duckdb_py_datascience
- profile_table_py_pipelines
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/profile_bq_table.py"
params:
- name: table_fqn
desc: "FQN de la tabla/vista BigQuery: `project.dataset.table`."
- name: sample_frac
desc: "None (DEFAULT) = FULL, perfila TODAS las filas del origen. Un float en (0,1) activa el muestreo opt-in (`WHERE rand() < frac`, ~frac del total)."
- name: max_rows
desc: "Tope duro opcional de filas (LIMIT). 0 (DEFAULT) = sin tope. Se combina con sample_frac si ambos se pasan."
- name: pseudonymize_cols
desc: "Columnas PII a seudonimizar (hash) antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad."
- name: run_models
desc: "PCA/KMeans/IsolationForest/normalidad sobre numericas. Default True (informe AutomaticEDA completo)."
- name: run_series
desc: "Analisis de serie temporal por columna numerica. Default False."
- name: run_llm
desc: "1 llamada LLM sobre el perfil agregado (nunca filas crudas). Default False."
- name: project_id
desc: "Proyecto GCP de facturacion. Vacio = primer segmento del FQN."
- name: report_dir
desc: "Directorio de salida de los reports. Default 'reports' (artefacto local gitignored)."
- name: duckdb_path
desc: "Ruta DuckDB a usar. Vacio = temporal autogestionado."
- name: keep_duckdb
desc: "Si True conserva el DuckDB materializado (para el notebook Jupyter). Default False."
output: "dict dict-no-throw. En exito {status:'ok', table_fqn, load:{n_rows_source,n_rows_fetched,sampled,sample_frac,pseudonymized,table}, duckdb_path, report_md_path, report_json_path, aeda_pdf_path, aeda_pptx_path, aeda_manifest_path, profile}. En error {status:'error', error, stage}."
---
## Ejemplo
```python
from pipelines.profile_bq_table import profile_bq_table
# FULL por defecto: EDA sobre TODAS las filas de la vista (3,8M).
r = profile_bq_table(
"autingo-159109.customer_marts.customer_profile",
pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"],
run_models=True,
)
print(r["load"]["n_rows_fetched"], "filas perfiladas, sampled=", r["load"]["sampled"])
print(r["aeda_pdf_path"]); print(r["aeda_pptx_path"]); print(r["report_md_path"])
# Muestreo opt-in: EDA sobre ~5 % de las filas (tabla enorme / iteracion rapida).
r = profile_bq_table(
"autingo-159109.customer_marts.customer_profile",
sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"],
)
```
## Cuando usarla
Cuando pidan un EDA de una tabla o vista de BigQuery ("hazme un EDA de esta
tabla BigQuery"). Es el adaptador BigQuery del grupo de capacidad `eda` por
composicion: trae el origen COMPLETO (todas las filas, por defecto) a un DuckDB
local y delega todo el perfilado y render en `profile_table`, sin adaptador
BigQuery nativo ni logica de EDA duplicada. Usala como primer paso al recibir un
dataset BigQuery desconocido, antes de modelar o limpiar, o para auditar la
calidad de una vista ya productiva. Para iteracion rapida o tablas que no quepan
en RAM, pasa `sample_frac` (muestreo opt-in).
## Gotchas
- Impura: requiere ADC de BigQuery configurado (Application Default Credentials)
para que `load_bq_table_to_duckdb` autentique contra el proyecto.
- FULL por defecto: `sample_frac=None` perfila TODAS las filas del origen. Una
vista de millones de filas se trae entera a RAM (varios GB posibles) antes de
materializar en DuckDB; el fetch usa el BigQuery Storage Read API (Arrow) cuando
esta disponible, mucho mas rapido que REST. Para acotar coste/memoria, pasa
`sample_frac` in (0,1) (muestreo opt-in) o `max_rows` (tope duro). Si por limite
de recursos no cabe el total, dilo explicito con el maximo que si se cargo.
- Seudonimiza PII con `pseudonymize_cols` para cumplir LOPDGDD/RGPD ANTES de
escribir a disco: nombres, DNI/NIE, email, telefono, direccion, IDs de cliente,
etc. Se hashean preservando nulos y cardinalidad. Sin seudonimizar, la muestra
materializada (DuckDB + reports) contiene datos personales reales [POL-MMNSEG-001-1.0].
- El DuckDB temporal se borra al terminar salvo `keep_duckdb=True` (pasalo para
seguir explorando la muestra desde un notebook Jupyter). Si pasas `duckdb_path`
explicito, la ruta se respeta y solo se conserva con `keep_duckdb=True`.
- Escribe reports a `report_dir` (default 'reports', artefacto local gitignored):
Markdown + JSON sidecar + PDF A5 movil + PPTX 16:9 del informe AutomaticEDA.
- `run_llm=True` gasta tokens (haiku) pero solo envia el perfil agregado, nunca
filas crudas ni datos personales.
## Capability growth log
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT del pipeline: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = perfila todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explicito (`sample_frac`). Alinea con la preferencia estandar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario. Hereda el fetch acelerado (Arrow/bqstorage) de `load_bq_table_to_duckdb` v1.1.0.
@@ -0,0 +1,138 @@
"""profile_bq_table — EDA one-shot de una tabla/vista BigQuery con el grupo `eda`.
Pipeline impuro: materializa una tabla o vista de BigQuery (por defecto COMPLETA —
todas las filas — o una muestra si se pasa `sample_frac`, con seudonimizacion PII
opcional, LOPDGDD/RGPD) a un DuckDB local con `load_bq_table_to_duckdb`, y la
perfila end-to-end con `profile_table` del grupo de capacidad `eda`, emitiendo el
informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el
adaptador BigQuery que faltaba en el grupo `eda`, resuelto por composicion
(BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni
de render.
Modo por defecto = FULL: `sample_frac=None` perfila TODAS las filas del origen
(preferencia estandar del usuario: los EDA se corren sobre el total salvo que se
pida lo contrario). El muestreo es opt-in explicito: `sample_frac=0.05` perfila
~5 % de las filas; `max_rows` es un tope duro opcional (0 = sin tope).
Funciones del registry compuestas (NO se reimplementa su logica):
- load_bq_table_to_duckdb : trae la tabla/vista BigQuery a un DuckDB local
(completa por defecto, o muestra si sample_frac).
- profile_table : orquestador one-shot del grupo `eda` que perfila la
DuckDB materializada y emite el informe AutomaticEDA.
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
"""
import os
import tempfile
from datascience import load_bq_table_to_duckdb
from pipelines.profile_table import profile_table
def profile_bq_table(
table_fqn: str,
sample_frac: float = None,
max_rows: int = 0,
pseudonymize_cols: list = None,
run_models: bool = True,
run_series: bool = False,
run_llm: bool = False,
project_id: str = "",
report_dir: str = "reports",
duckdb_path: str = "",
keep_duckdb: bool = False,
) -> dict:
"""EDA one-shot de una tabla/vista BigQuery.
Por defecto perfila TODAS las filas del origen (`sample_frac=None`, modo FULL).
Materializa el origen (con seudonimizacion PII opcional) a un DuckDB local y lo
perfila con `profile_table` del grupo `eda`, emitiendo el informe AutomaticEDA
(PDF A5 movil + PPTX 16:9) + Markdown + JSON sidecar.
Args:
table_fqn: FQN de la tabla/vista BigQuery ("project.dataset.table").
sample_frac: None (default) = FULL, perfila todas las filas. Un float en
(0,1) activa el muestreo opt-in (`WHERE rand() < frac`, ~frac del total).
max_rows: Tope duro opcional de filas (LIMIT). 0 (default) = sin tope.
pseudonymize_cols: Columnas PII a seudonimizar (hash) antes de materializar.
run_models: Modelos baratos (PCA/KMeans/IsolationForest/normalidad).
run_series: Analisis de serie temporal por columna numerica.
run_llm: 1 llamada LLM sobre el perfil agregado (nunca filas crudas).
project_id: Proyecto GCP de facturacion. Vacio = primer segmento del FQN.
report_dir: Directorio de salida de los reports.
duckdb_path: Ruta DuckDB a usar. Vacio = temporal autogestionado.
keep_duckdb: Si True conserva el DuckDB materializado.
Returns:
dict dict-no-throw con el resultado del pipeline (ver output del .md).
"""
tmp_created = False
try:
# DuckDB temporal si no se pasa ruta.
if not duckdb_path:
fd, duckdb_path = tempfile.mkstemp(prefix="eda_bq_", suffix=".duckdb")
os.close(fd)
os.remove(duckdb_path) # que lo cree DuckDB limpio
tmp_created = True
load = load_bq_table_to_duckdb(
table_fqn,
duckdb_path,
sample_frac=sample_frac,
max_rows=max_rows,
project_id=project_id,
pseudonymize_cols=pseudonymize_cols,
)
if load.get("status") != "ok":
return {
"status": "error",
"error": load.get("error", "load fallo"),
"stage": "load",
}
prof = profile_table(
duckdb_path,
load["table"],
backend="duckdb",
run_models=run_models,
run_series=run_series,
run_llm=run_llm,
emit_automatic=True, # PDF A5 movil + PPTX 16:9
emit_pdf=False,
write_report=True, # Markdown + JSON sidecar
report_dir=report_dir,
)
if prof.get("status") != "ok":
return {
"status": "error",
"error": prof.get("error", "profile fallo"),
"stage": "profile",
"load": load,
}
return {
"status": "ok",
"table_fqn": table_fqn,
"load": {
k: load[k]
for k in ("n_rows_source", "n_rows_fetched", "sampled", "sample_frac", "pseudonymized", "table")
if k in load
},
"duckdb_path": duckdb_path if keep_duckdb else None,
"report_md_path": prof.get("report_md_path"),
"report_json_path": prof.get("report_json_path"),
"aeda_pdf_path": prof.get("aeda_pdf_path"),
"aeda_pptx_path": prof.get("aeda_pptx_path"),
"aeda_manifest_path": prof.get("aeda_manifest_path"),
"profile": prof.get("profile"),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
finally:
# Limpia el DuckDB temporal salvo que se pida conservarlo.
if tmp_created and not keep_duckdb and duckdb_path and os.path.exists(duckdb_path):
try:
os.remove(duckdb_path)
except OSError:
pass