Skip to main content

Pipeline RAG

Por cada turno del paciente, Aura recupera contexto de dos bases vectoriales en paralelo y lo inyecta en el prompt del LLM. El orquestador es ProductionService.getRagContext() (src/memory/production/).

Los dos stores

StoreContenidoSe cargaThreshold (setting / default)
memory_embeddingsCorrecciones y respuestas validadas por supervisoresOnline, al dar feedbackrag.similarityThreshold 0.78 / rag.approvedThreshold 0.85
knowledge_baseManuales técnicos de equiposOffline, pnpm ingest:manualsrag.knowledgeThreshold 0.60 — el mismo en todos los canales, ver abajo. Además se restringe por equipo del paciente, ver el filtro
  • El umbral de manuales es más bajo a propósito en voz: el speech-to-text distorsiona nombres de equipos, pero los síntomas embeddean parecido.
  • El de correcciones es más alto: un falso positivo haría repetir un patrón equivocado de un caso no relacionado.

Los umbrales se resuelven por canal

Los tres son ajustes runtime con scope, resueltos por turno dentro de getRagContext usando meta.source (el canal) como scope:

setting(scope = source del turno) → setting(scope = 'global') → default del registry

O sea que se cambian en caliente, sin redeploy y sin tocar ENV. El log de cada búsqueda dice con qué scope y con qué umbrales corrió ([RAG] Searching (scope=whatsapp) — …), y el evento knowledge_queried guarda knowledgeThreshold junto al score para poder distinguir un miss genuino de un miss por poco.

Por qué WhatsApp NO va más alto (aunque el diseño decía que sí)

En texto tipeado no hay red de contención humana

En audio-fork un falso positivo lo intercepta el operador humano, que lee la sugerencia y decide. En WhatsApp el agente reemplaza al operador: nadie mira la respuesta antes de que salga. Un falso positivo deja de ser ruido de calidad y se convierte en una instrucción clínica incorrecta entregada al paciente, sobre equipamiento de oxígeno. Por eso el umbral es un control primario, no una optimización — y por eso se midió antes de moverlo.

El diseño original subía WhatsApp a 0.65, sobre la teoría de que el umbral bajo existe para absorber distorsión de speech-to-text y en texto tipeado solo compra falsos positivos. La medición (pnpm rag:calibrate, 22 consultas sobre 116 chunks) tumbó esa teoría por dos lados, así que el override no existe: WhatsApp resuelve al mismo global que voz.

thr CORRECT WRONG MISS
0.55 8 4 9
0.60 5 2 14
0.65 4 0 17
  • Los falsos positivos no vienen del STT, vienen de consultas vagas. dice Low O2 pega el manual del Zen-O Lite con 0.5012 estando bien escrito. Subir el umbral no ataca la causa.
  • 0.65 también rechaza verdaderos positivos, incluido el que el equipo pegajoso de 3.2 existe para producir: M50 error H08 da 0.582. A 0.65 aciertan 4 de 14 consultas respondibles, y un RAG que no acierta deja al LLM contestando de memoria — la falla peor.

El hallazgo de fondo es que ningún escalar separa los dos grupos: los aciertos viven entre 0.49 y 0.72 y los errores entre 0.37 y 0.64, superpuestos. El arreglo real es filtrar la búsqueda por el equipo del paciente, y desde el ticket 3.7 existe: ver Filtro por equipo del paciente. Mover el escalar solo elegía qué error cometer.

El comportamiento de voz no cambia: ningún canal tiene fila propia para los umbrales, así que todos heredan el valor global, que es el mismo que corría antes (ver el seed al boot).

Filtro por equipo del paciente

Es el arreglo de fondo del que habla la sección anterior: en lugar de subir el umbral para que un manual ajeno sea menos probable, se saca de la búsqueda para que sea imposible.

Cuando la conversación sabe qué equipos hay en juego, findRelevant corre con un WHERE source IN (...) armado a partir de esos modelos:

