Doctia: responde con citas y pide permiso antes de actuar
Un asistente que contesta solo con los documentos de la empresa, filtra en SQL lo que cada persona puede ver antes de que llegue al modelo, y pausa las acciones de riesgo hasta que alguien las aprueba.
La idea
En cualquier empresa alguien le pregunta a Recursos Humanos o a Finanzas algo que ya está escrito en un PDF que nadie encuentra. Un chatbot genérico no lo resuelve: inventa cuando no sabe, no distingue quién puede ver qué y, si le das herramientas, actúa sin control.
Doctia es mi respuesta a eso, y también mi forma de aprender AI Engineering construyendo: un asistente que contesta solo con los documentos de la empresa, cita el archivo del que sale cada dato, dice «no encontré» cuando no está, y puede ejecutar tareas —abrir un ticket, redactar un correo— con una persona aprobando las que tienen riesgo.
Lo armé en 13 lecciones, cada una con su bitácora y sus decisiones de arquitectura escritas.
Cómo está montado
- Búsqueda híbrida en una sola consulta SQL. Vectores (
<=>de pgvector) y búsqueda de texto completo en español, fusionados con Reciprocal Rank Fusion:1 / (60 + posición). Los embeddings capturan significado, pero no distinguen el contratoCT-2026-0457delCT-2026-0475; el texto completo sí. - Los permisos se filtran en SQL, antes del modelo.
f.grupos && grupos_del_usuarioen las dos ramas de la búsqueda, junto con el cliente. Lo que el modelo no recibe no lo puede filtrar mal ni revelar por una inyección. Sin usuario, el valor por defecto es el más restrictivo. - Fragmentos de 800 caracteres con 150 de solape, cortados en párrafo, línea u oración. Cada documento lleva un hash que incluye tamaño, solape, modelo y grupos, así que volver a cargar es idempotente.
- Aprobación humana con LangGraph. El grafo tiene tres nodos —modelo, aprobar y herramientas— y un checkpointer en Postgres. La pausa es durable: se puede aprobar horas después y desde otro proceso. El correo se valida contra los dominios permitidos antes de pedir aprobación, y nadie aprueba su propia solicitud.
- Un núcleo, un cliente por subdominio. Cada empresa es una carpeta con su configuración, sus documentos y sus herramientas; el subdominio decide cuál es, y SQL garantiza que los datos no se mezclen.
- Evaluaciones deterministas, sin un LLM como juez. Casos escritos a mano que miden si el fragmento correcto aparece, si la respuesta cita y si se abstiene cuando debe. Son reproducibles y no cuestan nada calificarlos.
El nodo que pausa el grafo es corto:
def nodo_aprobar(estado: Estado) -> dict:
# Solo las de riesgo que pasan la validación van al humano.
a_revisar = [
{"id": c["id"], "herramienta": c["name"], "argumentos": c["args"]}
for c in pendientes(estado)
if requiere_aprobacion(c["name"], ctx.cliente) and validar(c["name"], c["args"], ctx) is None
]
if not a_revisar:
return {}
# PAUSA. El grafo se guarda y devuelve el control.
decisiones = interrupt({"acciones": a_revisar})
return {"decisiones": {**estado.get("decisiones", {}), **decisiones}}
Lo que ya funciona
En local: chat con citas, permisos por grupo, tickets, correos que esperan aprobación, auditoría de cada acción, historial de conversaciones y varias empresas de demostración, cada una en su subdominio. Después del plan de lecciones sumé subir documentos desde la interfaz, un panel para el gerente y alertas de vencimientos.
Lo que todavía no
- No hay login real: la identidad se elige en un selector.
- El correo se simula: no sale nada a una bandeja real.
- La cita se exige por instrucciones y se mide en las evaluaciones, pero ningún código rechaza todavía una respuesta sin cita, ni verifica que cada cifra esté en el fragmento citado.
- Faltan OCR para PDF escaneados, streaming, trazas y las evaluaciones en CI.
Lo aprendido
Los embeddings entienden significado, no identificadores. Por eso la búsqueda pasó a ser híbrida. Y un detalle que costó encontrar: row_number() desempataba por orden de inserción y devolvía el contrato vecino; con rank() los empates reciben la misma posición.
La seguridad no puede depender del prompt, pero el prompt tiene que saber quién pregunta. Con un segundo cliente, el modelo se negaba a mostrarle a una socia un documento marcado como confidencial que SQL ya le permitía ver. El filtro siguió en SQL; el prompt ahora recibe los grupos del usuario.
Un 100 % en un examen fácil es una alerta, no un logro. Con pocos fragmentos, una métrica que no discrimina no dice nada; al medir también si el primer resultado era el correcto apareció el fallo real.
Siguiente paso
Una batería de robustez: inyección a través de documentos subidos, intentos de saltarse los permisos conversando, extracción del prompt de sistema, preguntas con premisa falsa y una métrica de fidelidad que compruebe que cada dato de la respuesta está en el fragmento citado. Después, la demo en línea.