Por qué no puedes subir documentos de clientes a ChatGPT y cómo usar IA de todas formas

Pipeline con OCR local, Presidio y anonimización reversible para que el modelo cloud solo vea texto ya desidentificado.

Por qué no puedes subir documentos de clientes a ChatGPT y cómo usar IA de todas formas

Por Ivor Padilla, co-fundador de Gradion · 8 min de lectura


La búsqueda chatgpt abogados casi siempre esconde la misma duda: si el modelo ahorra horas de revisión documental, ¿por qué no usarlo ya en el despacho? La respuesta corta es incómoda pero simple: porque no puedes subir un expediente real a un LLM cloud como si fuera un bloc de notas. Ahí viajan nombres, DNIs, NIFs, NIEs, CIFs, direcciones, referencias internas y, muchas veces, el contexto completo del asunto.

Eso no significa renunciar a la IA. Significa diseñar bien la frontera. El documento se procesa primero en local: OCR, detección de PII y anonimización reversible. Solo entonces sale al modelo cloud, y sale ya redactado. El LLM razona sobre <PERSONA_1> o <ORGANIZACION_1>, no sobre el nombre real del cliente.

TL;DR: no uses ChatGPT con documentos crudos de clientes. Flujo en cuatro pasos: (1) OCR local, (2) detección local de PII española, (3) anonimización reversible local, (4) LLM cloud solo sobre texto ya redactado. El proveedor nunca ve el dato real.

Lo que no puedes hacer con ChatGPT en un despacho

La respuesta rápida es esta: no puedes tratar un LLM cloud como si fuera una carpeta temporal del despacho. En cuanto el expediente real sale de tu entorno y entra en manos de un tercero, ya no estás en el terreno de "probar una herramienta". Estás en el terreno de tratamiento de datos personales por un proveedor externo.

Si un despacho usa un proveedor cloud para tratar datos personales, entra en juego el art. 28 del RGPD: el proveedor pasa a ser encargado del tratamiento y la relación no puede quedarse en un "acepto términos", sino que debe quedar regida por un contrato u otro acto jurídico que fije objeto, duración, finalidad, tipo de datos y obligaciones de cada parte. En un flujo con expedientes reales, ese contrato no es accesorio: es parte del perímetro mínimo de cumplimiento.

La guía de la AEPD sobre adecuación al RGPD de tratamientos que incorporan IA es explícita: si los datos del interesado se envían a terceros para explotar o refinar el componente de IA, hay una comunicación de datos y puede haber además tratamiento de almacenamiento o modificación del modelo. Si mandas el expediente real al proveedor, ya no estás "solo usando una herramienta"; estás abriendo un flujo de datos a terceros que tienes que poder defender.

El problema, por tanto, no es "ChatGPT sí o no". El problema es qué ve exactamente el proveedor. Si ve el expediente con dato real, has perdido la discusión antes de empezar.

El flujo correcto: OCR local, PII local, anonimización local, cloud al final

La respuesta útil no es un "no" abstracto. Es un flujo técnico muy concreto. La idea es separar identificación y razonamiento: la capa que toca el dato real se queda en local; la capa que razona sobre el texto puede ir a cloud si solo ve texto ya redactado.

Instalación

pip install langchain langchain-core langchain-openrouter langchain-ollama liteparse python-dotenv
pip install presidio-analyzer presidio-anonymizer spacy pydni
python -m spacy download es_core_news_sm
# Ollama con gemma4:e4b y lightonocr-2-1b cargados

Configuración

from presidio_analyzer import (AnalyzerEngine, RecognizerRegistry,
    EntityRecognizer, RecognizerResult, Pattern, PatternRecognizer)
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware
from langchain_core.messages import HumanMessage
from liteparse import LiteParse
from langchain_openrouter import ChatOpenRouter
from langchain_ollama import ChatOllama
from pydantic import BaseModel, Field
from PyDNI import verificar_dni, verificar_nie, verificar_cif
import base64, os, re

OLLAMA_URL  = "http://localhost:11434"  # [!code highlight]
OCR_MODEL   = "lightonocr-2-1b"
LLM_MODEL   = "gemma4:e4b"
NOTA_SIMPLE = "documento.pdf"  # tu PDF aquí

Paso 1 — OCR local

LiteParse renderiza el PDF y lo convierte en imagen por página. Esa imagen va en base64 directamente al modelo OCR a través del endpoint compatible con OpenAI de Ollama — sin sacar el documento fuera del entorno.

