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 { useColors } from '../lib/theme';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
|
||||
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';
|
||||
}
|
||||
import { detectEmotion } from '../lib/lyraResponse';
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
@ -364,14 +356,17 @@ export default function CoachScreen() {
|
||||
try {
|
||||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(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) {
|
||||
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
|
||||
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).
|
||||
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).
|
||||
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
|
||||
const EMOTION_ANIMATIONS: Record<Emotion, string> = {
|
||||
const EMOTION_ANIMATIONS: Record<string, string> = {
|
||||
idle: 'Idle Loop',
|
||||
happy: 'idle to Pose 1',
|
||||
thinking: 'WALK',
|
||||
empathy: '01 Wave 1',
|
||||
};
|
||||
|
||||
const EMOTION_LABELS: Record<Emotion, string> = {
|
||||
const EMOTION_LABELS: Record<string, string> = {
|
||||
idle: 'bereit',
|
||||
happy: 'froh für dich',
|
||||
thinking: 'überlegt ...',
|
||||
@ -67,13 +70,16 @@ type Props = {
|
||||
emotion: Emotion;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
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 resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback;
|
||||
|
||||
// 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
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emotion === 'happy') {
|
||||
if (resolvedEmotion === 'happy') {
|
||||
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
|
||||
setCurrentAnim('idle to Pose 1');
|
||||
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
setCurrentAnim(EMOTION_ANIMATIONS[emotion]);
|
||||
}, [emotion]);
|
||||
setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
|
||||
}, [resolvedEmotion]);
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', gap: 4 }}>
|
||||
@ -158,7 +164,7 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
|
||||
|
||||
{showLabel && (
|
||||
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
|
||||
{EMOTION_LABELS[emotion]}
|
||||
{EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// 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 type { Emotion } from '../components/RiveAvatar';
|
||||
|
||||
export type { LyraEmotion };
|
||||
export type LyraEmotion = Emotion;
|
||||
export type ChipSpec = { label: string; action: string };
|
||||
|
||||
// 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 { getMemoriesForUser, markReferenced } from "../../db/lyraMemory";
|
||||
import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract";
|
||||
import { stripMarkdown } from "../../utils/strip-markdown";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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