105e56cf05
Añade el capítulo `text_distr` al motor AutomaticEDA: perfila columnas de texto libre largo (reseñas, descripciones, comentarios) que la distribución categórica no resume bien. Sigue el patrón de cat_distr/num_distr (build_text_distr(profile, ctx) -> Chapter | None) y se registra en CHAPTER_ORDER tras cat_distr. Activación en dos fases: gate barato desde el perfil (columna no numérica con len_mean >= 50 chars) + confirmación con muestra cruda (mediana de palabras >= 20). Un dataset sin texto largo (p.ej. titanic) devuelve None sin tocar el informe. Bloques por columna (Group con page_break): resumen (longitudes, vocabulario con TTR y % hapax, idioma dominante, % duplicados, legibilidad), histograma de longitudes, top términos (tabla + barras), bigramas/trigramas, idiomas detectados y nube de palabras opcional. Términos ttr/hapax enganchados al glosario clicable. Lógica delegada a 7 funciones nuevas del registry (datascience, tag eda), estilo dict-no-throw: - extract_text_sample (impura, push-down SQL DuckDB/Postgres) - compute_text_length_stats, compute_vocabulary_stats, compute_top_ngrams (puras, stdlib) - detect_corpus_language (langdetect opcional), compute_text_readability (textstat opcional), compute_text_duplicates (hash + datasketch opcional) Versión barata sin modelos pesados: las piezas que dependen de una librería opcional (langdetect, textstat, wordcloud, datasketch) degradan a omitidas sin lanzar. Añade langdetect y textstat (ligeras) al pyproject + uv.lock. Verificado: golden sobre dataset de reviews multi-idioma (capítulo presente en PDF+PPTX+MD con métricas reales), titanic sin capítulo (None), degradación sin libs, suite automatic_eda + pipeline verde (128 passed), fn index OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
5.2 KiB
Python
113 lines
5.2 KiB
Python
"""extract_text_sample — muestrea columnas de texto de una tabla sin cargarla en RAM.
|
|
|
|
Funcion impura (lee de la base de datos) del grupo de capacidad `eda`. Dado un
|
|
``db_path`` + ``table`` (DuckDB o PostgreSQL) y una lista de ``columns`` de texto,
|
|
trae una MUESTRA de esas columnas con push-down SQL (``LIMIT sample``), nunca la
|
|
tabla entera. La usan los capitulos de texto/NLP del AutomaticEDA que necesitan
|
|
valores crudos de texto (longitudes, tokens, ejemplos) sin materializar millones
|
|
de filas en memoria.
|
|
|
|
El lector read-only ``query_fn(sql) -> dict`` se construye igual que en
|
|
``build_eda_render_ctx`` / ``profile_table``: un closure sobre el wrapper del
|
|
registry (``duckdb_query_readonly`` / ``pg_query``), importado perezosamente
|
|
dentro de la funcion para no crear ciclos al cargar el ``__init__`` del paquete
|
|
``datascience``. Nunca abre conexiones fuera de esos wrappers.
|
|
|
|
Estilo dict-no-throw del grupo `eda`: la funcion NUNCA lanza. Captura cualquier
|
|
excepcion (query, conversion) y devuelve ``{"status":"error", "error":str(e),
|
|
"columns":{}, "n":0}``. Si la query subyacente devuelve ``status != "ok"``, se
|
|
propaga como error con el mensaje del wrapper.
|
|
|
|
Por columna, la lista de strings solo contiene valores NO nulos y NO vacios:
|
|
cada celda no-None se convierte con ``str(...)`` y se descarta si queda ``""``.
|
|
La clave ``n`` reporta el numero de FILAS leidas por la query (antes de filtrar
|
|
los None/vacios), util para saber cuanto se muestreo realmente.
|
|
"""
|
|
|
|
|
|
def extract_text_sample(db_path, table, columns, backend="duckdb", sample=2000):
|
|
"""Muestrea columnas de texto de una tabla DuckDB/Postgres con push-down SQL.
|
|
|
|
Args:
|
|
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
|
|
Se inyecta en el closure query_fn. No se valida aqui: si la base no
|
|
existe o el DSN es invalido, la query devuelve status error y el
|
|
resultado es {status:'error', ...} (no lanza).
|
|
table: nombre de la tabla. Se escapa con comillas dobles en la query.
|
|
columns: lista de nombres de columna de texto a muestrear. Se filtra a las
|
|
entradas que sean str no vacio; cada nombre se escapa con comillas
|
|
dobles. Si tras filtrar queda vacia -> {status:'ok', columns:{}, n:0}.
|
|
backend: "duckdb" (default) o "postgres". Selecciona el lector read-only
|
|
del registry (duckdb_query_readonly / pg_query). Cualquier otro valor
|
|
-> {status:'error', error:'backend desconocido: ...', columns:{}, n:0}.
|
|
sample: maximo de filas a muestrear (clausula LIMIT). Default 2000. Acota
|
|
memoria y tiempo: con tablas grandes obtienes el primer tramo por
|
|
orden fisico, no un muestreo uniforme.
|
|
|
|
Returns:
|
|
dict (dict-no-throw, NUNCA lanza):
|
|
{"status": "ok"|"error",
|
|
"columns": {col_name: [str, str, ...], ...}, # solo no-None, no-""
|
|
"n": int, # nº de filas leidas por la query (antes de filtrar)
|
|
"error": str} # solo presente si status == "error"
|
|
"""
|
|
try:
|
|
# 1) Lector read-only del backend activo, construido como en
|
|
# build_eda_render_ctx (closure sobre el wrapper del registry). Imports
|
|
# perezosos: este modulo vive en el paquete `datascience`, importar a
|
|
# `infra` a nivel de modulo crearia un ciclo al cargar el __init__.
|
|
if backend == "duckdb":
|
|
from infra import duckdb_query_readonly
|
|
|
|
def query_fn(sql):
|
|
return duckdb_query_readonly(db_path, sql)
|
|
|
|
elif backend == "postgres":
|
|
from infra import pg_query
|
|
|
|
def query_fn(sql):
|
|
return pg_query(db_path, sql)
|
|
|
|
else:
|
|
return {
|
|
"status": "error",
|
|
"error": f"backend desconocido: {backend}",
|
|
"columns": {},
|
|
"n": 0,
|
|
}
|
|
|
|
# 2) Columnas validas (str no vacio). Si no queda ninguna, nada que
|
|
# muestrear: ok con columns vacio.
|
|
cols = []
|
|
if isinstance(columns, (list, tuple)):
|
|
cols = [c for c in columns if isinstance(c, str) and c != ""]
|
|
if not cols:
|
|
return {"status": "ok", "columns": {}, "n": 0}
|
|
|
|
# 3) Push-down: una sola query con LIMIT. Identificadores escapados con
|
|
# comillas dobles, igual que build_eda_render_ctx.
|
|
cols_sql = ", ".join(f'"{c}"' for c in cols)
|
|
sql = f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}'
|
|
q = query_fn(sql)
|
|
if not isinstance(q, dict) or q.get("status") != "ok":
|
|
err = q.get("error") if isinstance(q, dict) else "query sin resultado"
|
|
return {"status": "error", "error": str(err), "columns": {}, "n": 0}
|
|
|
|
rows = q.get("rows") or []
|
|
out = {c: [] for c in cols}
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
for c in cols:
|
|
value = row.get(c)
|
|
if value is None:
|
|
continue
|
|
s = str(value)
|
|
if s == "":
|
|
continue
|
|
out[c].append(s)
|
|
|
|
return {"status": "ok", "columns": out, "n": len(rows)}
|
|
except Exception as exc: # noqa: BLE001 - dict-no-throw del grupo eda
|
|
return {"status": "error", "error": str(exc), "columns": {}, "n": 0}
|