parser = LiteParse()
screenshot = parser.screenshot(NOTA_SIMPLE, dpi=200, load_bytes=True)
b64 = base64.b64encode(screenshot.get_page(0).image_bytes).decode()

ocr_llm = ChatOllama(           # [!code highlight]
    model=OCR_MODEL,
    base_url=OLLAMA_URL,
    temperature=0,
    num_ctx=16384,
    num_predict=16384,
)

msg = HumanMessage(content=[
    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
    {"type": "text", "text": "Transcribe todo el texto del documento. Mantén la estructura original."},
])

response = ocr_llm.invoke([msg])
content = response.content
print(f"OCR: {len(content)} caracteres extraídos")

Paso 2 — Detección de PII pensada para España

Presidio orquesta el reconocimiento de entidades en tres capas. Cada una hace algo distinto; las tres son necesarias para cubrir el espectro de PII en un documento jurídico español.

SpanishIDRecognizer — DNI, NIE y CIF

La mayoría de guías genéricas detectan PII con un regex de email y un regex de teléfono. En un despacho español eso no basta. Este recognizer detecta DNI, NIE y CIF — y no se limita al patrón: llama a pydni para validar el dígito de control. Un identificador que parece un DNI pero falla la suma de control no se intercepta.

class SpanishIDRecognizer(EntityRecognizer):
    def __init__(self):
        super().__init__(supported_entities=["ES_DNI", "ES_NIE", "ES_CIF"], supported_language="es")
        self.dni_re = re.compile(r"\b\d{8}[\s\-]?[A-Z]\b")
        self.nie_re = re.compile(r"\b[XYZ][\s\-]?\d{7}[\s\-]?[A-Z]\b")
        self.cif_re = re.compile(r"\b[A-HJ-NP-SUVW][\.\ -]?\d{2}[\.\ -]?\d{6}\b|\b[A-HJ-NP-SUVW]-?\d{7}-?[A-Z0-9]\b")
    def load(self): pass
    def _clean(self, v): return v.upper().replace("-","").replace(" ","").replace(".","")
    def analyze(self, text, entities, nlp_artifacts=None, regex_flags=None):
        results = []
        for m in self.dni_re.finditer(text):
            if verificar_dni(self._clean(m.group(0))):  # [!code highlight]
                results.append(RecognizerResult("ES_DNI", m.start(), m.end(), 1.0))
        for m in self.nie_re.finditer(text):
            if verificar_nie(self._clean(m.group(0))):  # [!code highlight]
                results.append(RecognizerResult("ES_NIE", m.start(), m.end(), 1.0))
        for m in self.cif_re.finditer(text):
            if verificar_cif(self._clean(m.group(0))):  # [!code highlight]
                results.append(RecognizerResult("ES_CIF", m.start(), m.end(), 1.0))
        return results

PIIEntities — schema de salida estructurada

Para que el LLM local devuelva resultados estructurados y no texto libre, definimos primero el schema Pydantic. Gemma tendrá que devolver exactamente estas cuatro listas — sin texto libre que parsear ni post-processing.

class PIIEntities(BaseModel):  # [!code highlight]
    personas:       list[str] = Field(default_factory=list, description="Full names of persons")
    organizaciones: list[str] = Field(default_factory=list, description="Company/organization names")
    direcciones:    list[str] = Field(default_factory=list, description="Full postal addresses")
    ubicaciones:    list[str] = Field(default_factory=list, description="Cities, provinces, countries")

GemmaLLMRecognizer — el LLM detecta lo que el regex no puede

Los identificadores estructurados (DNI, email, teléfono) se capturan bien con regex. Personas, organizaciones y direcciones son otro problema: dependen de contexto. "María López García" es PII; "Madrid" casi siempre no lo es. Este recognizer llama a Gemma en Ollama con with_structured_output(PIIEntities): el LLM devuelve directamente las cuatro categorías del schema.

