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 {
|
import {
|
||||||
View, Text, TextInput, FlatList, Pressable, Platform, Animated,
|
View, Text, TextInput, FlatList, Pressable, Platform, Animated,
|
||||||
Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent,
|
Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent,
|
||||||
NativeScrollEvent, ActivityIndicator, AppState,
|
NativeScrollEvent, ActivityIndicator, AppState, TouchableOpacity, Linking,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
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 { SOS_BOOT } from '../lib/sosPrompts';
|
||||||
import { CHIP_SETS, BREATH_PHASES, type ChipSet } from '../lib/sosConstants';
|
import { CHIP_SETS, BREATH_PHASES, type ChipSet } from '../lib/sosConstants';
|
||||||
import { parseLyraResponse, detectEmotion, type LyraEmotion, type ChipSpec } from '../lib/lyraResponse';
|
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 { SosTtsQueue } from '../lib/sosTtsQueue';
|
||||||
import { endpointForProvider, useTtsProvider, currentProvider } from '../lib/ttsProvider';
|
import { endpointForProvider, useTtsProvider, currentProvider } from '../lib/ttsProvider';
|
||||||
import { currentLlmProvider } from '../lib/llmProvider';
|
import { currentLlmProvider } from '../lib/llmProvider';
|
||||||
@ -57,6 +57,8 @@ export default function SOSScreen() {
|
|||||||
const soundEnabledRef = useRef(true);
|
const soundEnabledRef = useRef(true);
|
||||||
const [chipSet, setChipSet] = useState<ChipSet>('start');
|
const [chipSet, setChipSet] = useState<ChipSet>('start');
|
||||||
const [dynamicChips, setDynamicChips] = useState<ChipSpec[]>([]);
|
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);
|
const [userTurnCount, setUserTurnCount] = useState(0);
|
||||||
|
|
||||||
// ——— Session-Tracking für DiGA ———
|
// ——— Session-Tracking für DiGA ———
|
||||||
@ -465,6 +467,8 @@ export default function SOSScreen() {
|
|||||||
locale: i18n.language,
|
locale: i18n.language,
|
||||||
llmProvider: currentLlmProvider(),
|
llmProvider: currentLlmProvider(),
|
||||||
onMetric: bench.mark,
|
onMetric: bench.mark,
|
||||||
|
onCrisisLevel: (level) => setCrisisLevel(level),
|
||||||
|
onCrisisChips: (chips) => setCrisisChips(chips),
|
||||||
onTextUpdate: (full) => {
|
onTextUpdate: (full) => {
|
||||||
visible = full;
|
visible = full;
|
||||||
ensureBubble(full);
|
ensureBubble(full);
|
||||||
@ -658,6 +662,8 @@ export default function SOSScreen() {
|
|||||||
locale: i18n.language,
|
locale: i18n.language,
|
||||||
llmProvider: currentLlmProvider(),
|
llmProvider: currentLlmProvider(),
|
||||||
onMetric: greetingBench.mark,
|
onMetric: greetingBench.mark,
|
||||||
|
onCrisisLevel: (level) => { if (!cancelled) setCrisisLevel(level); },
|
||||||
|
onCrisisChips: (chips) => { if (!cancelled) setCrisisChips(chips); },
|
||||||
onTextUpdate: (full) => {
|
onTextUpdate: (full) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
visible = full;
|
visible = full;
|
||||||
@ -743,6 +749,10 @@ export default function SOSScreen() {
|
|||||||
async function handleChip(action: string) {
|
async function handleChip(action: string) {
|
||||||
if (thinking) return;
|
if (thinking) return;
|
||||||
Keyboard.dismiss();
|
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
|
// Clear dynamic chips on any chip action — Lyra will provide new ones in her reply
|
||||||
setDynamicChips([]);
|
setDynamicChips([]);
|
||||||
if (action.startsWith('feel:')) {
|
if (action.startsWith('feel:')) {
|
||||||
@ -1181,6 +1191,32 @@ export default function SOSScreen() {
|
|||||||
</View>
|
</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.
|
{/* Chips above input — only after Lyra has answered.
|
||||||
Bei Standard-Actions (breathing/game/overcome/etc): Ionicons
|
Bei Standard-Actions (breathing/game/overcome/etc): Ionicons
|
||||||
(native = SF Symbols iOS, Material Android) + Label OHNE Emoji.
|
(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 },
|
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 },
|
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' },
|
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 { BenchOnMetric } from './sosTtsBenchmark';
|
||||||
import type { LlmProvider } from './llmProvider';
|
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 = {
|
export type StreamSosLyraOpts = {
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
@ -26,6 +28,11 @@ export type StreamSosLyraOpts = {
|
|||||||
llmProvider?: LlmProvider;
|
llmProvider?: LlmProvider;
|
||||||
onTextUpdate: (full: string) => void;
|
onTextUpdate: (full: string) => void;
|
||||||
onChips: (chips: Array<{ label: string; action: 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
|
/** Phase B: feuert pro fertigem Satz live während des Streams + Tail beim
|
||||||
* done. Bei aktivem TTS-Streaming sollte der Aufrufer hier seine Queue
|
* done. Bei aktivem TTS-Streaming sollte der Aufrufer hier seine Queue
|
||||||
* füllen statt im onDone den ganzen Text zu sprechen. */
|
* 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 }),
|
body: JSON.stringify({ messages: opts.messages, locale: opts.locale, llmProvider: opts.llmProvider }),
|
||||||
});
|
});
|
||||||
if (!sessRes.ok) throw new Error(`session: ${sessRes.status}`);
|
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');
|
opts.onMetric?.('session-post-done');
|
||||||
|
|
||||||
// Step 2: EventSource für SSE-Stream
|
// Step 2: EventSource für SSE-Stream
|
||||||
@ -133,6 +144,14 @@ export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void
|
|||||||
} catch { /* ignore */ }
|
} 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', () => {
|
es.addEventListener('done', () => {
|
||||||
opts.onMetric?.('sse-done');
|
opts.onMetric?.('sse-done');
|
||||||
// Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird
|
// Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird
|
||||||
|
|||||||
@ -179,7 +179,8 @@
|
|||||||
"welcome_back": "Willkommen zurück",
|
"welcome_back": "Willkommen zurück",
|
||||||
"online": "online",
|
"online": "online",
|
||||||
"thinking": "schreibt …",
|
"thinking": "schreibt …",
|
||||||
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut."
|
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
|
"crisis_bar_label": "Krisen-Hilfe"
|
||||||
},
|
},
|
||||||
"blocker": {
|
"blocker": {
|
||||||
"title": "Blocker",
|
"title": "Blocker",
|
||||||
|
|||||||
@ -179,7 +179,8 @@
|
|||||||
"welcome_back": "Welcome back",
|
"welcome_back": "Welcome back",
|
||||||
"online": "online",
|
"online": "online",
|
||||||
"thinking": "typing …",
|
"thinking": "typing …",
|
||||||
"error": "Something went wrong. Please try again."
|
"error": "Something went wrong. Please try again.",
|
||||||
|
"crisis_bar_label": "Crisis support"
|
||||||
},
|
},
|
||||||
"blocker": {
|
"blocker": {
|
||||||
"title": "Blocker",
|
"title": "Blocker",
|
||||||
|
|||||||
@ -6,7 +6,14 @@
|
|||||||
*
|
*
|
||||||
* Grund: react-native-sse (EventSource API) unterstützt nur GET, nicht POST.
|
* Grund: react-native-sse (EventSource API) unterstützt nur GET, nicht POST.
|
||||||
* Daher 2-Step-Flow: POST Session erstellen → GET Stream öffnen.
|
* 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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
@ -20,6 +27,23 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, message: "messages fehlt" });
|
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
|
// Session-ID generieren
|
||||||
const sessionId = `sos_${user.id}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
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",
|
locale: locale ?? "de",
|
||||||
llmProvider,
|
llmProvider,
|
||||||
createdAt: Date.now(),
|
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.
|
* Frontend nutzt react-native-sse (EventSource) für progressives Streaming.
|
||||||
*
|
*
|
||||||
* Format (SSE-Standard):
|
* Format (SSE-Standard):
|
||||||
@ -11,15 +11,29 @@
|
|||||||
* event: chips
|
* event: chips
|
||||||
* data: [{"label":"...","action":"..."}]
|
* data: [{"label":"...","action":"..."}]
|
||||||
*
|
*
|
||||||
|
* event: crisis_chips ← NEU: deterministischer Krisen-Override
|
||||||
|
* data: [{"label":"...","action":"..."}]
|
||||||
|
*
|
||||||
* Flow:
|
* 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
|
* 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
|
* 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 { 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 { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
|
||||||
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
||||||
import { getProfile } from "../../db/profile";
|
import { getProfile } from "../../db/profile";
|
||||||
@ -85,6 +99,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const { messages, locale } = sessionData;
|
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)
|
// Session löschen (One-Time-Use)
|
||||||
deleteSosSession(sessionId);
|
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(() => "");
|
const errText = await upstream.text().catch(() => "");
|
||||||
console.error(
|
console.error(
|
||||||
"[coach/sos-stream] upstream error:",
|
"[coach/sos-stream] upstream error:",
|
||||||
upstream.status,
|
upstream.status,
|
||||||
errText.slice(0, 300),
|
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
|
// 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");
|
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 reader = upstream.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@ -388,8 +432,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chips.length > 0) {
|
if (chips.length > 0) {
|
||||||
write(`event: chips\ndata: ${JSON.stringify(chips)}\n\n`);
|
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");
|
write("event: done\ndata: {}\n\n");
|
||||||
console.log(
|
console.log(
|
||||||
@ -413,7 +476,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[coach/sos-stream] read error:", 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 {
|
} finally {
|
||||||
res.end();
|
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)
|
* TTL: 5 Minuten (Auto-Cleanup)
|
||||||
*/
|
*/
|
||||||
|
import type { CrisisLevel } from "./crisis-filter";
|
||||||
|
|
||||||
type SosSessionData = {
|
type SosSessionData = {
|
||||||
userId: string;
|
userId: string;
|
||||||
messages: Array<{ role: "user" | "assistant"; content: 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. */
|
/** A/B-Test: client wählt LLM via Toggle. Default openrouter-sonnet. */
|
||||||
llmProvider?: string;
|
llmProvider?: string;
|
||||||
createdAt: number;
|
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>();
|
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 |
|
| 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-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-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. |
|
| **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