""" Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter). Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit 24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) — derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht. Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste, Klon-Referenz und das alte Piper/Chatterbox-TTS. """ import json import logging import os # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). import sys as _sys import httpx from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from services import announce _GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") if _GUARD_DIR not in _sys.path: _sys.path.insert(0, _GUARD_DIR) try: from guard import wrap_untrusted except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen def wrap_untrusted(text: str, label: str = "") -> str: return text log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an # Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar. VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") # Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes. VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) _STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0) def _sse_fehler(text: str) -> bytes: """SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client).""" return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode() async def _describe_images(image_urls: list[str], hint: str) -> str: """Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler.""" multi = len(image_urls) > 1 intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " "Keine Einleitung, keine Wiederholung der Frage. " if multi else "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] for u in image_urls: content.append({"type": "image_url", "image_url": {"url": u}}) try: # 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne # Bildschirm-Kontext antworten als ewig hängen. async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client: r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, "messages": [{"role": "user", "content": content}], }) r.raise_for_status() return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() except Exception as exc: log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) return "" class ChatIn(BaseModel): text: str # die neue User-Äußerung (STT-Ergebnis) session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key) system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") model: str = "" images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") class AnnounceIn(BaseModel): text: str # die Meldung (wird von Lucy gesprochen) subject: str = "" # kurze Betreffzeile (z.B. "[Update]") source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen class AlarmIn(BaseModel): text: str # die Alarm-Meldung subject: str = "[Alarm]" # Betreff (Telegram-Präfix) source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") @router.post("/alarm") def alarm(body: AlarmIn) -> dict: """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht auch nachts sofort raus (notify.sh).""" text = (body.text or "").strip() if not text: raise HTTPException(400, "Leere Meldung.") subject = (body.subject or "[Alarm]").strip() try: item = announce.add(text, subject, body.source or "alarm", "normal") except ValueError as exc: raise HTTPException(400, str(exc)) announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) return {"ok": True, "item": item} @router.post("/voice/announce") def voice_announce(body: AnnounceIn) -> dict: """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-Cron.""" try: return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} except ValueError as exc: raise HTTPException(400, str(exc)) @router.get("/voice/announcements") def voice_announcements(after: int | None = None, limit: int = 20) -> dict: """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" return announce.list_after(after, limit) @router.post("/voice/stt") async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" data = await audio.read() if not data: raise HTTPException(400, "Leeres Audio.") files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} try: async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: 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: raise HTTPException(502, f"STT fehlgeschlagen: {exc}") @router.post("/voice/turn") async def voice_turn(audio: UploadFile = File(...)) -> dict: """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" data = await audio.read() if not data: raise HTTPException(400, "Leeres Audio.") files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) r.raise_for_status() return r.json() except httpx.HTTPError as exc: # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen. log.warning("Turn-Check fehlgeschlagen: %s", exc) return {"complete": True, "probability": 1.0, "engine": "fallback"} @router.post("/voice/chat") async def voice_chat(body: ChatIn) -> StreamingResponse: """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" if not HERMES_API_KEY: raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") headers = { "Authorization": f"Bearer {HERMES_API_KEY}", "X-Hermes-Session-Id": body.session_id, } if body.session_key: headers["X-Hermes-Session-Key"] = body.session_key async def gen(): # Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client # bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der # Request hängt, während das Bild-Modell beschreibt. user_text = body.text imgs = [u for u in (body.images or []) if u] if imgs: yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' 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 ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst # generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s). 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] yield _sse_fehler(f"Hermes {r.status_code}: {detail}") return async for chunk in r.aiter_raw(): yield chunk except httpx.HTTPError as exc: yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}") return StreamingResponse(gen(), media_type="text/event-stream") # --------------------------------------------------------------------------------------------- # Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts # german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie # die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, # /tts/stream -> PCM16 + X-Sample-Rate). class LucyTtsIn(BaseModel): text: str emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins @router.get("/lucy/stimme/health") def lucy_stimme_health() -> dict: """Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading).""" try: r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0)) r.raise_for_status() return r.json() except Exception as exc: raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}") @router.post("/lucy/stimme/tts") async def lucy_stimme_tts(body: LucyTtsIn) -> Response: """Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen.""" try: async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client: r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True)) r.raise_for_status() return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"), headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")}) except httpx.HTTPStatusError as exc: raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}") except httpx.HTTPError as exc: raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") @router.post("/lucy/stimme/tts/stream") async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse: """Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate). Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream.""" client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) try: req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True)) upstream = await client.send(req, stream=True) except httpx.HTTPError as exc: await client.aclose() raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") if upstream.status_code != 200: detail = (await upstream.aread()).decode("utf-8", "replace")[:200] await upstream.aclose(); await client.aclose() raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}") async def gen(): try: async for chunk in upstream.aiter_raw(): yield chunk finally: await upstream.aclose() await client.aclose() return StreamingResponse(gen(), media_type="application/octet-stream", headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})