feat(safety): zweistufige Notfall-Erkennung mit LLM-Klassifikation

- 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>
This commit is contained in:
Dieter Schlüter 2026-06-18 03:24:25 +02:00
commit 855fc71a1e
9 changed files with 341 additions and 25 deletions

View file

@ -1,18 +1,28 @@
"""Heuristische Notfall-Erkennung und Eskalation (Senioren-Kontext).
"""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).
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": [
@ -61,8 +71,21 @@ async def _fire_webhook(url: str, user, category: str, snippet: str) -> None:
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):
"""Erkennt, protokolliert und eskaliert ein Notfall-Signal.
"""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.
@ -71,14 +94,47 @@ def handle_emergency(user, text: str, store, cfg: Settings = settings):
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
_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

View file

@ -0,0 +1,96 @@
"""LLM-Notfall-Klassifikation (zweite Stufe der Notfall-Erkennung).
Ergaenzt die schnelle Stichwort-Heuristik (`app.safety.emergency.detect`) um einen
LLM-Klassifikator, der Formulierungen erkennt, die keine Stichwoerter treffen.
Bewusst **best-effort** und mit Konfidenz-Schwelle (sensibler Senioren-Kontext):
Ein LLM-Fehler oder kaputtes JSON fuehrt nie zu einem Alarm und nie zu einem Fehler
im Antwort-Turn.
"""
import json
import logging
import re
from app.config import Settings, settings
logger = logging.getLogger(__name__)
# Gueltige Notfall-Kategorien (deckungsgleich mit der Stichwort-Heuristik).
VALID_CATEGORIES = {"medical", "self_harm", "help"}
_SYSTEM_PROMPT = (
"Du bist ein Sicherheits-Klassifikator fuer einen Senioren-Sprachassistenten. "
"Beurteile, ob die Nutzeraeusserung einen akuten Notfall beschreibt. Kategorien: "
"'medical' (akute medizinische Notlage, z. B. Brustschmerz, Atemnot, Sturz, "
"Schlaganfall), 'self_harm' (Suizidalitaet/Selbstgefaehrdung), 'help' (akuter "
"Hilferuf, z. B. Feuer, Notruf), 'none' (kein Notfall). Antworte AUSSCHLIESSLICH "
"mit JSON: {\"category\": \"medical|self_harm|help|none\", \"confidence\": 0.0-1.0, "
"\"reason\": \"kurze Begruendung\"}. Sei zurueckhaltend: nur echte, akute Notlagen "
"sind ein Notfall, keine beilaeufigen Erwaehnungen oder Vergangenes."
)
def _build_classifier_llm(cfg: Settings):
"""Baut eine eigene LLM-Instanz fuer die Klassifikation (eigener JSON-Prompt)."""
provider = cfg.emergency_llm_provider or cfg.default_llm_provider
if provider == "local-openai-compatible":
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
return LocalOpenAICompatibleLLM(
cfg.local_llm_base_url,
cfg.local_llm_api_key,
cfg.local_llm_model,
system_prompt=_SYSTEM_PROMPT,
disable_reasoning=True,
max_tokens=128,
temperature=0.0,
)
from app.dependencies import get_llm_provider
return get_llm_provider(provider, cfg)
def parse_classification(raw: str) -> dict | None:
"""Liest {category, confidence, reason} aus der (evtl. verrauschten) LLM-Antwort."""
if not raw:
return None
match = re.search(r"\{.*\}", raw, re.DOTALL)
if not match:
return None
try:
data = json.loads(match.group(0))
except ValueError:
return None
if not isinstance(data, dict):
return None
category = data.get("category")
if category not in VALID_CATEGORIES:
return None
try:
confidence = float(data.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
return {
"category": category,
"confidence": confidence,
"reason": str(data.get("reason", "")),
}
async def classify_emergency(text: str, cfg: Settings = settings) -> dict | None:
"""Klassifiziert eine Aeusserung. Liefert {category, confidence, reason} oder None.
None bedeutet: kein Notfall (bzw. unter der Konfidenz-Schwelle / nicht parsebar).
"""
if not text or not text.strip():
return None
llm = _build_classifier_llm(cfg)
raw = await llm.complete(text)
result = parse_classification(raw)
if result is None:
return None
if result["confidence"] < cfg.emergency_llm_min_confidence:
return None
return result