SELECT title, section, content, source, 1 - (embedding <=> $1::vector) AS score
FROM knowledge_base
WHERE (source IN ('concentradores electricos/m50_sysmedm50.md')
OR source LIKE 'cap/%' OR source LIKE 'tubos/%')
ORDER BY embedding <=> $1::vector
LIMIT 1

El mapa modelo → archivo vive en src/knowledge/knowledge.sources.ts (MANUAL_SOURCE_BY_MODEL). Como la clave es knowledge_base.source, que es la ruta del archivo al momento de la ingesta, renombrar un manual dejaría el filtro apuntando a la nada; por eso KnowledgeService compara el mapa entero contra la tabla al boot y avisa si falta alguno.

De dónde salen los equipos

Dos fuentes que se unen, no se pisan:

FuenteCómo llegaVigencia
Los equipos activos del paciente en OxitesaPatientLookupPort, con el DNI dictado. Sólo en el copilotoToda la llamada
El modelo que el paciente nombra en el turnodetectEquipmentModels, el equipo pegajoso del 3.2Expira por inactividad o tope de turnos

La primera fila sólo existe en el copiloto, porque es el único canal con un identificador: el DNI que el paciente dicta. Ver Identidad: sólo el DNI.

La unión vale igual en los dos canales, con una diferencia: el copiloto no tiene el equipo pegajoso del 3.2. Lo que el paciente nombra filtra el turno en que lo nombró y nada más; la continuidad, cuando el paciente está identificado, la da su equipo de Oxitesa, que dura toda la llamada.

:::warning Medido: filtrar de más es peor que no filtrar

En una llamada scripteada real, un paciente registrado con un 7F-5 preguntó por un M50. El filtro tenía sólo el 7F-5, y El M50 me marca error H08 puntuó 0.497 contra la tabla de errores del 7F-5 — MISS. Con el M50 de vuelta adentro, la misma pregunta puntúa 0.630 contra la tabla de errores del M50. La respuesta estaba en el corpus todo el tiempo y el filtro la tapaba.

Es exactamente el caso que la unión existe para evitar, y el copiloto se pasó un rato sin ella.

:::

La unión es deliberada: si el paciente pregunta por el equipo de un familiar, o por uno que le entregaron y nadie cargó, negarle ese manual porque el CRM no coincide sería peor que buscar en uno más.

Identidad: sólo el DNI

El lookup por teléfono se sacó. Resolvía la línea desde la que llamaban contra CONTACTNUMBERS y ya no existe en ningún camino, por una razón que no es técnica: un número de teléfono no es una afirmación de identidad. Se comparte dentro de una familia, se reasigna, y un familiar llamando desde la línea del paciente no es el paciente. Matchear por ahí significa mostrar el equipamiento médico de una persona por quién es el titular de la línea, y el número no alcanza para justificar esa divulgación.

Queda un solo identificador: el DNI del paciente, que llega por dos caminos — dictado en la llamada y sacado de la transcripción, o tipeado por el operador en el panel de equipos cuando el primero no dio con nadie. El tipeado gana y queda fijo: la extracción deja de pisarlo (ver Equipos en Oxitesa). Sigue siendo el mismo documento contra el mismo endpoint; lo único que cambia es quién lo escribió.

Y no hay un paso de confirmación de identidad. Nada en el código verifica que quien dictó ese documento sea su titular: lo único que separa un DNI dictado de la divulgación de lo que ese paciente tiene entregado es el guion del operador, que lo repite y pide confirmación. Cualquier dato divulgable sobre un paciente debería pasar por ese paso; hoy no existe.

CanalIdentificadorContexto de equipos
Copiloto (operador humano)el DNI dictado, vía CallDataService, o el que el operador tipeasí, desde que lo dicta
ElevenLabs vozningunono
WhatsAppningunono

Los dos canales de bot no tienen con qué. Sus búsquedas corren contra los catorce manuales, filtradas únicamente por lo que el paciente nombra en el turno. Es un costo real y asumido — por ahora.

:::info TODO: el DNI por teclado (DTMF)

