feat(core): contact_import_key — clave de importacion determinista de contactos

Hash estable (tel normalizado > email > nombre normalizado) para importaciones
idempotentes: re-importar el mismo .vcf matchea la fila existente sin depender de
UIDs opacos ni de nombres que el pipeline de import transforma. Prefijo v1- para
versionar el algoritmo. Funcion pura + 5 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:47:48 +02:00
parent b8ec97e477
commit 167a7e5eb7
3 changed files with 191 additions and 0 deletions
@@ -0,0 +1,76 @@
---
name: contact_import_key
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def contact_import_key(name: str = \"\", phones: list = None, emails: list = None) -> str"
description: "Genera una clave de importacion determinista de contactos para imports idempotentes. La identidad prioriza el telefono normalizado (mas estable entre exports de Google), luego el email, y por ultimo el nombre normalizado sin acentos. Re-importar el mismo .vcf matchea la fila existente sin depender de UIDs opacos. Pura, sin I/O."
tags: [contactos, import, hash, dav, dedup, core]
params:
- name: name
desc: "Nombre del contacto (FN). Solo se usa como ultimo recurso cuando no hay telefono ni email. Se normaliza con NFKD (quita acentos), lowercase, espacios colapsados, y se filtra a [a-z0-9 ]."
- name: phones
desc: "Lista de telefonos en cualquier formato (con prefijos, espacios, guiones). Para cada uno se extraen solo los digitos (re.sub r'\\D') y se toman los ultimos 9 si len>=9 (numero nacional estable), o todos los digitos si son menos. Vacios descartados, dedup + sort ascendente. None equivale a lista vacia."
- name: emails
desc: "Lista de emails. Cada uno se pasa a strip+lowercase; se descartan los vacios y los que no contienen '@'. Dedup + sort. None equivale a lista vacia."
output: "Clave determinista con formato 'v1-' + los primeros 16 caracteres hex del SHA-1 de la identity string. Longitud total fija de 19 caracteres. La identity string es 'tel:<phones>' si hay telefonos, 'email:<emails>' si no hay telefonos pero si emails, o 'name:<name_norm>' en otro caso."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_determinismo_misma_entrada_misma_clave", "test_estabilidad_ante_formato_y_nombre_distinto", "test_fallback_a_email_cuando_no_hay_telefono", "test_fallback_a_nombre_insensible_a_acentos_y_mayusculas", "test_prefijo_v1_y_longitud"]
test_file_path: "python/functions/core/contact_import_key_test.py"
file_path: "python/functions/core/contact_import_key.py"
---
## Ejemplo
```python
from core.contact_import_key import contact_import_key
# El telefono manda y es robusto al formato: estos dos producen la MISMA clave.
k1 = contact_import_key("Bob", ["+34 600 11 22 33"])
k2 = contact_import_key("BOB DISTINTO", ["600112233"])
print(k1, k1 == k2)
# v1-8d3f... True (mismo telefono normalizado -> misma clave)
# Sin telefono, cae a email; sin email, cae al nombre normalizado.
print(contact_import_key("Carol", [], ["CAROL@Example.com"])) # v1-... (identity = email:carol@example.com)
print(contact_import_key("José Pérez")) # == contact_import_key("jose perez")
```
## Cuando usarla
Usala en el nucleo de un import idempotente de contactos: antes de insertar una
fila de un `.vcf` de Google, calcula la clave y busca esa clave en la tabla
destino. Si existe, actualizas la fila; si no, la insertas. Asi re-importar el
mismo export no duplica contactos y no depende de UIDs opacos (que Google rota)
ni del nombre (que el pipeline de import transforma: quita sufijos de lugar,
reordena). Es la primitiva de matching del sistema de import idempotente del
grupo `dav`.
## Gotchas
- **Pura y determinista**: misma entrada -> misma salida, sin red ni disco. No
lanza excepciones (entradas `None`/vacias se tratan como lista/cadena vacia).
- **Prioridad telefono > email > nombre por estabilidad**: el telefono
normalizado es lo mas estable entre exports de Google, por eso es la identidad
primaria; el email es fallback; el nombre es el ultimo recurso porque el
pipeline de import lo transforma (quita sufijos de lugar, reordena) y no es
fiable como identidad.
- **Cambiar los telefonos cambia la clave**: si los telefonos de un contacto
varian entre dos importaciones, la clave cambia y se trata como contacto
distinto. Es una limitacion aceptada: la idempotencia es robusta solo para
contactos cuyos telefonos no varian. Lo mismo aplica al fallback de email y al
de nombre.
- **Ultimos 9 digitos**: solo se conservan los ultimos 9 digitos del telefono
para ignorar prefijos de pais inconsistentes (`+34`, `0034`, sin prefijo
producen la misma clave). Numeros con menos de 9 digitos se usan completos.
- **Prefijo `v1-`**: versiona el algoritmo. Si en el futuro cambia la
normalizacion o el esquema de identidad, se sube a `v2-` y las claves nuevas no
colisionan con las viejas; el consumidor puede migrar de forma controlada.
@@ -0,0 +1,77 @@
"""Clave de importacion determinista de contactos para imports idempotentes.
Genera una clave estable a partir de los datos de un contacto (telefono, email,
nombre) para que re-importar el mismo .vcf de Google matchee la fila existente
sin depender de UIDs opacos ni de nombres que el pipeline de import transforma.
"""
import hashlib
import re
import unicodedata
def contact_import_key(
name: str = "",
phones: list = None,
emails: list = None,
) -> str:
"""Calcula una clave de importacion determinista para un contacto.
La identidad se construye priorizando lo mas estable entre exports de
Google: telefono normalizado > email normalizado > nombre normalizado.
La funcion es pura: dada la misma entrada devuelve siempre la misma clave,
sin I/O ni estado mutable.
Args:
name: nombre del contacto (FN). Solo se usa como ultimo recurso.
phones: lista de telefonos en cualquier formato. Cada uno se reduce a
sus digitos y se queda con los ultimos 9 (numero nacional estable).
emails: lista de emails. Se pasan a minusculas y se filtran los que no
contienen "@".
Returns:
Clave determinista con el formato "v1-" + 16 hex chars del SHA-1 de la
identity string. Longitud total fija de 19 caracteres.
"""
phones = phones or []
emails = emails or []
# 1. Normalizar phones: solo digitos, ultimos 9 si len>=9, dedup + sort.
phones_norm = set()
for p in phones:
digits = re.sub(r"\D", "", str(p))
if not digits:
continue
if len(digits) >= 9:
digits = digits[-9:]
phones_norm.add(digits)
phones_sorted = sorted(phones_norm)
# 2. Normalizar emails: lowercase + strip, descartar vacios y sin "@".
emails_norm = set()
for e in emails:
e = str(e).strip().lower()
if not e or "@" not in e:
continue
emails_norm.add(e)
emails_sorted = sorted(emails_norm)
# 3. Normalizar name: NFKD sin acentos, lowercase, colapsar espacios,
# quedarse solo con [a-z0-9 ].
decomposed = unicodedata.normalize("NFKD", name or "")
without_marks = "".join(c for c in decomposed if not unicodedata.combining(c))
lowered = without_marks.lower()
collapsed = re.sub(r"\s+", " ", lowered).strip()
name_norm = re.sub(r"[^a-z0-9 ]", "", collapsed)
# 4. Identity string priorizando lo mas estable.
if phones_sorted:
identity = "tel:" + ",".join(phones_sorted)
elif emails_sorted:
identity = "email:" + ",".join(emails_sorted)
else:
identity = "name:" + name_norm
# 5. Clave versionada.
digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:16]
return "v1-" + digest
@@ -0,0 +1,38 @@
"""Tests para contact_import_key."""
from contact_import_key import contact_import_key
def test_determinismo_misma_entrada_misma_clave():
a = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"])
b = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"])
assert a == b
def test_estabilidad_ante_formato_y_nombre_distinto():
# Mismo telefono normalizado (ultimos 9 digitos), distinto formato y nombre.
# El telefono manda -> misma clave.
con_formato = contact_import_key("Bob", ["+34 600 11 22 33"])
sin_formato = contact_import_key("BOB DISTINTO", ["600112233"])
assert con_formato == sin_formato
def test_fallback_a_email_cuando_no_hay_telefono():
solo_email = contact_import_key("Carol", [], ["CAROL@Example.com"])
mismo_email_otro_nombre = contact_import_key("nombre cambiado", [], ["carol@example.com"])
assert solo_email == mismo_email_otro_nombre
# Y distinto de la clave por nombre puro (prefijo de identity distinto).
por_nombre = contact_import_key("Carol")
assert solo_email != por_nombre
def test_fallback_a_nombre_insensible_a_acentos_y_mayusculas():
con_acentos = contact_import_key("José Pérez")
sin_acentos = contact_import_key("jose perez")
assert con_acentos == sin_acentos
def test_prefijo_v1_y_longitud():
k = contact_import_key("Dave", ["600999888"])
assert k.startswith("v1-")
assert len(k) == 19 # 3 ("v1-") + 16 hex chars