feat(1b): Automatische Notfallerkennung raus → manueller Notruf-Knopf

Die unzuverlässige/intransparente Auto-Erkennung (Stichwörter + LLM) ist
ersatzlos entfernt: emergency.py neu (nur manueller, vom Nutzer bestätigter
Notruf), llm_classifier.py gelöscht, Aufrufe in chat.py/ws.py raus,
emergency_llm_* aus der Config.

Neu: prominenter "🆘 Hilfe rufen"-Knopf (Bestätigung) → POST /api/emergency
→ protokolliert + liefert Hinweis in Nutzersprache. Eskalation (Benachrichtigung
an Angehörige) folgt später; vorerst Platzhalter "ACHTUNG: Es ist noch keine
Benachrichtigung eingebaut." (10 Sprachen). Admin-Tab "Notfälle" → "Notrufe".

Tests: test_emergency_llm.py entfernt, test_quota_safety.py auf Notruf-Endpunkt
umgeschrieben. 208 passed. Live verifiziert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 03:02:52 +02:00
commit 3389f75af0
10 changed files with 112 additions and 433 deletions

View file

@ -17,8 +17,8 @@ from app.dependencies import (
)
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
from app.schemas import ChatRequest
from app.safety.emergency import record_manual_emergency
from app.schemas import ChatRequest, EmergencyRequest
router = APIRouter()
@ -84,16 +84,10 @@ async def chat(
if user_context_parts:
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
emergency = handle_emergency(user, payload.text, store)
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
schedule_llm_emergency_check(user, payload.text, store, emergency)
if emergency is None:
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(exc))
# Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache.
voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender)
@ -126,7 +120,6 @@ async def chat(
"route": route.as_dict(),
"history_len": len(conversation),
"memories_len": len(memories),
"emergency": emergency,
"trace": {
"raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_transcript,
@ -148,3 +141,13 @@ async def chat(
if emergency:
headers["X-Emergency"] = emergency["category"]
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
@router.post("/emergency")
async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(require_user)):
"""Vom Nutzer bestätigter Notruf. Protokolliert + liefert den Hinweis (in Nutzersprache).
Die eigentliche Benachrichtigung von Angehörigen folgt später.
"""
store = get_store()
return record_manual_emergency(user, store, payload.language)

View file

@ -34,7 +34,6 @@ from app.audio.vad import EnergyVAD
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.auth import authenticate
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
router = APIRouter()
@ -86,30 +85,11 @@ async def _run_turn(
if user_context_parts:
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
emergency = handle_emergency(user, text, store)
async def _on_llm_emergency(category):
try:
await websocket.send_json(
{"type": "emergency", "category": category, "source": "llm"}
)
except Exception: # Socket evtl. geschlossen -> ignorieren
pass
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
schedule_llm_emergency_check(
user, text, store, emergency, on_emergency=_on_llm_emergency
)
if emergency:
await websocket.send_json({"type": "emergency", "category": emergency["category"]})
else:
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)})
return
try:
enforce_quota(user, store)
except QuotaExceededError as exc:
await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)})
return
await websocket.send_json({"type": "ack", "route": route.as_dict()})

View file

@ -192,9 +192,6 @@ class Settings(BaseSettings):
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
emergency_llm_enabled: bool = True
emergency_llm_provider: str = ""
emergency_llm_min_confidence: float = 0.6
model_config = SettingsConfigDict(
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
)

View file

