"""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 ( draw_join_graph_figure, 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.", }) join_graph = db_profile.get("join_graph") or {} has_edges = bool(join_graph.get("edges")) if has_edges: blocks.append({"kind": "heading", "text": "Diagrama (join graph)", "level": 3}) # Figure matplotlib REAL del grafo de relaciones (nodos = tablas, # aristas = FK). Lazy via `make`: el renderer la construye solo al # paginar, y se rasteriza en PDF/PPTX. draw_join_graph_figure nunca # lanza (devuelve una Figure de error si algo falla). blocks.append({ "kind": "figure", "make": (lambda jg=join_graph: draw_join_graph_figure( jg, title="Join graph (relaciones inter-tabla)")), "caption": "Grafo de relaciones: nodos = tablas, flechas = FK " "candidatas (etiqueta from_col→to_col).", "height_in": 4.5, }) # Además, el Mermaid en texto: en el Markdown queda como diagrama # renderizable y es útil para pegar a un LLM. mermaid = (join_graph.get("mermaid", "") or "").strip() if mermaid: blocks.append({"kind": "markdown", "text": "```mermaid\n" + mermaid + "\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__". 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: " + 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": } 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)}