voiceground 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voiceground/__init__.py +38 -0
- voiceground/_static/index.html +63 -0
- voiceground/events.py +72 -0
- voiceground/metrics.py +164 -0
- voiceground/observer.py +546 -0
- voiceground/py.typed +0 -0
- voiceground/reporters/__init__.py +19 -0
- voiceground/reporters/base.py +43 -0
- voiceground/reporters/html.py +238 -0
- voiceground/reporters/metrics.py +468 -0
- voiceground-0.1.4.dist-info/METADATA +199 -0
- voiceground-0.1.4.dist-info/RECORD +14 -0
- voiceground-0.1.4.dist-info/WHEEL +4 -0
- voiceground-0.1.4.dist-info/licenses/LICENSE +26 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""HTMLReporter - Generate HTML reports from conversation events."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import webbrowser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from voiceground.events import VoicegroundEvent
|
|
9
|
+
from voiceground.reporters.base import BaseReporter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_version() -> str:
|
|
13
|
+
"""Get the package version, avoiding circular imports."""
|
|
14
|
+
try:
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
|
|
17
|
+
return version("voiceground")
|
|
18
|
+
except (PackageNotFoundError, Exception):
|
|
19
|
+
return "0.0.0+dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HTMLReporter(BaseReporter):
|
|
23
|
+
"""Reporter that records events and generates self-contained HTML reports.
|
|
24
|
+
|
|
25
|
+
Collects all events during pipeline execution and generates an interactive
|
|
26
|
+
HTML report when the pipeline ends. The report is named
|
|
27
|
+
"voiceground_report_{conversation_id}.html".
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
output_dir: Directory to write output files. Defaults to "./reports".
|
|
31
|
+
auto_open: Whether to open the HTML report in browser after generation.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
output_dir: str | Path = "./reports",
|
|
37
|
+
auto_open: bool = False,
|
|
38
|
+
):
|
|
39
|
+
self._output_dir = Path(output_dir)
|
|
40
|
+
self._auto_open = auto_open
|
|
41
|
+
self._events: list[VoicegroundEvent] = []
|
|
42
|
+
self._finalized = False
|
|
43
|
+
self._conversation_id: str | None = None
|
|
44
|
+
|
|
45
|
+
async def on_start(self, conversation_id: str) -> None:
|
|
46
|
+
"""Set the conversation ID when the pipeline starts."""
|
|
47
|
+
self._conversation_id = conversation_id
|
|
48
|
+
|
|
49
|
+
async def on_event(self, event: VoicegroundEvent) -> None:
|
|
50
|
+
"""Record an event."""
|
|
51
|
+
self._events.append(event)
|
|
52
|
+
|
|
53
|
+
async def on_end(self) -> None:
|
|
54
|
+
"""Generate HTML report."""
|
|
55
|
+
# Guard against multiple calls
|
|
56
|
+
if self._finalized:
|
|
57
|
+
return
|
|
58
|
+
self._finalized = True
|
|
59
|
+
|
|
60
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# Generate HTML report
|
|
63
|
+
events_data = [event.to_dict() for event in self._events]
|
|
64
|
+
html_path = self._generate_html_report(events_data)
|
|
65
|
+
|
|
66
|
+
if self._auto_open and html_path:
|
|
67
|
+
# Convert path to file:// URL format (works cross-platform)
|
|
68
|
+
file_url = html_path.absolute().as_uri()
|
|
69
|
+
webbrowser.open(file_url)
|
|
70
|
+
|
|
71
|
+
# Reset events for potential reuse
|
|
72
|
+
self._events = []
|
|
73
|
+
|
|
74
|
+
def _generate_html_report(self, events_data: list[dict[str, Any]]) -> Path | None:
|
|
75
|
+
"""Generate an HTML report with embedded events data.
|
|
76
|
+
|
|
77
|
+
Returns the path to the generated HTML file, or None if the
|
|
78
|
+
bundled client is not available.
|
|
79
|
+
"""
|
|
80
|
+
# Try to load bundled client template
|
|
81
|
+
template_path = Path(__file__).parent.parent / "_static" / "index.html"
|
|
82
|
+
|
|
83
|
+
if template_path.exists():
|
|
84
|
+
with open(template_path) as f:
|
|
85
|
+
template = f.read()
|
|
86
|
+
|
|
87
|
+
# Inject events data, conversation_id, and version into the template
|
|
88
|
+
events_json = json.dumps(events_data)
|
|
89
|
+
conversation_id_json = (
|
|
90
|
+
json.dumps(self._conversation_id) if self._conversation_id else "null"
|
|
91
|
+
)
|
|
92
|
+
version_json = json.dumps(_get_version())
|
|
93
|
+
script_content = f"""<script>
|
|
94
|
+
window.__VOICEGROUND_EVENTS__ = {events_json};
|
|
95
|
+
window.__VOICEGROUND_CONVERSATION_ID__ = {conversation_id_json};
|
|
96
|
+
window.__VOICEGROUND_VERSION__ = {version_json};
|
|
97
|
+
</script>"""
|
|
98
|
+
html_content = template.replace(
|
|
99
|
+
"<!-- VOICEGROUND_EVENTS_PLACEHOLDER -->",
|
|
100
|
+
script_content,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
# Fallback: generate a simple HTML page
|
|
104
|
+
events_json = json.dumps(events_data, indent=2)
|
|
105
|
+
html_content = self._generate_fallback_html(events_json)
|
|
106
|
+
|
|
107
|
+
# Generate filename with conversation_id
|
|
108
|
+
if self._conversation_id:
|
|
109
|
+
filename = f"voiceground_report_{self._conversation_id}.html"
|
|
110
|
+
else:
|
|
111
|
+
filename = "voiceground_report.html"
|
|
112
|
+
|
|
113
|
+
html_path = self._output_dir / filename
|
|
114
|
+
with open(html_path, "w") as f:
|
|
115
|
+
f.write(html_content)
|
|
116
|
+
|
|
117
|
+
return html_path
|
|
118
|
+
|
|
119
|
+
def _generate_fallback_html(self, events_json: str) -> str:
|
|
120
|
+
"""Generate a simple fallback HTML report."""
|
|
121
|
+
return f"""<!DOCTYPE html>
|
|
122
|
+
<html lang="en">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="UTF-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
126
|
+
<title>Voiceground Report</title>
|
|
127
|
+
<style>
|
|
128
|
+
:root {{
|
|
129
|
+
--bg: #0a0a0a;
|
|
130
|
+
--surface: #141414;
|
|
131
|
+
--border: #262626;
|
|
132
|
+
--text: #fafafa;
|
|
133
|
+
--text-muted: #a1a1aa;
|
|
134
|
+
--accent: #22c55e;
|
|
135
|
+
}}
|
|
136
|
+
* {{
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
box-sizing: border-box;
|
|
140
|
+
}}
|
|
141
|
+
body {{
|
|
142
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
143
|
+
background: var(--bg);
|
|
144
|
+
color: var(--text);
|
|
145
|
+
padding: 2rem;
|
|
146
|
+
line-height: 1.6;
|
|
147
|
+
}}
|
|
148
|
+
h1 {{
|
|
149
|
+
color: var(--accent);
|
|
150
|
+
margin-bottom: 1rem;
|
|
151
|
+
font-size: 1.5rem;
|
|
152
|
+
}}
|
|
153
|
+
.info {{
|
|
154
|
+
color: var(--text-muted);
|
|
155
|
+
margin-bottom: 2rem;
|
|
156
|
+
font-size: 0.875rem;
|
|
157
|
+
}}
|
|
158
|
+
table {{
|
|
159
|
+
width: 100%;
|
|
160
|
+
border-collapse: collapse;
|
|
161
|
+
background: var(--surface);
|
|
162
|
+
border-radius: 8px;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}}
|
|
165
|
+
th, td {{
|
|
166
|
+
padding: 0.75rem 1rem;
|
|
167
|
+
text-align: left;
|
|
168
|
+
border-bottom: 1px solid var(--border);
|
|
169
|
+
}}
|
|
170
|
+
th {{
|
|
171
|
+
background: var(--border);
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
color: var(--text-muted);
|
|
174
|
+
text-transform: uppercase;
|
|
175
|
+
font-size: 0.75rem;
|
|
176
|
+
letter-spacing: 0.05em;
|
|
177
|
+
}}
|
|
178
|
+
tr:hover {{
|
|
179
|
+
background: rgba(34, 197, 94, 0.05);
|
|
180
|
+
}}
|
|
181
|
+
.category {{
|
|
182
|
+
padding: 0.25rem 0.5rem;
|
|
183
|
+
border-radius: 4px;
|
|
184
|
+
font-size: 0.75rem;
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
}}
|
|
187
|
+
.user_speak {{ background: #3b82f620; color: #60a5fa; }}
|
|
188
|
+
.bot_speak {{ background: #22c55e20; color: #4ade80; }}
|
|
189
|
+
.stt {{ background: #f59e0b20; color: #fbbf24; }}
|
|
190
|
+
.llm {{ background: #a855f720; color: #c084fc; }}
|
|
191
|
+
.tts {{ background: #ec489920; color: #f472b6; }}
|
|
192
|
+
.type {{
|
|
193
|
+
color: var(--text-muted);
|
|
194
|
+
font-size: 0.875rem;
|
|
195
|
+
}}
|
|
196
|
+
.data {{
|
|
197
|
+
font-size: 0.75rem;
|
|
198
|
+
color: var(--text-muted);
|
|
199
|
+
max-width: 300px;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
text-overflow: ellipsis;
|
|
202
|
+
white-space: nowrap;
|
|
203
|
+
}}
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<h1>Voiceground Report</h1>
|
|
208
|
+
<p class="info">Conversation events captured by Voiceground observer</p>
|
|
209
|
+
<table>
|
|
210
|
+
<thead>
|
|
211
|
+
<tr>
|
|
212
|
+
<th>Timestamp</th>
|
|
213
|
+
<th>Category</th>
|
|
214
|
+
<th>Type</th>
|
|
215
|
+
<th>Data</th>
|
|
216
|
+
</tr>
|
|
217
|
+
</thead>
|
|
218
|
+
<tbody id="events-body">
|
|
219
|
+
</tbody>
|
|
220
|
+
</table>
|
|
221
|
+
<script>
|
|
222
|
+
const events = {events_json};
|
|
223
|
+
const tbody = document.getElementById('events-body');
|
|
224
|
+
events.forEach(event => {{
|
|
225
|
+
const row = document.createElement('tr');
|
|
226
|
+
const timestamp = new Date(event.timestamp * 1000).toISOString();
|
|
227
|
+
row.innerHTML = `
|
|
228
|
+
<td>${{timestamp}}</td>
|
|
229
|
+
<td><span class="category ${{event.category}}">${{event.category}}</span></td>
|
|
230
|
+
<td class="type">${{event.type}}</td>
|
|
231
|
+
<td class="data">${{JSON.stringify(event.data)}}</td>
|
|
232
|
+
`;
|
|
233
|
+
tbody.appendChild(row);
|
|
234
|
+
}});
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>
|
|
238
|
+
"""
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""MetricsReporter - Generate MetricsFrame objects from conversation events."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pipecat.frames.frames import MetricsFrame
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from voiceground.events import EventCategory, EventType, VoicegroundEvent
|
|
11
|
+
from voiceground.metrics import (
|
|
12
|
+
VoicegroundLLMResponseTimeFrame,
|
|
13
|
+
VoicegroundResponseTimeFrame,
|
|
14
|
+
VoicegroundSystemOverheadFrame,
|
|
15
|
+
VoicegroundToolUsageFrame,
|
|
16
|
+
VoicegroundTranscriptionOverheadFrame,
|
|
17
|
+
VoicegroundTurnDurationFrame,
|
|
18
|
+
VoicegroundVoiceSynthesisOverheadFrame,
|
|
19
|
+
)
|
|
20
|
+
from voiceground.reporters.base import BaseReporter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolCallData(BaseModel):
|
|
24
|
+
"""Type-safe model for tool call data.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
name: Name of the tool/function that was called.
|
|
28
|
+
duration: Duration of the tool call in milliseconds.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
duration: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SystemOverheadData(BaseModel):
|
|
36
|
+
"""Type-safe model for system overhead data.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
name: Name/type of the system operation (e.g., "context_aggregation_timeout").
|
|
40
|
+
duration: Duration of the system operation in milliseconds.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
duration: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TurnMetricsData(BaseModel):
|
|
48
|
+
"""Type-safe model for calculated turn metrics.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
turn_duration: Total turn duration in milliseconds.
|
|
52
|
+
response_time: Response time in milliseconds (or None).
|
|
53
|
+
transcription_overhead: Transcription overhead in milliseconds (or None).
|
|
54
|
+
voice_synthesis_overhead: Voice synthesis overhead in milliseconds (or None).
|
|
55
|
+
llm_response_time: LLM response time in milliseconds (or None).
|
|
56
|
+
llm_net_time: LLM net time (without tools) in milliseconds (or None).
|
|
57
|
+
system_overheads: List of individual system overhead data.
|
|
58
|
+
tool_calls: List of individual tool call data.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
turn_duration: float
|
|
62
|
+
response_time: float | None
|
|
63
|
+
transcription_overhead: float | None
|
|
64
|
+
voice_synthesis_overhead: float | None
|
|
65
|
+
llm_response_time: float | None
|
|
66
|
+
llm_net_time: float | None
|
|
67
|
+
system_overheads: list[SystemOverheadData]
|
|
68
|
+
tool_calls: list[ToolCallData]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Pure functional helpers for event processing
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_event(
|
|
75
|
+
events: list[VoicegroundEvent], category: EventCategory, event_type: EventType
|
|
76
|
+
) -> VoicegroundEvent | None:
|
|
77
|
+
"""Find the first event matching category and type."""
|
|
78
|
+
return next(
|
|
79
|
+
(e for e in events if e.category == category and e.type == event_type),
|
|
80
|
+
None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_all_events(
|
|
85
|
+
events: list[VoicegroundEvent], category: EventCategory, event_type: EventType
|
|
86
|
+
) -> list[VoicegroundEvent]:
|
|
87
|
+
"""Find all events matching category and type."""
|
|
88
|
+
return [e for e in events if e.category == category and e.type == event_type]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def calculate_duration_ms(
|
|
92
|
+
start: VoicegroundEvent | None, end: VoicegroundEvent | None
|
|
93
|
+
) -> float | None:
|
|
94
|
+
"""Calculate duration between two events in milliseconds."""
|
|
95
|
+
if start and end:
|
|
96
|
+
return (end.timestamp - start.timestamp) * 1000
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def extract_operation_name(start_event: VoicegroundEvent, end_event: VoicegroundEvent) -> str:
|
|
101
|
+
"""Extract operation name from event data, with fallback."""
|
|
102
|
+
return (
|
|
103
|
+
end_event.data.get("operation", "")
|
|
104
|
+
or start_event.data.get("operation", "")
|
|
105
|
+
or "unknown_operation"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def find_matching_end_event(
|
|
110
|
+
events: list[VoicegroundEvent],
|
|
111
|
+
start_event: VoicegroundEvent,
|
|
112
|
+
category: EventCategory,
|
|
113
|
+
min_timestamp: float,
|
|
114
|
+
max_timestamp: float,
|
|
115
|
+
) -> VoicegroundEvent | None:
|
|
116
|
+
"""Find the matching end event for a start event within a time range."""
|
|
117
|
+
return next(
|
|
118
|
+
(
|
|
119
|
+
e
|
|
120
|
+
for e in events
|
|
121
|
+
if e.category == category
|
|
122
|
+
and e.type == EventType.END
|
|
123
|
+
and e.timestamp > start_event.timestamp
|
|
124
|
+
and min_timestamp <= e.timestamp <= max_timestamp
|
|
125
|
+
),
|
|
126
|
+
None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def calculate_tool_calls(
|
|
131
|
+
events: list[VoicegroundEvent], llm_start: VoicegroundEvent, llm_end: VoicegroundEvent
|
|
132
|
+
) -> list[ToolCallData]:
|
|
133
|
+
"""Calculate individual tool calls that start during LLM phase.
|
|
134
|
+
|
|
135
|
+
Tool calls can start during the LLM phase and may end after the LLM phase ends.
|
|
136
|
+
"""
|
|
137
|
+
tool_call_starts = [
|
|
138
|
+
e
|
|
139
|
+
for e in find_all_events(events, EventCategory.TOOL_CALL, EventType.START)
|
|
140
|
+
if llm_start.timestamp <= e.timestamp <= llm_end.timestamp
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
tool_calls = []
|
|
144
|
+
used_end_events = set()
|
|
145
|
+
|
|
146
|
+
for start_event in tool_call_starts:
|
|
147
|
+
# Find matching end event - it can be after llm_end
|
|
148
|
+
# Use a large max_timestamp to allow tool calls to end after LLM phase
|
|
149
|
+
end_event = next(
|
|
150
|
+
(
|
|
151
|
+
e
|
|
152
|
+
for e in events
|
|
153
|
+
if e.id not in used_end_events
|
|
154
|
+
and e.category == EventCategory.TOOL_CALL
|
|
155
|
+
and e.type == EventType.END
|
|
156
|
+
and e.timestamp > start_event.timestamp
|
|
157
|
+
and (
|
|
158
|
+
(e.data.get("operation") == start_event.data.get("operation"))
|
|
159
|
+
or (e.data.get("name") == start_event.data.get("name"))
|
|
160
|
+
)
|
|
161
|
+
),
|
|
162
|
+
None,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if end_event:
|
|
166
|
+
used_end_events.add(end_event.id)
|
|
167
|
+
duration_ms = calculate_duration_ms(start_event, end_event)
|
|
168
|
+
if duration_ms is not None:
|
|
169
|
+
tool_name = extract_operation_name(start_event, end_event).replace(
|
|
170
|
+
"unknown_operation", "unknown_tool"
|
|
171
|
+
)
|
|
172
|
+
tool_calls.append(ToolCallData(name=tool_name, duration=duration_ms))
|
|
173
|
+
|
|
174
|
+
return tool_calls
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def calculate_system_overheads(
|
|
178
|
+
events: list[VoicegroundEvent], stt_end: VoicegroundEvent, llm_start: VoicegroundEvent
|
|
179
|
+
) -> list[SystemOverheadData]:
|
|
180
|
+
"""Calculate individual system overheads between stt:end and llm:start."""
|
|
181
|
+
system_starts = [
|
|
182
|
+
e
|
|
183
|
+
for e in find_all_events(events, EventCategory.SYSTEM, EventType.START)
|
|
184
|
+
if stt_end.timestamp <= e.timestamp <= llm_start.timestamp
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
system_overheads = []
|
|
188
|
+
for start_event in system_starts:
|
|
189
|
+
end_event = find_matching_end_event(
|
|
190
|
+
events, start_event, EventCategory.SYSTEM, stt_end.timestamp, llm_start.timestamp
|
|
191
|
+
)
|
|
192
|
+
if end_event:
|
|
193
|
+
duration_ms = calculate_duration_ms(start_event, end_event)
|
|
194
|
+
if duration_ms is not None:
|
|
195
|
+
operation_name = extract_operation_name(start_event, end_event)
|
|
196
|
+
system_overheads.append(
|
|
197
|
+
SystemOverheadData(name=operation_name, duration=duration_ms)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return system_overheads
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def calculate_response_time(
|
|
204
|
+
events: list[VoicegroundEvent],
|
|
205
|
+
user_speak_end: VoicegroundEvent | None,
|
|
206
|
+
bot_speak_start: VoicegroundEvent | None,
|
|
207
|
+
) -> float | None:
|
|
208
|
+
"""Calculate response time from user_speak:end to bot_speak:start."""
|
|
209
|
+
if user_speak_end and bot_speak_start:
|
|
210
|
+
return calculate_duration_ms(user_speak_end, bot_speak_start)
|
|
211
|
+
|
|
212
|
+
if not user_speak_end and bot_speak_start:
|
|
213
|
+
first_event_time = min((e.timestamp for e in events), default=0.0)
|
|
214
|
+
return (bot_speak_start.timestamp - first_event_time) * 1000
|
|
215
|
+
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def calculate_llm_net_time(
|
|
220
|
+
llm_response_time: float | None, tools_total_duration: float
|
|
221
|
+
) -> float | None:
|
|
222
|
+
"""Calculate LLM net time (response time minus tools overhead)."""
|
|
223
|
+
if llm_response_time is None:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
if tools_total_duration > 0:
|
|
227
|
+
return llm_response_time - tools_total_duration
|
|
228
|
+
|
|
229
|
+
# If no tools, net time equals total time
|
|
230
|
+
return llm_response_time
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def calculate_turn_metrics(events: list[VoicegroundEvent]) -> TurnMetricsData:
|
|
234
|
+
"""Calculate all metrics for a turn from events."""
|
|
235
|
+
# Find key events
|
|
236
|
+
user_speak_end = find_event(events, EventCategory.USER_SPEAK, EventType.END)
|
|
237
|
+
stt_end = find_event(events, EventCategory.STT, EventType.END)
|
|
238
|
+
llm_first_byte = find_event(events, EventCategory.LLM, EventType.FIRST_BYTE)
|
|
239
|
+
tts_start = find_event(events, EventCategory.TTS, EventType.START)
|
|
240
|
+
bot_speak_start = find_event(events, EventCategory.BOT_SPEAK, EventType.START)
|
|
241
|
+
|
|
242
|
+
# Use the first LLM start for metrics
|
|
243
|
+
llm_starts = find_all_events(events, EventCategory.LLM, EventType.START)
|
|
244
|
+
llm_start = min(llm_starts, key=lambda e: e.timestamp) if llm_starts else None
|
|
245
|
+
llm_end = find_event(events, EventCategory.LLM, EventType.END)
|
|
246
|
+
|
|
247
|
+
# Calculate time ranges
|
|
248
|
+
first_event_time = min((e.timestamp for e in events), default=0.0)
|
|
249
|
+
last_event_time = max((e.timestamp for e in events), default=0.0)
|
|
250
|
+
turn_duration_ms = (last_event_time - first_event_time) * 1000
|
|
251
|
+
|
|
252
|
+
# Calculate metrics
|
|
253
|
+
response_time = calculate_response_time(events, user_speak_end, bot_speak_start)
|
|
254
|
+
transcription_overhead = calculate_duration_ms(user_speak_end, stt_end)
|
|
255
|
+
voice_synthesis_overhead = calculate_duration_ms(tts_start, bot_speak_start)
|
|
256
|
+
llm_response_time = calculate_duration_ms(llm_start, llm_first_byte)
|
|
257
|
+
|
|
258
|
+
# Calculate tool calls and system overheads
|
|
259
|
+
tool_calls = calculate_tool_calls(events, llm_start, llm_end) if llm_start and llm_end else []
|
|
260
|
+
tools_total_duration = sum(tc.duration for tc in tool_calls)
|
|
261
|
+
llm_net_time = calculate_llm_net_time(llm_response_time, tools_total_duration)
|
|
262
|
+
|
|
263
|
+
system_overheads = (
|
|
264
|
+
calculate_system_overheads(events, stt_end, llm_start) if stt_end and llm_start else []
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return TurnMetricsData(
|
|
268
|
+
turn_duration=turn_duration_ms,
|
|
269
|
+
response_time=response_time,
|
|
270
|
+
transcription_overhead=transcription_overhead,
|
|
271
|
+
voice_synthesis_overhead=voice_synthesis_overhead,
|
|
272
|
+
llm_response_time=llm_response_time,
|
|
273
|
+
llm_net_time=llm_net_time,
|
|
274
|
+
system_overheads=system_overheads,
|
|
275
|
+
tool_calls=tool_calls,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Pure functional helpers for metric frame creation
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def create_all_metric_frames(metrics: TurnMetricsData) -> list[MetricsFrame]:
|
|
283
|
+
"""Create all MetricsFrame objects for a turn's metrics."""
|
|
284
|
+
frames: list[MetricsFrame] = []
|
|
285
|
+
|
|
286
|
+
# Turn duration (always present)
|
|
287
|
+
frames.append(VoicegroundTurnDurationFrame(value=metrics.turn_duration / 1000))
|
|
288
|
+
|
|
289
|
+
# Optional metrics
|
|
290
|
+
if metrics.response_time is not None:
|
|
291
|
+
frames.append(VoicegroundResponseTimeFrame(value=metrics.response_time / 1000))
|
|
292
|
+
|
|
293
|
+
if metrics.transcription_overhead is not None:
|
|
294
|
+
frames.append(
|
|
295
|
+
VoicegroundTranscriptionOverheadFrame(value=metrics.transcription_overhead / 1000)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if metrics.voice_synthesis_overhead is not None:
|
|
299
|
+
frames.append(
|
|
300
|
+
VoicegroundVoiceSynthesisOverheadFrame(value=metrics.voice_synthesis_overhead / 1000)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if metrics.llm_response_time is not None:
|
|
304
|
+
frames.append(
|
|
305
|
+
VoicegroundLLMResponseTimeFrame(
|
|
306
|
+
value=metrics.llm_response_time / 1000,
|
|
307
|
+
net_value=metrics.llm_net_time / 1000 if metrics.llm_net_time is not None else None,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# System overheads
|
|
312
|
+
for so in metrics.system_overheads:
|
|
313
|
+
frames.append(
|
|
314
|
+
VoicegroundSystemOverheadFrame(
|
|
315
|
+
value=so.duration / 1000,
|
|
316
|
+
operation_name=so.name,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Tool usage
|
|
321
|
+
for tc in metrics.tool_calls:
|
|
322
|
+
frames.append(
|
|
323
|
+
VoicegroundToolUsageFrame(
|
|
324
|
+
value=tc.duration / 1000,
|
|
325
|
+
tool_name=tc.name,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return frames
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class MetricsReporter(BaseReporter):
|
|
333
|
+
"""Reporter that creates MetricsFrame objects from conversation events.
|
|
334
|
+
|
|
335
|
+
Calculates opinionated metrics per turn and creates MetricsFrame objects
|
|
336
|
+
with custom Voiceground metric classes. Supports an optional callback
|
|
337
|
+
for real-time metric processing (e.g., for Prometheus integration).
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
on_metric_reported: Optional callback function called immediately after
|
|
341
|
+
each MetricsFrame is created. Can be sync or async.
|
|
342
|
+
Signature: Callable[[MetricsFrame], Awaitable[None] | None]
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
def __init__(
|
|
346
|
+
self,
|
|
347
|
+
on_metric_reported: Callable[[MetricsFrame], Awaitable[None] | None] | None = None,
|
|
348
|
+
):
|
|
349
|
+
self._events: list[VoicegroundEvent] = []
|
|
350
|
+
self._metrics_frames: list[MetricsFrame] = []
|
|
351
|
+
self._on_metric_reported = on_metric_reported
|
|
352
|
+
self._conversation_id: str | None = None
|
|
353
|
+
|
|
354
|
+
async def on_start(self, conversation_id: str) -> None:
|
|
355
|
+
"""Set the conversation ID when the pipeline starts."""
|
|
356
|
+
self._conversation_id = conversation_id
|
|
357
|
+
self._events = []
|
|
358
|
+
self._metrics_frames = []
|
|
359
|
+
|
|
360
|
+
async def on_event(self, event: VoicegroundEvent) -> None:
|
|
361
|
+
"""Record an event."""
|
|
362
|
+
self._events.append(event)
|
|
363
|
+
|
|
364
|
+
async def on_end(self) -> None:
|
|
365
|
+
"""Calculate metrics and create MetricsFrame objects."""
|
|
366
|
+
if not self._events:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
turns = self._parse_turns(self._events)
|
|
370
|
+
|
|
371
|
+
for turn in turns:
|
|
372
|
+
metrics = calculate_turn_metrics(turn["events"])
|
|
373
|
+
await self._create_metrics_frames(metrics, turn["turn_id"])
|
|
374
|
+
|
|
375
|
+
def get_metrics_frames(self) -> list[MetricsFrame]:
|
|
376
|
+
"""Get all generated MetricsFrame objects.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
List of MetricsFrame objects, one per metric per turn.
|
|
380
|
+
"""
|
|
381
|
+
return self._metrics_frames.copy()
|
|
382
|
+
|
|
383
|
+
def _parse_turns(self, events: list[VoicegroundEvent]) -> list[dict[str, Any]]:
|
|
384
|
+
"""Parse events into turns, matching UI logic from TurnsView.tsx."""
|
|
385
|
+
if not events:
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
sorted_events = sorted(events, key=lambda e: e.timestamp)
|
|
389
|
+
turns: list[dict[str, Any]] = []
|
|
390
|
+
current_turn: dict[str, Any] | None = None
|
|
391
|
+
turn_id = 0
|
|
392
|
+
|
|
393
|
+
for event in sorted_events:
|
|
394
|
+
if event.category == EventCategory.USER_SPEAK and event.type == EventType.START:
|
|
395
|
+
if current_turn:
|
|
396
|
+
# Close the current turn
|
|
397
|
+
if current_turn["events"]:
|
|
398
|
+
current_turn["end_time"] = current_turn["events"][-1].timestamp
|
|
399
|
+
turns.append(current_turn)
|
|
400
|
+
current_turn = {
|
|
401
|
+
"turn_id": turn_id,
|
|
402
|
+
"start_time": event.timestamp,
|
|
403
|
+
"end_time": event.timestamp,
|
|
404
|
+
"events": [event],
|
|
405
|
+
}
|
|
406
|
+
turn_id += 1
|
|
407
|
+
elif current_turn:
|
|
408
|
+
# Check if this event belongs to the previous turn
|
|
409
|
+
if (
|
|
410
|
+
event.category == EventCategory.BOT_SPEAK
|
|
411
|
+
and event.type == EventType.END
|
|
412
|
+
and turns
|
|
413
|
+
):
|
|
414
|
+
last_turn = turns[-1]
|
|
415
|
+
if event.timestamp - last_turn["end_time"] < 0.1:
|
|
416
|
+
last_turn["events"].append(event)
|
|
417
|
+
last_turn["end_time"] = event.timestamp
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
current_turn["events"].append(event)
|
|
421
|
+
current_turn["end_time"] = event.timestamp
|
|
422
|
+
|
|
423
|
+
if event.category == EventCategory.BOT_SPEAK and event.type == EventType.END:
|
|
424
|
+
turns.append(current_turn)
|
|
425
|
+
current_turn = None
|
|
426
|
+
else:
|
|
427
|
+
# If no current turn and no turns yet, start a turn on the first event
|
|
428
|
+
if not turns:
|
|
429
|
+
current_turn = {
|
|
430
|
+
"turn_id": turn_id,
|
|
431
|
+
"start_time": event.timestamp,
|
|
432
|
+
"end_time": event.timestamp,
|
|
433
|
+
"events": [event],
|
|
434
|
+
}
|
|
435
|
+
turn_id += 1
|
|
436
|
+
else:
|
|
437
|
+
# Try to add to the last turn if within 2 seconds
|
|
438
|
+
last_turn = turns[-1]
|
|
439
|
+
if event.timestamp - last_turn["end_time"] < 2:
|
|
440
|
+
last_turn["events"].append(event)
|
|
441
|
+
last_turn["end_time"] = event.timestamp
|
|
442
|
+
|
|
443
|
+
if current_turn:
|
|
444
|
+
turns.append(current_turn)
|
|
445
|
+
|
|
446
|
+
return turns
|
|
447
|
+
|
|
448
|
+
async def _create_metrics_frames(self, metrics: TurnMetricsData, turn_id: int) -> None:
|
|
449
|
+
"""Create MetricsFrame objects for all metrics and call callback if provided."""
|
|
450
|
+
metric_frames = create_all_metric_frames(metrics)
|
|
451
|
+
|
|
452
|
+
for metrics_frame in metric_frames:
|
|
453
|
+
self._metrics_frames.append(metrics_frame)
|
|
454
|
+
|
|
455
|
+
if self._on_metric_reported:
|
|
456
|
+
await self._invoke_callback(metrics_frame)
|
|
457
|
+
|
|
458
|
+
async def _invoke_callback(self, metrics_frame: MetricsFrame) -> None:
|
|
459
|
+
"""Invoke the callback function, handling both sync and async cases."""
|
|
460
|
+
if self._on_metric_reported is None:
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if inspect.iscoroutinefunction(self._on_metric_reported):
|
|
464
|
+
await self._on_metric_reported(metrics_frame)
|
|
465
|
+
else:
|
|
466
|
+
result = self._on_metric_reported(metrics_frame)
|
|
467
|
+
if inspect.isawaitable(result):
|
|
468
|
+
await result
|