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