@ -1,140 +1,41 @@
"""Notfall-Erkennung und Eskalation (Senioren-Kontext).
"""Manuell ausgelöster Notruf — vom Nutzer bewusst bestätigt.
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.
Es gibt KEINE automatische Notfallerkennung (keine Stichwörter, kein LLM): die
wäre unzuverlässig (Fehlalarme wie verpasste Notfälle) und intransparent. Ein
Notruf entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
Die erkannten Textauszuege sind hochsensibel und werden bewusst protokolliert
(DSGVO beachten: Einwilligung, Aufbewahrung, Zugriff).
Die tatsächliche Benachrichtigung von Angehörigen (SMS/Anruf/Webhook) ist noch
NICHT implementiert. Bis dahin wird der Notruf protokolliert und der Nutzer
erhält einen klaren Hinweis in seiner Sprache.
"""
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",
],
# Platzhalter-Hinweis je Sprache, solange die Eskalation noch nicht gebaut ist.
_NO_NOTIFY_NOTICE: dict[str, str] = {
"de": "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.",
"en": "ATTENTION: No notification has been set up yet.",
"fr": "ATTENTION : aucune notification n'est encore configurée.",
"es": "ATENCIÓN: todavía no se ha configurado ninguna notificación.",
"it": "ATTENZIONE: non è ancora stata configurata alcuna notifica.",
"pt": "ATENÇÃO: ainda não há nenhuma notificação configurada.",
"pl": "UWAGA: Nie skonfigurowano jeszcze żadnego powiadomienia.",
"ar": "تنبيه: لم يتم إعداد أي إشعار بعد.",
"ru": "ВНИМАНИЕ: Уведомление пока не настроено.",
"zh": "注意:尚未设置任何通知。",
}
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
def notify_notice(language: str | None) -> str:
"""Liefert den Platzhalter-Hinweis in der Nutzersprache (Fallback Deutsch)."""
return _NO_NOTIFY_NOTICE.get((language or "de").lower(), _NO_NOTIFY_NOTICE["de"])
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 record_manual_emergency(user, store, language: str | None = None) -> dict:
"""Protokolliert einen vom Nutzer bestätigten Notruf und liefert den Hinweis.
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.
Eskalation (Benachrichtigung) folgt später; hier wird nur protokolliert.
"""
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
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
return {"category": "manual", "notice": notify_notice(language)}

View file

@ -1,96 +0,0 @@
"""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

View file

@ -103,3 +103,8 @@ class MemoryOut(BaseModel):
content: str
created_at: str
class EmergencyRequest(BaseModel):
"""Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext."""
language: str | None = None

View file

@ -577,8 +577,6 @@ function runTurn(path, onopen) {
}
scrollToBottom();
break;
case "emergency":
addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")");
break;
case "done":
sampleRate = msg.sample_rate || 24000;
@ -852,7 +850,7 @@ $("#admin-close").addEventListener("click", () => $("#admin-panel").classList.ad
const ADMIN_TABS = {
overview: [["overview", "Übersicht"]],
users: [["users", "Verwalten"], ["chat", "Gespräche"]],
emergency: [["emergency", "Notfälle"]],
emergency: [["emergency", "Notrufe"]],
system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]],
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]],
};
@ -1903,3 +1901,31 @@ function buildSettingRow(item) {
}
$("#load-settings").addEventListener("click", loadSettings);
// === NOTRUF-KNOPF =========================================================
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $("#emergency-btn");
if (emergencyBtn) {
emergencyBtn.addEventListener("click", async () => {
if (!confirm("Wirklich Hilfe rufen?")) return;
const lang = (langSel && langSel.value) || "de";
emergencyBtn.disabled = true;
try {
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: lang }),
});
const data = await res.json().catch(() => ({}));
const notice = (data && data.notice) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
addMessage("emergency", "🆘 " + notice);
} catch (e) {
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
} finally {
emergencyBtn.disabled = false;
}
});
}

View file

@ -42,7 +42,7 @@
<nav class="shrink-0 flex border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-x-auto">
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="overview">📊 Übersicht</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="users">👥 Nutzer</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notrufe</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="system">🖥 System</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="config">⚙ Konfiguration</button>
</nav>
@ -112,7 +112,7 @@
<div id="admin-tab-emergency" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Notfall-Ereignisse</h3>
<h3 class="font-semibold text-base">Notrufe (vom Nutzer ausgelöst)</h3>
<button id="load-emergency"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
@ -339,6 +339,12 @@
<main id="messages" class="flex-1 overflow-y-auto px-4 py-4 space-y-3 w-full max-w-3xl mx-auto"></main>
<footer class="shrink-0 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.6rem)]">
<div class="w-full max-w-3xl mx-auto mb-2">
<button type="button" id="emergency-btn"
class="w-full rounded-xl bg-red-600 hover:bg-red-700 active:bg-red-800 text-white font-semibold py-3 text-base flex items-center justify-center gap-2 transition-colors">
🆘 Hilfe rufen
</button>
</div>
<form id="prompt-form" class="flex items-center gap-2 w-full max-w-3xl mx-auto">
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon"
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-emerald-600 text-white text-lg">🎤</button>

