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:
parent
c36b884aab
commit
96dcd87ca8
5 changed files with 237 additions and 41 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
146
app/pipeline/fillers.py
Normal 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"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue