Saltar al contenido principal
Jordi Izquierdo Montalbán
Proyectos

Especialista

Un tutor para el personal de una clínica: responde sobre sus propios protocolos, guías y fármacos, y cada frase cita el documento y el apartado del que sale. Dos guardias deterministas impiden que decida sobre un paciente, y se le puede preguntar hablando: responde en voz alta con un avatar 2D que solo lee lo ya verificado.

Código privado: te lo enseño en una entrevista

LangGraph, NLP, FastAPI, ChromaDB, Neo4j, PostgreSQL, Azure OpenAI, Docker, Python, LangChain (core), BM25 propio, text-embeddings-inference, faster-whisper, Piper TTS, LivePortrait, MediaPipe, Web Audio API, Canvas, Starlette, psycopg, Pydantic, PyJWT, pdfplumber, pypdf, uvicorn, structlog, age, uv, pytest, vitest, mypy, import-linter, Ruff, pre-commit, GitHub Actions, Vite, TypeScript

El problema

El personal sanitario consulta constantemente protocolos, guías clínicas y fichas de fármacos. Un chatbot genérico es peligroso aquí por dos motivos: se inventa cosas con total seguridad, y no distingue entre explicar lo que dice una guía y decirle a un médico qué darle a su paciente.

Compara estas dos respuestas:

«Según la guía X, versión 2025, sección 4.2, se recomienda ajustar la dosis en pacientes con insuficiencia renal según este criterio.»

«A tu paciente dale 250 mg.»

La primera es una herramienta de conocimiento: te dice lo que dice la guía y tú decides. La segunda es un producto sanitario de alto riesgo. Todo mi diseño existe para no cruzar esa línea.

La solución

Un tutor conversacional que responde sobre protocolos, guías y fármacos y cita la fuente de cada afirmación. Esa línea roja no es un aviso legal al pie de la respuesta: son dos nodos deterministas del grafo. Un guardia de entrada corta la pregunta antes de que el modelo la vea si contiene datos de un paciente o pide una decisión individual, y un guardia de salida verifica el borrador antes de entregarlo.

El grafo del agente: dos guardias deterministas rodean al modelo, la recuperación combina tres fuentes y PostgreSQL guarda los checkpoints y la auditoría.

Recuperación híbrida con permisos dentro de la consulta

  • Tres fuentes combinadas: búsqueda vectorial en ChromaDB, BM25 léxico y un grafo de conocimiento en Neo4j para las preguntas relacionales («¿qué fármacos interactúan con…?»), fusionadas con Reciprocal Rank Fusion.
  • Los permisos viajan dentro de la consulta. Cada fragmento nace con su ámbito (scope) y la búsqueda solo toca los índices que el usuario puede ver. Filtrar después de recuperar sería una fuga de datos, así que lo hice imposible: scopes es un argumento obligatorio en toda firma de recuperación, y un test recorre todos los lectores (los reales y los falsos) comprobando que nunca devuelven nada fuera del ámbito.
  • Ontología cerrada para el grafo: tipos de nodo y relación fijos, con relaciones extraídas por reglas y un léxico con sinónimos. Cada relación guarda su cita y su versión del corpus.

Guardias que no dependen del modelo

  • guard_in normaliza el texto antes de comprobarlo (acentos, mayúsculas, caracteres invisibles, homoglifos, leet…) para que no se pueda sortear escribiendo raro. Lo probé con 53 casos adversarios y dejé su diseño y sus decisiones escritas en ADR.
  • guard_out exige una cita por afirmación y por cada línea con cifras, rechaza citas inventadas, enlaces, HTML y consejo individual. Si el borrador no pasa, el agente reintenta con los hallazgos concretos y, si se agotan los intentos, entrega una respuesta degradada en vez de una respuesta mala.
  • Cero capacidad de acción: el agente solo tiene herramientas de lectura, y un test falla si alguien registra una de escritura.

Respuestas que se pueden comprobar

  • Al abrir una cita se ve el párrafo exacto del documento del que sale, no solo su título.
  • Si los documentos no responden a la pregunta, lo dice: el modelo se abstiene con una señal fija y el usuario recibe «No he encontrado información sobre esto en los documentos a los que tienes acceso», en lugar de un resumen de fragmentos que no vienen a cuento ni una lista de fuentes que no se usaron.
  • Un documento no puede fabricar una cita: las marcas de referencia que traiga un texto (por ejemplo, la bibliografía de una guía) se neutralizan antes de llegar al modelo.

Arquitectura pensada para crecer en equipo

La diseñé para que pudiera desarrollarla un equipo sin romper nada. Por eso ninguna regla importante depende de que alguien se acuerde: todas tienen detrás un mecanismo que la comprueba y falla si no se cumple.

ReglaQué la comprueba
Nadie importa infraestructura fuera de adapters/ruff y 7 contratos de import-linter
Un adaptador cumple la firma de su puertomypy --strict y aserciones de conformidad
Una evidencia sin cita no existeCampo obligatorio de un modelo Pydantic congelado
El modelo nunca ve datos de pacienteArista condicional del grafo, antes del nodo que llama al modelo
Ninguna herramienta escribeRegistro de herramientas con test que falla

