Sistema de Anonimización de Texto - Solución Completa

Sistema de Anonimización de Texto - Solución Completa

Nota importada desde Inbox durante consolidacion bulk.

Resumen

Arquitectura y código Python completo para un sistema de anonimización de texto con tres capas de detección complementarias: Capa 1 (NER con transformers/Hugging Face para nombres, lugares, organizaciones), Capa 2 (Regex determinista para DNI, email, teléfono, IBAN, NIE, códigos postales, fechas), y Capa 3 (LLM con Ollama/llama3.2:3b para entidades complejas que requieren contexto como datos médicos, judiciales, edades). Las tres capas se unifican con resolución de solapamientos por prioridad (regex > ner > llm) y se aplican reglas de reemplazo configurables que generan un mapa reversible. Incluye 6 módulos Python completos: config.py, detector_ner.py, detector_regex.py, detector_llm.py, anonimizador.py y main.py.


Definición

Sistema de anonimización de texto que recibe un documento con datos sensibles y devuelve el texto con los datos reemplazados por tokens categorizados, sin conexión a internet obligatoria (LLM local), de forma inteligente usando tres capas complementarias de detección.

Ejemplo de entrada:

El paciente Juan Carlos Martínez, DNI 45678912B, domiciliado en
Calle Mayor 15, Madrid. Contacto: juan@gmail.com, tel 612345678.
Trabaja en Mapfre. IBAN: ES91 2100 0418 4502 0005 1332.

Ejemplo de salida:

El paciente [PERSONA_1], [DNI_REDACTADO], domiciliado en
[DIRECCIÓN_REDACTADA]. Contacto: [EMAIL_1], tel [TEL_REDACTADO].
Trabaja en [ORG_1]. IBAN: [CUENTA_REDACTADA].

Contexto

Arquitectura: 3 Capas de Detección

Cada capa cubre las debilidades de las otras:

                       TEXTO DE ENTRADA
                             │
               ┌─────────────┼─────────────┐
               ▼             ▼             ▼
         ┌──────────┐ ┌──────────┐ ┌──────────────┐
         │ CAPA 1   │ │ CAPA 2   │ │ CAPA 3       │
         │ NER      │ │ Regex    │ │ LLM (Ollama) │
         │ (rápido) │ │ (exacto) │ │ (inteligente)│
         └────┬─────┘ └────┬─────┘ └──────┬───────┘
              │             │              │
              └─────────────┼──────────────┘
                            ▼
                  ENTIDADES UNIFICADAS
                            │
                            ▼
                  ┌───────────────────┐
                  │ REGLAS DE         │
                  │ REEMPLAZO         │
                  └────────┬──────────┘
                           │
                           ▼
                  TEXTO ANONIMIZADO
                  + MAPA REVERSIBLE
Capa Detecta Fortaleza Debilidad
NER Nombres, lugares, organizaciones Muy rápido, alta precisión en español Solo 3-4 categorías
Regex DNI, email, teléfono, IBAN 100% determinista, nunca falla No entiende contexto
LLM Todo lo anterior + contexto complejo Entiende directrices, detecta lo ambiguo Más lento, puede alucinar

Aplicación

Dependencias

# NER (Capa 1)
pip install transformers torch

# LLM (Capa 3)
# Instalar Ollama desde https://ollama.com
ollama pull llama3.2:3b

Código Completo

config.py - Configuración centralizada

# Modelo NER de Hugging Face (se descarga una vez, ~500MB)
NER_MODEL = "Davlan/bert-base-multilingual-cased-ner-hrl"

# Modelo LLM en Ollama
LLM_MODEL = "llama3.2:3b"
OLLAMA_URL = "http://localhost:11434/api/generate"

# Mapeo de etiquetas NER a tipos internos
NER_LABEL_MAP = {
    "PER": "PERSONA",
    "LOC": "DIRECCION",
    "ORG": "ORGANIZACION",
}

# Patrones regex por tipo de entidad
REGEX_PATTERNS = {
    "EMAIL":           r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
    "TELEFONO":        r'\b[67]\d{8}\b',
    "TELEFONO_INTL":   r'\b\+34[\s.-]?[67]\d{8}\b',
    "DNI":             r'\b\d{8}[A-Za-z]\b',
    "NIE":             r'\b[XYZxyz]\d{7}[A-Za-z]\b',
    "IBAN":            r'\b[A-Z]{2}\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}\b',
    "CODIGO_POSTAL":   r'\b\d{5}\b',
    "FECHA":           r'\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b',
}

