feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)}
|
||||
Reference in New Issue
Block a user