# [!code word:PIIEntities]
class GemmaLLMRecognizer(EntityRecognizer):
    LABEL_MAP = {"personas":"PERSON","organizaciones":"ORGANIZATION",
                 "direcciones":"ADDRESS","ubicaciones":"LOCATION"}
    SYSTEM_PROMPT = (
        "Extrae toda la información personal identificable (PII) del texto. "
        "Solo nombres reales de personas, empresas, lugares y direcciones postales. "
        "No incluyas términos legales, códigos técnicos ni fórmulas. "
        "Ignora etiquetas HTML y sintaxis markdown."
    )
    def __init__(self):
        super().__init__(supported_entities=list(set(self.LABEL_MAP.values())), supported_language="es")
        self._cache = {}
        self._llm = None

    def _get_llm(self):
        if self._llm is None:
            self._llm = ChatOllama(      # [!code highlight]
                base_url=OLLAMA_URL,     # [!code highlight]
                model=LLM_MODEL, temperature=0, num_ctx=16384,
            ).with_structured_output(PIIEntities)
        return self._llm

    def load(self): pass

    def _clean_html(self, t):
        t = re.sub(r"<[^>]+>", " ", t)
        t = re.sub(r"!\[.*?\]\(.*?\)", "", t)
        t = re.sub(r"\[.*?\]\(.*?\)", "", t)
        return re.sub(r"\s+", " ", t).strip()

    def _call_llm(self, text):
        key = hash(text)
        if key in self._cache: return self._cache[key]
        try:
            result = self._get_llm().invoke([
                {"role": "system", "content": self.SYSTEM_PROMPT},
                {"role": "user",   "content": self._clean_html(text)},
            ])
            data = result.model_dump()
        except Exception:
            data = {}
        self._cache[key] = data
        return data

    def analyze(self, text, entities, nlp_artifacts=None, regex_flags=None):
        data = self._call_llm(text)
        results, seen = [], set()
        for key, mapped in self.LABEL_MAP.items():
            if mapped not in entities: continue
            for value in data.get(key, []):
                if not isinstance(value, str) or len(value) < 4 or value in seen: continue
                seen.add(value)
                idx = text.find(value)
                if idx != -1:
                    results.append(RecognizerResult(mapped, idx, idx + len(value), 0.85))
        return results

Reconocedores de patrón y motor Presidio

Tres reconocedores de patrón para email, teléfono y código postal. AnalyzerEngine registra los cinco recognizers y los orquesta en cada llamada a analyzer.analyze().

email_rec = PatternRecognizer(supported_entity="EMAIL_ADDRESS", supported_language="es",
    patterns=[Pattern("email", r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 0.9)])
url_rec = PatternRecognizer(supported_entity="URL", supported_language="es",
    patterns=[Pattern("url", r"(?:https?://|www\.)[^\s,]+", 0.5)])
phone_rec = PatternRecognizer(supported_entity="PHONE_NUMBER", supported_language="es",
    patterns=[Pattern("phone_es", r"\b(?:\+34[\s.-]?)?(?:\d{3}[\s.-]?\d{3}[\s.-]?\d{3}|\d{2}[\s.-]?\d{3}[\s.-]?\d{2}[\s.-]?\d{2})\b", 0.7)])
cp_rec = PatternRecognizer(supported_entity="POSTAL_CODE", supported_language="es",
    patterns=[Pattern("cp_es", r"\b(?:0[1-9]|[1-4]\d|5[0-2])\d{3}\b", 0.1)],
    context=["CP", "C.P.", "código postal", "Madrid", "Barcelona", "Sevilla", "Valencia"])

registry = RecognizerRegistry(supported_languages=["es"])
registry.add_recognizer(SpanishIDRecognizer())
registry.add_recognizer(GemmaLLMRecognizer())
registry.add_recognizer(email_rec)
registry.add_recognizer(url_rec)
registry.add_recognizer(phone_rec)
registry.add_recognizer(cp_rec)

nlp_config = {"nlp_engine_name": "spacy",
              "models": [{"lang_code": "es", "model_name": "es_core_news_sm"}]}
analyzer = AnalyzerEngine(
    registry=registry,
    nlp_engine=NlpEngineProvider(nlp_configuration=nlp_config).create_engine(),
    supported_languages=["es"],
)

Paso 3 — Anonimización reversible en local

Una vez detectadas las entidades, el flujo no las elimina sin más. Las sustituye por tokens estables y numerados. La clave es la palabra reversible: el mapeo entre token y valor original se guarda en local. El modelo mantiene el contexto lógico del documento; el despacho puede reconstruir la respuesta final sin exponer el dato al proveedor.

