Observability 1a: Per-Turn-Latenz-Trace + Latenz-Karte
voice_metrics.py: neben den rollenden Stats jetzt ein echter Per-Turn-Trace (TurnTrace + Ringpuffer der letzten 60 Turns). Balken-Stufen zeitlich disjunkt (stt · vision · hirn · gen); hirn misst ab mark_brain_start() VOR dem Hermes- Request, damit die Vision-Zeit nicht doppelt gezaehlt wird. STT (davor) und Mem0-Retrieve (Rueckruf waehrend) werden per park()/_take_* best-effort dem Turn zugeordnet (Ein-Nutzer-Geraet, kein Turn-ID noetig). Mem0 = Unter-Detail INNERHALB hirn (nicht addieren) -> ehrlich, kein Doppelzaehlen. voice.py: TurnTrace in voice_chat's gen() (commit garantiert 1x via finally, auch bei Fehler/Abbruch). STT-Endpoint parkt seine Dauer. NEU: GET /api/voice/metrics (schliesst die Luecke - selbstkritik-feed.sh curlte das, existierte nie -> 404) + GET /api/voice/trace?limit=N. memory.py: GET /api/memory?q=... (= Hermes' Mem0-Prefetch) misst + parkt die Retrieve-Zeit. Frontend: LatencyCard (gestapelte Balken je Turn, "Taeter" = groesste Stufe, Fehler-Turns rot, Tooltip mit "davon Mem0 X s"), Query useVoiceTrace, in der Zentrale unter "Stack & Telemetrie". Lokal gegen Seed-Server verifiziert: 30,3-s- Haenger -> Hirn-Balken 94% + "davon Mem0 26,1 s". Grenzen (ehrlich, als Fussnote in der Karte): Tool-Runden im Hermes-LLM-Loop haben keinen Callback an MC2 -> stecken in "Antwort". Reine Telegram-Text-Turns laufen an MC2 vorbei und erscheinen hier nicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import memory
|
from services import memory
|
||||||
|
from services.voice_metrics import park, record_stage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -56,6 +59,15 @@ def dedupe(body: DedupeIn) -> dict:
|
|||||||
|
|
||||||
@router.get("/memory")
|
@router.get("/memory")
|
||||||
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
||||||
|
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
|
||||||
|
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
|
||||||
|
if q:
|
||||||
|
_t0 = time.perf_counter()
|
||||||
|
res = memory.list_memories(q=q, category=category)
|
||||||
|
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
|
record_stage("memory_retrieve", _ms)
|
||||||
|
park("retrieve", _ms)
|
||||||
|
return res
|
||||||
return memory.list_memories(q=q, category=category)
|
return memory.list_memories(q=q, category=category)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+88
-46
@@ -20,7 +20,14 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||||
from services import announce
|
from services import announce
|
||||||
from services.voice_metrics import Timer, record_stage # Per-Stage-Latenz (intern)
|
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||||
|
Timer,
|
||||||
|
TurnTrace,
|
||||||
|
get_metrics,
|
||||||
|
get_trace,
|
||||||
|
park,
|
||||||
|
record_stage,
|
||||||
|
)
|
||||||
|
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
import sys as _sys
|
import sys as _sys
|
||||||
@@ -153,6 +160,20 @@ def voice_health() -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voice/metrics")
|
||||||
|
def voice_metrics() -> dict:
|
||||||
|
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
||||||
|
return get_metrics()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voice/trace")
|
||||||
|
def voice_trace(limit: int = 20) -> dict:
|
||||||
|
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
||||||
|
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||||
|
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
||||||
|
return {"turns": get_trace(limit)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/voice/voices")
|
@router.get("/voice/voices")
|
||||||
def voice_voices() -> dict:
|
def voice_voices() -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -172,8 +193,11 @@ 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")}
|
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
with Timer("stt"):
|
_t0 = time.perf_counter()
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||||
|
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
|
record_stage("stt", _ms)
|
||||||
|
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
@@ -265,51 +289,69 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
|||||||
headers["X-Hermes-Session-Key"] = body.session_key
|
headers["X-Hermes-Session-Key"] = body.session_key
|
||||||
|
|
||||||
async def gen():
|
async def gen():
|
||||||
t0 = time.perf_counter()
|
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
||||||
|
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||||
|
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||||
first = True
|
first = True
|
||||||
first_content = True
|
first_content = True
|
||||||
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
committed = False
|
||||||
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
|
||||||
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
def _commit() -> None:
|
||||||
user_text = body.text
|
nonlocal committed
|
||||||
imgs = [u for u in (body.images or []) if u]
|
if not committed:
|
||||||
if imgs:
|
committed = True
|
||||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
trace.commit()
|
||||||
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}"
|
|
||||||
messages = []
|
|
||||||
if body.system:
|
|
||||||
messages.append({"role": "system", "content": body.system})
|
|
||||||
messages.append({"role": "user", "content": user_text})
|
|
||||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
|
||||||
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
|
||||||
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
|
||||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
|
||||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
|
||||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
||||||
async with client.stream(
|
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
||||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
||||||
) as r:
|
user_text = body.text
|
||||||
if r.status_code != 200:
|
imgs = [u for u in (body.images or []) if u]
|
||||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
trace.had_images = bool(imgs)
|
||||||
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
if imgs:
|
||||||
return
|
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||||
async for chunk in r.aiter_raw():
|
_tv = time.perf_counter()
|
||||||
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
desc = await _describe_images(imgs, body.text)
|
||||||
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0)
|
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
|
||||||
first = False
|
if desc:
|
||||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + LLM-TTFT) —
|
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||||
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||||
if first_content and b'"content"' in chunk:
|
messages = []
|
||||||
record_stage("chat_first_content", (time.perf_counter() - t0) * 1000.0)
|
if body.system:
|
||||||
first_content = False
|
messages.append({"role": "system", "content": body.system})
|
||||||
yield chunk
|
messages.append({"role": "user", "content": user_text})
|
||||||
except httpx.HTTPError as exc:
|
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
|
||||||
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||||
|
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||||
|
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||||
|
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
||||||
|
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||||
|
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||||
|
) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||||
|
trace.error = f"Hermes {r.status_code}"
|
||||||
|
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 (Verbindungs-Overhead)
|
||||||
|
trace.note_ttfb()
|
||||||
|
first = False
|
||||||
|
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
|
||||||
|
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||||
|
if first_content and b'"content"' in chunk:
|
||||||
|
trace.note_first_content()
|
||||||
|
first_content = False
|
||||||
|
yield chunk
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
trace.error = "verbindung"
|
||||||
|
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
||||||
|
finally:
|
||||||
|
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
"""
|
"""
|
||||||
Per-Stage-Latenz-Metriken für die Voice/Lucy-Pipeline.
|
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
|
||||||
|
|
||||||
Misst die Server-seitige Dauer jeder Stufe (STT, Vision-Beschreibung, Chat-TTFB, TTS) und hält
|
Zwei Sichten auf dieselben Messungen:
|
||||||
rollende Statistiken (avg/p50/p95/last) im Speicher. Macht aus Latenz-VERMUTUNGEN gemessene Fakten
|
|
||||||
— die eigentliche Voraussetzung, um gezielt zu optimieren (Stufe 5/C2 des Reviews). Anzeige im
|
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
|
||||||
Frontend-Overhaul (E) analog zur TokenPerformanceCard.
|
letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
|
||||||
|
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
|
||||||
|
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
|
||||||
|
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
|
||||||
|
|
||||||
|
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
|
||||||
|
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
|
||||||
|
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
|
||||||
|
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
|
||||||
|
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
|
||||||
|
|
||||||
|
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
|
||||||
|
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
|
||||||
|
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
|
||||||
|
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
|
||||||
|
|
||||||
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
_MAX = 200
|
_MAX = 200
|
||||||
_STAGES: dict[str, deque] = {}
|
_STAGES: dict[str, deque] = {}
|
||||||
|
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
|
||||||
|
|
||||||
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
||||||
STAGES = ("stt", "vision", "chat_ttfb", "tts")
|
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
|
||||||
|
|
||||||
|
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
|
||||||
|
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
|
||||||
|
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
|
||||||
|
|
||||||
|
|
||||||
def record_stage(stage: str, ms: float) -> None:
|
def record_stage(stage: str, ms: float) -> None:
|
||||||
@@ -32,6 +52,36 @@ def record_stage(stage: str, ms: float) -> None:
|
|||||||
dq.append(float(ms))
|
dq.append(float(ms))
|
||||||
|
|
||||||
|
|
||||||
|
def park(kind: str, ms: float) -> None:
|
||||||
|
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
|
||||||
|
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
|
||||||
|
if ms is None or ms < 0 or kind not in _PARKED:
|
||||||
|
return
|
||||||
|
with _LOCK:
|
||||||
|
_PARKED[kind] = (float(ms), time.perf_counter())
|
||||||
|
|
||||||
|
|
||||||
|
def _take_stt(max_age: float = 20.0) -> float | None:
|
||||||
|
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
|
||||||
|
with _LOCK:
|
||||||
|
v = _PARKED.get("stt")
|
||||||
|
if v and (time.perf_counter() - v[1]) <= max_age:
|
||||||
|
_PARKED["stt"] = None
|
||||||
|
return round(v[0], 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
|
||||||
|
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
|
||||||
|
Turns) und frisch ist."""
|
||||||
|
with _LOCK:
|
||||||
|
v = _PARKED.get("retrieve")
|
||||||
|
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
|
||||||
|
_PARKED["retrieve"] = None
|
||||||
|
return round(v[0], 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Timer:
|
class Timer:
|
||||||
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
||||||
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
||||||
@@ -48,6 +98,68 @@ class Timer:
|
|||||||
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
|
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TurnTrace:
|
||||||
|
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
|
||||||
|
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
|
||||||
|
|
||||||
|
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
|
||||||
|
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
|
||||||
|
self.id = uuid.uuid4().hex[:8]
|
||||||
|
self.ts = time.time()
|
||||||
|
self.perf0 = time.perf_counter()
|
||||||
|
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
|
||||||
|
self.session_id = (session_id or "")[:24]
|
||||||
|
self.kind = kind
|
||||||
|
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
|
||||||
|
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
|
||||||
|
self.had_images = False
|
||||||
|
self.error: str | None = None
|
||||||
|
|
||||||
|
def note_vision(self, ms: float) -> None:
|
||||||
|
self.vision_ms = round(ms, 1)
|
||||||
|
record_stage("vision", ms)
|
||||||
|
|
||||||
|
def mark_brain_start(self) -> None:
|
||||||
|
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
|
||||||
|
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
|
||||||
|
self._brain0 = time.perf_counter()
|
||||||
|
|
||||||
|
def note_ttfb(self) -> None:
|
||||||
|
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
|
||||||
|
|
||||||
|
def note_first_content(self) -> None:
|
||||||
|
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
|
||||||
|
ms = (time.perf_counter() - self._brain0) * 1000.0
|
||||||
|
self.hirn_ms = round(ms, 1)
|
||||||
|
record_stage("chat_first_content", ms)
|
||||||
|
|
||||||
|
def commit(self) -> dict:
|
||||||
|
total = (time.perf_counter() - self.perf0) * 1000.0
|
||||||
|
stt = _take_stt() # rollend bereits in /voice/stt erfasst
|
||||||
|
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
|
||||||
|
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
|
||||||
|
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
|
||||||
|
rec = {
|
||||||
|
"id": self.id,
|
||||||
|
"ts": round(self.ts, 3),
|
||||||
|
"session": self.session_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"had_images": self.had_images,
|
||||||
|
"stt_ms": stt,
|
||||||
|
"vision_ms": self.vision_ms,
|
||||||
|
"hirn_ms": self.hirn_ms,
|
||||||
|
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
|
||||||
|
"mem0_ms": mem0,
|
||||||
|
"total_ms": round(total, 1),
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
|
with _LOCK:
|
||||||
|
_TURNS.append(rec)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def _summary(vals: list[float]) -> dict:
|
def _summary(vals: list[float]) -> dict:
|
||||||
if not vals:
|
if not vals:
|
||||||
return {"count": 0}
|
return {"count": 0}
|
||||||
@@ -66,3 +178,10 @@ def get_metrics() -> dict:
|
|||||||
"""Rollende Zusammenfassung je Stufe."""
|
"""Rollende Zusammenfassung je Stufe."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_trace(limit: int = 20) -> list[dict]:
|
||||||
|
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
|
||||||
|
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
|
||||||
|
with _LOCK:
|
||||||
|
return list(_TURNS)[-limit:][::-1]
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+150
-150
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DRqLCVxW.js"></script>
|
<script type="module" crossorigin src="/assets/index-Bcmk5MTW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CWAZq_3Z.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bwnys6u-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Gauge } from "lucide-react"
|
||||||
|
import { useVoiceTrace } from "@/lib/queries"
|
||||||
|
import type { VoiceTurn } from "@/lib/api"
|
||||||
|
|
||||||
|
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Mem0 ist ein Unter-Detail
|
||||||
|
// INNERHALB von „Hirn" und wird NICHT als eigenes Segment addiert (sonst Doppelzählung).
|
||||||
|
const SEGMENTS = [
|
||||||
|
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
|
||||||
|
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
|
||||||
|
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
|
||||||
|
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const fmt = (ms: number | null | undefined): string =>
|
||||||
|
ms == null ? "–" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
|
||||||
|
|
||||||
|
function ago(ts: number): string {
|
||||||
|
const s = Math.max(0, Date.now() / 1000 - ts)
|
||||||
|
if (s < 60) return `vor ${Math.round(s)} s`
|
||||||
|
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||||||
|
return `vor ${Math.round(s / 3600)} h`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
|
||||||
|
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
|
||||||
|
function rowTotal(t: VoiceTurn): number {
|
||||||
|
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dominante Stufe eines Turns = der „Täter". */
|
||||||
|
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
|
||||||
|
let best: { label: string; color: string; ms: number } | null = null
|
||||||
|
for (const s of SEGMENTS) {
|
||||||
|
const ms = t[s.key] as number | null
|
||||||
|
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
|
||||||
|
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
|
||||||
|
+ (t.mem0_ms != null ? ` (davon Mem0 ${fmt(t.mem0_ms)})` : "")
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
|
||||||
|
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
|
||||||
|
<div className="absolute inset-0 flex">
|
||||||
|
{SEGMENTS.map((s) => {
|
||||||
|
const ms = t[s.key] as number | null
|
||||||
|
if (!ms || ms <= 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
|
||||||
|
className="h-full first:rounded-l-sm"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{t.error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
|
||||||
|
Fehler
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
|
||||||
|
{t.error ? "–" : fmt(rowTotal(t))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LatencyCard() {
|
||||||
|
const { data: turns } = useVoiceTrace(12)
|
||||||
|
const list = turns ?? []
|
||||||
|
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
|
||||||
|
const latest = list[0]
|
||||||
|
const lead = latest ? culprit(latest) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gauge className="h-4.5 w-4.5 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Latenz je Turn</h2>
|
||||||
|
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{latest ? (
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
|
||||||
|
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
|
||||||
|
)}
|
||||||
|
{latest && !latest.error && (
|
||||||
|
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
|
||||||
|
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
|
||||||
|
{latest.mem0_ms != null && <span className="text-muted-foreground/50"> · davon Mem0 {fmt(latest.mem0_ms)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
|
||||||
|
{SEGMENTS.map((s) => (
|
||||||
|
<div key={s.key} className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
|
||||||
|
Sprich einmal mit Lucy — dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
|
||||||
|
„Hirn" = Zeit bis zum ersten Wort (Agent + Mem0-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
|
||||||
|
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -413,6 +413,27 @@ export interface TokenStats {
|
|||||||
pricing?: Record<string, { in: number; out: number }>
|
pricing?: Record<string, { in: number; out: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
|
||||||
|
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
|
||||||
|
export interface VoiceTurn {
|
||||||
|
id: string
|
||||||
|
ts: number // Epoch-Sekunden
|
||||||
|
session: string
|
||||||
|
kind: string
|
||||||
|
had_images: boolean
|
||||||
|
stt_ms: number | null
|
||||||
|
vision_ms: number | null
|
||||||
|
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT)
|
||||||
|
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
|
||||||
|
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
|
||||||
|
total_ms: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceTraceResp {
|
||||||
|
turns: VoiceTurn[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelsResp {
|
export interface ModelsResp {
|
||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
running?: string[]
|
running?: string[]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type SystemStatus,
|
type SystemStatus,
|
||||||
type TokenStats,
|
type TokenStats,
|
||||||
type UpdatesResp,
|
type UpdatesResp,
|
||||||
|
type VoiceTraceResp,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
|
|
||||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||||
@@ -45,8 +46,18 @@ export const qk = {
|
|||||||
connectHealth: ["connect-health"] as const,
|
connectHealth: ["connect-health"] as const,
|
||||||
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
||||||
memoryGraph: ["memory-graph"] as const,
|
memoryGraph: ["memory-graph"] as const,
|
||||||
|
voiceTrace: ["voice-trace"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
|
||||||
|
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: qk.voiceTrace,
|
||||||
|
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
|
||||||
|
refetchInterval,
|
||||||
|
select: (d) => d.turns ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
export const useMemoryGraph = (enabled = true) =>
|
export const useMemoryGraph = (enabled = true) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: qk.memoryGraph,
|
queryKey: qk.memoryGraph,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
|
|||||||
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||||
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
||||||
|
import { LatencyCard } from "@/components/dashboard/LatencyCard"
|
||||||
import { ModelsCard } from "@/components/dashboard/ModelsCard"
|
import { ModelsCard } from "@/components/dashboard/ModelsCard"
|
||||||
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
|
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
|
||||||
|
|
||||||
@@ -34,7 +35,10 @@ export function DashboardView() {
|
|||||||
{/* Stack & Telemetrie */}
|
{/* Stack & Telemetrie */}
|
||||||
<section>
|
<section>
|
||||||
<ZoneLabel>Stack & Telemetrie</ZoneLabel>
|
<ZoneLabel>Stack & Telemetrie</ZoneLabel>
|
||||||
<ServicesCard />
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<ServicesCard />
|
||||||
|
<LatencyCard />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Betrieb & Wissen */}
|
{/* Betrieb & Wissen */}
|
||||||
|
|||||||
Reference in New Issue
Block a user