From a7f718192f4832efe00c009a459c7dfdb0b47316 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 03:30:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(P3):=20Diktat=20in=20Fremdsprache=20?= =?UTF-8?q?=E2=86=92=20=C3=9Cbersetzung=20in=20die=20Zielsprache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spricht der Nutzer in einer anderen als der eingestellten Sprache, erkennt das System die gesprochene Sprache automatisch (transcribe_detect) und übersetzt die Anfrage per LLM in die Zielsprache — sie wird dann auch in der Zielsprache angezeigt und verarbeitet. Hilft beim Sprachenlernen und in der Kommunikation über Sprachgrenzen. - Orchestrator.translate() (LLM-basiert, nur Übersetzung). - ws.py Voice-STT: transcribe_detect + Übersetzung bei abweichender Sprache. - Test-Stubs um transcribe_detect ergänzt; neuer Übersetzungstest. 209 passed. Co-Authored-By: Claude Sonnet 4.6 --- app/api/ws.py | 11 +++++++++-- app/core/orchestrator.py | 25 +++++++++++++++++++++++++ tests/test_realtime.py | 3 +++ tests/test_ws.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/api/ws.py b/app/api/ws.py index e948065..4e8cc7c 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -203,8 +203,15 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): return try: - # Feste Sprache an Whisper → Whisper übersetzt automatisch. - transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language) + # Gesprochene Sprache automatisch erkennen (egal welche). Weicht sie von der + # Zielsprache ab, wird die Anfrage in die Zielsprache übersetzt — so erscheint + # und verarbeitet das System sie immer in der eingestellten Sprache. + transcript, detected = await orchestrator.stt.transcribe_detect( + audio, fmt=fmt, language=None + ) + if (transcript and transcript.strip() and detected + and detected.lower() != (route.language or "").lower()): + transcript = await orchestrator.translate(transcript, 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)}) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 3458581..cff898a 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -12,6 +12,14 @@ TTS_SAMPLE_RATE = 24000 TTS_CHANNELS = 1 +# Lesbare Sprachnamen für den Übersetzungs-Prompt (Code -> Name). +_LANG_NAMES = { + "de": "Deutsch", "en": "English", "fr": "français", "es": "español", + "it": "italiano", "pt": "português", "pl": "polski", "ar": "العربية", + "ru": "русский", "zh": "中文", +} + + class Orchestrator: def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer, normalize_level: str = "full"): @@ -131,6 +139,23 @@ class Orchestrator: await self._emit_to_output(audio, output) return trace, audio + async def translate(self, text: str, target_language: str) -> str: + """Übersetzt Text per LLM in die Zielsprache. Liefert nur die Übersetzung. + + Wird genutzt, wenn der Nutzer in einer anderen als der eingestellten + Sprache diktiert: die Anfrage wird in die Zielsprache übersetzt. + """ + if not text or not text.strip(): + return text + target = _LANG_NAMES.get((target_language or "").lower(), target_language) + prompt = ( + f"Übersetze den folgenden Text nach {target}. Gib AUSSCHLIESSLICH die " + f"Übersetzung zurück — ohne Anführungszeichen, ohne Erklärung.\n\n{text}" + ) + with _stage("translate"): + result = await self.llm.complete(prompt) + return (result or "").strip() or text + async def chat_stream( self, text: str, diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 9f0cc53..fd1d2cf 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -88,6 +88,9 @@ def _install_voice_stubs(monkeypatch): async def transcribe(self, audio_bytes, fmt, language=None): return "ok" + async def transcribe_detect(self, audio_bytes, fmt, language=None): + return "ok", None + class LLM: async def complete(self, text, history=None, session_id=None, language=None): return "antwort" diff --git a/tests/test_ws.py b/tests/test_ws.py index 655b8d2..51fcbdf 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -73,6 +73,9 @@ def _install_voice_stubs(monkeypatch): async def transcribe(self, audio_bytes, fmt, language=None): return f"erkannt({len(audio_bytes)})" + async def transcribe_detect(self, audio_bytes, fmt, language=None): + return f"erkannt({len(audio_bytes)})", None + class StubLLM: async def complete(self, text, history=None, session_id=None, language=None): return f"Antwort zu {text}" @@ -155,3 +158,33 @@ def test_ws_voice_empty_buffer_errors(monkeypatch): ws.send_json({"type": "end", **opts}) # kein Audio gesendet err = ws.receive_json() assert err["type"] == "error" and "no audio" in err["detail"] + + +def test_ws_voice_translates_foreign_language_to_target(monkeypatch): + # Diktat in einer anderen Sprache (detected=fr) bei Zielsprache de -> + # die Anfrage wird übersetzt und in der Zielsprache angezeigt. + class StubSTT: + async def transcribe_detect(self, audio_bytes, fmt, language=None): + return "bonjour, comment ça va", "fr" + + class StubLLM: + async def complete(self, text, history=None, session_id=None, language=None): + # Übersetzungs- UND Antwort-Aufruf liefern denselben Stub-Text. + return "GUTEN TAG" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"A" + + monkeypatch.setitem(deps.STT_REGISTRY, "ss", lambda s: StubSTT()) + monkeypatch.setitem(deps.LLM_REGISTRY, "ll", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "tt", lambda s: StubTTS()) + opts = {"stt_provider": "ss", "llm_provider": "ll", "tts_provider": "tt", + "output_endpoint": "loopback", "language": "de"} + with client.websocket_connect("/ws/voice") as ws: + ws.send_json({"type": "start", "format": "wav", **opts}) + ws.send_bytes(b"PCMDATA") + ws.send_json({"type": "end"}) + transcript = ws.receive_json() + # Übersetzte (deutsche) Fassung wird angezeigt, nicht das französische Original. + assert transcript["type"] == "transcript" and transcript["text"] == "GUTEN TAG"