"""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