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
- Gate de turno corto. Si el turno tiene menos de
minWordspalabras, no consulta el RAG y registraknowledge_queriedconreason: 'short_turn'. - 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. - 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_identityparaaudio_fork,agent_identitypara los canales de voz. Ver Prompts del agente. - 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/ragScorese conocen antes de streamear, y los adaptadores los necesitan para susuggestion_generated.- El adaptador arranca su propio cronómetro después del
await, asíllmMsmide solo el LLM. - La
AbortSignalse crea antes delawaitpero 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:
| Responsabilidad | Dueñ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 sugerencias | AudioForkService |
Crear/abortar el AbortController | cada adaptador |
| Broadcast al copiloto | cada adaptador |
suggestion_generated | cada 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
| Campo | Retell | Audio-fork | ElevenLabs (voz) | ElevenLabs (WhatsApp) |
|---|---|---|---|---|
turns | event.transcript (agent→assistant) | session.transcript (agent→assistant) | messages[] sin los system | ídem |
queryText | último turno del usuario | patientUtterance | último mensaje user | ídem |
lastUserContent | contenido del último turno, sea de quien sea | patientUtterance | contenido del último mensaje, sea de quien sea | ídem |
systemExtras | bloque [DATOS DEL PACIENTE] si hay | — | — (Fase 4) | — (Fase 4) |
turnContext | — | [DATOS DEL PACIENTE — YA CAPTURADOS] + [TUS SUGERENCIAS ANTERIORES] (últimas 3) | — | — |
ragQueryPrefix | equipo detectado | — | — (Fase 4) | — (Fase 4) |
historyLimit | sin tope | suggestion.maxHistoryTurns | sin tope | sin tope |
minWords | 4, fijo en código | suggestion.minWords | 4, del perfil de canal | 1, del perfil de canal |
alwaysRagOnEquipment | — | — | — | true |
maxTokens | 80, fijo en código | suggestion.maxTokens | 80, del perfil de canal | 200, del perfil de canal |
onUsage | acumula tokens por llamada | — | locals del turno | locals del turno |
| prompt base | agent_identity | copilot_identity | agent_identity | agent_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. minWordsymaxTokensestá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 deabortSignal/onUsage.src/retell/retell.gateway.spec.ts— snapshot de no-regresión: la secuencia exacta deRetellOutboundResponse[], 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 conid/created_atestables,confidence0.9/0.7 según hit de RAG —un proxy de dos valores; el score real va aparte enrag_hit/rag_scoredel 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.