Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/eva/assistant/pipeline/observers.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,14 @@ async def on_push_frame(self, data: FramePushed):
if not isinstance(frame, MetricsFrame):
return

# Deduplicate based on frame ID
frame_id = id(frame)
if frame_id in self._frames_seen:
# Deduplicate on the frame's own id, which pipecat assigns from a
# process-global monotonic counter. The same frame is observed once per
# pipeline hop, so dedup is required; id(frame) is not usable as the key
# because CPython reuses the memory address of a collected frame, which
# silently drops later, unrelated MetricsFrames.
if frame.id in self._frames_seen:
return
self._frames_seen.add(frame_id)
self._frames_seen.add(frame.id)

timestamp = self.clock.to_wall_time(data.timestamp)

Expand Down
28 changes: 27 additions & 1 deletion src/eva/metrics/experience/turn_taking.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@
computed from audit_log.json directly so it works uniformly across
cascade/S2S/audio-LLM; omitted when there are no tool calls or the
audit log is unavailable — see ``_compute_pre_tool_speech_groups``)
VAD turn diagnostics: vad_stuck_rate (fraction of classified turns where the turn analyzer
never produced a natural or forced completion signal at all —
includes both non-fatal mid-conversation hangs and, when it's also
why the conversation died via inactivity_timeout, a trailing turn
the analyzer never even opened a VAD-start window for),
forced_completion_rate
(Smart Turn's silence fallback firing rate; null for Krisp — the
mechanism doesn't exist), forced_completion_final_turn_{short,
acknowledgement,spelled_entity}_rate (among forced completions, how
often the STT transcript matched to that window has that input shape
— a candidate explanation for why the turn analyzer needed the
stop_secs fallback; omitted when there are no forced completions with
a matched transcript), mean/p50/p90_time_to_complete_ms
(natural completions only).
All null/omitted for runs with no local turn-analyzer VAD
(non-pipecat frameworks, or turn_stop_strategy == "external").
See src/eva/metrics/experience/vad_end_of_turn.py.

All reported sub-metrics are consistent with the main score: ``mean_overlap_score``,
``mean_count_score``, and ``mean_yield_score`` aggregate exactly the per-turn scores
Expand All @@ -53,6 +70,7 @@
from typing import Any

from eva.metrics.base import CodeMetric, MetricContext
from eva.metrics.experience.vad_end_of_turn import compute_vad_turn_sub_metrics
from eva.metrics.processor import is_agent_timeout_on_user_turn
from eva.metrics.registry import register_metric
from eva.metrics.utils import mean_agent_perf_stat
Expand All @@ -67,7 +85,7 @@ class TurnTakingMetric(CodeMetric):
description = "Turn-taking evaluation based on per-turn latency and interruption behavior"
category = "experience"
pass_at_k_threshold = 0.8
version = "v0.2"
version = "v0.3"

# --- Latency curve (piecewise linear). 0 outside [LATENCY_HARD_EARLY_MS, LATENCY_HARD_LATE_MS]. ---
# Ramp up 0 → 1 from LATENCY_HARD_EARLY_MS to LATENCY_SWEET_SPOT_LOW_MS.
Expand Down Expand Up @@ -585,6 +603,12 @@ async def compute(self, context: MetricContext) -> MetricScore:
"missed_turn": missed_turn,
}

vad_result = compute_vad_turn_sub_metrics(context)
vad_sub_metrics: dict[str, MetricScore] = {}
if vad_result is not None:
vad_sub_metrics, vad_per_turn = vad_result
details["vad_turns"] = vad_per_turn

if not per_turn_score:
self.logger.info(
f"[{context.record_id}] No turns with both user and assistant audio timestamps; "
Expand All @@ -595,10 +619,12 @@ async def compute(self, context: MetricContext) -> MetricScore:
score=0.0,
normalized_score=0.0,
details=details,
sub_metrics=vad_sub_metrics,
)

score = 0.0 if missed_turn else round(statistics.mean(per_turn_score.values()), 4)
sub_metrics = self._build_flat_sub_metrics(context, turn_keys, turns_with_tool_calls, per_turn_evidence)
sub_metrics.update(vad_sub_metrics)

return MetricScore(
name=self.name,
Expand Down
Loading
Loading