fix(coach): markdown-strip safety-net + tier-aware speak-endpoint

Backend:
- New stripMarkdown() util (utils/strip-markdown.ts) — handles **bold**,
  bullet-lists, headings, code-fences, links, blockquotes
- /api/coach/message: applies stripMarkdown(text) post-LLM as safety-net
  because Haiku/Llama keep emitting markdown despite explicit prompt rule

Frontend:
- lyra.tsx voice-flow: hardcoded /api/coach/speak-openai → /api/coach/speak
  (tier-aware dispatcher: Free=Google, Pro=Cartesia, Legend=ElevenLabs)
- Added Metro debug-logs at TTS call-site for endpoint + status visibility
- detectEmotion extracted to lib/lyraResponse.ts (was inline duplicate)
- RiveAvatar: small type-export adjustment for shared Emotion type

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-09 17:54:34 +02:00
parent e3042c10a2
commit f00d2319a5
5 changed files with 87 additions and 22 deletions

View File

@ -33,15 +33,7 @@ import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme'; import { useThemeStore } from '../stores/theme';
import { detectEmotion } from '../lib/lyraResponse';
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
function detectEmotion(text: string): Emotion {
if (HAPPY_RE.test(text)) return 'happy';
if (EMPATHY_RE.test(text)) return 'empathy';
return 'idle';
}
function formatDuration(s: number): string { function formatDuration(s: number): string {
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
@ -364,14 +356,17 @@ export default function CoachScreen() {
try { try {
const apiBase = Constants.expoConfig?.extra?.apiUrl as string; const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
const session = (await supabase.auth.getSession()).data.session; const session = (await supabase.auth.getSession()).data.session;
const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, { const speakUrl = `${apiBase}/api/coach/speak`;
console.log('[tts] POST', speakUrl, 'text-len:', res.message.length);
const ttsRes = await fetch(speakUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
}, },
body: JSON.stringify({ text: res.message, locale: i18n.language }), body: JSON.stringify({ text: res.message, mode: 'chat' }),
}); });
console.log('[tts] response status:', ttsRes.status, 'content-type:', ttsRes.headers.get('content-type'));
if (ttsRes.ok) { if (ttsRes.ok) {
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben. // Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
const buffer = await ttsRes.arrayBuffer(); const buffer = await ttsRes.arrayBuffer();

View File

@ -39,18 +39,21 @@ function preloadRiveAsset(): Promise<string | null> {
// Render bereits die cached URI nutzt (außer im allerersten App-Start). // Render bereits die cached URI nutzt (außer im allerersten App-Start).
preloadRiveAsset(); preloadRiveAsset();
export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy'; // Supported emotions sind durch state-machine im .riv-file definiert.
// Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig.
export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy';
export type Emotion = SupportedEmotion | (string & {});
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue). // Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop. // "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
const EMOTION_ANIMATIONS: Record<Emotion, string> = { const EMOTION_ANIMATIONS: Record<string, string> = {
idle: 'Idle Loop', idle: 'Idle Loop',
happy: 'idle to Pose 1', happy: 'idle to Pose 1',
thinking: 'WALK', thinking: 'WALK',
empathy: '01 Wave 1', empathy: '01 Wave 1',
}; };
const EMOTION_LABELS: Record<Emotion, string> = { const EMOTION_LABELS: Record<string, string> = {
idle: 'bereit', idle: 'bereit',
happy: 'froh für dich', happy: 'froh für dich',
thinking: 'überlegt ...', thinking: 'überlegt ...',
@ -67,13 +70,16 @@ type Props = {
emotion: Emotion; emotion: Emotion;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
showLabel?: boolean; showLabel?: boolean;
fallback?: Emotion;
}; };
export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) { export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) {
const px = SIZE_PX[size]; const px = SIZE_PX[size];
const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback;
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()). // Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS.idle); const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File // Lokale URI für die .riv-Datei — geht über expo-asset damit der File
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen. // im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
@ -92,14 +98,14 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
}, [riveUri]); }, [riveUri]);
useEffect(() => { useEffect(() => {
if (emotion === 'happy') { if (resolvedEmotion === 'happy') {
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar) // 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
setCurrentAnim('idle to Pose 1'); setCurrentAnim('idle to Pose 1');
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900); const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
setCurrentAnim(EMOTION_ANIMATIONS[emotion]); setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
}, [emotion]); }, [resolvedEmotion]);
return ( return (
<View style={{ alignItems: 'center', gap: 4 }}> <View style={{ alignItems: 'center', gap: 4 }}>
@ -158,7 +164,7 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
{showLabel && ( {showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}> <Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[emotion]} {EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
</Text> </Text>
)} )}
</View> </View>

