Skip to main content

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).

PantallaPreguntaFuenteEndpoint
Analíticas (/analytics)¿Qué tan buenas son las sugerencias?tabla feedbackGET /feedback/stats
Operación (/operations)¿Cómo se comporta el sistema?tabla event_logGET /metrics/operational
Logs (/logs)¿Quién hizo qué?tabla event_logGET /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, campo payload.hit (booleano, true si 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_generated y claim_initiated (contados con COUNT(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_started inflaba 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 silence por 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: byModel es comportamiento esperado y sano; byGate es el copiloto silenciado sin que el modelo opine, y si sube hay que revisar suggestion.minWords o 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.

CapaDe dóndeCampo
Embeddingknowledge_queriedpayload.embedMs — generar el vector de la consulta (OpenAI)
Búsquedaknowledge_queriedpayload.queryMs — la query vectorial en Postgres
LLMsuggestion_generatedpayload.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.
  • llmMs mide 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 ese llmMs; la latencia percibida es bastante menor que este número.
  • Se excluyen los ceros (> 0): los turnos cortos/salteados registran embedMs: 0 y 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 a call_sessions); si la llamada aún no terminó y no hay sesión persistida, cae a un fragmento del callId.
  • Columna Detalle: acción mapeada a español (Usada / Incorrecta / Descartada).

Notas de implementación

  • La lógica de rango current/previous es compartida: backend common/period.util.ts (resolvePeriodRange) y frontend lib/period.ts.
  • No hay migración de datos: event_log es 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.