Skip to main content

Motor de conversación

El ConversationEngine (src/conversation/) concentra todo lo que no depende del canal por el que entra la llamada: buscar contexto en el RAG, armar el prompt y streamear la respuesta del LLM. Los gateways quedan como adaptadores de transporte.

El engine existe para que agregar un canal nuevo no signifique una tercera copia. El primero en aprovecharlo fue ElevenLabs, que entró como un controller HTTP sin tocar una línea del engine.

Qué hace el engine

  1. Gate de turno corto. Si el turno tiene menos de minWords palabras, no consulta el RAG y registra knowledge_queried con reason: 'short_turn'.
  2. Query de RAG. [ragQueryPrefix, normalizeEquipmentNames(queryText)] unidos por espacio. El prefijo es el modelo de equipo detectado, para que la búsqueda siga enfocada cuando el turno que lo mencionó ya salió de la ventana.
  3. Armado de mensajes con layout amigable al prompt caching (ver abajo). El system message sale de la key que corresponde al canal (PROMPT_KEY_BY_SOURCE): copilot_identity para audio_fork, agent_identity para los canales de voz. Ver Prompts del agente.
  4. Stream del LLM, devuelto sin consumir para que el adaptador lo maneje.

Contrato

export interface InboundTurn {
callId: string;
agentId?: string; // agente humano, si la llamada fue reclamada
source: 'retell' | 'audio_fork' | 'elevenlabs' | 'whatsapp';

turns: ConversationTurn[]; // conversación completa, incluyendo el turno a responder
queryText: string; // texto para la query de RAG y el gate de turno corto
lastUserContent: string; // texto del último mensaje de usuario

systemExtras?: string[]; // bloques de sistema extra (ej. datos del paciente)
ragQueryPrefix?: string; // prefijo de la query (ej. "M50")
historyLimit?: number; // tope de turnos previos; sin límite si se omite

minWords: number;
alwaysRagOnEquipment?: boolean; // saltea el gate si el turno nombra equipo o código de error
maxTokens: number;
abortSignal: AbortSignal;
onUsage?: (usage: LlmUsage) => void;
}

export interface EngineTurnResult {
deltas: AsyncIterable<string>;
ragHit: boolean;
ragScore: number | null;
messages: ChatMessage[];
}

Las diferencias entre canales se expresan como inputs, nunca como un if (source === ...) dentro del engine. source se usa para etiquetar los eventos del event-log y las métricas del RAG, y para elegir el prompt base vía el mapa PROMPT_KEY_BY_SOURCE (una tabla, no una rama: sumar un canal es agregar una fila).

runTurn es async y devuelve el iterable sin consumirlo porque:

  • ragHit/ragScore se conocen antes de streamear, y los adaptadores los necesitan para su suggestion_generated.
  • El adaptador arranca su propio cronómetro después del await, así llmMs mide solo el LLM.
  • La AbortSignal se crea antes del await pero se registra después: un turno nuevo que llega durante la búsqueda de RAG interrumpe al anterior en el mismo momento que antes.

Layout de prompt caching

OpenAI cachea automáticamente el prefijo estable más largo. La estrategia es mantener el mensaje de sistema fijo durante toda la llamada, para que el historial que crece sea cacheable, y meter el contexto de RAG (dinámico por turno) en el último mensaje de usuario, que es la única parte que siempre es nueva.

[system(identity + systemExtras)] ← prefijo estable, cachea desde el turno 2
[user(turno 1)] ← cacheado desde el turno 3
[assistant(turno 1)] ← cacheado desde el turno 3
...
[user(contexto RAG + lastUserContent)] ← siempre nuevo, no cachea (y está bien)

Por eso systemExtras va en el mensaje de sistema y el contexto de RAG no.

Qué NO hace el engine

No conoce ws, node:http ni el protocolo de ningún proveedor. Queda del lado del adaptador:

ResponsabilidadDueño
Frames del protocolo (RetellOutboundResponse, content_complete, end_call)RetellGateway
Frames SSE de OpenAI (chat.completion.chunk, [DONE])CustomLlmController
Detección de despedida (isFarewell)RetellGateway
Estado en memoria de la llamada (transcript, tokens, equipo, contexto del paciente)RetellGateway
Debounce, suggestionId estable, emisión y persistencia de sugerenciasAudioForkService
Crear/abortar el AbortControllercada adaptador
Broadcast al copilotocada adaptador
suggestion_generatedcada adaptador

Por qué suggestion_generated se queda en los adaptadores

Los payloads difieren de forma sustantiva: Retell manda promptTokens/cachedTokens/ completionTokens (usa onUsage), audio-fork no; y las guardas para emitirlo son distintas (!aborted en Retell, fullText && agentId en audio-fork). Moverlo al engine obligaría a ramificar por canal, que es justo lo que el contrato evita. En cambio knowledge_queried sí vive en el engine, porque su payload se reconstruye exacto con source + agentId.

Cómo lo llama cada canal

