From 5a4f82cf76b626e6ebad091a3c9b0e8dcc3e7188 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Thu, 2 Jul 2026 19:00:13 +0200 Subject: [PATCH] chore: auto-commit (26 archivos) - python/functions/bigquery/bq_auth.md - python/functions/bigquery/bq_load_from_file.md - python/functions/bigquery/bq_load_from_gcs.md - python/functions/bigquery/client.py - python/functions/bigquery/queries.py - python/functions/datascience/__init__.py - python/functions/datascience/decode_qr_image.py - python/functions/datascience/load_bq_table_to_duckdb.md - python/functions/datascience/load_bq_table_to_duckdb.py - python/functions/pipelines/profile_bq_table.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) --- python/functions/bigquery/bq_auth.md | 20 +- .../functions/bigquery/bq_load_from_file.md | 6 +- python/functions/bigquery/bq_load_from_gcs.md | 6 +- python/functions/bigquery/client.py | 24 +- python/functions/bigquery/queries.py | 15 +- python/functions/datascience/__init__.py | 6 + .../datascience/build_column_dictionary.md | 142 +++++++ .../datascience/build_column_dictionary.py | 245 ++++++++++++ .../build_column_dictionary_test.py | 193 ++++++++++ .../functions/datascience/decode_qr_image.py | 13 +- .../datascience/forecast_seasonal_median.md | 94 +++++ .../datascience/forecast_seasonal_median.py | 126 ++++++ .../forecast_seasonal_median_test.py | 113 ++++++ .../datascience/list_bq_dataset_tables.md | 79 ++++ .../datascience/list_bq_dataset_tables.py | 134 +++++++ .../datascience/load_bq_table_to_duckdb.md | 68 +++- .../datascience/load_bq_table_to_duckdb.py | 360 +++++++++++++++--- .../load_bq_table_to_duckdb_test.py | 135 +++++++ .../functions/pipelines/profile_bq_dataset.md | 133 +++++++ .../functions/pipelines/profile_bq_dataset.py | 263 +++++++++++++ .../functions/pipelines/profile_bq_table.md | 40 +- .../functions/pipelines/profile_bq_table.py | 31 +- .../functions/pipelines/profile_database.md | 12 +- .../functions/pipelines/profile_database.py | 8 +- .../functions/pipelines/run_sales_forecast.md | 103 +++++ .../functions/pipelines/run_sales_forecast.py | 298 +++++++++++++++ 26 files changed, 2573 insertions(+), 94 deletions(-) create mode 100644 python/functions/datascience/build_column_dictionary.md create mode 100644 python/functions/datascience/build_column_dictionary.py create mode 100644 python/functions/datascience/build_column_dictionary_test.py create mode 100644 python/functions/datascience/forecast_seasonal_median.md create mode 100644 python/functions/datascience/forecast_seasonal_median.py create mode 100644 python/functions/datascience/forecast_seasonal_median_test.py create mode 100644 python/functions/datascience/list_bq_dataset_tables.md create mode 100644 python/functions/datascience/list_bq_dataset_tables.py create mode 100644 python/functions/datascience/load_bq_table_to_duckdb_test.py create mode 100644 python/functions/pipelines/profile_bq_dataset.md create mode 100644 python/functions/pipelines/profile_bq_dataset.py create mode 100644 python/functions/pipelines/run_sales_forecast.md create mode 100644 python/functions/pipelines/run_sales_forecast.py diff --git a/python/functions/bigquery/bq_auth.md b/python/functions/bigquery/bq_auth.md index b606a4ee..941f50c8 100644 --- a/python/functions/bigquery/bq_auth.md +++ b/python/functions/bigquery/bq_auth.md @@ -3,11 +3,11 @@ name: bq_auth kind: function lang: py domain: infra -version: "1.0.0" +version: "1.1.0" purity: impure -signature: "def bq_auth(project_id: str = '', credentials_path: str = '') -> BQClient" -description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD." -tags: [bigquery, gcp, auth, google-cloud, python, pendiente-usar] +signature: "def bq_auth(project_id: str = '', credentials_path: str = '', drop_quota_project: bool = False) -> BQClient" +description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD. Con drop_quota_project=True descarta el quota project del ADC del usuario (creds.with_quota_project(None)) para evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno." +tags: [bigquery, gcp, auth, google-cloud, python, forecast, pendiente-usar] uses_functions: [] uses_types: [] returns: [] @@ -19,6 +19,8 @@ params: desc: "ID del proyecto GCP (vacio = detectar de credenciales/entorno)" - name: credentials_path desc: "ruta a archivo JSON de service account (vacio = Application Default Credentials)" + - name: drop_quota_project + desc: "si True y sin credentials_path, resuelve ADC via google.auth.default y descarta el quota project del ADC (with_quota_project(None)); evita el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota_project_id ajeno. Default False = comportamiento original" output: "BQClient: cliente autenticado con proyecto resuelto" tested: false tests: [] @@ -40,6 +42,9 @@ client = bq_auth("my-project-id") # Service account client = bq_auth(credentials_path="/path/to/service-account.json") +# Sin quota project (evita 403 USER_PROJECT_DENIED con ADC de usuario) +client = bq_auth("autingo-159109", drop_quota_project=True) + # Context manager with bq_auth() as client: # client se cierra automaticamente @@ -48,9 +53,14 @@ with bq_auth() as client: ## Notas -Tres modos de autenticacion: +Modos de autenticacion: - Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login` - Con project_id: usa ADC pero fuerza el proyecto - Con credentials_path: lee el JSON de service account directamente +- Con drop_quota_project=True (y sin credentials_path): resuelve ADC via `google.auth.default(scopes=[".../bigquery"])`, aplica `creds.with_quota_project(None)` si el atributo existe y construye el cliente con ese creds. Es el fix del gotcha conocido: el ADC del usuario (`egutierrez`) lleva `quota_project_id=autingo` ajeno y BigQuery devuelve `403 USER_PROJECT_DENIED`; descartar el quota project lo resuelve. El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente. + +## Capability growth log + +- v1.1.0 (2026-07-02) — anade `drop_quota_project` para descartar el quota project del ADC del usuario (`creds.with_quota_project(None)`) y evitar el 403 USER_PROJECT_DENIED. Default False = comportamiento identico al anterior. diff --git a/python/functions/bigquery/bq_load_from_file.md b/python/functions/bigquery/bq_load_from_file.md index dae7a0a9..ed4257f1 100644 --- a/python/functions/bigquery/bq_load_from_file.md +++ b/python/functions/bigquery/bq_load_from_file.md @@ -3,7 +3,7 @@ name: bq_load_from_file kind: function lang: py domain: infra -version: "1.0.0" +version: "1.0.1" purity: impure signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict" description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local." @@ -73,3 +73,7 @@ cargar desde ahi es mas eficiente y permite paralelismo. La funcion bloquea hasta que el job termina (`job.result()`). Los archivos Parquet y Avro no admiten `skip_leading_rows` — ese parametro solo aplica para CSV. + +## Capability growth log + +- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0. diff --git a/python/functions/bigquery/bq_load_from_gcs.md b/python/functions/bigquery/bq_load_from_gcs.md index d9e049c3..835d317a 100644 --- a/python/functions/bigquery/bq_load_from_gcs.md +++ b/python/functions/bigquery/bq_load_from_gcs.md @@ -3,7 +3,7 @@ name: bq_load_from_gcs kind: function lang: py domain: infra -version: "1.0.0" +version: "1.0.1" purity: impure signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict" description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job." @@ -75,3 +75,7 @@ acepta la lista de archivos resultante como una sola carga atomica. `autodetect=True` es conveniente pero puede inferir tipos incorrectamente para columnas con valores nulos o mixtos. Para produccion, definir el schema explicitamente via `job_config.schema`. + +## Capability growth log + +- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0. diff --git a/python/functions/bigquery/client.py b/python/functions/bigquery/client.py index 06b65edb..ee9157fd 100644 --- a/python/functions/bigquery/client.py +++ b/python/functions/bigquery/client.py @@ -1,6 +1,7 @@ """Cliente base para Google BigQuery.""" from dataclasses import dataclass, field +import google.auth from google.cloud import bigquery from google.oauth2 import service_account @@ -27,7 +28,11 @@ class BQClient: self.close() -def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient: +def bq_auth( + project_id: str = "", + credentials_path: str = "", + drop_quota_project: bool = False, +) -> BQClient: """Autentica contra Google BigQuery. Tres modos de autenticacion: @@ -35,9 +40,18 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient: 2. Service account JSON: con credentials_path 3. Proyecto explicito: con project_id (usa ADC para credenciales) + Con drop_quota_project=True (y sin credentials_path) resuelve las credenciales + ADC via google.auth.default y elimina el quota project fijado en el ADC del + usuario (creds.with_quota_project(None)). Esto evita el error 403 + USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno al proyecto + contra el que se consulta. + Args: project_id: ID del proyecto GCP. Vacio = detectar de credenciales. credentials_path: Ruta a archivo JSON de service account. Vacio = ADC. + drop_quota_project: Si True y sin credentials_path, resuelve ADC con + google.auth.default y descarta el quota project del ADC + (with_quota_project(None)). Default False = comportamiento original. Returns: BQClient autenticado listo para usar. @@ -50,11 +64,19 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient: >>> client = bq_auth() # ADC >>> client = bq_auth("my-project") # ADC con proyecto explicito >>> client = bq_auth(credentials_path="/path/to/sa.json") # Service account + >>> client = bq_auth("autingo-159109", drop_quota_project=True) # sin quota project """ if credentials_path: creds = service_account.Credentials.from_service_account_file(credentials_path) proj = project_id or creds.project_id client = bigquery.Client(credentials=creds, project=proj) + elif drop_quota_project: + 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) + client = bigquery.Client(project=project_id or adc_project, credentials=creds) elif project_id: client = bigquery.Client(project=project_id) else: diff --git a/python/functions/bigquery/queries.py b/python/functions/bigquery/queries.py index add76df8..df5004a2 100644 --- a/python/functions/bigquery/queries.py +++ b/python/functions/bigquery/queries.py @@ -173,11 +173,14 @@ def bq_load_from_gcs( job_config = bigquery.LoadJobConfig( source_format=format_map.get(source_format, bigquery.SourceFormat.CSV), - write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND), + write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND), autodetect=autodetect, - skip_leading_rows=skip_leading_rows, ) - job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND) + # skip_leading_rows solo es valido para CSV: BigQuery rechaza el job + # ("Only CSV imports may specify leading rows to skip") si el campo va + # seteado con cualquier otro formato, incluso a 0. + if source_format == "CSV": + job_config.skip_leading_rows = skip_leading_rows table_ref = client._client.dataset(dataset_id).table(table_id) uris = uri if isinstance(uri, list) else [uri] @@ -251,8 +254,12 @@ def bq_load_from_file( source_format=format_map.get(source_format, bigquery.SourceFormat.CSV), write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND), autodetect=autodetect, - skip_leading_rows=skip_leading_rows, ) + # skip_leading_rows solo es valido para CSV: BigQuery rechaza el job + # ("Only CSV imports may specify leading rows to skip") si el campo va + # seteado con cualquier otro formato, incluso a 0. + if source_format == "CSV": + job_config.skip_leading_rows = skip_leading_rows table_ref = client._client.dataset(dataset_id).table(table_id) diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index fbd5ffc2..bb4fe499 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -25,6 +25,7 @@ from .describe_numeric import describe_numeric from .summarize_categorical import summarize_categorical from .infer_semantic_type import infer_semantic_type from .column_quality_score import column_quality_score +from .build_column_dictionary import build_column_dictionary from .select_groupby_keys import select_groupby_keys from .render_eda_markdown import render_eda_markdown from .detect_distribution_type import detect_distribution_type @@ -80,9 +81,13 @@ 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 +from .list_bq_dataset_tables import list_bq_dataset_tables +from .forecast_seasonal_median import forecast_seasonal_median __all__ = [ + "forecast_seasonal_median", "load_bq_table_to_duckdb", + "list_bq_dataset_tables", "generate_synthetic_eda_table", "generate_synthetic_eda_folder", "render_paper_pdf", @@ -141,6 +146,7 @@ __all__ = [ "summarize_categorical", "infer_semantic_type", "column_quality_score", + "build_column_dictionary", "select_groupby_keys", "render_eda_markdown", "detect_distribution_type", diff --git a/python/functions/datascience/build_column_dictionary.md b/python/functions/datascience/build_column_dictionary.md new file mode 100644 index 00000000..3b11876f --- /dev/null +++ b/python/functions/datascience/build_column_dictionary.md @@ -0,0 +1,142 @@ +--- +id: build_column_dictionary_py_datascience +name: build_column_dictionary +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def build_column_dictionary(db_profile: dict) -> dict" +description: "Construye el diccionario de columnas BUSCABLE de una base entera a partir del DatabaseProfile que emite profile_database (grupo eda). Aplana db_profile['table_profiles'] (lista de TableProfile con table y columns) en una entrada por columna con tabla, tipo inferido, tipo semantico, marca de PII (RGPD/LOPDGDD), %null, cardinalidad y valores top. Responde a nivel de base 'donde esta el customer_id / telefono / IBAN'. Emite tambien pii_columns y un markdown grep-able ordenado por columna, precedido de las columnas compartidas por nombre entre tablas (candidatas a join key cross-tabla). Funcion pura, dict-no-throw, no muta el input." +tags: [eda, relations] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +example: | + from datascience import build_column_dictionary + db_profile = {"table_profiles": [ + {"table": "clientes", "columns": [ + {"name": "email", "inferred_type": "text", "semantic_type": "email", + "null_pct": 0.05, "distinct_count": 990}]}]} + res = build_column_dictionary(db_profile) + # res["pii_columns"] -> [{"table": "clientes", "column": "email", "is_pii": True, ...}] +tested: true +tests: + - "test_golden_flattens_two_tables" + - "test_pii_flagged_from_semantic_type" + - "test_empty_semantic_type_maps_to_none_and_not_pii" + - "test_shared_column_names_detected_as_join_keys" + - "test_top_values_from_categorical_block" + - "test_empty_profile_returns_empty_ok" + - "test_malformed_input_returns_empty_ok" + - "test_missing_keys_read_defensively" + - "test_does_not_mutate_input" +test_file_path: "python/functions/datascience/build_column_dictionary_test.py" +file_path: "python/functions/datascience/build_column_dictionary.py" +params: + - name: db_profile + desc: > + DatabaseProfile del grupo eda tal como lo devuelve profile_database en su + clave db_profile (el dict con table_profiles). table_profiles es una lista + de TableProfile; de cada uno se leen table (nombre) y columns (lista de + ColumnProfile). De cada ColumnProfile se leen defensivamente con .get(...): + name, inferred_type (numeric|categorical|datetime|text|boolean), + semantic_type ("" que se normaliza a None; los que emite infer_semantic_type: + email, iban, credit_card, phone_intl, postal_code_es, ...), null_pct + (fraccion 0-1), distinct_count (cardinalidad, expuesta como n_distinct) y el + bloque categorical.top (para top_values). Una entrada vacia, None o + malformada produce el resultado vacio en estado ok (nunca lanza). +output: > + dict dict-no-throw con status ("ok" siempre), n_tables (int, tablas con columnas + procesadas), n_columns (int total de columnas), entries (list[dict] una por + columna con table, column, inferred_type, semantic_type|None, is_pii (bool), + null_pct (float 0-1|None), n_distinct (int|None), top_values (list[str]|None)), + pii_columns (subconjunto de entries con is_pii=True: dato personal segun + [POL-MMNSEG-001-1.0]) y markdown (str, tabla grep-able ordenada por nombre de + columna precedida de las columnas compartidas por nombre entre tablas). Entrada + vacia o malformada -> n_tables/n_columns 0, listas vacias, markdown "". +--- + +## Ejemplo + +```python +from datascience import build_column_dictionary + +# db_profile minimo de juguete (forma de la clave db_profile de profile_database). +db_profile = { + "table_profiles": [ + { + "table": "clientes", + "columns": [ + {"name": "customer_id", "inferred_type": "numeric", + "semantic_type": "", "null_pct": 0.0, "distinct_count": 1000}, + {"name": "email", "inferred_type": "text", + "semantic_type": "email", "null_pct": 0.05, "distinct_count": 990}, + {"name": "ciudad", "inferred_type": "categorical", + "semantic_type": "", "null_pct": 0.0, "distinct_count": 3, + "categorical": {"top": [ + {"value": "Madrid", "count": 5, "pct": 0.5}, + {"value": "Bilbao", "count": 3, "pct": 0.3}]}}, + ], + }, + { + "table": "pedidos", + "columns": [ + {"name": "customer_id", "inferred_type": "numeric", + "semantic_type": "", "null_pct": 0.0, "distinct_count": 800}, + {"name": "iban", "inferred_type": "text", + "semantic_type": "iban", "null_pct": 0.1, "distinct_count": 795}, + ], + }, + ] +} + +res = build_column_dictionary(db_profile) +print(res["n_tables"], res["n_columns"]) # 2 5 +print([(e["table"], e["column"]) for e in res["pii_columns"]]) +# [('clientes', 'email'), ('pedidos', 'iban')] +print(res["markdown"]) # tabla grep-able + seccion de join keys (customer_id) +``` + +Uso real componiendo con `profile_database` (perfila la base y construye el diccionario): + +```python +from pipelines.profile_database import profile_database +from datascience import build_column_dictionary + +r = profile_database("mi_base.duckdb", write_report=False) +if r["status"] == "ok": + dicc = build_column_dictionary(r["db_profile"]) + # grep sobre dicc["markdown"] para localizar donde vive cada dato, + # dicc["pii_columns"] para el inventario RGPD de la base. +``` + +## Cuando usarla + +Usala cuando necesites un indice tabla.columna de una base ENTERA: para localizar +por busqueda "donde esta el customer_id / telefono / IBAN" antes de escribir un +join, para descubrir claves de join cross-tabla (columnas con el mismo nombre en +varias tablas) o para levantar un inventario de columnas con datos personales +(RGPD/LOPDGDD) sobre el que auditar. Es el paso natural despues de +`profile_database`: toma su `db_profile` y lo convierte en diccionario buscable. + +## Gotchas + +- El criterio de PII se basa SOLO en el `semantic_type` que hoy emite el grupo + `eda` (`infer_semantic_type`): se marcan email, phone_intl, iban, credit_card y + postal_code_es. El catalogo de regex NO detecta hoy nombre de persona ni DNI/NIE, + asi que esas columnas caen como texto/categorico y NO se marcan automaticamente. + Politica [POL-MMNSEG-001-1.0]: ante cualquier duda sobre si una columna contiene + datos personales, tratala como PII y avisa antes de exponerla; `pii_columns` es + una ayuda, no un inventario RGPD exhaustivo. +- `n_distinct` se lee de la clave `distinct_count` del ColumnProfile (no de + `categorical.n_distinct`); en tablas grandes puede venir de `approx_unique` + (HyperLogLog) capado a n_rows, no exacto. +- `top_values` solo se rellena si la columna trae bloque `categorical` (lo pone + `profile_table` para columnas categorical/text); las numericas/datetime lo + dejan en None. +- Funcion pura: no toca disco ni muta el input. NO perfila la base — eso lo hace + `profile_database`; aqui solo se APLANA su salida. diff --git a/python/functions/datascience/build_column_dictionary.py b/python/functions/datascience/build_column_dictionary.py new file mode 100644 index 00000000..4253ba8b --- /dev/null +++ b/python/functions/datascience/build_column_dictionary.py @@ -0,0 +1,245 @@ +"""build_column_dictionary — diccionario de columnas BUSCABLE de una base entera. + +Funcion pura, stdlib-only. No hace I/O, no depende de nada externo y NO muta el +input. Toma el ``db_profile`` (DatabaseProfile) que emite ``profile_database`` del +grupo de capacidad ``eda`` y aplana su ``table_profiles`` (lista de TableProfile, +cada uno con ``table`` y ``columns``: lista de ColumnProfile) en una entrada por +columna. Es la pieza que responde, a nivel de BASE, "donde esta el customer_id / +telefono / IBAN en este dataset?": un indice grep-able tabla.columna con su tipo, +tipo semantico inferido, marca de PII, % de nulos, cardinalidad y valores top. + +Ademas del listado plano emite: + - ``pii_columns``: subconjunto marcado como dato personal (RGPD/LOPDGDD). + - ``markdown``: tabla grep-able ordenada por nombre de columna, precedida de una + seccion que agrupa columnas con el MISMO nombre presentes en varias tablas + (candidatas a clave de join cross-tabla). + +Estilo dict-no-throw del grupo ``eda``: nunca lanza. Lee cada clave de forma +defensiva con ``.get(...)`` y tolera valores None / estructuras malformadas; ante +una entrada vacia o corrupta devuelve el resultado vacio en estado ``ok``. + +Criterio de PII (politica [POL-MMNSEG-001-1.0]): se marca ``is_pii=True`` cuando el +``semantic_type`` real que emite el grupo ``eda`` (ver ``infer_semantic_type``) +pertenece al conjunto de tipos de dato personal detectables hoy: email, telefono +internacional, IBAN, tarjeta de credito y codigo postal (componente de direccion). +El catalogo de regex del grupo NO detecta hoy nombre de persona ni DNI/NIE, asi que +esas columnas caen como texto/categorico y no se marcan automaticamente: ante +cualquier duda sobre si una columna contiene datos personales, tratala como PII y +avisa antes de exponerla. +""" + +# semantic_types del grupo eda (infer_semantic_type) que son dato personal. +# El grupo emite hoy: email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl, +# postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean, +# hex_color. De esos, los que identifican a una persona fisica (RGPD/LOPDGDD) son: +_PII_SEMANTIC_TYPES = frozenset( + { + "email", + "phone_intl", + "iban", + "credit_card", + "postal_code_es", # codigo postal: componente de direccion (dato de localizacion) + } +) + +# Numero maximo de valores frecuentes que se listan por columna categorica. +_TOP_VALUES_LIMIT = 5 + + +def _empty_result() -> dict: + """Resultado vacio en estado ok para entradas vacias o malformadas.""" + return { + "status": "ok", + "n_tables": 0, + "n_columns": 0, + "entries": [], + "pii_columns": [], + "markdown": "", + } + + +def _top_values(col: dict) -> list | None: + """Extrae hasta _TOP_VALUES_LIMIT valores frecuentes del bloque categorical. + + ``summarize_categorical`` deja ``col["categorical"]["top"]`` como lista de + ``{value, count, pct}`` ordenada por frecuencia. Devuelve solo los valores + como strings, o None si la columna no tiene bloque categorical util. + """ + cat = col.get("categorical") + if not isinstance(cat, dict): + return None + top = cat.get("top") + if not isinstance(top, list) or not top: + return None + values = [] + for item in top[:_TOP_VALUES_LIMIT]: + if isinstance(item, dict): + values.append(str(item.get("value"))) + else: + values.append(str(item)) + return values or None + + +def _column_entry(table_name, col: dict) -> dict: + """Construye la entrada del diccionario para un ColumnProfile. + + Lee las claves del contrato eda de forma defensiva: name, inferred_type, + semantic_type ("" se normaliza a None), null_pct (fraccion 0-1), + distinct_count (se expone como n_distinct) y el bloque categorical (top). + """ + sem_raw = col.get("semantic_type") + semantic_type = sem_raw if sem_raw else None # "" -> None + + null_pct = col.get("null_pct") + if isinstance(null_pct, bool) or not isinstance(null_pct, (int, float)): + null_pct = None + else: + null_pct = float(null_pct) + + n_distinct = col.get("distinct_count") + if isinstance(n_distinct, bool) or not isinstance(n_distinct, int): + n_distinct = None + + return { + "table": table_name, + "column": col.get("name"), + "inferred_type": col.get("inferred_type"), + "semantic_type": semantic_type, + "is_pii": semantic_type in _PII_SEMANTIC_TYPES, + "null_pct": null_pct, + "n_distinct": n_distinct, + "top_values": _top_values(col), + } + + +def _render_markdown(entries: list) -> str: + """Renderiza el diccionario en markdown grep-able. + + Primero una seccion que agrupa columnas con el MISMO nombre presentes en + varias tablas (candidatas a clave de join cross-tabla), luego la tabla + completa ordenada por nombre de columna. + """ + lines = ["# Diccionario de columnas", ""] + + # Seccion: columnas compartidas por nombre (candidatas a join key). + by_name: dict = {} + for e in entries: + by_name.setdefault(e["column"], set()).add(e["table"]) + shared = { + name: tables + for name, tables in by_name.items() + if name is not None and len(tables) > 1 + } + + lines.append("## Columnas presentes en varias tablas (candidatas a join key)") + lines.append("") + if shared: + lines.append("| Columna | Tablas |") + lines.append("|---|---|") + for name in sorted(shared, key=lambda s: str(s).lower()): + tbls = ", ".join(sorted((str(t) for t in shared[name]), key=str.lower)) + lines.append(f"| {name} | {tbls} |") + else: + lines.append( + "_Ninguna columna aparece con el mismo nombre en mas de una tabla._" + ) + lines.append("") + + # Tabla completa ordenada por nombre de columna (y tabla como desempate). + lines.append("## Columnas") + lines.append("") + lines.append( + "| Columna | Tabla | Tipo | Tipo semantico | PII | %null | Distinct |" + ) + lines.append("|---|---|---|---|---|---|---|") + for e in sorted( + entries, key=lambda e: (str(e["column"]).lower(), str(e["table"]).lower()) + ): + sem = e["semantic_type"] or "—" + pii = "SI" if e["is_pii"] else "" + null_s = ( + f"{e['null_pct'] * 100:.1f}%" + if isinstance(e["null_pct"], (int, float)) + else "" + ) + distinct_s = str(e["n_distinct"]) if e["n_distinct"] is not None else "" + itype = e["inferred_type"] or "" + lines.append( + f"| {e['column']} | {e['table']} | {itype} | {sem} | {pii} " + f"| {null_s} | {distinct_s} |" + ) + lines.append("") + return "\n".join(lines) + + +def build_column_dictionary(db_profile: dict) -> dict: + """Construye el diccionario de columnas buscable de una base entera. + + Recorre ``db_profile["table_profiles"]`` (lista de TableProfile del grupo eda, + cada uno con ``table`` y ``columns``) y emite una entrada por columna con su + tipo fisico inferido, tipo semantico, marca de PII, % de nulos, cardinalidad y + valores frecuentes. Responde, a nivel de base, donde vive cada dato. + + Args: + db_profile: DatabaseProfile tal como lo devuelve + ``profile_database`` en su clave ``db_profile`` (el dict con + ``table_profiles``). Se lee de forma defensiva; una entrada vacia, + None o malformada produce el resultado vacio en estado ``ok``. + + Returns: + Dict dict-no-throw (nunca lanza) con las claves: + - ``status`` (str): siempre ``"ok"``. + - ``n_tables`` (int): tablas con columnas procesadas. + - ``n_columns`` (int): total de columnas indexadas. + - ``entries`` (list[dict]): una entrada por columna con + ``{table, column, inferred_type, semantic_type|None, is_pii, + null_pct|None, n_distinct|None, top_values|None}``. + - ``pii_columns`` (list[dict]): subconjunto de ``entries`` con + ``is_pii=True`` (dato personal segun [POL-MMNSEG-001-1.0]). + - ``markdown`` (str): tabla grep-able ordenada por nombre de columna, + precedida de las columnas compartidas por nombre entre tablas. + """ + try: + if not isinstance(db_profile, dict): + return _empty_result() + + table_profiles = db_profile.get("table_profiles") + if not isinstance(table_profiles, list) or not table_profiles: + return _empty_result() + + entries: list = [] + n_tables = 0 + for tp in table_profiles: + if not isinstance(tp, dict): + continue + columns = tp.get("columns") + if not isinstance(columns, list): + continue + n_tables += 1 + table_name = tp.get("table") + for col in columns: + if not isinstance(col, dict): + continue + entries.append(_column_entry(table_name, col)) + + if not entries: + return { + "status": "ok", + "n_tables": n_tables, + "n_columns": 0, + "entries": [], + "pii_columns": [], + "markdown": "", + } + + pii_columns = [e for e in entries if e["is_pii"]] + return { + "status": "ok", + "n_tables": n_tables, + "n_columns": len(entries), + "entries": entries, + "pii_columns": pii_columns, + "markdown": _render_markdown(entries), + } + except Exception: # noqa: BLE001 + return _empty_result() diff --git a/python/functions/datascience/build_column_dictionary_test.py b/python/functions/datascience/build_column_dictionary_test.py new file mode 100644 index 00000000..16a58c69 --- /dev/null +++ b/python/functions/datascience/build_column_dictionary_test.py @@ -0,0 +1,193 @@ +"""Tests para build_column_dictionary. + +Verifica el aplanado de un DatabaseProfile del grupo eda a un diccionario de +columnas buscable: entradas por columna, marca de PII desde el semantic_type, +deteccion de columnas compartidas por nombre (join keys), lectura defensiva y +que la funcion es pura (no muta el input). +""" + +import copy +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from build_column_dictionary import build_column_dictionary + + +def _col(name, inferred_type="categorical", semantic_type="", null_pct=0.0, + distinct_count=10, categorical=None) -> dict: + """ColumnProfile minimo con las claves del contrato eda usadas por la funcion.""" + return { + "name": name, + "physical_type": "VARCHAR", + "inferred_type": inferred_type, + "semantic_type": semantic_type, + "null_pct": null_pct, + "distinct_count": distinct_count, + "flags": [], + "numeric": None, + "categorical": categorical, + "datetime": None, + } + + +def _db_profile() -> dict: + """DatabaseProfile de juguete con dos tablas y una columna de join comun.""" + return { + "db_path": "toy.duckdb", + "n_tables": 2, + "table_profiles": [ + { + "table": "clientes", + "columns": [ + _col("customer_id", "numeric", "", 0.0, 1000), + _col("email", "text", "email", 0.05, 990), + _col( + "ciudad", + "categorical", + "", + 0.0, + 3, + categorical={ + "top": [ + {"value": "Madrid", "count": 5, "pct": 0.5}, + {"value": "Bilbao", "count": 3, "pct": 0.3}, + ] + }, + ), + ], + }, + { + "table": "pedidos", + "columns": [ + _col("customer_id", "numeric", "", 0.0, 800), + _col("iban", "text", "iban", 0.1, 795), + ], + }, + ], + } + + +# --------------------------------------------------------------------------- # +# Golden +# --------------------------------------------------------------------------- # +def test_golden_flattens_two_tables(): + res = build_column_dictionary(_db_profile()) + assert res["status"] == "ok" + assert res["n_tables"] == 2 + assert res["n_columns"] == 5 + # Una entrada por columna, con las claves del contrato. + keys = { + "table", "column", "inferred_type", "semantic_type", + "is_pii", "null_pct", "n_distinct", "top_values", + } + for e in res["entries"]: + assert keys.issubset(e.keys()) + # El markdown tiene la tabla y la seccion de join keys. + assert "## Columnas" in res["markdown"] + assert "candidatas a join key" in res["markdown"] + + +# --------------------------------------------------------------------------- # +# PII desde el semantic_type real del grupo +# --------------------------------------------------------------------------- # +def test_pii_flagged_from_semantic_type(): + res = build_column_dictionary(_db_profile()) + pii_cols = {(e["table"], e["column"]) for e in res["pii_columns"]} + assert ("clientes", "email") in pii_cols + assert ("pedidos", "iban") in pii_cols + # customer_id / ciudad NO son PII. + assert ("clientes", "customer_id") not in pii_cols + assert ("clientes", "ciudad") not in pii_cols + # Coherencia entre is_pii en entries y la lista pii_columns. + assert res["pii_columns"] == [e for e in res["entries"] if e["is_pii"]] + + +def test_empty_semantic_type_maps_to_none_and_not_pii(): + res = build_column_dictionary(_db_profile()) + ciudad = next( + e for e in res["entries"] + if e["table"] == "clientes" and e["column"] == "ciudad" + ) + assert ciudad["semantic_type"] is None + assert ciudad["is_pii"] is False + + +# --------------------------------------------------------------------------- # +# Columnas compartidas por nombre = candidatas a join key +# --------------------------------------------------------------------------- # +def test_shared_column_names_detected_as_join_keys(): + res = build_column_dictionary(_db_profile()) + md = res["markdown"] + # customer_id aparece en las dos tablas -> listada en la seccion de join keys. + join_section = md.split("## Columnas\n")[0] + assert "customer_id" in join_section + assert "clientes" in join_section and "pedidos" in join_section + # email solo esta en una tabla -> no aparece en la seccion de join keys. + assert "email" not in join_section + + +# --------------------------------------------------------------------------- # +# top_values desde el bloque categorical +# --------------------------------------------------------------------------- # +def test_top_values_from_categorical_block(): + res = build_column_dictionary(_db_profile()) + ciudad = next(e for e in res["entries"] if e["column"] == "ciudad") + assert ciudad["top_values"] == ["Madrid", "Bilbao"] + # Columnas sin bloque categorical -> None. + email = next(e for e in res["entries"] if e["column"] == "email") + assert email["top_values"] is None + + +# --------------------------------------------------------------------------- # +# Entrada vacia / malformada -> resultado vacio en ok +# --------------------------------------------------------------------------- # +def test_empty_profile_returns_empty_ok(): + empty = build_column_dictionary({}) + assert empty == { + "status": "ok", "n_tables": 0, "n_columns": 0, + "entries": [], "pii_columns": [], "markdown": "", + } + + +def test_malformed_input_returns_empty_ok(): + for bad in (None, [], "nope", 42, {"table_profiles": "x"}): + res = build_column_dictionary(bad) + assert res["status"] == "ok" + assert res["n_columns"] == 0 + assert res["entries"] == [] + assert res["markdown"] == "" + + +def test_missing_keys_read_defensively(): + # TableProfiles y columnas con claves ausentes / basura no rompen. + profile = { + "table_profiles": [ + {"table": "t1", "columns": [{"name": "a"}, "no-dict", None]}, + "no-dict", + {"table": "t2"}, # sin columns + {"columns": [{}]}, # sin table, columna vacia + ] + } + res = build_column_dictionary(profile) + assert res["status"] == "ok" + # t1 (1 col dict valida; "no-dict" y None se saltan) + tabla sin table + # (1 col {}). t2 no tiene columns -> no cuenta como tabla. + assert res["n_tables"] == 2 + assert res["n_columns"] == 2 + a = next(e for e in res["entries"] if e["column"] == "a") + assert a["semantic_type"] is None + assert a["null_pct"] is None + assert a["n_distinct"] is None + assert a["top_values"] is None + + +# --------------------------------------------------------------------------- # +# Pureza +# --------------------------------------------------------------------------- # +def test_does_not_mutate_input(): + profile = _db_profile() + snapshot = copy.deepcopy(profile) + build_column_dictionary(profile) + assert profile == snapshot diff --git a/python/functions/datascience/decode_qr_image.py b/python/functions/datascience/decode_qr_image.py index b39e1fa3..3962a5c5 100644 --- a/python/functions/datascience/decode_qr_image.py +++ b/python/functions/datascience/decode_qr_image.py @@ -23,15 +23,20 @@ from __future__ import annotations import sys -import cv2 import numpy as np +# OpenCV (cv2) se importa de forma perezosa dentro de las funciones que lo usan: +# un import a nivel de módulo rompería `import datascience` en entornos sin +# opencv instalado (p. ej. venvs de analysis que solo usan las funciones de +# series temporales o perfilado del paquete). + # -------------------------------------------------------------------------------------------- # Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza. # -------------------------------------------------------------------------------------------- def _make_opencv_runner(detector): """Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str].""" + import cv2 def run(img): out: list[str] = [] @@ -89,6 +94,8 @@ def _make_pyzbar_runner(zbar_decode): def _build_detectors(debug=False): """Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia.""" + import cv2 + detectors = [] # OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos. @@ -135,6 +142,8 @@ def _build_detectors(debug=False): # -------------------------------------------------------------------------------------------- def _load_bgr(image_path): """Carga la imagen como BGR (uint8). Devuelve None si no se puede leer.""" + import cv2 + bgr = cv2.imread(image_path, cv2.IMREAD_COLOR) if bgr is not None: return bgr @@ -150,6 +159,8 @@ def _load_bgr(image_path): def _build_variants(image_path, upscale): """Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad.""" + import cv2 + bgr = _load_bgr(image_path) if bgr is None: return [] diff --git a/python/functions/datascience/forecast_seasonal_median.md b/python/functions/datascience/forecast_seasonal_median.md new file mode 100644 index 00000000..83f81251 --- /dev/null +++ b/python/functions/datascience/forecast_seasonal_median.md @@ -0,0 +1,94 @@ +--- +name: forecast_seasonal_median +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: pure +signature: "def forecast_seasonal_median(history: list[dict], horizon_dates: list[str], as_of: str, dow_weeks: int = 8, trend_recent_weeks: int = 4, trend_clip: tuple = (0.5, 2.0)) -> list[dict]" +description: "Forecast diario por mediana estacional (mismo dia de semana) mas factor de tendencia acotado, para una o varias series temporales. Base estacional = mediana del valor en las ultimas dow_weeks fechas con el mismo dia de semana que la fecha objetivo (dias ausentes = 0, para series intermitentes). Factor de tendencia por serie = razon de la suma de las ultimas trend_recent_weeks semanas frente a las trend_recent_weeks anteriores, clipped a trend_clip. y_pred = max(0, base * factor). Funcion pura y determinista (solo stdlib, sin I/O ni datetime.now). Nucleo del forecast de ventas diarias Aurgi (dia x centro x subcategoria CGQ)." +tags: [forecast, bigquery, timeseries, seasonal, median, baseline, sales, aurgi, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: history + desc: "lista de observaciones {series_id: str, date: 'YYYY-MM-DD', value: float}. Filas duplicadas (misma serie+fecha) se suman. Los dias sin fila dentro de las ventanas cuentan como valor 0 (series intermitentes: sin fila = sin venta)" + - name: horizon_dates + desc: "fechas futuras a predecir, strings ISO 'YYYY-MM-DD'. Tipicamente as_of+1..as_of+horizon" + - name: as_of + desc: "fecha de corte 'YYYY-MM-DD': ultimo dia de historia utilizable, inclusive. Todas las ventanas se calculan hacia atras desde aqui" + - name: dow_weeks + desc: "numero de fechas del mismo dia de semana que la objetivo a promediar (mediana) para la base estacional. Default 8 (8 semanas)" + - name: trend_recent_weeks + desc: "tamano en semanas de cada una de las dos ventanas de tendencia (reciente y anterior). Default 4: compara 4 semanas recientes vs las 4 previas" + - name: trend_clip + desc: "tupla (min, max) al que se acota el factor de tendencia. Default (0.5, 2.0): la prediccion no puede caer a menos de la mitad ni superar el doble por tendencia" +output: "list[dict]: una fila {series_id: str, date: str, y_pred: float} por cada serie presente en history y cada fecha de horizon_dates. Ordenada por series_id (asc) y luego por el orden de horizon_dates. y_pred siempre >= 0.0" +tested: true +tests: + - "serie regular con patron semanal claro da la mediana correcta" + - "serie intermitente: los dias ausentes cuentan como 0 en la mediana" + - "serie con tendencia creciente aplica factor >1 acotado a trend_clip" + - "sin datos en la ventana anterior, el factor de tendencia es 1.0" + - "horizon de 7 dias produce una fila por serie y fecha, ordenadas" +test_file_path: "python/functions/datascience/forecast_seasonal_median_test.py" +file_path: "python/functions/datascience/forecast_seasonal_median.py" +--- + +## Ejemplo + +```python +from datascience import forecast_seasonal_median + +# Historia diaria por serie (centro|subcategoria). Sin fila = sin venta = 0. +history = [ + {"series_id": "12|NEUMATICOS", "date": "2026-06-23", "value": 1450.0}, + {"series_id": "12|NEUMATICOS", "date": "2026-06-16", "value": 1380.0}, + {"series_id": "12|NEUMATICOS", "date": "2026-06-09", "value": 1500.0}, + # ... mas historia (idealmente >= 8 semanas para la base estacional) ... +] + +# as_of = ultimo dia cerrado; predice los 7 dias siguientes. +horizon = ["2026-06-30", "2026-07-01", "2026-07-02", "2026-07-03", + "2026-07-04", "2026-07-05", "2026-07-06"] + +preds = forecast_seasonal_median(history, horizon, as_of="2026-06-29") +for p in preds: + print(p["series_id"], p["date"], round(p["y_pred"], 2)) +``` + +## Cuando usarla + +Cuando necesites un baseline de forecast diario robusto y explicable para series +con estacionalidad semanal fuerte (ventas por dia de la semana) y posibles huecos +(dias sin venta). Es el nucleo puro del pipeline `run_sales_forecast`: se llama una +vez con toda la historia agregada y devuelve todas las predicciones de golpe. +Usala como punto de partida antes de modelos mas pesados (Prophet, ARIMA, gradient +boosting): captura el patron dia-de-semana + una correccion de tendencia acotada +sin dependencias externas ni entrenamiento. Ideal para muchas series a la vez +(miles de pares centro x subcategoria) donde entrenar un modelo por serie no +compensa. + +## Notas + +- Funcion pura y determinista: no hace I/O, no llama `datetime.now()`; el corte + temporal siempre es el argumento `as_of` explicito. Solo stdlib (datetime, + statistics), sin numpy ni pandas. +- La base estacional toma las fechas EXACTAS del calendario: la mas reciente + <= as_of con el mismo dia de semana que la objetivo, y de ahi 7 dias hacia atras + por punto (hasta `dow_weeks` puntos). Una fecha ausente en `history` cuenta como + 0, por lo que la mediana refleja bien las series intermitentes. +- El factor de tendencia se calcula UNA vez por serie (no depende de la fecha + objetivo) como razon de sumas de dos ventanas contiguas de `trend_recent_weeks` + semanas. Denominador 0 => factor 1.0 (evita division por cero y no infla series + que arrancan). El clip a `trend_clip` evita que un pico reciente dispare la + prediccion. +- `y_pred = max(0.0, base * factor)`: nunca negativo. No modela festivos ni eventos + puntuales; para eso se necesitaria una capa de calendario adicional. +- Para que la base estacional sea fiable conviene aportar >= `dow_weeks` semanas de + historia. Con menos historia, los puntos ausentes (=0) empujan la mediana hacia + abajo. diff --git a/python/functions/datascience/forecast_seasonal_median.py b/python/functions/datascience/forecast_seasonal_median.py new file mode 100644 index 00000000..3455f5c4 --- /dev/null +++ b/python/functions/datascience/forecast_seasonal_median.py @@ -0,0 +1,126 @@ +"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia. + +Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro +de una o varias series temporales diarias combinando dos senales: + + 1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el + MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series + intermitentes donde "sin fila" significa "sin venta"). + 2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente + respecto al periodo inmediatamente anterior (razon de sumas), acotado a un + rango para no amplificar ruido. + +Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria +CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la +demanda (los sabados venden distinto que los martes). Solo usa stdlib +(datetime, statistics). +""" + +from datetime import date, datetime, timedelta +from statistics import median + + +def _to_date(value: str) -> date: + """Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date.""" + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, datetime): + return value.date() + return datetime.strptime(value[:10], "%Y-%m-%d").date() + + +def forecast_seasonal_median( + history: list[dict], + horizon_dates: list[str], + as_of: str, + dow_weeks: int = 8, + trend_recent_weeks: int = 4, + trend_clip: tuple = (0.5, 2.0), +) -> list[dict]: + """Predice el valor de cada serie para cada fecha del horizonte. + + Para cada serie presente en `history` y cada fecha objetivo del horizonte: + + 1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el + MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las + fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de + semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la + historia cuenta como 0 (series intermitentes). + 2. Factor de tendencia por serie = suma de los valores de las ultimas + `trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la + suma de las `trend_recent_weeks` semanas anteriores a esas. Si el + denominador es 0 el factor es 1.0. Se acota a `trend_clip`. + 3. y_pred = max(0.0, base * factor). + + Args: + history: observaciones {"series_id": str, "date": "YYYY-MM-DD", + "value": float}. Filas duplicadas (misma serie y fecha) se suman. Los + dias sin fila dentro de las ventanas se tratan como valor 0. + horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD'). + as_of: fecha de corte (ultimo dia de historia utilizable, inclusive). + dow_weeks: numero de fechas del mismo dia de semana a promediar para la + base estacional. Default 8. + trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de + tendencia (reciente y anterior). Default 4. + trend_clip: (min, max) al que se acota el factor de tendencia. Default + (0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas + que duplicarse por tendencia. + + Returns: + Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por + cada serie presente en `history` y cada fecha del horizonte. Ordenada por + series_id (asc) y luego por el orden de `horizon_dates`. + """ + as_of_d = _to_date(as_of) + lo_clip, hi_clip = trend_clip + + # Mapa (series_id, date) -> valor acumulado + conjunto de series presentes. + values: dict[tuple[str, date], float] = {} + series_ids: set[str] = set() + for obs in history: + sid = obs["series_id"] + d = _to_date(obs["date"]) + v = float(obs.get("value", 0.0) or 0.0) + series_ids.add(sid) + values[(sid, d)] = values.get((sid, d), 0.0) + v + + # Ventanas de tendencia (en dias) relativas a as_of. + span = 7 * trend_recent_weeks + recent_lo = as_of_d - timedelta(days=span) # reciente: recent_lo < d <= as_of + prior_lo = as_of_d - timedelta(days=2 * span) # anterior: prior_lo < d <= recent_lo + + # Factor de tendencia por serie (una sola vez por serie, no depende del horizonte). + trend_factor: dict[str, float] = {} + for sid in series_ids: + recent_sum = 0.0 + prior_sum = 0.0 + for (s, d), v in values.items(): + if s != sid: + continue + if recent_lo < d <= as_of_d: + recent_sum += v + elif prior_lo < d <= recent_lo: + prior_sum += v + if prior_sum == 0.0: + factor = 1.0 + else: + factor = recent_sum / prior_sum + trend_factor[sid] = min(hi_clip, max(lo_clip, factor)) + + horizon = [_to_date(h) for h in horizon_dates] + out: list[dict] = [] + for sid in sorted(series_ids): + factor = trend_factor[sid] + for h_str, h_d in zip(horizon_dates, horizon): + # Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo. + back = (as_of_d.weekday() - h_d.weekday()) % 7 + anchor = as_of_d - timedelta(days=back) + dow_values = [ + values.get((sid, anchor - timedelta(days=7 * i)), 0.0) + for i in range(dow_weeks) + ] + base = median(dow_values) + y_pred = max(0.0, base * factor) + out.append({"series_id": sid, "date": h_str, "y_pred": y_pred}) + + return out diff --git a/python/functions/datascience/forecast_seasonal_median_test.py b/python/functions/datascience/forecast_seasonal_median_test.py new file mode 100644 index 00000000..c969ea7e --- /dev/null +++ b/python/functions/datascience/forecast_seasonal_median_test.py @@ -0,0 +1,113 @@ +"""Tests para forecast_seasonal_median.""" + +import os +import sys +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(__file__)) + +from forecast_seasonal_median import forecast_seasonal_median + + +def _iso(d: date) -> str: + return d.isoformat() + + +def test_serie_regular_patron_semanal_mediana_correcta(): + """serie regular con patron semanal claro da la mediana correcta.""" + # as_of martes; historia diaria de 12 semanas con valor fijo por dia de semana + # y patron constante (tendencia neutra -> factor 1). + as_of = date(2026, 6, 30) # martes + by_weekday = {0: 100.0, 1: 10.0, 2: 20.0, 3: 30.0, 4: 40.0, 5: 5.0, 6: 0.0} + history = [] + for i in range(84): # 12 semanas de dias + d = as_of - timedelta(days=i) + history.append({"series_id": "c1|sub", "date": _iso(d), "value": by_weekday[d.weekday()]}) + + horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)] # 7 dias + result = forecast_seasonal_median(history, horizon, _iso(as_of)) + + # 1 serie x 7 fechas de horizonte + assert len(result) == 7 + for row in result: + wd = date.fromisoformat(row["date"]).weekday() + # base = mediana de 8 semanas del mismo valor constante; factor = 1. + assert row["y_pred"] == by_weekday[wd] + assert row["series_id"] == "c1|sub" + + +def test_serie_intermitente_con_ceros(): + """serie intermitente: los dias ausentes cuentan como 0 en la mediana.""" + # as_of martes. La serie solo vende martes alternos (w=0,2,4,6), el resto 0. + as_of = date(2026, 6, 30) # martes + history = [] + for w in (0, 2, 4, 6): + history.append({"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 40.0}) + + horizon = [_iso(as_of + timedelta(days=7))] # proximo martes + result = forecast_seasonal_median(history, horizon, _iso(as_of)) + + # dow_weeks=8 martes: [40,0,40,0,40,0,40,0] -> mediana (0+40)/2 = 20. + # tendencia: reciente (w0..3)=40+40=80, anterior (w4..7)=40+40=80 -> factor 1. + assert len(result) == 1 + assert result[0]["y_pred"] == 20.0 + + +def test_serie_con_tendencia_creciente_factor_clipped(): + """serie con tendencia creciente aplica factor >1 acotado a trend_clip.""" + as_of = date(2026, 6, 30) # martes + # Reciente (4 martes) = 30 c/u, anterior (4 martes) = 10 c/u. + vals = {0: 30.0, 1: 30.0, 2: 30.0, 3: 30.0, 4: 10.0, 5: 10.0, 6: 10.0, 7: 10.0} + history = [ + {"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": v} + for w, v in vals.items() + ] + + horizon = [_iso(as_of + timedelta(days=7))] + result = forecast_seasonal_median(history, horizon, _iso(as_of)) + + # base = mediana de [30,30,30,30,10,10,10,10] = 20. + # factor = 120/40 = 3.0 -> clipped a 2.0 (trend_clip=(0.5,2.0)). + # y_pred = 20 * 2.0 = 40. + assert result[0]["y_pred"] == 40.0 + + +def test_serie_sin_datos_en_denominador_tendencia_factor_1(): + """sin datos en la ventana anterior, el factor de tendencia es 1.0.""" + as_of = date(2026, 6, 30) # martes + # Solo hay datos en las 4 semanas recientes (w=0..3); nada mas antiguo. + history = [ + {"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 50.0} + for w in range(4) + ] + + horizon = [_iso(as_of + timedelta(days=7))] + result = forecast_seasonal_median(history, horizon, _iso(as_of)) + + # denominador (semanas anteriores) = 0 -> factor 1.0 (no crashea). + # base = mediana [50,50,50,50,0,0,0,0] = (0+50)/2 = 25 -> y_pred = 25. + assert result[0]["y_pred"] == 25.0 + + +def test_horizon_de_7_dias_una_fila_por_serie_y_fecha(): + """horizon de 7 dias produce una fila por serie y fecha, ordenadas.""" + as_of = date(2026, 6, 30) + history = [ + {"series_id": "b|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 12.0} + for w in range(8) + ] + [ + {"series_id": "a|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 8.0} + for w in range(8) + ] + + horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)] + result = forecast_seasonal_median(history, horizon, _iso(as_of)) + + # 2 series x 7 fechas = 14 filas, ordenadas por series_id asc. + assert len(result) == 14 + assert [r["series_id"] for r in result[:7]] == ["a|x"] * 7 + assert [r["series_id"] for r in result[7:]] == ["b|x"] * 7 + # el orden de fechas dentro de cada serie respeta horizon_dates + assert [r["date"] for r in result[:7]] == horizon + # y_pred >= 0 siempre + assert all(r["y_pred"] >= 0.0 for r in result) diff --git a/python/functions/datascience/list_bq_dataset_tables.md b/python/functions/datascience/list_bq_dataset_tables.md new file mode 100644 index 00000000..1a2944e2 --- /dev/null +++ b/python/functions/datascience/list_bq_dataset_tables.md @@ -0,0 +1,79 @@ +--- +name: list_bq_dataset_tables +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: impure +signature: "def list_bq_dataset_tables(project_id: str, dataset: str, include_views: bool = True, location: str = None) -> dict" +description: "Lista todas las tablas y vistas de un dataset BigQuery y enriquece las BASE TABLE con conteo de filas y tamaño en disco. Capa de descubrimiento del grupo eda: qué hay en el dataset, cuánto pesa cada tabla, qué es tabla vs vista, antes de perfilar una concreta. Query 1 sobre INFORMATION_SCHEMA.TABLES (catálogo completo) + query 2 sobre __TABLES__ (row_count, size_bytes). Las vistas dejan n_rows/size_mb en None (contarlas exigiría full scan). Auth ADC con fix de quota project (403 USER_PROJECT_DENIED)." +tags: [eda, bigquery] +params: + - name: project_id + desc: "Proyecto GCP que contiene el dataset (ej. `autingo-159109`). Se usa como proyecto de facturación de las dos queries." + - name: dataset + desc: "Nombre del dataset BigQuery a listar (ej. `customer_marts`). Solo el dataset, sin proyecto ni tabla." + - name: include_views + desc: "True (DEFAULT) incluye tablas y vistas. False filtra y devuelve solo las BASE TABLE." + - name: location + desc: "Región del dataset para las queries (ej. `europe-west1`, `EU`). None (DEFAULT) deja que el cliente resuelva la ubicación. Necesario si el dataset vive en una región no-US." +output: "dict dict-no-throw. En éxito {status:'ok', project_id, dataset, n_tables:int, tables:[{table, fqn:'project.dataset.table', table_type:'BASE TABLE'|'VIEW'|..., n_rows:int|None, size_mb:float|None, created:str|None}]}. En error {status:'error', error:str}." +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/list_bq_dataset_tables.py" +--- + +## Ejemplo + +```python +from datascience import list_bq_dataset_tables + +# Catálogo completo del dataset (tablas + vistas) con filas y tamaño. +r = list_bq_dataset_tables("autingo-159109", "customer_marts") +print(r["status"], r["n_tables"]) +for t in r["tables"]: + print(t["table"], t["table_type"], t["n_rows"], t["size_mb"], "MB") + +# Solo tablas base, dataset en europe-west1 (necesita location). +r = list_bq_dataset_tables( + "autingo-159109", "customer_marts", + include_views=False, location="europe-west1", +) +``` + +## Cuando usarla + +- Antes de perfilar una tabla concreta con el grupo `eda` (`profile_bq_table`, `load_bq_table_to_duckdb`): descubre qué tablas y vistas hay en el dataset y cuánto pesa cada una para decidir cuál analizar. +- Cuando necesites un inventario rápido de un dataset BigQuery (nombre, tipo, filas, tamaño, fecha de creación) sin abrir la consola de GCP. +- Cuando quieras distinguir tablas base de vistas antes de una carga o un cruce (las vistas no traen conteo de filas). + +## Gotchas + +- **Impura**: hace I/O de red contra la API de BigQuery (dos queries). Requiere ADC configurado (`gcloud auth application-default login`). +- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC del usuario arrastra un quota project ajeno (memoria `bq_direct_quota_project`). Mismo patrón que `load_bq_table_to_duckdb`. +- **Región del dataset**: si el dataset vive en `europe-west1` (o cualquier región distinta de la que asume el cliente por defecto) y no pasas `location`, las queries fallan con "Not found: Dataset ... was not found in location US". Pasa `location="europe-west1"` o `location="EU"` según corresponda. Muchos datasets de Aurgi están en `europe-west1`; otros en `EU` multi-region. +- **Las vistas no traen n_rows ni size_mb**: `__TABLES__` no da conteo fiable para vistas y contarlas exigiría un full scan por vista (coste + latencia). Por eso `n_rows`/`size_mb` van a None para todo lo que no sea `BASE TABLE`. +- **size_mb es tamaño lógico en disco** (bytes de `__TABLES__` / 1024²), no el coste de una query sobre la tabla. +- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (project/dataset inválido, auth, región, permisos) devuelve `{status:'error', error:str}`. + +## Notas + +Capa de descubrimiento del grupo de capacidad `eda`. Complementa a +`load_bq_table_to_duckdb` (que trae UNA tabla a DuckDB) y a `profile_bq_table` +(que perfila UNA tabla end-to-end): esta función responde "¿qué tablas hay en +este dataset y cuáles merece la pena perfilar?". `project_id` y `dataset` se +validan con regex (`^[A-Za-z0-9\-]+$` y `^[A-Za-z0-9_]+$`) antes de +interpolarlos en los identificadores con backticks de las dos queries, para +cerrar la superficie de inyección. + +A diferencia de `bq_list_tables_py_infra` (dominio infra, usa el wrapper +`BQClient` del SDK y no enriquece con filas ni tamaño), esta función es +standalone (auth ADC propia con el fix de quota project) y devuelve el conteo de +filas y el tamaño por tabla en el estilo dict-no-throw del grupo `eda`. diff --git a/python/functions/datascience/list_bq_dataset_tables.py b/python/functions/datascience/list_bq_dataset_tables.py new file mode 100644 index 00000000..e20e75a9 --- /dev/null +++ b/python/functions/datascience/list_bq_dataset_tables.py @@ -0,0 +1,134 @@ +"""list_bq_dataset_tables — catálogo de tablas y vistas de un dataset BigQuery. + +Lista todas las tablas y vistas de un dataset de Google BigQuery y enriquece las +BASE TABLE con su conteo de filas y su tamaño en disco. Es la capa de +descubrimiento del grupo `eda`: antes de perfilar una tabla concreta (con +`profile_bq_table` / `load_bq_table_to_duckdb`) necesitas saber qué hay en el +dataset, cuántas filas pesa cada tabla y qué es tabla vs vista. + +Estrategia de dos queries: + 1. `INFORMATION_SCHEMA.TABLES` del dataset -> table_name, table_type, + creation_time de TODOS los objetos (tablas y vistas). + 2. `__TABLES__` del dataset (una sola query adicional) -> row_count y + size_bytes por tabla. Solo las BASE TABLE se enriquecen; las vistas + dejan n_rows y size_mb en None (contarlas exigiría un full scan por vista, + con coste y latencia que no compensan para un catálogo). + +Autenticación: ADC (gcloud auth). Aplica `creds.with_quota_project(None)` para +evitar el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota +project ajeno — mismo patrón que `load_bq_table_to_duckdb`. + +Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve +{status:'error', ...} en cualquier fallo. +""" + +import re + +_PROJECT_RE = re.compile(r"^[A-Za-z0-9\-]+$") +_DATASET_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def list_bq_dataset_tables( + project_id: str, + dataset: str, + include_views: bool = True, + location: str = None, +) -> dict: + try: + import google.auth + from google.cloud import bigquery + + if not project_id or not _PROJECT_RE.match(project_id): + return {"status": "error", "error": f"project_id inválido: {project_id!r}"} + if not dataset or not _DATASET_RE.match(dataset): + return {"status": "error", "error": f"dataset inválido: {dataset!r}"} + + # 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 adc_project + client = bigquery.Client(project=proj, credentials=creds) + + # Query 1: catálogo de objetos (tablas + vistas) del dataset. + info_sql = ( + "SELECT table_name, table_type, creation_time " + f"FROM `{proj}.{dataset}`.INFORMATION_SCHEMA.TABLES " + "ORDER BY table_name" + ) + info_rows = list(client.query(info_sql, location=location).result()) + + # Query 2: enriquecimiento (row_count, size_bytes) desde __TABLES__. + stats_sql = ( + "SELECT table_id, row_count, size_bytes " + f"FROM `{proj}.{dataset}`.__TABLES__" + ) + stats = {} + for row in client.query(stats_sql, location=location).result(): + stats[row["table_id"]] = (row["row_count"], row["size_bytes"]) + + tables = [] + for row in info_rows: + table_name = row["table_name"] + table_type = row["table_type"] + is_base_table = table_type == "BASE TABLE" + + if not include_views and not is_base_table: + continue + + created = row["creation_time"] + created_iso = created.isoformat() if created is not None else None + + n_rows = None + size_mb = None + if is_base_table and table_name in stats: + raw_rows, raw_bytes = stats[table_name] + if raw_rows is not None: + n_rows = int(raw_rows) + if raw_bytes is not None: + size_mb = round(int(raw_bytes) / (1024 * 1024), 3) + + tables.append( + { + "table": table_name, + "fqn": f"{proj}.{dataset}.{table_name}", + "table_type": table_type, + "n_rows": n_rows, + "size_mb": size_mb, + "created": created_iso, + } + ) + + return { + "status": "ok", + "project_id": proj, + "dataset": dataset, + "n_tables": len(tables), + "tables": tables, + } + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + +if __name__ == "__main__": + import json + import sys + + args = sys.argv[1:] + if len(args) < 2: + print( + "uso: list_bq_dataset_tables.py [--no-views] [--location LOC]", + file=sys.stderr, + ) + sys.exit(2) + proj_arg, dataset_arg = args[0], args[1] + include_views_arg = "--no-views" not in args + loc_arg = None + if "--location" in args: + loc_arg = args[args.index("--location") + 1] + result = list_bq_dataset_tables( + proj_arg, dataset_arg, include_views=include_views_arg, location=loc_arg + ) + print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) diff --git a/python/functions/datascience/load_bq_table_to_duckdb.md b/python/functions/datascience/load_bq_table_to_duckdb.md index 7bfcb19e..605ac682 100644 --- a/python/functions/datascience/load_bq_table_to_duckdb.md +++ b/python/functions/datascience/load_bq_table_to_duckdb.md @@ -3,10 +3,10 @@ name: load_bq_table_to_duckdb kind: function lang: py domain: datascience -version: "1.1.0" +version: "1.3.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)." +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, where_sql: str = '', select_sql: str = '') -> 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. Ingesta streaming Arrow -> DuckDB por batches (pyarrow.RecordBatch) para RAM acotada en tablas de decenas de millones de filas; fallback al camino DataFrame completo si pyarrow no esta. Filtra el origen con where_sql y proyecta/castea con select_sql. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)." tags: [eda, bigquery, duckdb, datascience] params: - name: table_fqn @@ -22,17 +22,36 @@ params: - 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}." + desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad. En el camino streaming se aplica POR BATCH antes de insertar." + - name: where_sql + desc: "Clausula WHERE SQL (SIN la palabra WHERE) aplicada al SELECT sobre el origen y tambien al COUNT de n_rows_source (cuenta el origen filtrado). Se combina con el muestreo (sample_frac) via AND. Ej: `fecha <= CURRENT_DATE() AND venta_n IS NOT NULL`. Se interpola tal cual: NO usar con input no confiable." + - name: select_sql + desc: "Lista de expresiones del SELECT (SIN la palabra SELECT). Vacio (DEFAULT) = `*`. Permite proyectar/castear tipos problematicos, ej. `fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n` (util para castear BIGNUMERIC a FLOAT64 antes de ingerir). Se interpola tal cual: NO usar con input no confiable." +output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized, streamed, auto_casts}. En error {status:'error', error, stage?}. streamed=True si la ingesta fue por batches Arrow; n_rows_fetched = suma de filas de los batches insertados." uses_functions: [] uses_types: [] returns: [] returns_optional: false error_type: "error_go_core" imports: [] -tested: false -tests: [] -test_file_path: "" +tested: true +tests: + - "test_default_selects_star_no_where_no_limit" + - "test_select_sql_replaces_star" + - "test_select_sql_blank_and_whitespace_fall_back_to_star" + - "test_where_sql_only" + - "test_sample_frac_only" + - "test_where_sql_and_sample_frac_combined_with_and_parenthesized" + - "test_single_condition_not_parenthesized" + - "test_max_rows_appends_limit" + - "test_max_rows_zero_or_negative_no_limit" + - "test_all_combined_order_where_then_limit" + - "test_sample_frac_out_of_range_ignored" + - "test_dest_empty_uses_last_fqn_segment" + - "test_dest_explicit_valid_kept" + - "test_dest_invalid_chars_replaced_with_underscore" + - "test_dest_from_fqn_segment_with_hyphen_sanitized" +test_file_path: "python/functions/datascience/load_bq_table_to_duckdb_test.py" file_path: "python/functions/datascience/load_bq_table_to_duckdb.py" --- @@ -56,6 +75,17 @@ r = load_bq_table_to_duckdb( sample_frac=0.05, pseudonymize_cols=["document_number", "full_name", "email", "phone"], ) + +# Filtrar el origen + castear columnas problematicas antes de ingerir. El COUNT de +# n_rows_source respeta el mismo where_sql (cuenta el origen filtrado). Streaming +# Arrow por batches: RAM acotada aunque la tabla tenga decenas de millones de filas. +r = load_bq_table_to_duckdb( + "autingo-159109.data.ventas_39M", + "/tmp/eda_ventas.duckdb", + where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL", + select_sql="fecha, idCentro, CAST(importe_bignumeric AS FLOAT64) AS importe", +) +print(r["n_rows_fetched"], "de", r["n_rows_source"], "streamed=", r["streamed"]) ``` ## Cuando usarla @@ -68,19 +98,27 @@ r = load_bq_table_to_duckdb( - **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}`. +- **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. `where_sql` y el muestreo se combinan con AND (cada condición entre paréntesis cuando hay varias, para respetar precedencia). +- **Ingesta streaming Arrow (RAM acotada)**: cuando `pyarrow` + `to_arrow_iterable` están disponibles, el resultado se materializa por `pyarrow.RecordBatch` (primer batch `CREATE OR REPLACE TABLE ... AS SELECT`, siguientes `INSERT INTO`), con `streamed=True` en el retorno. Así una tabla de decenas de millones de filas no se carga entera en RAM. El cliente BigQuery Storage se crea con las mismas credenciales corregidas (`with_quota_project(None)`). +- **Fallback DataFrame completo (carga TODO en RAM)**: si `pyarrow` o `to_arrow_iterable` no están disponibles, se cae al camino antiguo — `to_dataframe()` completo antes de materializar (`streamed=False`), que puede consumir varios GB en tablas grandes. Para acotar, pasa `sample_frac`, `max_rows` o `where_sql`. +- **Auto-cast de tipos problemáticos (v1.3.0)**: si NO se pasa `select_sql`, la función inspecciona el schema del origen (`client.get_table`) y castea automáticamente en el SELECT: BIGNUMERIC -> `CAST(col AS FLOAT64)` (Arrow decimal256, DuckDB no lo ingiere), REPEATED/RECORD/JSON -> `TO_JSON_STRING(col)` (los LIST/STRUCT rompen el perfilado aguas abajo con "unhashable type: 'list'"), GEOGRAPHY -> `ST_ASTEXT(col)`. Las transformaciones aplicadas se reportan en `auto_casts` del retorno. Si se pasa `select_sql` explícito, se respeta tal cual (sin auto-cast). Si el schema no se puede leer, degrada a `SELECT *`. El guard decimal256 en la ingesta se conserva como backstop (`{status:'error', stage:'stream_schema'|'stream_insert'}`). +- **Inyección SQL**: `where_sql` y `select_sql` (igual que `table_fqn`) se interpolan TAL CUAL en la query, sin escapar. NO los construyas a partir de input no confiable. +- **db-dtypes solo en el camino DataFrame**: la normalización de `dbdate`/`dbtime` a tipos que DuckDB reconoce solo aplica al fallback pandas. En el camino Arrow los DATE/TIME llegan como tipos Arrow nativos que DuckDB ingiere directamente. +- **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. En streaming se aplica por batch (columnas no PII conservan su tipo Arrow; las PII se reescriben a string). +- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query, ingesta) devuelve `{status:'error', error:str}` (con `stage` en fallos de ingesta streaming). ## 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`. +hueco materializando una sola tabla DuckDB desde el resultado de la query BigQuery, +por batches Arrow cuando es posible. El SELECT sobre el origen lo compone el helper +puro `_build_source_sql` (testeable sin red) y el nombre de tabla destino se sanea +con `_sanitize_dest_table` (`^[A-Za-z_][A-Za-z0-9_]*$`) antes de citarlo en el +`CREATE OR REPLACE TABLE`. ## Capability growth log +- v1.3.0 (2026-07-02) — Auto-cast de tipos problemáticos cuando no se pasa `select_sql`: inspecciona el schema del origen y castea BIGNUMERIC->FLOAT64, REPEATED/RECORD/JSON->TO_JSON_STRING y GEOGRAPHY->ST_ASTEXT (elimina el gotcha decimal256 y el "unhashable type: 'list'" de profile_table sobre columnas array). Nueva clave `auto_casts` en el retorno. Descubierto en el piloto AEDA del dataset external_datasets (product_info_mat con BIGNUMERIC, product_object con arrays). +- v1.2.0 (2026-07-02) — Añade `where_sql` (cláusula WHERE en origen, combinada con el muestreo vía AND y aplicada también al COUNT de `n_rows_source`) y `select_sql` (proyección/casteo de columnas, útil para castear BIGNUMERIC->FLOAT64). Ingesta streaming Arrow -> DuckDB por batches (`pyarrow.RecordBatch`, RAM acotada al tamaño del batch) para tablas de decenas de millones de filas que no caben como DataFrame; fallback al camino DataFrame completo si pyarrow/`to_arrow_iterable` no están. Gotcha decimal256 (BIGNUMERIC) devuelto como error con recomendación de castear vía `select_sql`. Nueva clave `streamed` en el retorno. Tests unitarios sin red del builder de SQL y del saneado del destino. - 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. diff --git a/python/functions/datascience/load_bq_table_to_duckdb.py b/python/functions/datascience/load_bq_table_to_duckdb.py index 2a468cf1..e7e5bff8 100644 --- a/python/functions/datascience/load_bq_table_to_duckdb.py +++ b/python/functions/datascience/load_bq_table_to_duckdb.py @@ -4,22 +4,35 @@ 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. +DuckDB desde el resultado de la query. 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. +es un tope duro opcional (0 = sin tope). + +Ingesta streaming Arrow -> DuckDB por batches: cuando `pyarrow` y el iterador +`to_arrow_iterable` están disponibles, el resultado se trae y materializa por +`pyarrow.RecordBatch`, insertando batch a batch en DuckDB. Así la RAM queda +acotada al tamaño de un batch y una tabla de decenas de millones de filas cabe sin +cargarse entera como DataFrame de pandas. Si `pyarrow`/`to_arrow_iterable` no están +disponibles, cae al camino DataFrame completo (que sí carga todo en RAM). + +Filtrado en origen: `where_sql` aplica una cláusula WHERE SQL sobre la tabla origen +(y también al COUNT del origen, para contar las filas filtradas). `select_sql` +permite proyectar/castear expresiones concretas en el SELECT (vacío = `*`), útil +para castear tipos problemáticos (p. ej. BIGNUMERIC -> FLOAT64) antes de ingerir. 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. +nombre, email, teléfono, etc.). En el camino streaming se aplica POR BATCH antes de +insertar. 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. +evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno. El +cliente BigQuery Storage (usado por el streaming Arrow) se crea con esas MISMAS +credenciales corregidas. Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}. """ @@ -53,6 +66,138 @@ def _safe_isna(v): return False +def _sanitize_dest_table(dest_table: str, table_fqn: str) -> str: + """Nombre de tabla DuckDB destino saneado (helper puro, testeable sin red). + + Reglas: + - `dest_table` vacío -> último segmento del FQN. + - Si el resultado no casa `^[A-Za-z_][A-Za-z0-9_]*$`, cada carácter inválido + se sustituye por `_`; si quedara vacío se usa `bq_table`. + """ + 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" + return dest + + +def _build_source_sql( + table_fqn: str, + select_sql: str = "", + where_sql: str = "", + sample_frac: float = None, + max_rows: int = 0, +) -> str: + """Compone el SELECT sobre la tabla/vista origen de BigQuery (helper puro). + + Sin efectos: solo construye la cadena SQL, testeable sin red. + + SEGURIDAD: `select_sql` y `where_sql` se interpolan TAL CUAL (no se escapan), + igual que `table_fqn`, por lo que NO deben construirse a partir de input no + confiable (riesgo de inyección SQL). + + Reglas: + - `select_sql` vacío -> `SELECT *`; en otro caso `SELECT `. + - `where_sql` y el muestreo (`rand() < sample_frac`, para `sample_frac` en + (0,1)) se combinan con AND. Si hay más de una condición cada una se + envuelve en paréntesis para respetar la precedencia de operadores. + - `max_rows` > 0 añade un `LIMIT` como tope duro. + """ + select_expr = select_sql.strip() if (select_sql and select_sql.strip()) else "*" + + conditions = [] + ws = (where_sql or "").strip() + if ws: + conditions.append(ws) + if sample_frac is not None and 0 < float(sample_frac) < 1: + conditions.append(f"rand() < {float(sample_frac)}") + + if len(conditions) > 1: + where = " WHERE " + " AND ".join(f"({c})" for c in conditions) + elif conditions: + where = " WHERE " + conditions[0] + else: + where = "" + + limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else "" + return f"SELECT {select_expr} FROM `{table_fqn}`{where}{limit}" + + +def _decimal256_columns(schema) -> list: + """Nombres de columnas Arrow de tipo decimal256 (BigQuery BIGNUMERIC). + + DuckDB no ingiere decimal256 directamente; se usa para dar un error claro que + recomiende castear esas columnas a FLOAT64 vía `select_sql`. + """ + import pyarrow as pa + return [f.name for f in schema if pa.types.is_decimal256(f.type)] + + +def _auto_select_exprs(schema_fields) -> tuple: + """Construye el SELECT auto-casteado desde el schema BigQuery (helper puro). + + Recibe la lista de campos top-level del schema de BigQuery + (`google.cloud.bigquery.SchemaField` o cualquier objeto con `.name`, + `.field_type` y `.mode`) y devuelve `(select_sql, auto_casts)`: + + - BIGNUMERIC -> CAST(col AS FLOAT64) (Arrow decimal256, DuckDB no lo ingiere) + - REPEATED / RECORD / JSON -> TO_JSON_STRING(col) (arrays/structs rompen profile_table: + "unhashable type: 'list'") + - GEOGRAPHY -> ST_ASTEXT(col) (WKT string) + - resto -> col sin tocar + + Si ninguna columna necesita transformación devuelve ("", {}) para que el + caller use `SELECT *` (comportamiento previo intacto). + """ + exprs = [] + auto_casts = {} + for f in schema_fields: + name = f.name + ftype = (f.field_type or "").upper() + mode = (getattr(f, "mode", "") or "").upper() + if mode == "REPEATED" or ftype in ("RECORD", "STRUCT", "JSON"): + exprs.append(f"TO_JSON_STRING(`{name}`) AS `{name}`") + auto_casts[name] = "TO_JSON_STRING" + elif ftype == "BIGNUMERIC": + exprs.append(f"CAST(`{name}` AS FLOAT64) AS `{name}`") + auto_casts[name] = "CAST_FLOAT64" + elif ftype == "GEOGRAPHY": + exprs.append(f"ST_ASTEXT(`{name}`) AS `{name}`") + auto_casts[name] = "ST_ASTEXT" + else: + exprs.append(f"`{name}`") + if not auto_casts: + return "", {} + return ", ".join(exprs), auto_casts + + +def _pseudonymize_arrow_table(batch, pseudo_set: set, pseudo_applied: list): + """Envuelve un `pyarrow.RecordBatch` en una `pyarrow.Table`, hasheando las PII. + + Las columnas no listadas en `pseudo_set` conservan su tipo Arrow NATIVO (DATE, + TIME, TIMESTAMP incluidos), que DuckDB ingiere directamente sin normalización. + Solo las columnas PII se reescriben a string con el hash SHA-1 truncado. + + Muta `pseudo_applied` in situ (añade el nombre de cada columna seudonimizada la + primera vez que aparece). + """ + import pyarrow as pa + if not pseudo_set: + return pa.Table.from_batches([batch]) + names = list(batch.schema.names) + arrays = [] + for i, name in enumerate(names): + col = batch.column(i) + if name in pseudo_set: + hashed = _pseudonymize_series(col.to_pylist()) + arrays.append(pa.array(hashed, type=pa.string())) + if name not in pseudo_applied: + pseudo_applied.append(name) + else: + arrays.append(col) + new_batch = pa.RecordBatch.from_arrays(arrays, names=names) + return pa.Table.from_batches([new_batch]) + + def load_bq_table_to_duckdb( table_fqn: str, duckdb_path: str, @@ -61,6 +206,8 @@ def load_bq_table_to_duckdb( max_rows: int = 0, project_id: str = "", pseudonymize_cols: list = None, + where_sql: str = "", + select_sql: str = "", ) -> dict: try: import duckdb @@ -70,10 +217,8 @@ def load_bq_table_to_duckdb( 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" + # dest_table: derivar del último segmento del FQN si no se pasa, saneado. + dest = _sanitize_dest_table(dest_table, table_fqn) # Auth ADC con fix de quota project (403 USER_PROJECT_DENIED). creds, adc_project = google.auth.default( @@ -84,9 +229,31 @@ def load_bq_table_to_duckdb( proj = project_id or table_fqn.split(".")[0] or adc_project client = bigquery.Client(project=proj, credentials=creds) - # Conteo de filas de origen. + # Auto-cast de tipos problemáticos: si el caller no proyecta un + # select_sql propio, se inspecciona el schema del origen y se castean + # automáticamente BIGNUMERIC -> FLOAT64 (Arrow decimal256 que DuckDB no + # ingiere), REPEATED/RECORD/JSON -> TO_JSON_STRING (los LIST/STRUCT + # rompen el perfilado aguas abajo) y GEOGRAPHY -> ST_ASTEXT. Best-effort: + # si el schema no se puede leer, se sigue con SELECT * como antes. + auto_casts = {} + if not (select_sql and select_sql.strip()): + try: + src = client.get_table(table_fqn) + auto_sel, auto_casts = _auto_select_exprs(src.schema) + if auto_sel: + select_sql = auto_sel + except Exception: # noqa: BLE001 + auto_casts = {} + + # Conteo de filas del origen FILTRADO: aplica el mismo `where_sql` (cuenta + # las filas que se van a traer, no la tabla entera). El muestreo NO entra + # en el conteo (es un submuestreo aparte del origen filtrado). + count_where = "" + _ws = (where_sql or "").strip() + if _ws: + count_where = f" WHERE {_ws}" cnt = client.query( - f"SELECT COUNT(*) AS n FROM `{table_fqn}`" + f"SELECT COUNT(*) AS n FROM `{table_fqn}`{count_where}" ).result() n_source = 0 for row in cnt: @@ -94,51 +261,144 @@ def load_bq_table_to_duckdb( # 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}" + # `rand() < frac`, combinado con `where_sql` vía AND. max_rows>0 es un tope + # duro opcional (LIMIT). `select_sql` proyecta expresiones (vacío = `*`). + sampled = sample_frac is not None and 0 < float(sample_frac) < 1 + sql = _build_source_sql(table_fqn, select_sql, where_sql, sample_frac, max_rows) - # Fetch: BigQuery Storage Read API (Arrow, rápido para millones de filas) - # con fallback al conversor REST si la lib no está o falla. + # ¿Está pyarrow disponible? Decide el camino de ingesta ANTES de consumir + # el resultado (streaming Arrow por batches vs DataFrame completo). try: - df = client.query(sql).result().to_dataframe(create_bqstorage_client=True) + import pyarrow # noqa: F401 + has_pyarrow = True except Exception: # noqa: BLE001 - df = client.query(sql).result().to_dataframe(create_bqstorage_client=False) - n_fetched = len(df) + has_pyarrow = False - # 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) + job = client.query(sql) + result = job.result() + use_stream = has_pyarrow and hasattr(result, "to_arrow_iterable") - # Seudonimización de columnas PII antes de escribir a disco. + pseudo_set = set(pseudonymize_cols or []) pseudo_applied = [] - for col in (pseudonymize_cols or []): - if col in df.columns: - df[col] = _pseudonymize_series(df[col].tolist()) - pseudo_applied.append(col) + n_fetched = 0 + columns = [] + streamed = False - # 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") + if use_stream: + # Cliente BigQuery Storage con las MISMAS creds corregidas + # (quota None). Si la lib no está, to_arrow_iterable cae al + # transporte REST-Arrow con bqstorage_client=None. + try: + from google.cloud import bigquery_storage + bqstorage_client = bigquery_storage.BigQueryReadClient( + credentials=creds + ) + except Exception: # noqa: BLE001 + bqstorage_client = None + + first = True + for batch in result.to_arrow_iterable( + bqstorage_client=bqstorage_client + ): + # Seudonimización PII POR BATCH; no PII conserva tipo Arrow. + tbl = _pseudonymize_arrow_table(batch, pseudo_set, pseudo_applied) + + # Gotcha BIGNUMERIC: decimal256 no lo ingiere DuckDB. Detectar + # en el primer batch y devolver un error claro que recomiende + # castear a FLOAT64 vía select_sql (no intentar magia de tipos). + if first: + dcols = _decimal256_columns(tbl.schema) + if dcols: + return { + "status": "error", + "error": ( + "Ingesta Arrow bloqueada: columnas BIGNUMERIC " + f"(Arrow decimal256) que DuckDB no ingiere: {dcols}. " + "Castéalas a FLOAT64 con select_sql, p. ej. " + "select_sql='..., CAST(col AS FLOAT64) AS col, ...'." + ), + "stage": "stream_schema", + } + + con.register("_batch_arrow", tbl) + try: + if first: + con.execute( + f'CREATE OR REPLACE TABLE "{dest}" ' + f"AS SELECT * FROM _batch_arrow" + ) + columns = list(tbl.schema.names) + first = False + else: + con.execute( + f'INSERT INTO "{dest}" SELECT * FROM _batch_arrow' + ) + except Exception as ie: # noqa: BLE001 + msg = str(ie).lower() + if "decimal256" in msg or ("decimal" in msg and "256" in msg): + return { + "status": "error", + "error": ( + "Ingesta Arrow falló por columna BIGNUMERIC " + "(Arrow decimal256) que DuckDB no ingiere. Castea " + "esas columnas a FLOAT64 con select_sql. Detalle: " + + str(ie) + ), + "stage": "stream_insert", + } + raise + finally: + con.unregister("_batch_arrow") + n_fetched += tbl.num_rows + + # Origen vacío: si el iterable no emitió ningún batch, materializa + # una tabla vacía con el esquema del origen (evita que aguas abajo + # falle por "tabla inexistente"). job.result() da un iterador fresco. + if first: + empty_df = job.result().to_dataframe(create_bqstorage_client=False) + con.register("_empty_df", empty_df) + con.execute( + f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _empty_df' + ) + con.unregister("_empty_df") + columns = list(empty_df.columns) + streamed = True + else: + # Fallback: camino DataFrame completo (carga TODO el resultado en + # RAM). Mismo comportamiento que antes del streaming Arrow. + try: + df = result.to_dataframe(create_bqstorage_client=True) + except Exception: # noqa: BLE001 + df = job.result().to_dataframe(create_bqstorage_client=False) + n_fetched = len(df) + + # Normalizar dtypes de db-dtypes (solo camino pandas): el conversor + # REST mapea DATE/TIME a las extension dtypes `dbdate`/`dbtime` de + # db-dtypes, que DuckDB NO reconoce al registrar el DataFrame. Se + # convierten a tipos estándar: DATE -> datetime64[ns], TIME -> + # string. En el camino Arrow esto no aplica (tipos Arrow nativos). + 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. + for col in (pseudonymize_cols or []): + if col in df.columns: + df[col] = _pseudonymize_series(df[col].tolist()) + pseudo_applied.append(col) + + con.register("_src_df", df) + con.execute( + f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df' + ) + con.unregister("_src_df") + columns = list(df.columns) finally: con.close() @@ -150,8 +410,10 @@ def load_bq_table_to_duckdb( "n_rows_fetched": n_fetched, "sampled": sampled, "sample_frac": float(sample_frac) if sampled else None, - "columns": list(df.columns), + "columns": columns, "pseudonymized": pseudo_applied, + "streamed": streamed, + "auto_casts": auto_casts, } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} diff --git a/python/functions/datascience/load_bq_table_to_duckdb_test.py b/python/functions/datascience/load_bq_table_to_duckdb_test.py new file mode 100644 index 00000000..4c01e680 --- /dev/null +++ b/python/functions/datascience/load_bq_table_to_duckdb_test.py @@ -0,0 +1,135 @@ +"""Tests para load_bq_table_to_duckdb. + +Cubre la lógica PURA extraíble sin red ni BigQuery: la construcción del SELECT +sobre el origen (`_build_source_sql` — combinación de where_sql + sample_frac con +AND, select_sql sustituyendo a `*`, límite duro) y el saneado del nombre de tabla +destino (`_sanitize_dest_table`). No se toca la red: importar el módulo solo carga +`hashlib`/`re` a nivel superior (BigQuery/DuckDB/pyarrow se importan dentro de la +función impura, que aquí no se invoca). +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from load_bq_table_to_duckdb import _build_source_sql, _sanitize_dest_table + +_FQN = "autingo-159109.data.ventas" + + +# --------------------------------------------------------------------------- # +# _build_source_sql — golden / defaults +# --------------------------------------------------------------------------- # +def test_default_selects_star_no_where_no_limit(): + sql = _build_source_sql(_FQN) + assert sql == "SELECT * FROM `autingo-159109.data.ventas`" + + +def test_select_sql_replaces_star(): + sql = _build_source_sql( + _FQN, + select_sql="fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n", + ) + assert sql == ( + "SELECT fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n " + "FROM `autingo-159109.data.ventas`" + ) + + +def test_select_sql_blank_and_whitespace_fall_back_to_star(): + assert _build_source_sql(_FQN, select_sql="").startswith("SELECT * FROM") + assert _build_source_sql(_FQN, select_sql=" ").startswith("SELECT * FROM") + + +# --------------------------------------------------------------------------- # +# where_sql y sample_frac — solos y combinados con AND +# --------------------------------------------------------------------------- # +def test_where_sql_only(): + sql = _build_source_sql(_FQN, where_sql="fecha <= CURRENT_DATE()") + assert sql == ( + "SELECT * FROM `autingo-159109.data.ventas` " + "WHERE fecha <= CURRENT_DATE()" + ) + + +def test_sample_frac_only(): + sql = _build_source_sql(_FQN, sample_frac=0.05) + assert sql == "SELECT * FROM `autingo-159109.data.ventas` WHERE rand() < 0.05" + + +def test_where_sql_and_sample_frac_combined_with_and_parenthesized(): + sql = _build_source_sql( + _FQN, + where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL", + sample_frac=0.1, + ) + # Dos condiciones -> cada una entre paréntesis, unidas con AND. + assert sql == ( + "SELECT * FROM `autingo-159109.data.ventas` " + "WHERE (fecha <= CURRENT_DATE() AND venta_n IS NOT NULL) " + "AND (rand() < 0.1)" + ) + + +def test_single_condition_not_parenthesized(): + # Con una sola condición no se envuelve en paréntesis (más limpio). + assert " WHERE fecha = 1" in _build_source_sql(_FQN, where_sql="fecha = 1") + + +# --------------------------------------------------------------------------- # +# max_rows (LIMIT) — solo y combinado +# --------------------------------------------------------------------------- # +def test_max_rows_appends_limit(): + sql = _build_source_sql(_FQN, max_rows=1000) + assert sql == "SELECT * FROM `autingo-159109.data.ventas` LIMIT 1000" + + +def test_max_rows_zero_or_negative_no_limit(): + assert "LIMIT" not in _build_source_sql(_FQN, max_rows=0) + assert "LIMIT" not in _build_source_sql(_FQN, max_rows=-5) + + +def test_all_combined_order_where_then_limit(): + sql = _build_source_sql( + _FQN, + select_sql="a, b", + where_sql="a > 0", + sample_frac=0.2, + max_rows=500, + ) + assert sql == ( + "SELECT a, b FROM `autingo-159109.data.ventas` " + "WHERE (a > 0) AND (rand() < 0.2) LIMIT 500" + ) + + +# --------------------------------------------------------------------------- # +# sample_frac fuera de rango -> no muestrea +# --------------------------------------------------------------------------- # +def test_sample_frac_out_of_range_ignored(): + # >=1, <=0 y None no añaden la cláusula rand(). + assert "rand()" not in _build_source_sql(_FQN, sample_frac=1.0) + assert "rand()" not in _build_source_sql(_FQN, sample_frac=0.0) + assert "rand()" not in _build_source_sql(_FQN, sample_frac=None) + + +# --------------------------------------------------------------------------- # +# _sanitize_dest_table +# --------------------------------------------------------------------------- # +def test_dest_empty_uses_last_fqn_segment(): + assert _sanitize_dest_table("", "proj.dataset.customer_profile") == "customer_profile" + + +def test_dest_explicit_valid_kept(): + assert _sanitize_dest_table("mi_tabla", _FQN) == "mi_tabla" + + +def test_dest_invalid_chars_replaced_with_underscore(): + assert _sanitize_dest_table("my-table", _FQN) == "my_table" + assert _sanitize_dest_table("weird!!name", _FQN) == "weird__name" + + +def test_dest_from_fqn_segment_with_hyphen_sanitized(): + # El último segmento con guiones se sanea (guion no es válido en identificador). + assert _sanitize_dest_table("", "proj.dataset.tabla-con-guiones") == "tabla_con_guiones" diff --git a/python/functions/pipelines/profile_bq_dataset.md b/python/functions/pipelines/profile_bq_dataset.md new file mode 100644 index 00000000..fbad7de0 --- /dev/null +++ b/python/functions/pipelines/profile_bq_dataset.md @@ -0,0 +1,133 @@ +--- +name: profile_bq_dataset +kind: pipeline +lang: py +domain: pipelines +purity: impure +version: "1.1.0" +signature: "def profile_bq_dataset(project_id: str, dataset: str, tables: list = None, include_views: bool = False, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: dict = None, report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = True, min_inclusion: float = 0.9, emit_pdf: bool = True, run_llm: bool = False) -> dict" +description: "EDA one-shot de un dataset BigQuery ENTERO con descubrimiento cross-tabla: materializa CADA tabla del dataset (COMPLETA por defecto; muestreo opt-in con sample_frac; seudonimizacion PII por tabla, LOPDGDD/RGPD) en UN MISMO DuckDB compartido con load_bq_table_to_duckdb, lo perfila entero con profile_database (perfiles por tabla + relaciones FK inter-tabla por containment + join graph Mermaid + report markdown/JSON + PDF movil), y construye el diccionario de columnas del dataset con build_column_dictionary. Es el analogo BigQuery de profile_database a nivel de dataset, resuelto por composicion estricta (list_bq_dataset_tables -> load_bq_table_to_duckdb x N -> profile_database -> build_column_dictionary) sin duplicar descubrimiento, perfilado ni inferencia de relaciones. Es el hazme un EDA de este dataset BigQuery entero y descubre como se relacionan sus tablas, en una sola llamada." +tags: [eda, bigquery, relations, launcher] +uses_functions: + - list_bq_dataset_tables_py_datascience + - load_bq_table_to_duckdb_py_datascience + - profile_database_py_pipelines + - build_column_dictionary_py_datascience +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_dataset.py" +params: + - name: project_id + desc: "Proyecto GCP (facturacion + primer segmento del FQN). Ej: 'autingo-159109'." + - name: dataset + desc: "Dataset BigQuery a perfilar entero. Ej: 'customer_marts'." + - name: tables + desc: "Lista de NOMBRES de tabla del dataset. None (DEFAULT) = todas las del dataset (filtradas por include_views)." + - name: include_views + desc: "Si True incluye las VIEW ademas de las BASE TABLE cuando tables=None. Default False (solo BASE TABLE, coherente con profile_database que salta las VIEWs)." + - name: sample_frac + desc: "None (DEFAULT) = FULL, perfila TODAS las filas de cada tabla. Un float en (0,1) activa el muestreo opt-in por tabla (`WHERE rand() < frac`)." + - name: max_rows + desc: "Tope duro de filas por tabla (LIMIT). 0 (DEFAULT) = sin tope. Se aplica a cada tabla materializada." + - name: pseudonymize_cols + desc: "Dict {\"tabla\": [\"col1\", \"col2\"]} de columnas PII a seudonimizar (hash) por tabla ANTES de materializar (LOPDGDD/RGPD [POL-MMNSEG-001-1.0]). Preserva nulos y cardinalidad." + - name: report_dir + desc: "Directorio de salida de los reports + del DuckDB compartido por defecto. Default 'reports' (artefacto local gitignored). Se crea si no existe." + - name: duckdb_path + desc: "Ruta del DuckDB compartido donde se materializan todas las tablas. Vacio (DEFAULT) = report_dir/eda_bq_.duckdb (se limpia si ya existia)." + - name: keep_duckdb + desc: "Si True (DEFAULT) conserva el DuckDB materializado: es el artefacto explorable post-EDA (notebook Jupyter, joins ad-hoc). Con False se borra al terminar." + - name: min_inclusion + desc: "Umbral de inclusion (0-1) para emitir una FK candidata cross-tabla (se pasa a profile_database -> infer_fk_containment_duckdb). Default 0.9." + - name: emit_pdf + desc: "Si True (DEFAULT) emite el PDF movil DB-level (resumen de tablas + relaciones FK + join graph). Se pasa a profile_database." + - name: run_llm + desc: "Si True (default False) activa la capa LLM interpretativa por tabla (se pasa a profile_database -> profile_table): una llamada LLM por tabla sobre el perfil agregado, nunca filas crudas." +output: "dict dict-no-throw. En exito {status:'ok', project_id, dataset, n_tables_loaded, n_tables_profiled, tables_skipped:[...], errors:[...], duckdb_path, db_profile:, column_dictionary:{entries,pii_columns} (sin markdown), report_md_path, report_json_path, report_pdf_path, dict_md_path, dict_json_path}. En error {status:'error', error, stage}." +--- + +## Ejemplo + +```python +from pipelines.profile_bq_dataset import profile_bq_dataset + +# FULL por defecto: EDA del dataset ENTERO (todas las tablas, todas las filas), +# con FK cross-tabla + join graph + diccionario de columnas. El DuckDB compartido +# queda en reports/eda_bq_customer_marts.duckdb para seguir explorando. +r = profile_bq_dataset("autingo-159109", "customer_marts") +print(r["n_tables_loaded"], "tablas materializadas,", r["n_tables_profiled"], "perfiladas") +print("FKs:", [f"{fk['from_table']}.{fk['from_col']}->{fk['to_table']}.{fk['to_col']}" + for fk in r["db_profile"]["fk_candidates"]]) +print(r["report_md_path"]); print(r["report_pdf_path"]); print(r["dict_md_path"]) +print("DuckDB explorable:", r["duckdb_path"]) + +# Dataset con tablas enormes: muestreo opt-in + PII seudonimizada por tabla. +r = profile_bq_dataset( + "autingo-159109", + "customer_marts", + sample_frac=0.05, + pseudonymize_cols={ + "customer_profile": ["document_number", "full_name", "email", "phone"], + }, +) +``` + +## Cuando usarla + +Cuando pidan un EDA de un DATASET BigQuery entero y no solo de una tabla: quieres +el perfil de todas sus tablas MAS su esquema relacional (que tabla referencia a +cual, con que cardinalidad) descubierto cross-tabla en una sola llamada. Es el +escalon a nivel de dataset sobre `profile_bq_table` (una tabla) y el adaptador +BigQuery de `profile_database` (una base DuckDB). Usala al recibir un dataset +BigQuery desconocido, para documentar un data mart, para descubrir el star schema +(las tablas hub del join graph) o antes de escribir joins sin tener el modelo +declarado. Para datasets con tablas enormes, pasa `sample_frac` o `max_rows` y +dejalo declarado en el report. + +## Gotchas + +- Impura: requiere ADC de BigQuery configurado (Application Default Credentials). + Si el ADC del usuario lleva un quota project ajeno, `load_bq_table_to_duckdb` / + `list_bq_dataset_tables` aplican `creds.with_quota_project(None)` para evitar el + 403 USER_PROJECT_DENIED — remitido a los gotchas de esas funciones. +- Coste de traer un DATASET entero: FULL por defecto materializa TODAS las filas + de CADA tabla a RAM (via BigQuery Storage Read API/Arrow) antes de volcar al + DuckDB compartido. Un dataset con varias tablas de millones de filas puede + costar en tiempo, bytes escaneados de BigQuery y GBs de RAM/disco. Acota con + `sample_frac` in (0,1) (muestreo opt-in por tabla) o `max_rows` (tope duro por + tabla). Si por limite de recursos no cabe el total, dilo explicito en el report + con el maximo que si se cargo. +- El DuckDB compartido puede ocupar GBs: todas las tablas del dataset viven en un + MISMO archivo (necesario para que la inferencia de FK opere cross-tabla). Con + `keep_duckdb=True` (default) queda en disco como artefacto explorable; pasa + `keep_duckdb=False` para borrarlo al terminar. Con `duckdb_path` explicito la + ruta se respeta; el path por defecto se limpia al inicio para no mezclar tablas + de corridas anteriores. +- FK por containment es una HEURISTICA (falsos positivos/negativos posibles) y + `profile_database` SALTA los pares de FK hacia tablas con mas de 200k filas (el + lado caro del INTERSECT): esas relaciones quedan sin evaluar. Es un mapa de + partida del esquema, no un DDL autoritativo. +- Vistas excluidas por defecto (`include_views=False`, coherente con + profile_database que salta VIEWs — perfilarlas infla n_tables y multiplica FK + falsas). Pasa `include_views=True` solo si necesitas perfilarlas como si fueran + tablas materializadas. +- Seudonimiza PII con `pseudonymize_cols` (dict por tabla) para cumplir LOPDGDD/ + RGPD [POL-MMNSEG-001-1.0] ANTES de escribir a disco: nombres, DNI/NIE, email, + telefono, direccion, IDs de cliente, IBAN, etc. Sin seudonimizar, el DuckDB + compartido + los reports contienen datos personales reales. +- Tolera fallos por tabla: si una carga falla, se anota en `errors[]` + + `tables_skipped[]` y el pipeline sigue con las demas; `n_tables_profiled` cuenta + solo las perfiladas con exito. Revisa `errors` para saber que quedo fuera. +- Escribe a `report_dir` (default 'reports', artefacto local gitignored): el report + DB-level de `profile_database` (markdown + JSON + PDF si emit_pdf) MAS el + diccionario de columnas (`..._dict.md` + `..._dict.json`). + +## Capability growth log + +- v1.1.0 (2026-07-02) — añade `run_llm` (passthrough a profile_database -> profile_table: una llamada LLM por tabla sobre el perfil agregado). Default False, sin breaking changes. diff --git a/python/functions/pipelines/profile_bq_dataset.py b/python/functions/pipelines/profile_bq_dataset.py new file mode 100644 index 00000000..20d04c3e --- /dev/null +++ b/python/functions/pipelines/profile_bq_dataset.py @@ -0,0 +1,263 @@ +"""profile_bq_dataset — EDA one-shot de un dataset BigQuery ENTERO (cross-tabla). + +Pipeline impuro: perfila un dataset de Google BigQuery COMPLETO con descubrimiento +cross-tabla (relaciones FK inter-tabla + join graph + diccionario de columnas). Es +el analogo a nivel de dataset de `profile_bq_table` (que perfila UNA tabla) y el +adaptador BigQuery de `profile_database` (que perfila una base DuckDB entera), +resuelto por composicion estricta de funciones del registry — sin reimplementar +ni el descubrimiento, ni el perfilado, ni la inferencia de relaciones. + +Clave del diseno: materializa CADA tabla del dataset en UN MISMO archivo DuckDB +compartido, para que la inferencia de FK por containment (que necesita todas las +tablas en la misma base) opere cross-tabla. El DuckDB compartido queda como el +artefacto explorable post-EDA (keep_duckdb=True por defecto). + +Funciones del registry compuestas (NO se reimplementa su logica): + - list_bq_dataset_tables : catalogo de tablas/vistas del dataset (descubrimiento). + - load_bq_table_to_duckdb: materializa cada tabla BigQuery al DuckDB compartido + (completa por defecto, muestra si sample_frac; PII + seudonimizada por tabla antes de escribir a disco). + - profile_database : perfila el DuckDB compartido entero (perfiles por + tabla + FK por containment + join graph Mermaid + + report markdown/JSON + PDF movil DB-level). + - build_column_dictionary: diccionario de columnas buscable (tipo semantico, PII) + a partir del DatabaseProfile ensamblado. + +Modo por defecto = FULL: `sample_frac=None` trae cada tabla entera (preferencia +estandar del usuario: los EDA se corren sobre el total salvo que se pida lo +contrario). El muestreo es opt-in explicito por dataset: `sample_frac=0.05` trae +~5 % de cada tabla; `max_rows` es un tope duro por tabla (0 = sin tope). + +Seudonimizacion LOPDGDD/RGPD [POL-MMNSEG-001-1.0]: `pseudonymize_cols` es un dict +`{"tabla": ["col1", "col2"]}` que se aplica por tabla ANTES de materializar. + +Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y +devuelve {status:'error', error, stage}. Los fallos por tabla individual se +toleran: se anotan en errors[]/tables_skipped[] y se sigue con las demas. +""" + +import json +import os +from datetime import datetime, timezone + +from datascience import ( + build_column_dictionary, + list_bq_dataset_tables, + load_bq_table_to_duckdb, +) +from pipelines.profile_database import profile_database + + +def profile_bq_dataset( + project_id: str, + dataset: str, + tables: list = None, + include_views: bool = False, + sample_frac: float = None, + max_rows: int = 0, + pseudonymize_cols: dict = None, + report_dir: str = "reports", + duckdb_path: str = "", + keep_duckdb: bool = True, + min_inclusion: float = 0.9, + emit_pdf: bool = True, + run_llm: bool = False, +) -> dict: + """EDA one-shot de un dataset BigQuery entero, con descubrimiento cross-tabla. + + Materializa cada tabla del dataset a un DuckDB compartido, lo perfila entero + con `profile_database` (perfiles por tabla + FK inter-tabla + join graph + + reports + PDF) y construye el diccionario de columnas del dataset. Por defecto + perfila TODAS las filas de cada tabla (`sample_frac=None`, modo FULL) y solo + las BASE TABLE (las vistas se excluyen salvo `include_views=True`). + + Args: + project_id: proyecto GCP (facturacion + primer segmento del FQN). + dataset: dataset BigQuery a perfilar. + tables: lista de NOMBRES de tabla del dataset. None (default) = todas las + del dataset (filtradas por include_views). + include_views: si True incluye las VIEW ademas de las BASE TABLE cuando + tables=None. Default False (solo BASE TABLE, coherente con + profile_database que salta las VIEWs). + sample_frac: None (default) = FULL, perfila todas las filas de cada tabla. + Un float en (0,1) activa el muestreo opt-in por tabla. + max_rows: tope duro de filas por tabla (LIMIT). 0 (default) = sin tope. + pseudonymize_cols: dict {"tabla": ["col1", "col2"]} de columnas PII a + seudonimizar (hash) por tabla ANTES de materializar (LOPDGDD/RGPD). + report_dir: directorio de salida de los reports + del DuckDB por defecto. + duckdb_path: ruta del DuckDB compartido. Vacio = report_dir/eda_bq_.duckdb. + keep_duckdb: si True (default) conserva el DuckDB materializado (es el + artefacto explorable post-EDA). Con False se borra al terminar. + min_inclusion: umbral de inclusion (0-1) para emitir una FK candidata + (se pasa a profile_database -> infer_fk_containment_duckdb). + emit_pdf: si True (default) emite el PDF movil DB-level (se pasa a + profile_database). + run_llm: si True (default False) activa la capa LLM interpretativa por + tabla (se pasa a profile_database -> profile_table; una llamada LLM + por tabla sobre el perfil agregado, nunca filas crudas). + + Returns: + dict dict-no-throw con el resultado del pipeline (ver output del .md). + """ + try: + # 1) Resolver la lista de tablas a materializar como (nombre, fqn). + if tables is None: + lst = list_bq_dataset_tables( + project_id, dataset, include_views=include_views + ) + if lst.get("status") != "ok": + return { + "status": "error", + "error": lst.get("error", "list_bq_dataset_tables fallo"), + "stage": "list_tables", + } + table_specs = [ + (t["table"], t.get("fqn") or f"{project_id}.{dataset}.{t['table']}") + for t in lst.get("tables", []) + ] + elif isinstance(tables, list): + table_specs = [ + (name, f"{project_id}.{dataset}.{name}") for name in tables + ] + else: + return { + "status": "error", + "error": "tables debe ser una lista de nombres o None", + "stage": "list_tables", + } + + if not table_specs: + return { + "status": "error", + "error": ( + "el dataset no tiene tablas que perfilar " + "(revisa include_views / el nombre del dataset)" + ), + "stage": "list_tables", + } + + # 2) Resolver el DuckDB compartido. Vacio -> report_dir/eda_bq_.duckdb. + os.makedirs(report_dir, exist_ok=True) + created_default = False + if not duckdb_path: + duckdb_path = os.path.join(report_dir, f"eda_bq_{dataset}.duckdb") + created_default = True + # Si es el path por defecto y ya existe de una corrida previa, empezar + # limpio para que no se mezclen tablas de datasets distintos en la base. + if created_default and os.path.exists(duckdb_path): + try: + os.remove(duckdb_path) + except OSError: + pass + + # 3) Materializar CADA tabla en el MISMO DuckDB (tolerando fallos). + pseudo_map = pseudonymize_cols or {} + loaded_tables = [] # nombres de tabla dentro del DuckDB + tables_skipped = [] + errors = [] + for name, fqn in table_specs: + load = load_bq_table_to_duckdb( + fqn, + duckdb_path, + sample_frac=sample_frac, + max_rows=max_rows, + project_id=project_id, + pseudonymize_cols=pseudo_map.get(name), + ) + if load.get("status") == "ok": + loaded_tables.append(load["table"]) + else: + errors.append( + { + "table": name, + "stage": "load", + "error": load.get("error", "load fallo"), + } + ) + tables_skipped.append(name) + + if not loaded_tables: + return { + "status": "error", + "error": "ninguna tabla del dataset se pudo materializar", + "stage": "load", + "errors": errors, + } + + # 4) Perfilar el DuckDB compartido entero: perfiles por tabla + FK + # cross-tabla + join graph + report markdown/JSON + PDF (si emit_pdf). + prof = profile_database( + duckdb_path, + tables=loaded_tables, + report_dir=report_dir, + write_report=True, + min_inclusion=min_inclusion, + emit_pdf=emit_pdf, + run_llm=run_llm, + ) + if prof.get("status") != "ok": + return { + "status": "error", + "error": prof.get("error", "profile_database fallo"), + "stage": "profile", + } + db_profile = prof.get("db_profile", {}) + + # 5) Diccionario de columnas del dataset sobre el DatabaseProfile. + dict_md_path = None + dict_json_path = None + column_dictionary = None + dict_res = build_column_dictionary(db_profile) + if dict_res.get("status") == "ok": + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + dict_md_path = os.path.join( + report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.md" + ) + dict_json_path = os.path.join( + report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.json" + ) + with open(dict_md_path, "w", encoding="utf-8") as fh: + fh.write(dict_res.get("markdown", "")) + # JSON sidecar del diccionario sin el markdown (compacto). + dict_payload = {k: v for k, v in dict_res.items() if k != "markdown"} + with open(dict_json_path, "w", encoding="utf-8") as fh: + fh.write( + json.dumps(dict_payload, ensure_ascii=False, indent=1, default=str) + ) + column_dictionary = dict_payload + else: + errors.append( + { + "stage": "column_dictionary", + "error": dict_res.get("error", "build_column_dictionary fallo"), + } + ) + + # 6) Limpieza del DuckDB compartido salvo que se pida conservarlo. + final_duckdb = duckdb_path + if not keep_duckdb and os.path.exists(duckdb_path): + try: + os.remove(duckdb_path) + except OSError: + pass + final_duckdb = None + + return { + "status": "ok", + "project_id": project_id, + "dataset": dataset, + "n_tables_loaded": len(loaded_tables), + "n_tables_profiled": db_profile.get("n_tables", 0), + "tables_skipped": tables_skipped, + "errors": errors, + "duckdb_path": final_duckdb, + "db_profile": db_profile, + "column_dictionary": column_dictionary, + "report_md_path": prof.get("report_md_path"), + "report_json_path": prof.get("report_json_path"), + "report_pdf_path": prof.get("report_pdf_path"), + "dict_md_path": dict_md_path, + "dict_json_path": dict_json_path, + } + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e), "stage": "unexpected"} diff --git a/python/functions/pipelines/profile_bq_table.md b/python/functions/pipelines/profile_bq_table.md index 7621b07a..bd338ddc 100644 --- a/python/functions/pipelines/profile_bq_table.md +++ b/python/functions/pipelines/profile_bq_table.md @@ -4,8 +4,8 @@ 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" +version: "1.2.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, where_sql: str = \"\", select_sql: str = \"\") -> 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: @@ -43,7 +43,11 @@ params: 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}." + - name: where_sql + desc: "Clausula WHERE SQL (sin la palabra WHERE) aplicada al origen y a su COUNT. Pass-through a load_bq_table_to_duckdb; se combina con sample_frac via AND. Ej: `fecha <= CURRENT_DATE() AND venta_n IS NOT NULL`. Se interpola tal cual: no usar con input no confiable." + - name: select_sql + desc: "Expresiones del SELECT (sin la palabra SELECT); vacio (DEFAULT) = `*`. Pass-through a load_bq_table_to_duckdb. Util para castear tipos problematicos (p. ej. BIGNUMERIC->FLOAT64) antes de perfilar. Se interpola tal cual: no usar con input no confiable." +output: "dict dict-no-throw. En exito {status:'ok', table_fqn, load:{n_rows_source,n_rows_fetched,sampled,sample_frac,pseudonymized,table,streamed, where_sql?, select_sql?}, duckdb_path, report_md_path, report_json_path, aeda_pdf_path, aeda_pptx_path, aeda_manifest_path, profile}. En error {status:'error', error, stage}. where_sql/select_sql aparecen en load solo si vienen informados." --- ## Ejemplo @@ -66,6 +70,16 @@ r = profile_bq_table( sample_frac=0.05, pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"], ) + +# Filtrar el origen + castear una columna BIGNUMERIC antes de perfilar. where_sql y +# select_sql se pasan al loader (que ingiere por batches Arrow, RAM acotada). +r = profile_bq_table( + "autingo-159109.data.ventas_39M", + where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL", + select_sql="fecha, idCentro, CAST(importe_bignumeric AS FLOAT64) AS importe", + run_models=True, +) +print(r["load"]["n_rows_fetched"], "filas, streamed=", r["load"].get("streamed")) ``` ## Cuando usarla @@ -83,12 +97,19 @@ en RAM, pasa `sample_frac` (muestreo opt-in). - 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. +- FULL por defecto: `sample_frac=None` perfila TODAS las filas del origen. El + loader `load_bq_table_to_duckdb` (v1.2.0) ingiere por batches Arrow -> + DuckDB cuando `pyarrow` esta disponible, con la RAM acotada al tamano de un + batch (una tabla de decenas de millones de filas cabe sin cargarse entera); si + no, cae al camino DataFrame completo (todo en RAM, varios GB posibles). Para + acotar coste/memoria pasa `sample_frac` in (0,1), `max_rows` (tope duro) o + `where_sql` (filtra el origen). Si por limite de recursos no cabe el total, + dilo explicito con el maximo que si se cargo. +- `where_sql` / `select_sql` (pass-through al loader) se interpolan TAL CUAL en la + query BigQuery: NO los construyas a partir de input no confiable (inyeccion SQL). + `select_sql` es la via para castear columnas BIGNUMERIC (Arrow decimal256, que + DuckDB no ingiere) a FLOAT64 antes de perfilar; si no las casteas, el loader + devuelve `{status:'error', stage:'stream_schema'|'stream_insert'}`. - 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 @@ -103,4 +124,5 @@ en RAM, pasa `sample_frac` (muestreo opt-in). ## Capability growth log +- v1.2.0 (2026-07-02) — Añade `where_sql` y `select_sql` como pass-through al loader `load_bq_table_to_duckdb`: filtran/proyectan el origen antes de perfilar (`where_sql` tambien acota el COUNT del origen; `select_sql` permite castear BIGNUMERIC->FLOAT64). Ambos se reflejan en el bloque `load` del retorno (solo si vienen informados), junto con la nueva clave `streamed`. Hereda del loader v1.2.0 la ingesta streaming Arrow -> DuckDB por batches (RAM acotada) para tablas que no caben en RAM, con fallback DataFrame completo. - 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. diff --git a/python/functions/pipelines/profile_bq_table.py b/python/functions/pipelines/profile_bq_table.py index 37ad52a1..cf897d3d 100644 --- a/python/functions/pipelines/profile_bq_table.py +++ b/python/functions/pipelines/profile_bq_table.py @@ -42,6 +42,8 @@ def profile_bq_table( report_dir: str = "reports", duckdb_path: str = "", keep_duckdb: bool = False, + where_sql: str = "", + select_sql: str = "", ) -> dict: """EDA one-shot de una tabla/vista BigQuery. @@ -63,6 +65,13 @@ def profile_bq_table( 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. + where_sql: Clausula WHERE SQL (sin la palabra WHERE) aplicada al origen y a + su COUNT. Pass-through a `load_bq_table_to_duckdb`. Ej: + "fecha <= CURRENT_DATE() AND venta_n IS NOT NULL". Se interpola tal cual: + no usar con input no confiable. + select_sql: Expresiones del SELECT (sin la palabra SELECT); vacio = `*`. + Pass-through a `load_bq_table_to_duckdb`. Util para castear tipos + problematicos (p. ej. BIGNUMERIC->FLOAT64) antes de perfilar. Returns: dict dict-no-throw con el resultado del pipeline (ver output del .md). @@ -83,6 +92,8 @@ def profile_bq_table( max_rows=max_rows, project_id=project_id, pseudonymize_cols=pseudonymize_cols, + where_sql=where_sql, + select_sql=select_sql, ) if load.get("status") != "ok": return { @@ -111,14 +122,24 @@ def profile_bq_table( "load": load, } + load_block = { + k: load[k] + for k in ( + "n_rows_source", "n_rows_fetched", "sampled", "sample_frac", + "pseudonymized", "table", "streamed", + ) + if k in load + } + # Trazabilidad de los filtros de origen (solo si vienen informados). + if where_sql: + load_block["where_sql"] = where_sql + if select_sql: + load_block["select_sql"] = select_sql + 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 - }, + "load": load_block, "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"), diff --git a/python/functions/pipelines/profile_database.md b/python/functions/pipelines/profile_database.md index 7b80bcb2..d33d56c2 100644 --- a/python/functions/pipelines/profile_database.md +++ b/python/functions/pipelines/profile_database.md @@ -4,8 +4,8 @@ kind: pipeline lang: py domain: pipelines purity: impure -version: "1.0.0" -signature: "def profile_database(db_path: str, tables: list = None, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True, min_inclusion: float = 0.9) -> dict" +version: "1.1.0" +signature: "def profile_database(db_path: str, tables: list = None, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True, min_inclusion: float = 0.9, emit_pdf: bool = False, run_llm: bool = False) -> dict" description: "Orquestador one-shot del grupo eda a nivel de BASE: perfila TODA una base DuckDB (todas las tablas o las indicadas) componiendo profile_table por tabla, infiere las relaciones FK inter-tabla por containment y construye el join graph con diagrama Mermaid. Ensambla un DatabaseProfile (resumen por tabla + TableProfiles completos + fk_candidates + join_graph) y opcionalmente emite un report markdown DB-level + JSON sidecar. Es la composicion canonica para hazme un EDA de esta base de datos y entender su esquema relacional." tags: [eda, relations, duckdb, profiling, data-quality, pipeline, dataops] uses_functions: @@ -38,6 +38,10 @@ params: desc: "Si True (default) escribe report markdown DB-level + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None." - name: min_inclusion desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata (se pasa a infer_fk_containment_duckdb). Default 0.9." + - name: emit_pdf + desc: "Si True (default False) renderiza el PDF movil DB-level con render_eda_pdf_relational junto a los reports (report_pdf_path en el retorno)." + - name: run_llm + desc: "Si True (default False) activa la capa LLM interpretativa de profile_table para CADA tabla: una llamada LLM por tabla sobre el perfil agregado, nunca filas crudas." output: "dict {status:'ok', db_profile:, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)." --- @@ -101,3 +105,7 @@ se infieren las FK y se dibuja el diagrama de relaciones. perfiladas con exito. Revisa `errors` para saber que quedo fuera. - `db_path` debe existir: DuckDB read-only NO crea la base. El muestreo de cada tabla usa el sandbox read-only por defecto (sin acceso a FS/red). + +## Capability growth log + +- v1.1.0 (2026-07-02) — añade `run_llm` (passthrough a profile_table: capa LLM interpretativa por tabla) y documenta `emit_pdf` en el frontmatter (existía en el código desde el renderer relational). Sin breaking changes: ambos default False. diff --git a/python/functions/pipelines/profile_database.py b/python/functions/pipelines/profile_database.py index 7f63c893..4ed494ad 100644 --- a/python/functions/pipelines/profile_database.py +++ b/python/functions/pipelines/profile_database.py @@ -121,6 +121,7 @@ def profile_database( write_report: bool = True, min_inclusion: float = 0.9, emit_pdf: bool = False, + run_llm: bool = False, ) -> dict: """Perfila una base DuckDB entera + sus relaciones inter-tabla. @@ -141,6 +142,9 @@ def profile_database( render_eda_pdf_relational (resumen de tablas + relaciones FK + join graph) junto a los reports y devuelve su ruta en report_pdf_path. Con False no se toca el PDF (retrocompatible) y report_pdf_path es None. + run_llm: si True (default False) activa la capa LLM interpretativa de + profile_table para CADA tabla (una llamada LLM por tabla sobre el + perfil agregado, nunca filas crudas). Returns: dict dict-no-throw. En exito: @@ -177,7 +181,9 @@ def profile_database( # 2) Perfilar cada tabla (tolerando fallos individuales). for table in tables: - r = profile_table(db_path, table, sample=sample, write_report=False) + r = profile_table( + db_path, table, sample=sample, write_report=False, run_llm=run_llm + ) if r.get("status") == "ok": prof = r["profile"] table_profiles.append(prof) diff --git a/python/functions/pipelines/run_sales_forecast.md b/python/functions/pipelines/run_sales_forecast.md new file mode 100644 index 00000000..540cc12c --- /dev/null +++ b/python/functions/pipelines/run_sales_forecast.md @@ -0,0 +1,103 @@ +--- +name: run_sales_forecast +kind: pipeline +lang: py +domain: pipelines +purity: impure +version: "1.1.0" +signature: "def run_sales_forecast(as_of: str = '', horizon: int = 7, model: str = 'baseline_v1', author: str = 'egutierrez', dry_run: bool = False) -> dict" +description: "Forecast diario de ventas Aurgi (dia x centro x subcategoria CGQ) escrito en BigQuery autingo-159109.sales_forecast.predictions, en una sola llamada. Compone funciones del registry: bq_auth(drop_quota_project=True) para el cliente sin quota project ajeno, bq_query para leer la historia agregada del mart bi_ventas_mart.base_margenes_aa (18 semanas, venta_n saneado) y ejecutar el DELETE de idempotencia, forecast_seasonal_median (modelo PURO mediana estacional + tendencia acotada) para generar todas las predicciones, y bq_load_from_file para cargar el JSONL a la tabla de predicciones. Historia utilizable hasta as_of-1 (el dia as_of esta parcial cuando corre el cron a las 21:00); predice as_of+1..as_of+horizon; run_date=as_of. Solo predice series activas (venta>0 en las ultimas 8 semanas). Idempotente por (run_date, model, author). --dry-run no escribe." +tags: [forecast, bigquery, sales, aurgi, pipeline, launcher] +uses_functions: + - forecast_seasonal_median_py_datascience + - bq_auth_py_infra + - bq_query_py_infra + - bq_load_from_file_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [google-cloud-bigquery] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/pipelines/run_sales_forecast.py" +params: + - name: as_of + desc: "fecha de corte 'YYYY-MM-DD' (dia de la corrida). Vacio (DEFAULT) = hoy. La historia utilizable llega hasta as_of-1 dia (el dia as_of esta parcial en el cron 21:00); se predice as_of+1..as_of+horizon; run_date=as_of" + - name: horizon + desc: "numero de dias futuros a predecir a partir de as_of+1. Default 7" + - name: model + desc: "etiqueta del modelo escrita en la columna model de cada fila. Default 'baseline_v1'. Forma parte de la clave de idempotencia" + - name: author + desc: "autor de la corrida (columna author). Default 'egutierrez'. Forma parte de la clave de idempotencia" + - name: dry_run + desc: "si True no escribe en BigQuery (ni DELETE ni load): devuelve el resumen + una muestra de 5 filas. Default False" +output: "dict dict-no-throw. En exito {status:'ok', run_date, series:N (series activas), rows:N (filas predichas), model, author, rows_loaded, job_id}; con dry_run=True incluye sample:[5 filas] y omite rows_loaded/job_id. En error {status:'error', error, stage}. Por stdout imprime el JSON del resumen; exit 0 si ok, 1 si error" +--- + +## Ejemplo + +```bash +# Corrida real (cron 21:00): predice los 7 dias siguientes a hoy y carga a BigQuery. +./fn run run_sales_forecast + +# Fecha de corte y horizonte explicitos, sin escribir (revisar la muestra): +./fn run run_sales_forecast --as-of 2026-07-01 --horizon 7 --dry-run + +# Modelo alternativo (clave de idempotencia distinta: no pisa baseline_v1): +./fn run run_sales_forecast --model baseline_v2 --author egutierrez +``` + +```python +# Uso programatico (venv del proyecto, PYTHONPATH=python/functions): +from pipelines.run_sales_forecast import run_sales_forecast + +r = run_sales_forecast(as_of="2026-07-01", horizon=7, dry_run=True) +print(r["series"], "series activas,", r["rows"], "filas") +for row in r["sample"]: + print(row["forecast_date"], row["center_id"], row["subcat_cgq"], row["y_pred"]) +``` + +## Cuando usarla + +Cuando quieras (re)generar el forecast diario de ventas Aurgi por centro y +subcategoria CGQ y dejarlo en `autingo-159109.sales_forecast.predictions` en una +sola llamada. Es el pipeline que dispara el cron nocturno (21:00): lee la historia +del mart, aplica el baseline estacional, y carga las predicciones de forma +idempotente. Usa `--dry-run` para inspeccionar la muestra antes de escribir, o +para probar tras un cambio en el mart o en el modelo. Cambia `--model` para probar +una variante sin pisar las predicciones del modelo actual (la clave de +idempotencia es run_date + model + author). + +## Gotchas + +- Impura: requiere ADC de BigQuery configurado (`gcloud auth application-default + login`) con acceso a `autingo-159109`. Usa `bq_auth(drop_quota_project=True)` + para descartar el quota project del ADC del usuario `egutierrez` y evitar el + `403 USER_PROJECT_DENIED` (gotcha conocido del repo). +- Escribe en produccion: en modo real hace `DELETE` de las predicciones previas de + `(run_date, model, author)` y luego carga (WRITE_APPEND). Es idempotente para esa + combinacion: re-ejecutar la misma corrida no duplica. Cambiar `model` o `author` + crea un conjunto de predicciones paralelo. Usa `--dry-run` si solo quieres mirar. +- La tabla `sales_forecast.predictions` debe existir con schema fijo y con las + columnas exactas que emite el pipeline: `run_ts` (TIMESTAMP), `run_date` (DATE), + `forecast_date` (DATE), `lag_days` (INT64), `center_id` (STRING), `center_name` + (STRING), `ambito` (STRING), `subcat_cgq` (STRING), `model` (STRING), `author` + (STRING), `y_pred` (FLOAT64). El load usa `autodetect=False`: los nombres del + JSONL deben coincidir con los de la tabla o el load falla. +- `center_id` se emite como STRING (str(idCentro)); `subcat_cgq` toma el valor de + la columna `subcat_cqq` del mart (el nombre difiere entre origen y destino a + proposito). center_name/ambito son los ultimos conocidos por serie (fecha maxima). +- Historia hasta as_of-1: el dia `as_of` NO entra en la historia (esta parcial en + el cron de las 21:00). Si necesitas incluir el dia en curso, pasa `--as-of` con + el dia siguiente. +- Solo predice series con venta > 0 en las ultimas 8 semanas: las series muertas se + omiten (no aparecen en la tabla). `series` en la salida cuenta las activas. +- Guarda 18 semanas de historia del mart: cubre la ventana estacional (8 semanas + del mismo dia) mas la tendencia (4+4 semanas) con margen. venta_n se filtra + `ABS < 1e9` para descartar las filas veneno del mart. + +## Capability growth log + +- v1.1.0 (2026-07-02) — añade paso 9: refresh de `sales_forecast.actuals_daily` (tabla física de venta real, ventana móvil de 10 días) tras cargar las predicciones; `forecast_eval` y el dashboard de competición comparan contra ella. diff --git a/python/functions/pipelines/run_sales_forecast.py b/python/functions/pipelines/run_sales_forecast.py new file mode 100644 index 00000000..722f2798 --- /dev/null +++ b/python/functions/pipelines/run_sales_forecast.py @@ -0,0 +1,298 @@ +"""run_sales_forecast — forecast diario de ventas Aurgi a BigQuery (one-shot). + +Pipeline IMPURO que produce el forecast diario de ventas (dia x centro x +subcategoria CGQ) y lo escribe en `autingo-159109.sales_forecast.predictions`. +Compone funciones del registry sin reimplementar su logica: + + - bq_auth(..., drop_quota_project=True): cliente BigQuery sin quota project ajeno + (evita el 403 USER_PROJECT_DENIED del ADC del usuario). + - bq_query: lee la historia agregada del mart `bi_ventas_mart.base_margenes_aa` + y ejecuta el DELETE de idempotencia (parametros tipados). + - forecast_seasonal_median: modelo PURO (mediana estacional + tendencia acotada) + que genera todas las predicciones de golpe. + - bq_load_from_file: carga las filas (JSONL) a la tabla de predicciones. + +Cron previsto: 21:00. Por eso la historia utilizable llega hasta as_of - 1 dia +(el dia as_of aun esta parcial) y se predice as_of + 1 .. as_of + horizon. + +Estilo dict-no-throw: nunca lanza; captura errores y devuelve +{status:'error', error, stage}. Idempotente por (run_date, model, author): +borra las predicciones previas de esa combinacion antes de cargar. +""" + +import json +import os +import sys +import tempfile +from datetime import date, datetime, timedelta, timezone + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from bigquery import bq_auth, bq_query, bq_load_from_file +from datascience import forecast_seasonal_median + +PROJECT_ID = "autingo-159109" +SOURCE_TABLE = "autingo-159109.bi_ventas_mart.base_margenes_aa" +DEST_DATASET = "sales_forecast" +DEST_TABLE = "predictions" + +HISTORY_SQL = f""" +SELECT fecha, idCentro, subcat_cqq, + ANY_VALUE(NombreCentro) AS center_name, ANY_VALUE(Ambito) AS ambito, + SUM(CAST(venta_n AS FLOAT64)) AS venta +FROM `{SOURCE_TABLE}` +WHERE fecha BETWEEN DATE_SUB(@as_of, INTERVAL 18 WEEK) AND DATE_SUB(@as_of, INTERVAL 1 DAY) + AND venta_n IS NOT NULL AND ABS(CAST(venta_n AS FLOAT64)) < 1e9 + AND subcat_cqq IS NOT NULL AND idCentro IS NOT NULL +GROUP BY fecha, idCentro, subcat_cqq +""" + +DELETE_SQL = ( + f"DELETE FROM `{PROJECT_ID}.{DEST_DATASET}.{DEST_TABLE}` " + "WHERE run_date = @d AND model = @m AND author = @a" +) + +# Refresh de la tabla fisica de reales (sales_forecast.actuals_daily), consumida +# por la vista forecast_eval y por los dashboards de competicion. Ventana movil +# para recoger correcciones retroactivas del mart. +ACTUALS_DELETE_SQL = ( + f"DELETE FROM `{PROJECT_ID}.{DEST_DATASET}.actuals_daily` " + "WHERE fecha BETWEEN DATE_SUB(@as_of, INTERVAL @w DAY) AND DATE_SUB(@as_of, INTERVAL 1 DAY)" +) + +ACTUALS_INSERT_SQL = f""" +INSERT INTO `{PROJECT_ID}.{DEST_DATASET}.actuals_daily` + (fecha, center_id, center_name, ambito, subcat_cgq, y_real, unidades, loaded_ts) +SELECT forecast_date, IFNULL(center_id, 'SIN_CENTRO'), center_name, ambito, + IFNULL(subcat_cgq, 'Sin subcategoria'), + y_real, unidades, CURRENT_TIMESTAMP() +FROM `{PROJECT_ID}.{DEST_DATASET}.actuals` +WHERE forecast_date BETWEEN DATE_SUB(@as_of, INTERVAL @w DAY) AND DATE_SUB(@as_of, INTERVAL 1 DAY) +""" + + +def _refresh_actuals(client, as_of: date, window_days: int = 10) -> None: + """Rehace los ultimos `window_days` dias de actuals_daily desde la vista actuals.""" + params = [ + {"name": "as_of", "type": "DATE", "value": as_of}, + {"name": "w", "type": "INT64", "value": window_days}, + ] + bq_query(client, ACTUALS_DELETE_SQL, params=params) + bq_query(client, ACTUALS_INSERT_SQL, params=params) + + +def _as_date(value) -> date: + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, datetime): + return value.date() + return datetime.strptime(str(value)[:10], "%Y-%m-%d").date() + + +def run_sales_forecast( + as_of: str = "", + horizon: int = 7, + model: str = "baseline_v1", + author: str = "egutierrez", + dry_run: bool = False, +) -> dict: + """Genera el forecast diario de ventas y lo escribe en BigQuery. + + Args: + as_of: fecha de corte 'YYYY-MM-DD' (dia de la corrida). Vacio = hoy. La + historia utilizable llega hasta as_of - 1 dia; se predice + as_of + 1 .. as_of + horizon. run_date = as_of. + horizon: numero de dias futuros a predecir. Default 7. + model: etiqueta del modelo escrita en cada fila (columna model). Default + 'baseline_v1'. + author: autor de la corrida (columna author). Default 'egutierrez'. + dry_run: si True no escribe en BigQuery; devuelve el resumen + una muestra + de filas. + + Returns: + dict dict-no-throw. En exito {status:'ok', run_date, series, rows, model, + author, (sample si dry_run)}. En error {status:'error', error, stage}. + """ + try: + run_d = _as_date(as_of) if as_of else date.today() + # Ultimo dia de historia utilizable (inclusive): as_of - 1 dia. + hist_as_of = run_d - timedelta(days=1) + horizon_dates = [ + (run_d + timedelta(days=k)).isoformat() for k in range(1, horizon + 1) + ] + + # 1) Cliente BigQuery sin quota project (evita 403 USER_PROJECT_DENIED). + client = bq_auth(PROJECT_ID, drop_quota_project=True) + + # 2) Historia agregada del mart (hasta run_d - 1 via el WHERE de la query). + q = bq_query( + client, + HISTORY_SQL, + params=[{"name": "as_of", "type": "DATE", "value": run_d}], + ) + cols = {name: i for i, name in enumerate(q["columns"])} + + # Historia por serie + ultimos center_name/ambito conocidos + venta 8 semanas. + history = [] + last_meta = {} # series_id -> (max_date, center_name, ambito, center_id, subcat) + recent_sum = {} # series_id -> venta acumulada en las ultimas 8 semanas + active_cutoff = hist_as_of - timedelta(weeks=8) + for row in q["rows"]: + fecha = _as_date(row[cols["fecha"]]) + center_id = str(row[cols["idCentro"]]) + subcat = row[cols["subcat_cqq"]] + center_name = row[cols["center_name"]] + ambito = row[cols["ambito"]] + venta = float(row[cols["venta"]] or 0.0) + series_id = f"{center_id}|{subcat}" + + history.append( + {"series_id": series_id, "date": fecha.isoformat(), "value": venta} + ) + prev = last_meta.get(series_id) + if prev is None or fecha > prev[0]: + last_meta[series_id] = (fecha, center_name, ambito, center_id, subcat) + if fecha > active_cutoff: + recent_sum[series_id] = recent_sum.get(series_id, 0.0) + venta + + # 3) Series activas: venta > 0 en las ultimas 8 semanas. + active = {sid for sid, s in recent_sum.items() if s > 0.0} + history = [h for h in history if h["series_id"] in active] + + if not history: + result = { + "status": "ok", + "run_date": run_d.isoformat(), + "series": 0, + "rows": 0, + "model": model, + "author": author, + } + if dry_run: + result["sample"] = [] + return result + + # 4) Modelo puro: todas las predicciones de golpe. + preds = forecast_seasonal_median( + history, horizon_dates, as_of=hist_as_of.isoformat() + ) + + # 5) Filas para la tabla de predicciones. + run_ts = datetime.now(timezone.utc).isoformat() + rows_out = [] + for p in preds: + sid = p["series_id"] + meta = last_meta.get(sid) + _, center_name, ambito, center_id, subcat = meta + forecast_date = _as_date(p["date"]) + rows_out.append( + { + "run_ts": run_ts, + "run_date": run_d.isoformat(), + "forecast_date": forecast_date.isoformat(), + "lag_days": (forecast_date - run_d).days, + "center_id": center_id, + "center_name": center_name, + "ambito": ambito, + "subcat_cgq": subcat, + "model": model, + "author": author, + "y_pred": round(float(p["y_pred"]), 4), + } + ) + + summary = { + "status": "ok", + "run_date": run_d.isoformat(), + "series": len(active), + "rows": len(rows_out), + "model": model, + "author": author, + } + + # 6) dry-run: no escribe; devuelve resumen + muestra. + if dry_run: + summary["sample"] = rows_out[:5] + return summary + + # 7) Idempotencia: borra las predicciones previas de (run_date, model, author). + bq_query( + client, + DELETE_SQL, + params=[ + {"name": "d", "type": "DATE", "value": run_d}, + {"name": "m", "type": "STRING", "value": model}, + {"name": "a", "type": "STRING", "value": author}, + ], + ) + + # 8) Carga JSONL a la tabla (WRITE_APPEND, schema fijo de la tabla). + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp(prefix="sales_forecast_", suffix=".jsonl") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + for r in rows_out: + fh.write(json.dumps(r, ensure_ascii=False) + "\n") + load = bq_load_from_file( + client, + tmp_path, + DEST_DATASET, + DEST_TABLE, + source_format="NEWLINE_DELIMITED_JSON", + write_disposition="WRITE_APPEND", + autodetect=False, + ) + finally: + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) + + if load.get("status") != "DONE": + return { + "status": "error", + "error": f"load job no termino DONE: {load}", + "stage": "load", + } + + summary["rows_loaded"] = load.get("rows_loaded") + summary["job_id"] = load.get("job_id") + + # 9) Refresca la tabla fisica de reales (ventana movil de 10 dias) para + # que forecast_eval y el dashboard de competicion comparen contra el + # ultimo estado del mart. + try: + _refresh_actuals(client, run_d) + summary["actuals_refreshed"] = True + except Exception as e: # noqa: BLE001 + # No invalida las predicciones ya cargadas: se reporta y se sigue. + summary["actuals_refreshed"] = False + summary["actuals_error"] = str(e) + + return summary + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e), "stage": "unexpected"} + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Forecast diario de ventas Aurgi -> BigQuery sales_forecast.predictions." + ) + parser.add_argument("--as-of", default="", help="Fecha de corte YYYY-MM-DD (vacio = hoy).") + parser.add_argument("--horizon", type=int, default=7, help="Dias a predecir. Default 7.") + parser.add_argument("--model", default="baseline_v1", help="Etiqueta del modelo.") + parser.add_argument("--author", default="egutierrez", help="Autor de la corrida.") + parser.add_argument( + "--dry-run", action="store_true", help="No escribe en BigQuery; imprime muestra." + ) + args = parser.parse_args() + + out = run_sales_forecast( + as_of=args.as_of, + horizon=args.horizon, + model=args.model, + author=args.author, + dry_run=args.dry_run, + ) + print(json.dumps(out, ensure_ascii=False, default=str)) + sys.exit(0 if out.get("status") == "ok" else 1)