class ReversibleAnonymizer:
    """Anonimiza con tokens numerados y guarda el mapeo para revertir."""
    def __init__(self):
        self.mapping   = {}   # token  -> valor original
        self._counters = {}   # entity_type -> counter
        self._seen     = {}   # valor original -> token

    def _get_token(self, entity_type, original):
        if original in self._seen: return self._seen[original]
        count = self._counters.get(entity_type, 0) + 1
        self._counters[entity_type] = count
        token = f"<{entity_type}_{count}>"
        self.mapping[token] = original
        self._seen[original] = token
        return token

    def anonymize(self, text, analyzer_results):  # [!code focus]
        # Derecha a izquierda para no romper offsets  # [!code focus]
        sorted_r = sorted(analyzer_results, key=lambda x: x.start, reverse=True)  # [!code focus]
        out = text  # [!code focus]
        for r in sorted_r:  # [!code focus]
            original = text[r.start:r.end]  # [!code focus]
            token    = self._get_token(r.entity_type, original)  # [!code focus]
            out      = out[:r.start] + token + out[r.end:]  # [!code focus]
        return out  # [!code focus]

    def deanonymize(self, text):
        out = text
        for token, original in self.mapping.items():
            out = out.replace(token, original)
        return out

rev_anonymizer = ReversibleAnonymizer()

# Detectar y anonimizar
results         = analyzer.analyze(text=content, language="es", score_threshold=0.25)
anonymized_text = rev_anonymizer.anonymize(content, results)

print(f"Detectadas {len(results)} entidades")
for token, original in rev_anonymizer.mapping.items():
    print(f"  {token} -> {original}")

El resultado concreto con un fragmento de nota simple:

Titular: María López García, DNI 12345678Z, actuando en representación de
Bufete Martínez & Asociados S.L. (CIF B58492031), con domicilio en
Calle Gran Vía, 28, 4.º B, 28013 Madrid.
Contacto: m.lopez@bufete-ma.es  ·  +34 612 345 678

El pipeline detecta las entidades y construye el mapeo local:

Detectadas 6 entidades
  <PERSON_1>        -> María López García
  <ES_DNI_1>        -> 12345678Z
  <ORGANIZATION_1>  -> Bufete Martínez & Asociados S.L.
  <ES_CIF_1>        -> B58492031
  <ADDRESS_1>       -> Calle Gran Vía, 28, 4.º B, 28013 Madrid
  <EMAIL_ADDRESS_1> -> m.lopez@bufete-ma.es
  <PHONE_NUMBER_1>  -> +34 612 345 678

Ese es el texto que sale hacia el LLM cloud. El mapeo queda en local; el proveedor nunca lo ve.

Titular: <PERSON_1>, DNI <ES_DNI_1>, actuando en representación de
<ORGANIZATION_1> (CIF <ES_CIF_1>), con domicilio en <ADDRESS_1>.
Contacto: <EMAIL_ADDRESS_1>  ·  <PHONE_NUMBER_1>

Paso 4 — LLM cloud solo sobre texto redactado

En este punto sí puedes llamar a un modelo cloud. Como capa adicional, PIIMiddleware actúa de guardrail: si algo sensible se cuela antes del envío, lo redacta. No es la frontera principal — esa es la anonimización local previa — pero es la red de seguridad.

model = ChatOpenRouter(
    model="z-ai/glm-5.1",
    temperature=0.8,
    api_key=os.environ.get("OPENROUTER_API_KEY"),
)

def detect_pii(text: str) -> list[dict]:
    hits = analyzer.analyze(text=text, language="es", score_threshold=0.25)
    return [{"text": text[r.start:r.end], "start": r.start, "end": r.end} for r in hits]

agent = create_agent(
    model=model,
    tools=[],
    middleware=[PIIMiddleware("pii", detector=detect_pii, strategy="redact")],  # [!code highlight]
)

result = agent.invoke({
    "messages": [
        {"role": "system", "content": "Analiza el documento anonimizado. Describe riesgos e inconsistencias."},
        {"role": "user",   "content": anonymized_text},
    ]
})

# Deanonimizar antes de devolver al equipo
llm_response  = result["messages"][-1].content
deanonymized  = rev_anonymizer.deanonymize(llm_response)  # [!code highlight]
print(deanonymized)

Por qué este diseño sí es defendible en RGPD

El flujo correcto no "esquiva" el RGPD. Lo aplica por diseño. En vez de discutir en abstracto si la IA es segura, reduces la exposición del dato antes de que exista la llamada a terceros.

