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:
parent
e3042c10a2
commit
f00d2319a5
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
58
backend/server/utils/strip-markdown.ts
Normal file
58
backend/server/utils/strip-markdown.ts
Normal 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();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user