Skip to main content

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, linear16 a 8 kHz (G.711 telefónico es 8 kHz), language configurable (def. es). Se descartan los finales con confidence por debajo de deepgram.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 su ts (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.minWords palabras — 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:

  1. Saludo y presentación.
  2. El paciente describe su problema (acá entra la respuesta del manual vía RAG).
  3. Pedir nombre y apellido, y documento.
  4. 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 assistant es 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 de copilot_identity eran 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 () y isSilentSuggestion descarta 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.minWords las silenciaría justo donde hace falta la sugerencia. isScriptDataTurn saltea 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_turn cualquiera, indistinguible de un ahorro legítimo de LLM.

    Que el dato siga faltando es parte de la condición, no un detalle: DATA_REQUEST_FIELDS mapea la palabra pedida al campo de CallPatientData (documento|dnidni, nombre|apellidopatientName, whats?app|celular|teléfonowhatsapp) y el bypass solo aplica si ese campo está en null. 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úmero queda 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érico hasMissingFields.

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_sessions se escribe en endCall, así que durante la llamada no hay nada que hacerle PATCH. El evento patient_data lleva un flag persisted, que el backend manda en true recié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).

Mandar el WhatsApp es manual

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óduloQué 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)