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:
@@ -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
|
||||
Reference in New Issue
Block a user