- app/safety/llm_classifier.py: LLM klassifiziert Aeusserungen (medical/self_harm/ help/none) mit Konfidenz-Schwelle, eigener JSON-Prompt, Reasoning aus, temp 0 - emergency.py: Eskalation in _escalate() gebuendelt (Log/Metrik/Webhook, source-Label); schedule_llm_emergency_check() faehrt Stufe 2 als Hintergrund-Task NUR wenn die Stichwort-Heuristik nichts fand -> faengt verpasste Formulierungen ohne Mehrlatenz - /ws/voice schickt bei LLM-Treffer ein nachgelagertes emergency-Event (source=llm) - Konfig: EMERGENCY_LLM_ENABLED/_PROVIDER/_MIN_CONFIDENCE - Tests: Parsing, Konfidenz-Schwelle, Skip-bei-Keyword-Treffer, Eskalation+Callback, disabled, kaputtes JSON - Doku: README + Architektur-Roadmap (Punkt 6) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""Notfall-Erkennung und Eskalation (Senioren-Kontext).
|
|
|
|
Zweistufig:
|
|
1. Schnelle Stichwort-Heuristik (`detect` / `handle_emergency`) im Hot-Path -> 0 Latenz.
|
|
2. LLM-Klassifikation (`schedule_llm_emergency_check`) als Hintergrund-Task, der NUR
|
|
laeuft, wenn die Heuristik nichts fand -> faengt verpasste Formulierungen, ohne die
|
|
Antwortlatenz zu erhoehen.
|
|
|
|
Die erkannten Textauszuege sind hochsensibel und werden bewusst protokolliert
|
|
(DSGVO beachten: Einwilligung, Aufbewahrung, Zugriff).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from app.config import settings, Settings
|
|
from app.metrics import metrics
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Referenzen auf laufende Hintergrund-Tasks halten (sonst GC-gefaehrdet).
|
|
_pending: set[asyncio.Task] = set()
|
|
|
|
# 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 _escalate(user, category: str, snippet: str, store, cfg: Settings, source: str) -> None:
|
|
"""Protokolliert + eskaliert einen erkannten Notfall (Log, Metrik, Webhook)."""
|
|
store.log_emergency(user.id, category, snippet)
|
|
metrics.inc("emergency_total", {"category": category, "source": source})
|
|
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
|
|
|
|
|
|
def handle_emergency(user, text: str, store, cfg: Settings = settings):
|
|
"""Stufe 1: Stichwort-Heuristik. Erkennt, protokolliert und eskaliert sofort.
|
|
|
|
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
|
|
_escalate(user, category, text[:500], store, cfg, source="keyword")
|
|
return {"category": category, "matched": phrase}
|
|
|
|
|
|
async def _llm_check(user, text: str, store, cfg: Settings, on_emergency) -> None:
|
|
"""Hintergrund: LLM-Klassifikation + Eskalation (best-effort)."""
|
|
try:
|
|
from app.safety.llm_classifier import classify_emergency
|
|
|
|
result = await classify_emergency(text, cfg)
|
|
if not result:
|
|
return
|
|
category = result["category"]
|
|
_escalate(user, category, text[:500], store, cfg, source="llm")
|
|
logger.info(
|
|
"llm-emergency: %s (conf=%.2f) fuer %s", category, result["confidence"], user.id
|
|
)
|
|
if on_emergency is not None:
|
|
await on_emergency(category)
|
|
except Exception: # best-effort: darf den Turn nie brechen
|
|
logger.exception("llm-emergency-check fehlgeschlagen (ignoriert)")
|
|
|
|
|
|
def schedule_llm_emergency_check(user, text: str, store, keyword_hit,
|
|
cfg: Settings = settings, on_emergency=None):
|
|
"""Stufe 2: plant die LLM-Klassifikation als Hintergrund-Task.
|
|
|
|
Laeuft NUR, wenn die Stichwort-Heuristik nichts fand (`keyword_hit` ist None) und
|
|
die LLM-Stufe aktiviert ist. `on_emergency(category)` (async) wird bei Treffer
|
|
aufgerufen (z. B. WS-Event). Gibt den Task zurueck oder None - blockiert nie.
|
|
"""
|
|
if keyword_hit is not None or not cfg.emergency_llm_enabled:
|
|
return None
|
|
if not text or not text.strip():
|
|
return None
|
|
try:
|
|
task = asyncio.get_running_loop().create_task(
|
|
_llm_check(user, text, store, cfg, on_emergency)
|
|
)
|
|
except RuntimeError:
|
|
return None # kein laufender Event-Loop (z. B. Test) -> ueberspringen
|
|
_pending.add(task)
|
|
task.add_done_callback(_pending.discard)
|
|
return task
|