View file

@ -1,128 +0,0 @@
import asyncio
import pytest
from app.config import settings
from app.safety import emergency as em
from app.safety import llm_classifier as lc
class StubLLM:
def __init__(self, raw):
self.raw = raw
self.calls = 0
async def complete(self, text, history=None, session_id=None, language=None):
self.calls += 1
return self.raw
class FakeUser:
id = "anonymous"
display_name = "Test"
class FakeStore:
def __init__(self):
self.logged = []
def log_emergency(self, user_id, category, snippet):
self.logged.append((user_id, category, snippet))
@pytest.fixture(autouse=True)
def _clear_pending():
em._pending.clear()
yield
em._pending.clear()
def test_parse_classification_variants():
assert lc.parse_classification('{"category":"medical","confidence":0.9}')["category"] == "medical"
# umschlossen von Text
got = lc.parse_classification('Antwort: {"category":"help","confidence":0.7,"reason":"Feuer"}')
assert got["category"] == "help" and got["confidence"] == 0.7
# 'none' ist kein Notfall
assert lc.parse_classification('{"category":"none","confidence":0.99}') is None
# unbekannte Kategorie
assert lc.parse_classification('{"category":"foo","confidence":0.99}') is None
assert lc.parse_classification("kein json") is None
assert lc.parse_classification("") is None
def test_classify_respects_confidence_threshold(monkeypatch):
monkeypatch.setattr(settings, "emergency_llm_min_confidence", 0.6)
monkeypatch.setattr(lc, "_build_classifier_llm",
lambda cfg: StubLLM('{"category":"medical","confidence":0.4}'))
# unter der Schwelle -> kein Notfall
assert asyncio.run(lc.classify_emergency("mir ist schwindelig")) is None
monkeypatch.setattr(lc, "_build_classifier_llm",
lambda cfg: StubLLM('{"category":"medical","confidence":0.85}'))
res = asyncio.run(lc.classify_emergency("mir wird ganz schwarz vor augen"))
assert res["category"] == "medical"
def test_schedule_skips_when_keyword_already_hit(monkeypatch):
store = FakeStore()
called = StubLLM('{"category":"medical","confidence":0.9}')
monkeypatch.setattr(lc, "_build_classifier_llm", lambda cfg: called)
async def run():
# keyword_hit ist gesetzt -> Stufe 2 wird uebersprungen
task = em.schedule_llm_emergency_check(
FakeUser(), "egal", store, {"category": "medical", "matched": "x"}
)
return task
assert asyncio.run(run()) is None
assert called.calls == 0
assert store.logged == []
def test_schedule_disabled_is_noop(monkeypatch):
monkeypatch.setattr(settings, "emergency_llm_enabled", False)
store = FakeStore()
async def run():
return em.schedule_llm_emergency_check(FakeUser(), "hilfe", store, None)
assert asyncio.run(run()) is None
assert store.logged == []
def test_schedule_escalates_and_calls_callback(monkeypatch):
monkeypatch.setattr(settings, "emergency_llm_enabled", True)
monkeypatch.setattr(settings, "emergency_llm_min_confidence", 0.6)
monkeypatch.setattr(lc, "_build_classifier_llm",
lambda cfg: StubLLM('{"category":"self_harm","confidence":0.95}'))
store = FakeStore()
events = []
async def on_emergency(category):
events.append(category)
async def run():
task = em.schedule_llm_emergency_check(
FakeUser(), "ich will nicht mehr weiterleben",
store, None, on_emergency=on_emergency,
)
await task
asyncio.run(run())
assert store.logged == [("anonymous", "self_harm", "ich will nicht mehr weiterleben")]
assert events == ["self_harm"]
def test_schedule_malformed_output_no_escalation(monkeypatch):
monkeypatch.setattr(settings, "emergency_llm_enabled", True)
monkeypatch.setattr(lc, "_build_classifier_llm",
lambda cfg: StubLLM("Tut mir leid, kein JSON."))
store = FakeStore()
async def run():
task = em.schedule_llm_emergency_check(FakeUser(), "irgendwas", store, None)
await task
asyncio.run(run())
assert store.logged == []

