Compare commits

..

4 Commits

Author SHA1 Message Date
egutierrez 3be188a921 feat(eda): profile_level (lite/standard/full) en render_automatic_eda
Añade el parámetro profile_level a render_automatic_eda como preset de
consumo CPU/LLM que mapea a los flags existentes (run_models, run_series,
run_llm, sample). Tres niveles:

- lite (bajo consumo): run_llm=False, run_series=False, sample=2000 y modelos
  limitados a PCA + normalidad, SIN KMeans ni IsolationForest (lo caro en CPU).
  Para un vistazo rápido y barato.
- standard (default): comportamiento histórico — modelos completos, serie,
  sin LLM.
- full: standard + narrativa LLM por capítulo.

Precedencia: un flag explícito del caller (run_llm=..., run_models=..., etc.)
siempre prima sobre el default que fija el preset; el preset solo aplica al
parámetro que se deja en None.

Cableado del modo lite sin tocar profile_table (lo tocan otros agentes en
paralelo): profile_table NO corre los modelos (evita pagar KMeans +
IsolationForest); este pipeline los corre con run_eda_models(run_kmeans=False,
run_isolation=False) reusando ctx['raw_numeric'], y quita raw_numeric del ctx
para que el capítulo modelos no reproyecte clusters KMeans en vivo
(project_clusters_2d). geo_points ya queda derivado, así que geospatial no se
afecta.

Cambio aditivo y retro-compatible: sin profile_level el comportamiento es
idéntico al de v1.0.0 (standard). Tests nuevos cubren lite/standard, la
precedencia flag-sobre-preset, y la equivalencia del default con el histórico.
Bump 1.0.0 -> 1.1.0 + growth log en el .md. Skill /eda documenta --lite/--full.

Verificación: golden lite/standard/full sobre titanic — lite 4.8s (PCA+norm,
sin KMeans/iso/LLM/serie), standard 7.8s (modelos completos), full 38.3s
(+LLM). Suite render_automatic_eda + automatic_eda: 96 passed. fn index sin
error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:17 +02:00
egutierrez f2ac734ef7 merge: 4b head_rows — overview muestra df.head (build_eda_render_ctx pobla head_rows, verificado met) 2026-06-30 18:04:51 +02:00
egutierrez 7fb00defdf fix(fleetclaude): reusar contexto dentro de la flota tmux en vez de abrir kitty nueva
Lanzar `fleetclaude` estando ya dentro de una flota tmux viva abría una ventana
kitty nueva (y creaba un perfil/socket nuevo fleetN+1) en vez de mostrar la flota
en el pane actual. Causa: con $TMUX definido el launcher saltaba el `exec tmux
attach` y caía a la rama `setsid kitty`.

Cambio: cuando se invoca sin --new desde dentro de una flota fleetview viva (el
socket actual, derivado de $TMUX, tiene una sesión homónima con window 'console'),
se trae la TUI al contexto/pane actual (`fleetview show`, o `tmux select-window`
de la window console como fallback sin binario) y se retorna 0 antes de las ramas
kitty/wt.exe. Nuevo flag --new para forzar el comportamiento clásico (flota+ventana
nueva) aun dentro de tmux; pasar --session con un nombre distinto al perfil actual
equivale a --new implícito. Fuera de tmux el comportamiento es intacto (exec tmux
attach reutiliza la terminal).

Fix incidental: `local left_pane="" right_pane=""` (antes `local left_pane
right_pane` reventaba con "unbound variable" bajo `set -u` al reutilizar una sesión
existente, p. ej. con --reuse/--session sobre una flota viva).

Verificación e2e con sockets aislados fctest* (sin tocar la flota del humano):
golden (reuse, exit 0, kitty invariante), --new y --session-distinto (no reuse,
ruta ventana-nueva), fuera de tmux (salta reuse, ruta attach). bash -n limpio.

Docs: launch_fleetclaude.md (signature, params --new, ejemplo, cuando usarla,
gotchas, growth log v1.7.0) + /fleet show en .claude/commands/fleet.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:56:41 +02:00
egutierrez b1d205203a feat(eda): poblar head_rows real en el capitulo OVERVIEW (df.head)
El capitulo OVERVIEW del motor AutomaticEDA mostraba "df.head no disponible"
porque ninguna fase de calculo poblaba las primeras filas crudas de la tabla.

- build_eda_render_ctx: nuevo bloque que muestrea SELECT * LIMIT head_n
  (param nuevo head_n=10) y lo expone en ctx["head_rows"] como lista de
  dicts fila. Estilo dict-no-throw: si la query falla, se omite la clave.
- profile_table: puebla prof["head_rows"] reusando _sample_rows (SELECT de
  las columnas LIMIT 10) tras recalcular el type_breakdown. Asi el report
  JSON sidecar tambien lo lleva y el capitulo lo recoge via profile aunque
  no se construya el ctx.
- overview.py: la nota del DataTable de df.head ahora indica el total de
  filas del dataset cuando se conoce ("primeras 10 filas de 891"). Bump
  CHAPTER_VERSION 1.0.0 -> 1.1.0.
- overview_test.py (nuevo): golden (head via profile y via ctx, render PDF
  + PPTX muestran las filas reales, placeholder ausente), edge (sin
  head_rows degrada a nota honesta sin romper, None/vacio devuelven None).