El art. 32 del RGPD no habla de "usar o no usar IA": habla de aplicar medidas técnicas y organizativas apropiadas al riesgo. Entre esas medidas cita expresamente la seudonimización y el cifrado, y la capacidad de garantizar confidencialidad, integridad, disponibilidad y resiliencia del tratamiento. Si el modelo cloud nunca ve el dato real porque el texto sale redactado, el diseño se acerca mucho más a lo que ese artículo exige que si se envía el expediente crudo.

La misma guía de la AEPD aterriza la minimización del art. 5.1.c RGPD para este caso: los datos tienen que ser adecuados, pertinentes y limitados a lo necesario, y en IA eso se traduce en técnicas como la anonimización y la seudonimización, no solo al comunicar datos, sino también en entrenamiento, modelo e inferencia. Un flujo que redacta el texto antes de llamar al LLM no es un apaño: es una aplicación directa de minimización.

La guía de auditoría de la AEPD para tratamientos que incluyen IA va un paso más allá y exige aplicar criterios de minimización distintos según la etapa del componente, usando estrategias de ocultación, separación, abstracción, anonimización y seudonimización. Exactamente eso hace un flujo que separa OCR, detección de PII, anonimización reversible y llamada cloud.

Dónde fallan las guías genéricas

La mayoría de guías sobre "usar IA con documentos" se quedan cortas en tres puntos.

El primero es España. Hablan de PII en abstracto, pero no de DNI, NIF, NIE y CIF con validación real. En documentación jurídica y fiscal española eso no es un detalle: es la diferencia entre detectar un identificador de verdad o dejar pasar uno crítico.

El segundo es la frontera técnica. Muchas recomendaciones se conforman con poner un aviso o una política interna. Eso no cambia nada si el PDF crudo sigue viajando al proveedor. La política no sustituye a la arquitectura.

El tercero es la secuencia. OCR, extracción, redacción, inferencia y deanonimización no son el mismo paso. Si mezclas todo y mandas primero el documento al modelo para "ver qué saca", ya has perdido el control del dato. El orden correcto importa.

Preguntas frecuentes

¿ChatGPT guarda mis documentos?

Si subes el documento real a un proveedor cloud, estás abriendo un flujo de datos a terceros. La AEPD advierte que puede haber comunicación de datos, almacenamiento o incluso modificación del modelo según el servicio. La pregunta útil no es si la interfaz parece cómoda, sino por qué el expediente real ha llegado a verla.

No como reflejo automático de "subir PDF y pedir resumen". Puede ser defendible usar un LLM cloud sobre texto ya desidentificado, con el proveedor correctamente encuadrado, medidas del art. 32 aplicadas y la capa identificativa resuelta antes en local.

¿Qué pasa si mando un DNI a ChatGPT?

Has enviado un identificador personal real a un tercero. Por eso la capa local de PII debe bloquear DNIs, NIEs, NIFs, CIFs, nombres y direcciones antes de la llamada al modelo. Si en tu despacho eso ocurre fuera del diseño previsto, hay que revisar el flujo y la política de uso de inmediato.

¿Funciona con cualquier LLM cloud?

El paso cloud es intercambiable — el ejemplo usa OpenRouter pero puedes sustituirlo por cualquier proveedor. Lo que no es intercambiable es la capa previa: OCR local, detección de PII local y anonimización local. Eso es lo que decide si el dato real sale de tu entorno o no.

Cómo lo resolvemos en Gradion

En este tipo de proyecto, el primer entregable útil no es el prompt. Es el mapa del dato que nunca debe salir: qué campos se quedan en local, qué recognizers hacen falta para España, dónde se guarda el mapeo reversible, qué logs necesitas y en qué punto exacto se permite la llamada al proveedor.

En los proyectos que hemos realizado hasta la fecha, el primer bloqueo rara vez está en el OCR o en el prompt. Suele estar en no haber separado antes los identificadores personales del texto que el modelo necesita razonar. La observación que más se repite: el despacho no suele necesitar "más IA". Suele necesitar una frontera mejor diseñada entre el expediente real y la capa de razonamiento. Cuando esa frontera existe, tu equipo sigue revisando, decidiendo y firmando. La IA solo prepara el trabajo repetitivo.

¿Tu equipo pierde horas en papeleo? Cuéntanos tu caso →

Read more