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>
This commit is contained in:
parent
6422444017
commit
1eb79c1f09
14 changed files with 378 additions and 2 deletions
84
app/safety/emergency.py
Normal file
84
app/safety/emergency.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Heuristische Notfall-Erkennung und Eskalation (Senioren-Kontext).
|
||||
|
||||
WICHTIG: Schluesselwort-Heuristik, KEIN Ersatz fuer eine echte Klassifikation.
|
||||
Sie kann Notlagen verpassen oder Fehlalarme ausloesen. Die erkannten Textauszuege
|
||||
sind hochsensibel und werden bewusst protokolliert (DSGVO beachten: Einwilligung,
|
||||
Aufbewahrung, Zugriff).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings, Settings
|
||||
from app.metrics import metrics
|
||||
|
||||
# Phrasen je Kategorie (de/en), bewusst eher spezifisch gegen Fehlalarme.
|
||||
_PATTERNS: dict[str, list[str]] = {
|
||||
"medical": [
|
||||
"brustschmerz", "schmerzen in der brust", "kann nicht atmen", "keine luft",
|
||||
"atemnot", "herzinfarkt", "schlaganfall", "bewusstlos", "gestuerzt", "gestürzt",
|
||||
"gefallen und komme nicht hoch", "starke blutung",
|
||||
"chest pain", "can't breathe", "cannot breathe", "heart attack", "stroke",
|
||||
"i fell and can't", "bleeding badly",
|
||||
],
|
||||
"self_harm": [
|
||||
"nicht mehr leben", "mich umbringen", "selbstmord", "suizid", "will sterben",
|
||||
"kill myself", "end my life", "suicide", "want to die",
|
||||
],
|
||||
"help": [
|
||||
"notruf", "notarzt", "krankenwagen", "ruf einen arzt", "es brennt",
|
||||
"call an ambulance", "call 911", "call 112",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def detect(text: str):
|
||||
"""Liefert (category, matched_phrase) oder None."""
|
||||
if not text:
|
||||
return None
|
||||
low = text.lower()
|
||||
for category, phrases in _PATTERNS.items():
|
||||
for phrase in phrases:
|
||||
if phrase in low:
|
||||
return category, phrase
|
||||
return None
|
||||
|
||||
|
||||
async def _fire_webhook(url: str, user, category: str, snippet: str) -> None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
await client.post(
|
||||
url,
|
||||
json={
|
||||
"user_id": user.id,
|
||||
"display_name": user.display_name,
|
||||
"category": category,
|
||||
"text": snippet,
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best effort, darf den Chat nicht brechen
|
||||
metrics.inc("emergency_webhook_error_total")
|
||||
|
||||
|
||||
def handle_emergency(user, text: str, store, cfg: Settings = settings):
|
||||
"""Erkennt, protokolliert und eskaliert ein Notfall-Signal.
|
||||
|
||||
Gibt {"category", "matched"} zurueck, wenn etwas erkannt wurde, sonst None.
|
||||
Der Webhook (falls konfiguriert) wird nicht-blockierend ausgeloest.
|
||||
"""
|
||||
match = detect(text)
|
||||
if not match:
|
||||
return None
|
||||
category, phrase = match
|
||||
snippet = text[:500]
|
||||
store.log_emergency(user.id, category, snippet)
|
||||
metrics.inc("emergency_total", {"category": category})
|
||||
if cfg.emergency_webhook_url:
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
_fire_webhook(cfg.emergency_webhook_url, user, category, snippet)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # kein laufender Event-Loop (z. B. im Test) -> Webhook ueberspringen
|
||||
return {"category": category, "matched": phrase}
|
||||
Loading…
Add table
Add a link
Reference in a new issue