View File

@ -1,8 +1,8 @@
// Parser für Lyras LLM-JSON-Antworten + Emotion-Detection. // Parser für Lyras LLM-JSON-Antworten + Emotion-Detection.
import type { Emotion as LyraEmotion } from '../components/RiveAvatar';
import { EMPATHY_RE, HAPPY_RE } from './sosConstants'; import { EMPATHY_RE, HAPPY_RE } from './sosConstants';
import type { Emotion } from '../components/RiveAvatar';
export type { LyraEmotion }; export type LyraEmotion = Emotion;
export type ChipSpec = { label: string; action: string }; export type ChipSpec = { label: string; action: string };
// Parse LLM JSON response — robust gegen Markdown-Fences UND abgeschnittenes JSON // Parse LLM JSON response — robust gegen Markdown-Fences UND abgeschnittenes JSON

View File

@ -243,6 +243,7 @@ import { PLAN_LIMITS } from "../../utils/plan-features";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
import { stripMarkdown } from "../../utils/strip-markdown";
/** /**
* Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren. * Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren.
@ -599,6 +600,11 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Markdown-Strip safety-net: trotz expliziter Prompt-Regel emittieren manche
// Modelle (insbesondere Haiku) weiterhin **bold** + Bullet-Lists. RN-Mobile
// rendert kein Markdown — User sieht rohe Sterne. Hier final cleanen.
text = stripMarkdown(text);
const feedbackSaved = await feedbackPromise; const feedbackSaved = await feedbackPromise;
// Memory: markReferenced + Extraction fire-and-forget // Memory: markReferenced + Extraction fire-and-forget

View File

@ -0,0 +1,58 @@
/**
* Entfernt Markdown-Formatierung aus LLM-Antworten.
*
* Hintergrund: Trotz expliziter "kein Markdown"-Anweisung im System-Prompt
* emittieren manche Modelle (insbesondere Claude Haiku) weiterhin **bold**,
* Bullet-Lists und ähnliches. Das wirkt in der Mobile-App unsauber, weil dort
* kein Markdown-Renderer aktiv ist User sehen rohe Sterne.
*
* Diese Funktion ist ein Safety-Net: nach LLM-Call angewendet, garantiert
* sie sauberen Klartext, ohne den eigentlichen Inhalt zu beschädigen.
*/
export function stripMarkdown(input: string): string {
if (!input) return input;
let out = input;
// Bold/italic: **text** / __text__ / *text* / _text_
out = out.replace(/\*\*\*(.+?)\*\*\*/g, "$1");
out = out.replace(/___(.+?)___/g, "$1");
out = out.replace(/\*\*(.+?)\*\*/g, "$1");
out = out.replace(/__(.+?)__/g, "$1");
// Single * or _ als italic — nur wenn auf beiden Seiten Wort-Boundary,
// sonst zerstören wir Listen-Bullets oder Multiplikationen
out = out.replace(/(^|\s)\*([^\s*][^*]*?)\*(?=\s|$|[.,;:!?])/g, "$1$2");
out = out.replace(/(^|\s)_([^\s_][^_]*?)_(?=\s|$|[.,;:!?])/g, "$1$2");
// Headings: # / ## / ### am Zeilenanfang
out = out.replace(/^#{1,6}\s+/gm, "");
// Bullet-Lists: "- foo", "* foo", "+ foo" am Zeilenanfang
// Behalte einfachen Aufzählungs-Bindestrich aber entferne markdown-marker-Charakter
// → "- Schutz" wird "• Schutz" (Bullet ohne Markdown-Semantik)
out = out.replace(/^[ \t]*[-*+][ \t]+/gm, "• ");
// Numbered Lists: "1. foo", "2. foo" am Zeilenanfang
// Zahlen behalten (informativer als bullet) — nur falls strikt entfernt werden soll
// out = out.replace(/^[ \t]*\d+\.[ \t]+/gm, "");
// Inline-Code: `text`
out = out.replace(/`([^`]+)`/g, "$1");
// Code-Blocks: ```...```
out = out.replace(/```[a-zA-Z]*\n?([\s\S]*?)```/g, "$1");
// Links: [text](url) → text
out = out.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
// Blockquotes: "> foo" → "foo"
out = out.replace(/^>[ \t]?/gm, "");
// Horizontal rules: --- / *** / ___ alleinstehend
out = out.replace(/^[\t ]*[-*_]{3,}[\t ]*$/gm, "");
// Multiple Leerzeilen → max 1 Leerzeile
out = out.replace(/\n{3,}/g, "\n\n");
return out.trim();
}