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>