chahinebrini f2e822be95 feat(sos): llmProvider toggle + sort:latency + bench scaffolding
- backend/coach: routing zu Sonnet (default) / Haiku / Groq Llama je nach
  sessionData.llmProvider. sort:latency für Anthropic-Modelle (-30..58% TTFB).
- frontend: LlmProviderToggle (Sonnet/Haiku/Groq pills), llmProvider.ts
  Storage-Helper. sosStream.ts schickt llmProvider im /sos-session-Body.
- bench: SosTtsBenchmark sammelt Marker (req->session, lyra-ttfb, lyra-done,
  tts-fired/headers/body/file, audio-loaded, first-audio); Output als console.table.
- ops: backend/scripts/llm-bench.sh + Python-Variante für realistic SOS-Prompt.
- speak-cartesia + speak-elevenlabs Endpoints (waren ungetracked, jetzt mit drin).
2026-05-06 13:58:07 +02:00

158 lines
5.7 KiB
TypeScript

// SSE Streaming Helper für Lyras SOS-Antworten.
//
// Architektur (bewusst entkoppelt):
// - Step 1: POST /api/coach/sos-session → liefert sessionId
// - Step 2: EventSource auf /api/coach/sos-stream?session=<id>
// - Events: 'message' (chunk), 'chips' (parsed), 'done', 'error'
//
// Phase B (sentence-level TTS): zusätzlich `onSentence?` Callback. Wenn gesetzt,
// feuert er sobald ein vollständiger Satz erkannt wird (live während des
// Streams) + einmal am Stream-Ende für den Tail. Aufrufer kann den Satz dann
// direkt in eine TTS-Queue schieben → erste Audio-Wiedergabe ~3s früher als
// "warten bis fullText fertig".
import EventSource from 'react-native-sse';
import type { BenchOnMetric } from './sosTtsBenchmark';
import type { LlmProvider } from './llmProvider';
type SseEvents = 'message' | 'chips' | 'done';
export type StreamSosLyraOpts = {
apiBase: string;
token: string;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
locale: string;
/** LLM-Provider-Switch: bestimmt welches Modell der Server für diese Session
* benutzt. Default (undefined) → openrouter-sonnet auf Server-Seite. */
llmProvider?: LlmProvider;
onTextUpdate: (full: string) => void;
onChips: (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. */
onSentence?: (sentence: string) => void;
onDone: (full: string) => void;
onError: (err: unknown) => void;
/** Latenz-Benchmark: feuert session-post-start, session-post-done,
* sse-first-chunk, sse-done. Siehe lib/sosTtsBenchmark.ts. */
onMetric?: BenchOnMetric;
};
// Min-Länge für sentence-level TTS — winzige "Hm." / "Ja." kommen mit dem
// nächsten Satz mit, sonst klingt's choppy.
const MIN_SENTENCE_CHARS = 8;
/**
* Findet vollständige Sätze im Text. Ein Satz endet bei `[.!?]` GEFOLGT VON
* Whitespace + Großbuchstaben (oder Zitat-Anfang). Das filtert die häufigen
* deutschen Abkürzungen "z.B. einfach", "d.h. nichts" etc. ohne explizite
* Abkürzungs-Liste — der nächste Char ist dann meistens lowercase.
*
* Returns: { sentences[], consumed } — wieviele chars vom Anfang von `text`
* bereits in `sentences` enthalten sind (inkl. trailing whitespace).
*/
function consumeCompletedSentences(text: string): { sentences: string[]; consumed: number } {
const sentences: string[] = [];
const re = /[.!?](?=\s+[A-ZÄÖÜ"„])/g;
let lastEnd = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const endOfSentence = m.index + 1;
sentences.push(text.slice(lastEnd, endOfSentence));
const wsMatch = text.slice(endOfSentence).match(/^\s+/);
lastEnd = endOfSentence + (wsMatch ? wsMatch[0].length : 0);
re.lastIndex = lastEnd;
}
return { sentences, consumed: lastEnd };
}
export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void> {
// Step 1: POST zu /api/coach/sos-session → sessionId holen
opts.onMetric?.('session-post-start');
const sessRes = await fetch(`${opts.apiBase}/api/coach/sos-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${opts.token}`,
},
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();
opts.onMetric?.('session-post-done');
// Step 2: EventSource für SSE-Stream
// pollingInterval: 0 → KEIN Auto-Reconnect (Session ist one-time-use)
const es = new EventSource<SseEvents>(`${opts.apiBase}/api/coach/sos-stream?session=${sessionId}`, {
headers: { Authorization: `Bearer ${opts.token}` },
pollingInterval: 0,
lineEndingCharacter: '\n',
});
let fullText = '';
let sentenceConsumedIndex = 0;
let firstChunkSeen = false;
const flushNewSentences = () => {
if (!opts.onSentence) return;
const remaining = fullText.slice(sentenceConsumedIndex);
const { sentences, consumed } = consumeCompletedSentences(remaining);
for (const s of sentences) {
const trimmed = s.trim();
if (trimmed.length >= MIN_SENTENCE_CHARS) {
opts.onSentence(trimmed);
}
}
sentenceConsumedIndex += consumed;
};
es.addEventListener('message', (event) => {
if (!event.data) return;
// Backend sendet JSON-encoded String → parse für korrekte Whitespace-Behandlung
let chunk: string;
try {
chunk = JSON.parse(event.data);
} catch {
chunk = event.data;
}
if (!chunk) return;
if (!firstChunkSeen) {
firstChunkSeen = true;
opts.onMetric?.('sse-first-chunk');
}
fullText += chunk;
opts.onTextUpdate(fullText);
// Phase B: live sentence-detection für TTS-Queue
flushNewSentences();
});
es.addEventListener('chips', (event) => {
if (!event.data) return;
try {
const chips = JSON.parse(event.data);
if (Array.isArray(chips)) opts.onChips(chips);
} catch { /* ignore */ }
});
es.addEventListener('done', () => {
opts.onMetric?.('sse-done');
// Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird
// sonst nie als "complete" erkannt). Trim leeren Tail away.
if (opts.onSentence) {
const tail = fullText.slice(sentenceConsumedIndex).trim();
if (tail.length > 0) {
opts.onSentence(tail);
}
}
opts.onDone(fullText);
es.close();
});
es.addEventListener('error', (err) => {
opts.onError(err);
es.close();
});
// Return cancel-Funktion
return () => { es.close(); };
}