Métricas y paneles
Hay tres pantallas de supervisor. Dos son de métricas (miden cosas distintas, con
fuentes distintas) y una es de auditoría. Las tres están gateadas a supervisor /
superadmin (front + back).
| Pantalla | Pregunta | Fuente | Endpoint |
|---|---|---|---|
Analíticas (/analytics) | ¿Qué tan buenas son las sugerencias? | tabla feedback | GET /feedback/stats |
Operación (/operations) | ¿Cómo se comporta el sistema? | tabla event_log | GET /metrics/operational |
Logs (/logs) | ¿Quién hizo qué? | tabla event_log | GET /audit/logs |
Analíticas — calidad (según el humano)
Mide sobre lo que el operario marcó: % aciertos, sugerencias usadas / incorrectas /
descartadas, % aceptación. Depende de que haya feedback. Soporta modo Ver y
Comparar (período A vs B).
Operación — salud (automático)
Se registra solo en cada llamada, sin depender de que nadie marque nada.
MetricsService.aggregate() corre SQL sobre event_log, filtrando por la ventana de
tiempo [from, to) (y opcionalmente por source), y devuelve { current, previous }
para poder mostrar tendencias.
Cómo se arma cada valor
Todo sale de contar/percentilar eventos de event_log dentro de la ventana. Las cuatro
tasas se devuelven como { numerator, denominator, rate } (la card muestra rate en % y
numerator/denominator como subtexto).
Nota de vocabulario: un "turno" acá = una recuperación RAG = un evento
knowledge_queried. Se emite uno por cada turno del cliente en el que el pipeline
intentó recuperar contexto (incluidos los turnos cortos que se saltean, que cuentan como
miss). Ver event_log y RAG.
Cobertura (turno)
Qué tan seguido el RAG encontró contexto confiable para responder.
cobertura_turno = knowledge_queried con hit=true / total knowledge_queried
- Fuente: evento
knowledge_queried, campopayload.hit(booleano,truesi el score superó el threshold). - Numerador = turnos con hit; denominador = todos los turnos con recuperación.
- Más alto es mejor. Es la métrica más accionable: si baja, el bot se está quedando sin
material para responder (revisar manuales /
KNOWLEDGE_THRESHOLD).
Cobertura (llamada)
% de llamadas atendidas que recibieron al menos una sugerencia.
cobertura_llamada = call_id distintos con ≥1 suggestion_generated
/ call_id distintos con claim_initiated
- Fuente: eventos
suggestion_generatedyclaim_initiated(contados conCOUNT(DISTINCT call_id)). - El denominador son las llamadas reclamadas (donde el copiloto realmente estuvo activo),
no todas las entrantes. Como el claim es manual y muchas llamadas nunca se atienden,
usar
call_startedinflaba el denominador y hundía la métrica por una razón ajena a la calidad del copiloto. - Vista más gruesa/de negocio: mide llamadas "asistidas al menos una vez", no la calidad turno a turno. Más alto es mejor.
Sin contexto (RAG)
El complemento de la cobertura por turno: turnos donde el RAG no encontró contexto (miss por bajo score, input vacío, budget excedido, o turno corto salteado).
sin_contexto = knowledge_queried con hit=false / total knowledge_queried (= 1 − cobertura_turno)
- Más bajo es mejor (que suba es malo → el delta se colorea invertido).
- Se llamaba "Se calló", y el nombre engañaba: no tener contexto del manual no es quedarse
mudo. Un turno puede no matchear nada del manual y recibir igual una sugerencia perfectamente
buena — la mayoría de los turnos del guion (pedir el documento, confirmar el WhatsApp) son
exactamente eso. Con ese nombre, un copiloto que de verdad dejaba de hablar se leía como normal.
El campo del backend sigue siendo
silencepor compatibilidad, con un comentario que aclara qué mide en realidad.
Se calló
Turnos que llegaron al copiloto y no produjeron ninguna sugerencia, separados por quién lo decidió.
se_calló = suggestion_skipped / (suggestion_generated + suggestion_skipped)
byGate = suggestion_skipped con reason='short_turn' → lo silenció el gate
byModel = suggestion_skipped con reason='model_silent' → el modelo no tenía nada que agregar
- Fuente: el evento
suggestion_skipped(ver event_log). - El corte es lo importante:
byModeles comportamiento esperado y sano;byGatees el copiloto silenciado sin que el modelo opine, y si sube hay que revisarsuggestion.minWordso el bypass de turnos de datos del guion. - Más bajo es mejor, pero no a cero: un copiloto que nunca se calla es el que sugiere "contame más" tres veces seguidas.
Derivó
% de llamadas atendidas que terminaron escaladas a un humano.
derivó = transfer_initiated / call_id distintos con claim_initiated
- Fuente: evento
transfer_initiated(lo emite el flujo de transferencia de Retell). - Igual que la cobertura por llamada, el denominador son las llamadas reclamadas.
- Más bajo es mejor (delta invertido). Es distinto de "se calló": derivó = escaló la llamada a un humano; se calló = no produjo sugerencia en un turno.
Latencia p50/p95 por capa
Cuánto tarda cada etapa del pipeline, en milisegundos. Se calcula con
percentile_cont (percentil exacto interpolado) de Postgres sobre los tiempos guardados en
el payload.
| Capa | De dónde | Campo |
|---|---|---|
| Embedding | knowledge_queried | payload.embedMs — generar el vector de la consulta (OpenAI) |
| Búsqueda | knowledge_queried | payload.queryMs — la query vectorial en Postgres |
| LLM | suggestion_generated | payload.llmMs — generar la sugerencia |
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'embedMs')::numeric) -- p50 (mediana)
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'embedMs')::numeric) -- p95
FILTER (WHERE payload ? 'embedMs' AND (payload->>'embedMs')::numeric > 0)
- p50 = mediana (la mitad de los turnos tardó menos). p95 = el 95% tardó menos; es la "cola" — captura los casos lentos que el promedio esconde.
llmMsmide la generación completa de la sugerencia, no el tiempo hasta el primer token. Como la sugerencia se emite en streaming (ver Visión general), el agente ve las primeras palabras mucho antes de esellmMs; la latencia percibida es bastante menor que este número.- Se excluyen los ceros (
> 0): los turnos cortos/salteados registranembedMs: 0y no representan una recuperación real, así no ensucian la latencia. - La latencia de búsqueda tiene un presupuesto (
QUERY_LATENCY_BUDGET_MS, 500ms); si se pasa, ese turno se descarta como contexto (ver RAG).
Gráfico de tendencia
Serie diaria del período (DATE_TRUNC('day', event_date)):
- cobertura por turno por día (línea, eje izquierdo, %);
- p95 de latencia LLM por día (línea, eje derecho, ms).
Deja ver cuándo cambió algo (un día bajó la cobertura, otro se disparó la latencia).
current vs previous (el delta)
resolvePeriodRange calcula la ventana actual y una ventana previa de la misma
duración, inmediatamente anterior. El delta de cada card es la diferencia entre ambas, en
puntos porcentuales (pp), con flecha y color (verde/rojo según si esa métrica mejora al
subir o al bajar).
Filtro de origen
Todos los eventos relevantes llevan payload.source (retell / audio_fork), así que el
filtro de origen del panel acota las mismas fórmulas a un canal.
Logs — auditoría de acciones de usuario
GET /audit/logs (gateado a supervisor). Lista solo acciones de usuario
(feedback_given, claim_initiated, call_released, patient_data_updated) vía
queryUserActions: paginado, filtros por evento / usuario / llamada / fecha, orden por fecha desc.
- Columna Usuario:
user_email(los eventos previos a la feature muestran "—"). - Columna Llamada: teléfono del llamante (
caller_id, LEFT JOIN acall_sessions); si la llamada aún no terminó y no hay sesión persistida, cae a un fragmento delcallId. - Columna Detalle: acción mapeada a español (Usada / Incorrecta / Descartada).
Notas de implementación
- La lógica de rango
current/previouses compartida: backendcommon/period.util.ts(resolvePeriodRange) y frontendlib/period.ts. - No hay migración de datos:
event_loges la fuente; los paneles solo leen y agregan. - Complementariedad: Operación te dice que algo cambió (bajó cobertura, subió latencia); Analíticas te dice si lo que sí responde es acertado. Pueden divergir.