feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""summarize_table_duckdb — perfil base de una tabla DuckDB en una sola pasada SQL.
|
||||
|
||||
Funcion impura: lee de disco a traves de DuckDB (via la primitiva read-only del
|
||||
grupo `duckdb`, `duckdb_query_readonly`). Es el CORAZON del grupo de capacidad
|
||||
`eda` (exploratory data analysis): construye el esqueleto de un TableProfile con
|
||||
el perfil base por columna usando exclusivamente `SUMMARIZE`, que hace push-down
|
||||
en el motor de DuckDB y NO trae filas a RAM.
|
||||
|
||||
Lo que NO calcula aqui (a proposito, para ser barata): skew, kurtosis, histograma,
|
||||
percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates,
|
||||
quality_score ni el semantic_type. Esas claves quedan en None / [] para que las
|
||||
rellenen luego otras funciones del grupo `eda` (p.ej. describe_numeric) sobre una
|
||||
muestra. El contrato de claves (TableProfile / ColumnProfile) es compartido por
|
||||
todo el grupo `eda` y debe mantenerse estable.
|
||||
|
||||
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
|
||||
devuelve {status:'error', error:str}.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from infra import duckdb_query_readonly
|
||||
|
||||
# Identificador SQL valido. DuckDB SUMMARIZE no admite parametros posicionales
|
||||
# para el nombre de la tabla, asi que hay que validar e interpolar citado.
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Umbral de filas por debajo del cual calculamos COUNT(DISTINCT) EXACTO en una
|
||||
# sola query combinada (barato). Por encima usamos el approx_unique de SUMMARIZE
|
||||
# (HyperLogLog), capado a n_rows para que distinct_count nunca exceda las filas.
|
||||
_EXACT_DISTINCT_MAX_ROWS = 200_000
|
||||
|
||||
# Tipos fisicos DuckDB que mapean a "numeric".
|
||||
_NUMERIC_TYPES = {
|
||||
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
|
||||
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
|
||||
"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC",
|
||||
}
|
||||
# Tipos fisicos DuckDB que mapean a "datetime".
|
||||
_DATETIME_TYPES = {
|
||||
"DATE", "TIME", "TIMESTAMP", "DATETIME",
|
||||
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US",
|
||||
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ",
|
||||
}
|
||||
|
||||
# Claves del sub-dict numeric. summarize solo rellena unas pocas; el resto
|
||||
# quedan en None hasta que una funcion de muestreo (describe_numeric) las complete.
|
||||
_NUMERIC_SUB_KEYS = (
|
||||
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
|
||||
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
|
||||
"skew", "kurtosis", "n_outliers", "outlier_pct", "zero_pct",
|
||||
"negative_pct", "distribution_type", "histogram",
|
||||
)
|
||||
|
||||
|
||||
def _base_physical_type(column_type: str) -> str:
|
||||
"""Normaliza un column_type fisico de DuckDB a su forma base en mayusculas.
|
||||
|
||||
Quita los parametros (DECIMAL(10,2) -> DECIMAL) y los modificadores de array
|
||||
(INTEGER[] -> INTEGER) para poder compararlo contra los conjuntos de tipos.
|
||||
"""
|
||||
t = (column_type or "").strip().upper()
|
||||
# Quitar sufijo de array/lista (INTEGER[], VARCHAR[3], etc.).
|
||||
t = re.sub(r"\[.*\]$", "", t).strip()
|
||||
# Quitar parametros: DECIMAL(10,2) -> DECIMAL, VARCHAR(50) -> VARCHAR.
|
||||
t = re.sub(r"\(.*\)$", "", t).strip()
|
||||
return t
|
||||
|
||||
|
||||
def _infer_type(column_type: str, distinct_count, n_rows: int) -> str:
|
||||
"""Mapea el tipo fisico DuckDB al inferred_type del contrato.
|
||||
|
||||
numeric / datetime / boolean salen directos del tipo fisico. Para VARCHAR/TEXT
|
||||
se decide entre categorical y text con una heuristica de cardinalidad:
|
||||
categorical si distinct_count <= 50 o distinct_count/n_rows < 0.5; si no text.
|
||||
"""
|
||||
base = _base_physical_type(column_type)
|
||||
if base in _NUMERIC_TYPES:
|
||||
return "numeric"
|
||||
if base in _DATETIME_TYPES:
|
||||
return "datetime"
|
||||
if base in ("BOOLEAN", "BOOL"):
|
||||
return "boolean"
|
||||
if base in ("VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR"):
|
||||
au = distinct_count if distinct_count is not None else 0
|
||||
if n_rows <= 0:
|
||||
return "categorical"
|
||||
if au <= 50 or (au / n_rows) < 0.5:
|
||||
return "categorical"
|
||||
return "text"
|
||||
# Tipos complejos (STRUCT, MAP, LIST, BLOB, UUID, ...): tratamos como text.
|
||||
return "text"
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
"""Convierte a float un valor que SUMMARIZE devuelve como string/Decimal.
|
||||
|
||||
SUMMARIZE entrega min/max/avg/std/q25/q50/q75 como cadenas (o None). Para
|
||||
columnas no numericas (o fechas) la conversion fallara y devolvemos None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def summarize_table_duckdb(
|
||||
db_path: str, table: str, high_card_ratio: float = 0.9
|
||||
) -> dict:
|
||||
"""Perfila una tabla DuckDB en una sola pasada SQL (push-down, sin traer filas).
|
||||
|
||||
Args:
|
||||
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only, no se crea).
|
||||
table: nombre de la tabla a perfilar. Se valida contra
|
||||
^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (SUMMARIZE no admite
|
||||
parametros posicionales para el identificador).
|
||||
high_card_ratio: umbral de unicidad (unique_pct) a partir del cual una
|
||||
columna categorical se marca con el flag "high_cardinality". Default 0.9.
|
||||
|
||||
Returns:
|
||||
dict. En exito: {status:'ok', profile: <TableProfile>}. En error (sin
|
||||
lanzar): {status:'error', error:str}.
|
||||
"""
|
||||
try:
|
||||
if not _IDENT_RE.match(table or ""):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"nombre de tabla invalido: {table!r} "
|
||||
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
||||
),
|
||||
}
|
||||
|
||||
quoted = f'"{table}"'
|
||||
|
||||
# 1) Numero total de filas.
|
||||
count_res = duckdb_query_readonly(db_path, f"SELECT count(*) AS n FROM {quoted}")
|
||||
if count_res["status"] != "ok":
|
||||
return {"status": "error", "error": count_res["error"]}
|
||||
n_rows = int(count_res["rows"][0]["n"]) if count_res["rows"] else 0
|
||||
|
||||
# 2) SUMMARIZE: perfil base por columna en el motor.
|
||||
summ_res = duckdb_query_readonly(db_path, f"SUMMARIZE {quoted}")
|
||||
if summ_res["status"] != "ok":
|
||||
return {"status": "error", "error": summ_res["error"]}
|
||||
|
||||
# 3) distinct_count EXACTO para tablas pequenas/medianas. SUMMARIZE usa
|
||||
# approx_unique (HyperLogLog), que SOBREESTIMA: en tablas pequenas puede
|
||||
# reportar mas distintos que filas, inflando unique_pct por encima de 1.0
|
||||
# y disparando flags possible_id falsos. Para n_rows <= umbral calculamos
|
||||
# COUNT(DISTINCT) EXACTO en UNA sola query combinada (barato). Por encima
|
||||
# del umbral nos quedamos con approx_unique, pero capado a n_rows en
|
||||
# _build_column_profile. Mapea column_name -> distinct exacto.
|
||||
exact_distinct = {}
|
||||
col_names = [r.get("column_name") for r in summ_res["rows"]]
|
||||
if n_rows > 0 and n_rows <= _EXACT_DISTINCT_MAX_ROWS and col_names:
|
||||
select_parts = [
|
||||
f'count(DISTINCT "{name}") AS c{i}'
|
||||
for i, name in enumerate(col_names)
|
||||
]
|
||||
distinct_sql = f"SELECT {', '.join(select_parts)} FROM {quoted}"
|
||||
distinct_res = duckdb_query_readonly(db_path, distinct_sql)
|
||||
if distinct_res["status"] != "ok":
|
||||
return {"status": "error", "error": distinct_res["error"]}
|
||||
if distinct_res["rows"]:
|
||||
drow = distinct_res["rows"][0]
|
||||
for i, name in enumerate(col_names):
|
||||
val = drow.get(f"c{i}")
|
||||
if val is not None:
|
||||
exact_distinct[name] = int(val)
|
||||
|
||||
columns = []
|
||||
for row in summ_res["rows"]:
|
||||
columns.append(
|
||||
_build_column_profile(row, n_rows, high_card_ratio, exact_distinct)
|
||||
)
|
||||
|
||||
type_breakdown = {
|
||||
"numeric": 0,
|
||||
"categorical": 0,
|
||||
"datetime": 0,
|
||||
"text": 0,
|
||||
"boolean": 0,
|
||||
}
|
||||
for col in columns:
|
||||
it = col["inferred_type"]
|
||||
if it in type_breakdown:
|
||||
type_breakdown[it] += 1
|
||||
|
||||
constant_cols = [c["name"] for c in columns if "constant" in c["flags"]]
|
||||
all_null_cols = [c["name"] for c in columns if c["null_pct"] == 1.0]
|
||||
null_cell_pct = (
|
||||
sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0
|
||||
)
|
||||
|
||||
profile = {
|
||||
"table": table,
|
||||
"source": "duckdb",
|
||||
"profiled_at": datetime.now(timezone.utc).isoformat(),
|
||||
"n_rows": n_rows,
|
||||
"n_cols": len(columns),
|
||||
"size_bytes": None,
|
||||
"duplicate_rows": None,
|
||||
"duplicate_pct": None,
|
||||
"constant_cols": constant_cols,
|
||||
"all_null_cols": all_null_cols,
|
||||
"null_cell_pct": null_cell_pct,
|
||||
"type_breakdown": type_breakdown,
|
||||
"columns": columns,
|
||||
"correlations": None,
|
||||
"key_candidates": [],
|
||||
"quality_score": None,
|
||||
"llm": None,
|
||||
"models": None,
|
||||
}
|
||||
return {"status": "ok", "profile": profile}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def _build_column_profile(
|
||||
row: dict, n_rows: int, high_card_ratio: float, exact_distinct: dict = None
|
||||
) -> dict:
|
||||
"""Convierte una fila de SUMMARIZE en un ColumnProfile del contrato eda.
|
||||
|
||||
distinct_count: si la columna tiene un valor en `exact_distinct` (tablas
|
||||
pequenas/medianas perfiladas con COUNT(DISTINCT) exacto), se usa ese valor.
|
||||
Si no (tablas grandes), se usa approx_unique de SUMMARIZE CAPADO a n_rows
|
||||
para que nunca supere el numero de filas. unique_pct queda limitado a 1.0.
|
||||
"""
|
||||
name = row.get("column_name")
|
||||
physical_type = row.get("column_type")
|
||||
approx_unique = row.get("approx_unique")
|
||||
# null_percentage viene en escala 0-100 (Decimal). Lo pasamos a fraccion 0-1.
|
||||
null_pct_raw = row.get("null_percentage")
|
||||
null_pct = float(null_pct_raw) / 100.0 if null_pct_raw is not None else 0.0
|
||||
|
||||
# distinct_count corregido (exacto si disponible; si no approx capado a n_rows).
|
||||
exact_distinct = exact_distinct or {}
|
||||
if name in exact_distinct:
|
||||
distinct_count = exact_distinct[name]
|
||||
else:
|
||||
approx = int(approx_unique) if approx_unique is not None else 0
|
||||
distinct_count = min(approx, n_rows) if n_rows > 0 else approx
|
||||
|
||||
# Inferencia categorical/text con la cardinalidad ya corregida.
|
||||
inferred_type = _infer_type(physical_type, distinct_count, n_rows)
|
||||
|
||||
null_count = round(null_pct * n_rows)
|
||||
non_null_count = n_rows - null_count # SUMMARIZE.count es el total, no el no-nulo.
|
||||
|
||||
unique_pct = min(distinct_count / n_rows, 1.0) if n_rows > 0 else 0.0
|
||||
|
||||
numeric = None
|
||||
if inferred_type == "numeric":
|
||||
numeric = {k: None for k in _NUMERIC_SUB_KEYS}
|
||||
numeric["min"] = _to_float(row.get("min"))
|
||||
numeric["max"] = _to_float(row.get("max"))
|
||||
numeric["mean"] = _to_float(row.get("avg"))
|
||||
numeric["std"] = _to_float(row.get("std"))
|
||||
numeric["p25"] = _to_float(row.get("q25"))
|
||||
numeric["p50"] = _to_float(row.get("q50"))
|
||||
numeric["p75"] = _to_float(row.get("q75"))
|
||||
|
||||
flags = []
|
||||
if distinct_count <= 1:
|
||||
flags.append("constant")
|
||||
if unique_pct >= 0.99 and null_pct == 0:
|
||||
flags.append("possible_id")
|
||||
if inferred_type == "categorical" and unique_pct >= high_card_ratio:
|
||||
flags.append("high_cardinality")
|
||||
if null_pct > 0.5:
|
||||
flags.append("mostly_null")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"physical_type": physical_type,
|
||||
"inferred_type": inferred_type,
|
||||
"semantic_type": "",
|
||||
"count": non_null_count,
|
||||
"n_rows": n_rows,
|
||||
"null_count": null_count,
|
||||
"null_pct": null_pct,
|
||||
"empty_count": None,
|
||||
"empty_pct": None,
|
||||
"distinct_count": distinct_count,
|
||||
"unique_pct": unique_pct,
|
||||
"flags": flags,
|
||||
"quality_score": None,
|
||||
"numeric": numeric,
|
||||
"categorical": None,
|
||||
"datetime": None,
|
||||
}
|
||||
Reference in New Issue
Block a user