# Reglas de reemplazo por tipo
REGLAS_REEMPLAZO = {
    "PERSONA":       lambda i, txt: f"[PERSONA_{i}]",
    "DIRECCION":     lambda i, txt: "[DIRECCIÓN_REDACTADA]",
    "ORGANIZACION":  lambda i, txt: f"[ORG_{i}]",
    "EMAIL":         lambda i, txt: f"[EMAIL_{i}]",
    "TELEFONO":      lambda i, txt: "[TEL_REDACTADO]",
    "TELEFONO_INTL": lambda i, txt: "[TEL_REDACTADO]",
    "DNI":           lambda i, txt: "[DNI_REDACTADO]",
    "NIE":           lambda i, txt: "[NIE_REDACTADO]",
    "IBAN":          lambda i, txt: "[CUENTA_REDACTADA]",
    "CODIGO_POSTAL": lambda i, txt: "[CP_REDACTADO]",
    "FECHA":         lambda i, txt: "[FECHA_REDACTADA]",
    "EDAD":          lambda i, txt: "[EDAD_REDACTADA]",
}

detector_ner.py - Capa 1: NER con transformers

from transformers import pipeline
from config import NER_MODEL, NER_LABEL_MAP

_ner_pipeline = None

def cargar_ner():
    global _ner_pipeline
    if _ner_pipeline is None:
        _ner_pipeline = pipeline(
            "ner",
            model=NER_MODEL,
            aggregation_strategy="simple"
        )
    return _ner_pipeline

def detectar_ner(texto: str) -> list:
    """Detecta personas, lugares y organizaciones."""
    ner = cargar_ner()
    resultados = ner(texto)

    entidades = []
    for r in resultados:
        tipo = NER_LABEL_MAP.get(r["entity_group"])
        if tipo and r["score"] > 0.75:
            entidades.append({
                "texto":  texto[r["start"]:r["end"]],
                "tipo":   tipo,
                "inicio": r["start"],
                "fin":    r["end"],
                "score":  round(r["score"], 3),
                "fuente": "ner",
            })
    return entidades

detector_regex.py - Capa 2: Patrones regex

import re
from config import REGEX_PATTERNS

def detectar_regex(texto: str) -> list:
    """Detecta entidades con patrones predecibles: DNI, email, tel, IBAN..."""
    entidades = []

    for tipo, patron in REGEX_PATTERNS.items():
        for match in re.finditer(patron, texto):
            entidades.append({
                "texto":  match.group(),
                "tipo":   tipo,
                "inicio": match.start(),
                "fin":    match.end(),
                "score":  1.0,
                "fuente": "regex",
            })
    return entidades

detector_llm.py - Capa 3: LLM con Ollama

import json
import requests
from config import LLM_MODEL, OLLAMA_URL

DIRECTRICES = """
Eres un sistema de detección de datos personales para anonimización.

DEBES detectar:
- PERSONA: nombres completos o parciales de personas físicas
- DIRECCION: direcciones postales completas (calle, número, piso, ciudad)
- ORGANIZACION: empresas u organizaciones cuando identifican al individuo
- FECHA_NACIMIENTO: fechas de nacimiento explícitas
- EDAD: edad cuando combinada con otros datos puede identificar a alguien
- DATO_MEDICO: diagnósticos, tratamientos, condiciones de salud
- DATO_JUDICIAL: números de expediente, juzgados, sentencias

NO marques:
- Países o regiones genéricas ("España", "Europa")
- Cargos profesionales genéricos ("el médico", "la abogada")
- Datos ya detectables por regex (email, teléfono, DNI, IBAN)
- Información pública general

Responde SOLO con JSON válido:
{"entidades": [{"texto": "texto exacto", "tipo": "TIPO", "inicio": 0, "fin": 10}]}

Si no hay entidades: {"entidades": []}
"""

def detectar_llm(texto: str) -> list:
    """Detecta entidades complejas que requieren comprensión del contexto."""
    try:
        response = requests.post(OLLAMA_URL, json={
            "model": LLM_MODEL,
            "prompt": f"{DIRECTRICES}\n\nTexto:\n\"{texto}\"",
            "stream": False,
            "options": {"temperature": 0, "num_predict": 2048}
        }, timeout=30)

        raw = response.json()["response"]
        inicio = raw.find("{")
        fin = raw.rfind("}") + 1
        if inicio == -1 or fin == 0:
            return []

        resultado = json.loads(raw[inicio:fin])
        entidades = []
        for e in resultado.get("entidades", []):
            pos = texto.find(e["texto"])
            if pos != -1:
                entidades.append({
                    "texto":  e["texto"],
                    "tipo":   e["tipo"],
                    "inicio": pos,
                    "fin":    pos + len(e["texto"]),
                    "score":  0.8,
                    "fuente": "llm",
                })
        return entidades

    except (requests.RequestException, json.JSONDecodeError, KeyError):
        return []

anonimizador.py - Motor principal

from config import REGLAS_REEMPLAZO
from detector_ner import detectar_ner
from detector_regex import detectar_regex
from detector_llm import detectar_llm

def unificar_entidades(lista_ner: list, lista_regex: list, lista_llm: list) -> list:
    """
    Combina entidades de las 3 fuentes.
    Si hay solapamiento, prioriza: regex > ner > llm
    """
    todas = []
    for e in lista_regex:
        e["prioridad"] = 1
        todas.append(e)
    for e in lista_ner:
        e["prioridad"] = 2
        todas.append(e)
    for e in lista_llm:
        e["prioridad"] = 3
        todas.append(e)

    todas.sort(key=lambda e: (e["inicio"], e["prioridad"]))

    filtradas = []
    ultimo_fin = -1
    for e in todas:
        if e["inicio"] >= ultimo_fin:
            filtradas.append(e)
            ultimo_fin = e["fin"]

    return filtradas

def aplicar_reglas(texto: str, entidades: list) -> dict:
    """Aplica las reglas de reemplazo sobre el texto."""
    entidades_rev = sorted(entidades, key=lambda e: e["inicio"], reverse=True)

    resultado = texto
    mapa = {}
    contadores = {}

    for ent in entidades_rev:
        tipo = ent["tipo"]
        original = ent["texto"]

        contadores[tipo] = contadores.get(tipo, 0) + 1
        regla = REGLAS_REEMPLAZO.get(tipo, lambda i, t: f"[{tipo}_{i}]")
        reemplazo = regla(contadores[tipo], original)

        resultado = resultado[:ent["inicio"]] + reemplazo + resultado[ent["fin"]:]
        mapa[reemplazo] = original

    return {
        "original":          texto,
        "anonimizado":       resultado,
        "mapa_reversible":   mapa,
        "entidades":         entidades,
        "total_detectadas":  len(entidades),
    }

def anonimizar(texto: str, usar_llm: bool = True) -> dict:
    """Pipeline completo de anonimización."""
    ents_ner = detectar_ner(texto)
    ents_regex = detectar_regex(texto)
    ents_llm = detectar_llm(texto) if usar_llm else []
    entidades = unificar_entidades(ents_ner, ents_regex, ents_llm)
    return aplicar_reglas(texto, entidades)

main.py - Punto de entrada

from anonimizador import anonimizar

texto = """
Informe médico del paciente Juan Carlos Martínez García, de 45 años,
con DNI 45678912B, domiciliado en Calle Mayor 15, 3ºA, 28013 Madrid.
Diagnóstico: diabetes tipo 2 en tratamiento con metformina.
Contacto: juancarlos.martinez@gmail.com, teléfono 612345678.
Empleado de Seguros Mapfre desde 2015.
Cuenta para facturación: ES91 2100 0418 4502 0005 1332.
Expediente judicial 1234/2023 del Juzgado nº5 de Madrid.
"""

resultado = anonimizar(texto)

print(resultado["anonimizado"])
print(f"\nEntidades detectadas: {resultado['total_detectadas']}")
print("\nDetalle:")
for e in resultado["entidades"]:
    print(f"  [{e['fuente']:>5}] {e['tipo']:<20} → \"{e['texto']}\"")

Salida esperada:

Informe médico del paciente [PERSONA_1], de [EDAD_REDACTADA],
con [DNI_REDACTADO], domiciliado en [DIRECCIÓN_REDACTADA], [CP_REDACTADO] [DIRECCIÓN_REDACTADA].
Diagnóstico: [DATO_MEDICO_1] en tratamiento con [DATO_MEDICO_2].
Contacto: [EMAIL_1], teléfono [TEL_REDACTADO].
Empleado de [ORG_1] desde 2015.
Cuenta para facturación: [CUENTA_REDACTADA].
[DATO_JUDICIAL_1].

Entidades detectadas: 12

Detalle:
  [  ner] PERSONA              → "Juan Carlos Martínez García"
  [  llm] EDAD                 → "45 años"
  [regex] DNI                  → "45678912B"
  [  ner] DIRECCION            → "Calle Mayor 15, 3ºA"
  [regex] CODIGO_POSTAL        → "28013"
  [  ner] DIRECCION            → "Madrid"
  [  llm] DATO_MEDICO          → "diabetes tipo 2"
  [  llm] DATO_MEDICO          → "metformina"
  [regex] EMAIL                → "juancarlos.martinez@gmail.com"
  [regex] TELEFONO             → "612345678"
  [  ner] ORGANIZACION         → "Seguros Mapfre"
  [regex] IBAN                 → "ES91 2100 0418 4502 0005 1332"

Estructura de Archivos

anonimizador/
├── config.py              # Configuración, patrones, reglas
├── detector_ner.py        # Capa 1: NER (transformers)
├── detector_regex.py      # Capa 2: Regex
├── detector_llm.py        # Capa 3: LLM (Ollama)
├── anonimizador.py        # Motor principal: unifica + reemplaza
└── main.py                # Punto de entrada

Personalización de Directrices LLM

Las directrices pueden externalizarse a archivos de texto por dominio:

directrices/
├── medico.txt        # directrices para informes médicos
├── judicial.txt      # directrices para documentos legales
└── financiero.txt    # directrices para documentos bancarios
def cargar_directrices(dominio: str = "medico") -> str:
    with open(f"directrices/{dominio}.txt", "r", encoding="utf-8") as f:
        return f.read()

Esto permite que un especialista en protección de datos ajuste las reglas sin necesitar un programador.


Plan de Implementación (Roadmap 8 Fases / 47 Pasos)

Contenido integrado desde la nota original "Deanon Steps" (99_Originales/OSINT/Methodology/Deanon steps)

Fase 1: Preparación

  1. Definir stack tecnológico final (Python + framework GUI)
  2. Configurar entorno de desarrollo
  3. Crear repositorio Git
  4. Definir estructura de carpetas del proyecto

Fase 2: Motor Core

  1. Implementar extractor de texto (.doc, .docx, .md, .txt)
  2. Implementar conversor a markdown
  3. Crear sistema de detección con Regex (emails, IPs, teléfonos, etc.)
  4. Integrar spaCy para NER en español e inglés
  5. Implementar generador de tokens (consonantes aleatorias)
  6. Crear modelo de datos para entidades y relaciones
  7. Implementar diccionario de mapeo bidireccional
  8. Crear sistema de cifrado AES-256 para diccionario

Fase 3: Integración LLM Local

  1. Integrar Ollama como dependencia
  2. Configurar descarga automática de Phi-3 Mini
  3. Crear capa de comunicación con la LLM
  4. Implementar detección de entidades ambiguas con LLM
  5. Implementar sugerencias de vinculación inteligente
  6. Crear sistema de fallback (funcionar sin LLM si falla)

Fase 4: Interfaz Gráfica

  1. Diseñar layout principal (panel texto + árbol identidades)
  2. Implementar sistema de tabs (Original / Anonimizado)
  3. Crear árbol jerárquico de identidades con drag & drop
  4. Implementar menú contextual (click derecho)
  5. Crear diálogos de confirmación
  6. Implementar deshacer/rehacer
  7. Crear modo redacción (toggle)
  8. Implementar ventana ampliada con búsqueda y zoom
  9. Crear panel de Prioridades y Exclusiones
  10. Implementar gestión de proyectos (crear, abrir, guardar)

Fase 5: Historial y Auditoría

  1. Implementar sistema de versionado (20 versiones)
  2. Crear vista timeline con reversión
  3. Implementar log de auditoría
  4. Crear interfaz de consulta de logs (filtros, búsqueda)
  5. Implementar detección de modificaciones externas al diccionario

Fase 6: Des-anonimización

  1. Implementar carga de documento anonimizado
  2. Crear flujo de selección de diccionario + contraseña
  3. Implementar motor de des-anonimización
  4. Crear verificación de coherencia post des-anonimización

Fase 7: Testing y Pulido

  1. Escribir tests unitarios para motor core
  2. Escribir tests de integración
  3. Probar con documentos reales en español e inglés
  4. Optimizar rendimiento (documentos grandes)
  5. Pulir interfaz y experiencia de usuario

Fase 8: Empaquetado y Distribución

  1. Configurar empaquetado multiplataforma (Windows, Mac, Linux)
  2. Crear instalador con Ollama + Phi-3 Mini incluido
  3. Escribir documentación de usuario
  4. Crear guía de instalación
  5. Publicar en repositorio open source

Notas de Bugs del Proyecto Ralph

  • Las entidades y las entidades vinculadas deben mostrarse en el diccionario y en el grafo de manera ordenada y jerárquica
  • Las entidades identificadas como IPv4 en el documento son añadidas al diccionario con el código TEL de TELEFONO (error de clasificación)
  • Lo mismo ocurre con las entidades MAC al vincularlas: se les asigna el código TEL erróneamente
  • Necesidad de revisar toda la documentación relativa a los códigos de anonimización y su asignación correcta

Relaciones

  • Proyecto Ralph (anonimizador Tauri/Rust) - Implementación real basada en esta arquitectura

Referencias

  • Modelo NER: Davlan/bert-base-multilingual-cased-ner-hrl (Hugging Face)
  • LLM: llama3.2:3b via Ollama (https://ollama.com)
  • Frameworks: transformers (Hugging Face), torch (PyTorch)
  • Concepto de arquitectura multicapa para maximizar cobertura con resiliencia

Themes