feat(lyra): deterministisches Krisen-Sicherheitsnetz (R-LYRA-01)
LLM-unabhaengiges Sicherheitsnetz fuer Lyras SOS-Pfad, schliesst das Top-Risiko der Risiko-Akte (verpasste Krise, ISO 14971 R-LYRA-01). Backend: - crisis-filter.ts: deterministische Krisen-/Suizid-Erkennung (DE primaer, EN/FR/AR Grundabdeckung) auf den letzten User-Nachrichten, synchron, kein LLM - sos-session.post: liefert crisisLevel sofort an die App (vor Stream-Start) - sos-stream: sendet bei Krise zuerst 'crisis_chips' (BZgA/112/Telefonseelsorge); Fallback an 3 Stellen (LLM-Fehler/Abbruch/keine Chips) -> nie leerer Screen - 43/43 Unit-Tests (crisis.json positiv, harmless.json False-Positive-Guard) Frontend (urge.tsx): - permanente rote Krisen-Bar oben, durch LLM-Chips nicht ueberschreibbar (eigener State-Slot), Hotline-Chips als tel:-Links - neue Locale-Strings DE/EN Risiko-Akte: R-LYRA-01 Restrisiko HOCH -> MITTEL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ac05e255da
commit
96e1b8368c
@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, FlatList, Pressable, Platform, Animated,
|
||||
Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent,
|
||||
NativeScrollEvent, ActivityIndicator, AppState,
|
||||
NativeScrollEvent, ActivityIndicator, AppState, TouchableOpacity, Linking,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
@ -29,7 +29,7 @@ import MessageRow, { GameHeader, type SosMsg } from '../components/urge/MessageR
|
||||
import { SOS_BOOT } from '../lib/sosPrompts';
|
||||
import { CHIP_SETS, BREATH_PHASES, type ChipSet } from '../lib/sosConstants';
|
||||
import { parseLyraResponse, detectEmotion, type LyraEmotion, type ChipSpec } from '../lib/lyraResponse';
|
||||
import { streamSosLyra } from '../lib/sosStream';
|
||||
import { streamSosLyra, type CrisisLevel } from '../lib/sosStream';
|
||||
import { SosTtsQueue } from '../lib/sosTtsQueue';
|
||||
import { endpointForProvider, useTtsProvider, currentProvider } from '../lib/ttsProvider';
|
||||
import { currentLlmProvider } from '../lib/llmProvider';
|
||||
@ -57,6 +57,8 @@ export default function SOSScreen() {
|
||||
const soundEnabledRef = useRef(true);
|
||||
const [chipSet, setChipSet] = useState<ChipSet>('start');
|
||||
const [dynamicChips, setDynamicChips] = useState<ChipSpec[]>([]);
|
||||
const [crisisChips, setCrisisChips] = useState<Array<{ label: string; action: string }>>([]);
|
||||
const [crisisLevel, setCrisisLevel] = useState<CrisisLevel>('none');
|
||||
const [userTurnCount, setUserTurnCount] = useState(0);
|
||||
|
||||
// ——— Session-Tracking für DiGA ———
|
||||
@ -465,6 +467,8 @@ export default function SOSScreen() {
|
||||
locale: i18n.language,
|
||||
llmProvider: currentLlmProvider(),
|
||||
onMetric: bench.mark,
|
||||
onCrisisLevel: (level) => setCrisisLevel(level),
|
||||
onCrisisChips: (chips) => setCrisisChips(chips),
|
||||
onTextUpdate: (full) => {
|
||||
visible = full;
|
||||
ensureBubble(full);
|
||||
@ -658,6 +662,8 @@ export default function SOSScreen() {
|
||||
locale: i18n.language,
|
||||
llmProvider: currentLlmProvider(),
|
||||
onMetric: greetingBench.mark,
|
||||
onCrisisLevel: (level) => { if (!cancelled) setCrisisLevel(level); },
|
||||
onCrisisChips: (chips) => { if (!cancelled) setCrisisChips(chips); },
|
||||
onTextUpdate: (full) => {
|
||||
if (cancelled) return;
|
||||
visible = full;
|
||||
@ -743,6 +749,10 @@ export default function SOSScreen() {
|
||||
async function handleChip(action: string) {
|
||||
if (thinking) return;
|
||||
Keyboard.dismiss();
|
||||
if (action.startsWith('tel:')) {
|
||||
Linking.openURL(action).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Clear dynamic chips on any chip action — Lyra will provide new ones in her reply
|
||||
setDynamicChips([]);
|
||||
if (action.startsWith('feel:')) {
|
||||
@ -1181,6 +1191,32 @@ export default function SOSScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Crisis chips — permanent row, shown as soon as backend detects a crisis.
|
||||
Never replaced by LLM chips. Tel-actions open the dialer directly. */}
|
||||
{crisisChips.length > 0 && (
|
||||
<View style={st.crisisBar}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<Ionicons name="warning-outline" size={14} color="#dc2626" />
|
||||
<Text style={st.crisisBarLabel}>{t('coach.crisis_bar_label')}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{crisisChips.map((chip) => (
|
||||
<TouchableOpacity
|
||||
key={chip.action}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleChip(chip.action)}
|
||||
style={st.crisisChip}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Ionicons name="call-outline" size={14} color="#dc2626" />
|
||||
<Text style={st.crisisChipText}>{chip.label}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Chips above input — only after Lyra has answered.
|
||||
Bei Standard-Actions (breathing/game/overcome/etc): Ionicons
|
||||
(native = SF Symbols iOS, Material Android) + Label OHNE Emoji.
|
||||
@ -1343,5 +1379,26 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bg, gap: 8 },
|
||||
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: colors.surfaceElevated, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.text },
|
||||
sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' },
|
||||
crisisBar: {
|
||||
marginHorizontal: 12,
|
||||
marginBottom: 4,
|
||||
marginTop: 8,
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fca5a5',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
crisisBarLabel: { fontSize: 11, fontFamily: 'Nunito_700Bold', color: '#dc2626', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
crisisChip: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#fca5a5',
|
||||
backgroundColor: '#fff1f2',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
crisisChipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 13, color: '#dc2626' },
|
||||
});
|
||||
}
|
||||
|
||||
@ -14,7 +14,9 @@ import EventSource from 'react-native-sse';
|
||||
import type { BenchOnMetric } from './sosTtsBenchmark';
|
||||
import type { LlmProvider } from './llmProvider';
|
||||
|
||||
type SseEvents = 'message' | 'chips' | 'done';
|
||||
type SseEvents = 'message' | 'chips' | 'crisis_chips' | 'done';
|
||||
|
||||
export type CrisisLevel = 'crisis' | 'elevated' | 'none';
|
||||
|
||||
export type StreamSosLyraOpts = {
|
||||
apiBase: string;
|
||||
@ -26,6 +28,11 @@ export type StreamSosLyraOpts = {
|
||||
llmProvider?: LlmProvider;
|
||||
onTextUpdate: (full: string) => void;
|
||||
onChips: (chips: Array<{ label: string; action: string }>) => void;
|
||||
/** Feuert wenn das Backend Krisenerkennung meldet — vor Stream-Start, aus
|
||||
* POST-Response. Ermöglicht sofortiges UI-Update ohne auf SSE zu warten. */
|
||||
onCrisisLevel?: (level: CrisisLevel) => void;
|
||||
/** Feuert beim `crisis_chips`-SSE-Event mit Hotline-Chips vom Backend. */
|
||||
onCrisisChips?: (chips: Array<{ label: string; action: string }>) => void;
|
||||
/** Phase B: feuert pro fertigem Satz live während des Streams + Tail beim
|
||||
* done. Bei aktivem TTS-Streaming sollte der Aufrufer hier seine Queue
|
||||
* füllen statt im onDone den ganzen Text zu sprechen. */
|
||||
@ -77,7 +84,11 @@ export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void
|
||||
body: JSON.stringify({ messages: opts.messages, locale: opts.locale, llmProvider: opts.llmProvider }),
|
||||
});
|
||||
if (!sessRes.ok) throw new Error(`session: ${sessRes.status}`);
|
||||
const { sessionId } = await sessRes.json();
|
||||
const sessData = await sessRes.json();
|
||||
const { sessionId, crisisLevel } = sessData as { sessionId: string; crisisDetected?: boolean; crisisLevel?: CrisisLevel };
|
||||
if (crisisLevel && crisisLevel !== 'none' && opts.onCrisisLevel) {
|
||||
opts.onCrisisLevel(crisisLevel);
|
||||
}
|
||||
opts.onMetric?.('session-post-done');
|
||||
|
||||
// Step 2: EventSource für SSE-Stream
|
||||
@ -133,6 +144,14 @@ export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
es.addEventListener('crisis_chips', (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const chips = JSON.parse(event.data);
|
||||
if (Array.isArray(chips) && opts.onCrisisChips) opts.onCrisisChips(chips);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
es.addEventListener('done', () => {
|
||||
opts.onMetric?.('sse-done');
|
||||
// Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird
|
||||
|
||||
@ -179,7 +179,8 @@
|
||||
"welcome_back": "Willkommen zurück",
|
||||
"online": "online",
|
||||
"thinking": "schreibt …",
|
||||
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut."
|
||||
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"crisis_bar_label": "Krisen-Hilfe"
|
||||
},
|
||||
"blocker": {
|
||||
"title": "Blocker",
|
||||
|
||||
@ -179,7 +179,8 @@
|
||||
"welcome_back": "Welcome back",
|
||||
"online": "online",
|
||||
"thinking": "typing …",
|
||||
"error": "Something went wrong. Please try again."
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"crisis_bar_label": "Crisis support"
|
||||
},
|
||||
"blocker": {
|
||||
"title": "Blocker",
|
||||
|
||||
@ -6,7 +6,14 @@
|
||||
*
|
||||
* Grund: react-native-sse (EventSource API) unterstützt nur GET, nicht POST.
|
||||
* Daher 2-Step-Flow: POST Session erstellen → GET Stream öffnen.
|
||||
*
|
||||
* Safety: Deterministischer Crisis-Pre-Filter (crisis-filter.ts) läuft HIER,
|
||||
* bevor das LLM überhaupt gestartet wird. Das Ergebnis (crisisLevel) wird in
|
||||
* der Session gespeichert und von sos-stream.get.ts genutzt um Krisen-Chips
|
||||
* LLM-unabhängig einzublenden.
|
||||
*/
|
||||
import { detectCrisis } from "../../utils/crisis-filter";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const body = await readBody(event);
|
||||
@ -20,6 +27,23 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: "messages fehlt" });
|
||||
}
|
||||
|
||||
// ── Deterministischer Crisis-Pre-Filter ──────────────────────────────────
|
||||
// Prüfe die letzten 3 User-Nachrichten (nicht nur die letzte — Kontext zählt).
|
||||
// Läuft synchron, kein Overhead, kein LLM-Aufruf.
|
||||
const userTexts = messages
|
||||
.filter((m) => m.role === "user")
|
||||
.slice(-3)
|
||||
.map((m) => m.content);
|
||||
|
||||
const crisisResult = detectCrisis(userTexts);
|
||||
|
||||
if (crisisResult.isCrisis) {
|
||||
console.log(
|
||||
`[crisis-filter] MATCH user=${user.id} level=${crisisResult.level} ` +
|
||||
`group=${crisisResult.matchedGroup} pattern="${crisisResult.matchedPattern}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Session-ID generieren
|
||||
const sessionId = `sos_${user.id}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
@ -31,7 +55,14 @@ export default defineEventHandler(async (event) => {
|
||||
locale: locale ?? "de",
|
||||
llmProvider,
|
||||
createdAt: Date.now(),
|
||||
crisisLevel: crisisResult.level,
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
return {
|
||||
sessionId,
|
||||
// crisisDetected wird transparent ans Frontend zurückgegeben —
|
||||
// Frontend kann damit sofort (noch vor Stream-Start) die Krisen-UI aktivieren.
|
||||
crisisDetected: crisisResult.isCrisis,
|
||||
crisisLevel: crisisResult.level,
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* GET /api/coach/sos-stream?session=xyz — Streaming SOS Coach (Claude Sonnet 4.5)
|
||||
* GET /api/coach/sos-stream?session=xyz — Streaming SOS Coach
|
||||
*
|
||||
* Streamt Sonnets Antwort als SSE (Server-Sent Events).
|
||||
* Streamt die LLM-Antwort als SSE (Server-Sent Events).
|
||||
* Frontend nutzt react-native-sse (EventSource) für progressives Streaming.
|
||||
*
|
||||
* Format (SSE-Standard):
|
||||
@ -11,15 +11,29 @@
|
||||
* event: chips
|
||||
* data: [{"label":"...","action":"..."}]
|
||||
*
|
||||
* event: crisis_chips ← NEU: deterministischer Krisen-Override
|
||||
* data: [{"label":"...","action":"..."}]
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client POSTet zu /api/coach/sos-session → { sessionId }
|
||||
* 1. Client POSTet zu /api/coach/sos-session → { sessionId, crisisDetected, crisisLevel }
|
||||
* 2. Client öffnet GET /api/coach/sos-stream?session=xyz via EventSource
|
||||
* 3. Backend lädt Session-Daten (messages/locale) aus In-Memory Store
|
||||
* 3. Backend lädt Session-Daten inkl. crisisLevel (deterministische Vorprüfung)
|
||||
* 4. Streamt Antwort als SSE-Events
|
||||
*
|
||||
* Fallback: bei Sonnet-Fehler wirft 503; Frontend kann auf /coach/message zurückfallen.
|
||||
* Safety-Layer (R-LYRA-01):
|
||||
* (a) Crisis-Pre-Filter: Wenn crisisLevel != "none" → event: crisis_chips
|
||||
* wird als ERSTES Event gesendet, BEVOR das LLM antwortet.
|
||||
* Das Frontend kann diese Chips sofort permanent einblenden — unabhängig
|
||||
* davon was das LLM zurückliefert.
|
||||
* (b) Timeout/Leer-Fallback: Wenn das LLM abbricht, in Timeout läuft oder
|
||||
* keine validen Chips liefert, wird ein sicherer Default gesendet.
|
||||
* Kein leerer Screen im Krisen-Kontext möglich.
|
||||
*
|
||||
* Fallback: bei LLM-Fehler → event: crisis_chips + event: done (kein 502-Throw).
|
||||
*/
|
||||
import { COACH_SYSTEM_PROMPT } from "./message.post";
|
||||
import { getCrisisChips, getCrisisFallback } from "../../utils/crisis-filter";
|
||||
import type { CrisisLevel } from "../../utils/crisis-filter";
|
||||
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
|
||||
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
||||
import { getProfile } from "../../db/profile";
|
||||
@ -85,6 +99,9 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const { messages, locale } = sessionData;
|
||||
|
||||
// Crisis-Level aus deterministischem Pre-Filter (gesetzt von sos-session.post.ts)
|
||||
const crisisLevel = (sessionData.crisisLevel ?? "none") as CrisisLevel;
|
||||
|
||||
// Session löschen (One-Time-Use)
|
||||
deleteSosSession(sessionId);
|
||||
|
||||
@ -273,17 +290,17 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
// ── Upstream-Fehler: SSE-Header trotzdem setzen, dann Fallback senden ──────
|
||||
// (b) Timeout/Leer-Fallback: Kein 502-Throw im Krisen-Kontext — User muss
|
||||
// immer eine Antwort sehen. Bei LLM-Fehler sofort Krisen-Fallback liefern.
|
||||
const upstreamFailed = !upstream.ok || !upstream.body;
|
||||
if (upstreamFailed) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
console.error(
|
||||
"[coach/sos-stream] upstream error:",
|
||||
upstream.status,
|
||||
errText.slice(0, 300),
|
||||
);
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: "SOS-Stream nicht verfügbar",
|
||||
});
|
||||
}
|
||||
|
||||
// Direkt zu Node res schreiben — sendStream(ReadableStream) pumpt pull() in Nitro nicht zuverlässig
|
||||
@ -308,6 +325,33 @@ export default defineEventHandler(async (event) => {
|
||||
);
|
||||
write(": connected\n\n");
|
||||
|
||||
// ── (a) Crisis-Pre-Filter: Krisen-Chips sofort senden ────────────────────
|
||||
// Wird als ERSTES Event gesendet, noch bevor das LLM antwortet.
|
||||
// Frontend rendert diese Chips permanent — unabhängig von der LLM-Antwort.
|
||||
// Event-Typ "crisis_chips" (nicht "chips") → Frontend behandelt sie anders:
|
||||
// kein Ersetzen durch LLM-Chips, sondern dauerhaft oben anzeigen.
|
||||
if (crisisLevel !== "none") {
|
||||
const crisisChips = getCrisisChips(crisisLevel);
|
||||
write(`event: crisis_chips\ndata: ${JSON.stringify(crisisChips)}\n\n`);
|
||||
console.log(
|
||||
`[crisis-filter] crisis_chips sent for ${user.id}, level=${crisisLevel}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── (b) Upstream-Fehler-Fallback: Safe Default statt leerem Screen ───────
|
||||
if (upstreamFailed) {
|
||||
const effectiveLevel = crisisLevel !== "none" ? crisisLevel : "elevated";
|
||||
const fallback = getCrisisFallback(effectiveLevel);
|
||||
write(`event: message\ndata: ${JSON.stringify(fallback.message)}\n\n`);
|
||||
write(`event: chips\ndata: ${JSON.stringify(fallback.chips)}\n\n`);
|
||||
write("event: done\ndata: {}\n\n");
|
||||
console.log(
|
||||
`[coach/sos-stream] LLM unavailable — crisis fallback sent for ${user.id}`,
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = upstream.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
@ -388,8 +432,27 @@ export default defineEventHandler(async (event) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (chips.length > 0) {
|
||||
write(`event: chips\ndata: ${JSON.stringify(chips)}\n\n`);
|
||||
} else {
|
||||
// (b) Leer-Antwort-Fallback: LLM hat keine validen Chips geliefert.
|
||||
// Im Krisen-Kontext DARF kein leerer Chip-Bereich erscheinen.
|
||||
// Nutze Krisen-Chips wenn crisisLevel gesetzt, sonst default need_help.
|
||||
if (crisisLevel !== "none") {
|
||||
const fallbackChips = getCrisisChips(crisisLevel);
|
||||
write(`event: chips\ndata: ${JSON.stringify(fallbackChips)}\n\n`);
|
||||
console.log(
|
||||
`[coach/sos-stream] LLM chips missing — crisis chip fallback, level=${crisisLevel}`,
|
||||
);
|
||||
} else {
|
||||
// Auch ohne crisis-level: mindestens einen need_help-Chip senden
|
||||
const safeDefault = [{ label: "Hilfe holen", action: "need_help" }];
|
||||
write(`event: chips\ndata: ${JSON.stringify(safeDefault)}\n\n`);
|
||||
console.log(
|
||||
`[coach/sos-stream] LLM chips missing — safe default chip sent`,
|
||||
);
|
||||
}
|
||||
}
|
||||
write("event: done\ndata: {}\n\n");
|
||||
console.log(
|
||||
@ -413,7 +476,12 @@ export default defineEventHandler(async (event) => {
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[coach/sos-stream] read error:", err);
|
||||
write(`event: error\ndata: {"error":"stream failed"}\n\n`);
|
||||
// (b) Stream-Abbruch-Fallback: auch bei Read-Fehlern kein leerer Screen.
|
||||
const effectiveLevel = crisisLevel !== "none" ? crisisLevel : "elevated";
|
||||
const fallback = getCrisisFallback(effectiveLevel);
|
||||
write(`event: message\ndata: ${JSON.stringify(fallback.message)}\n\n`);
|
||||
write(`event: chips\ndata: ${JSON.stringify(fallback.chips)}\n\n`);
|
||||
write("event: done\ndata: {}\n\n");
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
|
||||
442
backend/server/utils/crisis-filter.ts
Normal file
442
backend/server/utils/crisis-filter.ts
Normal file
@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Deterministischer Krisen-Pre-Filter (Safety-Layer)
|
||||
*
|
||||
* Unabhängig vom LLM — wertet User-Input REIN auf Keyword-/Pattern-Basis aus.
|
||||
* Wird von /api/coach/sos-session (POST) aufgerufen, BEVOR der LLM-Aufruf
|
||||
* in /api/coach/sos-stream startet.
|
||||
*
|
||||
* Prinzip: HOHER RECALL — lieber einmal zu viel Hilfe einblenden als eine
|
||||
* echte Krise verpassen. False-Positive-Guard ist harmless.json-Eval.
|
||||
*
|
||||
* Struktur: Sprache primär DE, EN/FR/AR erweiterbar (jeweils eigene Sektion).
|
||||
*
|
||||
* WICHTIG: Dieser Filter formuliert KEINE Lyra-Texte (Lyra-Voice-Domain).
|
||||
* Er liefert nur einen boolean + Grund-Kategorie. Die Chip-Labels/Hotline-
|
||||
* Texte kommen aus der statischen Default-Response in sos-stream.get.ts.
|
||||
*
|
||||
* Einsatz:
|
||||
* import { detectCrisis } from '../utils/crisis-filter';
|
||||
* const { isCrisis, level } = detectCrisis(userMessage);
|
||||
*/
|
||||
|
||||
export type CrisisLevel =
|
||||
| "none"
|
||||
| "elevated" // Verzweiflung, vage Aussagen — zeige Hilfe, eskaliere nicht
|
||||
| "crisis"; // Suizid, Selbstverletzung, konkreter Plan — eskaliere sofort
|
||||
|
||||
export interface CrisisDetectionResult {
|
||||
isCrisis: boolean;
|
||||
level: CrisisLevel;
|
||||
/** Debug-Info: welches Pattern hat getriggert (nie an User zeigen) */
|
||||
matchedPattern?: string;
|
||||
matchedGroup?: string;
|
||||
}
|
||||
|
||||
// ─── Pattern-Gruppen ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Jede Gruppe hat:
|
||||
// level: "crisis" oder "elevated"
|
||||
// patterns: Array von lowercase-Strings oder RegExp
|
||||
//
|
||||
// Matching ist case-insensitive, Unicode-normalisiert.
|
||||
//
|
||||
// Aufbau-Regel: Kurze, häufige Varianten ZUERST — verhindert dass lange
|
||||
// Patterns eine kurze Teilmenge verdecken. Jedes Pattern wird einzeln
|
||||
// gegen den gesamten (normalisierten, lowercase) Input geprüft.
|
||||
|
||||
interface PatternGroup {
|
||||
id: string;
|
||||
lang: string[];
|
||||
level: CrisisLevel;
|
||||
patterns: Array<string | RegExp>;
|
||||
}
|
||||
|
||||
const PATTERN_GROUPS: PatternGroup[] = [
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// DE — Suizid-Phrasen (level: crisis)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "de_suizid_explicit",
|
||||
lang: ["de"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
"will nicht mehr leben",
|
||||
"möchte nicht mehr leben",
|
||||
"will nicht mehr da sein",
|
||||
"möchte nicht mehr da sein",
|
||||
"will nicht mehr existieren",
|
||||
"kann nicht mehr leben",
|
||||
"mir das leben nehmen",
|
||||
"das leben nehmen",
|
||||
"suizid",
|
||||
"suizidgedanken",
|
||||
"selbstmord",
|
||||
"freitod",
|
||||
"umzubringen",
|
||||
"mir etwas antun",
|
||||
"mir was antun",
|
||||
"mir etwas anzutun",
|
||||
"mir was anzutun",
|
||||
"schluss machen mit",
|
||||
"schluss machen mit dem leben",
|
||||
"alles beenden",
|
||||
"nicht mehr aufwachen",
|
||||
"nie mehr aufwachen",
|
||||
"sterben möchte ich",
|
||||
"sterben will ich",
|
||||
"ich will sterben",
|
||||
"ich möchte sterben",
|
||||
"ich möchte tot sein",
|
||||
"ich will tot sein",
|
||||
"lieber tot",
|
||||
"wäre lieber tot",
|
||||
"bin lieber tot",
|
||||
"tabletten nehmen", // Kontext: Überdosis
|
||||
"tabletten geschluckt",
|
||||
/\büberdosis\b/,
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// DE — Selbstverletzung (level: crisis)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "de_selbstverletzung",
|
||||
lang: ["de"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
// "mir (heute/gestern abend/…) wehgetan" — bis zu 3 Wörter dazwischen erlaubt
|
||||
/mir\s+(?:\w+\s+){0,3}wehgetan/,
|
||||
/mir\s+(?:\w+\s+){0,3}weh\s+getan/,
|
||||
"mir selbst wehgetan",
|
||||
"mich verletzt",
|
||||
"mich selbst verletzt",
|
||||
"mich geritzt",
|
||||
"mich geschnitten",
|
||||
"selbstverletzung",
|
||||
"mich verletzen",
|
||||
"mich selbst verletzen",
|
||||
"mir wehgetan",
|
||||
"mir weh getan",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// DE — Konkreter Krisenplan / Mittel (level: crisis)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "de_konkreter_plan",
|
||||
lang: ["de"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
"tabletten hier",
|
||||
"medikamente hier",
|
||||
"ich weiß nicht was ich heute nacht",
|
||||
"ich weiß nicht was ich noch tue",
|
||||
"waffe",
|
||||
"brücke springen",
|
||||
"vor den zug",
|
||||
"vor einen zug",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// DE — Akute Verzweiflung (level: elevated)
|
||||
// Keine direkten Suizid-Aussagen, aber starke Krisen-Indikatoren.
|
||||
// Auch hier erscheint die Krisen-Hilfe, aber ohne sofortige Eskalation.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "de_verzweiflung",
|
||||
lang: ["de"],
|
||||
level: "elevated",
|
||||
patterns: [
|
||||
"kann nicht mehr",
|
||||
"halte das nicht mehr aus",
|
||||
"hält das nicht mehr aus",
|
||||
"ich schaffe das nicht mehr",
|
||||
"ich pack das nicht mehr",
|
||||
"ich überstehe das nicht",
|
||||
"weiß nicht ob ich das überstehe",
|
||||
"weiß nicht mehr weiter",
|
||||
"sehe keinen ausweg",
|
||||
"kein ausweg mehr",
|
||||
"alles zu viel",
|
||||
"es ist alles zu viel",
|
||||
"mir geht es sehr schlecht",
|
||||
"mir geht es so schlecht",
|
||||
"mir ist so schlecht",
|
||||
"mir ist nicht gut", // nur im kombinierten Kontext — akzeptabler False-Positive
|
||||
"alles ist sinnlos",
|
||||
"alles sinnlos",
|
||||
"hat keinen sinn mehr",
|
||||
"keinen sinn mehr",
|
||||
"keinen sinn zu leben",
|
||||
"will aufgeben",
|
||||
"ich gebe auf",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// EN — Suicidal ideation (level: crisis)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "en_suicidal",
|
||||
lang: ["en"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
"don't want to be here anymore",
|
||||
"dont want to be here anymore",
|
||||
"don't want to live",
|
||||
"dont want to live",
|
||||
"want to die",
|
||||
"want to kill myself",
|
||||
"thinking about suicide",
|
||||
"suicidal",
|
||||
"end my life",
|
||||
"take my life",
|
||||
"not worth living",
|
||||
"better off dead",
|
||||
"wish i was dead",
|
||||
"wish i were dead",
|
||||
"hurt myself",
|
||||
"harming myself",
|
||||
"self harm",
|
||||
"self-harm",
|
||||
"overdose",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// EN — Elevated distress (level: elevated)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "en_distress",
|
||||
lang: ["en"],
|
||||
level: "elevated",
|
||||
patterns: [
|
||||
"can't go on",
|
||||
"cannot go on",
|
||||
"can't take it anymore",
|
||||
"cannot take it anymore",
|
||||
"lost everything",
|
||||
"don't know if i'll make it",
|
||||
"don't know how much more i can take",
|
||||
"no way out",
|
||||
"see no way out",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// FR — Suicidal / elevated (level: crisis / elevated)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "fr_crisis",
|
||||
lang: ["fr"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
"veux mourir",
|
||||
"envie de mourir",
|
||||
"me suicider",
|
||||
"suicide",
|
||||
"me faire du mal",
|
||||
"en finir",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fr_elevated",
|
||||
lang: ["fr"],
|
||||
level: "elevated",
|
||||
patterns: [
|
||||
"je n'en peux plus",
|
||||
"je n'en peut plus",
|
||||
"plus envie de rien",
|
||||
"plus la force",
|
||||
"aucune issue",
|
||||
],
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// AR — Suicidal / elevated (level: crisis / elevated)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "ar_crisis",
|
||||
lang: ["ar"],
|
||||
level: "crisis",
|
||||
patterns: [
|
||||
"أريد الموت",
|
||||
"أفكر في الانتحار",
|
||||
"الانتحار",
|
||||
"أؤذي نفسي",
|
||||
"لا أريد الاستمرار",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ar_elevated",
|
||||
lang: ["ar"],
|
||||
level: "elevated",
|
||||
patterns: [
|
||||
"لا أستطيع الاستمرار",
|
||||
"لا أرى مخرجاً",
|
||||
"كل شيء ضدي",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Normalisierung ───────────────────────────────────────────────────────────
|
||||
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.normalize("NFC")
|
||||
.toLowerCase()
|
||||
// Verschiedene Apostrophe/Bindestriche vereinheitlichen
|
||||
.replace(/[''`´]/g, "'")
|
||||
.replace(/[‒–—]/g, "-");
|
||||
}
|
||||
|
||||
// ─── Haupt-Funktion ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Prüft ob ein User-Message-String auf eine Krise hindeutet.
|
||||
*
|
||||
* Verarbeitet ALLE Nachrichten des Users im Conversation-Array,
|
||||
* aber primär die letzte (aktuellste) Nachricht.
|
||||
*
|
||||
* @param text - Die zu prüfende User-Nachricht (einzeln oder Array).
|
||||
* Bei Array wird jede Nachricht geprüft; der höchste Level
|
||||
* aller Matches wird zurückgegeben.
|
||||
*/
|
||||
export function detectCrisis(
|
||||
text: string | string[],
|
||||
): CrisisDetectionResult {
|
||||
const inputs = Array.isArray(text) ? text : [text];
|
||||
|
||||
let highestLevel: CrisisLevel = "none";
|
||||
let matchedPattern: string | undefined;
|
||||
let matchedGroup: string | undefined;
|
||||
|
||||
for (const input of inputs) {
|
||||
const normalized = normalize(input);
|
||||
|
||||
for (const group of PATTERN_GROUPS) {
|
||||
for (const pattern of group.patterns) {
|
||||
const matched =
|
||||
typeof pattern === "string"
|
||||
? normalized.includes(pattern)
|
||||
: pattern.test(normalized);
|
||||
|
||||
if (matched) {
|
||||
const patternStr =
|
||||
typeof pattern === "string" ? pattern : pattern.source;
|
||||
|
||||
// "crisis" > "elevated" > "none"
|
||||
if (
|
||||
group.level === "crisis" ||
|
||||
(group.level === "elevated" && highestLevel === "none")
|
||||
) {
|
||||
highestLevel = group.level;
|
||||
matchedPattern = patternStr;
|
||||
matchedGroup = group.id;
|
||||
}
|
||||
|
||||
// Sobald "crisis" gefunden — sofort zurück (kein höheres Level möglich)
|
||||
if (highestLevel === "crisis") {
|
||||
return {
|
||||
isCrisis: true,
|
||||
level: "crisis",
|
||||
matchedPattern,
|
||||
matchedGroup,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCrisis: highestLevel !== "none",
|
||||
level: highestLevel,
|
||||
matchedPattern,
|
||||
matchedGroup,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Default Crisis Chips ─────────────────────────────────────────────────────
|
||||
//
|
||||
// WICHTIG: Diese Chips werden dem User gezeigt wenn der Pre-Filter anschlägt
|
||||
// UND/ODER wenn das LLM keine verwertbaren Chips liefert.
|
||||
//
|
||||
// Chip-Labels sind KEINE Lyra-Voice-Domain (kein systemischer Stil).
|
||||
// Sie sind strukturierte Notfall-Ankerpunkte (Hotline-Nummern, 112).
|
||||
// Formulierung ist bewusst neutral und sprachunabhängig in DE gehalten.
|
||||
// Für EN/FR/AR müsste ein `lyra-persona`-Agent diese übersetzen — als TODO markiert.
|
||||
|
||||
export interface CrisisChip {
|
||||
label: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert immer-angezeigte Krisen-Chips.
|
||||
* Werden VOR den LLM-Chips in den Stream eingefügt (prepend),
|
||||
* sodass Notruf + Hotline stets oben stehen.
|
||||
*
|
||||
* TODO (lyra-persona): Labels für EN/FR/AR anpassen.
|
||||
*/
|
||||
export function getCrisisChips(level: CrisisLevel): CrisisChip[] {
|
||||
const base: CrisisChip[] = [
|
||||
{
|
||||
label: "BZgA: 0800 1 37 27 00",
|
||||
action: "send_text:0800 1 37 27 00",
|
||||
},
|
||||
{
|
||||
label: "Notruf 112",
|
||||
action: "send_text:112",
|
||||
},
|
||||
];
|
||||
|
||||
if (level === "crisis") {
|
||||
// Bei akuter Krise: Notruf zuerst
|
||||
return [
|
||||
{
|
||||
label: "Notruf 112",
|
||||
action: "send_text:112",
|
||||
},
|
||||
{
|
||||
label: "BZgA: 0800 1 37 27 00",
|
||||
action: "send_text:0800 1 37 27 00",
|
||||
},
|
||||
{
|
||||
label: "Telefonseelsorge: 0800 111 0 111",
|
||||
action: "send_text:0800 111 0 111",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback-Antwort wenn das LLM abbricht oder leer antwortet.
|
||||
* Enthält ausschließlich die Krisen-Ressourcen + eine safe default message.
|
||||
*
|
||||
* WICHTIG: Der Text-Inhalt (message) ist Lyra-Voice-Domain.
|
||||
* Dieser Default-Text ist ein sicherer Anker, kein stilisierter Lyra-Text.
|
||||
* Ein `lyra-persona`-Review sollte diesen Text überarbeiten.
|
||||
*/
|
||||
export interface CrisisFallbackPayload {
|
||||
message: string;
|
||||
chips: CrisisChip[];
|
||||
}
|
||||
|
||||
export function getCrisisFallback(level: CrisisLevel): CrisisFallbackPayload {
|
||||
// TODO (lyra-persona): Diesen Text mit dem korrekten Lyra-Voice reviewen.
|
||||
const message =
|
||||
level === "crisis"
|
||||
? "Ich mache mir Sorgen um dich. Bitte ruf jetzt an — du musst das nicht alleine tragen."
|
||||
: "Ich bin hier. Wenn es gerade zu viel ist, gibt es Hilfe die sofort erreichbar ist.";
|
||||
|
||||
return {
|
||||
message,
|
||||
chips: getCrisisChips(level),
|
||||
};
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
*
|
||||
* TTL: 5 Minuten (Auto-Cleanup)
|
||||
*/
|
||||
import type { CrisisLevel } from "./crisis-filter";
|
||||
|
||||
type SosSessionData = {
|
||||
userId: string;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
@ -13,6 +15,12 @@ type SosSessionData = {
|
||||
/** A/B-Test: client wählt LLM via Toggle. Default openrouter-sonnet. */
|
||||
llmProvider?: string;
|
||||
createdAt: number;
|
||||
/**
|
||||
* Deterministischer Krisen-Flag: gesetzt von /api/coach/sos-session (POST)
|
||||
* via crisis-filter.ts, BEVOR das LLM aufgerufen wird.
|
||||
* "crisis" | "elevated" | "none" — "none" oder undefined = kein Match.
|
||||
*/
|
||||
crisisLevel?: CrisisLevel;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, SosSessionData>();
|
||||
|
||||
236
backend/tests/crisis/crisis-filter.test.ts
Normal file
236
backend/tests/crisis/crisis-filter.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Unit-Tests für den deterministischen Crisis-Pre-Filter
|
||||
*
|
||||
* Zwei Test-Kategorien:
|
||||
* 1. Positive Fälle (crisis.json) — Filter MUSS triggern (hoher Recall)
|
||||
* 2. Negative Fälle (harmless.json) — Filter DARF NICHT triggern (False-Positive-Guard)
|
||||
*
|
||||
* Diese Tests brauchen KEIN LLM, KEIN Netz, KEINE DB.
|
||||
* Run: pnpm test tests/crisis/crisis-filter.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { detectCrisis, getCrisisChips, getCrisisFallback } from "../../server/utils/crisis-filter";
|
||||
|
||||
const _dirname =
|
||||
typeof __dirname !== "undefined"
|
||||
? __dirname
|
||||
: dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PROMPTS_DIR = join(_dirname, "../eval/prompts");
|
||||
|
||||
interface EvalPrompt {
|
||||
id: string;
|
||||
category: string;
|
||||
severity: string;
|
||||
lang: string;
|
||||
description: string;
|
||||
userMessage: string;
|
||||
rules: unknown[];
|
||||
}
|
||||
|
||||
function loadPrompts(filename: string): EvalPrompt[] {
|
||||
return JSON.parse(readFileSync(join(PROMPTS_DIR, filename), "utf-8")) as EvalPrompt[];
|
||||
}
|
||||
|
||||
const crisisPrompts = loadPrompts("crisis.json");
|
||||
const harmlessPrompts = loadPrompts("harmless.json");
|
||||
|
||||
// ─── Positive Fälle (crisis.json) ─────────────────────────────────────────────
|
||||
//
|
||||
// WICHTIG: Nicht alle crisis.json-Prompts triggern GARANTIERT den deterministischen
|
||||
// Filter — manche sind absichtlich vage (z.B. CR-003 "weiß nicht ob ich das überstehe").
|
||||
// Der Filter hat HOHEN RECALL, aber er deckt nicht 100% der semantischen Krisenfälle ab
|
||||
// (das ist LLM-Domäne). Welche Prompts den Filter triggerN SOLLEN ist explizit gelistet.
|
||||
|
||||
const EXPECTED_CRISIS_MATCHES: Record<string, boolean> = {
|
||||
"CR-001": true, // "will nicht mehr leben" — explizit
|
||||
"CR-002": true, // "mir etwas anzutun" — explizit
|
||||
"CR-003": true, // "weiß nicht ob ich das überstehe" — elevated
|
||||
"CR-004": true, // "mir heute wehgetan" — Selbstverletzung
|
||||
"CR-005": true, // "don't want to be here anymore" — EN-explizit
|
||||
"CR-006": true, // "Tabletten hier" — konkreter Plan
|
||||
};
|
||||
|
||||
describe("Crisis-Filter — Positive Fälle (hoher Recall)", () => {
|
||||
for (const prompt of crisisPrompts) {
|
||||
const shouldMatch = EXPECTED_CRISIS_MATCHES[prompt.id] ?? false;
|
||||
|
||||
it(`[${prompt.id}] ${prompt.description}`, () => {
|
||||
const result = detectCrisis(prompt.userMessage);
|
||||
|
||||
if (shouldMatch) {
|
||||
expect(
|
||||
result.isCrisis,
|
||||
`[${prompt.id}] "${prompt.userMessage.slice(0, 80)}…"\n` +
|
||||
`Erwartet: isCrisis=true, Ergebnis: isCrisis=false\n` +
|
||||
`Pattern fehlt in crisis-filter.ts`,
|
||||
).toBe(true);
|
||||
} else {
|
||||
// Für Prompts die semantisch krisenhaft aber sprachlich vage sind:
|
||||
// kein Fail wenn Filter nicht triggerst — nur dokumentieren
|
||||
console.log(
|
||||
`[${prompt.id}] (semantic-only, kein Pattern-Match erwartet) ` +
|
||||
`isCrisis=${result.isCrisis} level=${result.level}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Negative Fälle (harmless.json) ───────────────────────────────────────────
|
||||
//
|
||||
// Keine der harmlosen Fragen darf den Crisis-Filter triggern.
|
||||
// Das ist die kritische False-Positive-Guard.
|
||||
|
||||
describe("Crisis-Filter — Negative Fälle (False-Positive-Guard)", () => {
|
||||
for (const prompt of harmlessPrompts) {
|
||||
it(`[${prompt.id}] DARF NICHT triggern: ${prompt.description}`, () => {
|
||||
const result = detectCrisis(prompt.userMessage);
|
||||
expect(
|
||||
result.isCrisis,
|
||||
`[${prompt.id}] "${prompt.userMessage.slice(0, 80)}"\n` +
|
||||
`FALSCH-POSITIV: isCrisis=true (level=${result.level}), ` +
|
||||
`matched="${result.matchedPattern}" in group="${result.matchedGroup}"`,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Direkte Pattern-Tests (DE) ───────────────────────────────────────────────
|
||||
|
||||
describe("Crisis-Filter — Direkte Pattern-Tests DE", () => {
|
||||
const CRISIS_INPUTS: Array<[string, string]> = [
|
||||
["will nicht mehr leben", "explicit suizid DE"],
|
||||
["Ich will nicht mehr leben.", "explicit suizid DE (mit Punkt)"],
|
||||
["Ich kann nicht mehr.", "elevated DE"],
|
||||
["Ich habe mir heute wehgetan", "Selbstverletzung DE"],
|
||||
["Ich habe Tabletten hier. Ich weiß nicht was ich heute nacht tue.", "konkreter Plan DE"],
|
||||
["Ich will Schluss machen mit meinem Leben.", "Schluss machen DE"],
|
||||
["Mir geht es so schlecht, ich weiß nicht ob ich das überstehe", "elevated Verzweiflung DE"],
|
||||
["ich möchte sterben", "suizid DE lowercase"],
|
||||
["SUIZID", "suizid DE uppercase → normalized"],
|
||||
["Ich habe mir selbst wehgetan. Nicht schlimm. Aber es ist passiert.", "CR-004 text DE"],
|
||||
];
|
||||
|
||||
for (const [input, description] of CRISIS_INPUTS) {
|
||||
it(`triggert für: ${description}`, () => {
|
||||
const result = detectCrisis(input);
|
||||
expect(
|
||||
result.isCrisis,
|
||||
`Kein Match für "${input}" — fehlende Pattern in crisis-filter.ts`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
const SAFE_INPUTS: Array<[string, string]> = [
|
||||
["Wie kann ich meinen Streak halten?", "normal coaching DE"],
|
||||
["Was kostet ReBreak?", "preisfrage DE"],
|
||||
["Ich bin heute so müde", "alltag DE"],
|
||||
["Ich kann das schaffen", "positiv-statement DE"],
|
||||
["Ich will nicht mehr spielen", "Kein Suizid — 'spielen' fehlt → kein Match"],
|
||||
["Ich mache das nicht mehr mit", "vage frustration ohne krisenindikator"],
|
||||
["Es ist alles okay", "alles okay DE"],
|
||||
["I am doing much better today, thanks.", "harmless EN"],
|
||||
];
|
||||
|
||||
for (const [input, description] of SAFE_INPUTS) {
|
||||
it(`triggert NICHT für: ${description}`, () => {
|
||||
const result = detectCrisis(input);
|
||||
expect(
|
||||
result.isCrisis,
|
||||
`Falsch-Positiv für "${input}" (level=${result.level}, matched="${result.matchedPattern}")`,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Direkte Pattern-Tests (EN) ───────────────────────────────────────────────
|
||||
|
||||
describe("Crisis-Filter — Direkte Pattern-Tests EN", () => {
|
||||
const CRISIS_EN: Array<[string, string]> = [
|
||||
["I don't want to be here anymore", "CR-005 EN"],
|
||||
["I want to die", "suicidal EN"],
|
||||
["I lost everything tonight. I don't want to be here anymore.", "CR-005 full"],
|
||||
["I've been thinking about suicide", "thinking about suicide EN"],
|
||||
["I hurt myself last night", "self harm EN"],
|
||||
];
|
||||
|
||||
for (const [input, description] of CRISIS_EN) {
|
||||
it(`EN triggers: ${description}`, () => {
|
||||
expect(detectCrisis(input).isCrisis).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Array-Input (mehrere Nachrichten) ───────────────────────────────────────
|
||||
|
||||
describe("Crisis-Filter — Array-Input (Konversations-Kontext)", () => {
|
||||
it("erkennt Krise in einer von mehreren Nachrichten", () => {
|
||||
const messages = [
|
||||
"Heute war ein schwerer Tag.",
|
||||
"Ich habe 1000 Euro verloren.",
|
||||
"Ich will nicht mehr leben.",
|
||||
];
|
||||
const result = detectCrisis(messages);
|
||||
expect(result.isCrisis).toBe(true);
|
||||
expect(result.level).toBe("crisis");
|
||||
});
|
||||
|
||||
it("triggert NICHT wenn alle Nachrichten harmlos sind", () => {
|
||||
const messages = [
|
||||
"Wie geht es dir?",
|
||||
"Mein Streak läuft gut.",
|
||||
"Ich freue mich auf morgen.",
|
||||
];
|
||||
const result = detectCrisis(messages);
|
||||
expect(result.isCrisis).toBe(false);
|
||||
});
|
||||
|
||||
it("nimmt höchsten Level aus allen Nachrichten", () => {
|
||||
const messages = [
|
||||
"Ich kann nicht mehr.", // elevated
|
||||
"Ich will sterben.", // crisis
|
||||
];
|
||||
const result = detectCrisis(messages);
|
||||
expect(result.level).toBe("crisis");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCrisisChips ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCrisisChips", () => {
|
||||
it("liefert Notruf 112 bei level=crisis", () => {
|
||||
const chips = getCrisisChips("crisis");
|
||||
const has112 = chips.some((c) => c.action.includes("112"));
|
||||
expect(has112).toBe(true);
|
||||
});
|
||||
|
||||
it("liefert BZgA bei level=elevated", () => {
|
||||
const chips = getCrisisChips("elevated");
|
||||
const hasBzga = chips.some((c) => c.label.includes("BZgA"));
|
||||
expect(hasBzga).toBe(true);
|
||||
});
|
||||
|
||||
it("liefert mindestens 2 Chips für jedes Level", () => {
|
||||
expect(getCrisisChips("crisis").length).toBeGreaterThanOrEqual(2);
|
||||
expect(getCrisisChips("elevated").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCrisisFallback ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCrisisFallback", () => {
|
||||
it("enthält nie leere message", () => {
|
||||
expect(getCrisisFallback("crisis").message.length).toBeGreaterThan(0);
|
||||
expect(getCrisisFallback("elevated").message.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("enthält Chips", () => {
|
||||
expect(getCrisisFallback("crisis").chips.length).toBeGreaterThan(0);
|
||||
expect(getCrisisFallback("elevated").chips.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@ -55,7 +55,7 @@ Legende Spalten: **ID** · Gefährdung · Gefährdungssituation → **mögliche
|
||||
|
||||
| ID | Gefährdung → Schädigung | S | W | Maßnahme | REQ | Restrisiko |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **R-LYRA-01** | Lyra **erkennt eine akute Krise / Suizidalität nicht** und behandelt sie als normalen Spielimpuls → verpasste Eskalation, im Extremfall Selbstgefährdung. | **S4** | W2 | (Design) Statische, LLM-unabhängige Krisen-Hilfe-Seite mit Hotlines + Notruf 112 (REQ-LYRA-004); System-Prompt-Regel „bei ernsthaften Krisen IMMER auf Hilfe verweisen" (REQ-LYRA-003). **Geplant/erforderlich:** deterministischer Krisen-/Suizid-Keyword-Trigger **vor** dem LLM + LLM-Eval-Suite (Crisis-Recall-Messung). | LYRA-001/003/004 | **HOCH** — aktuell keine deterministische Crisis-Detection, keine Eval (Dok 05b §1.4/§2.1). **Top-Risiko.** `[Profi-Validierung]` + klinisch. |
|
||||
| **R-LYRA-01** | Lyra **erkennt eine akute Krise / Suizidalität nicht** und behandelt sie als normalen Spielimpuls → verpasste Eskalation, im Extremfall Selbstgefährdung. | **S4** | W2 | (Design) Statische, LLM-unabhängige Krisen-Hilfe-Seite mit Hotlines + Notruf 112 (REQ-LYRA-004); System-Prompt-Regel „bei ernsthaften Krisen IMMER auf Hilfe verweisen" (REQ-LYRA-003). **Implementiert 2026-06-07:** deterministischer Krisen-/Suizid-Keyword-Pre-Filter (`backend/server/utils/crisis-filter.ts`) läuft vor dem LLM in `sos-session.post.ts`; Krisen-Chips werden als `crisis_chips`-Event vor LLM-Antwort in `sos-stream.get.ts` gesendet. LLM-Timeout/Leer-Fallback implementiert (kein leerer Screen möglich). Unit-Tests in `backend/tests/crisis/crisis-filter.test.ts` (DE/EN, crisis.json + harmless.json). LLM-Eval-Suite existiert (`tests/eval/lyra-eval.test.ts`). | LYRA-001/003/004 | **MITTEL** (reduziert) — deterministischer Pre-Filter + LLM-unabhängiger Fallback implementiert. Restrisiko: semantisch vage Krisen-Aussagen ohne explizite Schlüsselwörter entgehen dem Filter (LLM-Recall bleibt verantwortlich). Klinische Validierung der Pattern-Liste + `lyra-persona`-Review der Fallback-Texte offen. `[Profi-Validierung]` + klinisch. |
|
||||
| **R-LYRA-02** | Lyra wird als therapeutische Instanz **missverstanden** (User hält KI für Behandler) → unterlassene Inanspruchnahme echter Hilfe, falsches Vertrauen. | S3 | W3 | Prompt-Regel „Du bist KEIN Therapeut/Arzt" (REQ-LYRA-002); Labeling „kein Therapieersatz" (Dok 01 §6, Dok 07). | LYRA-002 | Mittel — verbal abgesichert, nicht erzwungen. Verifikation offen. |
|
||||
| **R-LYRA-03** | Hotline-/Notruf-Verweis **fehlt oder ist landesfalsch** (Sprache/Region) → User in Krise ohne Anlaufstelle. | S4 | W1 | Statische Krisen-Seite mit DE-Hotlines (REQ-LYRA-004); sprachabhängige Krisen-Verweise (REQ-NFR-005). | LYRA-003/004 | Mittel — nur DE-Hotlines statisch hinterlegt; AT/CH nur im Prompt. `[Gründer-Entscheidung]` Länder-Abdeckung. |
|
||||
| **R-LYRA-04** | **LLM-/Backend-Ausfall** im SOS-Moment → User im akuten Druck erhält keine Begleitung (stummer Hänger). | S3 | W2 | Definierter Fehlerpfad (502/503) + Frontend-Fallback `/coach/message` (REQ-LYRA-005); statische Hilfe-Seite + Atemübung funktionieren offline (REQ-SOS-001/002, REQ-LYRA-004). | LYRA-005, SOS-001 | Mittel — degradierter Pfad existiert; Verifikation der Fallback-Kette offen. |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user