rtx-csm: Phase 13.6 — emotion metrics for converse_server

Production observability on the Phase 13.4/13.5 stack. Adds Prometheus
counters to the existing /metrics endpoint:

  rtx_csm_reactive_emotion_calls_total          — detector invocations
  rtx_csm_reactive_emotion_total{label=...}     — 5 buckets (neutral,
                                                  calm, sad, angry,
                                                  excited)
  rtx_csm_emotion_aware_llm_applied_total       — LLM augmentations
                                                  that actually fired

Verified end-to-end on Metal: server with Q8 + LoRA + reactive-emotion
+ emotion-aware-llm, 1 bench turn → /metrics shows
calls=1, calm=1 (all other labels 0), llm_applied=1. Lib suite 110/110.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 21:20:39 -07:00
co-authored by Claude Opus 4.7
parent 273a92dcb9
commit 01081eb45a
@@ -389,6 +389,18 @@ struct Metrics {
/// Total turn wall time: turn start → done event sent. /// Total turn wall time: turn start → done event sent.
total_turn_ms_sum: AtomicU64, total_turn_ms_sum: AtomicU64,
total_turn_ms_count: AtomicU64, total_turn_ms_count: AtomicU64,
/// Reactive emotion observability (Phase 13.6). Each turn that ran
/// the detector increments `_calls_total`; the per-label counters
/// add to one of the five labels. `emotion_aware_llm_applied_total`
/// counts turns where the LLM-history augmentation actually fired
/// (only when the label is non-Neutral AND --emotion-aware-llm is on).
reactive_emotion_calls_total: AtomicU64,
reactive_emotion_neutral_total: AtomicU64,
reactive_emotion_calm_total: AtomicU64,
reactive_emotion_sad_total: AtomicU64,
reactive_emotion_angry_total: AtomicU64,
reactive_emotion_excited_total: AtomicU64,
emotion_aware_llm_applied_total: AtomicU64,
} }
/// ASR backend wrapped behind a single Mutex so the receive loop can /// ASR backend wrapped behind a single Mutex so the receive loop can
@@ -916,7 +928,17 @@ async fn metrics_handler(State(shared): State<Arc<Shared>>) -> impl IntoResponse
# TYPE rtx_csm_conv_total_ms_avg gauge\n\ # TYPE rtx_csm_conv_total_ms_avg gauge\n\
rtx_csm_conv_total_ms_avg {}\n\ rtx_csm_conv_total_ms_avg {}\n\
# TYPE rtx_csm_total_turn_ms_avg gauge\n\ # TYPE rtx_csm_total_turn_ms_avg gauge\n\
rtx_csm_total_turn_ms_avg {}\n", rtx_csm_total_turn_ms_avg {}\n\
# TYPE rtx_csm_reactive_emotion_calls_total counter\n\
rtx_csm_reactive_emotion_calls_total {}\n\
# TYPE rtx_csm_reactive_emotion_total counter\n\
rtx_csm_reactive_emotion_total{{label=\"neutral\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"calm\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"sad\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"angry\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"excited\"}} {}\n\
# TYPE rtx_csm_emotion_aware_llm_applied_total counter\n\
rtx_csm_emotion_aware_llm_applied_total {}\n",
m.turns_total.load(Ordering::Relaxed), m.turns_total.load(Ordering::Relaxed),
m.errors_total.load(Ordering::Relaxed), m.errors_total.load(Ordering::Relaxed),
m.connections_total.load(Ordering::Relaxed), m.connections_total.load(Ordering::Relaxed),
@@ -928,6 +950,13 @@ async fn metrics_handler(State(shared): State<Arc<Shared>>) -> impl IntoResponse
m.llm_to_first_audio_ms_sum.load(Ordering::Relaxed) / llm_to_audio_count, m.llm_to_first_audio_ms_sum.load(Ordering::Relaxed) / llm_to_audio_count,
m.conv_total_ms_sum.load(Ordering::Relaxed) / conv_count, m.conv_total_ms_sum.load(Ordering::Relaxed) / conv_count,
m.total_turn_ms_sum.load(Ordering::Relaxed) / total_count, m.total_turn_ms_sum.load(Ordering::Relaxed) / total_count,
m.reactive_emotion_calls_total.load(Ordering::Relaxed),
m.reactive_emotion_neutral_total.load(Ordering::Relaxed),
m.reactive_emotion_calm_total.load(Ordering::Relaxed),
m.reactive_emotion_sad_total.load(Ordering::Relaxed),
m.reactive_emotion_angry_total.load(Ordering::Relaxed),
m.reactive_emotion_excited_total.load(Ordering::Relaxed),
m.emotion_aware_llm_applied_total.load(Ordering::Relaxed),
); );
( (
StatusCode::OK, StatusCode::OK,
@@ -1293,11 +1322,34 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
.len() .len()
.saturating_sub((PCM_RATE as f32 * 2.0) as usize); .saturating_sub((PCM_RATE as f32 * 2.0) as usize);
let speech = &user_audio_24k[..trim_to]; let speech = &user_audio_24k[..trim_to];
shared
.metrics
.reactive_emotion_calls_total
.fetch_add(1, Ordering::Relaxed);
match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) { match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) {
Ok(speech_16k) => { Ok(speech_16k) => {
let det = rtx_csm::ser::ProsodyDetector::default(); let det = rtx_csm::ser::ProsodyDetector::default();
let label = rtx_csm::ser::EmotionDetector::classify(&det, &speech_16k) let label = rtx_csm::ser::EmotionDetector::classify(&det, &speech_16k)
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral); .unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
// Per-label counters.
let bucket = match label {
rtx_csm::ser::EmotionLabel::Neutral => {
&shared.metrics.reactive_emotion_neutral_total
}
rtx_csm::ser::EmotionLabel::Calm => {
&shared.metrics.reactive_emotion_calm_total
}
rtx_csm::ser::EmotionLabel::Sad => {
&shared.metrics.reactive_emotion_sad_total
}
rtx_csm::ser::EmotionLabel::Angry => {
&shared.metrics.reactive_emotion_angry_total
}
rtx_csm::ser::EmotionLabel::Excited => {
&shared.metrics.reactive_emotion_excited_total
}
};
bucket.fetch_add(1, Ordering::Relaxed);
if label == rtx_csm::ser::EmotionLabel::Neutral { if label == rtx_csm::ser::EmotionLabel::Neutral {
None // fall back to static --emotion-hint None // fall back to static --emotion-hint
} else { } else {
@@ -1362,6 +1414,10 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
"{}\n\n[user audio tone: {label} — adjust your response in tone and content to match]", "{}\n\n[user audio tone: {label} — adjust your response in tone and content to match]",
last.content last.content
); );
shared
.metrics
.emotion_aware_llm_applied_total
.fetch_add(1, Ordering::Relaxed);
} }
} }
h h