my_voice_assistant_v3/app/api/speak.py
Dieter Schlüter 1eb79c1f09 feat: Tageskontingent und Notfall-Eskalation (#6)
- Quota (app/quota.py): Anfragen pro Nutzer/Tag (usage-Tabelle), DAILY_REQUEST_LIMIT,
  pro Nutzer via prefs uebersteuerbar; 429 (REST) bzw. error-Event (WS); Metrik
- Notfall (app/safety/emergency.py): heuristische Erkennung (de/en); Log im Store
  (emergency_events) + optionaler Webhook (best-effort) + X-Emergency/emergency-Event;
  Metrik emergency_total; Notfaelle umgehen das Kontingent
- verdrahtet in chat/speak/transcribe + WS-Turns
- Tests: 64 gruen (+6); Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, .env.example)

Hinweis: Notfall-Erkennung ist eine Heuristik (kein Lebensretter); erkannte Texte
sind sensibel -> DSGVO beachten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:29:30 +02:00

74 lines
2.2 KiB
Python

from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.auth import require_user
from app.store import User, SessionOwnershipError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
get_store,
)
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.schemas import SpeakRequest
router = APIRouter()
@router.post("/speak")
async def speak(
payload: SpeakRequest,
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
user: User = Depends(require_user),
):
overrides = {
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"tts_provider": payload.tts_provider,
}
voice = payload.voice or settings.openrouter_tts_voice
try:
route = resolve_route(user, session_id, overrides)
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
store = get_store()
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
try:
audio = await orchestrator.speak_only(
payload.text,
voice=voice,
language=route.language,
output=output,
)
record_usage(user, store, len(payload.text))
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
"X-Output-Endpoint": route.output_endpoint,
"X-TTS-Provider": route.tts_provider,
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))