feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
@@ -0,0 +1,227 @@
"""profile_database — orquestador one-shot del grupo `eda` a nivel de BASE.
Pipeline impuro: perfila TODA una base DuckDB (todas las tablas o las indicadas)
componiendo el grupo de capacidad `eda` y, encima, infiere las relaciones FK
entre tablas y construye el join graph. Es la composicion canonica para "hazme
un EDA de esta base de datos": una sola llamada en vez de orquestar el perfil de
cada tabla + la inferencia de relaciones a mano.
Funciones del registry compuestas (NO se reimplementa su logica):
- profile_table : perfila UNA tabla end-to-end (a su vez compone el grupo eda).
- infer_fk_containment_duckdb : infiere FK candidatas por containment de valores.
- build_join_graph : grafo de relaciones inter-tabla + diagrama Mermaid.
- duckdb_list_tables : introspeccion "que tablas hay" (read-only).
- render_eda_markdown : report legible de un TableProfile.
Aporta una capa propia de AGREGACION A NIVEL DE BASE: ensambla un DatabaseProfile
con el resumen de cada tabla, los TableProfiles completos, las FK candidatas y el
join graph, y opcionalmente emite un report markdown DB-level (con un diagrama
Mermaid) + un JSON sidecar a disco.
Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
{status:'error', error:str}. Los fallos por tabla individual se toleran: se anota
el error en errors[] y se sigue con las demas tablas.
"""
import json
import os
from datetime import datetime, timezone
from datascience import (
build_join_graph,
infer_fk_containment_duckdb,
render_eda_markdown,
)
from infra import duckdb_list_tables
from pipelines.profile_table import profile_table
def _table_summary(prof: dict) -> dict:
"""Extrae el resumen de cabecera de un TableProfile para la vista DB-level."""
return {
"table": prof.get("table"),
"n_rows": prof.get("n_rows"),
"n_cols": prof.get("n_cols"),
"quality_score": prof.get("quality_score"),
"key_candidates": prof.get("key_candidates", []),
"type_breakdown": prof.get("type_breakdown", {}),
}
def _render_db_markdown(db_profile: dict) -> str:
"""Renderiza el report markdown a nivel de base.
Tabla resumen de tablas, tabla de relaciones inter-tabla (FK candidatas),
diagrama Mermaid del join graph, y un detalle por tabla reusando
render_eda_markdown sobre cada TableProfile completo.
"""
lines = []
lines.append(f"# EDA base — {db_profile.get('db_path')}")
lines.append("")
lines.append(f"- profiled_at: {db_profile.get('profiled_at')}")
lines.append(f"- n_tables: {db_profile.get('n_tables')}")
lines.append("")
# ## Tablas
lines.append("## Tablas")
lines.append("")
lines.append("| Tabla | Filas | Cols | Calidad | key_candidates |")
lines.append("|---|---|---|---|---|")
for t in db_profile.get("tables", []):
keys = ", ".join(t.get("key_candidates") or []) or ""
lines.append(
f"| {t.get('table')} | {t.get('n_rows')} | {t.get('n_cols')} "
f"| {t.get('quality_score')} | {keys} |"
)
lines.append("")
# ## Relaciones inter-tabla
lines.append("## Relaciones inter-tabla")
lines.append("")
fks = db_profile.get("fk_candidates", [])
if fks:
lines.append("| From | To | Inclusion | Cardinalidad |")
lines.append("|---|---|---|---|")
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)
lines.append(f"| {frm} | {to} | {inc_s} | {fk.get('cardinality')} |")
else:
lines.append("_Sin relaciones FK candidatas detectadas._")
lines.append("")
# ## Diagrama
lines.append("## Diagrama")
lines.append("")
mermaid = (db_profile.get("join_graph") or {}).get("mermaid", "")
lines.append("```mermaid")
lines.append(mermaid)
lines.append("```")
lines.append("")
# ## Detalle por tabla
lines.append("## Detalle por tabla")
lines.append("")
for prof in db_profile.get("table_profiles", []):
lines.append(render_eda_markdown(prof))
lines.append("")
return "\n".join(lines)
def profile_database(
db_path: str,
tables: list = None,
sample: int = 5000,
report_dir: str = "reports",
write_report: bool = True,
min_inclusion: float = 0.9,
) -> dict:
"""Perfila una base DuckDB entera + sus relaciones inter-tabla.
Args:
db_path: ruta al archivo DuckDB (read-only, debe existir).
tables: lista de tablas a perfilar. None (default) usa todas las del
esquema main (duckdb_list_tables).
sample: maximo de valores no nulos muestreados por columna en el perfil
de cada tabla (se pasa a profile_table). Default 5000.
report_dir: directorio donde escribir los reports DB-level si
write_report. Default "reports". Se crea si no existe.
write_report: si True (default), escribe un report markdown DB-level + un
JSON sidecar timestamped en report_dir. Si False, no toca disco y los
paths del retorno son None.
min_inclusion: umbral minimo de inclusion (0-1) para emitir una FK
candidata (se pasa a infer_fk_containment_duckdb). Default 0.9.
Returns:
dict dict-no-throw. En exito:
{status:'ok', db_profile:<DatabaseProfile>,
report_md_path:str|None, report_json_path:str|None}.
En error (sin lanzar): {status:'error', error:str}.
DatabaseProfile = {
db_path, profiled_at, n_tables,
tables:[{table, n_rows, n_cols, quality_score, key_candidates,
type_breakdown}, ...],
table_profiles:[<TableProfile completo>, ...],
fk_candidates:[...], join_graph:{nodes, edges, mermaid, hubs},
errors:[...]
}
"""
try:
# 1) Resolver lista de tablas.
if tables is None:
lst = duckdb_list_tables(db_path)
if lst.get("status") != "ok":
return {"status": "error", "error": lst.get("error", "list failed")}
tables = lst.get("tables", [])
if not isinstance(tables, list):
return {"status": "error", "error": "tables debe ser una lista o None"}
errors = []
table_profiles = []
table_summaries = []
# 2) Perfilar cada tabla (tolerando fallos individuales).
for table in tables:
r = profile_table(db_path, table, sample=sample, write_report=False)
if r.get("status") == "ok":
prof = r["profile"]
table_profiles.append(prof)
table_summaries.append(_table_summary(prof))
else:
errors.append(
{"table": table, "error": r.get("error", "profile failed")}
)
# 3) Inferir FK candidatas por containment.
fk = infer_fk_containment_duckdb(
db_path, tables=tables, min_inclusion=min_inclusion
)
if fk.get("status") == "ok":
fk_candidates = fk.get("fk_candidates", [])
else:
fk_candidates = []
errors.append({"step": "infer_fk", "error": fk.get("error", "fk failed")})
# 4) Construir el join graph.
graph = build_join_graph(fk_candidates, tables=tables)
# 5) Ensamblar el DatabaseProfile.
db_profile = {
"db_path": db_path,
"profiled_at": datetime.now(timezone.utc).isoformat(),
"n_tables": len(table_profiles),
"tables": table_summaries,
"table_profiles": table_profiles,
"fk_candidates": fk_candidates,
"join_graph": graph,
"errors": errors,
}
# 6) Reports opcionales.
report_md_path = None
report_json_path = None
if write_report:
os.makedirs(report_dir, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
report_json_path = os.path.join(report_dir, f"eda_db_{ts}.json")
report_md_path = os.path.join(report_dir, f"eda_db_{ts}.md")
with open(report_json_path, "w", encoding="utf-8") as fh:
fh.write(
json.dumps(db_profile, ensure_ascii=False, indent=1, default=str)
)
with open(report_md_path, "w", encoding="utf-8") as fh:
fh.write(_render_db_markdown(db_profile))
return {
"status": "ok",
"db_profile": db_profile,
"report_md_path": report_md_path,
"report_json_path": report_json_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}