El plan es un paso previo de IVR: antes de que la llamada llegue al agente, el paciente marca su documento en el teclado. De ahí sale el identificador.

Y sería el más confiable de los tres, no un parche:

Cómo llegaQué puede fallar
Teléfono (eliminado)de la líneano es una afirmación de identidad: se comparte, se reasigna
DNI dictado (copiloto)voz → Deepgram → extractor LLMel reconocimiento de dígitos, sin dígito verificador que lo atrape
DNI tipeado (copiloto)el operador lo carga en el panel de equiposque el operador tipee mal lo que le leyó al paciente
DNI por tecladoDTMF, sin audio de por medioprácticamente nada: lo tipea el paciente y son dígitos exactos

Pendiente de definir cuando se implemente:

  • WhatsApp no tiene teclado. Ahí el equivalente es que el agente pida el documento por chat, que es texto exacto igual — pero es otro camino y hay que capturarlo del mensaje.
  • Qué pasa si el paciente no lo marca o marca cualquier cosa: hoy la respuesta correcta es la misma que ahora, seguir sin contexto de equipos.
  • Dónde entra el dato: el TODO está puesto en resolvePatient de conversation.state.service.ts, que es donde el camino de bot perdió su identificador.

:::

El proveedor es intercambiable

Oxitesa es el sistema con el que Aura habla hoy, pero nada del copiloto depende de que sea Oxitesa. La consulta pasa por un puerto, PatientLookupPort (src/patients/patient-lookup.types.ts):

export interface PatientLookupPort {
readonly name: string;
byDni(dni: string): Promise<PatientLookupResult | null>;
}

PatientContextService, PatientDataService, SuggestionService, el gateway y el panel dependen de esa interfaz y nunca de quién la implementa. El contrato de datos (PatientContext, PatientDevice) también es genérico: el mapeo de la respuesta cruda vive dentro del adaptador.

Hay tres implementaciones, que es lo que prueba que el puerto sirve de algo:

PATIENT_LOOKUP_PROVIDERQué hace
oxitesaPOST /v1/aura/patient-lookup con el secreto compartido
mockRoster en memoria, para trabajar sin acceso al sistema real
disabledNunca consulta: el deploy corre sin contexto de equipos. Sólo por elección explícita, nunca inferido

Agregar un cliente es escribir un adaptador y sumar una línea al mapa ADAPTERS de patients.module.ts, que es el único archivo de todo el backend que nombra un proveedor.

Dos detalles que existen por razones específicas:

  • El puerto contesta tres cosas y no dos: found, not_found (se preguntó y no está) y unavailable (no se pudo preguntar, con la causa). Antes las dos últimas eran el mismo null, así que una clave compartida mal puesta se veía en pantalla idéntica a un paciente que no es cliente. El caché sigue siendo el respaldo de un unavailable, nunca de un not_found: si el sistema contestó que no lo conoce, es la fuente autoritativa y servir una fila vieja taparía eso.
  • El name del puerto viaja hasta el panel como source. Es lo que hace que un panel mockeado se reconozca como mockeado, así que es dato y no acoplamiento.
  • Un PATIENT_LOOKUP_PROVIDER desconocido rompe el arranque. Degradarlo a disabled haría que un typo apagara el contexto de equipos sin que nadie lo note: en pantalla, un lookup que contesta null a todo es indistinguible de un paciente que no está en el sistema.

En pantalla, en cambio, el panel dice "Oxitesa" y eso es a propósito: nombrar el sistema real del que salió un dato es información para el operador, no acoplamiento. Cuando exista un segundo cliente ese nombre sale de la fila del tenant, resuelto por la sesión del usuario (paso 2 de specs/plan-multi-tenant.md) — no de una constante de build, que sería un build de frontend por cliente y no es el modelo que el plan describe.

decideLookup (en src/audio-fork/audio-fork.service.ts) queda en cuatro estados, y son de progreso, no de confianza:

