From 855fc71a1e0765ba3f8236b5ad43bbe6bda212e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 03:24:25 +0200 Subject: [PATCH] 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 --- .env.example | 5 ++ Docs/voice-assistant-architecture.md | 2 +- README.md | 21 +++-- app/api/chat.py | 4 +- app/api/ws.py | 16 +++- app/config.py | 6 ++ app/safety/emergency.py | 88 ++++++++++++++---- app/safety/llm_classifier.py | 96 ++++++++++++++++++++ tests/test_emergency_llm.py | 128 +++++++++++++++++++++++++++ 9 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 app/safety/llm_classifier.py create mode 100644 tests/test_emergency_llm.py diff --git a/.env.example b/.env.example index c1af288..d4b6c00 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,8 @@ TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg # --- Betrieb: Kontingent & Notfall ----------------------------------------- DAILY_REQUEST_LIMIT=0 # Anfragen pro Nutzer/Tag (0 = unbegrenzt) # EMERGENCY_WEBHOOK_URL=https://example.org/alert # optionale Eskalation +# LLM-Notfall-Klassifikation (Stufe 2): faengt im Hintergrund Notlagen, die die +# Stichwort-Heuristik verpasst -> keine zusaetzliche Antwortlatenz. +# EMERGENCY_LLM_ENABLED=true +# EMERGENCY_LLM_PROVIDER= # leer = Default-LLM; sonst Registry-Name +# EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index b76ccce..20c6fa7 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -217,7 +217,7 @@ Reihenfolge der Weiterentwicklung: 3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell **und automatisch** gepflegt, als LLM-Kontext). **Automatische Extraktion** (`app/core/memory_extractor.py`): nach je N Turns destilliert ein LLM dauerhafte Fakten/Vorlieben aus dem Verlauf und legt sie dedupliziert als Erinnerungen ab — best-effort, nicht-blockierend (Hintergrund-Task), konfigurierbar (`MEMORY_EXTRACTION_*`). Offen: periodische Verdichtung/Zusammenfassung wachsender Erinnerungslisten. 4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung. 5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting. -6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. +6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung. 7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, ffmpeg-Resampling auf 24000 Hz) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper. 8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene. diff --git a/README.md b/README.md index 277bfde..9296074 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Resilienz:** Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken -- **Betrieb:** Tageskontingent pro Nutzer (`429`) + heuristische Notfall-Eskalation +- **Betrieb:** Tageskontingent pro Nutzer (`429`) + zweistufige Notfall-Eskalation (Stichwörter + LLM) - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext - **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport @@ -304,20 +304,29 @@ DAILY_REQUEST_LIMIT=200 # 0 = unbegrenzt; pro Nutzer/Tag ``` Pro Nutzer übersteuerbar via `prefs.daily_request_limit` (siehe `PUT /api/me/prefs`). -**Notfall-Eskalation:** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe heuristisch +**Notfall-Eskalation (zweistufig):** `/api/chat` und `/ws/chat` prüfen die Nutzereingabe auf Notlagen-Signale (medizinisch, Selbstgefährdung, Hilferuf — de/en). Bei Treffer wird der Vorfall protokolliert, optional ein Webhook ausgelöst und das Signal sichtbar gemacht (`X-Emergency`-Header / `emergency`-Feld / WebSocket-`emergency`-Event). Eine Notfall-Eingabe umgeht das Kontingent (wird nie geblockt). +1. **Stichwort-Heuristik** im Hot-Path — sofort, ohne Latenz. +2. **LLM-Klassifikation** als **Hintergrund-Task**, der nur läuft, wenn die Stichwörter + nichts fanden. Fängt verpasste Formulierungen (z. B. metaphorisch geäußerte + Suizidalität oder Schlaganfall-Symptome ohne Schlüsselwort) mit Konfidenz-Schwelle — + **ohne** die Antwortlatenz zu erhöhen. Eskaliert genauso (Log/Webhook), beim WebSocket + zusätzlich ein nachgelagertes `emergency`-Event (`source: "llm"`). + ```bash EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung +EMERGENCY_LLM_ENABLED=true # Stufe 2 (Default an); false = nur Stichwörter +EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme ``` -> ⚠️ Die Erkennung ist eine **Schlüsselwort-Heuristik** — kein verlässlicher -> Lebensretter und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen -> oder Fehlalarme auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, -> Aufbewahrung, Zugriff beachten). +> ⚠️ Die Erkennung (Heuristik **und** LLM) ist **kein verlässlicher Lebensretter** +> und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen oder Fehlalarme +> auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, Aufbewahrung, +> Zugriff beachten). ## Authentifizierung diff --git a/app/api/chat.py b/app/api/chat.py index 6deb509..c1e3189 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -15,7 +15,7 @@ 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 +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check from app.schemas import ChatRequest router = APIRouter() @@ -83,6 +83,8 @@ async def chat( # 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: diff --git a/app/api/ws.py b/app/api/ws.py index 01ce254..0f75111 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -31,7 +31,7 @@ from app.store import SessionOwnershipError 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.safety.emergency import handle_emergency +from app.safety.emergency import handle_emergency, schedule_llm_emergency_check router = APIRouter() @@ -80,6 +80,20 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out # 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: diff --git a/app/config.py b/app/config.py index bed84ac..717a383 100644 --- a/app/config.py +++ b/app/config.py @@ -157,6 +157,12 @@ class Settings(BaseSettings): tts_fallback: str = "" daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag emergency_webhook_url: str = "" # optionaler Eskalations-Webhook + # 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 fbef7a9..2862df0 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -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 diff --git a/app/safety/llm_classifier.py b/app/safety/llm_classifier.py new file mode 100644 index 0000000..6e54c2a --- /dev/null +++ b/app/safety/llm_classifier.py @@ -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 diff --git a/tests/test_emergency_llm.py b/tests/test_emergency_llm.py new file mode 100644 index 0000000..b463519 --- /dev/null +++ b/tests/test_emergency_llm.py @@ -0,0 +1,128 @@ +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): + 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 == []