Verificado end-to-end con titanic: render_automatic_eda emite PDF + PPTX con
df.head visible (Braund/Cumings/Heikkinen + columnas) y sin el placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:56:24 +02:00
21 changed files with 733 additions and 1703 deletions
+3 -1
View File
@@ -27,6 +27,7 @@ Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar
- `--series``run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica).
- `--pdf``emit_pdf=True` (PDF A5 legacy de `render_eda_pdf`, legible en móvil).
- `--legacy-only` → emite SOLO el PDF legacy (sin AutomaticEDA), para casos en que solo se quiera el PDF rápido.
- `--lite` / `--bajo-consumo``render_automatic_eda(profile_level="lite")`: EDA barato y rápido (CI, vistazo previo, máquina sin GPU/red). Apaga LLM y serie temporal y limita los modelos a **PCA + normalidad** (sin KMeans ni IsolationForest, lo caro en CPU), con `sample` reducido. `--full``profile_level="full"` (standard + narrativa LLM). Por defecto `profile_level="standard"` (comportamiento histórico). Un flag explícito (`--llm`, `--models`, ...) prima sobre el preset.
Por defecto, **un EDA completo emite SIEMPRE el informe AutomaticEDA en sus dos formatos: PDF (A5 móvil) Y PPTX (16:9 para compartir)** con los 11 capítulos poblados (portada, overview, distribuciones, calidad, correlaciones, modelos, series, geoespacial, agregación, interpretación LLM). Usa el pipeline `render_automatic_eda` (o `profile_table(emit_automatic=True)`), que activa `run_models` y `run_series` para que los capítulos de modelos/series/geoespacial/agregación salgan poblados. Deja `run_llm` para cuando el usuario lo pida o interese la interpretación semántica + narrativa por capítulo (es la única parte que gasta tokens del modelo).
@@ -50,7 +51,8 @@ from pipelines.render_automatic_eda import render_automatic_eda
# tablas de agregación). run_llm=True añade la narrativa LLM por capítulo.
r = render_automatic_eda(
"/ruta/datos.duckdb", "ventas",
run_models=True, run_series=True, run_llm=False, out_dir="reports",
profile_level="standard", # "lite" = bajo consumo CPU/LLM; "full" = + narrativa LLM
out_dir="reports",
)
print("status:", r["status"])
print("pdf: ", r["pdf_path"], "(", r["n_pages"], "págs )")
+26 -3
View File
@@ -1,6 +1,6 @@
---
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet.
argument-hint: "[texto|sessionId|PID para saltar — vacío = listar la flota]"
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet. `/fleet show` trae la TUI al contexto tmux actual.
argument-hint: "[show | texto|sessionId|PID para saltar — vacío = listar la flota]"
---
# /fleet — ver y navegar la flota de Claudes
@@ -33,9 +33,32 @@ cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview" && go build -o fleetv
- la sesión actual / orquestador si la puedes identificar (su `session_id` coincide con el de quien invoca).
4. Si la lista está vacía, indícalo y sugiere que el perfil fleet podría no estar activo (revisar `$FLEET_SOCKET` y que la sesión tmux exista).
### `show` → traer la TUI al contexto tmux actual
Si `$ARGUMENTS` es exactamente `show` (alias `open`/`attach`), el usuario quiere
volver a ver el panel FleetView en el contexto/pane actual sin abrir ninguna
ventana ni arrancar una flota nueva. Ejecuta:
```bash
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" show
```
Comportamiento (decidido por la app, no abre terminal externa):
- **dentro de tmux con la flota viva** → `select-window` de la window `console`
del socket fleet (trae la TUI al frente; no abre nada).
- **fuera de tmux** → `attach` a la sesión fleet en la terminal actual (la reutiliza).
- **sin flota viva** → error claro, exit 1, no abre nada (sugiere arrancarla con
`fleetclaude`).
Es el equivalente del comportamiento de `fleetclaude` sin args invocado dentro de
una flota viva (reuse de contexto): úsalo cuando ya tengas una flota corriendo y
solo quieras recuperar la vista del panel. Para abrir una flota NUEVA aparte, usa
`fleetclaude --new` (no este comando).
### Con argumentos → saltar con foco
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID.
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID (cualquier valor que no sea `show`).
1. Ejecuta:
```bash
+73 -28
View File
@@ -3,10 +3,10 @@ name: launch_fleetclaude
kind: function
lang: bash
domain: infra
version: "1.6.0"
version: "1.7.0"
purity: impure
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--new] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. REUSO DE CONTEXTO: si se invoca DENTRO de una flota tmux viva (su window 'console') sin --new, NO abre ventana ni crea un perfil nuevo; trae la TUI al pane/contexto actual (equivale a 'fleetview show'). El flag --new fuerza una flota+ventana nueva aunque estes en tmux. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: fuera de tmux, o con --new, cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher, wsl, windows-terminal]
params:
- name: --cwd
@@ -14,12 +14,14 @@ params:
- name: --bin
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
- name: --session
desc: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...)."
desc: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...). Invocado DENTRO de tmux con un nombre DISTINTO al de la flota actual equivale a --new (pides otra flota: ventana nueva, sin reuse de contexto)."
- name: --reuse
desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)."
- name: --new
desc: "Fuerza una flota NUEVA en una ventana NUEVA (kitty/wt.exe) incluso estando dentro de una flota tmux. Opcional. Es la via explicita para abrir una FleetView aparte; sin este flag, invocado dentro de una flota viva se reusa el contexto actual (no abre ventana ni crea perfil)."
- name: --cols
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta a ella (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
output: "Caso reuse de contexto (dentro de una flota tmux viva, sin --new): trae la TUI al pane/contexto actual con select-window de la window 'console' (o 'fleetview show' si el binario existe) y retorna 0, sin abrir nada. Caso ventana-nueva (fuera de tmux, o con --new): crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito, !=0 con mensaje claro si no hay terminal ni contexto que reusar."
uses_functions:
- supervise_fleetview_tui_bash_infra
uses_types: []
@@ -36,32 +38,44 @@ file_path: "bash/functions/infra/launch_fleetclaude.sh"
## Ejemplo
```bash
# Via fn run (resuelve por nombre o ID):
fn run launch_fleetclaude
# DENTRO de una flota tmux viva (p. ej. en el pane del orquestador): reusa el
# contexto, trae la TUI al pane actual. NO abre ventana ni crea perfil nuevo.
fleetclaude
# Perfil nuevo automatico (fleet la 1a vez; fleet2, fleet3, ... si ya hay uno):
launch_fleetclaude
# FUERA de tmux: perfil nuevo automatico (fleet la 1a vez; fleet2, ... si ya hay
# uno) en una ventana de terminal nueva, reutilizando la terminal actual (attach):
fleetclaude
# Forzar una flota+ventana NUEVA aunque estes dentro de una flota tmux:
fleetclaude --new
# Reattach a la flota principal 'fleet' (comportamiento idempotente clasico):
launch_fleetclaude --reuse
fleetclaude --reuse
# Perfil con nombre fijo y ancho de pane personalizado:
launch_fleetclaude --session trabajo --cols 50
fleetclaude --session trabajo --cols 50
# Via fn run (resuelve por nombre o ID):
fn run launch_fleetclaude
```
Tras invocarlo aparece una ventana de terminal titulada `FleetView (<perfil>)` con dos
panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
aislados con su propia flota: puedes tener varias FleetView abiertas a la vez.
Por defecto, volver a invocarlo abre un perfil NUEVO (no reusa); usa `--reuse`
o `--session <nombre>` para volver a una flota concreta.
Dentro de una flota viva, `fleetclaude` sin args reusa el contexto (la window
`console` pasa al frente). Fuera de tmux (o con `--new`) aparece una ventana de
terminal titulada `FleetView (<perfil>)` con dos panes lado a lado: a la izquierda
la TUI `fleetview`, a la derecha una sesion de `claude --dangerously-skip-permissions`.
Cada perfil es un socket+sesion tmux aislados con su propia flota: puedes tener
varias FleetView abiertas a la vez con `--new`.
## Cuando usarla
Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de
N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un
Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o
al retomar el trabajo en el repo `fn_registry`.
al retomar el trabajo en el repo `fn_registry`. Si **ya estas dentro de una
flota** (en el pane del orquestador) y solo quieres volver a ver la TUI, lanza
`fleetclaude` sin args: trae el panel al contexto actual sin abrir otra ventana
ni arrancar una flota duplicada. Usa `--new` solo cuando quieras DELIBERADAMENTE
una segunda flota aparte.
## Gotchas
@@ -87,10 +101,27 @@ al retomar el trabajo en el repo `fn_registry`.
funciona en un PC con kitty y en otro WSL sin kitty, cada uno elige su
terminal. Causa raiz del sintoma "se lanza la flota pero no se ve": kitty no
instalado en WSL hacia que la sesion tmux se creara sin ventana que la mostrara.
- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de
una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de
nesting); cae a la ruta ventana-nueva (auto-deteccion de terminal). Fuera de
tmux y con TTY, reutiliza la terminal actual con `exec tmux attach`.
- **Dentro de una flota tmux viva: reuse de contexto (no ventana nueva)**: si
invocas `fleetclaude` sin `--new` desde dentro de una flota fleetview viva
(`$TMUX` definido y el socket actual tiene una sesion homonima con window
`console`), NO abre ventana ni crea un perfil `fleetN+1`: trae la TUI al pane
actual (`fleetview show`, o `tmux -L <perfil> select-window -t <perfil>:console`
si el binario no esta compilado) y retorna 0. El perfil de la flota actual se
deriva de `$TMUX` (basename del socket = nombre `-L`), senal fiable aunque
`$FLEET_SOCKET` venga vacio (ver `detect_fleet_context`). **`--new`** fuerza el
comportamiento clasico (flota+ventana nueva); pasar `--session <otro>` distinto
al perfil actual equivale a `--new` implicito. Fuera de tmux y con TTY, reutiliza
la terminal actual con `exec tmux attach` (nunca `attach` anidado dentro de
tmux). Sin TTY ni contexto que reusar (atajo de escritorio/cron) cae a la ruta
ventana-nueva. Antes de este fix (v1.6.0 y anteriores) cualquier `fleetclaude`
dentro de tmux abria una kitty nueva y un socket `fleetN+1` — el sintoma que
acumulaba 6+ sockets `fleet*`.
- **`local x` unbound bajo `set -u`**: el archivo corre con `set -euo pipefail`.
`local left_pane right_pane` dejaba esas vars *unbound* (no vacias), asi que la
rama "reutilizar sesion existente" (`--reuse`/`--session <vivo>`) reventaba con
`left_pane: unbound variable` al evaluar `[[ -z "$left_pane" ]]`. Se inicializan
explicitamente a `""` (`local left_pane="" right_pane=""`). Si tocas estas vars,
no vuelvas a declararlas sin valor.
- **kitty detached (setsid)**: la ventana kitty se lanza con `setsid ... &` para
sobrevivir al cierre de la terminal que la invoco. La ventana de Windows
Terminal (wt.exe) ya es un proceso Windows independiente del arbol Linux, asi
@@ -128,15 +159,29 @@ al retomar el trabajo en el repo `fn_registry`.
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
conmutar de Claude redistribuyen el espacio.
- **tmux siempre; terminal (kitty/wt.exe) solo sin TTY**: `tmux` es obligatorio
(aborta != 0 si falta). Una terminal nueva (kitty o Windows Terminal) solo se
necesita en la ruta sin-TTY (dentro de tmux, atajo de escritorio, cron, script),
donde abre una ventana nueva. Invocado desde una terminal interactiva fuera de
tmux (el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
`exec tmux attach` y no necesita ni kitty ni wt.exe.
- **tmux siempre; terminal (kitty/wt.exe) solo en la ruta ventana-nueva**: `tmux`
es obligatorio (aborta != 0 si falta). Una terminal nueva (kitty o Windows
Terminal) solo se necesita en la ruta ventana-nueva: `--new`, o sin TTY ni flota
viva que reusar (atajo de escritorio, cron, script). Dentro de una flota viva sin
`--new` se reusa el contexto (ni kitty ni wt.exe). Invocado desde una terminal
interactiva fuera de tmux (el caso normal del alias `fleetclaude`), reutiliza la
terminal actual con `exec tmux attach` y tampoco necesita kitty ni wt.exe.
## Capability growth log
- v1.7.0 (2026-06-30) — **reuse de contexto dentro de la flota + flag `--new`**.
Invocado sin `--new` desde dentro de una flota tmux viva (su window `console`),
`fleetclaude` ya NO abre una kitty nueva ni crea un perfil `fleetN+1`: trae la
TUI al pane/contexto actual (`fleetview show`, o `tmux -L <perfil> select-window
-t <perfil>:console` como fallback sin binario) y retorna 0. El perfil actual se
deriva de `$TMUX` (basename del socket); pasar `--session <otro>` distinto al
actual equivale a `--new` implicito. Nuevo flag `--new` para forzar la ruta
clasica (flota+ventana nueva) aun dentro de tmux. Fuera de tmux el comportamiento
es intacto (`exec tmux attach` reutiliza la terminal). Arregla el sintoma de que
lanzar `fleetclaude` dentro de una flota abria ventana kitty + socket nuevo
(`fleet7`, `fleet8`, ...). Fix incidental: `local left_pane="" right_pane=""`
(antes `local left_pane right_pane` reventaba con `unbound variable` bajo
`set -u` al reutilizar una sesion existente).
- v1.6.0 (2026-06-29) — **auto-deteccion de terminal (kitty ↔ Windows Terminal)**.
La ruta ventana-nueva ya no asume kitty: elige terminal segun el host. kitty si
esta instalado y hay display (`$DISPLAY`/`$WAYLAND_DISPLAY`); si no, en WSL abre
+61 -2
View File
@@ -23,6 +23,7 @@ launch_fleetclaude() {
local cols=52
local explicit_session=0 # 1 si el usuario pasó --session <name> a mano
local reuse=0 # 1 si el usuario pidió --reuse (reattach al perfil principal)
local want_new=0 # 1 si el usuario pidió --new (forzar flota+ventana nueva)
local T="" # socket tmux aislado; se fija al resolver el perfil
# -----------------------------------------------------------------------
@@ -46,6 +47,9 @@ launch_fleetclaude() {
--reuse)
reuse=1
;;
--new)
want_new=1
;;
--cols)
shift
cols="${1:-40}"
@@ -62,6 +66,11 @@ Claudes). Sin --session ni --reuse, cada invocacion abre un perfil NUEVO: usa
el primer nombre libre de la secuencia fleet, fleet2, fleet3, ... Asi puedes
tener varias FleetView abiertas a la vez, cada una con su flota independiente.
REUSO DE CONTEXTO: si ya estas DENTRO de una flota tmux viva (p. ej. en el pane
del orquestador), 'fleetclaude' sin args NO abre una ventana ni crea un perfil
nuevo: trae la TUI al contexto/pane actual (equivale a 'fleetview show'). Para
abrir explicitamente una flota aparte en una ventana nueva, usa --new.
Opciones:
--cwd <dir> Directorio de trabajo de los panes.
Default: raiz del repo fn_registry (derivada dinamicamente).
@@ -69,13 +78,21 @@ Opciones:
Default: <repo>/apps/fleetview/fleetview
--session <name> Fija el perfil (socket+sesion) por nombre exacto; reutiliza
el existente si ya esta vivo. Sin esta opcion, perfil auto.
Si se invoca DENTRO de tmux con un nombre DISTINTO al de la
flota actual, equivale a --new (pides otra flota).
--reuse Reattach al perfil principal 'fleet' en vez de abrir uno
nuevo (vuelve al comportamiento idempotente clasico).
--new Fuerza una flota NUEVA en una ventana NUEVA (kitty/wt.exe),
incluso dentro de tmux. Es la via explicita para tener una
FleetView aparte; sin este flag, dentro de tmux se reusa el
contexto actual.
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
-h, --help Muestra esta ayuda.
Ejemplos:
launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...)
launch_fleetclaude # dentro de la flota: reusa el contexto;
# fuera de tmux: perfil nuevo (fleet, ...)
launch_fleetclaude --new # flota+ventana nueva aunque estes en tmux
launch_fleetclaude --reuse # reattach a la flota principal 'fleet'
launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo'
launch_fleetclaude --cwd ~/fn_registry --cols 50
@@ -127,6 +144,45 @@ USAGE
return 1
fi
# -----------------------------------------------------------------------
# REUSO DE CONTEXTO (sin --new): si ya estamos DENTRO de una flota tmux
# viva, 'fleetclaude' sin args NO abre una ventana/terminal nueva ni crea
# un perfil fleetN+1 — trae la TUI al contexto/pane actual, igual que
# 'fleetview show'. El flag --new fuerza el comportamiento clasico (flota
# nueva en ventana nueva); --reuse mantiene su semantica historica.
#
# El perfil de la flota actual se deriva de $TMUX (el basename del socket
# es el nombre -L; senal fiable aunque $FLEET_SOCKET venga vacio, ver
# detect_fleet_context). Si se paso --session con un nombre DISTINTO al
# actual, es pedir OTRA flota -> se trata como --new implicito (no reusa).
# "Flota viva" = el socket tiene una sesion homonima con una window
# 'console' (la firma de una FleetView), no un tmux cualquiera.
# -----------------------------------------------------------------------
if [[ "$want_new" -eq 0 && "$reuse" -eq 0 && -n "${TMUX:-}" ]]; then
local current_socket target_socket
current_socket="$(basename "${TMUX%%,*}")"
target_socket="$current_socket"
[[ "$explicit_session" -eq 1 ]] && target_socket="$session"
if [[ "$target_socket" == "$current_socket" ]] \
&& tmux -L "$current_socket" has-session -t "$current_socket" 2>/dev/null \
&& tmux -L "$current_socket" list-windows -t "$current_socket" \
-F '#{window_name}' 2>/dev/null | grep -qx console; then
# Traer la TUI al contexto actual sin abrir nada nuevo. Preferimos
# el binario (centraliza la politica en la app: 'fleetview show');
# si no esta compilado, caemos a 'select-window' directo, que es lo
# que 'show' hace por dentro dentro de tmux (cero dependencia).
if [[ -x "$bin" ]] \
&& FLEET_SOCKET="$current_socket" FLEET_SESSION="$current_socket" \
"$bin" show 2>/dev/null; then
return 0
fi
tmux -L "$current_socket" select-window -t "$current_socket":console
echo "launch_fleetclaude: flota '$current_socket' viva; TUI traida al contexto actual (sin ventana nueva)."
return 0
fi
fi
# -----------------------------------------------------------------------
# Resolver el PERFIL (socket+sesion tmux comparten nombre).
#
@@ -200,7 +256,10 @@ USAGE
# indice 1 y cualquier referencia a console.0 falla con
# "can't find pane: 0". Los pane ID son estables e inmunes al base-index.
# -----------------------------------------------------------------------
local left_pane right_pane
# Inicializadas a "" (no solo declaradas): bajo `set -u` una `local x` sin
# valor queda *unbound*, y al reutilizar una sesion existente el `[[ -z
# "$left_pane" ]]` de mas abajo reventaba con "unbound variable".
local left_pane="" right_pane=""
if $T has-session -t "$session" 2>/dev/null; then
echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola."
else
-4
View File
@@ -34,7 +34,6 @@ from .theils_u import theils_u
from .correlation_ratio import correlation_ratio
from .mutual_info_columns import mutual_info_columns
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
from .build_join_graph import build_join_graph
from .association_matrix import association_matrix
from .correlation_matrix_duckdb import correlation_matrix_duckdb
@@ -70,10 +69,8 @@ from .build_eda_render_ctx import build_eda_render_ctx
from .profile_datetime import profile_datetime
from .resample_timeseries import resample_timeseries
from .add_pdf_internal_links import add_pdf_internal_links
from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
__all__ = [
"suggest_intratable_fk_candidates",
"detect_time_column",
"extract_timeseries_raw",
"build_eda_render_ctx",
@@ -100,7 +97,6 @@ __all__ = [
"correlation_ratio",
"mutual_info_columns",
"infer_fk_containment_duckdb",
"detect_declared_keys_duckdb",
"build_join_graph",
"association_matrix",
"correlation_matrix_duckdb",
@@ -20,7 +20,7 @@ from __future__ import annotations
from .. import model
CHAPTER_VERSION = "1.0.0"
CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "overview"
CHAPTER_TITLE = "Overview"
@@ -90,8 +90,14 @@ def _head_block(profile: dict, ctx: dict):
if not cols:
cols = list(head[0].keys())
rows = [[model._safe_str(r.get(c)) for c in cols] for r in head[:10]]
return model.DataTable(header=cols, rows=rows,
note=f"primeras {len(rows)} filas")
# Honest note: how many rows are shown and, when known, out of how many
# rows the dataset has (so "primeras 10 filas de 891" gives context).
note = f"primeras {len(rows)} filas"
n_rows = profile.get("n_rows")
if isinstance(n_rows, int) and not isinstance(n_rows, bool) \
and n_rows > len(rows):
note += f" de {n_rows:,}".replace(",", ".")
return model.DataTable(header=cols, rows=rows, note=note)
return model.Note(
"df.head no disponible: el TableProfile no incluye 'head_rows'. La fase "
"de cálculo debe añadir profile['head_rows'] (lista de dicts fila) o "
@@ -0,0 +1,187 @@
"""Tests for the OVERVIEW chapter — DoD: golden + edges + degradation.
Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that ``build_overview`` renders the raw first rows
(``df.head``) as a DataTable when ``head_rows`` is present — both when it arrives
via ``profile['head_rows']`` (populated by ``profile_table``) and via
``ctx['head_rows']`` (populated by ``build_eda_render_ctx``) — that the chapter
also renders the column dictionary and the numeric describe, that the full
document renders to PDF and PPTX showing the head values, and that a profile with
NO head data degrades to an honest note instead of raising or inventing rows.
"""
import os
import re
import tempfile
from pypdf import PdfReader
from pptx import Presentation
from datascience.automatic_eda.model import DataTable, Note
from datascience.automatic_eda.chapters.overview import (
CHAPTER_ID, CHAPTER_VERSION, build_overview,
)
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
def _columns() -> list:
return [
{"name": "PassengerId", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 2.0, "median": 2.0, "min": 1.0,
"max": 3.0, "std": 1.0}},
{"name": "Survived", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 0.33, "median": 0.0, "min": 0.0,
"max": 1.0, "std": 0.58}},
{"name": "Pclass", "inferred_type": "numeric", "null_pct": 0.0,
"null_count": 0, "numeric": {"mean": 2.33, "median": 3.0, "min": 1.0,
"max": 3.0, "std": 1.15}},
{"name": "Name", "inferred_type": "categorical", "null_pct": 0.0,
"null_count": 0, "distinct_count": 3},
{"name": "Sex", "inferred_type": "categorical", "null_pct": 0.0,
"null_count": 0, "distinct_count": 2,
"categorical": {"top": [{"value": "male", "count": 2},
{"value": "female", "count": 1}]}},
]
def _head_rows() -> list:
return [
{"PassengerId": 1, "Survived": 0, "Pclass": 3,
"Name": "Braund Owen", "Sex": "male"},
{"PassengerId": 2, "Survived": 1, "Pclass": 1,
"Name": "Cumings Florence", "Sex": "female"},
{"PassengerId": 3, "Survived": 1, "Pclass": 3,
"Name": "Heikkinen Laina", "Sex": "female"},
]
def _profile(with_head: bool = True) -> dict:
prof = {
"table": "titanic",
"source": "/data/titanic.csv",
"profiled_at": "2026-06-30T10:00:00+00:00",
"n_rows": 891,
"n_cols": 5,
"quality_score": 88.0,
"columns": _columns(),
}
if with_head:
prof["head_rows"] = _head_rows()
return prof
def _pdf_text(path: str) -> str:
txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
return re.sub(r"\s+", " ", txt)
def _pptx_text(path: str) -> str:
prs = Presentation(path)
parts = []
for sl in prs.slides:
for sh in sl.shapes:
if sh.has_text_frame:
parts.append(sh.text_frame.text)
if sh.has_table:
tb = sh.table
for r in range(len(tb.rows)):
for c in range(len(tb.columns)):
parts.append(tb.cell(r, c).text)
return re.sub(r"\s+", " ", " ".join(parts))
def _flatten(blocks):
"""Recursively flatten Group blocks into a flat list (none here today)."""
out = []
for b in blocks:
inner = getattr(b, "blocks", None)
if inner is not None and getattr(b, "kind", None) == "group":
out.extend(_flatten(inner))
else:
out.append(b)
return out
def test_golden_build_overview_muestra_head_desde_profile():
ch = build_overview(_profile(), {})
assert ch is not None
assert ch.id == CHAPTER_ID
assert ch.version == CHAPTER_VERSION
blocks = _flatten(ch.blocks)
# The first DataTable is df.head: its header is the column names and the
# real first rows are present (not a placeholder note).
tables = [b for b in blocks if isinstance(b, DataTable)]
assert tables, "overview must emit at least the df.head DataTable"
head_tbl = tables[0]
assert head_tbl.header == ["PassengerId", "Survived", "Pclass",
"Name", "Sex"]
assert len(head_tbl.rows) == 3
flat = [str(c) for row in head_tbl.rows for c in row]
assert "Braund Owen" in flat and "Cumings Florence" in flat
# Honest note carries how many rows shown out of the dataset total.
assert head_tbl.note is not None
assert "primeras 3 filas" in head_tbl.note and "891" in head_tbl.note
# No "df.head no disponible" placeholder when head_rows is present.
assert not any(isinstance(b, Note) and "no disponible" in b.text
for b in blocks)
def test_golden_head_desde_ctx_tambien_funciona():
# head_rows absent in profile but present in ctx (build_eda_render_ctx path).
prof = _profile(with_head=False)
ch = build_overview(prof, {"head_rows": _head_rows()})
assert ch is not None
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
flat = [str(c) for row in tables[0].rows for c in row]
assert "Braund Owen" in flat
def test_golden_render_pdf_muestra_head():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pdf")
res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pdf_text(out)
assert "Braund" in txt and "male" in txt
assert "primeras" in txt # head note rendered.
assert "df.head" in txt # chapter heading rendered.
assert "no disponible" not in txt # placeholder NOT shown.
def test_golden_render_pptx_muestra_head():
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "eda.pptx")
res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
assert res["path"] == out and os.path.exists(out)
assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
txt = _pptx_text(out)
assert "Braund" in txt and "Cumings" in txt
def test_edge_sin_head_rows_degrada_a_nota_honesta():
# No head data anywhere: chapter still builds (columns exist), shows the
# honest placeholder note, and never invents rows nor raises.
prof = _profile(with_head=False)
ch = build_overview(prof, {})
assert ch is not None
blocks = _flatten(ch.blocks)
assert any(isinstance(b, Note) and "no disponible" in b.text
for b in blocks)
# The first DataTable now is the column dictionary, not df.head rows.
tables = [b for b in blocks if isinstance(b, DataTable)]
assert all("Braund" not in str(c)
for tbl in tables for row in tbl.rows for c in row)
def test_edge_none_y_vacio_no_rompen():
# Nothing to render at all -> None, no raise.
assert build_overview(None, None) is None
assert build_overview({}, {}) is None
assert build_overview({"columns": []}, {}) is None
# Only head_rows (no columns) still yields a chapter with the head table.
ch = build_overview({"columns": []}, {"head_rows": _head_rows()})
assert ch is not None
tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
assert tables and len(tables[0].rows) == 3
@@ -1,500 +0,0 @@
"""Key-relations chapter (RELACIONES) — the keys / join structure of the data.
This chapter is the *relational* section of an AutomaticEDA report. It answers a
single question for the table (or the whole DuckDB source it lives in): **how do
the keys relate?** It composes, without reimplementing them, the registry's
relation primitives and degrades honestly when a layer does not apply.
It renders, in order, only the layers that have something to say:
1. **Declared keys** (real schema constraints) — when the DuckDB source declares
PRIMARY KEY / FOREIGN KEY / UNIQUE constraints, they are read verbatim via
``detect_declared_keys_duckdb`` and shown as ground truth: which column is the
PK, which columns are FKs and the table/column they point to.
2. **Primary-key candidates** — the ``key_candidates`` the TableProfile already
carries (columns whose cardinality equals the row count, with no nulls). These
are *candidates*: a column that could serve as the row identifier.
3. **Foreign-key candidates** when none are declared:
- **Inter-table** (the DuckDB source has several tables): real FK candidates by
name signal + value containment via ``infer_fk_containment_duckdb``, plus the
join graph (roles + a pasteable Mermaid diagram) via ``build_join_graph``.
- **Intra-table** (a single table): columns that *look* like a foreign key by a
name+cardinality heuristic (``suggest_intratable_fk_candidates``). This is a
**suggestion**, explicitly flagged as a heuristic, never an assertion.
``build_relaciones(profile, ctx) -> Chapter | None``: returns ``None`` when there
is nothing to say (no declared key, no key candidates, and no FK candidate —
inter- or intra-table). Reads everything defensively (``.get``) and never raises:
anything missing degrades to a note or is omitted; a failing registry call drops
its layer instead of aborting the chapter.
ctx keys this chapter consumes (all optional):
db_path, table : str — the DuckDB file and table being profiled (set by
``build_eda_render_ctx``). ``db_path`` is needed to read declared
constraints, to list the sibling tables, and to run the containment-based
FK inference. Without it, only the profile-derived layers (PK candidates,
intra-table FK heuristic) are available.
glossary : model.GlossaryCollector — shared glossary; the chapter registers
the relational terms (PK, FK, containment, cardinality) and marks their
first appearance clickable.
Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""
from __future__ import annotations
from .. import model
# Pure/impure registry functions (group ``eda``) this chapter composes. Imported
# defensively (module-leaf imports, like the AGREGACION chapter) so the chapter
# still builds — degrading the affected layer to nothing — if a function is
# somehow unavailable / not indexed yet.
try:
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
except Exception: # noqa: BLE001 — keep the chapter importable no matter what.
detect_declared_keys_duckdb = None # type: ignore[assignment]
try:
from datascience.infer_fk_containment_duckdb import infer_fk_containment_duckdb
except Exception: # noqa: BLE001
infer_fk_containment_duckdb = None # type: ignore[assignment]
try:
from datascience.build_join_graph import build_join_graph
except Exception: # noqa: BLE001
build_join_graph = None # type: ignore[assignment]
try:
from datascience.suggest_intratable_fk_candidates import (
suggest_intratable_fk_candidates,
)
except Exception: # noqa: BLE001
suggest_intratable_fk_candidates = None # type: ignore[assignment]
try:
from infra import duckdb_list_tables
except Exception: # noqa: BLE001
duckdb_list_tables = None # type: ignore[assignment]
CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "relaciones"
CHAPTER_TITLE = "Relaciones de clave"
# Cap the inter-table FK table so a wide schema does not blow up the page; the
# rest is summarized in a closing note (no silent truncation).
MAX_FK_ROWS = 40
# --------------------------------------------------------------------------- #
# Glossary terms this chapter explains. Registered in the shared collector and
# marked clickable on their first appearance (contract §11.1).
# --------------------------------------------------------------------------- #
_TERMS = {
"pk": (
"Clave primaria (PK)",
"Columna (o conjunto de columnas) que identifica de forma única cada fila "
"de una tabla: sus valores no se repiten y no son nulos. Una tabla tiene "
"como mucho una clave primaria; es el ancla por la que otras tablas la "
"referencian.",
),
"fk": (
"Clave foránea (FK)",
"Columna de una tabla cuyos valores apuntan a la clave primaria de otra "
"tabla (o de la misma), creando una relación entre ambas. Una FK suele ser "
"N:1: muchas filas de la tabla origen comparten el mismo valor de la tabla "
"destino.",
),
"containment": (
"Containment / inclusión",
"Señal con la que se infiere una clave foránea sin que la base la declare: "
"la fracción de valores distintos de una columna A que también aparecen "
"como valores de otra columna B. Si casi todos los valores de A están "
"contenidos en B (inclusión ≈ 1) y B parece una clave, A → B es una FK "
"candidata.",
),
"cardinalidad": (
"Cardinalidad",
"Número de valores distintos de una columna. Cardinalidad igual al número "
"de filas (y sin nulos) señala un identificador (candidato a clave "
"primaria); cardinalidad alta pero menor que el número de filas, con "
"valores repetidos, es típica de una clave foránea.",
),
}
def _register_terms(ctx: dict) -> bool:
"""Register the relational terms in the shared glossary. Returns whether the
in-text appearances should be marked clickable."""
glossary = ctx.get("glossary")
if not isinstance(glossary, model.GlossaryCollector):
return False
for key, (label, definition) in _TERMS.items():
glossary.add(key, label, definition)
return True
# --------------------------------------------------------------------------- #
# Formatting helpers (mirror the other chapters' defensive style).
# --------------------------------------------------------------------------- #
def _fmt_int(value) -> str:
if value is None:
return ""
try:
return f"{int(value):,}".replace(",", ".")
except (TypeError, ValueError):
return model._safe_str(value)
def _fmt_pct_fraction(value, decimals: int = 1) -> str:
"""Format a 01 fraction as a percentage. None -> placeholder."""
if value is None:
return ""
try:
v = float(value)
except (TypeError, ValueError):
return model._safe_str(value)
if v <= 1.0:
v *= 100.0
return f"{v:.{decimals}f}%"
def _fmt_ratio(value, decimals: int = 3) -> str:
"""Format an already-01 ratio (inclusion) as a plain number."""
if value is None:
return ""
try:
return f"{float(value):.{decimals}f}".rstrip("0").rstrip(".")
except (TypeError, ValueError):
return model._safe_str(value)
def _is_dict(v) -> bool:
return isinstance(v, dict)
def _columns_by_name(profile: dict) -> dict:
"""Index the profile columns by name for quick metric lookup."""
out = {}
for col in (profile.get("columns") or []):
if _is_dict(col) and col.get("name") is not None:
out[col.get("name")] = col
return out
# --------------------------------------------------------------------------- #
# Layer 1 — declared keys (real schema constraints).
# --------------------------------------------------------------------------- #
def _declared_keys(db_path: str, table: str):
"""Read declared PK/FK/UNIQUE for the source, or None if unavailable."""
if not db_path or detect_declared_keys_duckdb is None:
return None
try:
out = detect_declared_keys_duckdb(db_path, table)
except Exception: # noqa: BLE001 — dict-no-throw: treat as unavailable.
return None
if not _is_dict(out) or out.get("status") != "ok":
return None
return out
def _declared_section(declared: dict) -> list:
"""Blocks for the declared-keys layer, or [] if there is nothing declared."""
pks = [p for p in (declared.get("primary_keys") or []) if _is_dict(p)]
fks = [f for f in (declared.get("foreign_keys") or []) if _is_dict(f)]
uqs = [u for u in (declared.get("unique") or []) if _is_dict(u)]
if not (pks or fks or uqs):
return []
blocks = [
model.Heading(text="Claves declaradas en el esquema", level=2),
model.Markdown(text=(
"La base **declara** estas relaciones de clave como restricciones "
"reales del esquema (constraints). Son la verdad de referencia: no se "
"infieren, se leen tal cual de la definición de las tablas.")),
]
if pks:
rows = [[model._safe_str(p.get("table")),
", ".join(model._safe_str(c) for c in (p.get("columns") or []))]
for p in pks]
blocks.append(model.DataTable(
header=["Tabla", "Columna(s) PK"], rows=rows,
title="Claves primarias declaradas",
note="Cada fila: la clave primaria declarada de una tabla."))
if fks:
rows = []
for f in fks:
src = ", ".join(model._safe_str(c) for c in (f.get("columns") or []))
dst = ", ".join(
model._safe_str(c) for c in (f.get("referenced_columns") or []))
rows.append([
model._safe_str(f.get("table")), src,
model._safe_str(f.get("referenced_table")), dst])
blocks.append(model.DataTable(
header=["Tabla origen", "Columna(s) FK", "→ Tabla destino",
"Columna(s) destino"],
rows=rows, title="Claves foráneas declaradas",
note="Cada fila: una FK declarada — origen → destino."))
if uqs:
rows = [[model._safe_str(u.get("table")),
", ".join(model._safe_str(c) for c in (u.get("columns") or []))]
for u in uqs]
blocks.append(model.DataTable(
header=["Tabla", "Columna(s) UNIQUE"], rows=rows,
title="Restricciones UNIQUE declaradas"))
return blocks
# --------------------------------------------------------------------------- #
# Layer 2 — primary-key candidates (from the profile).
# --------------------------------------------------------------------------- #
def _pk_candidates_section(profile: dict, mark: bool) -> list:
"""Blocks for the PK-candidates layer, or [] if there are none."""
keys = [k for k in (profile.get("key_candidates") or []) if k is not None]
if not keys:
return []
by_name = _columns_by_name(profile)
pk = ("[[term:pk]]**clave primaria**[[/term]]" if mark
else "**clave primaria**")
intro = (
f"Estas columnas son **candidatas a {pk}**: su "
"[[term:cardinalidad]]cardinalidad[[/term]] iguala al número de filas y no "
"tienen nulos, así que cada valor identifica una fila distinta. Son "
"candidatas, no una clave declarada: la base no las marca como tal."
if mark else
"Estas columnas son **candidatas a clave primaria**: su cardinalidad "
"iguala al número de filas y no tienen nulos, así que cada valor "
"identifica una fila distinta.")
rows = []
for name in keys:
col = by_name.get(name) or {}
rows.append([
model._safe_str(name),
_fmt_int(col.get("distinct_count")),
_fmt_pct_fraction(col.get("unique_pct")),
model._safe_str(col.get("inferred_type") or col.get("physical_type") or ""),
])
return [
model.Heading(text="Candidatos a clave primaria", level=2),
model.Markdown(text=intro),
model.DataTable(
header=["Columna", "Valores distintos", "% único", "Tipo"],
rows=rows, title="Candidatas a clave primaria",
note=f"{_fmt_int(profile.get('n_rows'))} filas en total como referencia."),
]
# --------------------------------------------------------------------------- #
# Layer 3a — inter-table FK candidates (containment) + join graph.
# --------------------------------------------------------------------------- #
def _list_source_tables(db_path: str) -> list:
"""List the tables in the DuckDB source, or [] if it can't be listed."""
if not db_path or duckdb_list_tables is None:
return []
try:
out = duckdb_list_tables(db_path)
except Exception: # noqa: BLE001
return []
if not _is_dict(out) or out.get("status") != "ok":
return []
return [t for t in (out.get("tables") or []) if isinstance(t, str)]
def _inter_table_section(db_path: str, tables: list, mark: bool) -> list:
"""Blocks for the inter-table FK layer (containment + join graph), or []."""
if infer_fk_containment_duckdb is None or len(tables) < 2:
return []
try:
fk = infer_fk_containment_duckdb(db_path, tables=tables)
except Exception: # noqa: BLE001
return []
if not _is_dict(fk) or fk.get("status") != "ok":
return []
candidates = [c for c in (fk.get("fk_candidates") or []) if _is_dict(c)]
if not candidates:
return []
containment = ("[[term:containment]]containment (inclusión de valores)[[/term]]"
if mark else "containment (inclusión de valores)")
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
blocks = [
model.Heading(text="Claves foráneas candidatas (inter-tabla)", level=2),
model.Markdown(text=(
f"La fuente tiene varias tablas. Estas {fk_term} candidatas se infieren "
f"por señal de nombre y por {containment}: una columna de una tabla cuyos "
"valores están contenidos en la clave de otra. No están declaradas por "
"la base; son la relación más probable según los datos.")),
]
shown = candidates[:MAX_FK_ROWS]
rows = []
for c in shown:
rows.append([
f"{model._safe_str(c.get('from_table'))}.{model._safe_str(c.get('from_col'))}",
f"{model._safe_str(c.get('to_table'))}.{model._safe_str(c.get('to_col'))}",
_fmt_ratio(c.get("inclusion")),
model._safe_str(c.get("cardinality") or ""),
"" if c.get("name_match") else "no",
])
note = "Ordenadas por señal de nombre e inclusión."
if len(candidates) > len(shown):
note += f" Se muestran {len(shown)} de {len(candidates)} candidatas."
blocks.append(model.DataTable(
header=["Origen", "→ Destino", "Inclusión", "Cardinalidad", "Coincide nombre"],
rows=rows, title="FK candidatas por containment", note=note))
# Join graph: node roles + a pasteable Mermaid diagram, kept together.
if build_join_graph is not None:
try:
graph = build_join_graph(candidates, tables=tables)
except Exception: # noqa: BLE001
graph = None
if _is_dict(graph):
graph_blocks = [model.Heading(text="Grafo de relaciones", level=3)]
nodes = [n for n in (graph.get("nodes") or []) if _is_dict(n)]
if nodes:
node_rows = [[
model._safe_str(n.get("table")),
model._safe_str(n.get("role") or ""),
_fmt_int(n.get("out_degree")),
_fmt_int(n.get("in_degree")),
] for n in nodes]
graph_blocks.append(model.DataTable(
header=["Tabla", "Rol", "FK salientes", "FK entrantes"],
rows=node_rows, title="Tablas y su rol en el grafo",
note="Rol: fact (apunta a otras), dimension (referenciada), "
"bridge (ambas), standalone (aislada)."))
hubs = [h for h in (graph.get("hubs") or []) if h]
if hubs:
graph_blocks.append(model.Markdown(text=(
"Tablas con más relaciones salientes (candidatas a tabla de "
"hechos): " + ", ".join(model._safe_str(h) for h in hubs) + ".")))
mermaid = model._safe_str(graph.get("mermaid")).strip()
if mermaid:
graph_blocks.append(model.Markdown(text=(
"Diagrama de las relaciones (pegable en un bloque Mermaid):")))
graph_blocks.append(model.Markdown(
text="```mermaid\n" + mermaid + "\n```"))
if len(graph_blocks) > 1:
blocks.append(model.Group(blocks=graph_blocks,
title="Grafo de relaciones"))
skipped = [s for s in (fk.get("skipped") or []) if s]
if skipped:
blocks.append(model.Note(
"Algunos pares se omitieron por tamaño: "
+ "; ".join(model._safe_str(s) for s in skipped) + "."))
return blocks
# --------------------------------------------------------------------------- #
# Layer 3b — intra-table FK candidates (name+cardinality heuristic).
# --------------------------------------------------------------------------- #
def _intra_table_section(profile: dict, mark: bool) -> list:
"""Blocks for the intra-table FK heuristic layer, or [] if no candidates."""
if suggest_intratable_fk_candidates is None:
return []
try:
cands = suggest_intratable_fk_candidates(profile)
except Exception: # noqa: BLE001
return []
cands = [c for c in (cands or []) if _is_dict(c)]
if not cands:
return []
fk_term = "[[term:fk]]**claves foráneas**[[/term]]" if mark else "**claves foráneas**"
blocks = [
model.Heading(text="Posibles claves foráneas (heurística de nombre)", level=2),
model.Markdown(text=(
f"No hay otras tablas que referenciar, pero algunas columnas **parecen** "
f"{fk_term} por su nombre (terminan en «id») y su cardinalidad (muchos "
"valores repetidos, N:1). Es una **sugerencia heurística**, no una "
"afirmación: el nombre de la tabla destino es una conjetura y no se "
"comprueba inclusión de valores contra ninguna tabla real.")),
]
rows = []
for c in cands:
rows.append([
model._safe_str(c.get("column")),
model._safe_str(c.get("ref_table_guess") or ""),
_fmt_int(c.get("distinct_count")),
_fmt_pct_fraction(c.get("unique_pct")),
model._safe_str(c.get("inferred_type") or c.get("physical_type") or ""),
model._safe_str(c.get("reason") or ""),
])
blocks.append(model.DataTable(
header=["Columna", "Posible tabla", "Valores distintos", "% único",
"Tipo", "Motivo"],
rows=rows, title="Posibles FK por nombre y cardinalidad",
note="Heurística: posibles falsos positivos/negativos. No confirma containment."))
blocks.append(model.Note(
"Estas sugerencias se basan solo en el nombre y la cardinalidad. Para "
"confirmarlas haría falta la tabla destino y comprobar la inclusión de "
"valores (containment)."))
return blocks
# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def _intro_blocks(mark: bool) -> list:
pk = "[[term:pk]]clave primaria[[/term]]" if mark else "clave primaria"
fk = "[[term:fk]]clave foránea[[/term]]" if mark else "clave foránea"
text = (
f"Este capítulo analiza las **relaciones de clave** de la tabla: qué columna "
f"identifica cada fila (la {pk}) y qué columnas referencian a otra tabla (las "
f"{fk}). Cuando la base las **declara** como restricciones del esquema, se "
"muestran tal cual; cuando no, se proponen las más probables a partir de los "
"datos —por inclusión de valores entre tablas (containment) o, en una sola "
"tabla, por una heurística de nombre y cardinalidad— siempre marcadas como "
"candidatas, nunca como hechos.")
return [model.Heading(text=CHAPTER_TITLE, level=1), model.Markdown(text=text)]
def build_relaciones(profile: dict, ctx: dict):
"""Build the RELACIONES Chapter, or None if there is nothing to say.
Args:
profile: the ``eda`` group TableProfile dict (may be None/empty).
ctx: presentation context. Consumes ``db_path`` + ``table`` (to read
declared constraints, list sibling tables and run the containment FK
inference) and ``glossary`` (to register the relational terms).
Returns:
A ``model.Chapter`` with the applicable relation layers; or ``None`` when
the dataset has no declared key, no key candidates and no FK candidate
(neither inter- nor intra-table).
"""
if not isinstance(profile, dict):
profile = {}
ctx = ctx if isinstance(ctx, dict) else {}
db_path = ctx.get("db_path")
table = ctx.get("table")
mark = _register_terms(ctx)
# Build each layer; the chapter is the concatenation of the non-empty ones.
declared = _declared_keys(db_path, table)
declared_blocks = _declared_section(declared) if declared else []
declared_has_fk = bool(declared and declared.get("foreign_keys"))
pk_blocks = _pk_candidates_section(profile, mark)
tables = _list_source_tables(db_path)
inter_blocks = _inter_table_section(db_path, tables, mark)
# The intra-table heuristic only makes sense when no real FK is available for
# this table — neither declared nor inferred inter-table. Otherwise the real
# relations already answer the question and the heuristic is just noise.
if declared_has_fk or inter_blocks:
intra_blocks = []
else:
intra_blocks = _intra_table_section(profile, mark)
body = declared_blocks + pk_blocks + inter_blocks + intra_blocks
if not body:
return None # chapter does not apply: nothing to say about relations.
blocks = _intro_blocks(mark) + body
return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
version=CHAPTER_VERSION, blocks=blocks)
@@ -1,273 +0,0 @@
"""Tests for the RELACIONES chapter — DoD: golden(s) + edges + no-cut render.
Two goldens covering the two real paths of the chapter:
- **Intra-table** (a single table, no db source for relations): the chapter shows
the primary-key candidates from the profile and the heuristic foreign-key
suggestions (name + cardinality), explicitly flagged as a heuristic. Renders to
PDF and PPTX with nothing cut.
- **Inter-table** (a real DuckDB file with two related tables, customers/orders,
with a declared FK): the chapter shows the declared keys, the containment-based
FK candidates and the join graph (roles + a pasteable Mermaid diagram).
Edges: a profile with no key candidate and no FK-looking column returns None;
``None`` / ``{}`` profiles do not raise. The chapter registers its glossary terms.
Layers that depend on the sibling registry functions delegated alongside this
chapter (``detect_declared_keys_duckdb``, ``suggest_intratable_fk_candidates``)
are asserted **conditionally on the function being importable**, so the chapter's
honest-degradation contract is what is tested, never a hard dependency on import
timing.
"""
import os
import tempfile
import duckdb
from pptx import Presentation
from pypdf import PdfReader
from datascience.automatic_eda.chapters.relaciones import build_relaciones
from datascience.automatic_eda.model import Chapter, Group, GlossaryCollector
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx
# The optional sibling functions: their layers are asserted only when present.
try:
from datascience.detect_declared_keys_duckdb import detect_declared_keys_duckdb
except Exception: # noqa: BLE001
detect_declared_keys_duckdb = None
try:
from datascience.suggest_intratable_fk_candidates import (
suggest_intratable_fk_candidates,
)
except Exception: # noqa: BLE001
suggest_intratable_fk_candidates = None
# --------------------------------------------------------------------------- #
# Helpers.
# --------------------------------------------------------------------------- #
def _flatten(blocks) -> list:
"""Flatten Group blocks so a test can inspect every leaf block."""
out = []
for b in blocks:
if isinstance(b, Group):
out.extend(_flatten(b.blocks))
else:
out.append(b)
return out
def _text_of(chapter: Chapter) -> str:
"""Collect all visible text of a chapter's blocks into one string."""
parts = []
for b in _flatten(chapter.blocks):
for attr in ("text", "title", "note"):
v = getattr(b, attr, None)
if isinstance(v, str):
parts.append(v)
header = getattr(b, "header", None)
if isinstance(header, list):
parts.extend(str(c) for c in header)
rows = getattr(b, "rows", None)
if isinstance(rows, list):
for r in rows:
if isinstance(r, (list, tuple)):
parts.extend(str(c) for c in r)
else:
parts.append(str(r))
return "\n".join(parts)
def _render_both(chapter: Chapter, tag: str):
"""Render the chapter to PDF and PPTX; return (pdf_text, n_slides)."""
tmp = tempfile.mkdtemp(prefix=f"relaciones_{tag}_")
pdf_path = os.path.join(tmp, "out.pdf")
pptx_path = os.path.join(tmp, "out.pptx")
meta = {"title": f"EDA — {tag}"}
render_automatic_eda_pdf([chapter], pdf_path, meta)
render_automatic_eda_pptx([chapter], pptx_path, meta)
assert os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0
assert os.path.exists(pptx_path) and os.path.getsize(pptx_path) > 0
text = "".join(p.extract_text() or "" for p in PdfReader(pdf_path).pages)
n_slides = len(Presentation(pptx_path).slides)
return text, n_slides
# --------------------------------------------------------------------------- #
# Fixtures.
# --------------------------------------------------------------------------- #
def _titanic_profile() -> dict:
"""A single-table profile: a PK candidate + a column that looks like a FK."""
return {
"table": "titanic",
"source": "/data/titanic.csv",
"n_rows": 891,
"n_cols": 4,
"key_candidates": ["PassengerId"],
"columns": [
{"name": "PassengerId", "inferred_type": "numeric",
"physical_type": "BIGINT", "distinct_count": 891,
"unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "ticket_id", "inferred_type": "numeric",
"physical_type": "BIGINT", "distinct_count": 681,
"unique_pct": 0.76, "flags": []},
{"name": "fare", "inferred_type": "numeric",
"physical_type": "DOUBLE", "distinct_count": 248,
"unique_pct": 0.28, "flags": []},
{"name": "sex", "inferred_type": "categorical",
"physical_type": "VARCHAR", "distinct_count": 2,
"unique_pct": 0.002, "flags": []},
],
}
def _make_relational_db(path: str) -> None:
"""Create a small DuckDB with customers(id) <- orders(customer_id), real FK."""
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders(id INTEGER PRIMARY KEY, "
"customer_id INTEGER REFERENCES customers(id), amount DOUBLE)")
con.execute("INSERT INTO customers VALUES "
"(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')")
con.execute("INSERT INTO orders VALUES "
"(1,1,10.0),(2,1,20.0),(3,2,30.0),(4,3,40.0),"
"(5,3,50.0),(6,4,60.0),(7,5,70.0),(8,2,80.0)")
con.close()
def _orders_profile() -> dict:
"""A profile for the `orders` table of the relational DB."""
return {
"table": "orders",
"source": "orders",
"n_rows": 8,
"n_cols": 3,
"key_candidates": ["id"],
"columns": [
{"name": "id", "inferred_type": "numeric", "physical_type": "INTEGER",
"distinct_count": 8, "unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "customer_id", "inferred_type": "numeric",
"physical_type": "INTEGER", "distinct_count": 5, "unique_pct": 0.625,
"flags": []},
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 8, "unique_pct": 1.0, "flags": []},
],
}
# --------------------------------------------------------------------------- #
# Golden 1 — intra-table.
# --------------------------------------------------------------------------- #
def test_golden_intra_table_pk_and_fk_heuristic():
"""Single table: PK candidate shown; FK heuristic shown (if fn available);
renders to PDF + PPTX with nothing cut."""
prof = _titanic_profile()
glossary = GlossaryCollector()
# No db_path: only the profile-derived layers apply (no declared, no inter).
chapter = build_relaciones(prof, {"glossary": glossary})
assert isinstance(chapter, Chapter)
assert chapter.id == "relaciones"
text = _text_of(chapter)
# PK candidate is always present (comes from the profile).
assert "Candidatos a clave primaria" in text
assert "PassengerId" in text
# Glossary terms got registered.
for key in ("pk", "fk", "cardinalidad"):
assert glossary.has(key)
# FK heuristic layer: present iff the delegated function is importable.
if suggest_intratable_fk_candidates is not None:
assert "Posibles claves foráneas" in text
assert "ticket_id" in text
# The float measure and the PK itself are NOT suggested as FKs.
assert "Posibles FK por nombre" in text
pdf_text, n_slides = _render_both(chapter, "intra")
assert "PassengerId" in pdf_text
assert n_slides >= 1
# --------------------------------------------------------------------------- #
# Golden 2 — inter-table (real DuckDB).
# --------------------------------------------------------------------------- #
def test_golden_inter_table_containment_and_join_graph():
"""Two related tables: declared FK (if fn available) + containment FK
candidate + Mermaid join graph."""
tmp = tempfile.mkdtemp(prefix="relaciones_db_")
db_path = os.path.join(tmp, "shop.duckdb")
_make_relational_db(db_path)
prof = _orders_profile()
glossary = GlossaryCollector()
chapter = build_relaciones(
prof, {"db_path": db_path, "table": "orders", "glossary": glossary})
assert isinstance(chapter, Chapter)
text = _text_of(chapter)
# Inter-table containment FK candidate: customer_id -> customers.id. This path
# uses infer_fk_containment_duckdb + build_join_graph, both already in the
# registry, so it must be present.
assert "Claves foráneas candidatas (inter-tabla)" in text
assert "orders.customer_id" in text
assert "customers.id" in text
# Join graph with a pasteable Mermaid diagram.
assert "Grafo de relaciones" in text
assert "mermaid" in text
assert "graph LR" in text
assert "containment" in text.lower()
# Declared-keys layer: present iff the delegated function is importable.
if detect_declared_keys_duckdb is not None:
assert "Claves declaradas en el esquema" in text
assert "Claves foráneas declaradas" in text
pdf_text, n_slides = _render_both(chapter, "inter")
assert "customer_id" in pdf_text
assert n_slides >= 1
# --------------------------------------------------------------------------- #
# Edges.
# --------------------------------------------------------------------------- #
def test_none_when_no_relations():
"""No key candidates, no FK-looking columns, no db source -> None."""
prof = {
"table": "flat", "n_rows": 100, "n_cols": 2, "key_candidates": [],
"columns": [
{"name": "value", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 50, "unique_pct": 0.5, "flags": []},
{"name": "label", "inferred_type": "categorical",
"physical_type": "VARCHAR", "distinct_count": 3, "unique_pct": 0.03,
"flags": []},
],
}
assert build_relaciones(prof, {}) is None
def test_empty_and_none_profile_do_not_raise():
"""None / {} profile and missing ctx degrade to None without raising."""
assert build_relaciones(None, None) is None
assert build_relaciones({}, {}) is None
assert build_relaciones({}, {"glossary": GlossaryCollector()}) is None
def test_pk_candidate_only_builds_chapter():
"""A profile with only a key candidate (no FK anything, no db) still builds:
the relations chapter applies because there is a PK candidate to report."""
prof = {
"table": "t", "n_rows": 10, "n_cols": 1, "key_candidates": ["row_id"],
"columns": [
{"name": "row_id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 10, "unique_pct": 1.0, "flags": ["possible_id"]},
],
}
chapter = build_relaciones(prof, {})
assert isinstance(chapter, Chapter)
assert "Candidatos a clave primaria" in _text_of(chapter)
@@ -33,7 +33,6 @@ CHAPTER_ORDER = [
"cat_distr", # categorical distributions
"calidad", # data quality
"correlacion", # correlations / associations
"relaciones", # key relations: declared/candidate PK + FK (inter/intra-table)
"modelos", # cheap models (PCA/KMeans/outliers)
"timeseries", # time-series analysis
"geospatial", # geospatial
@@ -20,6 +20,10 @@ vacia y el resto del ctx se construye igual. Ante un fallo global devuelve al
menos ``{**base_ctx, "db_path": db_path, "table": table}``.
Claves de DATOS que produce (las consumen los capitulos):
- ``head_rows`` : [ {col: valor, ...}, ... ] primeras filas CRUDAS de la
tabla (``SELECT * LIMIT head_n``), una entrada por fila.
La lee el capitulo OVERVIEW para mostrar df.head real en
lugar del placeholder "df.head no disponible".
- ``raw_numeric`` : {col: [float|None, ...]} muestra cruda de las columnas
numericas, ALINEADA POR FILA (una entrada por fila aunque
sea None). La leen modelos (clustering 2D en vivo) y
@@ -56,7 +60,7 @@ def _to_float(value):
return None
def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None):
def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000, base_ctx=None, head_n=10):
"""Construye el ctx de datos crudos para los renderers de AutomaticEDA.
Args:
@@ -77,13 +81,15 @@ def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000,
base_ctx: dict opcional con claves de presentacion ya preparadas
(dataset_name, source_origin, ...). Se parte de una copia y NO se
pisan sus claves; solo se añaden las de datos. Default None -> {}.
head_n: numero de filas crudas a muestrear para ``ctx["head_rows"]``
(df.head del capitulo OVERVIEW). Default 10. <=0 omite la clave.
Returns:
El dict ``ctx`` directamente (NO un wrapper {status,...}): se pasa tal
cual como ``meta={"ctx": <ese dict>}`` a render_automatic_eda_pdf/pptx.
Nunca lanza. Claves que puede contener: raw_numeric, timeseries_raw,
geo_points (omitidas si no aplican o fallan), y siempre db_path + table
para backends validos.
Nunca lanza. Claves que puede contener: head_rows, raw_numeric,
timeseries_raw, geo_points (omitidas si no aplican o fallan), y siempre
db_path + table para backends validos.
"""
# Copia de base_ctx: nunca mutamos el dict del caller. Las claves de
# presentacion que ya traiga se conservan; las de datos se añaden encima.
@@ -117,6 +123,24 @@ def build_eda_render_ctx(db_path, table, profile, backend="duckdb", sample=5000,
ctx["db_path"] = db_path
ctx["table"] = table
# 1.5) head_rows: primeras filas CRUDAS de la tabla (SELECT * LIMIT n)
# para que el capitulo OVERVIEW muestre df.head real en vez del
# placeholder. Una sola query, dict-no-throw: si falla, se omite la
# clave (el capitulo degrada a su nota honesta). No se pisa una clave
# head_rows que ya viniera en base_ctx (presentacion).
if head_n and int(head_n) > 0 and "head_rows" not in ctx:
try:
hq = query_fn(f'SELECT * FROM "{table}" LIMIT {int(head_n)}')
if isinstance(hq, dict) and hq.get("status") == "ok":
hrows = [
dict(r) for r in (hq.get("rows") or [])
if isinstance(r, dict)
]
if hrows:
ctx["head_rows"] = hrows
except Exception: # noqa: BLE001 - dict-no-throw: omitir la clave
pass
# 2) Columnas del perfil agregado (lectura defensiva).
cols = profile.get("columns") if isinstance(profile, dict) else None
cols = cols or []
@@ -1,107 +0,0 @@
---
name: detect_declared_keys_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict"
description: "Detecta las claves DECLARADAS (constraints reales) de un schema DuckDB leyendo la table function duckdb_constraints(): extrae PRIMARY KEY, FOREIGN KEY y UNIQUE (ignora NOT NULL y CHECK) y las devuelve normalizadas con sus columnas, y para las FK con su tabla y columnas referenciadas. Con table=None procesa todas las tablas; con table='X' filtra a PK/UNIQUE de X y a FK cuyo origen es X (case-sensitive). A diferencia de infer_fk_containment_duckdb (que INFIERE FKs candidatas por containment de valores cuando el schema no las declara), esta funcion devuelve las relaciones de clave REALES del schema. Estilo dict-no-throw: nunca lanza. Parte del grupo eda (relaciones de clave)."
tags: [eda, duckdb, datascience, relations, primary-key, foreign-key, schema, exploratory-data-analysis]
params:
- name: db_path
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via duckdb_query_readonly; no se crea). Un path inexistente devuelve {status:'error', ...}."
- name: table
desc: "Si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea `table` (no la referenciada). None (default) devuelve los constraints de todas las tablas. La comparacion es case-sensitive (nombres tal cual los devuelve DuckDB)."
output: "dict dict-no-throw. En exito {status:'ok', primary_keys:[{table:str, columns:[str,...]}, ...], foreign_keys:[{table:str, columns:[str,...], referenced_table:str, referenced_columns:[str,...]}, ...], unique:[{table:str, columns:[str,...]}, ...], tables:[str,...]} donde tables es la lista ordenada de tablas (origen) que poseen al menos un constraint PK/FK/UNIQUE emitido. Solo se emiten constraints de clave: NOT NULL y CHECK se ignoran. En error {status:'error', error:str}."
uses_functions: [duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_detecta_pks_y_fk", "test_golden_ignora_not_null_y_check", "test_edge_filtra_por_tabla_orders", "test_edge_filtra_por_tabla_customers", "test_edge_unique_declarado", "test_edge_sin_constraints_listas_vacias", "test_error_db_inexistente_no_lanza", "test_shape_resultado"]
test_file_path: "python/functions/datascience/detect_declared_keys_duckdb_test.py"
file_path: "python/functions/datascience/detect_declared_keys_duckdb.py"
---
## Ejemplo
```python
import sys, os, duckdb
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import detect_declared_keys_duckdb
# Base de ejemplo en /tmp: orders.customer_id -> customers.id (FK declarada)
path = "/tmp/declared_keys_demo.duckdb"
if os.path.exists(path):
os.remove(path)
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders("
" id INTEGER PRIMARY KEY,"
" customer_id INTEGER REFERENCES customers(id),"
" amt DOUBLE)"
)
con.close()
res = detect_declared_keys_duckdb(path)
if res["status"] == "ok":
for pk in res["primary_keys"]:
print(f"PK {pk['table']}({', '.join(pk['columns'])})")
for fk in res["foreign_keys"]:
print(f"FK {fk['table']}({', '.join(fk['columns'])}) -> "
f"{fk['referenced_table']}({', '.join(fk['referenced_columns'])})")
# PK customers(id)
# PK orders(id)
# FK orders(customer_id) -> customers(id)
else:
print("error:", res["error"])
# Filtrar a una tabla concreta (PK/UNIQUE de orders + FK con origen orders):
solo_orders = detect_declared_keys_duckdb(path, table="orders")
print(solo_orders["tables"]) # ['orders']
```
## Cuando usarla
- Cuando exploras un esquema DuckDB y quieres mostrar las relaciones de clave REALES (PK/FK/UNIQUE) que el schema ha declarado, sin inferir nada.
- Como paso del capitulo RELACIONES del grupo `eda`: primero mira las claves declaradas con esta funcion; si el schema no declara FKs, complementa con `infer_fk_containment_duckdb` (inferencia por containment).
- Antes de documentar o migrar un esquema, para listar el contrato de integridad referencial que el motor ya conoce.
- Para validar que las constraints que esperas (esa FK que creaste con `REFERENCES`) realmente estan declaradas en la base materializada.
## Gotchas
- **Impura**: lee de disco via la primitiva read-only `duckdb_query_readonly` (no crea ni modifica la base). El `db_path` debe existir; un path inexistente devuelve `{status:'error'}` (read_only NO crea la base).
- **Requiere `duckdb_constraints()`**: usa la table function `duckdb_constraints()`, disponible en DuckDB modernos (verificado en 1.5.2). En versiones antiguas sin esa funcion, la query falla y se devuelve `{status:'error'}`.
- **Solo claves DECLARADAS**: devuelve lo que el schema declaro con `PRIMARY KEY` / `FOREIGN KEY (... REFERENCES ...)` / `UNIQUE`. Una tabla materializada con `CREATE TABLE AS SELECT` NO lleva constraints — para esos casos no habra claves que mostrar y hay que INFERIRLAS (`infer_fk_containment_duckdb`).
- **NOT NULL y CHECK se ignoran**: `duckdb_constraints()` tambien emite filas `NOT NULL` (DuckDB genera una por cada columna PK) y `CHECK`; esta funcion las descarta y solo conserva PK/FK/UNIQUE.
- **Nombres case-sensitive**: el filtro `table='Orders'` no casa con una tabla `orders`. Se comparan los nombres tal cual los devuelve DuckDB.
- **FK atribuida al origen**: una FOREIGN KEY se atribuye a su tabla ORIGEN (el `table` de la entrada), no a la referenciada. El filtro `table='X'` trae las FK cuyo origen es X, no las que apuntan a X.
- **`tables` = tablas dueñas de constraints emitidos**: la lista `tables` contiene solo las tablas que poseen al menos un PK/FK/UNIQUE en el resultado (su campo `table`), ordenadas. No incluye tablas referenciadas que no tengan constraint propio en la salida.
- **Columnas como listas**: `constraint_column_names` y `referenced_column_names` son columnas LIST de DuckDB; en 1.5.2 llegan como listas Python. La funcion las normaliza a listas de strings con una red de seguridad por si llegaran como string.
## Notas
`duckdb_constraints()` devuelve una fila por constraint con los campos
`table_name`, `constraint_type`, `constraint_column_names`, `referenced_table`,
`referenced_column_names`. Mapeo a la salida:
```text
PRIMARY KEY -> primary_keys[]: {table, columns}
UNIQUE -> unique[]: {table, columns}
FOREIGN KEY -> foreign_keys[]: {table, columns, referenced_table, referenced_columns}
NOT NULL -> ignorado
CHECK -> ignorado
```
Para una FK, `referenced_table` y `referenced_column_names` vienen poblados; para
PK/UNIQUE, `referenced_table` es NULL y `referenced_column_names` una lista vacia.
Complementa a `infer_fk_containment_duckdb`: esta funcion devuelve las relaciones
de clave REALES del schema (declaradas); la otra INFIERE FKs candidatas por
containment de valores cuando el schema no las declaro. En el capitulo RELACIONES
de AutomaticEDA se usan en orden: primero las declaradas, luego la inferencia como
respaldo.
@@ -1,127 +0,0 @@
"""detect_declared_keys_duckdb — lee las claves DECLARADAS de un schema DuckDB.
Funcion impura: lee de disco a traves de la primitiva read-only del grupo
`duckdb` (duckdb_query_readonly). Pertenece al grupo de capacidad `eda`
(relaciones de clave): a diferencia de infer_fk_containment_duckdb, que INFIERE
FOREIGN KEYs candidatas por containment de valores, esta funcion devuelve las
constraints REALES que el schema ha declarado (PRIMARY KEY / FOREIGN KEY /
UNIQUE) leyendo la table function `duckdb_constraints()`.
Es la pieza del capitulo RELACIONES de AutomaticEDA que muestra las relaciones de
clave reales cuando existen — frente a la inferencia, que se usa cuando el schema
no las declaro.
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
devuelve {status:'error', error:str}.
"""
from infra import duckdb_query_readonly
def _as_list(value) -> list:
"""Normaliza el valor de una columna LIST de DuckDB a una lista de strings.
En DuckDB 1.5.2, `constraint_column_names` y `referenced_column_names` llegan
ya como listas Python a traves de duckdb_query_readonly. Este helper es solo
una red de seguridad: si por cualquier motivo llegara como string (p.ej. la
representacion `[id, customer_id]`), la parsea de forma defensiva.
"""
if value is None:
return []
if isinstance(value, (list, tuple)):
return [str(v) for v in value]
if isinstance(value, str):
s = value.strip()
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
if not s.strip():
return []
return [
part.strip().strip("'\"")
for part in s.split(",")
if part.strip().strip("'\"")
]
return [str(value)]
def detect_declared_keys_duckdb(db_path: str, table: str = None) -> dict:
"""Detecta las claves PRIMARY KEY / FOREIGN KEY / UNIQUE declaradas en DuckDB.
Lee la table function `duckdb_constraints()` y extrae solo las constraints de
clave (PRIMARY KEY, FOREIGN KEY, UNIQUE), ignorando NOT NULL y CHECK.
Args:
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only; no se
crea). Un path inexistente devuelve {status:'error', ...} sin lanzar.
table: si se pasa, filtra los resultados a esa tabla: incluye PRIMARY KEY
y UNIQUE cuya tabla sea `table`, y FOREIGN KEY cuya tabla ORIGEN sea
`table`. None (default) devuelve los constraints de todas las tablas.
La comparacion de nombres es case-sensitive (tal cual los devuelve
DuckDB).
Returns:
dict dict-no-throw. En exito:
{status:'ok',
primary_keys:[{table:str, columns:[str, ...]}, ...],
foreign_keys:[{table:str, columns:[str, ...],
referenced_table:str,
referenced_columns:[str, ...]}, ...],
unique:[{table:str, columns:[str, ...]}, ...],
tables:[str, ...]} # tablas (origen) con algun PK/FK/UNIQUE emitido
En error (sin lanzar): {status:'error', error:str}.
"""
try:
sql = (
"SELECT table_name, constraint_type, constraint_column_names, "
"referenced_table, referenced_column_names FROM duckdb_constraints()"
)
res = duckdb_query_readonly(db_path, sql)
if res["status"] != "ok":
return {"status": "error", "error": res["error"]}
primary_keys = []
foreign_keys = []
unique = []
tables = set()
for row in res["rows"]:
ctype = row["constraint_type"]
tname = row["table_name"]
# Filtro por tabla origen: para PK/FK/UNIQUE el dueño del constraint es
# `table_name`. Una FK se atribuye a su tabla origen (no a la
# referenciada), igual que el filtro pide.
if table is not None and tname != table:
continue
cols = _as_list(row["constraint_column_names"])
if ctype == "PRIMARY KEY":
primary_keys.append({"table": tname, "columns": cols})
tables.add(tname)
elif ctype == "UNIQUE":
unique.append({"table": tname, "columns": cols})
tables.add(tname)
elif ctype == "FOREIGN KEY":
foreign_keys.append(
{
"table": tname,
"columns": cols,
"referenced_table": row["referenced_table"],
"referenced_columns": _as_list(
row["referenced_column_names"]
),
}
)
tables.add(tname)
# NOT NULL y CHECK se ignoran: no son relaciones de clave.
return {
"status": "ok",
"primary_keys": primary_keys,
"foreign_keys": foreign_keys,
"unique": unique,
"tables": sorted(tables),
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -1,167 +0,0 @@
"""Tests para detect_declared_keys_duckdb."""
import duckdb
import pytest
from .detect_declared_keys_duckdb import detect_declared_keys_duckdb
@pytest.fixture
def db(tmp_path):
"""DuckDB temporal con claves declaradas.
- customers(id PRIMARY KEY, name)
- orders(id PRIMARY KEY, customer_id REFERENCES customers(id), amt)
Esto declara dos PRIMARY KEY (customers.id, orders.id) y una FOREIGN KEY
(orders.customer_id -> customers.id). DuckDB ademas genera constraints
NOT NULL para las columnas PK, que la funcion debe ignorar.
"""
path = str(tmp_path / "keys_test.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT)")
con.execute(
"CREATE TABLE orders("
" id INTEGER PRIMARY KEY,"
" customer_id INTEGER REFERENCES customers(id),"
" amt DOUBLE"
")"
)
con.close()
return path
def _pk_for(res, table):
"""Devuelve la entrada primary_keys cuya tabla es `table`, o None."""
for pk in res["primary_keys"]:
if pk["table"] == table:
return pk
return None
def test_golden_detecta_pks_y_fk(db):
"""Golden: detecta las dos PK y la FK declaradas, con valores concretos."""
res = detect_declared_keys_duckdb(db)
assert res["status"] == "ok"
# PRIMARY KEY de customers y de orders.
pk_customers = _pk_for(res, "customers")
pk_orders = _pk_for(res, "orders")
assert pk_customers is not None
assert pk_customers["columns"] == ["id"]
assert pk_orders is not None
assert pk_orders["columns"] == ["id"]
# FOREIGN KEY orders.customer_id -> customers.id.
assert len(res["foreign_keys"]) == 1
fk = res["foreign_keys"][0]
assert fk["table"] == "orders"
assert fk["columns"] == ["customer_id"]
assert fk["referenced_table"] == "customers"
assert fk["referenced_columns"] == ["id"]
# tables incluye ambas (origen de algun constraint).
assert res["tables"] == ["customers", "orders"]
def test_golden_ignora_not_null_y_check(db):
"""NOT NULL (auto-generado por las PK) no aparece como clave."""
res = detect_declared_keys_duckdb(db)
assert res["status"] == "ok"
# Solo 2 PK reales (no las NOT NULL que DuckDB genera por cada columna PK).
assert len(res["primary_keys"]) == 2
# No hay UNIQUE declarado en este schema.
assert res["unique"] == []
def test_edge_filtra_por_tabla_orders(db):
"""Edge table='orders': PK de orders + su FK; NO la PK de customers."""
res = detect_declared_keys_duckdb(db, table="orders")
assert res["status"] == "ok"
# Solo la PK de orders.
assert len(res["primary_keys"]) == 1
assert res["primary_keys"][0]["table"] == "orders"
assert res["primary_keys"][0]["columns"] == ["id"]
# La PK de customers NO esta.
assert _pk_for(res, "customers") is None
# La FK de orders si esta (origen = orders).
assert len(res["foreign_keys"]) == 1
assert res["foreign_keys"][0]["table"] == "orders"
assert res["foreign_keys"][0]["referenced_table"] == "customers"
# tables solo contiene orders (la dueña de los constraints emitidos).
assert res["tables"] == ["orders"]
def test_edge_filtra_por_tabla_customers(db):
"""Edge table='customers': solo su PK; ninguna FK (orders queda fuera)."""
res = detect_declared_keys_duckdb(db, table="customers")
assert res["status"] == "ok"
assert len(res["primary_keys"]) == 1
assert res["primary_keys"][0]["table"] == "customers"
assert res["foreign_keys"] == []
assert res["tables"] == ["customers"]
def test_edge_unique_declarado(tmp_path):
"""Edge: una constraint UNIQUE declarada aparece en `unique`."""
path = str(tmp_path / "unique_test.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE products(sku INTEGER UNIQUE, name TEXT)")
con.close()
res = detect_declared_keys_duckdb(path)
assert res["status"] == "ok"
assert len(res["unique"]) == 1
assert res["unique"][0]["table"] == "products"
assert res["unique"][0]["columns"] == ["sku"]
assert res["primary_keys"] == []
assert res["foreign_keys"] == []
assert res["tables"] == ["products"]
def test_edge_sin_constraints_listas_vacias(tmp_path):
"""Edge: tabla sin PK/FK/UNIQUE -> todas las listas vacias, status ok."""
path = str(tmp_path / "no_keys.duckdb")
con = duckdb.connect(path)
con.execute("CREATE TABLE log(a INTEGER, b INTEGER)")
con.close()
res = detect_declared_keys_duckdb(path)
assert res["status"] == "ok"
assert res["primary_keys"] == []
assert res["foreign_keys"] == []
assert res["unique"] == []
assert res["tables"] == []
def test_error_db_inexistente_no_lanza(tmp_path):
"""Error: db_path inexistente -> status error, sin lanzar excepcion."""
path = str(tmp_path / "does_not_exist.duckdb")
res = detect_declared_keys_duckdb(path)
assert res["status"] == "error"
assert isinstance(res["error"], str)
assert res["error"] != ""
def test_shape_resultado(db):
"""El retorno tiene exactamente las claves esperadas."""
res = detect_declared_keys_duckdb(db)
assert set(res.keys()) == {
"status",
"primary_keys",
"foreign_keys",
"unique",
"tables",
}
for pk in res["primary_keys"]:
assert set(pk.keys()) == {"table", "columns"}
for fk in res["foreign_keys"]:
assert set(fk.keys()) == {
"table",
"columns",
"referenced_table",
"referenced_columns",
}
@@ -1,91 +0,0 @@
---
name: suggest_intratable_fk_candidates
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def suggest_intratable_fk_candidates(profile: dict, max_candidates: int = 20) -> list"
description: "Sobre el TableProfile de UNA tabla (el dict de profile_table), sugiere por heuristica de nombre + cardinalidad que columnas PARECEN una clave foranea hacia otra tabla, cuando no hay relaciones inter-tabla que medir (una sola tabla). Es una SUGERENCIA, no una afirmacion: el ref_table_guess es el stem del nombre (customer_id -> customer) y NO confirma containment. Pura: solo lee el dict, sin I/O; nunca lanza (devuelve [])."
tags: [eda, datascience, relationships, foreign-key, fk, heuristic, schema, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: profile
desc: "TableProfile (dict que produce profile_table / summarize_table_*). Se leen de forma defensiva `columns` (lista de ColumnProfile con name/inferred_type/physical_type/distinct_count/unique_pct/flags), `n_rows` (int) y `key_candidates` (lista de nombres de columna ya candidatos a PK, que se excluyen). Si no es dict o no trae columns -> []."
- name: max_candidates
desc: "Tope de sugerencias devueltas (default 20). Las columnas candidatas se ordenan por distinct_count descendente (mas informativas primero) antes de cortar a este maximo."
output: "list (posiblemente vacia) de dicts, uno por columna sugerida, con claves: `column` (nombre), `ref_table_guess` (tabla conjeturada por el stem del nombre, p.ej. customer_id -> 'customer'), `reason` (frase humana que deja claro que es heuristica sin confirmar containment), `distinct_count` (int|None), `unique_pct` (float|None, fraccion 0-1 tal como viene del profile), `inferred_type` (str), `physical_type` (str). Nunca lanza."
tested: true
tests: ["test_golden_customer_id_detectado_otras_no", "test_camelcase_albumid_detectado", "test_constante_status_id_no_aparece", "test_profile_vacio_y_none_devuelven_lista_vacia", "test_category_id_casi_unico_parece_pk_no_aparece", "test_ref_table_guess_multitoken_y_orden_por_distinct", "test_max_candidates_corta_la_lista", "test_id_generico_solo_nunca_es_fk"]
test_file_path: "python/functions/datascience/suggest_intratable_fk_candidates_test.py"
file_path: "python/functions/datascience/suggest_intratable_fk_candidates.py"
---
## Ejemplo
```python
from datascience import suggest_intratable_fk_candidates
# TableProfile de UNA tabla (tipo titanic): customer_id es FK N:1; id es la PK;
# amount es una medida float; name es categorica sin sufijo de id.
profile = {
"n_rows": 891,
"key_candidates": ["id"],
"columns": [
{"name": "id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 891, "unique_pct": 1.0, "flags": ["possible_id"]},
{"name": "customer_id", "inferred_type": "numeric", "physical_type": "BIGINT",
"distinct_count": 137, "unique_pct": 0.15, "flags": []},
{"name": "amount", "inferred_type": "numeric", "physical_type": "DOUBLE",
"distinct_count": 400, "unique_pct": 0.45, "flags": []},
{"name": "name", "inferred_type": "categorical", "physical_type": "VARCHAR",
"distinct_count": 700, "unique_pct": 0.78, "flags": []},
],
}
out = suggest_intratable_fk_candidates(profile)
[c["column"] for c in out] # -> ["customer_id"]
out[0]["ref_table_guess"] # -> "customer"
out[0]["reason"]
# -> "el nombre termina en '_id' y es N:1 (137 valores distintos < 891 filas):
# parece (heuristica por nombre, sin confirmar containment) una referencia a
# una tabla «customer»"
```
## Cuando usarla
Cuando el EDA tiene SOLO UNA tabla y, por tanto, no se puede inferir una FK
inter-tabla por containment (no hay otra tabla cuyos valores contener). Es el plan B
del capitulo RELACIONES de AutomaticEDA: en vez de medir solapamiento de valores
entre tablas (lo correcto cuando hay varias, ver `infer_fk_containment_duckdb` /
`build_join_graph`), conjetura por el NOMBRE de la columna (`<algo>_id`) y por su
CARDINALIDAD N:1 que columnas parecen apuntar a una entidad externa. Usala para
enriquecer el reporte con "estas columnas parecen referencias a otras tablas" sin
prometer que esa tabla exista. NO la uses si tienes varias tablas: ahi mide
containment de verdad.
## Gotchas
- Es **heuristica**, no una verdad: produce **falsos positivos** (una columna
`period_id` que en realidad es un codigo libre, no una FK) y **falsos negativos**
(una FK que no se llama `*_id`, p.ej. `parent`, `owner`, `sku`). No la trates como
una afirmacion de esquema.
- `ref_table_guess` es una **conjetura por el nombre** (el stem sin el sufijo id):
`customer_id` -> `customer`, `AlbumId` -> `album`, `manager_staff_id` ->
`manager_staff`. Puede no coincidir con el nombre real de la tabla (plurales,
prefijos, alias). Es una pista, no un join garantizado.
- **NO confirma containment**: no comprueba que los valores de la columna existan en
ninguna otra tabla (no puede — solo recibe el perfil de una tabla). Para confirmar
una FK real con varias tablas usa `infer_fk_containment_duckdb`.
- Excluye deliberadamente: el `id`/`Id`/`ID` generico a secas (suele ser la PK
propia, no una referencia), las columnas constantes, las que parecen unicas
(`unique_pct >= 0.99`, mas PK que FK) y los tipos no-clave (float/decimal son
medidas; date/time/timestamp y boolean no son claves). En camelCase, `paid`,
`valid`, `grid` (con `id` en minuscula y sin separador) NO se confunden con FK.
- `unique_pct` se interpreta como **fraccion 0-1** (tal como la emite el profile), no
como porcentaje 0-100.
@@ -1,202 +0,0 @@
"""suggest_intratable_fk_candidates — heuristica de FK intra-tabla del grupo `eda`.
Sobre el TableProfile de UNA tabla (el dict que produce ``profile_table``), sugiere
por heuristica de NOMBRE + CARDINALIDAD que columnas PARECEN una clave foranea hacia
otra tabla, util cuando no hay relaciones inter-tabla disponibles (una sola tabla y,
por tanto, sin containment cruzado que medir). Es una SUGERENCIA, no una afirmacion:
no confirma que exista la tabla referida ni que los valores esten contenidos en ella.
La consume el capitulo RELACIONES de AutomaticEDA cuando solo hay una tabla.
Funcion PURA: solo lee el dict (lectura defensiva con ``.get``), no hace I/O y nunca
lanza por inputs raros (devuelve ``[]``).
"""
# inferred_type que es compatible con una clave foranea (entero/categorico).
_FK_INFERRED_OK = {"numeric", "categorical", "integer"}
# Prefijos de physical_type que admiten ser clave foranea (enteros, texto, uuid).
_FK_PHYSICAL_PREFIXES = (
"int", "bigint", "smallint", "tinyint", "hugeint", "uint",
"varchar", "text", "char", "bpchar", "string", "uuid",
)
# Prefijos de physical_type que EXCLUYEN ser clave foranea: medidas en coma flotante
# (float/double/decimal/numeric/real), temporales (date/time/timestamp/interval) y
# boolean. Se comprueban ANTES que las senales positivas (la exclusion gana: una
# columna numeric con physical DOUBLE es una medida, no una FK).
_FK_PHYSICAL_EXCLUDE = (
"float", "double", "decimal", "numeric", "real",
"date", "time", "timestamp", "interval",
"bool",
)
def _fk_name_signal(name):
"""Detecta el sufijo de clave foranea en el nombre y devuelve ``(stem, sufijo)``.
Reconoce ``<algo>_id`` (snake), ``<Algo>Id`` y ``<algo>ID`` (camel). NO reconoce
el ``id``/``Id``/``ID`` generico a secas (suele ser la PK propia de la tabla, no
una referencia). En camelCase la ``I`` mayuscula marca el limite de palabra, asi
que ``paid``/``valid``/``grid`` (``id`` en minuscula y sin separador) NO matchean.
El ``stem`` se devuelve en minusculas y sirve de ``ref_table_guess`` (la tabla a
la que probablemente apunta): ``customer_id`` -> ``"customer"``, ``AlbumId`` ->
``"album"``, ``manager_staff_id`` -> ``"manager_staff"``. Devuelve ``None`` si no
hay senal de nombre.
"""
if not isinstance(name, str):
return None
raw = name.strip()
if not raw:
return None
# Snake: termina en "_id" (indiferente a mayusculas en la parte "id").
if raw.lower().endswith("_id"):
stem = raw[:-3].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "_id")
# Camel todo-mayuscula: "...ID" (p.ej. customerID).
if raw.endswith("ID"):
stem = raw[:-2].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "ID")
# Camel: "...Id" (p.ej. AlbumId).
if raw.endswith("Id"):
stem = raw[:-2].rstrip("_-. ")
if not stem:
return None
return (stem.lower(), "Id")
return None
def _fk_type_compatible(col):
"""True si el tipo de la columna admite ser clave foranea.
Compatible si el ``physical_type`` NO es una medida flotante, una temporal ni
boolean, Y ademas (``inferred_type`` en {numeric, categorical, integer} O el
``physical_type`` empieza por entero/varchar/text/char/uuid). La comparacion es
indistinta a mayusculas/minusculas.
"""
phys = (col.get("physical_type") or "").strip().lower()
inferred = (col.get("inferred_type") or "").strip().lower()
# Exclusion por tipo fisico (gana sobre cualquier senal positiva).
for bad in _FK_PHYSICAL_EXCLUDE:
if phys.startswith(bad):
return False
# Senal positiva por tipo inferido.
if inferred in _FK_INFERRED_OK:
return True
# Senal positiva por tipo fisico (entero/texto/uuid).
for good in _FK_PHYSICAL_PREFIXES:
if phys.startswith(good):
return True
return False
def suggest_intratable_fk_candidates(profile: dict, max_candidates: int = 20) -> list:
"""Sugiere columnas que parecen una FK intra-tabla por nombre + cardinalidad.
Heuristica (no afirma nada): una columna es candidata a clave foranea si su nombre
tiene sufijo de id con stem no vacio (``<algo>_id`` / ``<Algo>Id`` / ``<algo>ID``,
NUNCA el ``id`` generico), no es ya candidata a PK, no es constante, tiene
cardinalidad alta pero por debajo del numero de filas (N:1, no unica) y un tipo
compatible con clave (entero/categorico/texto/uuid; nunca float/fecha/boolean).
Args:
profile: TableProfile (dict de ``profile_table``). Se leen, de forma
defensiva, ``columns`` (lista de ColumnProfile), ``n_rows`` y
``key_candidates`` (nombres de columna ya candidatos a PK).
max_candidates: tope de sugerencias devueltas (default 20). Las columnas se
ordenan por ``distinct_count`` descendente (mas informativas primero)
antes de cortar.
Returns:
list de dicts (posiblemente vacia), uno por columna sugerida, con claves:
``column``, ``ref_table_guess`` (stem del nombre), ``reason`` (frase humana),
``distinct_count``, ``unique_pct`` (fraccion 0-1 tal como viene del profile),
``inferred_type``, ``physical_type``. Nunca lanza: si ``profile`` no es dict o
no hay columnas, devuelve ``[]``.
"""
if not isinstance(profile, dict):
return []
columns = profile.get("columns")
if not isinstance(columns, list):
return []
n_rows = profile.get("n_rows")
has_n_rows = (
isinstance(n_rows, int) and not isinstance(n_rows, bool) and n_rows > 0
)
key_candidates = profile.get("key_candidates")
if not isinstance(key_candidates, (list, tuple, set)):
key_candidates = []
key_set = set(key_candidates)
out = []
for col in columns:
if not isinstance(col, dict):
continue
name = col.get("name")
# 1) Senal de nombre: sufijo de id con stem no vacio.
signal = _fk_name_signal(name)
if signal is None:
continue
ref_guess, suffix = signal
# 2) No es ya candidata a PK (clave primaria de la propia tabla).
if name in key_set:
continue
# 3) No constante y con >= 2 valores distintos.
flags = col.get("flags") or []
if "constant" in flags:
continue
dc = col.get("distinct_count")
if not (isinstance(dc, int) and not isinstance(dc, bool) and dc >= 2):
continue
# 4) Cardinalidad alta pero < n_rows (no es PK) y no parece unica.
if has_n_rows and dc >= n_rows:
continue
unique_pct = col.get("unique_pct")
has_unique = (
isinstance(unique_pct, (int, float)) and not isinstance(unique_pct, bool)
)
if has_unique and unique_pct >= 0.99:
continue
# 5) Tipo compatible con clave foranea (entero/categorico/texto; no medida).
if not _fk_type_compatible(col):
continue
out.append(
{
"column": name,
"ref_table_guess": ref_guess,
"reason": _build_reason(suffix, dc, n_rows if has_n_rows else None, ref_guess),
"distinct_count": dc,
"unique_pct": float(unique_pct) if has_unique else None,
"inferred_type": col.get("inferred_type") or "",
"physical_type": col.get("physical_type") or "",
}
)
# Mas informativas primero (mayor cardinalidad), luego corte.
out.sort(key=lambda d: d.get("distinct_count") or 0, reverse=True)
return out[: max(0, int(max_candidates))]
def _build_reason(suffix, dc, n_rows, ref_guess):
"""Frase humana que deja claro que la sugerencia es heuristica, no confirmada."""
if n_rows is not None:
card = f"es N:1 ({dc} valores distintos < {n_rows} filas)"
else:
card = f"tiene {dc} valores distintos que se repiten (cardinalidad N:1)"
return (
f"el nombre termina en '{suffix}' y {card}: parece (heuristica por nombre, "
f"sin confirmar containment) una referencia a una tabla «{ref_guess}»"
)
@@ -1,157 +0,0 @@
"""Tests para suggest_intratable_fk_candidates (funcion pura, sin I/O)."""
from suggest_intratable_fk_candidates import suggest_intratable_fk_candidates
def _col(name, inferred_type="numeric", physical_type="BIGINT", distinct_count=10,
unique_pct=0.1, flags=None):
"""Construye un ColumnProfile minimo a mano (el dict que emite profile_table)."""
return {
"name": name,
"inferred_type": inferred_type,
"physical_type": physical_type,
"semantic_type": "",
"distinct_count": distinct_count,
"unique_pct": unique_pct,
"null_count": 0,
"null_pct": 0.0,
"flags": list(flags) if flags else [],
}
def test_golden_customer_id_detectado_otras_no():
# Tabla tipo titanic: customer_id es FK N:1; id es la PK; amount es medida;
# name es categorica sin sufijo de id. Solo customer_id debe aparecer.
profile = {
"n_rows": 891,
"key_candidates": ["id"],
"columns": [
_col("id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=891, unique_pct=1.0, flags=["possible_id"]),
_col("customer_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=137, unique_pct=0.15, flags=[]),
_col("amount", inferred_type="numeric", physical_type="DOUBLE",
distinct_count=400, unique_pct=0.45),
_col("name", inferred_type="categorical", physical_type="VARCHAR",
distinct_count=700, unique_pct=0.78),
],
}
out = suggest_intratable_fk_candidates(profile)
assert isinstance(out, list)
assert [c["column"] for c in out] == ["customer_id"]
cand = out[0]
assert cand["ref_table_guess"] == "customer"
assert cand["distinct_count"] == 137
assert cand["unique_pct"] == 0.15
assert cand["inferred_type"] == "numeric"
assert cand["physical_type"] == "BIGINT"
# La razon deja claro que es heuristica + cita el sufijo y la tabla.
assert "customer" in cand["reason"]
assert "_id" in cand["reason"]
def test_camelcase_albumid_detectado():
# AlbumId (camelCase, VARCHAR) -> detectada, ref_table_guess "album".
profile = {
"n_rows": 3503,
"key_candidates": ["TrackId"],
"columns": [
_col("AlbumId", inferred_type="categorical", physical_type="VARCHAR",
distinct_count=347, unique_pct=0.10),
],
}
out = suggest_intratable_fk_candidates(profile)
# TrackId es PK candidata (en key_candidates), AlbumId no -> AlbumId aparece.
assert [c["column"] for c in out] == ["AlbumId"]
assert out[0]["ref_table_guess"] == "album"
def test_constante_status_id_no_aparece():
# status_id constante (flag "constant", distinct_count 1) NO es FK util.
profile = {
"n_rows": 1000,
"key_candidates": [],
"columns": [
_col("status_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=1, unique_pct=0.001, flags=["constant"]),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
def test_profile_vacio_y_none_devuelven_lista_vacia():
# Lectura defensiva: ni {} ni None lanzan; devuelven [].
assert suggest_intratable_fk_candidates({}) == []
assert suggest_intratable_fk_candidates(None) == []
# profile sin columns o con columns no-lista tampoco lanza.
assert suggest_intratable_fk_candidates({"n_rows": 10}) == []
assert suggest_intratable_fk_candidates({"columns": "no-soy-lista"}) == []
def test_category_id_casi_unico_parece_pk_no_aparece():
# unique_pct 0.999 -> parece PK (no N:1) -> NO se sugiere como FK.
profile = {
"n_rows": 891,
"key_candidates": [],
"columns": [
_col("category_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=890, unique_pct=0.999),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
def test_ref_table_guess_multitoken_y_orden_por_distinct():
# manager_staff_id conserva los underscores del stem -> "manager_staff".
# Ademas, con varias candidatas, se ordenan por distinct_count descendente.
profile = {
"n_rows": 10000,
"key_candidates": ["staff_id"], # staff_id es PK aqui, no debe aparecer
"columns": [
_col("staff_id", inferred_type="numeric", physical_type="BIGINT",
distinct_count=10000, unique_pct=1.0, flags=["possible_id"]),
_col("store_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=2, unique_pct=0.0002),
_col("manager_staff_id", inferred_type="numeric", physical_type="INTEGER",
distinct_count=40, unique_pct=0.004),
],
}
out = suggest_intratable_fk_candidates(profile)
cols = [c["column"] for c in out]
# staff_id excluida (PK); las otras dos ordenadas por distinct desc.
assert cols == ["manager_staff_id", "store_id"]
refs = {c["column"]: c["ref_table_guess"] for c in out}
assert refs["manager_staff_id"] == "manager_staff"
assert refs["store_id"] == "store"
def test_max_candidates_corta_la_lista():
# max_candidates limita el numero de sugerencias devueltas.
profile = {
"n_rows": 10000,
"key_candidates": [],
"columns": [
_col("a_id", distinct_count=300, unique_pct=0.03),
_col("b_id", distinct_count=200, unique_pct=0.02),
_col("c_id", distinct_count=100, unique_pct=0.01),
],
}
out = suggest_intratable_fk_candidates(profile, max_candidates=2)
assert [c["column"] for c in out] == ["a_id", "b_id"]
def test_id_generico_solo_nunca_es_fk():
# 'id'/'Id'/'ID' a secas (sin stem) jamas se sugieren como FK.
profile = {
"n_rows": 500,
"key_candidates": [],
"columns": [
_col("id", distinct_count=500, unique_pct=1.0),
_col("Id", distinct_count=120, unique_pct=0.24),
_col("ID", distinct_count=80, unique_pct=0.16),
],
}
out = suggest_intratable_fk_candidates(profile)
assert out == []
@@ -536,6 +536,21 @@ def profile_table(
type_breakdown[it] += 1
prof["type_breakdown"] = type_breakdown
# 8.1) Primeras filas crudas (df.head) para el capitulo OVERVIEW del motor
# AutomaticEDA: una muestra SELECT col1,col2,... LIMIT 10 alineada por fila.
# Se reusa _sample_rows (mismo lector read-only). Estilo dict-no-throw: si
# falla, head_rows queda None y el capitulo degrada a su nota honesta. El
# capitulo lo recoge via profile["head_rows"]; build_eda_render_ctx ademas
# lo replica en ctx["head_rows"] cuando se construye el contexto de render.
try:
head_names = [c.get("name") for c in cols if c.get("name")]
head_rows = _sample_rows(_q, table, head_names, 10)
prof["head_rows"] = [
dict(r) for r in head_rows if isinstance(r, dict)
] or None
except Exception: # noqa: BLE001
prof["head_rows"] = None
# 8.5) Matriz de correlacion/asociacion sobre una muestra de filas
# alineadas. Elige la metrica por par de tipos (Pearson/Spearman,
# Cramer's V/Theil's U, correlation ratio, MI) via association_matrix.
@@ -4,9 +4,9 @@ kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = 5000, run_models: bool = True, run_series: bool = True, run_llm: bool = False, out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
version: "1.1.0"
signature: "def render_automatic_eda(db_path: str, table: str, backend: str = \"duckdb\", sample: int = None, run_models: bool = None, run_series: bool = None, run_llm: bool = None, profile_level: str = \"standard\", out_dir: str = \"reports\", basename: str = None, ctx_extra: dict = None) -> dict"
description: "Informe AutomaticEDA COMPLETO one-shot de una tabla DuckDB/PostgreSQL: perfila con profile_table, construye el ctx con los datos crudos (build_eda_render_ctx: raw_numeric para modelos/geo, timeseries_raw para series, geo_points para el mapa, db_path/table para la agregacion push-down) y emite PDF (A5 movil) Y PPTX (16:9) del mismo documento por capitulos, con los 11 capitulos POBLADOS de verdad (clusters pintados sobre el PCA, evolucion temporal, mapa geografico y tablas de agregacion), no degradados. El parametro profile_level es un preset de consumo CPU/LLM (lite/standard/full) que mapea a los flags run_models/run_series/run_llm/sample; un flag explicito siempre prima sobre el preset. lite=bajo consumo (sin LLM, sin serie, modelos solo PCA+normalidad sin KMeans/IsolationForest, sample reducido); standard=comportamiento historico; full=standard+narrativa LLM. Devuelve las rutas de PDF/PPTX y el manifiesto de versiones por capitulo."
tags: [eda, duckdb, postgres, profiling, pipeline, dataops, report, pdf, pptx]
uses_functions:
- profile_table_py_pipelines
@@ -31,13 +31,15 @@ params:
- name: backend
desc: "'duckdb' (default) o 'postgres'. Selecciona el motor de perfilado y muestreo."
- name: sample
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default 5000."
desc: "Maximo de filas/valores muestreados por columna para el perfil y para los datos crudos del ctx (LIMIT). Default None => lo fija el preset de profile_level (lite=2000, standard/full=5000). Un valor explicito prima sobre el preset."
- name: run_models
desc: "Si True (default) corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA."
desc: "Corre los modelos baratos (PCA/KMeans/IsolationForest/normalidad); necesario para que el capitulo modelos pinte los clusters sobre el plano PCA. Default None => lo fija el preset (True en los tres niveles); en lite los modelos se limitan a PCA+normalidad. Un valor explicito prima sobre el preset."
- name: run_series
desc: "Si True (default) calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries (la grafica de evolucion sale de los datos crudos del ctx aunque sea False)."
desc: "Calcula el analisis de serie temporal por columna numerica; necesario para el analisis del capitulo timeseries. Default None => lo fija el preset (standard/full=True, lite=False). Un valor explicito prima sobre el preset."
- name: run_llm
desc: "Si True (default False) hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red."
desc: "Hace la interpretacion LLM del perfil y ACTIVA la narrativa LLM de los capitulos modelos/geospatial/agregacion (titulos de segmento, descripcion de zona, seleccion de agregaciones). Con False usan su derivacion cuantitativa sin red. Default None => lo fija el preset (full=True, lite/standard=False). Un valor explicito prima sobre el preset."
- name: profile_level
desc: "Preset de consumo CPU/LLM (default 'standard'). Mapea a defaults de run_models/run_series/run_llm/sample; un flag explicito SIEMPRE prima. 'lite'=bajo consumo (run_llm=False, run_series=False, sample=2000, modelos solo PCA+normalidad sin KMeans/IsolationForest); 'standard'=comportamiento historico (modelos completos, serie, sin LLM); 'full'=standard+narrativa LLM. Un nivel desconocido cae a 'standard'."
- name: out_dir
desc: "Directorio de salida (se crea si no existe). Default 'reports'."
- name: basename
@@ -52,14 +54,21 @@ output: "dict {status:'ok', pdf_path:str, pptx_path:str, manifest_path:str|None,
```python
from pipelines.render_automatic_eda import render_automatic_eda
# Tabla DuckDB con categoricas + fecha + numericas: informe completo a reports/.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
run_models=True, run_series=True, out_dir="reports")
# Informe completo a reports/ (standard = comportamiento por defecto historico).
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", out_dir="reports")
print(r["status"], r["pdf_path"], r["pptx_path"], r["n_pages"], r["n_slides"])
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 14 16
# ok reports/aeda_ventas_20260630-120500.pdf reports/aeda_ventas_20260630-120500.pptx 37 39
# Con narrativa LLM (titulos de segmento, descripcion geografica, etc.):
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", run_llm=True)
# Bajo consumo (CPU/LLM): vistazo rapido y barato — sin LLM, sin serie, modelos
# solo PCA + normalidad (sin KMeans/IsolationForest), sample reducido.
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="lite")
# Maximo: standard + narrativa LLM por capitulo (titulos de segmento, etc.).
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas", profile_level="full")
# Precedencia: el flag explicito SIEMPRE prima sobre el preset. lite pero con LLM:
r = render_automatic_eda("/tmp/ventas.duckdb", "ventas",
profile_level="lite", run_llm=True) # el LLM SI se ejecuta
```
## Cuando usarla
@@ -72,20 +81,41 @@ llama a los dos renderers": este pipeline orquesta `profile_table` ->
entregable para compartir un EDA, o como el motor detras de `profile_table(
emit_automatic=True)` y del skill `/eda`.
Para un EDA **barato/rapido** (CI, vistazo previo, maquina sin GPU o sin red) usa
`profile_level="lite"`: evita KMeans + IsolationForest (lo caro en CPU), la serie
temporal y el LLM. Para el **maximo** con interpretacion narrativa por capitulo,
`profile_level="full"`. El default `"standard"` mantiene el comportamiento previo.
## Gotchas
- Impura: ESCRIBE el PDF, el PPTX y `automatic_eda_manifest.json` en `out_dir`.
- `db_path` debe existir: DuckDB read-only no crea la base.
- `run_models=True` y `run_series=True` por defecto encarecen el perfil (PCA/
KMeans/IsolationForest + ADF/KPSS/STL por columna). Para un informe mas barato
ponlos a False: los capitulos modelos/timeseries se omiten o se reducen, pero
el resto del informe sale igual.
- `run_llm=True` hace llamadas de red (interpretacion del perfil + narrativa por
capitulo). Sin red, dejalo en False: los capitulos siguen completos con su
derivacion cuantitativa (titulos de segmento derivados, nota geografica
derivada, seleccion de agregaciones cuantitativa).
- **Precedencia de flags vs preset**: `profile_level` solo fija los DEFAULTS de
`run_models`/`run_series`/`run_llm`/`sample` (los que quedan en None). Cualquiera
de esos flags pasado explicito gana al preset. Ej: `profile_level="lite",
run_llm=True` ejecuta el LLM pese a que lite lo apaga por defecto.
- **lite y la seleccion de features de modelo**: en lite los modelos (PCA +
normalidad) corren sobre la muestra numerica cruda (`ctx['raw_numeric']`), sin la
poda fina de features que aplica el modo standard (que excluye ids enteros y
columnas de baja cardinalidad antes de PCA/KMeans). Es el coste de mantener el
cableado minimo y barato; para el analisis fino de modelos usa standard/full.
- `profile_level="standard"`/`"full"` corren PCA/KMeans/IsolationForest +
ADF/KPSS/STL por columna (caro). Para un informe mas barato usa `"lite"` (o pon
los flags a False a mano): los capitulos modelos/timeseries se reducen pero el
resto del informe sale igual.
- `run_llm=True` (preset full o flag explicito) hace llamadas de red
(interpretacion del perfil + narrativa por capitulo). Sin red, usa lite/standard:
los capitulos siguen completos con su derivacion cuantitativa.
- El PPTX requiere `python-pptx`; si no esta instalado, `pptx_path` sera None y
`pptx_note` lo explica (el PDF se emite igual).
- Los datos crudos del ctx se muestrean con `sample` (LIMIT), no se trae la tabla
entera a RAM; con tablas enormes sube `sample` si quieres mas representatividad
(coste: mas memoria).
## Capability growth log
- v1.1.0 (2026-06-30) — anade el parametro `profile_level` (lite/standard/full),
preset de consumo CPU/LLM que mapea a los flags run_models/run_series/run_llm/
sample. lite limita los modelos a PCA+normalidad (cableado a run_eda_models con
run_kmeans=False/run_isolation=False) y apaga LLM/serie. Cambio aditivo y
retro-compatible: sin profile_level el comportamiento es identico al de v1.0.0.
@@ -34,21 +34,62 @@ from datascience import (
build_eda_render_ctx,
render_automatic_eda_pdf,
render_automatic_eda_pptx,
run_eda_models,
)
from pipelines.profile_table import profile_table
# Tokens de almacenamiento por backend (para la portada del informe).
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}
# Presets de consumo CPU/LLM: cada profile_level fija SOLO los DEFAULTS de los
# flags que controlan el coste (un flag explícito del caller siempre prima sobre
# el preset). model_opts != None marca el camino "modelos baratos" (lite): los
# modelos NO los corre profile_table (que ejecutaría KMeans + IsolationForest),
# sino run_eda_models con esa granularidad, de modo que el coste CPU de los
# multivariantes nunca se paga. model_opts None => modelos completos como hasta
# ahora (profile_table los corre con todos los algoritmos).
_PROFILE_PRESETS = {
# Bajo consumo: sin LLM, sin serie, sample reducido y modelos limitados a
# PCA + normalidad (sin KMeans ni IsolationForest, lo caro en CPU). Vistazo
# rápido y barato de una tabla.
"lite": {
"run_models": True,
"run_series": False,
"run_llm": False,
"sample": 2000,
"model_opts": {"run_kmeans": False, "run_isolation": False},
},
# Default: idéntico al comportamiento histórico del pipeline (modelos
# completos, serie temporal, sin LLM, sample 5000).
"standard": {
"run_models": True,
"run_series": True,
"run_llm": False,
"sample": 5000,
"model_opts": None,
},
# Máximo: standard + narrativa LLM (interpretación del perfil y de los
# capítulos modelos/geospatial/agregacion). Es la única parte que gasta
# tokens del modelo.
"full": {
"run_models": True,
"run_series": True,
"run_llm": True,
"sample": 5000,
"model_opts": None,
},
}
def render_automatic_eda(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = True,
run_series: bool = True,
run_llm: bool = False,
sample: int = None,
run_models: bool = None,
run_series: bool = None,
run_llm: bool = None,
profile_level: str = "standard",
out_dir: str = "reports",
basename: str = None,
ctx_extra: dict = None,
@@ -60,19 +101,39 @@ def render_automatic_eda(
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres".
sample: máximo de filas/valores muestreados por columna para el perfil
y para los datos crudos del ctx (LIMIT). Default 5000.
run_models: si True (default) corre los modelos baratos
y para los datos crudos del ctx (LIMIT). Default None => lo fija el
preset de profile_level (lite=2000, standard/full=5000).
run_models: corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad). Necesario para que el
capítulo `modelos` pinte los clusters sobre el plano PCA.
run_series: si True (default) calcula el análisis de serie temporal por
capítulo `modelos` pinte los clusters sobre el plano PCA. Default
None => lo fija el preset (True en los tres niveles); en `lite` los
modelos se limitan a PCA + normalidad (ver profile_level).
run_series: calcula el análisis de serie temporal por
columna numérica. Necesario para el análisis del capítulo
`timeseries` (la gráfica de evolución sale de los datos crudos del
ctx aunque run_series sea False).
run_llm: si True (default False) hace la interpretación LLM del perfil y
ctx aunque run_series sea False). Default None => lo fija el preset
(standard/full=True, lite=False).
run_llm: hace la interpretación LLM del perfil y
ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
agregacion (títulos de segmento, descripción de la zona, selección de
agregaciones). Con False esos capítulos usan su derivación
cuantitativa (siguen completos, sin llamadas de red).
cuantitativa (siguen completos, sin llamadas de red). Default None =>
lo fija el preset (full=True, lite/standard=False).
profile_level: preset de consumo CPU/LLM. Mapea a defaults de los flags
anteriores; un flag explícito SIEMPRE prima sobre el preset (el
preset solo fija el default cuando el flag se deja en None):
- "lite" bajo consumo: run_llm=False, run_series=False,
sample=2000 y modelos limitados a **PCA + normalidad** (SIN KMeans
ni IsolationForest, que es lo caro en CPU). Pensado para un vistazo
rápido y barato. El capítulo `modelos` sale con PCA + normalidad,
sin el scatter de clusters.
- "standard" (default): comportamiento histórico — modelos completos
(PCA/KMeans/IsolationForest/normalidad), serie temporal, sin LLM.
- "full" standard + narrativa LLM (run_llm=True).
Ejemplo de precedencia: profile_level="lite" con run_llm=True
explícito => el LLM SÍ se ejecuta (el flag explícito gana al preset).
out_dir: directorio de salida (se crea si no existe). Default "reports".
basename: nombre base de los archivos sin extensión. Default
"aeda_<table>_<timestamp>".
@@ -90,6 +151,24 @@ def render_automatic_eda(
En error: {"status": "error", "error": str}.
"""
try:
# 0) Resolución del preset: el profile_level fija los DEFAULTS de los
# flags de coste; cualquier flag que el caller haya pasado explícito
# (!= None) prima sobre el preset. Un profile_level desconocido cae a
# "standard" (comportamiento histórico), sin lanzar.
preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
sample = preset["sample"] if sample is None else sample
run_models = preset["run_models"] if run_models is None else run_models
run_series = preset["run_series"] if run_series is None else run_series
run_llm = preset["run_llm"] if run_llm is None else run_llm
model_opts = preset["model_opts"]
# En el camino "modelos baratos" (lite) profile_table NO corre los
# modelos: los ejecuta este pipeline con run_eda_models y la granularidad
# del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
# En standard/full profile_table los corre completos como siempre.
lite_models = bool(run_models) and model_opts is not None
pt_run_models = bool(run_models) and not lite_models
# 1) Perfil base + modelos/serie opcionales. No escribe report propio
# (write_report=False): este pipeline emite su propio par PDF/PPTX.
pres = profile_table(
@@ -97,7 +176,7 @@ def render_automatic_eda(
table,
backend=backend,
sample=sample,
run_models=run_models,
run_models=pt_run_models,
run_llm=run_llm,
run_series=run_series,
emit_pdf=False,
@@ -131,6 +210,28 @@ def render_automatic_eda(
base_ctx=base_ctx,
)
# 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
# IsolationForest). profile_table no corrió los modelos; aquí se corren
# con run_eda_models reusando la muestra numérica alineada por fila que
# build_eda_render_ctx ya trajo en ctx['raw_numeric'] (no se reimplementa
# la lógica de los modelos: se delega en run_eda_models con la
# granularidad del preset).
if lite_models:
raw_numeric = ctx.get("raw_numeric") if isinstance(ctx, dict) else None
if isinstance(raw_numeric, dict) and raw_numeric:
model_input = {
col: {"values": vals, "type": "numeric"}
for col, vals in raw_numeric.items()
}
prof["models"] = run_eda_models(model_input, **model_opts)
# Quita raw_numeric del ctx para que el capítulo `modelos` NO
# reproyecte clusters KMeans en vivo (project_clusters_2d ejecuta
# KMeans): en lite ese coste se evita. geo_points ya quedó derivado
# en ctx por build_eda_render_ctx, así que el capítulo geospatial no
# se ve afectado.
if isinstance(ctx, dict):
ctx.pop("raw_numeric", None)
# 3) Render a ambos formatos desde el MISMO documento por capítulos.
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -89,3 +89,170 @@ def test_pipeline_bad_db_degrades_without_raising(tmp_path):
out_dir=str(tmp_path / "o"))
assert r["status"] == "error"
assert "error" in r
# --------------------------------------------------------------------------- #
# profile_level: preset de bajo consumo CPU/LLM.
# --------------------------------------------------------------------------- #
def _make_db_models(path):
"""DB con >=2 numéricas continuas (alta cardinalidad, 3 clusters gaussianos).
El DB `sales` de _make_db solo deja UNA columna de modelo tras la selección de
features (units es baja cardinalidad, lat/lon discretizadas), insuficiente para
PCA/KMeans/IsolationForest (necesitan >=2). Este DB sí tiene 3 numéricas
continuas con estructura de clusters para que el modo completo ejecute los
multivariantes.
"""
import random
from datetime import date, timedelta
con = duckdb.connect(path)
con.execute(
"CREATE TABLE pts (d DATE, grp VARCHAR, x1 DOUBLE, x2 DOUBLE, x3 DOUBLE)"
)
random.seed(42)
centers = [(0.0, 0.0, 0.0), (10.0, 10.0, 10.0), (20.0, 5.0, 15.0)]
d0 = date(2024, 1, 1)
rows = []
for i in range(150):
cx, cy, cz = centers[i % 3]
rows.append((
d0 + timedelta(days=i), f"g{i % 3}",
round(cx + random.gauss(0, 1.0), 4),
round(cy + random.gauss(0, 1.0), 4),
round(cz + random.gauss(0, 1.0), 4),
))
con.executemany("INSERT INTO pts VALUES (?,?,?,?,?)", rows)
con.close()
def test_profile_level_lite_skips_expensive_models(tmp_path):
"""lite: el bloque models trae PCA + normalidad pero NO KMeans/IsolationForest.
Demuestra (DoD bajo consumo) que el camino lite no ejecuta los modelos caros
en CPU ni la capa LLM ni la serie temporal: prof['models'] queda con pca y
normality poblados y kmeans/outliers a None, prof['llm'] y prof['series'] a
None, y el capítulo `modelos` se renderiza igualmente (con PCA, sin clusters).
"""
import json
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", profile_level="lite",
out_dir=out, basename="lite")
assert r["status"] == "ok", r.get("error")
models = (r["profile"] or {}).get("models") or {}
assert models.get("pca") is not None, "lite debe traer PCA"
assert models.get("normality") is not None, "lite debe traer normalidad"
assert models.get("kmeans") is None, "lite NO debe ejecutar KMeans"
assert models.get("outliers") is None, "lite NO debe ejecutar IsolationForest"
assert (r["profile"] or {}).get("llm") is None, "lite NO debe llamar al LLM"
assert (r["profile"] or {}).get("series") is None, "lite NO debe calcular serie"
# El capítulo modelos sigue presente (lo puebla el PCA), sin clusters KMeans.
with open(r["manifest_path"], encoding="utf-8") as fh:
man = json.load(fh)
assert "modelos" in (man.get("chapters") or {})
def test_profile_level_standard_runs_full_models(tmp_path):
"""standard (default): modelos completos (KMeans + IsolationForest) y serie."""
db = str(tmp_path / "pts.duckdb")
_make_db_models(db)
out = str(tmp_path / "out")
r = render_automatic_eda(db, "pts", profile_level="standard",
out_dir=out, basename="std")
assert r["status"] == "ok", r.get("error")
models = (r["profile"] or {}).get("models") or {}
assert models.get("pca") is not None
assert models.get("kmeans") is not None, "standard debe ejecutar KMeans"
assert models.get("outliers") is not None, "standard debe ejecutar IsolationForest"
assert (r["profile"] or {}).get("series") is not None, "standard calcula serie"
def _patch_pipeline_internals(monkeypatch, captured):
"""Stub de las dependencias del pipeline para tests de resolución de flags.
Sustituye profile_table / build_eda_render_ctx / renderers por stubs rápidos
sin red ni matplotlib, capturando los kwargs con los que se invocan. Permite
verificar la PRECEDENCIA flag-explícito-sobre-preset sin ejecutar el EDA real.
"""
import pipelines.render_automatic_eda as mod
def fake_profile_table(db_path, table, **kw):
captured["run_llm"] = kw.get("run_llm")
captured["run_models"] = kw.get("run_models")
captured["run_series"] = kw.get("run_series")
captured["sample"] = kw.get("sample")
return {"status": "ok", "profile": {"columns": []}}
def fake_ctx(db_path, table, prof, **kw):
captured["base_ctx"] = kw.get("base_ctx")
return {}
monkeypatch.setattr(mod, "profile_table", fake_profile_table)
monkeypatch.setattr(mod, "build_eda_render_ctx", fake_ctx)
monkeypatch.setattr(mod, "render_automatic_eda_pdf",
lambda *a, **k: {"path": "x.pdf", "n_pages": 1,
"manifest_path": "m.json"})
monkeypatch.setattr(mod, "render_automatic_eda_pptx",
lambda *a, **k: {"path": "x.pptx", "n_slides": 1})
def test_explicit_flag_overrides_preset(monkeypatch):
"""Precedencia: profile_level='lite' con run_llm=True explícito → LLM activo.
El flag explícito del caller gana al default del preset. Se verifica tanto en
el flag que llega a profile_table (run_llm=True ⇒ profile_table llamará al
LLM) como en el base_ctx (run_cluster_llm=True ⇒ narrativa LLM por capítulo).
"""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="lite", run_llm=True)
assert captured["run_llm"] is True, "flag explícito debe primar sobre preset lite"
assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True
def test_full_preset_enables_llm(monkeypatch):
"""full: el preset resuelve run_llm=True y activa la narrativa LLM en el ctx."""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="full")
assert captured["run_llm"] is True
assert (captured["base_ctx"] or {}).get("run_cluster_llm") is True
def test_no_profile_level_defaults_to_standard(monkeypatch):
"""Retro-compat: sin profile_level ni flags, el comportamiento es el histórico.
standard = run_models True, run_series True, run_llm False, sample 5000. Es el
mismo default que tenía el pipeline antes de introducir profile_level (cambio
aditivo: las llamadas existentes no cambian de comportamiento).
"""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t") # sin profile_level ni flags de coste
assert captured["run_models"] is True
assert captured["run_series"] is True
assert captured["run_llm"] is False
assert captured["sample"] == 5000
def test_lite_preset_defaults(monkeypatch):
"""lite por defecto: run_llm/run_series False y sample reducido a 2000."""
captured = {}
_patch_pipeline_internals(monkeypatch, captured)
captured.clear()
render_automatic_eda("db", "t", profile_level="lite")
assert captured["run_llm"] is False
assert captured["run_series"] is False
assert captured["sample"] == 2000