View file

@ -3,7 +3,6 @@ from fastapi.testclient import TestClient
import app.dependencies as deps
from app.main import app
from app.config import settings
from app.safety.emergency import detect
client = TestClient(app)
@ -22,16 +21,6 @@ def _stub_chat(monkeypatch, answer="ok"):
return {"llm_provider": "l", "tts_provider": "t"}
# --- Notfall-Erkennung (Einheit) ------------------------------------------
def test_detect_categories():
assert detect("Ich habe Brustschmerzen")[0] == "medical"
assert detect("Bitte ruf einen Arzt")[0] == "help"
assert detect("I want to die")[0] == "self_harm"
assert detect("Wie wird das Wetter morgen?") is None
assert detect("") is None
# --- Quota -----------------------------------------------------------------
def test_quota_blocks_after_limit(monkeypatch):
@ -49,33 +38,29 @@ def test_quota_unlimited_by_default(monkeypatch):
assert client.post("/api/chat?debug=true", json=body).status_code == 200
# --- Notfall ueber Endpunkt ------------------------------------------------
# --- Manueller Notruf (vom Nutzer ausgelöst) -------------------------------
def test_emergency_surfaced_in_response(monkeypatch):
base = _stub_chat(monkeypatch, answer="Bleiben Sie ruhig.")
resp = client.post(
"/api/chat?debug=true",
json={"text": "Ich habe starke Schmerzen in der Brust", **base},
)
def test_manual_emergency_logs_and_returns_localized_notice():
resp = client.post("/api/emergency", json={"language": "de"})
assert resp.status_code == 200
assert resp.json()["emergency"]["category"] == "medical"
data = resp.json()
assert data["category"] == "manual"
assert "noch keine Benachrichtigung" in data["notice"]
# Notruf wurde protokolliert
events = deps.get_store().list_emergency_events(10)
assert any(e["category"] == "manual" for e in events)
def test_emergency_bypasses_quota(monkeypatch):
base = _stub_chat(monkeypatch)
def test_manual_emergency_notice_language_fallback():
# Unbekannte Sprache -> deutscher Fallback-Hinweis
en = client.post("/api/emergency", json={"language": "en"}).json()["notice"]
assert "ATTENTION" in en
xx = client.post("/api/emergency", json={"language": "xx"}).json()["notice"]
assert "ACHTUNG" in xx
def test_manual_emergency_does_not_count_against_quota(monkeypatch):
# Der Notruf-Endpunkt ist von der Chat-Quota unabhängig.
monkeypatch.setattr(settings, "daily_request_limit", 1)
body = {**base}
assert client.post("/api/chat?debug=true", json={"text": "hallo", **body}).status_code == 200
assert client.post("/api/chat?debug=true", json={"text": "hallo", **body}).status_code == 429
rescue = client.post("/api/chat?debug=true", json={"text": "ich kann nicht atmen", **body})
assert rescue.status_code == 200
assert rescue.json()["emergency"]["category"] == "medical"
def test_ws_emergency_event(monkeypatch):
base = _stub_chat(monkeypatch, answer="Ruhig bleiben, Hilfe kommt.")
with client.websocket_connect("/ws/chat") as ws:
ws.send_json({"text": "Ich bin gestürzt und komme nicht hoch", **base})
event = ws.receive_json()
assert event["type"] == "emergency" and event["category"] == "medical"
assert ws.receive_json()["type"] == "ack"
for _ in range(3):
assert client.post("/api/emergency", json={"language": "de"}).status_code == 200