
Oculta CURP, RFC y CLABE antes de que lleguen al LLM: tutorial de Presidio para agentes de IA mexicanos
Cuando tu agente de IA manda texto del usuario a un LLM, el PII mexicano (CURP, RFC, CLABE, teléfonos) viaja con él. En un producto MX regulado conviene ocultarlo primero. Usa Microsoft Presidio: agrega reconocedores propios de CURP/RFC/CLABE, analiza el texto, anonimiza a placeholders y conéctalo como middleware para que el PII crudo nunca entre al modelo.
La fuga que no ves: tu agente manda CURP, RFC y CLABE directo al modelo
Imagínate el caso más común que tengo en producción: un agente de soporte o fiscal recibe un mensaje del cliente que dice “mi RFC es XAXX010101000 y mi CLABE es 002010077777777771, ¿ya cayó mi pago?”. Tú armas el prompt, le pegas el mensaje crudo del usuario y se lo mandas al LLM. En ese instante el CURP, el RFC y la CLABE de tu cliente acaban de viajar al contexto de un modelo de un tercero. Nadie lo ve, no truena nada, pero la PII ya salió de tu sistema.
Para un producto mexicano regulado — fintech, fiscal, salud — eso muchas veces simplemente no lo puedes hacer: no debes mandar PII cruda a un modelo externo. Y la nueva LFPDPPP (publicada en el DOF en marzo de 2025; confirma la fecha y la versión vigente contra la fuente oficial) subió la vara para el manejo de datos personales de mexicanos. No soy abogado, soy ingeniero, así que para el tema legal busca asesoría de compliance; lo que sí te puedo dar es el control de ingeniería.
El arreglo en una línea: oculta la PII primero, enfrente del modelo. Esta capa se coloca delante de cualquier LLM, Claude incluido. Y seamos honestos desde el principio: la redacción reduce la fuga, no la lleva a cero. Lo que vamos a construir es un middleware de redacción basado en Microsoft Presidio, con reconocedores propios de CURP, RFC y CLABE, conectado de modo que el PII crudo nunca entre al contexto del modelo. Si quieres el panorama más amplio de asegurar agentes de IA, lo cubro en cómo asegurar servidores MCP.
¿Qué identificadores mexicanos tienes que cachar?
Antes de escribir un solo regex, hay que tener clarísima la forma de cada identificador. Eso es lo que tus reconocedores van a tener que matchear:
- CURP: 18 caracteres — 4 letras + 6 dígitos (fecha de nacimiento) + H/M (sexo) + 5 letras + 1 alfanumérico + 1 dígito verificador. El carácter 18 es un dígito verificador calculado.
- RFC: 12 caracteres (personas morales) / 13 caracteres (personas físicas) — 3 o 4 letras + 6 dígitos + 3 alfanuméricos. Los últimos 3 son la homoclave, que incluye un carácter de verificación.
- CLABE: 18 dígitos — el dígito 18 es un dígito de control mod-10 ponderado (los pesos estándar son 3, 7, 1 repetidos; verifica el algoritmo exacto contra la especificación oficial de Banxico/CLABE antes de confiar en él).
- Más un reconocedor de teléfono mexicano, y los reconocedores EMAIL y PERSON que Presidio trae por defecto.
El punto clave: Presidio trae reconocedores afinados para inglés y no conoce CURP/RFC/CLABE de fábrica. Por eso le agregamos reconocedores propios.
Presidio en 60 segundos: el Analyzer lo encuentra, el Anonymizer lo reemplaza
Microsoft Presidio es un framework open-source (MIT) de detección y anonimización de PII, con releases activos a la fecha de 2026 (confirma la versión actual antes de fijarla en tus dependencias). Tiene dos componentes que vas a usar:
- El ANALYZER (
AnalyzerEngine) encuentra la PII: tipo de entidad, posición (start/end) y score. - El ANONYMIZER (
AnonymizerEngine) la reemplaza.
Instálalo, junto con el modelo de spaCy que necesita el motor NLP por defecto de Presidio:
pip install presidio-analyzer presidio-anonymizer
python -m spacy download en_core_web_lg
La unidad de detección que vamos a extender es el PatternRecognizer: detecta una entidad a partir de una lista de Pattern (regex), más palabras de contexto opcionales y una función de validación opcional. El flujo será: registrar los reconocedores MX, llamar analyzer.analyze(text=..., language="en") y luego anonymizer.anonymize(...). Los detalles de la documentación de reconocedores propios están en los docs del analyzer.
Constrúyelo: reconocedores propios de CURP, RFC y CLABE
Aquí está el core copy-pasteable. Defines un Pattern (nombre, regex, score base) y lo envuelves en un PatternRecognizer por cada entidad. Las palabras de contexto suben el score cuando la etiqueta (“curp”, “rfc”, “clabe”, “cuenta”, “interbancaria”) aparece cerca:
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
# --- CURP: 18 chars ---
curp_pattern = Pattern(
name="curp",
regex=r"\b[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d\b",
score=0.6,
)
curp_recognizer = PatternRecognizer(
supported_entity="MX_CURP",
patterns=[curp_pattern],
context=["curp"],
)
# --- RFC: 12 (morales) / 13 (físicas) ---
rfc_pattern = Pattern(
name="rfc",
regex=r"\b[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}\b",
score=0.5,
)
rfc_recognizer = PatternRecognizer(
supported_entity="MX_RFC",
patterns=[rfc_pattern],
context=["rfc", "homoclave"],
)
# --- CLABE: 18 dígitos ---
clabe_pattern = Pattern(
name="clabe",
regex=r"\b\d{18}\b",
score=0.4, # score bajo: lo subimos con contexto + validación
)
# OJO: aquí registramos el PatternRecognizer plano para que la demo de
# anonimización funcione tal cual. Más abajo definimos ClabeRecognizer
# (con validación de dígito verificador) y te digo cómo intercambiarlo.
clabe_recognizer = PatternRecognizer(
supported_entity="MX_CLABE",
patterns=[clabe_pattern],
context=["clabe", "cuenta", "interbancaria"],
)
# --- Teléfono MX (10 dígitos, con o sin lada/separadores) ---
phone_pattern = Pattern(
name="mx_phone",
regex=r"\b(?:\+?52[\s-]?)?(?:\d{2,3}[\s-]?)?\d{4}[\s-]?\d{4}\b",
score=0.4,
)
phone_recognizer = PatternRecognizer(
supported_entity="PHONE_NUMBER",
patterns=[phone_pattern],
context=["tel", "teléfono", "celular", "whatsapp"],
)
# Registrar todos en el motor
analyzer = AnalyzerEngine()
for r in (curp_recognizer, rfc_recognizer, clabe_recognizer, phone_recognizer):
analyzer.registry.add_recognizer(r)
Un detalle de solapamiento que tienes que conocer: el regex de teléfono puede competir con la CLABE de 18 dígitos y con el RFC cuando el texto viene sin separadores. Cuando dos reconocedores marcan el mismo span, Presidio se queda con el de mayor score — por eso la CLABE y el RFC, con su score base más el empujón de las palabras de contexto, deben ganarle al teléfono. Prueba específicamente cadenas de 18 dígitos para confirmar que no te las etiquete como PHONE_NUMBER.
Y así corres el análisis sobre el texto del usuario:
user_text = (
"Hola, mi CURP es XEXX010101HNEXXXA4, mi RFC XAXX010101000 "
"y mi CLABE interbancaria es 002010077777777771. ¿Ya cayó mi pago?"
)
results = analyzer.analyze(
text=user_text,
entities=["MX_CURP", "MX_RFC", "MX_CLABE",
"PHONE_NUMBER", "EMAIL_ADDRESS", "PERSON"],
language="en",
)
# results -> lista de (entity_type, start, end, score)
Fíjate en el detalle de language="en": los reconocedores built-in están afinados a inglés, pero tus reconocedores de regex son agnósticos al idioma, así que disparan sobre texto en español sin problema. Por eso el language="en" no te estorba para detectar CURP/RFC/CLABE en mensajes en español.
Un aviso honesto sobre estos ejemplos: el user_text de arriba está construido a propósito para que los tres valores matcheen los regex de este post (lo puedes verificar tú mismo). Corre el snippet tal cual y revisa la salida; pero en datos reales mide falsos positivos y negativos — no des por hecho que tu dataset se comporta como estos tres ejemplos bonitos.
- Instala Presidio + spaCypip install presidio-analyzer presidio-anonymizer; spacy download en_core_web_lg
- Registra reconocedores MXPatternRecognizer propio para CURP, RFC, CLABE y teléfono
- Analiza el textoanalyzer.analyze(...) -> entidades con tipo, posición y score
- Anonimiza a placeholdersAnonymizerEngine reemplaza por <CURP>, <RFC>, <CLABE>
- Conéctalo como middlewareredact() corre antes de armar el prompt del LLM
- Pruébalo en datos representativosmide falsos positivos y negativos antes de confiar
Reduce falsos positivos con validación de dígito verificador (verifica el algoritmo)
Un \d{18} matchea cualquier cadena de 18 dígitos, no nada más CLABEs reales. Para recortar esos falsos positivos, el PatternRecognizer acepta una función de validación (validate_result, con firma validate_result(self, pattern_text: str) -> Optional[bool]) que recalcula el dígito de control y devuelve True/False/None para confirmar, descartar o dejar neutral el match.
Te muestro solo la forma — no tomes este checksum como autoritativo:
from typing import Optional
def validate_clabe(num: str) -> bool:
# FORMA del algoritmo — VERIFICA contra la spec oficial Banxico/CLABE
if len(num) != 18 or not num.isdigit():
return False
pesos = [3, 7, 1] # patrón ponderado estándar (CONFIRMA el exacto)
suma = 0
for i, d in enumerate(num[:17]):
# aplico módulo 10 por término ANTES de sumar: ese paso intermedio
# también confírmalo contra la spec, no lo des por hecho
suma += (int(d) * pesos[i % 3]) % 10
control = (10 - (suma % 10)) % 10
return control == int(num[17])
class ClabeRecognizer(PatternRecognizer):
def validate_result(self, pattern_text: str) -> Optional[bool]:
# devuelve True/False/None para confirmar, descartar o dejar neutral
return validate_clabe(pattern_text)
Aquí está el wiring que de verdad importa, porque es donde la promesa “sígueme y funciona” se rompe si no pones atención: el reconocedor que registramos arriba es el PatternRecognizer plano, así que la validación nunca corre. Para que sí filtre, instancia ClabeRecognizer y registra ese en lugar del plano:
clabe_recognizer = ClabeRecognizer(
supported_entity="MX_CLABE",
patterns=[clabe_pattern],
context=["clabe", "cuenta", "interbancaria"],
)
# ...y en el loop de registro usa este clabe_recognizer (el que valida),
# no el PatternRecognizer plano de la sección anterior.
Una trampa de la demo: si activas la validación, los valores de ejemplo de este post son sintéticos y pueden no tener un dígito de control válido. En ese caso validate_result devuelve False y el match se descarta — la anonimización de la CLABE de ejemplo dejaría de funcionar. Para probar la validación usa CLABEs con dígito de control real; para la demo de anonimización, deja el reconocedor plano sin validación.
Insisto en lo importante: confirma el algoritmo exacto del dígito verificador contra la especificación oficial de Banxico/CLABE (y la spec del SAT para el carácter de verificación del RFC y el dígito del CURP) antes de confiar en él. No presentes un checksum sin verificar como si fuera autoridad. La validación recorta falsos positivos, pero si tu algoritmo está sutilmente mal te mete falsos negativos — ese es el trade-off, y por eso se verifica contra la fuente oficial.
Anonimiza a placeholders antes de armar el prompt
Cierra el ciclo: le pasas los resultados del analyzer al AnonymizerEngine para reemplazar cada entidad por un placeholder como <CURP>, <RFC>, <CLABE>:
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
anonymizer = AnonymizerEngine()
anonymized = anonymizer.anonymize(
text=user_text,
analyzer_results=results,
operators={
"MX_CURP": OperatorConfig("replace", {"new_value": "<CURP>"}),
"MX_RFC": OperatorConfig("replace", {"new_value": "<RFC>"}),
"MX_CLABE": OperatorConfig("replace", {"new_value": "<CLABE>"}),
"DEFAULT": OperatorConfig("replace", {"new_value": "<PII>"}),
},
)
print(anonymized.text)
# "Hola, mi CURP es <CURP>, mi RFC <RFC> y mi CLABE interbancaria es <CLABE>. ¿Ya cayó mi pago?"
Por defecto el reemplazo es irreversible — el modelo solo ve placeholders, y este es el modo más seguro. Si de plano necesitas reinsertar los valores reales en la respuesta, mantén un mapa reversible placeholder→valor de tu lado y des-anonimiza después de que el modelo responda. Presidio soporta esto con un operador encrypt/decrypt con llave, y la reinserción reversible se hace típicamente con el DeanonymizeEngine usando el mismo operador y la misma llave (no re-corriendo anonymize). Si no lo necesitas, déjalo irreversible. Regla de dedo: solo vas reversible cuando el flujo de verdad necesita el valor real de regreso en la salida; por default, irreversible.
Conéctalo como middleware: el PII crudo nunca entra al contexto del modelo
Ahora juntamos todo en una sola función redact(text) y la llamas sobre cada input del usuario, antes de armar el prompt y mandarlo al LLM:
def redact(text: str) -> str:
results = analyzer.analyze(
text=text,
entities=["MX_CURP", "MX_RFC", "MX_CLABE",
"PHONE_NUMBER", "EMAIL_ADDRESS", "PERSON"],
language="en",
)
return anonymizer.anonymize(
text=text,
analyzer_results=results,
operators={"DEFAULT": OperatorConfig("replace", {"new_value": "<PII>"})},
).text
def ask_llm(user_text: str) -> str:
safe_text = redact(user_text) # PII fuera ANTES del prompt
prompt = f"Cliente dice: {safe_text}\nResponde como soporte."
# client.messages.create(model="claude-opus-4-8", ...) # cualquier modelo
return call_claude(prompt)
Aplícalo en la frontera del sistema — una dependencia/middleware de FastAPI, o un wrapper alrededor de la llamada a tu cliente de Anthropic — de modo que ningún code path pueda mandar PII cruda por accidente. Cuando el LLM de abajo es Claude, además elige una configuración apropiada de retención de datos: la redacción y la config de retención son capas complementarias, no sustitutas. El detalle de correr el modelo en producción frente al que se coloca esta capa lo desarrollo en LLM en producción, y cómo el agente que consume esto se mantiene sin alucinar lo cubro en chatbot de IA sin alucinaciones.
Preguntas frecuentes: falsos negativos, texto en español, rendimiento y reversibilidad
¿La redacción garantiza cero fuga? No. Los reconocedores de Presidio tienen falsos positivos y falsos negativos; prueba cada uno en un dataset representativo antes de confiar. La redacción reduce la fuga, no la lleva a cero — combínala con controles de acceso y config de retención.
¿Funciona en texto en español? Tus reconocedores de regex son agnósticos al idioma y disparan en español; los built-in PERSON/EMAIL están afinados a inglés, así que prueba PERSON específicamente sobre datos en español mexicano.
¿Puedo recuperar los valores reales? Sí, solo si mantienes un mapa reversible de tu lado (o usas DeanonymizeEngine con el operador encrypt/decrypt y la misma llave); si no, déjalo irreversible.
¿Esto basta para cumplir la LFPDPPP? Es un control de ingeniería, no un visto bueno legal. Ingeniero, no abogado: busca asesoría de compliance.
¿Y la latencia? El análisis corre local, antes de la llamada al API. Presupuesta la carga del modelo de spaCy y el tiempo de analyze por petición. Un agente real que maneja PII de clientes lo ejemplifico en agente de IA en WhatsApp para tu negocio.
Redactar antes del LLM
- El modelo solo ve placeholders <CURP> / <RFC> / <CLABE>
- Reduce la fuga de PII al contexto de un tercero
- Menor exposición frente a la LFPDPPP
- De-anonimización opcional con mapa de tu lado
Mandar PII cruda
- El modelo recibe CURP, RFC y CLABE reales
- La PII vive en el contexto de un modelo externo
- Mayor exposición regulatoria en un producto MX
- Nada que des-anonimizar: ya salió crudo
Lanza la capa de redacción y pruébala antes de confiar en ella
Recapitulando el arco: reconocedores propios de CURP/RFC/CLABE → analyze → anonimizar a placeholders → middleware enfrente del LLM. Eso es todo el pipeline.
Dos cosas no negociables. Primera: prueba cada reconocedor sobre un dataset representativo, no sobre tres ejemplos bonitos. Segunda: verifica los algoritmos de dígito verificador contra las specs oficiales de Banxico (CLABE) y el SAT (RFC/CURP) antes de confiar en la validación.
Y la honestidad de siempre: esta es una capa de reducción de fuga, no una garantía — combínala con controles de acceso y una configuración de retención apropiada del modelo. Para la LFPDPPP 2025 y el manejo de PII, ingeniero, no abogado: busca asesoría de compliance. Si quieres más guías de este tipo, todas están en agentes de IA, y para el contexto vale la pena leer el writeup de Ploomber sobre prevenir fuga de PII con Presidio.