Feat: Per-Stage-Latenz-Metriken für die Voice-Pipeline (C2)

Neues services/voice_metrics.py (rollend, thread-safe, in-memory): misst STT,
Vision-Beschreibung, Chat-TTFB (Hermes-Stream) und TTS server-seitig. voice.py
instrumentiert die vier Stufen; GET /api/voice/metrics liefert avg/p50/p95/last
je Stufe. Macht aus Latenz-Vermutungen Messdaten — Anzeige folgt im Frontend (E).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-29 18:14:13 +02:00
parent c77be502f9
commit 3611defe5d
2 changed files with 88 additions and 3 deletions
+20 -3
View File
@@ -11,6 +11,7 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
import logging
import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
@@ -18,6 +19,7 @@ from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services.voice_metrics import Timer, get_metrics, record_stage # Per-Stage-Latenz (C2)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
@@ -86,6 +88,13 @@ class ChatIn(BaseModel):
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Per-Stage-Latenz (STT/Vision/Chat-TTFB/TTS) — rollende Statistik, macht die Voice-Pipeline
messbar (C2). Anzeige im Frontend-Overhaul (E)."""
return get_metrics()
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
@@ -118,7 +127,8 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
with Timer("stt"):
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
@@ -166,7 +176,8 @@ async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
@@ -190,7 +201,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
if imgs:
# Bildschirm-Sicht: erst das Vision-Modell die Monitore beschreiben lassen, dann die Beschreibung
# als TEXT-Kontext an Hermes (Lucy antwortet mit vollem Hirn/Gedächtnis, sieht via besserem VL-Modell).
desc = await _describe_images(imgs, body.text)
with Timer("vision"):
desc = await _describe_images(imgs, body.text)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
@@ -204,6 +216,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
t0 = time.perf_counter()
first = True
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
@@ -214,6 +228,9 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (gefühlte Lucy-Latenz)
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0)
first = False
yield chunk
except httpx.HTTPError as exc:
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()