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

106 lines
3.9 KiB
TypeScript

// SOS+TTS Latenz-Benchmark.
//
// Eine BenchSession pro sendToLyra-Call. Aggregiert Timing-Marker aus
// sosStream + sosTtsQueue und druckt am Ende eine Tabelle ins Dev-Console.
//
// Marker-Reihenfolge im typischen Flow:
// t0 request-fired (sendToLyra start)
// t1 session-post-done (POST /sos-session resolved → sessionId da)
// t2 sse-first-chunk ("Lyra denkt fertig" — erstes Token)
// t3 sse-done (full text fertig)
// t4 tts-fetch-start (POST an /api/coach/speak-* fired)
// t5 tts-fetch-headers (response headers da, body kommt noch)
// t6 tts-body-done (kompletter Audio-Body geladen — DAS ist der Bottleneck)
// t7 tts-file-written (base64 → File geschrieben)
// t8 audio-loaded (Audio.Sound.createAsync resolved)
// t9 first-audio (erstes onPlaybackStatusUpdate mit isPlaying)
//
// Bottleneck-Diagnose:
// - (t6 - t5) groß → Body-Download dominiert. Cartesia's TTFB-Vorteil
// verpufft hier weil wir auf alles warten statt zu streamen.
// - (t9 - t8) groß → expo-av lädt langsam (file-codec-detect etc.)
export type BenchMarker =
| 'session-post-start'
| 'session-post-done'
| 'sse-first-chunk'
| 'sse-done'
| 'tts-fetch-start'
| 'tts-fetch-headers'
| 'tts-body-done'
| 'tts-file-written'
| 'audio-loaded'
| 'first-audio';
export type BenchOnMetric = (marker: BenchMarker, meta?: Record<string, unknown>) => void;
type MarkerEntry = {
marker: BenchMarker;
/** ms relativ zu t0 */
tRel: number;
meta?: Record<string, unknown>;
};
export class BenchSession {
readonly t0: number;
readonly provider: string;
readonly label: string;
private entries: MarkerEntry[] = [];
private printed = false;
constructor(opts: { provider: string; label?: string }) {
this.t0 = Date.now();
this.provider = opts.provider;
this.label = opts.label ?? 'sos-turn';
}
/** Bound version — kann direkt als onMetric weitergegeben werden. */
readonly mark: BenchOnMetric = (marker, meta) => {
if (this.printed) return;
this.entries.push({ marker, tRel: Date.now() - this.t0, meta });
};
/** Druckt eine kompakte Tabelle. Idempotent (nur 1x pro Session). */
print(extraNote?: string): void {
if (this.printed) return;
this.printed = true;
const get = (m: BenchMarker) => this.entries.find((e) => e.marker === m)?.tRel;
const fmt = (v: number | undefined) => (v == null ? '—' : `${v}ms`);
const diff = (a: BenchMarker, b: BenchMarker) => {
const va = get(a), vb = get(b);
return va != null && vb != null ? `${vb - va}ms` : '—';
};
const stages = {
provider: this.provider,
label: this.label,
'req→session': fmt(get('session-post-done')),
'lyra-ttfb': fmt(get('sse-first-chunk')),
'lyra-done': fmt(get('sse-done')),
'tts-fired': fmt(get('tts-fetch-start')),
'tts-ttfb (rel)': diff('tts-fetch-start', 'tts-fetch-headers'),
'tts-body (rel)': diff('tts-fetch-headers', 'tts-body-done'),
'tts-file (rel)': diff('tts-body-done', 'tts-file-written'),
'audio-load (rel)': diff('tts-file-written', 'audio-loaded'),
'first-audio': fmt(get('first-audio')),
'TOTAL → speak': fmt(get('first-audio')),
};
// Eine kompakte Zeile als console.log (für Logbox-Lesbarkeit) +
// console.table mit allen Markern (für strukturierte Inspektion).
// eslint-disable-next-line no-console
console.log(
`[bench] ${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
stages,
);
// eslint-disable-next-line no-console
console.table(this.entries.map((e) => ({ marker: e.marker, tRel: e.tRel })));
}
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
snapshot(): { provider: string; label: string; entries: MarkerEntry[] } {
return { provider: this.provider, label: this.label, entries: [...this.entries] };
}
}