EstadoCuándo
waitingnadie dictó un documento todavía. Donde una llamada pasa casi todo el tiempo
pendingla consulta está en vuelo
dniOxitesa contestó con un paciente
not_foundse consultó y Oxitesa no conoce ese documento

waiting y not_found son deliberadamente distintos: uno es "todavía no preguntamos", el otro es "preguntamos y no está". Un operador los lee muy distinto, y el panel los muestra distinto.

:::danger Lo que se perdió con el teléfono

Había un cruce: el teléfono y el DNI se resolvían por separado y se comparaban. Cuando daban pacientes distintos, el sistema lo detectaba (mismatch), descartaba el filtro y se lo mostraba al operador. Era el único mecanismo capaz de detectar un documento mal transcripto.

Sin él: un DNI que el reconocimiento de voz convierte en el de OTRO paciente real resuelve en silencio, con total confianza, y nada downstream puede notarlo. El DNI argentino no tiene dígito verificador, así que un número de ocho dígitos mal escuchado es indistinguible de uno correcto.

La defensa que queda es aguas arriba: el guion del call center hace que el operador repita el documento y el paciente lo confirme antes de que cuente.

:::

:::note Por qué el copiloto puede mostrar más que el bot

El PatientLookupPort devuelve dos mitades: context (DNI + modelos canónicos) va al motor, y devices (nombre, marca, sku, categoría de cada aparato) solo va al panel del operador — nunca al prompt. La asimetría es a propósito: al paciente por teléfono no se le confirma qué equipo creemos que tiene, porque eso se lo confirma a cualquiera que tenga el teléfono en la mano; el operador, en cambio, es un empleado que ve de dónde salió el dato y puede juzgarlo.

Las dos mitades son tipos separados para que la mitad rica no tenga camino hacia un prompt.

:::

Un detalle del cache: patients guarda los modelos pero no las filas de aparatos. Un modelo viejo solo puede empeorar una búsqueda; el inventario de equipos médicos de otra empresa no es algo que valga la pena acumular. Por eso, cuando Oxitesa no responde y la respuesta sale del cache, el panel muestra los modelos y dice que no tiene el detalle.

Códigos de error: por qué el top-1 no alcanza

Las tablas de errores están chunkeadas una fila por chunk, y cada fila es corta. Eso hace que una pregunta que nombra un código y un síntoma a la vez tenga dos filas compitiendo: la que matchea el código y la que matchea el ruido.

Medido en una llamada real, "El M50 me marca error H08 y suena un pitido cada un rato":

0.663 Código en Pantalla: Sin Imagen ← "Alarma sonora continua. Falla de energía"
0.620 Código en Pantalla: H08 / H01 ← la que contesta
0.596 Código en Pantalla: Low O2 / H02

Con LIMIT 1 se inyectó la primera, y el LLM la siguió al pie de la letra: contestó sobre el cable y el enchufe. El modelo no falló, falló la recuperación.

Dos cambios, y hacen falta los dos:

1. Se traen 3 candidatos, no 1 (CANDIDATE_LIMIT en knowledge.service.ts). Se inyectan los que pasan el umbral, así que una pregunta con una sola respuesta clara sigue costando un chunk.

2. Rerank léxico por código. Si la pregunta nombra un código y alguno de los candidatos lo contiene literalmente, ese pasa a primero. Es el caso donde lo léxico le gana a lo semántico: un código es un identificador, no un significado, y ningún embedding va a garantizar que H08 pese más que "suena un pitido".

El patrón es angosto a propósito: E o H seguido de dígitos, que cubre los códigos alfanuméricos que existen en el corpus (E1–E7, E43, H01, H02, H08). H8 y H08 se comparan igual. Los códigos de texto (Low O2, Check Filter, HI TEMP) quedan afuera: son frases, que es justo lo que el embedding hace bien.

Y no toca lo que no debe:

CasoAntesDespués
H08 + "suena un pitido"0.663, fila equivocada0.620, la fila de H08, con las otras dos como contexto
"no enciende y suena una alarma continua" (sin código)0.767, fila de energíaigual: sin código, el orden queda intacto
"¿cada cuánto limpio el filtro?"MISS 0.500MISS 0.500: el umbral no se tocó

