Los organizadores llaman a nuestro socket, interpretan a un paciente, y nosotros enviamos la acción que habríamos hecho: reservar, registrar, cambiar, cancelar, no hacer nada o escalar. Acierta o falla: no hay nota parcial. Esta página explica cómo está hecho, qué decidimos y qué no sabemos todavía.
Una clínica pierde citas porque nadie coge el teléfono a la vez que atiende el mostrador. Vortex atiende esas llamadas: identifica a quien llama, lee su historia en el sistema de la clínica, decide qué cita cabe según las reglas, y deja constancia de lo que ha hecho.
No es un chatbot con voz. Es un sistema de decisión al que le hemos puesto voz: la parte difícil no es hablar bonito, es no equivocarse de paciente, de médico, de centro o de minuto, y saber decir «no» cuando la regla de la clínica lo exige.
Nunca enviar nada es el peor resultado posible. Una llamada sin registro enviado es un caso intentado y fallado, y desde fuera un agente que ha petado y una negativa correcta se ven igual.
Por eso cada llamada acaba, sí o sí, con una acción enviada. Si no se puede reservar, se envía NO_ACTION con el motivo tipado que nombra la regla que ha mordido. Esa decisión aparece luego en casi todas las demás: en la escalera de reintentos, en el fallback al cerrar el socket, en las herramientas que devuelven tipos y no frases.
Está escrita como regla dura del equipo en CLAUDE.md, no es una intención.
No hay número de teléfono en nuestro lado. La plataforma abre un WebSocket y nos manda audio de teléfono en el formato de Twilio Media Streams. Cada conexión monta su propio agente completo y no comparte nada con las demás.
plataforma ──ws──> line/server.py ── CallSession (una por socket)
│ ├─ ToolContext: call_id, ahora (Madrid), clínica, log, submitter
│ └─ pipeline de voz: Soniox → LLM → TTS
▼
conversation/ prompt + turnos
▼
tools.py ── call_tool(nombre, ctx, args) ──> identity/ diary/ rules/
│
clinic/ (API de solo lectura)
▼
line/submit.py ── POST /api/v1/submit/<acción> ── dentro de los 30 s tras cerrar
▼
observability/ logs/calls.jsonl (un JSON por evento, con su call_id)
| Etapa | Qué hace | Dónde | Estado |
|---|---|---|---|
| Entrada | Acepta el socket y espera el mensaje start de Twilio. | line/server.py, line/twilio.py | Hecho |
| Sesión | Crea una CallSession con su contexto: reloj de Madrid, cliente de la clínica, log y cliente de envío. | line/session.py | Hecho |
| Oído | Transcribe en tiempo real con identificación de idioma y vocabulario de clínica reforzado. | Soniox stt-rt-v5 | Hecho |
| Cabeza | Un modelo de lenguaje con el prompt de la clínica y un juego cerrado de herramientas. No improvisa datos: los pide. | conversation/prompt.py, models.py | Hecho |
| Manos | Las herramientas leen la API de la clínica y devuelven datos tipados o un rechazo tipado. Nunca prosa. | tools.py → identity/ diary/ rules/ | Hecho |
| Voz | Habla en español con Chirp 3 HD y en catalán, gallego y euskera por el proveedor alternativo. Un filtro revisa lo que va a decir antes de decirlo. | line/pipecat_voice.py, line/privacy.py | Hecho |
| Constancia | POST de la acción a la plataforma dentro de la ventana de 30 s tras cerrar el socket, y una línea JSON por evento. | line/submit.py, observability/ | Hecho |
Somos cinco personas trabajando un fin de semana. Para no pisarnos, el código está dividido en carriles con una sola superficie compartida: vortex/contract.py, que congela la firma de cada herramienta, las seis acciones y los motivos de rechazo.
Una herramienta se puede reescribir sin tocar el resto, porque su firma no cambia. Eso es lo que nos ha permitido arreglar fallos de puntuación en paralelo durante la noche.
Cada llamada está limitada a tres minutos. Este es el camino completo, desde que suena hasta que queda constancia. Los nombres en monoespaciado son los eventos reales que se escriben en el log, así que esta lista se puede seguir mirando la consola.
Ningún proveedor está escrito en el código. Cambiar de modelo, de voz o de transcriptor es editar el .env y reiniciar. Eso nos ha permitido cambiar de modelo tres veces en una noche sin abrir un pull request.
| Pieza | Qué usamos | Variable | Estado |
|---|---|---|---|
| Transporte | WebSocket propio en formato Twilio Media Streams, µ-law 8 kHz, tramas de 20 ms | VORTEX_WS_PATH | En producción |
| Transcripción | Soniox stt-rt-v5, con identificación de idioma y términos de clínica reforzados | SONIOX_API_KEY | En producción |
| Modelo | Cualquier modelo compatible con la API de OpenAI. Hoy, por preajuste helmcode | LLM_PROVIDER, LLM_MODEL | En producción |
| Modelo de reserva | Un segundo modelo al que se reintenta si el primero se cuelga | LLM_ALT_MODEL | Hecho |
| Voz, español | Google Chirp 3 HD | VORTEX_TTS_PROVIDER | En producción |
| Voz, ca / gl / eu | Proveedor alternativo, enrutado por idioma detectado | VORTEX_TTS_PROVIDER_ALT | Hecho |
| Voz a voz | Gemini Live, un único modelo que oye y habla | VORTEX_VOICE_MODE=gemini-live | Solo demo |
| Clínica | API de solo lectura de la plataforma; sin clave, datos de prueba locales | PLATFORM_API_KEY | En producción |
| Despliegue | Contenedor en el servidor del equipo, publicado por Traefik con HTTPS; se levanta solo tras un reinicio | deploy/compose.yaml | En producción |
| Trazas | Langfuse, con lista blanca de campos | LANGFUSE_* | Hecho |
Si falta la clave de la plataforma, se usa la clínica de prueba y un cliente de envío que escribe en el log en vez de hacer el POST. Si faltan las claves de voz, corre un pipeline de relleno que cuenta tramas y envía una negativa tipada al final. GET /health dice en qué modo está.
Suena a detalle, pero es lo que permite que cinco personas ejecuten toda la suite y una llamada completa en su portátil, sin red y sin gastar un céntimo, mientras la clave de verdad está en un solo sitio.
Toda decisión de arquitectura renuncia a algo. Estas son las nuestras y lo que hemos sacrificado en cada una. Ninguna es gratis y ninguna es irreversible.
| Decisión | Por qué | Qué sacrificamos |
|---|---|---|
| Cascada (oír → pensar → hablar) en vez de voz a voz | Los modelos de voz a voz están por debajo del 52 % en tareas agénticas con herramientas. En una reserva el error no es un matiz de tono: es el paciente equivocado. LiveKit, Coval y Deepgram recomiendan la cascada para este caso. | Latencia y naturalidad. La cascada suma el retardo de tres servicios. Gemini Live queda como demostración para el jurado y nunca puntúa. |
| Soniox como transcriptor | Es la única opción barata con español y catalán nativos, identificación de idioma, detección semántica de fin de turno y entrada µ-law de 8 kHz directa. | Alternativas con mejor detección de turno, pero sin catalán. Cambiar tiraría a la basura todo el ajuste de vocabulario de clínica. |
| Las herramientas devuelven tipos o un rechazo tipado. Nunca prosa | El motivo que devuelve la herramienta es literalmente el motivo que enviamos. Si la herramienta devolviera una frase, el modelo tendría que interpretarla para encontrar el motivo, y ahí se pierde el punto. | Flexibilidad. Un caso nuevo exige un motivo nuevo en el contrato, no una frase distinta. |
| Los identificadores salen siempre de la API | Se comparan carácter a carácter. Un identificador adivinado falla el caso incluso cuando la hora es la correcta. | Una llamada de red más en cada paso, y un agente que no puede «recordar» lo que no ha consultado. |
| Una sesión completa por socket, sin nada compartido | Un Run All abre diez sockets a la vez y el reto 2 abre veinte. Compartir una conversación, un contador o una llamada en vuelo es exactamente el fallo que el reto busca. | Memoria: cada llamada paga su propio pipeline. El contenedor está limitado a 4 GB y eso hay que vigilarlo. |
| Escalera de fallback al cerrar | Nunca enviar nada es el peor resultado. Antes de rendirse, la escalera prueba lo preparado, el último rechazo tipado y una reserva en frío a partir del historial. | Riesgo de enviar algo peor que el silencio. Lo aceptamos: el silencio nunca es más barato que una respuesta equivocada, porque puntúan igual. |
| Proveedores por variable de entorno | Cambiar de modelo o de voz es editar el .env y reiniciar, no un cambio de código con revisión. | Más superficie de configuración de la que nadie ha verificado entera. Dos preajustes de modelo siguen marcados como no verificados en el README. |
| Sin juez basado en modelo en el camino de cada PR | Las comprobaciones deterministas primero. Un juez de modelo mete varianza justo donde queremos una señal estable. | No medimos el tono ni la calidad conversacional automáticamente. Eso queda en juicio humano. |
| pass^k en lugar de pass@1 | Un escenario que pasa el 70 % de las veces es un escenario que falla en una tanda de 68 llamadas. Solo cuenta como aprobado si pasa las k veces. | Cuesta k veces más tiempo y dinero por medida. |
Manejamos nombres, documentos de identidad, teléfonos e historial clínico. Y una sola clave de API que si se filtra hay que rotar en el mostrador para los cinco.
Dependemos de tres servicios externos y de una red. Todos fallan alguna vez. La pregunta de diseño no es «cómo evitamos el fallo» sino «qué envía la llamada cuando falla».
| Si falla… | Qué hace el sistema | Estado |
|---|---|---|
| El modelo tarda o se cuelga sin soltar el primer token | Hay un plazo para el primer token. Al vencer, reintenta; si se configura un modelo alternativo, reintenta con él; si todo falla, el agente dice una frase de espera en el idioma del paciente en vez de quedarse mudo. Queda como llm.timeout y llm.retry. | Hecho |
| Una herramienta tarda | El agente dice «un momento» en cuanto arranca la llamada a herramienta, para que el silencio no se lea como línea muerta. | Hecho |
| La búsqueda por número de origen no responde | Tope de 2 segundos y se sigue sin ella. No bloquea el saludo. | Hecho |
| El POST de la acción falla | Tiempo límite de 8 segundos y cada código tratado por separado: aceptado, duplicado, ventana cerrada, llamada desconocida, campos inválidos. Un duplicado se trata como éxito, no como error. | Hecho |
| La llamada termina sin haber enviado nada | La escalera de ocho ramas, con una reserva en frío antes de rendirse. Dentro de los 30 segundos, con 5 de margen. | Hecho |
| El pipeline lanza una excepción | Se anota call.crashed y el cierre sigue ejecutándose: un agente que ha petado también envía su acción. | Hecho |
| El servidor se cae o se reinicia la máquina | Corre como contenedor con reinicio automático, detrás de Traefik con certificado propio. Ya no depende del portátil de nadie ni de un túnel. | En producción |
| Se pierde la conexión a mitad de llamada | El cierre intenta enviar igualmente. Aun así, es nuestro fallo abierto más caro: los dos casos que perdimos en el mejor run traen la señal connection_lost. | Sin resolver |
La plataforma puntúa un Run All cada media hora aproximadamente y nunca dice qué campo hemos fallado. Medir allí es lento y ciego. Así que medimos aquí.
Cada llamada escribe un JSON por evento, todos etiquetados con su identificador de llamada: inicio, cada turno del paciente y del agente, cada herramienta llamada y lo que devolvió, el envío y su resultado, el consumo y el fin.
Sobre ese fichero se construye todo lo demás: la consola, el muro del jurado, la ficha de una llamada y las métricas. No hay una segunda fuente de verdad.
| Capa | Qué prueba | Clínica | Qué vale |
|---|---|---|---|
| 1 · Lógica | Una herramienta, o un flujo de herramientas, aislada. 132 casos. | De prueba | Forma, no verdad. En cada PR. |
| 2 · Conversación | Un paciente guionizado, turno a turno, en texto. 57 escenarios con correcciones, interrupciones, silencios y terceras personas. | De prueba | Prueba que el prompt conduce las herramientas. Ciego al audio. |
| 3 · Voz | Proveedores reales sobre las mismas frases, limpias y con ruido, tras un ciclo telefónico de 8 kHz. Latencia percibida, tasa de error por idioma, coste. | — | Cuesta dinero de verdad. Fuera del camino de PR, a propósito. |
| 4 · Corpus | Un juez que replica el de la plataforma, sobre una copia real de la clínica. | Real | La única capa con verdad de campo. Las 73 respuestas oficiales pasan por él y las 130 reservas publicadas se reproducen contra la clínica real. |
| 5 · Banco de modelos | Los modelos candidatos jugando los mismos escenarios, con el prompt y las herramientas reales, y el enrutado que corre hoy al lado. | De prueba | Convierte «qué modelo usamos» en un número en vez de una costumbre. |
Hoy, no. Nuestras capas comprueban condiciones necesarias; ninguna comprueba la suficiente. Las capas 1, 2 y 5 corren contra una clínica de prueba con pacientes inventados, así que un escenario en verde dice que el prompt sabe conducir las herramientas, no que el agente reservaría el paciente correcto en el hueco que el caso acepta.
Lo que sí está sólido es el juez de la capa 4: si dice que un registro pasa, la plataforma dice que pasa. Y la copia de la clínica es real. Lo que falta es el puente: pasar los 73 casos oficiales por el agente y juzgar el resultado. Está diseñado y documentado; no está construido.
Esto está escrito así, con estas palabras, en docs/NEXT-STEPS.md desde antes de que nadie nos lo preguntara. Lo contamos porque es la diferencia entre un equipo que mide y un equipo que cree.
El marcador guarda el mejor Run All de cada equipo, no el último ni la suma. Estos son los números tal como los devuelve el panel de la plataforma, leídos a las 11:58 del sábado.
18 de los 20 casos privados superados en el mejor run. Y no fue una casualidad: tres runs distintos llegaron a la misma marca, a las 06:25, a las 08:06 y a las 11:30 de la mañana. Repetir un resultado tres veces con semillas privadas diferentes es la mejor prueba que tenemos de que el sistema, cuando funciona, funciona por diseño.
Los dos casos perdidos en el mejor run valían 2 puntos cada uno y traen las mismas dos señales: connection_lost y record_mismatch. La plataforma los atribuye como no concluyentes, lo que significa que no puede decidir si el fallo fue nuestro o suyo.
Inferencia Nuestra lectura es que la conexión se cortó antes de que el registro correcto llegara a salir, y el fallback envió una acción que no coincidía. Es la misma familia de fallo en los tres runs de 36 puntos, así que es un solo defecto, no cuatro problemas distintos.
Sobre los 347 casos juzgados en todos los runs, hemos pasado 171. El mejor run vale 36; la media está muy por debajo. Esa distancia es varianza, y es nuestro problema abierto número uno.
De dónde viene: un bucle de mejora automática corrió durante la noche optimizando contra cuatro casos privados por problema, sin ninguna barrera local que detuviera una regresión. Nos subió a lo más alto y luego nos bajó. La lección está escrita: el Run All es el último paso de la comprobación, nunca el primero.
Decisión, no dato Que no lancemos más Run All es una decisión del equipo en el momento de escribir esto, no un límite de la plataforma. Los datos que sí son hechos: el marcador conserva el mejor run, abrir retos nuevos nunca baja una puntuación anterior, y solo cuentan los runs terminados antes del muro del domingo a las 06:00.
Dicho de otra forma: 36/40 está guardado y no se puede perder. Lanzar otro run solo puede subirlo o no cambiarlo. Si la decisión es no lanzar más, es por dónde queremos gastar las horas que quedan, no porque el marcador esté cerrado.
El reto 2 es el reto 1 repetido cinco, diez o veinte veces al mismo tiempo. No da puntos y el Run All no lo marca nunca: hay que dispararlo a mano. Pero un socket que mezcla estado entre llamadas no falla un caso, falla el run entero.
El domingo hay una segunda puntuación que no es automática: el mismo panel llama a todos los equipos. La columna de la izquierda son los criterios tal como están publicados, sin añadir ni interpretar. La de la derecha es lo nuestro: qué le ponemos delante para cada uno.
Hay una frase en las bases que ordena toda la preparación: un criterio que el jurado no puede observar no puntúa. Así que nada de contar lo que hay; hay que poder mostrarlo.
| Criterio publicado | Qué le ponemos delante |
|---|---|
| Experiencia del paciente | Que llamen ellos. Saludo inmediato sin esperar al modelo, «un momento» mientras consulta, y colgado limpio al confirmar. Si se quedan callados, el agente les empuja suave en vez de dejar la línea muerta. |
| Lo personal que resulta (usar la ficha y el historial antes de preguntar) | La búsqueda por número de origen ocurre antes del saludo. Si el número está en el directorio, el agente ya sabe quién llama y no le hace repetir su nombre ni su documento. Es el detalle que más se nota por teléfono. |
| La plataforma alrededor del agente (consola en vivo, «¿por qué dijo eso?») | El muro en el proyector mientras hablan: transcrito, traza de herramientas y veredicto. Y la respuesta a «por qué dijo eso» es una columna, no una explicación: el motivo tipado con su frase. La ficha de la llamada se comparte por enlace. |
| Seguridad y límites | Que intenten sacarle un dato de otro paciente. El filtro anterior al sintetizador bloquea documentos y teléfonos en la voz, y queda registrado el bloqueo. Que le pidan algo fuera de alcance: responde NO_ACTION(out_of_scope), no improvisa. |
| Manejo de idiomas | Que llamen en catalán, gallego o euskera. La identificación de idioma va en la transcripción y la voz se enruta al proveedor que sabe hablarlo. Es la razón por la que elegimos el transcriptor que elegimos, y se puede contar en una frase. |
| Rigor de ingeniería (arnés de evaluación, varianza, fallos con nombre, coste por llamada) | Cinco capas de evals con un juez verificado contra el de la plataforma; 18 modelos comparados con número y página publicada; el coste por llamada calculado del consumo real; y la varianza contada como lo que es: 36/40 en el mejor run, 49,3 % de media, y el motivo identificado. |
| Discreción | Teléfonos enmascarados en todo lo público, exportación de trazas con lista blanca, claves que no salen ni en el /health, y un log operativo que no sale de la máquina. |
Con la respuesta que daríamos. Sin adornos: si algo no lo sabemos, la respuesta dice que no lo sabemos.
Todo lo que sabemos que no está hecho o no está verificado, en un solo sitio. Si el jurado encuentra algo que no está en esta lista, hemos fallado en esta sección.