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
| Store | Contenido | Se carga | Threshold (setting / default) |
|---|---|---|---|
memory_embeddings | Correcciones y respuestas validadas por supervisores | Online, al dar feedback | rag.similarityThreshold 0.78 / rag.approvedThreshold 0.85 |
knowledge_base | Manuales técnicos de equipos | Offline, pnpm ingest:manuals | rag.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 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 O2pega 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 H08da 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:
| Fuente | Cómo llega | Vigencia |
|---|---|---|
| Los equipos activos del paciente en Oxitesa | PatientLookupPort, con el DNI dictado. Sólo en el copiloto | Toda la llamada |
| El modelo que el paciente nombra en el turno | detectEquipmentModels, el equipo pegajoso del 3.2 | Expira 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.
| Canal | Identificador | Contexto de equipos |
|---|---|---|
| Copiloto (operador humano) | el DNI dictado, vía CallDataService, o el que el operador tipea | sí, desde que lo dicta |
| ElevenLabs voz | ninguno | no |
| ninguno | no |
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 llega | Qué puede fallar | |
|---|---|---|
| Teléfono (eliminado) | de la línea | no es una afirmación de identidad: se comparte, se reasigna |
| DNI dictado (copiloto) | voz → Deepgram → extractor LLM | el reconocimiento de dígitos, sin dígito verificador que lo atrape |
| DNI tipeado (copiloto) | el operador lo carga en el panel de equipos | que el operador tipee mal lo que le leyó al paciente |
| DNI por teclado | DTMF, sin audio de por medio | prá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
TODOestá puesto enresolvePatientdeconversation.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_PROVIDER | Qué hace |
|---|---|
oxitesa | POST /v1/aura/patient-lookup con el secreto compartido |
mock | Roster en memoria, para trabajar sin acceso al sistema real |
disabled | Nunca 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á) yunavailable(no se pudo preguntar, con la causa). Antes las dos últimas eran el mismonull, 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 ununavailable, nunca de unnot_found: si el sistema contestó que no lo conoce, es la fuente autoritativa y servir una fila vieja taparía eso. - El
namedel puerto viaja hasta el panel comosource. Es lo que hace que un panel mockeado se reconozca como mockeado, así que es dato y no acoplamiento. - Un
PATIENT_LOOKUP_PROVIDERdesconocido rompe el arranque. Degradarlo adisabledharía que un typo apagara el contexto de equipos sin que nadie lo note: en pantalla, un lookup que contestanulla 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:
| Estado | Cuándo |
|---|---|
waiting | nadie dictó un documento todavía. Donde una llamada pasa casi todo el tiempo |
pending | la consulta está en vuelo |
dni | Oxitesa contestó con un paciente |
not_found | se 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:
| Caso | Antes | Después |
|---|---|---|
H08 + "suena un pitido" | 0.663, fila equivocada | 0.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ía | igual: sin código, el orden queda intacto |
| "¿cada cuánto limpio el filtro?" | MISS 0.500 | MISS 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
- Nunca es solo el manual del equipo.
cap/ytubos/entran siempre."cómo pido la recarga de oxígeno"pega encap/recarga.mda 0.80, y un paciente con M50 preguntando eso tiene que encontrarlo. Los prefijos son prefijos y no una lista de archivos, así que uncap/*.mdnuevo queda dentro del filtro el día que se ingesta. - Lo que dice el paciente se suma. Ver la tabla de arriba.
- 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 O2traí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.tsconvierte 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.knowledgeThresholddel canal. - Devuelve un
KnowledgeLookup, no un hit a secas:{ hit, bestScore, bestSource, bestSection }. Elhites null cuando nada pasó el umbral, pero losbest*sobreviven al miss y terminan en el payload deknowledge_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 sonnullsolo si no hubo candidatos (tabla vacía, filtro sin match) o la query falló. - La query a
memory_embeddingstampoco filtra por umbral en elWHERE: 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, conmemoryBestScorede yapa. - Filtra por
sourcecuando 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 porsource, 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.