Ampel / ampel (push) Successful in 26s
Ballast raus: - 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy, system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan, voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60. - Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt 10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/. - Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext- Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...). Robuster: - Jobs in eigener Prozessgruppe (Abbrechen beendet wirklich alles), Zeitlimit je Job-Art, start_job_exklusiv: zwei Klicks starten kein doppeltes Update mehr; alte Jobs raeumen sich auf. - Update-Pruefung meldet Fehler (pruef_fehler, Lampe "Pruefung unklar") statt "aktuell". - Nach jedem Update sofort neu pruefen (update_stand) statt 10 Minuten alten Stand zeigen. - llama-swap-Config: Sperre (RLock + flock) fuer UI, Radar, Aufraeumen und Hirn-Umstellung. - Hermes-Config: bei Lesefehler nichts schreiben, atomar, mit Sicherung. - Live-Strom und Gateway-Warnung blockieren den Event-Loop nicht mehr (Lucy, OpenChamber). - Gateway antwortet bei Engine-Ausfall im OpenAI-Fehlerformat (502) statt nacktem 500. - Abgestuerzte Waechter-Pruefung wird ein gelber Hinweis statt still zu verschwinden. - Download laedt nur den gewuenschten Quant (vorher bei Fehlen alle Teile aller Varianten), Download-Jobs in Gruppe "download"; HF-Suche kodiert den Suchbegriff. - Herkunftspruefung: schreibende /api-Aufrufe fremder Webseiten werden abgelehnt (keine Anmeldung, User-Entscheid); Skripte, Desktop-Lucy und /v1 unveraendert. - Modellpfade: Eintragen und Loeschen nur innerhalb von MODELS_DIR. - Dienste-Liste fragt keine abgebauten Dienste mehr ab (PC-Ausfuehrer haette 3 s gekostet). - SSE-Fehlerzeilen von /api/voice/chat als gueltiges JSON. - mission-control-2.service: --timeout-graceful-shutdown 3 (Neustart ohne 10-s-Haenger). Tests: 92 gruen (neu: Herkunft, Quant-Auswahl, abgestuerzte Pruefung). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
14 KiB
Python
290 lines
14 KiB
Python
"""
|
|
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")})
|