Skip to content

feat(risk): adaptador SanctionsLists con matching difuso (E6-T07) - #15

Merged
JheampierreRaPe merged 1 commit into
mainfrom
feature/compliance-sanctions-adapter
Nov 12, 2026
Merged

JheampierreRaPe merged 1 commit into
mainfrom
feature/compliance-sanctions-adapter

Conversation

@JheampierreRaPe

Copy link
Copy Markdown
Owner

Resumen para tontos

El banco ahora puede comparar el nombre de una persona o empresa contra listas de sanciones y de personas políticamente expuestas (PEP). Detecta la coincidencia aunque el nombre esté escrito con acentos, en otro orden o con una pequeña falta de ortografía. Por ahora usa listas de prueba con nombres inventados, así que no hay riesgo de señalar a nadie real. No bloquea ninguna operación: solo informa las coincidencias para que el área de cumplimiento las revise.

Cómo probarlo

  1. Entra al backend:

    cd backend
  2. Corre las pruebas de esta tarea:

    .venv\Scripts\python.exe -m pytest tests/test_e6_t07_sanctions_lists.py -q

    Resultado esperado: 53 passed.

  3. Verifica estilo y formato:

    ruff check .
    black --check .

    Ambos deben salir limpios (All checks passed! y 488 files would be left unchanged).

  4. Prueba el adaptador a mano (coincidencia exacta y difusa):

    .venv\Scripts\python.exe -c "from app.adapters.sanctions_lists import create_sanctions_lists; l=create_sanctions_lists('mock'); print(l.screen(subject_type='person', name='Quispe Aurelio Mendoza')); print(l.screen(subject_type='person', name='Aurelio Mendoza Quispi'))"
    • Nombre en otro orden (Quispe Aurelio Mendoza) → OFAC con matched=True y match_score=1.0.
    • Nombre con una letra cambiada (Quispi en vez de Quispe) → OFAC con matched=True y match_score=0.9818 (por encima del umbral 0.85).
    • Las otras listas salen con matched=False y su score parcial, para que se vea el mejor candidato por lista.
  5. Nota: todavía no hay endpoint HTTP. Esto es un adaptador interno; se conectará al cotejo de onboarding y transacciones en E6-T09.

Resumen técnico

  • Interfaz SanctionsLists (backend/app/adapters/sanctions_lists.py): dos métodos, screen(subject_type, name, document_number?, country?) y list_versions(). screen devuelve el mejor candidato por lista (siempre un ScreeningHit por lista, con matched=false cuando no hay coincidencia), y list_versions expone la versión vigente de cada lista.
  • Dominio puro de similitud (backend/app/modules/risk/domain/screening.py): normalización de texto (mayúsculas, sin acentos, sin puntuación, espacios colapsados) + Jaro-Winkler implementado en stdlib, sin dependencias nuevas. La comparación es tolerante al orden de los tokens ("QUISPE AURELIO" vs "AURELIO QUISPE" → 1.0). Funciones: normalize_text, jaro_winkler, name_similarity, is_match.
  • MockSanctionsLists: determinista, sin red. Carga 3 listas ficticias versionadas desde JSON (backend/app/adapters/data/sanctions/ofac.json, worldcheck.json, pep.json). Soporta modos de falla simulada (timeout, down) para probar el manejo de errores.
  • HttpSanctionsLists: contrato POST {base_url}/screen y GET {base_url}/lists, con timeout, reintentos con backoff exponencial y circuito (se abre tras N fallas consecutivas y se cierra solo tras un cooldown). La API key viaja únicamente en la cabecera Authorization: Bearer ...; nunca en URL, cuerpo ni logs. Los errores se traducen a códigos tipados: SANCTIONS_TIMEOUT (502), SANCTIONS_UNAVAILABLE (503), VALIDATION_ERROR (422).
  • Fábrica create_sanctions_lists() exportada desde app.adapters: elige implementación por SANCTIONS_PROVIDER (mock por defecto; valor desconocido cae a mock).
  • Privacidad en logs: solo se registra un hash corto del sujeto (subject_hash), el código de lista, el score y el código de error. Nunca el nombre, el documento ni la API key.

Alcance

