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:
parent
aa64ccf585
commit
855fc71a1e
9 changed files with 341 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
21
README.md
21
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
app/safety/llm_classifier.py
Normal file
96
app/safety/llm_classifier.py
Normal 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
|
||||
128
tests/test_emergency_llm.py
Normal file
128
tests/test_emergency_llm.py
Normal file
|
|
@ -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 == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue