feat(fillers): zufällige, übersetzte Senioren-Beruhigungssätze + Geduld-Schleife

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-29 22:34:15 +02:00
commit 96dcd87ca8
5 changed files with 237 additions and 41 deletions

View file

@ -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

View file

@ -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:

View file

@ -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)")

View file

@ -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),
)

146
app/pipeline/fillers.py Normal file
View file

@ -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"])