From 96dcd87ca82786ba0c57b7b9b08604ca8c2a423a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 22:34:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(fillers):=20zuf=C3=A4llige,=20=C3=BCberset?= =?UTF-8?q?zte=20Senioren-Beruhigungss=C3=A4tze=20+=20Geduld-Schleife?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die statischen 3-Satz-Filler durch einen kreativen, weniger roboterhaften Mechanismus für schnelles Feedback waehrend der Web-Suche. - app/pipeline/fillers.py: EINZIGE Pflege-Stelle. Englische Master-Listen (12 OPENING + 16 PATIENCE). Zur Laufzeit einmal je Sprache uebersetzt und gecacht (durchgaengig hoefliche Anrede "Sie"/"vous"/"usted"...), Fallback Englisch. Zufaellige Auswahl statt fester Reihenfolge. - Geduld-Schleife: bei laengerer Recherche schiebt ein Hintergrund-Task alle FILLER_PATIENCE_INTERVAL Sekunden einen zufaelligen PATIENCE-Satz nach; Abbruch beim ersten Antwort-Delta und im finally (keine verwaisten Tasks). - Ephemer wie bisher: laeuft ueber on_token/_dispatch, nie in semantic_response/History. - Standardsprache wird beim Start vorgewaermt (warmup.py) -> erster Such-Turn ohne Uebersetzungs-Latenz. dependencies: get_fillers()-Singleton. Doc: Docs/weg2-tool-calling.md §5.4. Tests: 290 gruen. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 29 +++++--- app/core/orchestrator.py | 76 +++++++++++--------- app/core/warmup.py | 11 +++ app/dependencies.py | 16 +++++ app/pipeline/fillers.py | 146 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 41 deletions(-) create mode 100644 app/pipeline/fillers.py diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 1ce7d6e..611dbd3 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -85,14 +85,27 @@ selbstständig („hij" → „Rutger Hauer"). **Gegated**: nur bei kurzer Folge ### 5.4 Filler / Beruhigung `stream()` erhält einen `on_tool_start(language)`-Callback. Der Orchestrator -verdrahtet ihn auf: lokalisierten Satz (rotierender Pool je Sprache, Muster wie -die `_NOTICE`-Dicts) → `spoken_adapter`/`tts_normalizer`/`tts` → `on_audio` **und** -`on_token`. **Ephemeralität (Invariante):** Der Filler läuft über *diesen -Callback*, nicht über den Delta-Stream — dadurch landet er **nicht** in -`trace.semantic_response` und **nicht** im gespeicherten History-Turn. Bei langen/ -mehreren Tools gestaffelt eskalieren („Moment …" → „Bitte noch einen Augenblick …"). -Server-TTS ist ein kleiner Eingriff; **„Im Gerät" ist Fast-follow** (eigener -Event-Typ → Browser-`speak()`, Gesten-/Voices-Absicherung). +verdrahtet ihn auf zwei Pools (`app/pipeline/fillers.py`): **OPENING** (sofort beim +Such-Start) und **PATIENCE** (bei längerer Recherche nachgeschoben). Beide werden +**zufällig** gewählt (schnelles Feedback, nicht roboterhaft) und gehen über +`spoken_adapter`/`tts_normalizer`/`tts` → `on_audio` **und** `on_token`. + +**Eine Pflege-Stelle, Englisch:** Die Master-Sätze stehen als englische Listen in +`fillers.py` (`OPENING_PHRASES`/`PATIENCE_PHRASES`). Zur Laufzeit werden sie **einmal +je Sprache übersetzt und gecacht** (`FillerPhrases`, Übersetzer = OpenRouter-Modell, +durchgängig höfliche Anrede „Sie"/„vous"/„usted"…). Die **Standardsprache wird beim +Start vorgewärmt** (`warmup.py`) → erster Such-Turn ohne Übersetzungs-Latenz; andere +Sprachen werden beim ersten Bedarf einmalig übersetzt. Fällt die Übersetzung aus → +Fallback Englisch. + +**Geduld-Schleife:** Beim Tool-Start läuft ein Hintergrund-Task, der alle +`FILLER_PATIENCE_INTERVAL` Sekunden einen zufälligen PATIENCE-Satz nachschiebt; er +wird beim ersten Antwort-Delta (Antwort beginnt) und im `finally` abgeräumt. + +**Ephemeralität (Invariante):** Filler laufen über den Callback, nicht über den +Delta-Stream — sie landen **nicht** in `trace.semantic_response` und **nicht** im +gespeicherten History-Turn. Server-TTS ist verdrahtet; **„Im Gerät" ist Fast-follow** +(eigener Event-Typ → Browser-`speak()`, Gesten-/Voices-Absicherung). ### 5.5 Konfiguration & Opt-out `web_search_enabled: bool = True` — **global an per Default**, pro Nutzer/Profil diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 36ea56c..a315f51 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -3,8 +3,13 @@ import inspect from app.schemas import AudioChunk, PipelineTrace from app.pipeline.sentence_chunker import SentenceChunker +from app.pipeline.fillers import FillerPhrases from app.metrics import timer, metrics +# Intervall (Sekunden), nach dem bei noch laufender Recherche ein zufälliger +# Geduldssatz nachgeschoben wird. +FILLER_PATIENCE_INTERVAL = 4.0 + def _stage(name: str): return timer("stage_duration_seconds", {"stage": name}) @@ -22,29 +27,6 @@ _LANG_NAMES = { "ru": "русский", "zh": "中文", } -# Beruhigungs-/Filler-Sätze beim Tool-Start (Web-Suche). Gestaffelt: erster Aufruf -# kurz, weitere "Geduld". Ephemer — nie in semantic_response/History. Fallback: Deutsch. -_FILLERS: dict[str, list[str]] = { - "de": ["Einen Moment, ich schaue kurz nach.", "Ich bin gleich so weit.", - "Bitte noch einen kleinen Augenblick Geduld."], - "en": ["One moment, let me check.", "Almost there.", - "Just a little more patience, please."], - "nl": ["Een ogenblik, ik zoek het even op.", "Ik ben er bijna.", - "Nog heel even geduld, alstublieft."], - "fr": ["Un instant, je vérifie.", "J'y suis presque.", - "Encore un petit instant, s'il vous plaît."], - "es": ["Un momento, lo consulto.", "Ya casi está.", - "Un poco más de paciencia, por favor."], - "it": ["Un momento, controllo subito.", "Ci sono quasi.", - "Ancora un attimo di pazienza, per favore."], -} - - -def _pick_filler(language: str | None, n: int) -> str: - phrases = _FILLERS.get((language or "de").lower(), _FILLERS["de"]) - return phrases[min(n, len(phrases) - 1)] - - def _stream_supports(stream_fn, name: str) -> bool: """Ob stream() ein bestimmtes kwarg (oder **kwargs) akzeptiert — sonst nicht übergeben. @@ -60,7 +42,7 @@ def _stream_supports(stream_fn, name: str) -> bool: class Orchestrator: def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer, - normalize_level: str = "full"): + normalize_level: str = "full", fillers=None): self.stt = stt self.llm = llm self.tts = tts @@ -68,6 +50,8 @@ class Orchestrator: self.spoken_adapter = spoken_adapter self.tts_normalizer = tts_normalizer self.normalize_level = normalize_level + # Ohne externe Verdrahtung: englische Master-Sätze (kein Crash in Tests). + self.fillers = fillers or FillerPhrases() async def _emit_to_output(self, audio: bytes, output) -> None: """Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt. @@ -262,21 +246,44 @@ class Orchestrator: await consumer_task await queue.put(sentence) - _filler_state = {"n": 0} + patience_task: asyncio.Task | None = None - async def _on_tool_start(lang: str | None) -> None: - """Ephemerer Beruhigungssatz beim Tool-Start: sofort anzeigen + (Server-TTS) sprechen. + async def _emit_filler(phrase: str) -> None: + """Ephemer: über on_token (Anzeige) + _dispatch (Server-TTS, "Satz null"). - Läuft bewusst NICHT über den Token-Stream -> landet nie in parts/ - semantic_response/History. Bei Server-TTS als "Satz null" vor die Antwort. + Läuft bewusst NICHT über den Token-Stream -> nie in parts/ + semantic_response/History. """ - phrase = _pick_filler(lang, _filler_state["n"]) - _filler_state["n"] += 1 if on_token: await on_token(phrase + " ") if chunker is not None: await _dispatch(phrase) + async def _patience_loop(lang: str | None) -> None: + # Bei längerer Recherche zufällige Geduldssätze nachschieben. + try: + while True: + await asyncio.sleep(FILLER_PATIENCE_INTERVAL) + await _emit_filler(await self.fillers.patience(lang)) + except asyncio.CancelledError: + return + + async def _on_tool_start(lang: str | None) -> None: + nonlocal patience_task + await _emit_filler(await self.fillers.opening(lang)) + if patience_task is None or patience_task.done(): + patience_task = asyncio.create_task(_patience_loop(lang)) + + async def _cancel_patience() -> None: + nonlocal patience_task + task, patience_task = patience_task, None + if task is not None and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + if queue is not None: consumer_task = asyncio.create_task(_consume()) @@ -286,6 +293,8 @@ class Orchestrator: if _stream_supports(stream_fn, "on_tool_start"): stream_kwargs["on_tool_start"] = _on_tool_start async for delta in stream_fn(trace.cleaned_transcript or "", **stream_kwargs): + if patience_task is not None: + await _cancel_patience() # Antwort beginnt -> Geduldsschleife stoppen parts.append(delta) if on_token: await on_token(delta) @@ -331,8 +340,9 @@ class Orchestrator: trace.tts_ready_text, voice=voice, language=effective_language ) finally: - # Bei Fehler/Abbruch den noch laufenden Consumer-Task abräumen, - # damit kein verwaister Task zurückbleibt. + # Geduldsschleife und Consumer-Task abräumen, damit keine verwaisten + # Tasks zurückbleiben (bei Fehler/Abbruch). + await _cancel_patience() if consumer_task is not None and not consumer_task.done(): consumer_task.cancel() try: diff --git a/app/core/warmup.py b/app/core/warmup.py index b2f8d7e..b55a0bf 100644 --- a/app/core/warmup.py +++ b/app/core/warmup.py @@ -43,3 +43,14 @@ async def warmup_local_models() -> None: logger.info("warmup: faster-whisper-Modell geladen") except Exception: logger.exception("warmup: STT-Vorladen fehlgeschlagen (ignoriert)") + + # Filler-Sätze der Standardsprache vorab übersetzen/cachen, damit der erste + # Such-Turn sie sofort (ohne Übersetzungs-Latenz) sprechen kann. + try: + from app.runtime_config import runtime_settings as rs + if rs.web_search_enabled and rs.openrouter_api_key: + from app.dependencies import get_fillers + await get_fillers().warm(rs.default_language) + logger.info("warmup: Filler-Sätze (%s) übersetzt/gecacht", rs.default_language) + except Exception: + logger.exception("warmup: Filler-Vorwärmen fehlgeschlagen (ignoriert)") diff --git a/app/dependencies.py b/app/dependencies.py index f4df509..e0f7688 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -29,6 +29,7 @@ from app.providers.fallback import ( FallbackTTSProvider, ) from app.pipeline.decontextualizer import Decontextualizer +from app.pipeline.fillers import FillerPhrases, make_openrouter_translator from app.pipeline.input_cleaner import InputCleaner from app.pipeline.spoken_response_adapter import SpokenResponseAdapter from app.pipeline.tts_normalizer import TTSNormalizer @@ -48,6 +49,20 @@ def get_store() -> Store: _store = SQLiteStore(settings.db_path) return _store + +# Filler-Sätze (Web-Suche): Modul-Singleton mit Übersetzungs-Cache. +_fillers: FillerPhrases | None = None + + +def get_fillers(cfg=None) -> FillerPhrases: + global _fillers + if _fillers is None: + cfg = cfg or runtime_settings + _fillers = FillerPhrases( + translate_pool=make_openrouter_translator(cfg.openrouter_api_key, cfg.openrouter_llm_model) + ) + return _fillers + # --------------------------------------------------------------------------- # Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern. # Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError. @@ -378,6 +393,7 @@ def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator: spoken_adapter=SpokenResponseAdapter(), tts_normalizer=TTSNormalizer(), normalize_level=_resolve_normalize_level(route.tts_provider, cfg), + fillers=get_fillers(cfg), ) diff --git a/app/pipeline/fillers.py b/app/pipeline/fillers.py new file mode 100644 index 0000000..b633254 --- /dev/null +++ b/app/pipeline/fillers.py @@ -0,0 +1,146 @@ +"""Senioren-Beruhigungssätze für die Web-Suche — englische Master-Quelle. + +DIES IST DIE EINZIGE STELLE zum Pflegen der Sätze. Zur Laufzeit werden sie einmal +je Sprache übersetzt und gecacht (die Standardsprache wird beim Serverstart +vorgewärmt → sofortige Ausgabe ohne Verzögerung). Zwei Pools: + +- OPENING: sofort beim Such-Start ("Einen Moment, ich schaue kurz nach.") +- PATIENCE: bei längerer Recherche nachgeschoben ("Bitte noch etwas Geduld …") + +Beide werden ZUFÄLLIG gewählt, damit schnelles Feedback kommt und es nicht +roboterhaft eintönig klingt. Siehe Docs/weg2-tool-calling.md §5.4. +""" +import asyncio +import logging +import random +import re + +import httpx + +logger = logging.getLogger(__name__) + +ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" + +# --- Master-Sätze (Englisch — einzige Quelle der Wahrheit) ------------------ +OPENING_PHRASES = [ + "One moment, let me look that up for you.", + "Just a second, I'll check that for you right away.", + "Let me find that out for you — one moment.", + "Good question — let me check the latest on that.", + "Hold on just a moment, I'm looking it up.", + "Let me quickly find the current information for you.", + "One little moment while I check that for you.", + "I'll go and look that up for you right now.", + "Let me see what the latest is — just a moment.", + "Give me a second, I'll find that out for you.", + "I'm checking that for you now, one moment please.", + "Let me look that up so I can tell you exactly.", +] + +PATIENCE_PHRASES = [ + "Thank you for your patience, I'm almost there.", + "Just a little longer, please — I'm still looking.", + "Bear with me a moment, I'm nearly done.", + "Almost ready — thank you for waiting.", + "I'm still checking, just a few more seconds.", + "Please hold on a little longer, I'm getting there.", + "Nearly finished — thank you for your patience.", + "Just a moment more, I want to get this right for you.", + "I'm still looking it up, please stay with me.", + "It's taking a little longer, but I'm almost done.", + "Thank you for waiting — I'll have it for you shortly.", + "Hang on just a bit longer, I'm nearly there.", + "I haven't forgotten you — I'm still checking.", + "Almost there now, thank you for being so patient.", + "Just finishing up — one more moment, please.", + "I'm making sure I get it right for you — nearly done.", +] + +_LANG_DISPLAY = { + "de": "German", "en": "English", "fr": "French", "es": "Spanish", + "it": "Italian", "nl": "Dutch", "pt": "Portuguese", "pl": "Polish", + "ar": "Arabic", "ru": "Russian", "zh": "Chinese", +} + + +def _parse_numbered(text: str, n: int) -> list[str] | None: + """Liest eine nummerierte Liste; liefert genau n Zeilen oder None (→ Fallback).""" + out = [] + for raw in (text or "").splitlines(): + s = re.sub(r"^\s*\d+[.)]\s*", "", raw.strip()).strip().strip('"').strip("„“”") + if s: + out.append(s) + return out[:n] if len(out) >= n else None + + +def make_openrouter_translator(api_key: str, model: str): + """Liefert eine async translate_pool(phrases, language) -> list[str] | None.""" + api_key = (api_key or "").strip() + model = (model or "").strip() + + async def translate_pool(phrases: list[str], language: str) -> list[str] | None: + if not api_key or not model: + return None + target = _LANG_DISPLAY.get(language, language) + numbered = "\n".join(f"{i + 1}. {p}" for i, p in enumerate(phrases)) + prompt = ( + f"Translate each numbered phrase into {target}. These are short, warm, " + f"reassuring things a voice assistant says to an elderly person while it " + f"looks something up. Keep them natural and spoken. ALWAYS use the polite, " + f"formal form of address (German 'Sie', not 'du'; French 'vous'; Spanish " + f"'usted'; Italian 'Lei'; Dutch 'u') consistently. Same count, same order, " + f"no quotes, no extra words. Output ONLY a numbered list of the translations." + f"\n\n{numbered}" + ) + payload = {"model": model, "temperature": 0.4, + "messages": [{"role": "user", "content": prompt}]} + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: + resp = await client.post(ENDPOINT, json=payload, headers=headers) + resp.raise_for_status() + text = resp.json()["choices"][0]["message"]["content"] + return _parse_numbered(text, len(phrases)) + + return translate_pool + + +class FillerPhrases: + """Zufällige, in die Zielsprache übersetzte Beruhigungssätze (einmal je Sprache gecacht).""" + + def __init__(self, translate_pool=None): + self._translate_pool = translate_pool + self._cache: dict[str, dict] = { + "en": {"opening": OPENING_PHRASES, "patience": PATIENCE_PHRASES}} + self._locks: dict[str, asyncio.Lock] = {} + + async def warm(self, language: str | None) -> None: + """Vorab übersetzen (z. B. Standardsprache beim Serverstart).""" + await self._ensure(language) + + async def _ensure(self, language: str | None) -> str: + lang = (language or "en").lower() + if lang in self._cache: + return lang + if self._translate_pool is None: + return "en" # ohne Übersetzer: englische Master-Sätze + lock = self._locks.setdefault(lang, asyncio.Lock()) + async with lock: + if lang in self._cache: + return lang + opening = patience = None + try: + opening = await self._translate_pool(OPENING_PHRASES, lang) + patience = await self._translate_pool(PATIENCE_PHRASES, lang) + except Exception as exc: # noqa: BLE001 — best effort, Fallback Englisch + logger.warning("Filler-Übersetzung (%s) fehlgeschlagen: %s", lang, exc) + self._cache[lang] = {"opening": opening or OPENING_PHRASES, + "patience": patience or PATIENCE_PHRASES} + return lang + + async def opening(self, language: str | None) -> str: + lang = await self._ensure(language) + return random.choice(self._cache[lang]["opening"]) + + async def patience(self, language: str | None) -> str: + lang = await self._ensure(language) + return random.choice(self._cache[lang]["patience"])