From 3389f75af032aad8421d2797f3ede738a86c2484 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 03:02:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(1b):=20Automatische=20Notfallerkennung=20r?= =?UTF-8?q?aus=20=E2=86=92=20manueller=20Notruf-Knopf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/chat.py | 29 ++++--- app/api/ws.py | 30 ++----- app/config.py | 3 - app/safety/emergency.py | 155 +++++++---------------------------- app/safety/llm_classifier.py | 96 ---------------------- app/schemas.py | 5 ++ app/web/app.js | 32 +++++++- app/web/index.html | 10 ++- tests/test_emergency_llm.py | 128 ----------------------------- tests/test_quota_safety.py | 57 +++++-------- 10 files changed, 112 insertions(+), 433 deletions(-) delete mode 100644 app/safety/llm_classifier.py delete mode 100644 tests/test_emergency_llm.py diff --git a/app/api/chat.py b/app/api/chat.py index dbd12e1..c592fc2 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -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) diff --git a/app/api/ws.py b/app/api/ws.py index 45d4060..e948065 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -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()}) diff --git a/app/config.py b/app/config.py index ee07ed7..35b73c8 100644 --- a/app/config.py +++ b/app/config.py @@ -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" ) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 2862df0..0ef65ba 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -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)} diff --git a/app/safety/llm_classifier.py b/app/safety/llm_classifier.py deleted file mode 100644 index 6e54c2a..0000000 --- a/app/safety/llm_classifier.py +++ /dev/null @@ -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 diff --git a/app/schemas.py b/app/schemas.py index 25f1814..e872bb2 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/web/app.js b/app/web/app.js index 2af0310..5a220db 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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; + } + }); +} diff --git a/app/web/index.html b/app/web/index.html index dd59388..4c4c8a4 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -42,7 +42,7 @@ @@ -112,7 +112,7 @@