68f4ddabce
Añade el capítulo `relaciones` al motor AutomaticEDA: analiza las relaciones de clave de la tabla/base y se coloca tras `correlacion`, antes de `modelos`, en CHAPTER_ORDER. Capas que renderiza (solo las que aplican; None si no hay nada que decir): - Claves declaradas: PK/FK/UNIQUE reales del esquema DuckDB, vía la nueva función `detect_declared_keys_duckdb` (lee `duckdb_constraints()`). - Candidatos a clave primaria: los `key_candidates` del TableProfile. - FK candidatas inter-tabla: reusa `infer_fk_containment_duckdb` (containment + señal de nombre) y `build_join_graph` (roles de nodos + diagrama Mermaid pegable). Solo si la fuente DuckDB tiene varias tablas. - FK candidatas intra-tabla: heurística nombre + cardinalidad, vía la nueva función pura `suggest_intratable_fk_candidates`, marcada como sugerencia. Engancha al glosario clicable los términos PK, FK, containment/inclusión y cardinalidad (contrato §11.1) y usa Group (keep-together) para el grafo. Funciones nuevas del registry (grupo `eda`): - detect_declared_keys_duckdb (impure, datascience) + test. - suggest_intratable_fk_candidates (pure, datascience) + test. Tests: relaciones_test.py (golden intra + inter, edges, no-cut render) + los tests de ambas funciones. Suite automatic_eda + render_automatic_eda verde (89 passed). Golden end-to-end con el pipeline render_automatic_eda verificado sobre titanic (intra) y una BD customers/orders (inter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
8.1 KiB
Python
203 lines
8.1 KiB
Python
"""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}»"
|
|
)
|