Incluido:

  • backend/app/adapters/sanctions_lists.py (nuevo): interfaz, payloads tipados, config por entorno, mock, http y fábrica.
  • backend/app/adapters/data/sanctions/*.json (nuevos): 3 semillas ficticias versionadas (OFAC, WORLDCHECK, PEP).
  • backend/app/modules/risk/domain/screening.py (nuevo): normalización y Jaro-Winkler puros.
  • backend/app/adapters/__init__.py: exporta create_sanctions_lists.
  • backend/tests/test_e6_t07_sanctions_lists.py (nuevo): 53 pruebas.
  • backend/.env.example: variables SANCTIONS_* documentadas (sin secretos reales).
  • docs/02-arquitectura.md, docs/modules/README.md, docs/tasks/E6-T07.md: documentación y reporte de la tarea.

No incluido: endpoints, casos de uso, tablas, migraciones, listas reales.

Criterios de aceptación

  • CA-01 — Coincidencia exacta y difusa: un nombre idéntico (normalizado) da match_score=1.0; un nombre con error de tipeo o tokens en otro orden da un score alto y matched=true cuando supera el umbral, con matched_fields indicando qué campos coincidieron (name, document_number, country) y sin exponer valores crudos.
  • CA-02 — Sin coincidencia: un nombre distinto devuelve todos los hits con matched=false y matched_fields=(), sin falsos positivos.
  • CA-03 — Resiliencia del proveedor http: timeout, 503 y caída sostenida se traducen a SANCTIONS_TIMEOUT / SANCTIONS_UNAVAILABLE; el circuito se abre y se recupera tras el cooldown. La API key no aparece en URL, cuerpo ni logs.
  • CA-04 — Determinismo y privacidad: la misma entrada produce el mismo match_score; los logs solo contienen hash del sujeto y metadatos, nunca PII.
  • CA-05 — Calidad: ruff check y black --check limpios; suite completa en verde.

Pruebas

  • Nuevas: backend/tests/test_e6_t07_sanctions_lists.py → 53 passed.
  • Suite completa del backend: 2217 passed, 1 skipped (~110 s).
  • ruff check . → All checks passed!
  • black --check . → 488 files would be left unchanged
  • Sin migraciones: esta tarea no toca base de datos.

Supuestos

  • Jaro-Winkler en stdlib: se implementa a mano (Jaro + bono por prefijo común, tope 4 caracteres) para no agregar dependencias ni servicios externos. Es determinista y suficiente para el volumen esperado.
  • Umbral 0.85: SANCTIONS_SCREENING_THRESHOLD con default 0.85, espejo de risk.screening_threshold. El valor límite cuenta como coincidencia (score >= threshold).
  • El país solo refuerza: country puede sumar a matched_fields, pero por sí solo nunca crea un hit; la coincidencia se decide por nombre/documento.
  • Mejor candidato por lista: screen devuelve un hit por lista (el de mayor score), no todos los candidatos. Así el consumidor decide sin recorrer listas completas.
  • Semillas ficticias: los nombres de las listas son inventados (prefijo FIC- en los documentos) y están marcados como ficticios en el propio JSON. No hay PII real.
  • http recalibra matched: si el proveedor responde matched, el adaptador lo recalcula localmente con el umbral propio para no depender de la configuración del proveedor.

Fuera de alcance

  • Versionado y actualización de listas reales → E6-T08: hoy las semillas son JSON versionados en el repo; la ingesta, actualización periódica y persistencia de versiones reales quedan para esa tarea.
  • Cotejo en onboarding/transacciones y persistencia de resultados → E6-T09: aquí solo se entrega el adaptador. El caso de uso, el endpoint y la tabla screening_results llegan con esa tarea.
  • Listas reales de OFAC/World-Check/PEP, reglas de bloqueo automático y revisión manual en UI.

Riesgos

  • Falsos positivos/negativos del umbral: 0.85 es un punto de partida; nombres muy cortos o con muchas partículas pueden puntuar distinto. Se mitiga devolviendo siempre el mejor candidato con su score para que cumplimiento calibre con datos reales.
  • Jaro-Winkler no es semántico: no detecta alias, transliteraciones complejas ni abreviaturas. Para listas reales probablemente haga falta una etapa adicional (alias/weak aliases).
  • Circuito en memoria: el estado del circuito vive en el proceso; con varias réplicas cada una abre el suyo. Aceptable por ahora, a revisar si se despliega en varios workers.
  • Semillas ficticias: no deben confundirse con listas reales; están marcadas como ficticias y la tarea E6-T08 las reemplaza.
  • Contrato http asumido: POST /screen y GET /lists son una suposición de integración; si el proveedor real difiere, habrá que ajustar el parseo (ya tolerante a alias y a data anidado) en E6-T08.

- Interfaz SanctionsLists (screen + list_versions) con dominio puro de
  similitud en risk/domain/screening.py: normalizacion (acentos, mayusculas,
  puntuacion, orden de tokens) y Jaro-Winkler stdlib, tolerante al orden.
- MockSanctionsLists determinista sobre 3 listas ficticias versionadas
  (OFAC/WORLDCHECK/PEP) en adapters/data/sanctions/*.json, sin PII real.
- HttpSanctionsLists: POST /screen y GET /lists con timeout, reintentos con
  backoff y circuito; API key solo en cabecera Authorization, nunca en URL,
  cuerpo ni logs (solo hash del sujeto).
- Fabrica create_sanctions_lists() (SANCTIONS_PROVIDER, default mock).
- 53 pruebas nuevas; suite 2217 passed + 1 skipped; ruff/black limpios.
- Sin migracion ni endpoints nuevos (adaptador interno, se consume en E6-T09).
@JheampierreRaPe
JheampierreRaPe merged commit 56f4279 into main Nov 12, 2026
16 checks passed
@JheampierreRaPe
JheampierreRaPe deleted the feature/compliance-sanctions-adapter branch November 12, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant