Módulo 4: Guardrails — Input & Output Validation
6. PII Detection y Redaction
Descripción
PII (Personally Identifiable Information) son datos que identifican a una persona: nombre, email, teléfono, DNI, dirección, número de tarjeta de crédito. En apps LLM, el PII puede aparecer en el input del usuario (texto que contiene datos personales), o en el output del LLM (cuando procesa documentos con PII y los reproduce en el resultado). Esta cápsula cubre tres niveles de detección: regex para patrones estándar, Presidio (biblioteca de Microsoft) para NER, y los trade-offs de cada enfoque.
Los dos vectores de PII en apps LLM
# Vector 1: PII en el INPUT del usuario
user_input = "Analiza este email de mi cliente: Mi nombre es Juan García,
puedes contactarme en juan.garcia@empresa.com o al 612 345 678."
# El PII del input puede:
# - Aparecer en los logs del sistema
# - Ser incluido en el output ("El sentimiento de Juan García es positivo")
# - Ser enviado a APIs de terceros (el LLM provider recibe este texto)
# Vector 2: PII en el OUTPUT del LLM
document = """Informe Q3
Responsable: María López (m.lopez@empresa.com, DNI 12345678A)
Ventas: $1.2M en el trimestre..."""
llm_output = summarize(document)
# El LLM puede incluir: "María López logró ventas de $1.2M..."
# → PII de María expuesta en el output
Nivel 1: Regex para patrones estándar
# src/guardrails/pii_detector.py
import re
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class PIIMatch:
pii_type: str
value: str
start: int
end: int
@dataclass
class PIIDetectionResult:
has_pii: bool
matches: list[PIIMatch] = field(default_factory=list)
redacted_text: Optional[str] = None
# Patrones por tipo de PII
PII_PATTERNS = {
# Email: user@domain.tld
"email": re.compile(
r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,10}\b',
re.IGNORECASE
),
# Teléfono España: 6XX XXX XXX, +34 6XX XXX XXX, 9XX XXX XXX
"phone_es": re.compile(
r'(?:(?:\+34|0034)\s?)?(?:6|7|8|9)\d{2}[\s.\-]?\d{3}[\s.\-]?\d{3}\b'
),
# DNI/NIE España
"dni_es": re.compile(
r'\b\d{8}[A-HJ-NP-TV-Z]\b|\b[XYZ]\d{7}[A-HJ-NP-TV-Z]\b',
re.IGNORECASE
),
# Tarjeta de crédito: 16 dígitos en grupos
"credit_card": re.compile(
r'\b(?:\d{4}[\s\-]?){3}\d{4}\b'
),
# IBAN España
"iban_es": re.compile(
r'\bES\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}\b',
re.IGNORECASE
),
# IP Address (puede ser PII en algunos contextos)
"ip_address": re.compile(
r'\b(?:\d{1,3}\.){3}\d{1,3}\b'
),
}
def detect_pii_regex(text: str, patterns: dict = None) -> PIIDetectionResult:
"""
Detecta PII usando patrones regex.
Rápido (<2ms) pero solo captura patrones conocidos con formato estándar.
No detecta: nombres en texto libre, direcciones, fechas de nacimiento.
Args:
text: Texto a analizar
patterns: Dict de patrones (por defecto usa PII_PATTERNS)
Returns:
PIIDetectionResult con los matches encontrados
"""
if patterns is None:
patterns = PII_PATTERNS
matches = []
for pii_type, pattern in patterns.items():
for match in pattern.finditer(text):
matches.append(PIIMatch(
pii_type=pii_type,
value=match.group(),
start=match.start(),
end=match.end()
))
return PIIDetectionResult(
has_pii=len(matches) > 0,
matches=matches
)
Estrategias de redacción
def redact_pii(
text: str,
strategy: str = "replace",
replacement_char: str = "*",
patterns: dict = None
) -> tuple[str, PIIDetectionResult]:
"""
Detecta y redacta PII del texto.
Strategies:
- "replace": Reemplaza con placeholder [EMAIL], [PHONE], etc.
- "mask": Enmascara parcialmente: juan@empresa.com → j***@empresa.com
- "remove": Elimina completamente: "Juan en j@e.com" → "Juan en "
Returns:
(texto_redactado, PIIDetectionResult)
"""
detection = detect_pii_regex(text, patterns)
if not detection.has_pii:
return text, detection
redacted = text
if strategy == "replace":
redacted = _replace_pii(text, detection.matches)
elif strategy == "mask":
redacted = _mask_pii(text, detection.matches)
elif strategy == "remove":
redacted = _remove_pii(text, detection.matches)
detection.redacted_text = redacted
return redacted, detection
def _replace_pii(text: str, matches: list[PIIMatch]) -> str:
"""Reemplaza PII con placeholders legibles."""
replacements = {
"email": "[EMAIL]",
"phone_es": "[TELÉFONO]",
"dni_es": "[DNI]",
"credit_card": "[TARJETA]",
"iban_es": "[IBAN]",
"ip_address": "[IP]",
}
# Ordenar matches por posición (de atrás hacia adelante para no romper índices)
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
placeholder = replacements.get(match.pii_type, "[REDACTED]")
result = result[:match.start] + placeholder + result[match.end:]
return result
def _mask_pii(text: str, matches: list[PIIMatch]) -> str:
"""Enmascara PII parcialmente, preservando legibilidad."""
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
masked = _mask_value(match.value, match.pii_type)
result = result[:match.start] + masked + result[match.end:]
return result
def _mask_value(value: str, pii_type: str) -> str:
"""Enmascara un valor específico según su tipo."""
if pii_type == "email":
local, domain = value.rsplit("@", 1)
if len(local) > 1:
return local[0] + "*" * (len(local) - 1) + "@" + domain
return "*@" + domain
elif pii_type == "phone_es":
digits = re.sub(r'\D', '', value)
if len(digits) >= 6:
return digits[:3] + "***" + digits[-3:]
return "***"
elif pii_type == "dni_es":
return value[:2] + "****" + value[-2:]
elif pii_type == "credit_card":
digits = re.sub(r'\D', '', value)
return "**** **** **** " + digits[-4:]
else:
n = len(value)
visible = max(1, n // 4)
return value[:visible] + "*" * (n - visible * 2) + value[-visible:]
def _remove_pii(text: str, matches: list[PIIMatch]) -> str:
"""Elimina PII completamente del texto."""
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
result = result[:match.start] + result[match.end:]
return result.strip()
Nivel 2: Presidio (NER para PII más complejo)
Microsoft Presidio usa Named Entity Recognition para detectar PII más difícil de capturar con regex:
pip install presidio-analyzer presidio-anonymizer
python -m spacy download es_core_news_md # Modelo para español
python -m spacy download en_core_web_lg # Modelo para inglés (mejor cobertura)
from functools import lru_cache
@lru_cache(maxsize=1)
def get_presidio_engines():
"""Carga los engines de Presidio una vez y los cachea."""
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
# Configurar para español
provider = NlpEngineProvider(nlp_configuration={
"nlp_engine_name": "spacy",
"models": [{"lang_code": "es", "model_name": "es_core_news_md"}]
})
analyzer = AnalyzerEngine(nlp_engine=provider.create_engine())
anonymizer = AnonymizerEngine()
return analyzer, anonymizer
def detect_pii_presidio(
text: str,
language: str = "es",
entities: list[str] = None
) -> PIIDetectionResult:
"""
Detecta PII usando Presidio con NER.
Detecta lo que regex no puede:
- PERSON (nombres de personas)
- LOCATION (ciudades, direcciones)
- ORGANIZATION
- DATE_TIME (puede ser PII como fecha de nacimiento)
Latencia: ~20-100ms (más lento que regex pero más preciso para PII complejo)
"""
try:
analyzer, _ = get_presidio_engines()
# Entidades a detectar (por defecto: las más comunes)
if entities is None:
entities = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "LOCATION",
"CREDIT_CARD", "IBAN_CODE", "NRP"] # NRP = National Registration Number
results = analyzer.analyze(
text=text,
language=language,
entities=entities
)
matches = [
PIIMatch(
pii_type=r.entity_type.lower(),
value=text[r.start:r.end],
start=r.start,
end=r.end
)
for r in results
]
return PIIDetectionResult(has_pii=len(matches) > 0, matches=matches)
except Exception as e:
# Si Presidio falla, hacer fallback a regex
import logging
logging.getLogger("guardrails").warning(f"Presidio failed: {e}")
return detect_pii_regex(text)
def redact_with_presidio(text: str, language: str = "es") -> str:
"""Redacta PII usando Presidio (más preciso, más lento)."""
try:
from presidio_anonymizer.entities import OperatorConfig
analyzer, anonymizer = get_presidio_engines()
results = analyzer.analyze(text=text, language=language)
anonymized = anonymizer.anonymize(
text=text,
analyzer_results=results,
operators={
"DEFAULT": OperatorConfig("replace", {"new_value": "[REDACTED]"}),
"PERSON": OperatorConfig("replace", {"new_value": "[NOMBRE]"}),
"EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "[EMAIL]"}),
"PHONE_NUMBER": OperatorConfig("replace", {"new_value": "[TELÉFONO]"}),
}
)
return anonymized.text
except Exception:
# Fallback a regex si Presidio falla
redacted, _ = redact_pii(text)
return redacted
Comparación: cuándo usar cada nivel
# Función de decisión para elegir el nivel apropiado:
def redact_pii_smart(
text: str,
use_presidio: bool = False,
language: str = "es"
) -> tuple[str, dict]:
"""
Redacta PII eligiendo el nivel apropiado.
Nivel 1 (regex): para outputs de sentimiento (emails, teléfonos son raro pero posible)
Nivel 2 (Presidio): para outputs de RAG con documentos (pueden tener nombres)
"""
if use_presidio:
try:
redacted = redact_with_presidio(text, language)
return redacted, {"method": "presidio"}
except ImportError:
pass # Presidio no instalado → usar regex
redacted, detection = redact_pii(text)
return redacted, {
"method": "regex",
"pii_types_found": [m.pii_type for m in detection.matches]
}
| Nivel | Qué detecta | Velocidad | Cuándo usar |
|---|---|---|---|
| Regex | Emails, teléfonos, DNI, IBAN, CC | <2ms | Output de sentimiento, APIs simples |
| Presidio | + Nombres, ciudades, organizaciones | 20-100ms | RAG, resúmenes de documentos con personas |
| LLM judge | Todo, incluyendo PII implícito | +400ms | Solo para datos muy sensibles (médico, legal) |
Tests del PII detector
# tests/unit/guardrails/test_pii_detector.py
import pytest
from src.guardrails.pii_detector import detect_pii_regex, redact_pii
class TestEmailDetection:
def test_detects_standard_email(self):
result = detect_pii_regex("Contacta en juan@empresa.com por favor")
assert result.has_pii
assert any(m.pii_type == "email" for m in result.matches)
def test_detects_complex_email(self):
result = detect_pii_regex("Email: juan.garcia+filtro@subdomain.empresa.es")
assert result.has_pii
def test_no_false_positive_for_non_email(self):
result = detect_pii_regex("La versión 3.0 es mejor que 2.5")
email_matches = [m for m in result.matches if m.pii_type == "email"]
assert len(email_matches) == 0
class TestPhoneDetection:
def test_detects_mobile_spain(self):
result = detect_pii_regex("Llámame al 612 345 678")
phone_matches = [m for m in result.matches if m.pii_type == "phone_es"]
assert len(phone_matches) > 0
def test_detects_phone_with_country_code(self):
result = detect_pii_regex("Teléfono: +34 612345678")
assert result.has_pii
class TestRedaction:
def test_email_redacted_with_replace(self):
redacted, _ = redact_pii("Email: test@example.com", strategy="replace")
assert "test@example.com" not in redacted
assert "[EMAIL]" in redacted
def test_email_redacted_with_mask(self):
redacted, _ = redact_pii("Email: test@example.com", strategy="mask")
assert "test@example.com" not in redacted
assert "@example.com" in redacted # El dominio se preserva
def test_multiple_pii_all_redacted(self):
text = "Juan: juan@email.com, teléfono 612345678"
redacted, detection = redact_pii(text)
assert "juan@email.com" not in redacted
assert "612345678" not in redacted
def test_text_without_pii_unchanged(self):
text = "El sentimiento es positivo con alta confianza."
redacted, detection = redact_pii(text)
assert redacted == text
assert not detection.has_pii
@pytest.mark.parametrize("text,pii_type", [
("Envía a juan@empresa.com", "email"),
("Llama al 612345678", "phone_es"),
("DNI: 12345678A", "dni_es"),
("Tarjeta: 4111 1111 1111 1111", "credit_card"),
])
def test_pii_detection_parametrized(text, pii_type):
result = detect_pii_regex(text)
assert result.has_pii
assert any(m.pii_type == pii_type for m in result.matches)
Integración en el pipeline
# En el output guardrail del pipeline:
def process_output_with_pii_redaction(output: dict) -> dict:
"""
Redacta PII de todos los campos de texto del output.
Para el output de sentimiento, los campos a redactar son:
- explanation (puede mencionar el texto analizado con PII)
- keywords (podrían contener nombres o datos del texto)
"""
if "explanation" in output and output["explanation"]:
redacted, detection = redact_pii(output["explanation"])
if detection.has_pii:
import logging
logging.getLogger("guardrails.pii").info(
"pii_redacted_from_output",
extra={
"field": "explanation",
"pii_types": list(set(m.pii_type for m in detection.matches))
}
)
output["explanation"] = redacted
if "keywords" in output:
cleaned_keywords = []
for kw in output["keywords"]:
redacted_kw, detection = redact_pii(kw)
if not detection.has_pii:
cleaned_keywords.append(kw)
# Si la keyword es PII, simplemente eliminarla
output["keywords"] = cleaned_keywords
return output
Ejercicios
Ejercicio 1: Regex para IBAN
Escribe y prueba un regex para IBAN español (formato: ES XX XXXX XXXX XX XXXXXXXXXX):
Ver solución
import re
IBAN_ES_PATTERN = re.compile(
r'\bES\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{2}[\s]?\d{10}\b',
re.IGNORECASE
)
# Tests:
test_cases = [
("IBAN: ES91 2100 0418 4502 0005 1332", True),
("ES9121000418450200051332", True),
("No hay IBAN aquí", False),
("ES1234 no válido", False),
]
for text, expected in test_cases:
found = bool(IBAN_ES_PATTERN.search(text))
status = "✅" if found == expected else "❌"
print(f"{status} '{text[:40]}': {'detected' if found else 'not detected'}")
Ejercicio 2: Mask para tarjeta de crédito
Implementa una función que enmascare un número de tarjeta de crédito mostrando solo los últimos 4 dígitos:
Ver solución
import re
def mask_credit_card(card_number: str) -> str:
"""
Enmascara número de tarjeta: 4111 1111 1111 1111 → **** **** **** 1111
"""
# Extraer solo dígitos
digits = re.sub(r'\D', '', card_number)
if len(digits) < 13:
return "*" * len(card_number)
# Mostrar solo últimos 4 dígitos
last_four = digits[-4:]
masked_digits = "**** **** **** " + last_four
return masked_digits
# Tests:
assert mask_credit_card("4111 1111 1111 1111") == "**** **** **** 1111"
assert mask_credit_card("4111111111111111") == "**** **** **** 1111"
Ejercicio 3: Test de redacción completa
Escribe un test que verifica que un texto con email Y teléfono queda completamente redactado:
Ver solución
def test_complete_redaction():
text = "Datos: María García, contacto maria@empresa.com, teléfono +34 612 345 678"
redacted, detection = redact_pii(text)
# Verificar que PII fue detectado
assert detection.has_pii
assert len(detection.matches) >= 2
# Verificar que PII fue eliminado del output
assert "maria@empresa.com" not in redacted
assert "612 345 678" not in redacted
assert "612345678" not in redacted
# El nombre "María García" puede quedar (regex no detecta nombres)
# → Esto es un trade-off documentado: regex no detecta nombres en texto libre
Resumen
- PII en apps LLM: dos vectores — en el input del usuario y en el output del LLM
- Nivel 1 (regex): emails, teléfonos, DNI, IBAN, tarjetas — rápido (<2ms), sin costo
- Nivel 2 (Presidio): + nombres, ciudades, organizaciones — NER, 20-100ms
- Tres estrategias de redacción: replace (placeholders), mask (parcial), remove (eliminar)
- Nunca loguear PII — siempre redactar antes de enviar a logs o analytics
- Trade-off documentado: regex no detecta nombres en texto libre — eso requiere Presidio o LLM
Recursos adicionales
- Presidio (Microsoft) — La biblioteca más completa para PII detection/anonymization
- spaCy NER — Named Entity Recognition para nombres, lugares
- GDPR y PII — Marco legal europeo de protección de datos
- RGPD (España) — Versión española del GDPR
- Presidio Supported Entities — Lista completa de entidades que Presidio detecta
- Anonymization Techniques — Guía de técnicas de anonimización