Visión general
Componentes
- Backend (
Aura-reloaded): NestJS. Los adaptadores reciben la llamada (Retell WS, audio-fork WS o el HTTP de ElevenLabs) y delegan el turno al motor de conversación, que corre el RAG, arma el prompt y streamea el LLM. El broadcast de sugerencias/transcript al dashboard va por el WebSocket del copiloto. - Frontend (
aura-front): dashboard en tiempo real del agente/supervisor. - Ajolote: identidad, sesión y roles (ver Autenticación).
- PostgreSQL + pgvector: datos relacionales + las dos bases vectoriales del RAG.
Flujo 1 — Retell (voz IA)
Aura es el Custom LLM de Retell. Retell se conecta por WebSocket y Aura responde.
Además hay dos custom functions que Retell puede invocar: consult (RAG crudo sin LLM) y transfer (resumen de la llamada para derivar a un humano).
Flujo 2 — Audio-fork (Yeastar / FreeSWITCH)
El humano atiende; Aura escucha una copia del audio y sugiere. El audio llega vía un
tap-client (aura-tap-client) que captura el RTP del carrier, separa las dos voces por SSRC,
decodifica G.711 a PCM y lo manda estéreo al backend.
El agente toma (claim) la llamada desde el dashboard, ve la sugerencia y la valida (usar / incorrecta / descartar). El feedback alimenta el flywheel (ver RAG y Observabilidad).
Ciclo de vida de la transcripción (audio-fork)
AudioForkService maneja las sesiones en memoria. Puntos clave (varios de estos son
ajustables en caliente desde Ajustes del copiloto):
- Transcripción lazy + buffer: Deepgram no se abre al entrar la llamada. Mientras
nadie la reclama, el audio se guarda en un buffer rolling (
audioFork.bufferSeconds, def. 90s). Al reclamar, se abren las 2 sesiones STT y se hace backfill del buffer, así no se pierde lo que se dijo antes del claim. Una llamada no reclamada no cuesta STT ni se persiste ni aparece en el historial. El invariante es simétrico: al soltar la llamada (Soltar, cerrar sesión o barrido de claim huérfano) se cierran las 2 sesiones de Deepgram y el audio vuelve al buffer, así que tampoco cuesta STT mientras espera a que la tome otro. El transcript ya capturado se conserva y el que la reclame después recibe el backfill del hueco. - Deepgram: modelo
nova-3,linear16a 8 kHz (G.711 telefónico es 8 kHz),languageconfigurable (def.es). Se descartan los finales conconfidencepor debajo dedeepgram.minConfidence(def. 0.6) para filtrar alucinaciones sobre ruido/silencio. - Fusión de turnos: finales consecutivos del mismo hablante dentro de
audioFork.mergeGapMs(def. 4s) se unen en un solo turno. Cada turno persiste suts(hora de inicio). - Debounce de sugerencia: la generación RAG+LLM se dispara una sola vez por intervención,
tras
suggestion.debounceMs(def. 1.2s) de silencio del paciente, con el turno completo. Solo se generan sugerencias en llamadas reclamadas y con turnos de ≥suggestion.minWordspalabras — con la excepción de los turnos de datos del guion (ver más abajo). - Sugerencia en streaming: la respuesta del LLM se emite al agente incrementalmente a
medida que se genera (broadcasts parciales cada pocos tokens, reusando el mismo
id), en vez de esperar la generación completa. Baja la latencia percibida: el agente ve las primeras palabras a los ~200-400 ms en lugar de esperar ~1.3-1.6s el texto entero. En la DB y el event-log solo se persiste la sugerencia completa (los parciales no ensucian el historial ni las métricas). Mismo patrón que el flujo de Retell. - Barrido de sesiones muertas: si una llamada no recibe audio por
session.staleMs(def. 2 min) —p. ej. el tap-client se cayó sin cerrar—, se limpia (cierra Deepgram + persiste). - Fin de llamada + reconexión: el tap-client da por terminada la llamada por BYE o por
inactividad de RTP (~15s sin audio), no por la TTL del SIP (que expiraría llamadas largas).
El cierre del WS distingue: cierre limpio (1000) = fin real → el backend cierra la llamada
al toque; cierre abnormal (blip de red) = el backend espera un período de gracia (~12s)
y, si el tap reconecta con el mismo
call_id, la sesión continúa (transcript/claim/Deepgram intactos) en vez de cortar. El tap reconecta solo con backoff.
Guion de la llamada y datos del paciente
El copiloto no solo responde consultas técnicas: sigue la metodología del call center, que
tiene cuatro etapas. El prompt copilot_identity (ver
Prompts del agente) las describe y el LLM infiere del transcript en
cuál está, para sugerir solo el próximo paso:
- Saludo y presentación.
- El paciente describe su problema (acá entra la respuesta del manual vía RAG).
- Pedir nombre y apellido, y documento.
- Confirmar el número de WhatsApp (normalmente el mismo desde el que llama, así que se confirma en vez de pedir uno nuevo), avisando que le va a llegar un mensaje para que mande foto o video del problema.
Cuatro particularidades que esto impone:
-
Confirmación de números, sin inventar dígitos. La transcripción de dígitos dictados es poco confiable, así que un número dictado se confirma repitiéndolo de a un dígito antes de avanzar de etapa. Pero una confirmación con dígitos equivocados es peor que ninguna: el paciente puede contestar "sí" sin escuchar y el dato queda mal. Por eso el prompt prohíbe completar o corregir dígitos que no se dijeron: si la dictada vino confusa, la sugerencia es pedir que la repita número por número. El parser refuerza lo mismo descartando cantidades imposibles (un documento es de 7 u 8 dígitos).
-
El copiloto ve sus propias sugerencias y los datos ya capturados. Los dos viajan juntos en
turnContext:[DATOS DEL PACIENTE — YA CAPTURADOS](los campos que la extracción ya resolvió, omitiendo los que faltan para que el modelo no lea un campo vacío como capturado) y[TUS SUGERENCIAS ANTERIORES](las últimas 3).El copiloto no recibe la hora, y por eso no le corresponde elegir el saludo: el guion cambia de "buenos días" a "buenas tardes" a las 13:00 y el modelo no tiene reloj. El saludo es del operador. Cuando exista la frase de apertura automática al reclamar la llamada, el saludo lo va a resolver ese código, no una sugerencia del LLM.
Las sugerencias no van como turnos del historial: en este canal el rol
assistantes lo que el operador humano dijo de verdad, y el operador es libre de ignorar una sugerencia — pasarlas como historial le diría al modelo que el paciente escuchó frases que nunca se pronunciaron. Por eso van como bloque etiquetado, aclarando que pueden no haberse usado. Sin esto el modelo no veía una sola palabra propia, y tres reglas decopilot_identityeran inejecutables: mantener un solo tratamiento entre sugerencias, no volver a pedir un dato que ya pidió, y callarse cuando su pedido anterior sigue pendiente. Se veía en llamadas reales como el copiloto pidiendo lo mismo cinco veces con cinco redacciones distintas, alternando "¿me decís?" con "¿me puede dar?". -
El copiloto puede callarse. Si no hay un próximo paso concreto —el paciente está en medio de una explicación, o el operador ya cubrió lo que se iba a sugerir— el prompt pide responder con un guion (
—) yisSilentSuggestiondescarta esa respuesta sin emitirla. La alternativa es lo que hacía antes: sugerir "contame más" tres veces seguidas, que entrena al operador a ignorar el panel. -
Excepción al gate de palabras mínimas. Las respuestas del guion son cortas por naturaleza ("Juan Pérez", "20123456", "sí, correcto") y
suggestion.minWordslas silenciaría justo donde hace falta la sugerencia.isScriptDataTurnsaltea el gate en tres casos: el turno del paciente trae dígitos, el operador acababa de pedir un dato, o la última sugerencia del copiloto pidió un dato que todavía no llegó. Solo se habilita la sugerencia: el gate del engine sigue vigente, así que un turno corto no dispara búsqueda en el manual.La tercera rama existe porque las dos primeras miran al operador humano, no al copiloto. Un paciente que esquiva el pedido ("sí", "ajá", "yo soy") contesta siempre por debajo del mínimo, así que si el operador no vuelve a nombrar el dato el copiloto se callaba justo cuando el guion dice insistir — el bot parecía haber dejado de responder, y el silencio quedaba registrado como un
short_turncualquiera, indistinguible de un ahorro legítimo de LLM.Que el dato siga faltando es parte de la condición, no un detalle:
DATA_REQUEST_FIELDSmapea la palabra pedida al campo deCallPatientData(documento|dni→dni,nombre|apellido→patientName,whats?app|celular|teléfono→whatsapp) y el bypass solo aplica si ese campo está ennull. Sin ese candado, una vez que el copiloto pide el documento el gate quedaría abierto hasta el final de la llamada y cada "gracias" y "hasta luego" pagaría una llamada al LLM.númeroqueda afuera del mapa a propósito: solo no nombra ningún campo, así que un "¿me pasás el número?" pelado cae al chequeo genéricohasMissingFields.
Captura de datos. CallDataService (src/audio-fork/call-data.service.ts) lee del transcript
el nombre, el documento, el WhatsApp y el motivo. Es una llamada al LLM aparte (key
copilot.data_extraction) porque Deepgram devuelve los dígitos como palabras y el valor válido es
el confirmado, no el primer intento — nada de eso lo resuelve un regex. Corre debounceada
junto a la sugerencia, con dos candados para no gastar tokens: solo mientras falte algún campo, y
solo en turnos que plausiblemente traigan un dato. Cada campo nuevo se emite como patient_data
al panel "Datos del paciente" del operador, y un campo ya capturado nunca se borra con un
null posterior (la pasada en vivo solo ve una ventana del transcript). Al cerrar la llamada, si
quedó algo sin capturar se hace una última pasada sobre el transcript completo y los cuatro campos
se persisten en call_sessions. Si hay
DNI, la llamada además se agrupa bajo su paciente.
Y una tercera pasada, sobre el transcript ya corregido. La pasada anterior lee el transcript
crudo, porque corre en paralelo con la corrección post-llamada para que la pantalla de revisión
no espere a las dos. Pero los números dictados son justo lo que Deepgram escribe en palabras
("tres cero tres") y lo que la corrección pasa a cifras — o sea que los campos que más suelen
faltar son los que la pasada cruda no podía resolver nunca. Por eso extractFromPolished vuelve a
extraer sobre el texto corregido, actualiza la fila y empuja el panel. Corre después de liberar
la revisión, así no le cuesta latencia al operador, y está gateada por dos condiciones para no
gastar tokens de más: que la corrección haya cambiado algo y que todavía falte algún campo.
Corrección manual. La sección "Datos del paciente" es editable en los dos lugares donde se
revisa una llamada: la revisión post-llamada del operador (botón "Corregir" en el panel
derecho, que abre un diálogo) y el detalle de llamada de /logs. La transcripción de dígitos
falla seguido y el DNI es lo que agrupa las llamadas de una persona, así que arreglarlo ahí
reagrupa la llamada, no solo corrige esa fila. Vaciar un campo es una respuesta válida ("se
entendió mal y no hay forma de saber qué era").
Dos detalles que explican por qué se edita recién al terminar la llamada:
- La fila de
call_sessionsse escribe enendCall, así que durante la llamada no hay nada que hacerlePATCH. El eventopatient_datalleva un flagpersisted, que el backend manda entruerecién después de insertar la fila; el botón "Corregir" aparece con ese flag. - Mantenerlo read-only en vivo además evita que la pasada siguiente del extractor sobrescriba lo que el humano escribió, que si no habría que resolver con un marcador por campo.
El evento manda los cuatro campos crudos (mismos nombres que las columnas y que el body del
PATCH), no entidades de presentación: la pantalla los edita, así que necesita los datos. Las
filas que se muestran las arma el dashboard (lib/patient-data.ts). El context_update sigue
siendo exclusivo del lookup de Retell, que tiene otros slots (carnet, autorización).
El copiloto sugiere pedir el número; el mensaje lo manda el operador por su vía actual. No hay integración con WhatsApp Business API ni almacenamiento de la foto/video que el paciente envía.
Flujo 3 — ElevenLabs (voz IA, HTTP)
Aura es el Custom LLM del agente de ElevenLabs, pero por HTTP compatible con OpenAI en vez
de un WebSocket propietario: cada turno es un POST /chat/completions independiente y la
respuesta es un stream SSE. Ver Custom LLM para ElevenLabs.
Diferencias con el Flujo 1: Retell es un WS bidireccional propietario y con estado por
llamada; ElevenLabs es stateless por request — el historial completo viaja en cada
messages[]. Por eso, hasta que se resuelva la correlación del call_id, este canal todavía
no alimenta el dashboard del copiloto.
Los tres flujos comparten el mismo motor de conversación: los adaptadores sólo hacen transporte.
Conceptos clave del RAG
- Dos bases en paralelo:
memory_embeddings(correcciones de supervisores, alta precisión) +knowledge_base(manuales técnicos). - Normalización de STT: convierte variantes fonéticas al nombre técnico
(
"m cincuenta"→"M50") antes de embeddear. - Context locking: al primer hit de manual, el contexto se fija para toda la llamada; los turnos siguientes no re-buscan.
- Prompt caching: el system message es fijo por llamada; el contexto RAG se inyecta en el último mensaje del usuario para maximizar el prefijo cacheable por OpenAI. El prompt base del system sale de un store editable por superadmin (ver Prompts del agente).
Detalle completo en RAG → Pipeline.
Mapa de módulos (backend src/)
| Módulo | Qué hace |
|---|---|
retell/ | Custom LLM WS, webhook, consult, transfer, claim |
custom-llm/ | Endpoint HTTP compatible con OpenAI para ElevenLabs (POST /chat/completions) |
conversation/ | Motor de conversación agnóstico del canal (RAG · prompt · stream del LLM) |
audio-fork/ | Gateway WS de FreeSWITCH, STT, sesiones, claim/release |
memory/ | RAG sobre correcciones (production/ retrieval, learning/ ingesta) |
knowledge/ | RAG sobre manuales (knowledge_base) |
llm/ | Abstracción de provider LLM (port/adapter) |
copilot/ | WebSocket del dashboard (broadcast de eventos) |
patients/ | Roster de pacientes por DNI, alimentado por las llamadas (ver Base de datos) |
feedback/ | Acciones del supervisor + estadísticas |
prompts/ | Store versionado del prompt base del agente (edición superadmin) |
settings/ | Ajustes del copiloto tuneables en runtime (ver Ajustes runtime) |
event-log/ | Logging estructurado + auditoría (ver Observabilidad) |
metrics/ | Métricas operativas agregadas (ver Observabilidad) |
auth/ | Guards de Ajolote (SessionGuard, AppRoleGuard, GlobalRoleGuard) |
database/ | Conexión Drizzle + barrel de schemas |
common/ | Utilidades compartidas (ej. period.util.ts) |