Primer commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
contact_classifier.py
|
||||
|
||||
Modulo compartido que detecta contactos de test/prueba/E3 a partir de
|
||||
first_name, last_name, email y tags. Es la fuente de verdad de los
|
||||
patrones; tanto el script `scripts/find_test_contacts.py` como el endpoint
|
||||
`/api/contacts/{location_id}` lo usan para mantener consistencia entre el
|
||||
reporte CLI y la marca visual del dashboard.
|
||||
|
||||
Funciones publicas:
|
||||
- classify_contact(first_name, last_name, email, tags_list) -> (reasons, classes)
|
||||
- is_test_contact(contact_dict) -> bool
|
||||
- annotate_contact(contact_dict) -> dict (agrega is_test y test_reasons)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
# Patrones (mismos del script find_test_contacts.py)
|
||||
SPANISH_PATTERNS = [
|
||||
r'prueba', r'pruebas', r'probando', r'testeo', r'ejemplo', r'ejemplos',
|
||||
r'demostracion', r'borrador', r'probar', r'pruebita'
|
||||
]
|
||||
|
||||
ENGLISH_PATTERNS = [
|
||||
r'test', r'testing', r'tester', r'tests', r'dummy', r'fake', r'mock',
|
||||
r'example', r'examples', r'demo', r'trial', r'sandbox'
|
||||
]
|
||||
|
||||
E3_PATTERNS = [
|
||||
r'e3', r'e-3'
|
||||
]
|
||||
|
||||
GENERIC_PATTERNS = [
|
||||
r'temporal', r'temp', r'generico', r'sdasd', r'asdasd', r'qwerty', r'12345',
|
||||
r'testea', r'ficticio'
|
||||
]
|
||||
|
||||
# Correos conocidos del equipo E3 que siempre se consideran test, aunque el
|
||||
# nombre/etiquetas no disparen ningun patron.
|
||||
TEST_EMAILS = {
|
||||
"servandobra@gmail.com",
|
||||
"uriel.conse3@gmail.com",
|
||||
}
|
||||
|
||||
|
||||
def normalize_text(text):
|
||||
if not text:
|
||||
return ""
|
||||
nfkd = unicodedata.normalize("NFD", str(text))
|
||||
clean = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
|
||||
return " ".join(clean.lower().split())
|
||||
|
||||
|
||||
def _word_match(pattern, text):
|
||||
"""
|
||||
True si `pattern` aparece como palabra completa en `text` (limites \\b).
|
||||
|
||||
Evita falsos positivos por subcadena: p.ej. 'temp' NO debe matchear dentro
|
||||
de 'tlatempa', ni 'test' dentro de 'protesta'. Una palabra solo cuenta si
|
||||
esta delimitada por inicio/fin de cadena, espacios o puntuacion (incluido
|
||||
el '-' de tags como 'qa-test' o el '@' de un email 'test@...').
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
return re.search(rf'\b{re.escape(pattern)}\b', text, re.IGNORECASE) is not None
|
||||
|
||||
|
||||
def classify_contact(first_name, last_name, email, tags_list):
|
||||
"""
|
||||
Devuelve (reasons, classifications). Si reasons es vacio, el contacto NO es de prueba.
|
||||
"""
|
||||
first_name_lower = (first_name or "").lower()
|
||||
last_name_lower = (last_name or "").lower()
|
||||
email_lower = (email or "").lower()
|
||||
tags_lower = [t.lower() for t in tags_list] if tags_list else []
|
||||
|
||||
reasons = []
|
||||
classifications = set()
|
||||
|
||||
# 0. Correos conocidos del equipo E3 (match exacto).
|
||||
if email_lower in TEST_EMAILS:
|
||||
reasons.append(f"Email en lista E3 ({email_lower})")
|
||||
classifications.add("E3")
|
||||
|
||||
# 1. E3 (regex con limites para evitar falsos positivos)
|
||||
for p in E3_PATTERNS:
|
||||
pattern_regex = re.compile(rf'\b{p}\b|{p}[-_]|[-_]{p}', re.IGNORECASE)
|
||||
if pattern_regex.search(first_name_lower) or pattern_regex.search(last_name_lower):
|
||||
reasons.append(f"Nombre contiene '{p.upper()}'")
|
||||
classifications.add("E3")
|
||||
elif p in first_name_lower or p in last_name_lower:
|
||||
reasons.append(f"Nombre contiene subcadena '{p.upper()}'")
|
||||
classifications.add("E3")
|
||||
|
||||
email_pattern = re.compile(rf'\b{p}\b|^{p}|{p}@|{p}[-_]|[-_]{p}', re.IGNORECASE)
|
||||
if email_pattern.search(email_lower):
|
||||
reasons.append(f"Email contiene '{p.upper()}'")
|
||||
classifications.add("E3")
|
||||
|
||||
if any(p in t for t in tags_lower):
|
||||
reasons.append(f"Etiqueta contiene '{p.upper()}'")
|
||||
classifications.add("E3")
|
||||
|
||||
# 2. Espanol (palabra completa para evitar falsos positivos por subcadena)
|
||||
for p in SPANISH_PATTERNS:
|
||||
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
|
||||
reasons.append(f"Nombre contiene '{p}'")
|
||||
classifications.add("Test/Prueba Espanol")
|
||||
if _word_match(p, email_lower):
|
||||
reasons.append(f"Email contiene '{p}'")
|
||||
classifications.add("Test/Prueba Espanol")
|
||||
if any(_word_match(p, t) for t in tags_lower):
|
||||
reasons.append(f"Etiqueta contiene '{p}'")
|
||||
classifications.add("Test/Prueba Espanol")
|
||||
|
||||
# 3. Ingles (palabra completa para evitar falsos positivos por subcadena)
|
||||
for p in ENGLISH_PATTERNS:
|
||||
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
|
||||
reasons.append(f"Nombre contiene '{p}'")
|
||||
classifications.add("Test/Prueba Ingles")
|
||||
if _word_match(p, email_lower):
|
||||
reasons.append(f"Email contiene '{p}'")
|
||||
classifications.add("Test/Prueba Ingles")
|
||||
if any(_word_match(p, t) for t in tags_lower):
|
||||
reasons.append(f"Etiqueta contiene '{p}'")
|
||||
classifications.add("Test/Prueba Ingles")
|
||||
|
||||
# 4. Generico (palabra completa: 'temp' no debe matchear dentro de 'tlatempa')
|
||||
for p in GENERIC_PATTERNS:
|
||||
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
|
||||
reasons.append(f"Nombre contiene temporal '{p}'")
|
||||
classifications.add("Temporal/Generico")
|
||||
if _word_match(p, email_lower):
|
||||
reasons.append(f"Email contiene temporal '{p}'")
|
||||
classifications.add("Temporal/Generico")
|
||||
if any(_word_match(p, t) for t in tags_lower):
|
||||
reasons.append(f"Etiqueta contiene temporal '{p}'")
|
||||
classifications.add("Temporal/Generico")
|
||||
|
||||
# 5. Nombre o apellido puramente numerico
|
||||
if (first_name_lower and first_name_lower.isdigit()) or (last_name_lower and last_name_lower.isdigit()):
|
||||
reasons.append("Nombre o Apellido es numerico")
|
||||
classifications.add("Temporal/Generico")
|
||||
|
||||
return sorted(list(set(reasons))), sorted(list(classifications))
|
||||
|
||||
|
||||
def _coerce_tags(raw):
|
||||
"""Acepta tags como lista, JSON string o None y devuelve lista de strings."""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(t) for t in raw if t is not None]
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [str(t) for t in parsed if t is not None]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def annotate_contact(contact):
|
||||
"""
|
||||
Recibe un dict de contacto (como lo devuelve db.get_contacts) y le agrega
|
||||
los campos `is_test` (bool) y `test_reasons` (lista de strings).
|
||||
Devuelve el mismo dict mutado.
|
||||
"""
|
||||
tags = _coerce_tags(contact.get("tags"))
|
||||
reasons, _classes = classify_contact(
|
||||
contact.get("first_name"),
|
||||
contact.get("last_name"),
|
||||
contact.get("email"),
|
||||
tags,
|
||||
)
|
||||
contact["is_test"] = bool(reasons)
|
||||
contact["test_reasons"] = reasons
|
||||
return contact
|
||||
|
||||
|
||||
def is_test_contact(contact):
|
||||
"""Atajo: True/False sin mutar el dict."""
|
||||
tags = _coerce_tags(contact.get("tags"))
|
||||
reasons, _ = classify_contact(
|
||||
contact.get("first_name"),
|
||||
contact.get("last_name"),
|
||||
contact.get("email"),
|
||||
tags,
|
||||
)
|
||||
return bool(reasons)
|
||||
Reference in New Issue
Block a user