CampoRetellAudio-forkElevenLabs (voz)ElevenLabs (WhatsApp)
turnsevent.transcript (agentassistant)session.transcript (agentassistant)messages[] sin los systemídem
queryTextúltimo turno del usuariopatientUtteranceúltimo mensaje userídem
lastUserContentcontenido del último turno, sea de quien seapatientUtterancecontenido del último mensaje, sea de quien seaídem
systemExtrasbloque [DATOS DEL PACIENTE] si hay— (Fase 4)— (Fase 4)
turnContext[DATOS DEL PACIENTE — YA CAPTURADOS] + [TUS SUGERENCIAS ANTERIORES] (últimas 3)
ragQueryPrefixequipo detectado— (Fase 4)— (Fase 4)
historyLimitsin topesuggestion.maxHistoryTurnssin topesin tope
minWords4, fijo en códigosuggestion.minWords4, del perfil de canal1, del perfil de canal
alwaysRagOnEquipmenttrue
maxTokens80, fijo en códigosuggestion.maxTokens80, del perfil de canal200, del perfil de canal
onUsageacumula tokens por llamadalocals del turnolocals del turno
prompt baseagent_identitycopilot_identityagent_identityagent_identity_whatsapp

systemExtras y turnContext inyectan contexto en lugares distintos por caching: el system message se arma una vez y se mantiene fijo por llamada, así que el historial que crece queda como prefijo cacheable. Un bloque que cambia en cada turno —el contexto de RAG, o las sugerencias previas del copiloto— va montado sobre el último mensaje user, que es la única parte siempre nueva. Meterlo en systemExtras invalidaría el prefijo en cada turno. El orden dentro de ese mensaje es contexto de RAG → turnContext → el turno a responder, que queda siempre último.

Dos particularidades del path de voz, deliberadas:

  • Retell manda el último turno como role: 'user' aunque sea del agente. Retell siempre pide una respuesta, así que el turno a responder es el último que llegó, sin importar el rol.
  • minWords y maxTokens están fijos en código, no en Ajustes runtime. Un cambio en el panel de admin no debe alterar el comportamiento de voz.

El gate de turno corto tiene un bypass opcional y por canal: con alwaysRagOnEquipment, un turno que nombra un modelo (EQUIPMENT_MODEL_RE) o un código de error (ERROR_CODE_RE) busca en el RAG por más corto que sea. Los dos regex se evalúan sobre el texto normalizado, así que "h o ocho" cuenta igual que "H08". Solo WhatsApp lo activa — ver Pipeline RAG.

También difiere qué pasa en un turno corto: Retell siempre responde (solo saltea el RAG), mientras que el copiloto puede quedarse callado. Por eso AudioForkService tiene su propio early-return antes de llamar al engine, usando el helper countWords exportado por el engine para no duplicar la regla. Ese early-return tiene una excepción propia del copiloto: los turnos de datos del guion sí generan sugerencia aunque no lleguen a minWords. Son tres casos —dígitos, una respuesta corta a un pedido del operador, o un pedido del propio copiloto que sigue sin respuesta— y el tercero es el que le permite insistir cuando el paciente esquiva con dos palabras. El minWords que se le pasa al engine no cambia, así que el gate del RAG sigue intacto — ver Guion de la llamada.

Tests

Los primeros tests del repo cubren justamente esta costura (pnpm test):

  • src/audio-fork/call-data.service.spec.ts — parseo tolerante de la extracción, rechazo de números con cantidad de dígitos imposible, y que una pasada posterior nunca borre un dato ya capturado.
  • src/conversation/conversation.engine.spec.ts — orden de mensajes, systemExtras, recorte de historial, gate de turno corto, composición de la query, passthrough de abortSignal/onUsage.
  • src/retell/retell.gateway.spec.ts — snapshot de no-regresión: la secuencia exacta de RetellOutboundResponse[], los eventos al copiloto y los payloads del event-log para un turno normal, una despedida (end_call), un turno corto, un error del LLM y un abort.
  • src/audio-fork/audio-fork.service.spec.ts — el pipeline de sugerencias: partials cada 4 chunks con id/created_at estables, confidence 0.9/0.7 según hit de RAG —un proxy de dos valores; el score real va aparte en rag_hit/rag_score del evento y se persiste con la sugerencia—, tope de historial, silencio en turno corto y en llamada sin reclamar, interrupción y error del LLM. Las dos caras del bypass por pedido propio están cubiertas: sugiere en un turno corto mientras su pedido sigue sin responder, y se calla —sin gastar LLM— en cuanto el dato aparece.
  • src/custom-llm/*.spec.ts — el canal de ElevenLabs (ver Custom LLM).

El snapshot de Retell se grabó contra el código anterior a la extracción: si un cambio en el engine lo mueve, es una regresión del comportamiento de voz, no un snapshot desactualizado. El spec de audio-fork se escribió después de la extracción, así que no prueba la migración en sí (eso se verificó leyendo el diff y con una llamada real) — es la red para los cambios que vengan, incluido ElevenLabs.

Esa red ya sirvió: sumar el canal de ElevenLabs no movió ni el snapshot de Retell ni el spec de audio-fork, que es la evidencia de que el engine soportó un tercer canal sin cambiar de comportamiento.