From 81d4cf6fd8f94c024d78dd47917f6bbd922e11d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 14:12:16 +0200 Subject: [PATCH] fix(llm): LLM antwortet jetzt in der konfigurierten Systemsprache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wurde `language` zwar an STT/TTS übergeben, aber nie an den LLM. Lokales Modell antwortete deshalb immer auf Englisch/Deutsch, egal ob Französisch, Spanisch o.a. eingestellt war. Lösung: `language`-Parameter durch die gesamte LLM-Schicht gezogen: - base.py: `lang_instruction()` helper + Signatur erweitert - local_openai_compatible.py: Sprachanweisung ("Respond in Français.") wird als letzter System-Part in den Message-Stack eingefügt - openrouter.py: Explizite Sprachanweisung ergänzt den bestehenden "Answer in the same language as the user"-Prompt - fallback.py: FallbackLLMProvider leitet `language` durch - orchestrator.py: alle 3 LLM-Aufrufstellen übergeben `language` - Tests: alle Stub-LLMs um `language=None` ergänzt Co-Authored-By: Claude Sonnet 4.6 --- app/core/orchestrator.py | 5 ++-- app/providers/fallback.py | 12 ++++++---- app/providers/llm/base.py | 24 +++++++++++++++++++- app/providers/llm/local_openai_compatible.py | 21 ++++++++++++----- app/providers/llm/openrouter.py | 19 ++++++++++++---- tests/test_emergency_llm.py | 2 +- tests/test_endpoints_e2e.py | 2 +- tests/test_memory.py | 2 +- tests/test_memory_extraction.py | 2 +- tests/test_quota_safety.py | 2 +- tests/test_realtime.py | 6 ++--- tests/test_resilience.py | 20 ++++++++-------- tests/test_streaming.py | 12 +++++----- tests/test_ws.py | 4 ++-- 14 files changed, 89 insertions(+), 44 deletions(-) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index e561e2a..d5ca8b3 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -99,6 +99,7 @@ class Orchestrator: trace.semantic_response = await self.llm.complete( trace.cleaned_transcript or "", history=history, + language=language, ) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") @@ -158,7 +159,7 @@ class Orchestrator: parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) if stream_fn is not None: - async for delta in stream_fn(trace.cleaned_transcript or "", history=history): + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=language): parts.append(delta) if on_token: await on_token(delta) @@ -167,7 +168,7 @@ class Orchestrator: await _emit_sentence(sentence) else: # Provider ohne Streaming -> komplette Antwort als ein Token. - result = await self.llm.complete(trace.cleaned_transcript or "", history=history) + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=language) parts.append(result) if on_token: await on_token(result) diff --git a/app/providers/fallback.py b/app/providers/fallback.py index 3e7e71d..3cfb122 100644 --- a/app/providers/fallback.py +++ b/app/providers/fallback.py @@ -37,11 +37,13 @@ class FallbackSTTProvider(_Chain): class FallbackLLMProvider(_Chain): - async def complete(self, text, history=None, session_id=None) -> str: + async def complete(self, text, history=None, session_id=None, language=None) -> str: last_exc = None for index, (name, provider) in enumerate(self.entries): try: - result = await provider.complete(text, history=history, session_id=session_id) + result = await provider.complete( + text, history=history, session_id=session_id, language=language + ) if index > 0: self._on_fallback() return result @@ -50,12 +52,14 @@ class FallbackLLMProvider(_Chain): self._on_error(name) raise last_exc - async def stream(self, text, history=None, session_id=None) -> AsyncIterator[str]: + async def stream(self, text, history=None, session_id=None, language=None) -> AsyncIterator[str]: last_exc = None for index, (name, provider) in enumerate(self.entries): produced = False try: - async for delta in provider.stream(text, history=history, session_id=session_id): + async for delta in provider.stream( + text, history=history, session_id=session_id, language=language + ): produced = True yield delta if index > 0: diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index 2721b00..bf566c5 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -2,6 +2,26 @@ import json from abc import ABC, abstractmethod from collections.abc import AsyncIterator +_LANG_NAMES: dict[str, str] = { + "de": "Deutsch", + "en": "English", + "fr": "Français", + "es": "Español", + "it": "Italiano", + "nl": "Nederlands", + "ru": "Русский", + "zh": "中文", + "cmn": "中文", +} + + +def lang_instruction(language: str | None) -> str | None: + """'Respond in X.' instruction for the given ISO language code, or None.""" + if not language: + return None + name = _LANG_NAMES.get(language, language) + return f"Respond in {name}." + def sse_delta(line: str) -> str | None: """Extrahiert das Token-Delta aus einer OpenAI-kompatiblen SSE-Zeile (oder None).""" @@ -24,6 +44,7 @@ class LLMProvider(ABC): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> str: ... async def stream( @@ -31,9 +52,10 @@ class LLMProvider(ABC): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> AsyncIterator[str]: """Token-Stream. Default: kein echtes Streaming -> komplette Antwort als ein Chunk. Provider mit SSE-Unterstuetzung ueberschreiben diese Methode. """ - yield await self.complete(text, history=history, session_id=session_id) + yield await self.complete(text, history=history, session_id=session_id, language=language) diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index c550aed..713d825 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, sse_delta +from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta class LocalOpenAICompatibleLLM(LLMProvider): @@ -24,7 +24,9 @@ class LocalOpenAICompatibleLLM(LLMProvider): self.max_tokens = max_tokens self.temperature = temperature - def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: + def _build_messages( + self, text: str, history: list[dict] | None, language: str | None = None + ) -> list[dict]: # Manche Chat-Templates (z. B. Qwen3) erlauben nur EINE System-Nachricht, # ganz am Anfang. Daher den Sprach-System-Prompt und etwaige System- # Nachrichten aus der History (z. B. Nutzer-Erinnerungen) zu einer einzigen @@ -40,6 +42,9 @@ class LocalOpenAICompatibleLLM(LLMProvider): system_parts.append(content) else: rest.append(msg) + instr = lang_instruction(language) + if instr: + system_parts.append(instr) messages: list[dict] = [] if system_parts: @@ -48,10 +53,12 @@ class LocalOpenAICompatibleLLM(LLMProvider): messages.append({"role": "user", "content": text}) return messages - def _payload(self, text: str, history: list[dict] | None, stream: bool) -> dict: + def _payload( + self, text: str, history: list[dict] | None, stream: bool, language: str | None = None + ) -> dict: payload: dict = { "model": self.model, - "messages": self._build_messages(text, history), + "messages": self._build_messages(text, history, language=language), "temperature": self.temperature, } if stream: @@ -68,12 +75,13 @@ class LocalOpenAICompatibleLLM(LLMProvider): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> str: async with httpx.AsyncClient(timeout=120) as client: response = await client.post( f"{self.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.api_key}"}, - json=self._payload(text, history, stream=False), + json=self._payload(text, history, stream=False, language=language), ) response.raise_for_status() data = response.json() @@ -84,13 +92,14 @@ class LocalOpenAICompatibleLLM(LLMProvider): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> AsyncIterator[str]: async with httpx.AsyncClient(timeout=120) as client: async with client.stream( "POST", f"{self.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.api_key}"}, - json=self._payload(text, history, stream=True), + json=self._payload(text, history, stream=True, language=language), ) as response: if response.status_code >= 400: body = await response.aread() diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index 369140e..ab8116d 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, sse_delta +from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta SYSTEM_PROMPT = """ @@ -51,7 +51,9 @@ class OpenRouterLLMProvider(LLMProvider): self.api_key = (api_key or "").strip() self.model = (model or "").strip() - def _build_messages(self, text: str, history: list[dict] | None) -> list[dict]: + def _build_messages( + self, text: str, history: list[dict] | None, language: str | None = None + ) -> list[dict]: if not self.api_key: raise ValueError("OPENROUTER_API_KEY is empty") if not self.model: @@ -59,7 +61,12 @@ class OpenRouterLLMProvider(LLMProvider): if not text or not text.strip(): raise ValueError("LLM input text is empty") - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + system_content = SYSTEM_PROMPT + instr = lang_instruction(language) + if instr: + system_content = f"{SYSTEM_PROMPT}\n\n{instr}" + + messages = [{"role": "system", "content": system_content}] if history: messages.extend(history) messages.append({"role": "user", "content": text.strip()}) @@ -70,10 +77,11 @@ class OpenRouterLLMProvider(LLMProvider): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> str: payload = { "model": self.model, - "messages": self._build_messages(text, history), + "messages": self._build_messages(text, history, language=language), } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -115,10 +123,11 @@ class OpenRouterLLMProvider(LLMProvider): text: str, history: list[dict] | None = None, session_id: str | None = None, + language: str | None = None, ) -> AsyncIterator[str]: payload = { "model": self.model, - "messages": self._build_messages(text, history), + "messages": self._build_messages(text, history, language=language), "stream": True, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) diff --git a/tests/test_emergency_llm.py b/tests/test_emergency_llm.py index b463519..5fefaf0 100644 --- a/tests/test_emergency_llm.py +++ b/tests/test_emergency_llm.py @@ -12,7 +12,7 @@ class StubLLM: self.raw = raw self.calls = 0 - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): self.calls += 1 return self.raw diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index 7cac465..699717d 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -59,7 +59,7 @@ def test_session_route_applies(monkeypatch): def test_chat_per_request_override_and_loopback(monkeypatch): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "Mir geht es gut, danke." class StubTTS: diff --git a/tests/test_memory.py b/tests/test_memory.py index d29d260..fceb038 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -8,7 +8,7 @@ client = TestClient(app) def _install_stubs(monkeypatch, captured): class MemLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): captured["history"] = list(history or []) return f"Antwort auf: {text}" diff --git a/tests/test_memory_extraction.py b/tests/test_memory_extraction.py index 2c5cae0..850dfa2 100644 --- a/tests/test_memory_extraction.py +++ b/tests/test_memory_extraction.py @@ -12,7 +12,7 @@ class StubLLM: self.raw = raw self.calls = 0 - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): self.calls += 1 return self.raw diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 928cb7a..fcc2cd5 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -10,7 +10,7 @@ client = TestClient(app) def _stub_chat(monkeypatch, answer="ok"): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return answer class StubTTS: diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 8df0b21..a1c4b1b 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -42,10 +42,10 @@ def test_energy_vad_ignores_silence_without_speech(): def _install_slow_stream(monkeypatch): class SlowLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "fertig" - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): for i in range(200): await asyncio.sleep(0.005) yield f"t{i} " @@ -89,7 +89,7 @@ def _install_voice_stubs(monkeypatch): return "ok" class LLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "antwort" class TTS: diff --git a/tests/test_resilience.py b/tests/test_resilience.py index 7d25933..5af6cfd 100644 --- a/tests/test_resilience.py +++ b/tests/test_resilience.py @@ -20,11 +20,11 @@ def _run(coro): def test_llm_fallback_uses_second_on_error(): class BadLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): raise RuntimeError("down") class GoodLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "ok" chain = FallbackLLMProvider("llm", [("bad", BadLLM()), ("good", GoodLLM())]) @@ -37,7 +37,7 @@ def test_llm_fallback_uses_second_on_error(): def test_llm_fallback_all_fail_raises(): class BadLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): raise RuntimeError("x") chain = FallbackLLMProvider("llm", [("a", BadLLM()), ("b", BadLLM())]) @@ -47,18 +47,18 @@ def test_llm_fallback_all_fail_raises(): def test_llm_stream_fallback_before_first_token(): class BadStream: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "x" - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): raise RuntimeError("boom") yield # macht die Funktion zum Generator class GoodStream: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "ok" - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): yield "he" yield "llo" @@ -74,11 +74,11 @@ def test_llm_stream_fallback_before_first_token(): def test_config_llm_fallback_applied(monkeypatch): class BadLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): raise RuntimeError("primary down") class GoodLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "rescued" class StubTTS: @@ -102,7 +102,7 @@ def test_config_llm_fallback_applied(monkeypatch): def test_metrics_endpoint_records_requests_and_stages(monkeypatch): class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "hi" class StubTTS: diff --git a/tests/test_streaming.py b/tests/test_streaming.py index fa19c13..b388c98 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -40,7 +40,7 @@ def test_sse_delta_parsing(): def test_base_stream_default_yields_full_completion(): class P(LLMProvider): - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "ganze Antwort" async def run(): @@ -51,10 +51,10 @@ def test_base_stream_default_yields_full_completion(): def _install_streaming(monkeypatch, tokens): class StreamLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "".join(tokens) - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): for tok in tokens: yield tok @@ -95,7 +95,7 @@ def test_ws_without_stream_flag_has_no_tokens(monkeypatch): def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch): class OnlyComplete: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "komplett" class StubTTS: @@ -118,10 +118,10 @@ def test_ws_audio_stream_sends_chunks_per_sentence(monkeypatch): tts_calls = [] class StreamLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return "Satz eins. Satz zwei." - async def stream(self, text, history=None, session_id=None): + async def stream(self, text, history=None, session_id=None, language=None): for tok in ["Satz ", "eins. ", "Satz ", "zwei."]: yield tok diff --git a/tests/test_ws.py b/tests/test_ws.py index 2e3e1f2..5a81041 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -11,7 +11,7 @@ client = TestClient(app) def _install_stubs(monkeypatch, captured): class MemLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): captured["history"] = list(history or []) return f"Echo: {text}" @@ -74,7 +74,7 @@ def _install_voice_stubs(monkeypatch): return f"erkannt({len(audio_bytes)})" class StubLLM: - async def complete(self, text, history=None, session_id=None): + async def complete(self, text, history=None, session_id=None, language=None): return f"Antwort zu {text}" class StubTTS: