"""build_column_dictionary — diccionario de columnas BUSCABLE de una base entera. Funcion pura, stdlib-only. No hace I/O, no depende de nada externo y NO muta el input. Toma el ``db_profile`` (DatabaseProfile) que emite ``profile_database`` del grupo de capacidad ``eda`` y aplana su ``table_profiles`` (lista de TableProfile, cada uno con ``table`` y ``columns``: lista de ColumnProfile) en una entrada por columna. Es la pieza que responde, a nivel de BASE, "donde esta el customer_id / telefono / IBAN en este dataset?": un indice grep-able tabla.columna con su tipo, tipo semantico inferido, marca de PII, % de nulos, cardinalidad y valores top. Ademas del listado plano emite: - ``pii_columns``: subconjunto marcado como dato personal (RGPD/LOPDGDD). - ``markdown``: tabla grep-able ordenada por nombre de columna, precedida de una seccion que agrupa columnas con el MISMO nombre presentes en varias tablas (candidatas a clave de join cross-tabla). Estilo dict-no-throw del grupo ``eda``: nunca lanza. Lee cada clave de forma defensiva con ``.get(...)`` y tolera valores None / estructuras malformadas; ante una entrada vacia o corrupta devuelve el resultado vacio en estado ``ok``. Criterio de PII (politica [POL-MMNSEG-001-1.0]): se marca ``is_pii=True`` cuando el ``semantic_type`` real que emite el grupo ``eda`` (ver ``infer_semantic_type``) pertenece al conjunto de tipos de dato personal detectables hoy: email, telefono internacional, IBAN, tarjeta de credito y codigo postal (componente de direccion). El catalogo de regex del grupo NO detecta hoy nombre de persona ni DNI/NIE, asi que esas columnas caen como texto/categorico y no se marcan automaticamente: ante cualquier duda sobre si una columna contiene datos personales, tratala como PII y avisa antes de exponerla. """ # semantic_types del grupo eda (infer_semantic_type) que son dato personal. # El grupo emite hoy: email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl, # postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean, # hex_color. De esos, los que identifican a una persona fisica (RGPD/LOPDGDD) son: _PII_SEMANTIC_TYPES = frozenset( { "email", "phone_intl", "iban", "credit_card", "postal_code_es", # codigo postal: componente de direccion (dato de localizacion) } ) # Numero maximo de valores frecuentes que se listan por columna categorica. _TOP_VALUES_LIMIT = 5 def _empty_result() -> dict: """Resultado vacio en estado ok para entradas vacias o malformadas.""" return { "status": "ok", "n_tables": 0, "n_columns": 0, "entries": [], "pii_columns": [], "markdown": "", } def _top_values(col: dict) -> list | None: """Extrae hasta _TOP_VALUES_LIMIT valores frecuentes del bloque categorical. ``summarize_categorical`` deja ``col["categorical"]["top"]`` como lista de ``{value, count, pct}`` ordenada por frecuencia. Devuelve solo los valores como strings, o None si la columna no tiene bloque categorical util. """ cat = col.get("categorical") if not isinstance(cat, dict): return None top = cat.get("top") if not isinstance(top, list) or not top: return None values = [] for item in top[:_TOP_VALUES_LIMIT]: if isinstance(item, dict): values.append(str(item.get("value"))) else: values.append(str(item)) return values or None def _column_entry(table_name, col: dict) -> dict: """Construye la entrada del diccionario para un ColumnProfile. Lee las claves del contrato eda de forma defensiva: name, inferred_type, semantic_type ("" se normaliza a None), null_pct (fraccion 0-1), distinct_count (se expone como n_distinct) y el bloque categorical (top). """ sem_raw = col.get("semantic_type") semantic_type = sem_raw if sem_raw else None # "" -> None null_pct = col.get("null_pct") if isinstance(null_pct, bool) or not isinstance(null_pct, (int, float)): null_pct = None else: null_pct = float(null_pct) n_distinct = col.get("distinct_count") if isinstance(n_distinct, bool) or not isinstance(n_distinct, int): n_distinct = None return { "table": table_name, "column": col.get("name"), "inferred_type": col.get("inferred_type"), "semantic_type": semantic_type, "is_pii": semantic_type in _PII_SEMANTIC_TYPES, "null_pct": null_pct, "n_distinct": n_distinct, "top_values": _top_values(col), } def _render_markdown(entries: list) -> str: """Renderiza el diccionario en markdown grep-able. Primero una seccion que agrupa columnas con el MISMO nombre presentes en varias tablas (candidatas a clave de join cross-tabla), luego la tabla completa ordenada por nombre de columna. """ lines = ["# Diccionario de columnas", ""] # Seccion: columnas compartidas por nombre (candidatas a join key). by_name: dict = {} for e in entries: by_name.setdefault(e["column"], set()).add(e["table"]) shared = { name: tables for name, tables in by_name.items() if name is not None and len(tables) > 1 } lines.append("## Columnas presentes en varias tablas (candidatas a join key)") lines.append("") if shared: lines.append("| Columna | Tablas |") lines.append("|---|---|") for name in sorted(shared, key=lambda s: str(s).lower()): tbls = ", ".join(sorted((str(t) for t in shared[name]), key=str.lower)) lines.append(f"| {name} | {tbls} |") else: lines.append( "_Ninguna columna aparece con el mismo nombre en mas de una tabla._" ) lines.append("") # Tabla completa ordenada por nombre de columna (y tabla como desempate). lines.append("## Columnas") lines.append("") lines.append( "| Columna | Tabla | Tipo | Tipo semantico | PII | %null | Distinct |" ) lines.append("|---|---|---|---|---|---|---|") for e in sorted( entries, key=lambda e: (str(e["column"]).lower(), str(e["table"]).lower()) ): sem = e["semantic_type"] or "—" pii = "SI" if e["is_pii"] else "" null_s = ( f"{e['null_pct'] * 100:.1f}%" if isinstance(e["null_pct"], (int, float)) else "" ) distinct_s = str(e["n_distinct"]) if e["n_distinct"] is not None else "" itype = e["inferred_type"] or "" lines.append( f"| {e['column']} | {e['table']} | {itype} | {sem} | {pii} " f"| {null_s} | {distinct_s} |" ) lines.append("") return "\n".join(lines) def build_column_dictionary(db_profile: dict) -> dict: """Construye el diccionario de columnas buscable de una base entera. Recorre ``db_profile["table_profiles"]`` (lista de TableProfile del grupo eda, cada uno con ``table`` y ``columns``) y emite una entrada por columna con su tipo fisico inferido, tipo semantico, marca de PII, % de nulos, cardinalidad y valores frecuentes. Responde, a nivel de base, donde vive cada dato. Args: db_profile: DatabaseProfile tal como lo devuelve ``profile_database`` en su clave ``db_profile`` (el dict con ``table_profiles``). Se lee de forma defensiva; una entrada vacia, None o malformada produce el resultado vacio en estado ``ok``. Returns: Dict dict-no-throw (nunca lanza) con las claves: - ``status`` (str): siempre ``"ok"``. - ``n_tables`` (int): tablas con columnas procesadas. - ``n_columns`` (int): total de columnas indexadas. - ``entries`` (list[dict]): una entrada por columna con ``{table, column, inferred_type, semantic_type|None, is_pii, null_pct|None, n_distinct|None, top_values|None}``. - ``pii_columns`` (list[dict]): subconjunto de ``entries`` con ``is_pii=True`` (dato personal segun [POL-MMNSEG-001-1.0]). - ``markdown`` (str): tabla grep-able ordenada por nombre de columna, precedida de las columnas compartidas por nombre entre tablas. """ try: if not isinstance(db_profile, dict): return _empty_result() table_profiles = db_profile.get("table_profiles") if not isinstance(table_profiles, list) or not table_profiles: return _empty_result() entries: list = [] n_tables = 0 for tp in table_profiles: if not isinstance(tp, dict): continue columns = tp.get("columns") if not isinstance(columns, list): continue n_tables += 1 table_name = tp.get("table") for col in columns: if not isinstance(col, dict): continue entries.append(_column_entry(table_name, col)) if not entries: return { "status": "ok", "n_tables": n_tables, "n_columns": 0, "entries": [], "pii_columns": [], "markdown": "", } pii_columns = [e for e in entries if e["is_pii"]] return { "status": "ok", "n_tables": n_tables, "n_columns": len(entries), "entries": entries, "pii_columns": pii_columns, "markdown": _render_markdown(entries), } except Exception: # noqa: BLE001 return _empty_result()