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:
chahinebrini 2026-06-07 07:56:34 +02:00
parent ac05e255da
commit 96e1b8368c
10 changed files with 882 additions and 19 deletions

View File

@ -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' },
});
}

View File

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

View File

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

View File

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

View File

@ -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,
};
});

View File

@ -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();
}

View 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),
};
}

View File

@ -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>();

View 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);
});
});

View File

@ -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. |