6a1520f458
Pipeline render_automatic_eda_folder: apunta el AutomaticEDA a una CARPETA de archivos tabulares (CSV/Parquet/JSON) o a una DuckDB existente y emite el informe de la BASE por capitulos en PDF (A5 movil) + PPTX (16:9) + Markdown. Documento-base con portada-base, resumen de todas las tablas y relaciones inter-tabla (FK candidatas por containment + diagrama Mermaid del join graph). Flag per_table_eda anexa el mini-EDA de cada tabla. Aditivo: render_automatic_eda (tabla unica) intacto. Funcion nueva load_folder_to_duckdb (infra, grupo eda+duckdb): carga una carpeta a una DuckDB (temp si no se da path), CREATE TABLE por archivo con read_csv_auto/ read_parquet/read_json_auto. dict-no-throw. Compone profile_database + los 3 renderers del motor AutomaticEDA + build_document (per-tabla), sin reimplementar su logica. Tests: golden 3 CSV relacionados (FK orders.customer_id->customers.id detectada) + edges (carpeta vacia, 1 tabla, DuckDB existente, path inexistente). fn index sin error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
15 KiB
Python
351 lines
15 KiB
Python
"""render_automatic_eda_folder — EDA de una CARPETA / base multi-tabla one-shot.
|
|
|
|
Pipeline impuro del grupo de capacidad `eda`, a nivel de BASE. Dada una CARPETA
|
|
de archivos tabulares (CSV/Parquet/JSON) o una DuckDB ya existente, produce el
|
|
informe AutomaticEDA de la BASE en sus tres formatos a la vez (PDF móvil A5 +
|
|
PPTX 16:9 + Markdown autocontenido), con los capítulos POBLADOS, en una sola
|
|
llamada. Es el hermano a nivel de base de ``render_automatic_eda`` (que perfila
|
|
UNA tabla): aquí el documento por capítulos resume TODAS las tablas y, sobre
|
|
todo, sus RELACIONES inter-tabla (FK candidatas + join graph).
|
|
|
|
Compone funciones del registry SIN reimplementar su lógica:
|
|
|
|
- load_folder_to_duckdb : carga una carpeta de archivos a una DuckDB temporal
|
|
(rama "carpeta"). En la rama "ya es duckdb" se omite.
|
|
- profile_database : perfila TODA la base (resumen de cada tabla,
|
|
TableProfiles completos, FK candidatas por
|
|
containment y join graph con diagrama Mermaid).
|
|
- render_automatic_eda_pdf : renderiza el documento-base por capítulos a PDF.
|
|
- render_automatic_eda_pptx : renderiza el mismo documento-base a PPTX.
|
|
- render_automatic_eda_markdown : serializa el mismo documento-base a Markdown
|
|
autocontenido (texto + tablas markdown).
|
|
- build_document : (solo con per_table_eda=True) ensambla los capítulos
|
|
canónicos de CADA tabla para anexarlos al documento.
|
|
|
|
La capa propia de este pipeline es ENSAMBLAR EL DOCUMENTO-BASE de capítulos a
|
|
partir del ``DatabaseProfile`` que devuelve ``profile_database`` y cablear los
|
|
tres renderers del motor AutomaticEDA. El documento-base mínimo tiene tres
|
|
capítulos: portada-base (nombre/nº tablas/totales/fecha/fuente), resumen de
|
|
tablas (una fila por tabla) y relaciones inter-tabla (FK candidatas + diagrama
|
|
Mermaid). Con ``per_table_eda=True`` anexa, por cada tabla, sus capítulos de
|
|
mini-EDA.
|
|
|
|
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
|
|
degrada a ``{"status": "error", "error": str}``.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
from datascience import (
|
|
render_automatic_eda_markdown,
|
|
render_automatic_eda_pdf,
|
|
render_automatic_eda_pptx,
|
|
)
|
|
from datascience.automatic_eda import build_document
|
|
from infra import load_folder_to_duckdb
|
|
from pipelines.profile_database import profile_database
|
|
|
|
# Mapa profile_level -> tamaño de muestra por columna del perfil de cada tabla.
|
|
# A nivel de base el coste lo domina el nº de tablas; el preset solo ajusta el
|
|
# sample que profile_database pasa a profile_table.
|
|
_SAMPLE_BY_LEVEL = {"lite": 2000, "standard": 5000, "full": 5000}
|
|
|
|
# Extensiones que se consideran "una DuckDB ya hecha" en la rama directa.
|
|
_DUCKDB_EXTS = (".duckdb", ".ddb", ".db")
|
|
|
|
|
|
def _fmt_num(v) -> str:
|
|
"""Formatea un entero con separador de millar; '—' si no es número."""
|
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
return "—"
|
|
try:
|
|
return f"{int(v):,}".replace(",", ".")
|
|
except Exception: # noqa: BLE001
|
|
return str(v)
|
|
|
|
|
|
def _portada_chapter(db_profile: dict, source_path: str, db_path: str,
|
|
meta_ctx: dict) -> dict:
|
|
"""Capítulo de portada a nivel de base (NO reusa chapters/portada.py, que es
|
|
de tabla única): nombre de la base, nº de tablas, totales y procedencia."""
|
|
tables = db_profile.get("tables", []) or []
|
|
total_rows = sum(
|
|
(t.get("n_rows") or 0) for t in tables if isinstance(t.get("n_rows"), (int, float))
|
|
)
|
|
total_cols = sum(
|
|
(t.get("n_cols") or 0) for t in tables if isinstance(t.get("n_cols"), (int, float))
|
|
)
|
|
base_name = (meta_ctx or {}).get("dataset_name") or os.path.basename(
|
|
os.path.normpath(source_path)
|
|
) or source_path
|
|
|
|
rows = [
|
|
("Base", base_name),
|
|
("Tablas", _fmt_num(db_profile.get("n_tables"))),
|
|
("Filas totales", _fmt_num(total_rows)),
|
|
("Columnas totales", _fmt_num(total_cols)),
|
|
("Relaciones FK", _fmt_num(len(db_profile.get("fk_candidates", []) or []))),
|
|
("Fuente", source_path),
|
|
("DuckDB", db_path),
|
|
("Generado", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")),
|
|
]
|
|
blocks = [
|
|
{"kind": "heading", "text": f"EDA de la base — {base_name}", "level": 1},
|
|
{"kind": "kv_table", "rows": rows, "title": "Resumen de la base"},
|
|
]
|
|
errs = db_profile.get("errors", []) or []
|
|
if errs:
|
|
blocks.append({
|
|
"kind": "note",
|
|
"text": f"{len(errs)} aviso(s) durante el perfilado (ver detalle).",
|
|
})
|
|
return {"id": "portada_base", "title": "Portada", "version": "1.0.0",
|
|
"blocks": blocks}
|
|
|
|
|
|
def _resumen_chapter(db_profile: dict) -> dict:
|
|
"""Capítulo con una fila por tabla: filas, columnas, calidad, key_candidates."""
|
|
header = ["Tabla", "Filas", "Columnas", "Calidad", "key_candidates"]
|
|
rows = []
|
|
for t in db_profile.get("tables", []) or []:
|
|
keys = ", ".join(t.get("key_candidates") or []) or "—"
|
|
rows.append([
|
|
t.get("table"),
|
|
_fmt_num(t.get("n_rows")),
|
|
_fmt_num(t.get("n_cols")),
|
|
t.get("quality_score"),
|
|
keys,
|
|
])
|
|
if rows:
|
|
blocks = [{
|
|
"kind": "data_table", "header": header, "rows": rows,
|
|
"title": "Tablas de la base",
|
|
"note": "Una fila por tabla. Calidad = score agregado del TableProfile.",
|
|
}]
|
|
else:
|
|
blocks = [{"kind": "note",
|
|
"text": "La base no contiene tablas perfilables."}]
|
|
return {"id": "resumen_tablas", "title": "Resumen de tablas",
|
|
"version": "1.0.0", "blocks": blocks}
|
|
|
|
|
|
def _relaciones_chapter(db_profile: dict) -> dict:
|
|
"""Capítulo de relaciones inter-tabla: tabla de FK candidatas + diagrama
|
|
Mermaid del join graph (vuelca el Mermaid como bloque de código)."""
|
|
fks = db_profile.get("fk_candidates", []) or []
|
|
blocks = [{
|
|
"kind": "heading", "text": "Relaciones inter-tabla", "level": 2,
|
|
}]
|
|
if fks:
|
|
header = ["From", "To", "Inclusión", "Cardinalidad"]
|
|
rows = []
|
|
for fk in fks:
|
|
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
|
|
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
|
|
inc = fk.get("inclusion")
|
|
inc_s = f"{inc:.3f}" if isinstance(inc, (int, float)) else str(inc)
|
|
rows.append([frm, to, inc_s, fk.get("cardinality")])
|
|
blocks.append({
|
|
"kind": "data_table", "header": header, "rows": rows,
|
|
"title": "FK candidatas (por containment de valores)",
|
|
"note": "Inclusión = fracción de valores de From contenidos en To.",
|
|
})
|
|
else:
|
|
blocks.append({
|
|
"kind": "note",
|
|
"text": "Sin relaciones FK candidatas detectadas entre las tablas.",
|
|
})
|
|
|
|
mermaid = (db_profile.get("join_graph") or {}).get("mermaid", "") or ""
|
|
if mermaid.strip():
|
|
blocks.append({"kind": "heading", "text": "Diagrama (join graph)",
|
|
"level": 3})
|
|
# El Mermaid se vuelca como bloque de código: en MD queda como diagrama
|
|
# renderizable; en PDF/PPTX se muestra el texto del grafo (legible).
|
|
blocks.append({"kind": "markdown",
|
|
"text": "```mermaid\n" + mermaid.strip() + "\n```"})
|
|
return {"id": "relaciones", "title": "Relaciones inter-tabla",
|
|
"version": "1.0.0", "blocks": blocks}
|
|
|
|
|
|
def _build_db_document(db_profile: dict, source_path: str, db_path: str,
|
|
meta_ctx: dict, per_table_eda: bool) -> list:
|
|
"""Ensambla el documento-base por capítulos a partir del DatabaseProfile.
|
|
|
|
Mínimo: portada-base + resumen de tablas + relaciones. Con per_table_eda
|
|
True anexa, por cada tabla, un capítulo separador + los capítulos canónicos
|
|
de su mini-EDA (reusando build_document sobre cada TableProfile)."""
|
|
chapters = [
|
|
_portada_chapter(db_profile, source_path, db_path, meta_ctx),
|
|
_resumen_chapter(db_profile),
|
|
_relaciones_chapter(db_profile),
|
|
]
|
|
if per_table_eda:
|
|
for prof in db_profile.get("table_profiles", []) or []:
|
|
tname = prof.get("table") or "tabla"
|
|
chapters.append({
|
|
"id": f"tabla_{tname}", "title": f"Tabla: {tname}",
|
|
"version": "1.0.0",
|
|
"blocks": [{"kind": "heading", "text": f"Tabla: {tname}",
|
|
"level": 1}],
|
|
})
|
|
try:
|
|
# build_document devuelve los capítulos canónicos de la tabla.
|
|
# ctx None -> los capítulos que necesitan datos crudos degradan,
|
|
# pero salen completos los de portada/overview/distrib/calidad.
|
|
chapters.extend(build_document(prof, None) or [])
|
|
except Exception: # noqa: BLE001 — una tabla mala no rompe el doc.
|
|
chapters.append({
|
|
"id": f"tabla_{tname}_err", "title": f"Tabla: {tname}",
|
|
"version": "1.0.0",
|
|
"blocks": [{"kind": "note",
|
|
"text": "No se pudo ensamblar el mini-EDA de "
|
|
"esta tabla."}],
|
|
})
|
|
return chapters
|
|
|
|
|
|
def _resolve_db_path(path: str) -> dict:
|
|
"""Resuelve el DuckDB a perfilar desde ``path``.
|
|
|
|
- Directorio -> carga la carpeta con load_folder_to_duckdb (DuckDB temp).
|
|
- Archivo .duckdb/.ddb/.db -> se usa directo (rama "ya es duckdb").
|
|
- Otro archivo / inexistente -> error.
|
|
|
|
Devuelve {status, db_path, loaded, n_tables, load_errors}.
|
|
"""
|
|
if os.path.isdir(path):
|
|
lr = load_folder_to_duckdb(path)
|
|
if lr.get("status") != "ok":
|
|
return {"status": "error",
|
|
"error": f"load_folder_to_duckdb falló: {lr.get('error')}"}
|
|
return {
|
|
"status": "ok",
|
|
"db_path": lr.get("db_path"),
|
|
"loaded": True,
|
|
"n_tables": len(lr.get("tables", []) or []),
|
|
"load_errors": lr.get("errors", []) or [],
|
|
}
|
|
if os.path.isfile(path):
|
|
if path.lower().endswith(_DUCKDB_EXTS):
|
|
return {"status": "ok", "db_path": path, "loaded": False,
|
|
"n_tables": None, "load_errors": []}
|
|
return {"status": "error",
|
|
"error": f"'{path}' no es un directorio ni una DuckDB "
|
|
f"(extensiones {_DUCKDB_EXTS})."}
|
|
return {"status": "error", "error": f"path no existe: {path}"}
|
|
|
|
|
|
def render_automatic_eda_folder(
|
|
path: str,
|
|
out_dir: str = "reports",
|
|
basename: str = None,
|
|
profile_level: str = "standard",
|
|
emit_pdf: bool = True,
|
|
emit_pptx: bool = True,
|
|
emit_md: bool = True,
|
|
per_table_eda: bool = False,
|
|
min_inclusion: float = 0.9,
|
|
ctx_extra: dict = None,
|
|
) -> dict:
|
|
"""Perfila una CARPETA (o una DuckDB) y emite el informe AutomaticEDA de la base.
|
|
|
|
Args:
|
|
path: o bien un DIRECTORIO con archivos tabulares (CSV/Parquet/JSON) que
|
|
se cargan a una DuckDB temporal, o bien una DuckDB ya existente
|
|
(``.duckdb``/``.ddb``/``.db``) que se perfila directa.
|
|
out_dir: directorio de salida (se crea si no existe). Default "reports".
|
|
basename: nombre base de los archivos sin extensión. Default
|
|
"aeda_base_<nombre>_<timestamp>".
|
|
profile_level: preset de coste del perfil por tabla ("lite"/"standard"/
|
|
"full"); ajusta el ``sample`` que profile_database pasa a cada tabla.
|
|
emit_pdf / emit_pptx / emit_md: qué formatos emitir. Default los tres.
|
|
per_table_eda: si True, anexa al documento-base los capítulos de mini-EDA
|
|
de cada tabla (un Heading "Tabla: <n>" + build_document por tabla).
|
|
Default False (solo el documento-base: portada + resumen + relaciones).
|
|
min_inclusion: umbral de inclusión para emitir una FK candidata (0-1).
|
|
ctx_extra: dict opcional de claves de presentación (p.ej. dataset_name,
|
|
description) que se mezclan en el contexto de la portada.
|
|
|
|
Returns:
|
|
dict (nunca lanza). En éxito::
|
|
|
|
{"status": "ok", "pdf_path": str|None, "pptx_path": str|None,
|
|
"md_path": str|None, "manifest_path": str|None,
|
|
"n_tables": int, "n_pages": int|None, "n_slides": int|None,
|
|
"md_chars": int|None, "db_path": str, "db_profile": <DatabaseProfile>}
|
|
|
|
En error: {"status": "error", "error": str}.
|
|
"""
|
|
try:
|
|
# 1) Resolver la DuckDB a perfilar (cargar carpeta o usar la dada).
|
|
rdb = _resolve_db_path(path)
|
|
if rdb.get("status") != "ok":
|
|
return {"status": "error", "error": rdb.get("error")}
|
|
db_path = rdb.get("db_path")
|
|
|
|
# 2) Perfilar la base entera (resumen + FK + join graph). Sin report
|
|
# propio (write_report/emit_pdf False): este pipeline emite el suyo.
|
|
sample = _SAMPLE_BY_LEVEL.get(profile_level, 5000)
|
|
pres = profile_database(
|
|
db_path, sample=sample, write_report=False,
|
|
min_inclusion=min_inclusion, emit_pdf=False,
|
|
)
|
|
if pres.get("status") != "ok":
|
|
return {"status": "error",
|
|
"error": f"profile_database falló: {pres.get('error')}"}
|
|
db_profile = pres.get("db_profile") or {}
|
|
|
|
# 3) Ensamblar el documento-base por capítulos.
|
|
meta_ctx = dict(ctx_extra or {})
|
|
chapters = _build_db_document(
|
|
db_profile, path, db_path, meta_ctx, per_table_eda
|
|
)
|
|
|
|
# 4) Render a los tres 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")
|
|
nm = (meta_ctx.get("dataset_name")
|
|
or os.path.basename(os.path.normpath(path)) or "base")
|
|
nm = "".join(c if c.isalnum() else "_" for c in str(nm)).strip("_") or "base"
|
|
base = basename or f"aeda_base_{nm}_{ts}"
|
|
title = f"EDA base — {meta_ctx.get('dataset_name') or nm}"
|
|
meta = {"title": title}
|
|
|
|
pdf_path = pptx_path = md_path = manifest_path = None
|
|
n_pages = n_slides = md_chars = None
|
|
|
|
if emit_pdf:
|
|
target = os.path.join(out_dir, base + ".pdf")
|
|
rpdf = render_automatic_eda_pdf(chapters, target, meta) or {}
|
|
pdf_path = rpdf.get("path")
|
|
n_pages = rpdf.get("n_pages")
|
|
manifest_path = rpdf.get("manifest_path")
|
|
if emit_pptx:
|
|
target = os.path.join(out_dir, base + ".pptx")
|
|
rpptx = render_automatic_eda_pptx(chapters, target, meta) or {}
|
|
pptx_path = rpptx.get("path")
|
|
n_slides = rpptx.get("n_slides")
|
|
if emit_md:
|
|
target = os.path.join(out_dir, base + ".md")
|
|
rmd = render_automatic_eda_markdown(chapters, target, meta) or {}
|
|
md_path = rmd.get("path")
|
|
md_chars = rmd.get("n_chars")
|
|
|
|
return {
|
|
"status": "ok",
|
|
"pdf_path": pdf_path,
|
|
"pptx_path": pptx_path,
|
|
"md_path": md_path,
|
|
"manifest_path": manifest_path,
|
|
"n_tables": db_profile.get("n_tables"),
|
|
"n_pages": n_pages,
|
|
"n_slides": n_slides,
|
|
"md_chars": md_chars,
|
|
"db_path": db_path,
|
|
"db_profile": db_profile,
|
|
}
|
|
except Exception as e: # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
|
|
return {"status": "error", "error": str(e)}
|