feat(eda): funciones de agregación/OLAP para AutomaticEDA (groupby/pivot push-down + selección LLM)

Cuatro funciones nuevas del grupo eda que nutren el capítulo AGREGACION:
- select_groupby_keys (pure): elige categóricas agrupables + numéricas medida desde el TableProfile.
- groupby_stats_duckdb (impure): GROUP BY push-down en DuckDB (count/mean/median/std/min/max por grupo).
- pivot_table_duckdb (impure): pivot A×B push-down, limitado a top filas/cols para no cortar.
- suggest_aggregations_llm (impure): el LLM elige las agregaciones interesantes con fallback determinista.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:33:55 +02:00
parent 415154d9a3
commit 96da9e3015
13 changed files with 2146 additions and 0 deletions
@@ -0,0 +1,96 @@
---
name: suggest_aggregations_llm
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def suggest_aggregations_llm(profile: dict, candidates: dict, max_aggs: int = 4, model: str = \"claude-haiku-4-5-20251001\") -> dict"
description: "MUST-11.1 del capitulo AGREGACION del AutomaticEDA (grupo eda). Dado el TableProfile de una tabla y los candidatos cuantitativos de select_groupby_keys ({group_keys:[{col,cardinality,score}], measures:[str], pivots:[{index,columns,value}]}), con UNA sola llamada al LLM elige y ordena las K agregaciones (GROUP BY categorica x medidas numericas) y los pivots MAS INFORMATIVOS para un analisis de grupos, con una razon corta cada uno, evitando la explosion combinatoria (no todo contra todo). Privacidad/coste: NO envia filas crudas, solo el resumen AGREGADO de los candidatos (tabla, columnas categoricas con cardinalidad/score, medidas, pivots). Reusa ask_llm del grupo claude-direct (API directa con token OAuth de Claude). Impura, dict-no-throw: NUNCA lanza y SIEMPRE devuelve algo usable; si el LLM falla, el JSON no parsea o no hay seleccion valida, cae a un fallback determinista construido desde los candidatos (source='fallback'). Toda columna que el LLM invente se descarta."
tags: [eda, claude-direct, llm, aggregation, groupby, pivot, datascience, automatic-eda]
params:
- name: profile
desc: "TableProfile del grupo eda. Solo se usa profile['table'] para nombrar la tabla en el prompt; puede ir vacio o sin esa clave (se usa '(tabla sin nombre)')."
- name: candidates
desc: "Salida de select_groupby_keys: {group_keys:[{col, cardinality, score}], measures:[str], pivots:[{index, columns, value}]}. group_keys = columnas categoricas candidatas para GROUP BY; measures = columnas numericas a agregar (sum/avg); pivots = cruces index x columns -> value sugeridos. Cualquier columna que el LLM elija debe existir aqui o se descarta. None o no-dict se trata como vacio."
- name: max_aggs
desc: "Tope de agregaciones a devolver. Default 4. Valores <1 o no-int se normalizan a 4. Limita tanto la seleccion del LLM como el fallback determinista, para evitar la explosion combinatoria."
- name: model
desc: "id del modelo Anthropic a usar en la unica llamada. Default 'claude-haiku-4-5-20251001' (haiku, coste bajo, ~2-3s). Para razones mas finas, pasar p.ej. 'claude-opus-4-8'."
output: "dict dict-no-throw: {status:'ok', source:'llm'|'fallback', aggregations:[{group_by:str, measures:[str], why:str}], pivots:[{index:str, columns:str, value:str|None, why:str}], note:str}. source=='llm' si el LLM produjo al menos una agregacion valida (columnas existentes en candidates); en cualquier otro caso (LLM caido, JSON invalido, seleccion vacia, sin candidatos) source=='fallback' y aggregations/pivots se derivan de candidates con why='selección cuantitativa (sin LLM)'. NUNCA lanza."
uses_functions: [ask_llm_py_core, select_groupby_keys_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_extract_json_object", "test_extract_json_wrapped_in_fences_and_junk", "test_extract_json_non_json_returns_none", "test_validate_aggregations_drops_invalid_columns", "test_llm_path_uses_selection", "test_llm_path_respects_max_aggs", "test_llm_invented_column_is_discarded", "test_fallback_on_empty_llm_response", "test_fallback_on_unparseable_response", "test_fallback_respects_max_aggs", "test_fallback_when_llm_raises", "test_no_candidates_returns_empty_fallback", "test_non_dict_candidates_does_not_raise"]
test_file_path: "python/functions/datascience/suggest_aggregations_llm_test.py"
file_path: "python/functions/datascience/suggest_aggregations_llm.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.suggest_aggregations_llm import suggest_aggregations_llm
profile = {"table": "ventas"}
# candidates = salida de select_groupby_keys (aqui literal de ejemplo).
candidates = {
"group_keys": [
{"col": "categoria", "cardinality": 8, "score": 0.91},
{"col": "region", "cardinality": 5, "score": 0.74},
{"col": "canal", "cardinality": 3, "score": 0.60},
],
"measures": ["importe", "unidades"],
"pivots": [
{"index": "categoria", "columns": "region", "value": "importe"},
],
}
out = suggest_aggregations_llm(profile, candidates, max_aggs=4) # haiku por defecto
print("fuente:", out["source"]) # "llm" o "fallback" si no hay red
for agg in out["aggregations"]:
print(f"GROUP BY {agg['group_by']} -> {agg['measures']} ({agg['why']})")
for piv in out["pivots"]:
print(f"pivot {piv['index']} x {piv['columns']} = {piv['value']} ({piv['why']})")
```
## Cuando usarla
Justo despues de `select_groupby_keys` en el capitulo AGREGACION del AutomaticEDA:
cuando ya tienes los candidatos cuantitativos (columnas categoricas con cardinalidad,
medidas numericas y pivots posibles) y quieres que un LLM se quede con las K
agregaciones y pivots MAS INFORMATIVOS en vez de generar "todo contra todo". Usala para
priorizar el plan de analisis de grupos antes de materializar las tablas con
`aggregate_by_group` / pivots, manteniendo el coste y el ruido bajos. Si no hay red o
credenciales, sigue funcionando con un fallback determinista, asi que es seguro
ponerla en un pipeline.
## Gotchas
- **Impura: hace 1 llamada de red al LLM.** No es determinista ni gratis. Latencia
tipica ~2-3s con haiku. Una sola llamada cubre toda la seleccion.
- **Requiere token OAuth de Claude** en `~/.claude/.credentials.json` (via `ask_llm` /
grupo `claude-direct`). Sin token / sin red NO lanza: cae al **fallback
determinista** (`source="fallback"`) construido desde `candidates`
(group_keys x measures hasta `max_aggs`, pivots tal cual) con
`why="selección cuantitativa (sin LLM)"`. Comprueba `out["source"]` para saber si la
seleccion vino del LLM o del fallback.
- **NO envia filas crudas al LLM**, solo el resumen AGREGADO de los candidatos. Esto
exige que `candidates` venga ya calculado por `select_groupby_keys` (cardinalidades,
scores, medidas, pivots).
- **Valida columnas inventadas**: si el LLM propone un `group_by`/`measure`/`index`/
`columns` que no esta en `candidates`, esa entrada se descarta (las medidas se
recortan a las validas). Si tras validar no queda ninguna agregacion, cae al
fallback completo.
- **`max_aggs` acota la explosion combinatoria** tanto en el camino LLM como en el
fallback. Subirlo demasiado reintroduce el ruido que esta funcion evita.
- **Modelo `haiku` por defecto** para coste bajo; sube a `claude-opus-4-8` si necesitas
razones (`why`) mas finas (mas caro y lento).