Files
fn_registry/python/functions/datascience/extract_text_sample.py
T
egutierrez 105e56cf05 feat(eda): capítulo text_distr (TEXTO/NLP) — primer capítulo de datos no tabulares
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>
2026-06-30 20:38:17 +02:00

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}