Arquitectura hexagonal (puertos con Protocol y adaptadores), una única raíz de composición que elige las implementaciones, y adaptadores falsos para todo: make check ejecuta lint, tipos, contratos de capas y tests sin red y sin claves.

Evaluación que bloquea la integración

La calidad no se comprueba a ojo. Un banco de evaluación propio mide, entre otras cosas, si se recuperó la evidencia correcta (recall@k), si la respuesta la cita, si se rechaza lo que hay que rechazar sin rechazar lo que no, y si algo se filtra fuera del ámbito del usuario. Tiene dos perfiles:

  • Sin red, en cada cambio: con modelo y recuperador locales y deterministas, umbrales exactos (cero fugas de ámbito, cero errores de turno) y un corpus de casos adversarios para los guardias. Si una métrica empeora, la integración falla.
  • Contra el modelo real, a mano, para medir la calidad que el perfil sin red no puede ver.

Despliegue, operación y auditoría

  • Un silo aislado por clínica, con su red interna, sus comprobaciones de salud y un manual de operación: alta de clínica, reindexado, rotación de claves y purga de versiones antiguas del corpus.
  • Copias de seguridad cifradas, comprobadas y firmadas, y una restauración que puede exigir la firma y guarda lo que va a sustituir antes de hacerlo.
  • Cada turno tiene un plazo y la API un tope de turnos simultáneos, para que una dependencia lenta no bloquee el servicio.
  • Una auditoría de seguridad completa (fase 11) cuyos hallazgos corregí uno a uno, cada uno con su test y su ADR. Los pocos casos que quedan abiertos están contados en la evaluación, para que no puedan aumentar sin que se note.

Voz y avatar

Se le puede preguntar hablando y responde en voz alta con un avatar 2D. Lo construí como una capa de presentación sobre el chat, sin tocar lo que lo hace seguro: lo dictado entra por el mismo turno, con los mismos guardias, permisos y auditoría, y solo suena lo verificado. El servidor firma cada frase con HMAC después de escribir el registro de auditoría, y el servicio de voz se niega a decir una frase sin firma.

  • Dictado: el micrófono solo se abre mientras se pulsa «hablar» y no guarda las muestras. Transcribe faster-whisper en la GPU, y el texto queda en la caja para corregirlo antes de enviarlo.
  • Lectura: Piper devuelve el audio y sus fonemas, y el avatar los convierte en 12 imágenes de boca y ojos con un reloj sacado del propio audio. También parpadea, respira y respeta prefers-reduced-motion.
  • Cifras sin ambigüedad: nunca se dicen dos cifras seguidas («5 mg por 150 ml» sonaba a «por ciento cincuenta»). Si una frase es ambigua, se queda sin voz.
  • Medido en una RTX 4060, de punta a punta y con seis preguntas: el primer audio llega en unos 0,3 s de mediana y la respuesta completa se oye en unos 2,3 s.

Stack desglosado

  • Agente: LangGraph, con checkpoints en PostgreSQL.
  • NLP: procesamiento del lenguaje natural para entender las preguntas del personal sanitario y recuperar la evidencia en guías, protocolos y fichas de fármacos.
  • Recuperación: ChromaDB, BM25 por ámbito, Neo4j y fusión RRF.
  • Modelos: Azure OpenAI (región europea) en producción, un proveedor compatible con OpenAI solo para pruebas, y los embeddings servidos por text-embeddings-inference de Hugging Face dentro del propio despliegue.
  • API: FastAPI, con identidad y permisos sacados del JWT, nunca del cuerpo de la petición.
  • Seguridad: registro de auditoría en PostgreSQL con roles separados y redacción de datos personales.
  • Infraestructura: Docker Compose con un silo aislado por clínica, uv y CI con GitHub Actions y cobertura mínima del 85 %.
  • Evaluación: banco propio que mide recall, tasa de cita, rechazos y fugas de ámbito, con un perfil sin red en la CI.
  • Voz y avatar: faster-whisper para reconocer y Piper para sintetizar, como servicios aislados sin salida a internet; el avatar se generó fuera de línea con LivePortrait y MediaPipe, y se anima en el navegador con Web Audio y Canvas.
  • Interfaz: chat web con Vite y TypeScript, sin framework, servido por la propia API.
  • Calidad: mypy --strict, Ruff, import-linter, pre-commit y logs estructurados con structlog.

Estado y aprendizajes

Dónde está: terminado (versión 0.11), con la integración continua en verde. Corre en local con Docker, un silo por clínica, y lo he probado con un corpus de cuatro documentos inventados. No es un sistema de apoyo a la decisión clínica: es una herramienta para consultar lo que dicen los documentos de la clínica.

Lo que estoy aprendiendo: a escribir las decisiones antes que el código. Cada decisión de arquitectura tiene su ADR, y cuando una cambia no se edita: se escribe otro que la sustituye. Así cualquiera que llegue al proyecto puede entender no solo qué está hecho, sino por qué.