feat(1a): Flex-Sprachmodus vollständig entfernen

Sprache ist immer fest. Entfernt: language_mode aus Route/Schema/Config/
Runtime-Config, die Flex-Verzweigung in Orchestrator (effective_language =
language) und ws.py (immer normales transcribe), die "🔄 Flex"-Option im
Frontend samt zugehöriger app.js-Logik (langOverrides, loadMe, Turn-Sprache).

Die generische STT-Methode transcribe_detect bleibt als wiederverwendbare
Infrastruktur erhalten (nicht mehr aufgerufen). Test: Route trägt kein
language_mode mehr. 215 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 02:51:17 +02:00
commit be5206c7ed
10 changed files with 24 additions and 64 deletions

View file

@ -50,7 +50,6 @@ async def chat(
"input_endpoint": payload.input_endpoint,
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"language_mode": payload.language_mode,
"stt_provider": payload.stt_provider,
"llm_provider": payload.llm_provider,
"tts_provider": payload.tts_provider,
@ -106,7 +105,6 @@ async def chat(
voice=voice,
output=output,
history=llm_context,
language_mode=route.language_mode,
text_only=bool(payload.text_only),
)
except Exception as exc:

View file

@ -42,7 +42,6 @@ _OVERRIDE_KEYS = (
"input_endpoint",
"output_endpoint",
"language",
"language_mode",
"stt_provider",
"llm_provider",
"tts_provider",
@ -67,7 +66,7 @@ async def _resolve(user, session_id, options):
async def _run_turn(
websocket, store, user, session_id, route, orchestrator, output, text, options,
detected_language: str | None = None, effective_voice: str | None = None,
effective_voice: str | None = None,
):
"""Faehrt einen Antwort-Turn und streamt die Events an den Client."""
conversation = (
@ -158,8 +157,6 @@ async def _run_turn(
history=llm_context,
on_token=on_token,
on_audio=on_audio,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
)
else:
@ -169,8 +166,6 @@ async def _run_turn(
voice=voice,
output=output,
history=llm_context,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
)
except Exception as exc:
@ -228,21 +223,9 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
return
try:
if route.language_mode == "flex":
# Flex: Sprache auto-erkennen, kein Übersetzen durch Whisper.
transcript, detected_language = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=None
)
# Stimme folgt der erkannten Sprache (Fallback: Routensprache).
effective_voice = voice_for_route(
route.tts_provider, detected_language or route.language, route.voice_gender
)
else:
# Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch.
transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language)
detected_language = None
# Stimme folgt der konfigurierten Sprache.
effective_voice = voice_for_route(route.tts_provider, route.language, route.voice_gender)
# Feste Sprache an Whisper → Whisper übersetzt automatisch.
transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language)
effective_voice = voice_for_route(route.tts_provider, route.language, route.voice_gender)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
return
@ -250,7 +233,6 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
await websocket.send_json({
"type": "transcript",
"text": transcript,
"detected_language": detected_language,
})
if not transcript or not transcript.strip():
await websocket.send_json({
@ -260,7 +242,7 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
return
await _run_turn(
websocket, store, user, session_id, route, orchestrator, output, transcript, options,
detected_language=detected_language, effective_voice=effective_voice,
effective_voice=effective_voice,
)

View file

@ -112,7 +112,6 @@ class Settings(BaseSettings):
openrouter_tts_voice: str = "alloy"
openrouter_llm_model: str = "openai/gpt-4.1-mini"
default_language: str = "de"
default_language_mode: str = "fix"
default_input_endpoint: str = "local-default"
default_output_endpoint: str = "local-default"
default_stt_provider: str = "openrouter"

View file

@ -89,8 +89,6 @@ class Orchestrator:
voice: str | None = None,
output=None,
history: list[dict] | None = None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
):
trace = PipelineTrace()
@ -98,10 +96,8 @@ class Orchestrator:
trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
# Flex: die erkannte Sprache bestimmt die Ausgabe. Gibt es keine (z. B. Text-Chat
# ohne Audio), bleibt sie None -> das LLM antwortet von selbst in der Eingabesprache.
# Fix: immer die konfigurierte Sprache (Whisper hat die Eingabe übersetzt).
effective_language = detected_language if language_mode == "flex" else language
# Immer die konfigurierte (feste) Sprache (Whisper hat die Eingabe übersetzt).
effective_language = language
with _stage("llm"):
trace.semantic_response = await self.llm.complete(
@ -144,8 +140,6 @@ class Orchestrator:
history: list[dict] | None = None,
on_token=None,
on_audio=None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
):
"""Wie chat_text, aber gestreamt.
@ -158,9 +152,8 @@ class Orchestrator:
trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
# Siehe chat_text: Flex folgt der erkannten Sprache (None -> LLM spiegelt Eingabe),
# Fix nutzt die konfigurierte Sprache.
effective_language = detected_language if language_mode == "flex" else language
# Immer die konfigurierte (feste) Sprache.
effective_language = language
# text_only: kein Server-Audio (Geräte-TTS spricht selbst) -> kein Chunked-TTS.
chunker = SentenceChunker() if (on_audio and not text_only) else None

View file

@ -155,7 +155,6 @@ ROUTE_KEYS = (
"llm_provider",
"tts_provider",
"language",
"language_mode",
"voice_gender",
)
@ -235,7 +234,6 @@ class ResolvedRoute:
llm_provider: str
tts_provider: str
language: str
language_mode: str = "fix"
voice_gender: str = "any"
def as_dict(self) -> dict:
@ -246,7 +244,6 @@ class ResolvedRoute:
"llm_provider": self.llm_provider,
"tts_provider": self.tts_provider,
"language": self.language,
"language_mode": self.language_mode,
"voice_gender": self.voice_gender,
}
@ -288,7 +285,6 @@ def resolve_route(
"llm_provider": cfg.default_llm_provider,
"tts_provider": cfg.default_tts_provider,
"language": cfg.default_language,
"language_mode": cfg.default_language_mode,
}
user_prefs = user.prefs if user is not None else {}

View file

@ -20,7 +20,6 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
"default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"),
"default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox — Standard, pro Nutzer überschreibbar"),
"default_language": ("Sprache (Standard)", "str", "de | en | … — Standard, pro Nutzer überschreibbar"),
"default_language_mode": ("Sprachmodus (Standard)", "str", "fix | flex — Standard, pro Nutzer überschreibbar"),
"openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"),
"openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"),
"openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"),

View file

@ -46,7 +46,6 @@ class ChatRequest(BaseModel):
input_endpoint: str | None = None
output_endpoint: str | None = None
language: str | None = None
language_mode: Literal["fix", "flex"] | None = None
voice: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
@ -93,7 +92,6 @@ class UserPrefs(BaseModel):
llm_provider: str | None = None
tts_provider: str | None = None
language: str | None = None
language_mode: Literal["fix", "flex"] | None = None
class MemoryCreate(BaseModel):

View file

@ -13,13 +13,9 @@ const cartesiaHint = $("#cartesia-lang-hint");
let cartesiaUnsupportedLangs = []; // von /api/config: Sprachen, die Cartesia nicht spricht
const CARTESIA_LANG_LABELS = { ar: "Arabisch", it: "Italienisch", ru: "Russisch", pl: "Polnisch", nl: "Niederländisch", sv: "Schwedisch", tr: "Türkisch" };
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
// Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet.
// Die gewählte Sprache ist immer fest (Flex-Modus wurde entfernt).
function langOverrides() {
const sel = langSel && langSel.value;
if (!sel || sel === "flex") return { language_mode: "flex" };
return { language: sel, language_mode: "fix" };
return { language: (langSel && langSel.value) || "de" };
}
// Nutzer-Präferenzen als Request-Overrides für jeden Turn.
@ -348,10 +344,7 @@ function loadMe() {
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
const prefs = me.prefs || {};
if (langSel) {
// Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex).
langSel.value = prefs.language_mode === "flex"
? "flex"
: (prefs.language || "de");
langSel.value = prefs.language || "de";
}
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
@ -544,10 +537,9 @@ function runTurn(path, onopen) {
const pcm = [];
let answerEl = null;
let sampleRate = 24000;
// Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache.
let routeLang = null, routeMode = null, detectedLang = null;
const turnLang = () =>
(routeMode === "flex" ? (detectedLang || routeLang) : routeLang) || null;
// Sprache dieses Turns (für den Replay je Bubble) = feste Routensprache.
let routeLang = null;
const turnLang = () => routeLang || null;
ws.onopen = () => onopen(ws);
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
@ -560,10 +552,8 @@ function runTurn(path, onopen) {
switch (msg.type) {
case "ack":
routeLang = (msg.route && msg.route.language) || null;
routeMode = (msg.route && msg.route.language_mode) || null;
break;
case "transcript":
detectedLang = msg.detected_language || null;
if (msg.text) addMessage("user", msg.text, { lang: turnLang() });
break;
case "token":
@ -620,9 +610,8 @@ async function sendText(text) {
busy = true;
setMicBusy();
await ensureSession();
// Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen.
const lo = langOverrides();
addMessage("user", text, { lang: lo.language_mode === "fix" ? lo.language : null });
addMessage("user", text, { lang: lo.language || null });
statusEl.textContent = "denkt …";
const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern
await runTurn("/ws/chat", (ws) => {

View file

@ -219,9 +219,8 @@
<header class="shrink-0 h-14 flex items-center gap-2 px-3 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
<img src="/favicon.svg?v=1" alt="" class="w-7 h-7 shrink-0" />
<h1 class="flex-1 truncate font-semibold text-base">Voice&nbsp;Assistant</h1>
<select id="lang-sel" title="Sprache — feste Sprache oder 🔄 Flex (folgt der gesprochenen Sprache)"
<select id="lang-sel" title="Sprache"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="flex">🔄 Flex</option>
<option value="de">🇩🇪 DE</option>
<option value="en">🇬🇧 EN</option>
<option value="fr">🇫🇷 FR</option>

View file

@ -132,3 +132,10 @@ def test_piper_falls_back_to_opposite_gender(monkeypatch):
# (vor dem gender-agnostischen LANG_TO_PIPER_VOICE-Fallback).
monkeypatch.delitem(PIPER_VOICE_GENDERED, ("de", "m"), raising=False)
assert voice_for_route("piper", "de", "m") == "de_DE-kerstin-low"
def test_route_has_no_language_mode():
# Flex-Modus wurde entfernt: die Route trägt kein language_mode mehr.
route = resolve_route()
assert "language_mode" not in route.as_dict()
assert not hasattr(route, "language_mode")