---
id: build_column_dictionary_py_datascience
name: build_column_dictionary
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def build_column_dictionary(db_profile: dict) -> dict"
description: "Construye el diccionario de columnas BUSCABLE de una base entera a partir del DatabaseProfile que emite profile_database (grupo eda). Aplana db_profile['table_profiles'] (lista de TableProfile con table y columns) en una entrada por columna con tabla, tipo inferido, tipo semantico, marca de PII (RGPD/LOPDGDD), %null, cardinalidad y valores top. Responde a nivel de base 'donde esta el customer_id / telefono / IBAN'. Emite tambien pii_columns y un markdown grep-able ordenado por columna, precedido de las columnas compartidas por nombre entre tablas (candidatas a join key cross-tabla). Funcion pura, dict-no-throw, no muta el input."
tags: [eda, relations]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
  from datascience import build_column_dictionary
  db_profile = {"table_profiles": [
      {"table": "clientes", "columns": [
          {"name": "email", "inferred_type": "text", "semantic_type": "email",
           "null_pct": 0.05, "distinct_count": 990}]}]}
  res = build_column_dictionary(db_profile)
  # res["pii_columns"] -> [{"table": "clientes", "column": "email", "is_pii": True, ...}]
tested: true
tests:
  - "test_golden_flattens_two_tables"
  - "test_pii_flagged_from_semantic_type"
  - "test_empty_semantic_type_maps_to_none_and_not_pii"
  - "test_shared_column_names_detected_as_join_keys"
  - "test_top_values_from_categorical_block"
  - "test_empty_profile_returns_empty_ok"
  - "test_malformed_input_returns_empty_ok"
  - "test_missing_keys_read_defensively"
  - "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/build_column_dictionary_test.py"
file_path: "python/functions/datascience/build_column_dictionary.py"
params:
  - name: db_profile
    desc: >
      DatabaseProfile del grupo eda tal como lo devuelve profile_database en su
      clave db_profile (el dict con table_profiles). table_profiles es una lista
      de TableProfile; de cada uno se leen table (nombre) y columns (lista de
      ColumnProfile). De cada ColumnProfile se leen defensivamente con .get(...):
      name, inferred_type (numeric|categorical|datetime|text|boolean),
      semantic_type ("" que se normaliza a None; los que emite infer_semantic_type:
      email, iban, credit_card, phone_intl, postal_code_es, ...), null_pct
      (fraccion 0-1), distinct_count (cardinalidad, expuesta como n_distinct) y el
      bloque categorical.top (para top_values). Una entrada vacia, None o
      malformada produce el resultado vacio en estado ok (nunca lanza).
output: >
  dict dict-no-throw con status ("ok" siempre), n_tables (int, tablas con columnas
  procesadas), n_columns (int total de columnas), entries (list[dict] una por
  columna con table, column, inferred_type, semantic_type|None, is_pii (bool),
  null_pct (float 0-1|None), n_distinct (int|None), top_values (list[str]|None)),
  pii_columns (subconjunto de entries con is_pii=True: dato personal segun
  [POL-MMNSEG-001-1.0]) y markdown (str, tabla grep-able ordenada por nombre de
  columna precedida de las columnas compartidas por nombre entre tablas). Entrada
  vacia o malformada -> n_tables/n_columns 0, listas vacias, markdown "".
---

## Ejemplo

```python
from datascience import build_column_dictionary

# db_profile minimo de juguete (forma de la clave db_profile de profile_database).
db_profile = {
    "table_profiles": [
        {
            "table": "clientes",
            "columns": [
                {"name": "customer_id", "inferred_type": "numeric",
                 "semantic_type": "", "null_pct": 0.0, "distinct_count": 1000},
                {"name": "email", "inferred_type": "text",
                 "semantic_type": "email", "null_pct": 0.05, "distinct_count": 990},
                {"name": "ciudad", "inferred_type": "categorical",
                 "semantic_type": "", "null_pct": 0.0, "distinct_count": 3,
                 "categorical": {"top": [
                     {"value": "Madrid", "count": 5, "pct": 0.5},
                     {"value": "Bilbao", "count": 3, "pct": 0.3}]}},
            ],
        },
        {
            "table": "pedidos",
            "columns": [
                {"name": "customer_id", "inferred_type": "numeric",
                 "semantic_type": "", "null_pct": 0.0, "distinct_count": 800},
                {"name": "iban", "inferred_type": "text",
                 "semantic_type": "iban", "null_pct": 0.1, "distinct_count": 795},
            ],
        },
    ]
}

res = build_column_dictionary(db_profile)
print(res["n_tables"], res["n_columns"])          # 2 5
print([(e["table"], e["column"]) for e in res["pii_columns"]])
# [('clientes', 'email'), ('pedidos', 'iban')]
print(res["markdown"])   # tabla grep-able + seccion de join keys (customer_id)
```

Uso real componiendo con `profile_database` (perfila la base y construye el diccionario):

```python
from pipelines.profile_database import profile_database
from datascience import build_column_dictionary

r = profile_database("mi_base.duckdb", write_report=False)
if r["status"] == "ok":
    dicc = build_column_dictionary(r["db_profile"])
    # grep sobre dicc["markdown"] para localizar donde vive cada dato,
    # dicc["pii_columns"] para el inventario RGPD de la base.
```

## Cuando usarla

Usala cuando necesites un indice tabla.columna de una base ENTERA: para localizar
por busqueda "donde esta el customer_id / telefono / IBAN" antes de escribir un
join, para descubrir claves de join cross-tabla (columnas con el mismo nombre en
varias tablas) o para levantar un inventario de columnas con datos personales
(RGPD/LOPDGDD) sobre el que auditar. Es el paso natural despues de
`profile_database`: toma su `db_profile` y lo convierte en diccionario buscable.

## Gotchas

- El criterio de PII se basa SOLO en el `semantic_type` que hoy emite el grupo
  `eda` (`infer_semantic_type`): se marcan email, phone_intl, iban, credit_card y
  postal_code_es. El catalogo de regex NO detecta hoy nombre de persona ni DNI/NIE,
  asi que esas columnas caen como texto/categorico y NO se marcan automaticamente.
  Politica [POL-MMNSEG-001-1.0]: ante cualquier duda sobre si una columna contiene
  datos personales, tratala como PII y avisa antes de exponerla; `pii_columns` es
  una ayuda, no un inventario RGPD exhaustivo.
- `n_distinct` se lee de la clave `distinct_count` del ColumnProfile (no de
  `categorical.n_distinct`); en tablas grandes puede venir de `approx_unique`
  (HyperLogLog) capado a n_rows, no exacto.
- `top_values` solo se rellena si la columna trae bloque `categorical` (lo pone
  `profile_table` para columnas categorical/text); las numericas/datetime lo
  dejan en None.
- Funcion pura: no toca disco ni muta el input. NO perfila la base — eso lo hace
  `profile_database`; aqui solo se APLANA su salida.
