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

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