:::warning El umbral sigue midiendo el más cercano

El rerank decide cuál de los candidatos va primero, no si hay respuesta. El umbral se aplica sobre el chunk más cercano, igual que siempre, porque contesta otra pregunta: "¿este material tiene algo que ver con lo que preguntaron?". Un MISS sigue siendo el mismo MISS, con el mismo número.

Y el rerank sólo reordena entre los candidatos ya traídos de los manuales permitidos: no puede ampliar la búsqueda ni traer el manual de otro equipo.

:::

El bloque del prompt cambia cuando hay más de uno. Con un solo chunk sigue diciendo "Encontré la entrada exacta… Seguí ÚNICAMENTE esta información". Con varios eso sería mentira —y peor, sería decirle al modelo que siga la última que leyó—, así que pasa a enumerarlas y a pedir explícitamente que use la del código que nombró quien llama, y ninguna otra.

Tres reglas que el filtro respeta

  1. Nunca es solo el manual del equipo. cap/ y tubos/ entran siempre. "cómo pido la recarga de oxígeno" pega en cap/recarga.md a 0.80, y un paciente con M50 preguntando eso tiene que encontrarlo. Los prefijos son prefijos y no una lista de archivos, así que un cap/*.md nuevo queda dentro del filtro el día que se ingesta.
  2. Lo que dice el paciente se suma. Ver la tabla de arriba.
  3. Filtrar NO habilita bajar el umbral. Medido: dentro del manual correcto las consultas vagas siguen en 0.35–0.51, y a ese umbral aparece la falla siguiente — "y cómo limpio el filtro" filtrado al M50 trae la tabla de errores, sección equivocada del manual correcto (0.549, o sea MISS con el umbral actual, que es lo que corresponde). El filtro elimina la clase "otro equipo" y nada más.

Qué pasa cuando NO hay manual

Un MISS honesto no sirve de nada si el LLM llena el hueco igual. Pasó, y está medido: ante "¿cada cuánto tendría que limpiar el filtro?" —una pregunta que el corpus no cubre para ningún equipo, porque es un corpus de troubleshooting y eso es mantenimiento preventivo— el copiloto contestó "una vez por semana". Un intervalo inventado, mostrado a un operador que se lo iba a repetir al paciente.

Desde la v1.4.0 del prompt del copiloto hay una regla explícita para eso:

SIN MANUAL NO HAY DATO TÉCNICO. El bloque [INFORMACIÓN TÉCNICA DEL MANUAL] llega SOLO cuando se encontró la sección que corresponde. Si no está, no tenés el manual. No hay excepción.

Prohíbe afirmar intervalos de limpieza o recambio, significado de códigos y luces, litros por minuto, presiones, autonomías, pasos de un procedimiento, vida útil y compatibilidades. La sugerencia correcta pasa a ser "Déjeme confirmar ese dato con el manual del equipo y le contesto en un momento."

Tres cosas que la regla cuida:

  • No lo calla. Si lo preguntado no es técnico —una etapa del guion, un reclamo, una gestión— el copiloto sigue normal. Sin esa salvedad se volvía mudo justo donde más sirve.
  • No anuncia su limitación. Nada de "no tengo esa información", ni mencionar manuales o sistemas. La frase sostiene la llamada mientras se verifica.
  • La ausencia del bloque es la señal, y está dicho en la sección de contexto del prompt: antes sólo decía qué hacer cuando el bloque venía.

:::info El prompt vivo está en la base

prompt_versions es la fuente de verdad. Los .txt de Aura-reloaded/prompts/copilot/versions/ son el registro, y activar una versión es pegarla en /anfibios/prompts. Ver Prompts del agente.

:::

Qué compró, medido

pnpm rag:calibrate corre las 22 consultas dos veces, sin filtro y con filtro, y separa los dos tipos de error, porque solo uno es el que este cambio ataca: otro equipo (el manual de un aparato que el paciente no tiene, el peligroso) contra otra sección (el material correcto, el chunk equivocado, que es un problema del corpus).

SIN filtro CON filtro
thr CORRECT WRONG (equipo/sección) thr CORRECT WRONG (equipo/sección)
0.40 10 9 (7 / 2) 0.40 15 3 (0 / 3)
0.55 8 4 (2 / 2) 0.55 10 2 (0 / 2)
0.65 4 0 (0 / 0) 0.65 4 0 (0 / 0)

Con filtro, la clase "otro equipo" es 0 en todo el barrido, incluso a 0.40. Sin filtro hay que llegar a 0.65 — y ahí aciertan 4 de 20. Al umbral que corre hoy (0.55) los aciertos pasan de 8 a 10 y los errores de equipo de 2 a 0.

Caso verificado en vivo, mismo turno, distinto contexto de paciente:

"el equipo dice Low O2" sin contexto → HIT 0.582 lovego_lg103.md § Tabla de Errores y Alarmas
"el equipo dice Low O2" paciente con → HIT 0.573 zen_o_lite.md § Errores con Código en Pantalla
CP501+Zen-O Lite

El primero es una instrucción del manual de un equipo que el paciente no tiene, entregada como [INSTRUCCIÓN OBLIGATORIA — MANUAL TÉCNICO]. Es exactamente el error que el filtro vuelve imposible.

Los equipos no llegan al prompt

Divergencia respecto del diseño original, que los ponía en systemExtras (→ ragQueryPrefix): el equipo que viene del CRM entra al filtro y no al prompt ni a la query embeddeada.

  • No al prompt, porque decirle al paciente "veo que tenés un M50" le confirma a quien esté del otro lado qué equipo usa el titular de la ficha, y un documento mal transcripto no se puede descartar. Lo que el modelo nunca ve no lo puede divulgar; es más barato que pedirle que se calle.
  • No a la query, porque el prefijo ya se midió insuficiente (CP501 dice Low O2 traía el manual del M50 con el prefijo correcto puesto) y encima degrada las consultas administrativas.

Costo en tokens de este ticket: cero. systemExtras sigue exactamente como estaba.

El equipo que el paciente nombra sí sigue yendo al ragQueryPrefix, como desde el 3.2: eso no divulga nada, lo dijo él.

Cuando no se sabe nada, no se filtra

resolveKnowledgeFilter devuelve null —búsqueda sobre el corpus completo, igual que antes del 3.7— en tres casos:

  • no hay ningún equipo conocido (todo el canal de voz está acá: no manda system__caller_id),
  • alguno de los modelos no tiene manual ingestado (CPAP y BiPAP, hoy),
  • alguno de los identificadores no está en el mapa.

Los dos últimos fallan abiertos a propósito: restringir la búsqueda mientras un equipo que el paciente sí tiene queda afuera del filtro esconde el único material que podía responderle, y el síntoma se ve igual que un miss cualquiera. Una búsqueda peor es mejor que una pregunta irrespondible.

Flujo por turno

El contexto se inyecta en el último mensaje del usuario, no en el system message.

Optimizaciones

  • Normalización de STT: equipment-normalizer.ts convierte variantes fonéticas al nombre técnico antes de embeddear.
  • Context locking (canal Retell): al primer HIT de manual, el contexto se fija para toda la llamada; los turnos siguientes no re-embeddean ni re-buscan.
  • Prompt caching: el system message es fijo por llamada (identidad + datos del paciente). Al mantener estable el prefijo, OpenAI cachea automáticamente los tokens repetidos entre turnos. Por eso el contexto RAG (dinámico) va en el último mensaje.

Recuperación de knowledge_base

knowledge.service.findRelevant():

  • Query top-1 por coseno (1 - (embedding <=> vector)), ORDER BY ... LIMIT 1.
  • Trae siempre el mejor match sin filtrar por threshold y recién después descarta si score < rag.knowledgeThreshold del canal.
  • Devuelve un KnowledgeLookup, no un hit a secas: { hit, bestScore, bestSource, bestSection }. El hit es null cuando nada pasó el umbral, pero los best* sobreviven al miss y terminan en el payload de knowledge_queried — es lo que permite separar por SQL un miss al borde del umbral de uno contra un corpus que no cubre el tema. Los tres son null solo si no hubo candidatos (tabla vacía, filtro sin match) o la query falló.
  • La query a memory_embeddings tampoco filtra por umbral en el WHERE: la selección re-aplica los dos umbrales (corrección vs. aprobado) en código, así que filtrar en SQL solo tiraba el score de la lección que casi matcheó. Mismo plan y mismo costo, con memoryBestScore de yapa.
  • Filtra por source cuando la conversación conoce el equipo, ver Filtro por equipo del paciente. El filtro va dentro del top-1, no después: así el score que se loguea es el mejor score del material permitido, y un miss del filtro se distingue de un miss del umbral.
  • No filtra por category. La categoría es solo metadata; el recorte se hace por source, que es la ruta del archivo. Y lo que no está en ningún filtro sigue compitiendo por el único chunk, así que conviene curar qué entra (ver Ingesta).

El flywheel (memory_embeddings)

El feedback del supervisor entrena a Aura:

El id es sha256(customerComplaint) → dar feedback sobre el mismo reclamo actualiza la lección en vez de duplicarla.

Supervisión (editar / eliminar). Cada marca guarda customer_complaint y lesson_id (= sha256 del reclamo), así que edición y borrado apuntan a la lección exacta sin reconstruir nada. Cuando un supervisor edita desde el modal de /logs, FeedbackService.update() re-entrena la lección (mismo id, upsert); al eliminarla, LearningService.deleteLessonById() la borra de memory_embeddings, pero solo si ninguna otra marca la referencia (reference counting sobre lesson_id). Así una corrección equivocada de un agente deja de influir en el RAG de llamadas futuras. Las marcas viejas sin estas columnas usan un fallback: recomponer el reclamo desde el transcript persistido y borrar con guard por call_id.

Divergente (discarded) no entra al flywheel. Es una marca neutra: no genera lección, queda fuera del cálculo de precisión y su fila guarda lesson_id = NULL. Convertir una marca existente en divergente desaprende: update() borra la lección con el mismo reference counting que el borrado, en vez de re-entrenarla — si la re-entrenara, un veredicto neutro terminaría escrito en memory_embeddings como feedback_wrong.

Observabilidad del RAG

Cada recuperación emite un evento knowledge_queried con la query, hit, scores, embedMs, queryMs, el knowledgeThreshold que corrió y —desde el 3.7— knowledgeSources: los manuales a los que se restringió la búsqueda, o null si vio el corpus completo. Ese campo es la única forma de distinguir a posteriori un miss del umbral de un miss del filtro.

Eso alimenta las métricas de cobertura y latencia por capa del panel de Operación (ver Observabilidad). Si las FAQ no matchean consultas habladas, el primer sospechoso es rag.knowledgeThreshold, resuelto por canal (hoy todos heredan el mismo global).

Gate de turnos cortos por canal

Antes de embeddear, el motor descarta los turnos de menos de minWords palabras: en voz los turnos de 1–3 palabras son acknowledgments ("sí", "ajá", "dale") y no vale gastar un embedding en ellos. Ese minWords es del canal (custom-llm.profiles.ts), no un setting: voz usa 4, WhatsApp usa 1, porque alguien que escribe tres palabras las eligió — "error H08" o "el M50 pita" son consultas técnicas perfectamente válidas.

Además WhatsApp lleva alwaysRagOnEquipment: true: si el turno menciona un modelo (EQUIPMENT_MODEL_RE) o un código de error / alarma (ERROR_CODE_RE, ambos en equipment-normalizer.ts), el gate se saltea por más corto que sea el turno. Los dos regex se evalúan después de la normalización, así que un "h o ocho" dictado cuenta igual que un "H08" tipeado. El flag es true solo en WhatsApp: el comportamiento de voz quedó fijo desde la Fase 1.