From 703a695bedaf98ef31539690f053186ef6857b3e Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 21:37:08 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(llm):=20Web-Suche=20per=20Tool-Calling?= =?UTF-8?q?=20(Weg=202)=20=E2=80=94=20Sonar=20+=20ToolCallingLLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frische-/Web-Such-Funktion: Das zentrale Modell entscheidet selbst via web_search-Tool, ob es tagesaktuelle Fakten braucht, holt sie über perplexity/sonar und formuliert die Antwort in Persona (Augment). - SonarTool (app/tools/web_search.py): Fakten via perplexity/sonar, Citations als Metadaten, honest-punt-Sentinel bei Fehler/Timeout. - ToolCallingLLM (app/providers/llm/tool_calling.py): agentischer Loop als LLMProvider; complete() + gestreamtes stream() mit SSE-Tool-Assembler; Persona- + Trigger- + Vorrang-Prompt (Tool-Ergebnis schlaegt Gedaechtnis). - Verdrahtung: Registry-Eintrag openrouter-tools; web_search_enabled (global an, pro Nutzer/Profil abschaltbar) via Route-Layering; build_orchestrator waehlt tool-faehig vs. plain, Fallback-Kette erhalten. - Filler: ephemerer Beruhigungssatz beim Tool-Start (sofort angezeigt UND gesprochen als Satz null), nie in semantic_response/History; on_tool_start defensiv durch die stream()-Kette gefaedelt (kein Bruch bestehender Provider). - Modellwechsel: Standard auf mistralai/mistral-small-3.2-24b-instruct (tool-faehig; im Eval einziger Recall-Gate-Passer). 2501 ist tool-unfaehig. - Eval-Harness (eval/tool_calling/): Datensatz + Runner zur Modellauswahl. Doc: Docs/weg2-tool-calling.md. Tests: 290 gruen. Offen (Schritt 5): Koreferenz-Vorstufe (nl-Pronomen) + Metrik-Zaehler. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + DEPLOYMENT.md | 4 +- Docs/weg2-tool-calling.md | 151 ++++++ app/config.py | 1 + app/core/orchestrator.py | 56 ++- app/dependencies.py | 33 +- app/providers/fallback.py | 5 +- app/providers/llm/base.py | 4 +- app/providers/llm/local_openai_compatible.py | 1 + app/providers/llm/openrouter.py | 1 + app/providers/llm/tool_calling.py | 284 +++++++++++ app/runtime_config.py | 1 + app/tools/__init__.py | 0 app/tools/web_search.py | 115 +++++ config/models.yaml | 4 +- eval/tool_calling/cases.yaml | 492 +++++++++++++++++++ eval/tool_calling/client.py | 105 ++++ eval/tool_calling/run_eval.py | 161 ++++++ eval/tool_calling/tool.py | 58 +++ 19 files changed, 1470 insertions(+), 9 deletions(-) create mode 100644 Docs/weg2-tool-calling.md create mode 100644 app/providers/llm/tool_calling.py create mode 100644 app/tools/__init__.py create mode 100644 app/tools/web_search.py create mode 100644 eval/tool_calling/cases.yaml create mode 100644 eval/tool_calling/client.py create mode 100644 eval/tool_calling/run_eval.py create mode 100644 eval/tool_calling/tool.py diff --git a/.gitignore b/.gitignore index 5f880b9..4458d31 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ Ideen/ # Local voice models config/voices/*.onnx config/voices/*.onnx.json + +# Eval-Roh-Ergebnisse (Tool-Calling-Harness, instanz-/laufabhängig) +eval/tool_calling/results_*.json diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e5087ac..03d1ff9 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -874,7 +874,7 @@ default_language = "de" default_input_endpoint = "local-default" default_output_endpoint = "local-default" -openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" +openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct" llm_fallback = "openrouter" # WICHTIG: Piper als TTS-Fallback. Greift, wenn Cartesia eine Sprache nicht kann @@ -1636,7 +1636,7 @@ Priorität der Wartezeit bei 429: Für zuverlässigen Betrieb das **Bezahlmodell** verwenden: ```toml -openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" +openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct" ``` Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md new file mode 100644 index 0000000..1ce7d6e --- /dev/null +++ b/Docs/weg2-tool-calling.md @@ -0,0 +1,151 @@ +# Design: Web-Suche per Tool-Calling (Weg 2) + +> Stand: Entwurf. Beschreibt das Konzept für die Frische-/Web-Such-Funktion auf +> Basis von agentischem Tool-Calling. Modellwahl und Schwellen sind durch den +> Eval-Harness (`eval/tool_calling/`) belegt; Implementierung folgt diesem Dokument. + +## 1. Ziel & Leitidee + +Das zentrale LLM kennt nur Wissen bis zu seinem Trainings-Cutoff. Fragt der Nutzer +nach etwas, das sich seither geändert haben könnte (aktuelle Amtsträger, Preise, +Wetter, Nachrichten, „lebt X noch", Öffnungszeiten …), soll das Modell **selbst** +eine Web-Suche auslösen, die frischen Fakten holen und die Antwort **in der +Alexis-Persona** formulieren. + +**Weg 2 = Tool-Calling + Anreichern:** Das Antwortmodell entscheidet via Werkzeug, +ob es sucht; die Suche liefert nur Fakten, das Modell formuliert. Vorteil: +durchgängige Stimme, Persona/Safety/History bleiben beim Hauptmodell. Bewusst +verworfen wurde der separate Vorab-Klassifikator („Weg 1"), weil das gewählte +Modell sich zuverlässig genug selbst triggert (siehe §7). + +## 2. Modellwahl (belegt) + +`mistralai/mistral-small-3.2-24b-instruct` über OpenRouter. Eval-Ergebnis +(69 Fälle × 5 Läufe): Trigger-Recall **93 %**, Vorrang-Treue **100 %**, +Wohlgeformtheit **100 %**, gefährliche Kategorien (Amtsträger/Preise/Nachrichten/ +Sport/Releases) **100 %**, Wetter inkl. implizitem Ort 98 %. Günstig und klein → +niedrige Latenz (im Senioren-Kontext doppelt wertvoll). Details und Vergleich +(Flash/Pro/alte Baseline) in der Memory-Notiz bzw. `eval/tool_calling/`. + +> Achtung: Die bisherige TOML-Baseline `mistral-small-24b-instruct-2501` kann +> **kein** Tool-Calling (OpenRouter 404 „No endpoints found that support tool use"). +> Ein Revert darauf würde Weg 2 abschalten. + +## 3. Keystone: Der Tool-Loop lebt *im* LLM-Provider + +Ein neuer Wrapper **`ToolCallingLLM`** implementiert die bestehende +`LLMProvider`-Schnittstelle (`complete()`/`stream()`), wickelt aber intern die +Agenten-Schleife ab. Der Orchestrator ruft weiter nur `self.llm.stream(...)` und +weiß nichts von Tools. Folge: **ein neuer `LLM_REGISTRY`-Eintrag**, keine +Kern-Änderung, volle Kompatibilität mit `resolve_route`, Fallback-Ketten und +Profilen. Das ist das Leitprinzip „jede Achse austauschbar". + +`ToolCallingLLM` enthält einen **eigenen** tool-fähigen OpenRouter-Client (Keim: +`eval/tool_calling/client.py`) — nicht den plain `OpenRouterLLMProvider`, da +dessen `complete()` keine `tools` kennt. + +## 4. Datenfluss + +**Kein-Tool-Turn (Normalfall, kein Latenz-Regress):** +``` +stream() → Modell mit tools=[…] → Text-Deltas → 1:1 an on_token/SentenceChunker +``` + +**Such-Turn (augment):** +``` +(0) Koreferenz-Vorstufe: Pronomen aus History auflösen (gegated) +(1) Modell → tool_call("web_search", query) +(2) on_tool_start(language) → Filler SOFORT sprechen/anzeigen +(3) SonarTool(query) → Fakten (~1–3 s, vom Filler überdeckt) +(4) Modell-Runde 2 mit tool-Ergebnis → finale Antwort, gestreamt, in Persona +``` + +## 5. Komponenten + +### 5.1 `SonarTool` +Ruft `perplexity/sonar` über OpenRouter (gleiche Chat-API wie `OpenRouterLLMProvider`). +Prompt an Sonar: knapp, Zielsprache, reine Fakten. Rückgabe als `tool`-Message ans +Modell. **Citations** als strukturierte Metadaten an die UI-Bubble (nicht ins TTS). +Timeout/Fehler → Sentinel „keine aktuellen Daten verfügbar", damit das Modell +ehrlich punktet statt zu hängen. + +### 5.2 `ToolCallingLLM` +Begrenzte Schleife (max. ~3 Runden gegen Runaway). System-Prompt = +Senioren-Persona (`SYSTEM_PROMPT` aus `openrouter.py`) **+** kalibrierte +Trigger-Leitlinie **+** Vorrang-Regel („Tool-Ergebnis ist maßgeblich und neuer +als dein Wissen; bei Widerspruch folge dem Tool, nicht mischen"). Implementiert +`complete()` (für `chat_text`/`translate`) und `stream()`. + +### 5.3 Koreferenz-Vorstufe (in v1) +Schließt die einzige nicht-triviale Eval-Restkante (`ctx_alive_nl`: +nl + Pronomen-aus-History; benannt + de-Pronomen sind 5/5). Ein +dekontextualisierender Rewrite macht die letzte Äußerung mit Hilfe des Verlaufs +selbstständig („hij" → „Rutger Hauer"). **Gegated**: nur bei kurzer Folgefrage +*mit* Pronomen *und* vorhandener History, damit er nicht jeden Turn kostet. + +### 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). + +### 5.5 Konfiguration & Opt-out +`web_search_enabled: bool = True` — **global an per Default**, pro Nutzer/Profil +**abschaltbar** (Opt-out greift über die bestehende Präzedenz +Defaults < Profil < Nutzer-Prefs). Der Schalter ist die nutzerfreundliche +Admin-/Profil-Option; intern wählt `build_orchestrator` daraufhin den +tool-fähigen vs. den plain Provider — **beide mit demselben konfigurierten +Modell** (`openrouter_llm_model`), damit der Modellwechsel an *einer* Stelle bleibt. + +### 5.6 Resilienz & Metriken +Fallback: Sonar-Ausfall → honest-punt im Loop; totaler Modellausfall → bestehende +`llm_fallback`-Kette. **Metriken** (`app/metrics.py`): Tool-Calls und Sonar-Calls +separat zählen — Kostensicht **und** Live-Beobachtung der Trigger-Rate. +**Kein** eigenes Sonar-Kontingent in v1 (erst Metrik-Sicht; `quota.py`-Anbindung +später bei Bedarf). + +## 6. Die zwei harten Stellen (bewusst benannt) + +- **Streaming + Tool-Erkennung.** Beim gestreamten ersten Call kommen + `tool_call`-Deltas fragmentiert. Der Wrapper setzt sie zusammen und + unterscheidet: Text-Deltas → durchreichen (kein Regress im Normalfall); + materialisiert sich ein Tool-Call → Stream verwerfen, Filler, Sonar, zweite + Runde streamen. mistral emittiert *entweder* Tool-Call *oder* Text — das macht + es handhabbar; das Delta-Zusammensetzen ist die eigentliche Arbeit. +- **Filler-Ephemeralität.** Spielt in Bubble *und* TTS, darf aber nie in + `semantic_response`/Store/`history`. Der Callback-Weg löst das by-design — beim + Implementieren strikt einhalten. + +## 7. Warum nicht Weg 1 (separater Klassifikator) + +Der Eval zeigte: das Modell self-triggert auf den gefährlichen, konfident- +veraltbaren Kategorien zu 100 %; die Recall-Lücke (→93 %) liegt nur in +low-/medium-harm-Slices (Logistik honest-punt, eine nl-Koreferenz-Kante). +Tool-Calling spart den Vorab-Roundtrip und ist zugleich die Basis für den +späteren Multitool-Ausbau (weitere Werkzeuge in dieselbe Schleife, Vorrang-Regel +wird zur Tool-Rangordnung). + +## 8. Phasenplan + +- **v1:** `SonarTool` + `ToolCallingLLM` (Loop, Persona+Vorrang-Prompt, eigener + Tool-Client) + Koreferenz-Vorstufe + Registry-Eintrag + `web_search_enabled` + (global an, Opt-out) + Filler für **Server-TTS** + Tool/Sonar-Metriken. + schedule_hours-honest-punt akzeptiert. +- **Fast-follow:** Filler für **Gerät-TTS** (Event-Protokoll) → Citations in die + UI-Bubble → ggf. deterministischer Logistik-Nudge. +- **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife + (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. + +## 9. Berührte Dateien (Implementierungs-Landkarte) + +- neu: `app/providers/llm/tool_calling.py` (`ToolCallingLLM`), `app/tools/web_search.py` (`SonarTool`), `app/pipeline/decontextualizer.py` (Koreferenz-Vorstufe) +- `app/dependencies.py` — `LLM_REGISTRY`-Eintrag; `build_orchestrator` (tool vs. plain je `web_search_enabled`) +- `app/core/orchestrator.py` — `on_tool_start`-Callback in `chat_stream`, Filler-Emission (ephemer) +- `app/config.py` / `app/runtime_config.py` — `web_search_enabled` (Default true, RUNTIME_SETTABLE + Nutzer-Pref-Opt-out) +- `app/metrics.py` — Zähler `tool_calls_total`, `sonar_calls_total` +- Filler-Texte je Sprache (Pool) diff --git a/app/config.py b/app/config.py index 13adf33..c30493e 100644 --- a/app/config.py +++ b/app/config.py @@ -188,6 +188,7 @@ class Settings(BaseSettings): memory_extraction_max: int = 50 memory_extraction_provider: str = "" audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) + web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index af2e6ac..36ea56c 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,4 +1,5 @@ import asyncio +import inspect from app.schemas import AudioChunk, PipelineTrace from app.pipeline.sentence_chunker import SentenceChunker @@ -21,6 +22,41 @@ _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. + + Hält den Orchestrator kompatibel mit stream()-Implementierungen ohne on_tool_start + (Test-Doubles, ältere Provider). + """ + try: + params = inspect.signature(stream_fn).parameters.values() + except (TypeError, ValueError): + return False + return any(p.name == name or p.kind == p.VAR_KEYWORD for p in params) + class Orchestrator: def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer, @@ -226,12 +262,30 @@ class Orchestrator: await consumer_task await queue.put(sentence) + _filler_state = {"n": 0} + + async def _on_tool_start(lang: str | None) -> None: + """Ephemerer Beruhigungssatz beim Tool-Start: sofort anzeigen + (Server-TTS) sprechen. + + Läuft bewusst NICHT über den Token-Stream -> landet nie in parts/ + semantic_response/History. Bei Server-TTS als "Satz null" vor die Antwort. + """ + 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) + if queue is not None: consumer_task = asyncio.create_task(_consume()) try: if stream_fn is not None: - async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): + stream_kwargs = {"history": history, "language": effective_language} + 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): parts.append(delta) if on_token: await on_token(delta) diff --git a/app/dependencies.py b/app/dependencies.py index 35479f4..7d3cb4e 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -17,6 +17,8 @@ from app.providers.stt.openrouter import OpenRouterSTTProvider from app.providers.stt.faster_whisper import FasterWhisperProvider from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM from app.providers.llm.openrouter import OpenRouterLLMProvider +from app.providers.llm.tool_calling import ToolCallingLLM +from app.tools.web_search import SonarTool from app.providers.tts.openrouter import OpenRouterTTSProvider from app.providers.tts.cartesia import CartesiaTTSProvider from app.providers.tts.chatterbox import ChatterboxTTSProvider @@ -56,6 +58,12 @@ STT_REGISTRY = { LLM_REGISTRY = { "openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model), + # Tool-fähige Variante desselben OpenRouter-Modells (Weg 2: web_search via Sonar). + "openrouter-tools": lambda s: ToolCallingLLM( + s.openrouter_api_key, + s.openrouter_llm_model, + tools=[SonarTool(s.openrouter_api_key)], + ), "local-openai-compatible": lambda s: LocalOpenAICompatibleLLM( s.local_llm_base_url, s.local_llm_api_key, @@ -156,8 +164,20 @@ ROUTE_KEYS = ( "tts_provider", "language", "voice_gender", + "web_search_enabled", ) + +def _as_bool(value, default: bool = True) -> bool: + """Robuste Bool-Auflösung (Prefs/Overrides können Strings sein).""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + if value is None: + return default + return bool(value) + # Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme (gender-agnostisch). LANG_TO_PIPER_VOICE: dict[str, str] = { "de": "de_DE-thorsten-high", @@ -236,6 +256,7 @@ class ResolvedRoute: tts_provider: str language: str voice_gender: str = "any" + web_search_enabled: bool = True def as_dict(self) -> dict: return { @@ -246,6 +267,7 @@ class ResolvedRoute: "tts_provider": self.tts_provider, "language": self.language, "voice_gender": self.voice_gender, + "web_search_enabled": self.web_search_enabled, } @@ -286,6 +308,7 @@ def resolve_route( "llm_provider": cfg.default_llm_provider, "tts_provider": cfg.default_tts_provider, "language": cfg.default_language, + "web_search_enabled": cfg.web_search_enabled, } user_prefs = user.prefs if user is not None else {} @@ -298,6 +321,8 @@ def resolve_route( if value is not None: resolved[key] = value + # web_search_enabled kann als String aus Prefs/Overrides kommen -> robust nach bool. + resolved["web_search_enabled"] = _as_bool(resolved["web_search_enabled"], cfg.web_search_enabled) route = ResolvedRoute(**resolved) # Admin-Vorgabe „erlaubte Sprachen pro Nutzer": auf eine erlaubte Sprache klemmen. allowed = [s.strip() for s in str(user_prefs.get("allowed_languages") or "").split(",") if s.strip()] @@ -337,9 +362,15 @@ def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str: def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator: cfg = cfg or runtime_settings + # web_search (Weg 2): tool-fähige Variante desselben OpenRouter-Modells wählen. + # Nur wenn der aufgelöste LLM-Provider "openrouter" ist — lokale Modelle können + # (über diesen Pfad) kein Tool-Calling. Die llm_fallback-Kette bleibt erhalten. + llm_name = route.llm_provider + if route.web_search_enabled and llm_name == "openrouter": + llm_name = "openrouter-tools" return Orchestrator( stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg), - llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg), + llm=_provider_chain(LLM_REGISTRY, llm_name, cfg.llm_fallback, "llm", cfg), tts=_provider_chain(TTS_REGISTRY, route.tts_provider, cfg.tts_fallback, "tts", cfg), input_cleaner=InputCleaner(), spoken_adapter=SpokenResponseAdapter(), diff --git a/app/providers/fallback.py b/app/providers/fallback.py index 5682f2d..e227915 100644 --- a/app/providers/fallback.py +++ b/app/providers/fallback.py @@ -65,13 +65,14 @@ class FallbackLLMProvider(_Chain): self._on_error(name) raise last_exc - async def stream(self, text, history=None, session_id=None, language=None) -> AsyncIterator[str]: + async def stream(self, text, history=None, session_id=None, language=None, + **kwargs) -> AsyncIterator[str]: last_exc = None for index, (name, provider) in enumerate(self.entries): produced = False try: async for delta in provider.stream( - text, history=history, session_id=session_id, language=language + text, history=history, session_id=session_id, language=language, **kwargs ): produced = True yield delta diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index a72a1e7..61dc541 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -70,9 +70,11 @@ class LLMProvider(ABC): history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, + **kwargs, ) -> AsyncIterator[str]: """Token-Stream. Default: kein echtes Streaming -> komplette Antwort als ein Chunk. - Provider mit SSE-Unterstuetzung ueberschreiben diese Methode. + Provider mit SSE-Unterstuetzung ueberschreiben diese Methode. Unbekannte + kwargs (z. B. on_tool_start) werden ignoriert. """ yield await self.complete(text, history=history, session_id=session_id, language=language) diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index bb6e15d..eb7761d 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -102,6 +102,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, + **kwargs, # z. B. on_tool_start — hier ignoriert (kein Tool-Calling) ) -> AsyncIterator[str]: async with httpx.AsyncClient(timeout=120) as client: async with client.stream( diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index f29864a..d047692 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -175,6 +175,7 @@ class OpenRouterLLMProvider(LLMProvider): history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, + **kwargs, # z. B. on_tool_start — hier ignoriert (kein Tool-Calling) ) -> AsyncIterator[str]: payload = { "model": self.model, diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py new file mode 100644 index 0000000..3d52f92 --- /dev/null +++ b/app/providers/llm/tool_calling.py @@ -0,0 +1,284 @@ +"""ToolCallingLLM: agentischer Wrapper um ein tool-fähiges OpenRouter-Modell. + +Implementiert die LLMProvider-Schnittstelle und wickelt intern die Tool-Schleife +ab (Modell entscheidet via web_search, ob es sucht; Werkzeug liefert Fakten; +Modell formuliert die finale Antwort in Persona). Der Orchestrator ruft nur +`complete()`/`stream()` und weiß nichts von Tools. Siehe Docs/weg2-tool-calling.md. + +Schritt 2: nur `complete()` (nicht gestreamt). Der ABC-Default `stream()` fällt +auf `complete()` zurück, sodass der Loop schon durch den Orchestrator nutzbar ist; +echtes Streaming + Filler folgen in Schritt 4. +""" +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from datetime import date + +import httpx + +from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder +from app.providers.llm.openrouter import SYSTEM_PROMPT as PERSONA_PROMPT + +logger = logging.getLogger(__name__) + +ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" +_RETRY_STATUS = {404, 429, 500, 502, 503} + +# Tool-Schema inkl. Trigger-Kategorien — wortgleich zur im Eval validierten Fassung. +WEB_SEARCH_TOOL = { + "type": "function", + "function": { + "name": "web_search", + "description": ( + "Look up real-world information that may be newer than your " + "knowledge or may have changed since your last update. Call it " + "whenever the true answer could plausibly have changed, INCLUDING " + "when no exact place or date is named. This covers: who currently " + "holds an office; where a living person now lives; whether someone " + "is still alive; current prices, rates or crypto; current weather " + "or outdoor conditions (even phrased as 'is it cold/raining right " + "now', using the user's location); sports results and standings; " + "the latest version or model of a product; recent news; and " + "time-sensitive logistics such as opening hours, schedules, and " + "public-transport or train/bus departure times. Do NOT use it for " + "timeless knowledge, opinions, jokes, small talk, the current " + "clock time or today's date (you already have those), anything " + "you can derive yourself, or anything about the user themselves." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Concise search query, in the user's language or English.", + }, + }, + "required": ["query"], + }, + }, +} + +# Trigger- + Vorrang-Anweisung. cutoff ist ein bewusst KONSERVATIVER Anker (eher +# zu früh als zu spät) — das verschiebt im Zweifel Richtung „suchen", die sichere +# Seite (höherer Recall). Im Eval mit genau dieser Formulierung validiert. +_TRIGGER_AND_PRIORITY = ( + "Your knowledge was last updated around {cutoff}. Today is {today}. " + "When a question could require information newer than {cutoff} that you " + "cannot reason out yourself, call web_search before answering. Never call " + "web_search merely to find the current time or today's date — you already " + "have them. Tool results are authoritative and newer than your memory: if a " + "tool result conflicts with what you believe, follow the tool and never " + "contradict it." +) + + +def _system_prompt(cutoff: str, language: str | None) -> str: + today = date.today().isoformat() + parts = [PERSONA_PROMPT, _TRIGGER_AND_PRIORITY.format(cutoff=cutoff, today=today)] + instr = lang_instruction(language) + if instr: + parts.append(instr) + return "\n\n".join(parts) + + +class _StreamAcc: + """Sammelt einen gestreamten Modell-Schritt: Text-Buffer + (fragmentierte) tool_calls. + + Beim Streaming kommen tool_calls in Bruchstücken (je `index`: id/name einmal, + `arguments` über viele Deltas). Hier zusammengesetzt; Text wird zugleich + durchgereicht (siehe _stream_chat). + """ + + def __init__(self): + self.text: list[str] = [] + self._tc: dict[int, dict] = {} + + def add_text(self, s: str) -> None: + self.text.append(s) + + def add_tool_fragments(self, frags: list[dict]) -> None: + for tc in frags: + idx = tc.get("index", 0) + entry = self._tc.setdefault(idx, {"id": "", "name": "", "arguments": ""}) + if tc.get("id"): + entry["id"] = tc["id"] + fn = tc.get("function") or {} + if fn.get("name"): + entry["name"] = fn["name"] + if fn.get("arguments"): + entry["arguments"] += fn["arguments"] + + @property + def tool_calls(self) -> list[dict]: + return [ + {"id": e["id"] or f"call_{idx}", "type": "function", + "function": {"name": e["name"], "arguments": e["arguments"]}} + for idx, e in sorted(self._tc.items()) + ] + + def assistant_message(self) -> dict: + """Assistant-Message zum Re-Threading der tool_calls an das Modell.""" + return {"role": "assistant", "content": "".join(self.text) or None, + "tool_calls": self.tool_calls} + + +class ToolCallingLLM(LLMProvider): + def __init__(self, api_key: str, model: str, tools: list, + knowledge_cutoff: str = "fall 2024", max_rounds: int = 3, + max_retries: int = 4, temperature: float = 0.3): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + self.tools = {t.name: t for t in tools} + self.cutoff = knowledge_cutoff + self.max_rounds = max(1, max_rounds) + self.max_retries = max(1, max_retries) + self.temperature = temperature + + def _initial_messages(self, text: str, history, language) -> list[dict]: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("ToolCallingLLM model is empty") + if not text or not text.strip(): + raise ValueError("LLM input text is empty") + messages = [{"role": "system", "content": _system_prompt(self.cutoff, language)}] + if history: + messages.extend(history) + messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) + return messages + + async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict: + """Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls).""" + payload = {"model": self.model, "messages": messages, "temperature": self.temperature} + if allow_tools: + payload["tools"] = [WEB_SEARCH_TOOL] + payload["tool_choice"] = "auto" + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + last_error: Exception | None = None + + for attempt in range(1, self.max_retries + 1): + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(ENDPOINT, json=payload, headers=headers) + if resp.status_code in _RETRY_STATUS and attempt < self.max_retries: + last_error = RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:200]}") + await asyncio.sleep(min(2.0 * attempt, 30.0)) + continue + resp.raise_for_status() + data = resp.json() + if "choices" not in data: # transiente Fehler kommen teils als 200 mit {"error":...} + if attempt < self.max_retries: + last_error = RuntimeError(f"Antwort ohne 'choices': {str(data)[:200]}") + await asyncio.sleep(min(2.0 * attempt, 30.0)) + continue + raise RuntimeError(f"OpenRouter-Antwort ohne 'choices': {str(data)[:300]}") + return data["choices"][0]["message"] + + raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") + + async def _run_tool(self, tool_call: dict, language: str | None) -> str: + """Führt einen Tool-Aufruf aus und liefert den tool-Message-Inhalt.""" + fn = tool_call.get("function", {}) + name = fn.get("name", "") + tool = self.tools.get(name) + if tool is None: + logger.warning("Unbekanntes Tool angefragt: %r", name) + return f"ERROR: unknown tool {name!r}." + try: + args = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + args = {} + query = str(args.get("query", "")).strip() + result = await tool.run(query, language=language) + # result.citations -> UI-Bubble (Fast-follow); hier (noch) nicht durchgereicht. + return result.text + + async def complete(self, text: str, history: list[dict] | None = None, + session_id: str | None = None, language: str | None = None) -> str: + messages = self._initial_messages(text, history, language) + + for _ in range(self.max_rounds): + msg = await self._chat(messages, allow_tools=True) + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = (msg.get("content") or "").strip() + if content: + return content + break # leer ohne Tool-Call -> finale Runde erzwingen + messages.append(msg) # Assistant-Message mit tool_calls (unverändert zurück) + for tc in tool_calls: + result_text = await self._run_tool(tc, language) + messages.append({"role": "tool", "tool_call_id": tc.get("id", ""), + "content": result_text}) + + # max_rounds erschöpft (oder leer): finale Antwort ohne weitere Tools erzwingen. + final = await self._chat(messages, allow_tools=False) + content = (final.get("content") or "").strip() + if not content: + raise RuntimeError("ToolCallingLLM returned an empty response") + return content + + async def stream(self, text: str, history: list[dict] | None = None, + session_id: str | None = None, language: str | None = None, + on_tool_start=None, **kwargs) -> AsyncIterator[str]: + """Gestreamter Loop: Text-Deltas durchreichen; bei tool_call Sonar+nächste Runde. + + Kein Latenz-Regress im Normalfall (kein Tool): die Antwort streamt direkt + durch. `on_tool_start(language)` (optional) feuert, bevor ein Tool läuft — + Aufhänger für den ephemeren Filler (Schritt 4b). + """ + messages = self._initial_messages(text, history, language) + + for _ in range(self.max_rounds): + acc = _StreamAcc() + async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): + yield piece + if not acc.tool_calls: + return # war Text -> fertig (durchgereicht) + if on_tool_start is not None: + await on_tool_start(language) # 4b: Filler sofort sprechen/anzeigen + messages.append(acc.assistant_message()) + for tc in acc.tool_calls: + result_text = await self._run_tool(tc, language) + messages.append({"role": "tool", "tool_call_id": tc["id"], + "content": result_text}) + + # max_rounds erschöpft: finale Runde ohne weitere Tools, gestreamt. + acc = _StreamAcc() + async for piece in self._stream_chat(messages, allow_tools=False, acc=acc): + yield piece + + async def _stream_chat(self, messages: list[dict], allow_tools: bool, + acc: _StreamAcc) -> AsyncIterator[str]: + """Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls).""" + payload = {"model": self.model, "messages": messages, + "temperature": self.temperature, "stream": True} + if allow_tools: + payload["tools"] = [WEB_SEARCH_TOOL] + payload["tool_choice"] = "auto" + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("POST", ENDPOINT, json=payload, headers=headers) as resp: + if resp.status_code >= 400: + body = await resp.aread() + raise RuntimeError( + f"OpenRouter {resp.status_code}: {body.decode(errors='replace')[:200]}") + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + data = line[len("data:"):].strip() + if not data or data == "[DONE]": + continue + try: + delta = json.loads(data)["choices"][0]["delta"] + except (ValueError, KeyError, IndexError, TypeError): + continue + content = delta.get("content") + if content: + acc.add_text(content) + yield content + if delta.get("tool_calls"): + acc.add_tool_fragments(delta["tool_calls"]) diff --git a/app/runtime_config.py b/app/runtime_config.py index aaef3ea..ef99a53 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -30,6 +30,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"), "tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"), "audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"), + "web_search_enabled": ("Web-Suche (Standard)", "bool", "true | false — global an, pro Nutzer abschaltbar"), "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), diff --git a/app/tools/__init__.py b/app/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tools/web_search.py b/app/tools/web_search.py new file mode 100644 index 0000000..2162674 --- /dev/null +++ b/app/tools/web_search.py @@ -0,0 +1,115 @@ +"""web_search-Werkzeug: holt tagesaktuelle Fakten über perplexity/sonar (OpenRouter). + +Reiner Executor — die Tool-Schema-/Trigger-Entscheidung liegt beim ToolCallingLLM +(siehe Docs/weg2-tool-calling.md). Liefert knappe Fakten als tool-Message-Inhalt +plus Citations als UI-Metadaten (nicht ins TTS). Bei Timeout/Fehler einen +honest-punt-Sentinel, damit das Modell ehrlich antwortet statt zu hängen. +""" +import asyncio +import logging +from dataclasses import dataclass, field + +import httpx + +from app.providers.llm.base import lang_instruction + +logger = logging.getLogger(__name__) + +ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" +_RETRY_STATUS = {429, 500, 502, 503} + +# Klare Anweisung an das MODELL (nicht den Nutzer), wenn keine Daten kamen — +# das Antwortmodell formuliert daraus den ehrlichen Hinweis in Persona/Sprache. +_NO_DATA = ("NO_CURRENT_DATA: the web search returned no usable result. " + "Tell the user briefly that you could not retrieve current information.") + +_SONAR_SYSTEM = ( + "You are a real-time web search assistant. Answer with current, factual " + "information only, concisely in 1-3 sentences. Plain text only: no markdown, " + "no lists, no citation markers like [1]." +) + + +@dataclass +class ToolResult: + text: str # tool-Message-Inhalt fürs Modell + citations: list[str] = field(default_factory=list) # UI-Metadaten, nicht ins TTS + ok: bool = True # False = honest-punt-Sentinel + + +class SonarTool: + """Web-Such-Executor (perplexity/sonar via OpenRouter).""" + + name = "web_search" + + def __init__(self, api_key: str, model: str = "perplexity/sonar", + timeout: float = 20.0, max_retries: int = 2): + self.api_key = (api_key or "").strip() + self.model = model.strip() + self.timeout = timeout + self.max_retries = max(1, max_retries) + + def _messages(self, query: str, language: str | None) -> list[dict]: + system = _SONAR_SYSTEM + instr = lang_instruction(language) + if instr: + system = f"{_SONAR_SYSTEM} {instr}" + return [{"role": "system", "content": system}, + {"role": "user", "content": query.strip()}] + + async def run(self, query: str, language: str | None = None) -> ToolResult: + if not query or not query.strip(): + return ToolResult(text=_NO_DATA, ok=False) + if not self.api_key: + logger.warning("SonarTool ohne API-Key") + return ToolResult(text=_NO_DATA, ok=False) + + payload = {"model": self.model, "messages": self._messages(query, language)} + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + timeout = httpx.Timeout(self.timeout) + + for attempt in range(1, self.max_retries + 1): + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(ENDPOINT, json=payload, headers=headers) + if resp.status_code in _RETRY_STATUS and attempt < self.max_retries: + await asyncio.sleep(1.5 * attempt) + continue + resp.raise_for_status() + except (httpx.TimeoutException, httpx.HTTPError) as exc: + logger.warning("Sonar-Aufruf fehlgeschlagen (Versuch %d): %s", attempt, exc) + if attempt < self.max_retries: + await asyncio.sleep(1.0 * attempt) + continue + return ToolResult(text=_NO_DATA, ok=False) + + data = resp.json() + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError): + logger.warning("Unerwartete Sonar-Antwort: %s", str(data)[:200]) + return ToolResult(text=_NO_DATA, ok=False) + text = (content or "").strip() + if not text: + return ToolResult(text=_NO_DATA, ok=False) + return ToolResult(text=text, citations=_extract_citations(data), ok=True) + + return ToolResult(text=_NO_DATA, ok=False) + + +def _extract_citations(data: dict) -> list[str]: + """Citations aus OpenRouter/Perplexity-Antwort: top-level `citations` oder annotations.""" + cites = data.get("citations") + if isinstance(cites, list) and cites: + return [c for c in cites if isinstance(c, str)] + try: + ann = data["choices"][0]["message"].get("annotations") or [] + except (KeyError, IndexError, TypeError): + ann = [] + urls = [] + for a in ann: + if isinstance(a, dict) and a.get("type") == "url_citation": + url = (a.get("url_citation") or {}).get("url") + if url: + urls.append(url) + return urls diff --git a/config/models.yaml b/config/models.yaml index 969b155..11df8e4 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -10,9 +10,9 @@ # Nur diese LLM-Schlüssel werden ausgewertet: # default_llm_provider, openrouter_llm_model, local_llm_model presets: - - label: "Mistral Small 24B — Standard" + - label: "Mistral Small 3.2 24B — Standard" default: true - note: "Standard aus der Konfiguration (OpenRouter)" + note: "Standard aus der Konfiguration (OpenRouter) — tool-fähig für die Web-Suche" - label: "DeepSeek V4 Flash (OpenRouter)" set: diff --git a/eval/tool_calling/cases.yaml b/eval/tool_calling/cases.yaml new file mode 100644 index 0000000..2df4b35 --- /dev/null +++ b/eval/tool_calling/cases.yaml @@ -0,0 +1,492 @@ +# Eval-Datensatz: Tool-Calling-Trigger für die Frische-/Web-Such-Funktion. +# +# Felder: id, utterance, lang, expect (search|no_search), category, +# optional: history, injected_result, expect_fact, forbid_fact, note. +# +# Vorrang-Fälle (mit injected_result) sind eine Teilmenge der search-Fälle und +# werden doppelt genutzt: Pass 1 misst Trigger-Recall, Pass 2 die Vorrang-Treue. + +# ============================================================ +# A) MUSS SUCHEN (expect: search) +# ============================================================ + +# --- officeholder: aktuelle Amtsträger (KEIN Zeit-Stichwort!) --- +- id: chancellor_de + lang: de + expect: search + category: officeholder + utterance: "Wer ist Bundeskanzler?" +- id: uspres_en + lang: en + expect: search + category: officeholder + utterance: "Who is the president of the United States?" +- id: pm_nl + lang: nl + expect: search + category: officeholder + utterance: "Wie is de minister-president van Nederland?" +- id: pope_de + lang: de + expect: search + category: officeholder + utterance: "Wer ist gerade Papst?" + +# --- residence_living_person: Wohnort lebender Person (DER Trap-Fall) --- +- id: heino_home_de + lang: de + expect: search + category: residence_living_person + utterance: "Wo wohnt Heino?" + note: "Fühlt sich statisch an, ist es aber nicht (Umzug Kitzbühel)." +- id: player_club_en + lang: en + expect: search + category: residence_living_person + utterance: "Which club does Robert Lewandowski play for now?" +- id: singer_home_nl + lang: nl + expect: search + category: residence_living_person + utterance: "Waar woont André Rieu tegenwoordig?" + +# --- alive_status: lebt/aktiv? --- +- id: alive_de + lang: de + expect: search + category: alive_status + utterance: "Lebt Heino noch?" +- id: alive_en + lang: en + expect: search + category: alive_status + utterance: "Is Brigitte Bardot still alive?" + +# --- weather: Echtzeit --- +- id: weather_paris_de + lang: de + expect: search + category: weather + utterance: "Wie ist das Wetter in Paris?" +- id: weather_rain_nl + lang: nl + expect: search + category: weather + utterance: "Gaat het vandaag regenen in Amsterdam?" +- id: weather_cold_en + lang: en + expect: search + category: weather + utterance: "Is it cold outside right now?" + note: "Braucht Ort aus Nutzerprofil — Trigger muss trotzdem feuern." + +# --- price: Kurse/Preise --- +- id: stock_de + lang: de + expect: search + category: price + utterance: "Was kostet die Apple-Aktie?" +- id: gold_nl + lang: nl + expect: search + category: price + utterance: "Wat is de goudprijs op dit moment?" +- id: fuel_de + lang: de + expect: search + category: price + utterance: "Was kostet gerade ein Liter Diesel?" + +# --- latest_release: neueste Version/Produkt --- +- id: iphone_en + lang: en + expect: search + category: latest_release + utterance: "What is the newest iPhone?" +- id: release_de + lang: de + expect: search + category: latest_release + utterance: "Welches ist das aktuellste Samsung-Galaxy-Handy?" + +# --- sports_result: Ergebnisse/Tabellen --- +- id: match_de + lang: de + expect: search + category: sports_result + utterance: "Wie hat Bayern München am Wochenende gespielt?" +- id: standings_en + lang: en + expect: search + category: sports_result + utterance: "Who is leading the Premier League?" + +# --- news_event: jüngere Ereignisse --- +- id: news_de + lang: de + expect: search + category: news_event + utterance: "Was ist gerade die wichtigste Nachricht aus Berlin?" +- id: news_fr + lang: fr + expect: search + category: news_event + utterance: "Que s'est-il passé récemment en France ?" + +# --- schedule_hours: volatile Öffnungs-/Fahrzeiten --- +- id: hours_de + lang: de + expect: search + category: schedule_hours + utterance: "Hat die Apotheke am Markt heute geöffnet?" +- id: train_nl + lang: nl + expect: search + category: schedule_hours + utterance: "Hoe laat gaat de volgende trein naar Utrecht?" + +# ============================================================ +# B) MUSS NICHT SUCHEN (expect: no_search) +# ============================================================ + +# --- timeless: zeitloses Wissen --- +- id: capital_de + lang: de + expect: no_search + category: timeless + utterance: "Was ist die Hauptstadt von Frankreich?" +- id: faust_de + lang: de + expect: no_search + category: timeless + utterance: "Wer hat den Faust geschrieben?" +- id: boil_en + lang: en + expect: no_search + category: timeless + utterance: "At what temperature does water boil?" +- id: ww2_nl + lang: nl + expect: no_search + category: timeless + utterance: "Wie won de Tweede Wereldoorlog?" +- id: math_es + lang: es + expect: no_search + category: timeless + utterance: "¿Cuánto es ciento veinte dividido entre cuatro?" + +# --- derivable: ableitbar, kein Web nötig --- +- id: heino_age_de + lang: de + expect: no_search + category: derivable + utterance: "Wie alt ist Heino?" + note: "Aus Geburtsjahr + heutigem Datum ableitbar." +- id: weekday_en + lang: en + expect: no_search + category: derivable + utterance: "What day of the week is Christmas this year?" + +# --- static_fact: feststehende Personendaten --- +- id: heino_birthday_de + lang: de + expect: no_search + category: static_fact + utterance: "Wann hat Heino Geburtstag?" + +# --- opinion / Vorschlag --- +- id: recipe_de + lang: de + expect: no_search + category: opinion + utterance: "Kannst du mir ein einfaches Rezept für Kartoffelsuppe sagen?" +- id: advice_nl + lang: nl + expect: no_search + category: opinion + utterance: "Welke bloemen kan ik het beste in de schaduw planten?" + +# --- smalltalk / sozial --- +- id: howareyou_de + lang: de + expect: no_search + category: smalltalk + utterance: "Wie geht es dir heute?" + note: "Keyword-Köder 'heute' — darf NICHT triggern." +- id: chat_en + lang: en + expect: no_search + category: smalltalk + utterance: "I'm a bit bored, let's talk for a while." + +# --- emotional / Unterstützung --- +- id: lonely_de + lang: de + expect: no_search + category: emotional + utterance: "Ich fühle mich heute sehr einsam." +- id: worry_nl + lang: nl + expect: no_search + category: emotional + utterance: "Ik maak me zorgen en kan niet slapen." + +# --- joke / Unterhaltung --- +- id: joke_de + lang: de + expect: no_search + category: joke + utterance: "Erzähl mir bitte einen Witz." + +# --- procedural_timeless --- +- id: howto_en + lang: en + expect: no_search + category: procedural_timeless + utterance: "How do I cook a soft-boiled egg?" + +# --- personal_memory: betrifft den Nutzer selbst (Store, nicht Web) --- +- id: mymed_de + lang: de + expect: no_search + category: personal_memory + utterance: "Welche Medikamente nehme ich morgens?" + note: "Aus Nutzerprofil/Erinnerungen, nicht aus dem Web." +- id: myname_nl + lang: nl + expect: no_search + category: personal_memory + utterance: "Hoe heet mijn dochter ook alweer?" + +# ============================================================ +# C) ADVERSARIAL (Köder & Grenzfälle — höchster Testwert) +# ============================================================ + +# --- "aktuell"-Köder, aber persönlich --- +- id: trap_fav_de + lang: de + expect: no_search + category: personal_memory + utterance: "Was ist gerade mein Lieblingslied?" + note: "'gerade' ist Köder; Antwort aus Erinnerungen." + +# --- aktuell, ABER nicht aus dem Web (Systemfunktion) --- +- id: time_now_de + lang: de + expect: no_search + category: system_clock + utterance: "Wie spät ist es?" + note: "Echtzeit, aber Systemuhr — kein Web. Grenzfall bewusst no_search." +- id: date_today_en + lang: en + expect: no_search + category: system_clock + utterance: "What's today's date?" + +# --- "könnte sich theoretisch ändern", praktisch nie --- +- id: trap_capital_de + lang: de + expect: no_search + category: timeless + utterance: "Ist Berlin immer noch die Hauptstadt Deutschlands?" + note: "Suggeriert Volatilität, ist aber stabil — darf nicht übertriggern." + +# ============================================================ +# D) FOLGEFRAGEN MIT HISTORY (Kontext-Trigger) +# ============================================================ + +- id: ctx_heino_home_de + lang: de + expect: search + category: residence_living_person + history: + - role: user + content: "Erzähl mir etwas über den Sänger Heino." + - role: assistant + content: "Heino ist ein deutscher Schlagersänger, bekannt für seine dunkle Brille." + utterance: "Und wo wohnt der eigentlich?" + note: "Referent nur aus History — Trigger muss trotzdem feuern." + +- id: ctx_alive_nl + lang: nl + expect: search + category: alive_status + history: + - role: user + content: "Ken je de acteur Rutger Hauer?" + - role: assistant + content: "Ja, een bekende Nederlandse acteur." + utterance: "Leeft hij eigenlijk nog?" + +- id: ctx_followup_static_de + lang: de + expect: no_search + category: static_fact + history: + - role: user + content: "Wer hat den Faust geschrieben?" + - role: assistant + content: "Johann Wolfgang von Goethe." + utterance: "Und wann wurde der geboren?" + note: "Folgefrage, aber zeitlos — darf nicht triggern." + +# ============================================================ +# E) VORRANG-TESTS (injiziertes Faktum widerspricht dem Training) +# ============================================================ + +- id: prio_chancellor_de + lang: de + expect: search + category: officeholder + utterance: "Wer ist Bundeskanzler?" + injected_result: "Bundeskanzler ist Friedrich Merz (CDU), im Amt seit Mai 2025." + expect_fact: "Merz" + forbid_fact: "Scholz" + +- id: prio_pope_de + lang: de + expect: search + category: officeholder + utterance: "Wer ist gerade Papst?" + injected_result: "Papst ist Leo XIV., gewählt im Mai 2025." + expect_fact: "Leo" + forbid_fact: "Franziskus" + note: "Sehr jung (nach Cutoff) — harter Vorrang-Test." + +- id: prio_uspres_en + lang: en + expect: search + category: officeholder + utterance: "Who is the US president?" + injected_result: "Donald Trump is the US president, inaugurated January 2025." + expect_fact: "Trump" + forbid_fact: "Biden" + +- id: prio_heino_home_de + lang: de + expect: search + category: residence_living_person + utterance: "Wo wohnt Heino?" + injected_result: "Heino lebt inzwischen dauerhaft in Kitzbühel, Österreich." + expect_fact: "Kitzbühel" + forbid_fact: "Münstereifel" + +- id: prio_iphone_en + lang: en + expect: search + category: latest_release + utterance: "What is the newest iPhone?" + injected_result: "The newest model is the iPhone 17, released September 2025." + expect_fact: "17" + forbid_fact: "16" + +# ============================================================ +# F) AUFSTOCKUNG schwacher Kategorien (für belastbare Kategorie-Raten) +# Bewusst variantenreich: andere Entitäten/Sprachen, explizit + implizit. +# ============================================================ + +# --- schedule_hours (war nur 2 Fälle) --- +- id: bakery_hours_de + lang: de + expect: search + category: schedule_hours + utterance: "Wann macht die Bäckerei morgen früh auf?" +- id: bus_nl + lang: nl + expect: search + category: schedule_hours + utterance: "Wanneer vertrekt de volgende bus naar het centrum?" +- id: museum_hours_en + lang: en + expect: search + category: schedule_hours + utterance: "What are the opening hours of the British Museum today?" +- id: doctor_hours_de + lang: de + expect: search + category: schedule_hours + utterance: "Hat die Arztpraxis heute Nachmittag noch geöffnet?" +- id: pharmacy_night_de + lang: de + expect: search + category: schedule_hours + utterance: "Welche Apotheke hat heute Nacht Notdienst?" + +# --- weather (war 3 Fälle) — Fokus implizit/ortlos/ohne Wort 'Wetter' --- +- id: forecast_munich_de + lang: de + expect: search + category: weather + utterance: "Wie warm wird es morgen in München?" +- id: weather_london_en + lang: en + expect: search + category: weather + utterance: "What's the weather going to be like in London tomorrow?" +- id: umbrella_de + lang: de + expect: search + category: weather + utterance: "Soll ich heute einen Regenschirm mitnehmen?" + note: "Impliziter Ort, kein Wort 'Wetter' — der harte Fall." +- id: jacket_de + lang: de + expect: search + category: weather + utterance: "Brauche ich heute draußen eine Jacke?" + note: "Impliziter Ort, kein Wort 'Wetter'." +- id: umbrella_nl + lang: nl + expect: search + category: weather + utterance: "Moet ik vandaag een paraplu meenemen?" + note: "Implizit, nl." + +# --- residence_living_person (war ~4 Fälle) --- +- id: becker_home_de + lang: de + expect: search + category: residence_living_person + utterance: "Wo lebt Boris Becker inzwischen?" +- id: madonna_home_en + lang: en + expect: search + category: residence_living_person + utterance: "Where does Madonna live these days?" +- id: ctx_residence_de + lang: de + expect: search + category: residence_living_person + history: + - role: user + content: "Kennst du den Moderator Thomas Gottschalk?" + - role: assistant + content: "Ja, ein bekannter deutscher Fernsehmoderator." + utterance: "Wo wohnt der eigentlich inzwischen?" + note: "Pronomen-Folgefrage (de) — Gegenstück zur nl-Pronomen-Lücke." + +# --- alive_status (war 3 Fälle) — benannt vs. Pronomen, mehrsprachig --- +- id: jagger_en + lang: en + expect: search + category: alive_status + utterance: "Is Mick Jagger still alive?" +- id: alive_nl2 + lang: nl + expect: search + category: alive_status + utterance: "Leeft Willeke Alberti nog?" + note: "Benannt-nl — isoliert Sprache von Pronomen (vgl. ctx_alive_nl)." +- id: ctx_alive_de + lang: de + expect: search + category: alive_status + history: + - role: user + content: "Erzähl mir vom Sänger Udo Lindenberg." + - role: assistant + content: "Ein bekannter deutscher Rockmusiker mit Hut und Sonnenbrille." + utterance: "Lebt der eigentlich noch?" + note: "Pronomen-Folgefrage (de)." diff --git a/eval/tool_calling/client.py b/eval/tool_calling/client.py new file mode 100644 index 0000000..7e67b4a --- /dev/null +++ b/eval/tool_calling/client.py @@ -0,0 +1,105 @@ +"""Minimaler OpenAI-kompatibler Tool-Calling-Client (OpenRouter). + +Bewusst eigenständig gehalten: misst die Tool-Call-Fähigkeit des Modells und +wird zugleich der Keim der echten Weg-2-Tool-Schicht. Gleicher Endpunkt/Auth +wie app/providers/llm/openrouter.py, nur ohne dessen Streaming/Persona-Ballast. +""" +import asyncio +import json +from dataclasses import dataclass, field + +import httpx + +ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" +_RETRY_STATUS = {429, 500, 502, 503} + + +@dataclass +class ToolCall: + """Ein einzelner Tool-Aufruf des Modells, inkl. Wohlgeformtheits-Urteil.""" + id: str + name: str + raw_arguments: str + arguments: dict = field(default_factory=dict) + well_formed: bool = False + + @classmethod + def parse(cls, tc: dict, expected_name: str = "web_search") -> "ToolCall": + fn = tc.get("function", {}) + raw = fn.get("arguments", "") or "" + try: + args = json.loads(raw) if raw else {} + except json.JSONDecodeError: + args = {} + well_formed = ( + fn.get("name") == expected_name + and isinstance(args, dict) + and bool(str(args.get("query", "")).strip()) + ) + return cls(id=tc.get("id", ""), name=fn.get("name", ""), + raw_arguments=raw, arguments=args, well_formed=well_formed) + + +@dataclass +class ModelTurn: + tool_calls: list[ToolCall] + text: str | None + finish_reason: str + raw_message: dict # Original-Assistant-Message zum Re-Threading in Pass 2 + + @property + def searched(self) -> bool: + return bool(self.tool_calls) + + @property + def query(self) -> str | None: + return self.tool_calls[0].arguments.get("query") if self.tool_calls else None + + +class ToolCallingClient: + def __init__(self, api_key: str, model: str, temperature: float = 0.3, + max_retries: int = 4): + self.api_key = (api_key or "").strip() + self.model = model.strip() + self.temperature = temperature + self.max_retries = max(1, max_retries) + + async def respond(self, messages: list[dict], tools: list[dict], + tool_choice: str = "auto") -> ModelTurn: + payload = { + "model": self.model, + "messages": messages, + "tools": tools, + "tool_choice": tool_choice, + "temperature": self.temperature, + } + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + headers = {"Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json"} + last_error: Exception | None = None + + for attempt in range(1, self.max_retries + 1): + async with httpx.AsyncClient(timeout=timeout) as c: + resp = await c.post(ENDPOINT, json=payload, headers=headers) + if resp.status_code in _RETRY_STATUS and attempt < self.max_retries: + last_error = RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:200]}") + await asyncio.sleep(min(2.0 * attempt, 30.0)) + continue + resp.raise_for_status() + data = resp.json() + # OpenRouter liefert transiente Provider-/Rate-Fehler teils als HTTP 200 + # mit {"error": ...} statt {"choices": ...} — als retrybar behandeln. + if "choices" not in data: + if attempt < self.max_retries: + last_error = RuntimeError(f"Antwort ohne 'choices': {str(data)[:200]}") + await asyncio.sleep(min(2.0 * attempt, 30.0)) + continue + raise RuntimeError(f"Antwort ohne 'choices' nach Retries: {str(data)[:300]}") + choice = data["choices"][0] + msg = choice["message"] + calls = [ToolCall.parse(tc) for tc in (msg.get("tool_calls") or [])] + return ModelTurn(tool_calls=calls, text=msg.get("content"), + finish_reason=choice.get("finish_reason", ""), + raw_message=msg) + + raise last_error or RuntimeError("OpenRouter: alle Versuche fehlgeschlagen") diff --git a/eval/tool_calling/run_eval.py b/eval/tool_calling/run_eval.py new file mode 100644 index 0000000..c73d7c9 --- /dev/null +++ b/eval/tool_calling/run_eval.py @@ -0,0 +1,161 @@ +"""Runner für den Tool-Calling-Eval. + +Lädt cases.yaml, fragt das Modell je Fall N× (Pass 1: Tool-Call-Entscheidung; +Pass 2 nur bei Vorrang-Fällen: injiziertes Ergebnis, finale Antwort prüfen), +und gibt einen Report aus, der die Weg-2-Tauglichkeit/Modellwahl entscheidet. + +Echte OpenRouter-Calls — gehört zur `make smoke`-Familie, NICHT in `make test`. + +Aufruf: + OPENROUTER_API_KEY=... python eval/tool_calling/run_eval.py [MODELL] [LÄUFE] +""" +import asyncio +import json +import os +import statistics +import sys +from collections import defaultdict +from datetime import date + +import yaml + +from client import ToolCallingClient +from tool import WEB_SEARCH_TOOL, build_messages + +HERE = os.path.dirname(os.path.abspath(__file__)) +CASES_PATH = os.path.join(HERE, "cases.yaml") + +# Wissens-Cutoff des ANTWORTENDEN Modells (nicht des Klassifikators) + heutiges Datum. +CUTOFF = "fall 2024" +TODAY = date.today().isoformat() + +# Tauglichkeits-Schwellen (siehe Entscheidungsregel im Plan). +THRESHOLDS = {"recall": 0.95, "precision": 0.80, "well_formed": 0.99, "priority": 0.95} + +# Marker, die das verbotene (veraltete) Faktum als VERGANGENHEIT ausweisen. +# Erwähnt das Modell den alten Wert nur historisch ("früher war ..."), ist das +# kein Vorrang-Fehler — es folgt ja dem Tool-Ergebnis. +_PAST_MARKERS = ( + "früher", "ehemalig", "vorher", "davor", "zuvor", "bis ", " war ", "wurde ", + "previously", "former", "used to", "until ", "no longer", "past", +) + + +def priority_ok(answer: str, expect_fact: str, forbid_fact: str) -> bool: + """Folgt die Antwort dem Tool? Aktuelles Faktum da; veraltetes höchstens historisch.""" + low = answer.lower() + if expect_fact.lower() not in low: + return False + if forbid_fact.lower() not in low: + return True + return any(m in low for m in _PAST_MARKERS) + + +async def run_case(client: ToolCallingClient, case: dict, runs: int, + sem: asyncio.Semaphore) -> dict: + """Führt einen Fall N× aus und sammelt die Roh-Ergebnisse je Lauf.""" + base = build_messages(case, CUTOFF, TODAY) + laeufe = [] + for _ in range(runs): + try: + async with sem: + t1 = await client.respond(base, [WEB_SEARCH_TOOL]) + except Exception as exc: + # Ein einzelner fehlerhafter Lauf darf nicht den ganzen Batch killen. + laeufe.append({"searched": None, "error": str(exc)[:200], + "query": None, "well_formed": None, "prio_ok": None, "answer": None}) + continue + rec = { + "searched": t1.searched, + "query": t1.query, + "well_formed": all(tc.well_formed for tc in t1.tool_calls) if t1.searched else None, + "prio_ok": None, + "answer": None, + } + # Pass 2: nur bei Vorrang-Fällen und nur wenn überhaupt gesucht wurde. + if case.get("injected_result") and t1.searched: + tc = t1.tool_calls[0] + msgs = base + [t1.raw_message, { + "role": "tool", + "tool_call_id": tc.id, + "content": case["injected_result"], + }] + async with sem: + t2 = await client.respond(msgs, [WEB_SEARCH_TOOL], tool_choice="none") + ans = (t2.text or "") + rec["answer"] = ans + rec["prio_ok"] = priority_ok(ans, case["expect_fact"], case["forbid_fact"]) + laeufe.append(rec) + return {"case": case, "runs": laeufe} + + +async def main(model: str, runs: int, conc: int = 4) -> None: + cases = yaml.safe_load(open(CASES_PATH, encoding="utf-8")) + key = os.environ.get("OPENROUTER_API_KEY") + if not key: + sys.exit("OPENROUTER_API_KEY fehlt in der Umgebung.") + client = ToolCallingClient(key, model) + sem = asyncio.Semaphore(conc) + results = await asyncio.gather(*(run_case(client, c, runs, sem) for c in cases)) + report(model, runs, results) + dump_path = os.path.join(HERE, f"results_{model.replace('/', '_')}.json") + with open(dump_path, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"\nRoh-Ergebnisse: {dump_path}") + + +def report(model: str, runs: int, results: list[dict]) -> None: + recall, precision, well_formed, priority = [], [], [], [] + per_cat: dict[str, list[float]] = defaultdict(list) + offenders: list[tuple[str, str, float]] = [] + + errored = 0 + for res in results: + case, laeufe = res["case"], res["runs"] + want_search = case["expect"] == "search" + valid = [r for r in laeufe if r["searched"] is not None] + errored += len(laeufe) - len(valid) + if not valid: + continue # ganzer Fall fehlerhaft -> nicht werten + searched_rate = statistics.mean(r["searched"] for r in valid) + correct_rate = searched_rate if want_search else 1.0 - searched_rate + (recall if want_search else precision).append(correct_rate) + per_cat[case["category"]].append(correct_rate) + + wfs = [r["well_formed"] for r in laeufe if r["well_formed"] is not None] + if wfs: + well_formed.append(statistics.mean(wfs)) + prios = [r["prio_ok"] for r in laeufe if r["prio_ok"] is not None] + if prios: + priority.append(statistics.mean(prios)) + if correct_rate < 0.8: + offenders.append((case["id"], case["category"], round(correct_rate, 2))) + + def pct(xs: list[float]) -> str: + return f"{100 * statistics.mean(xs):.0f}%" if xs else "n/a" + + def flag(xs: list[float], key: str) -> str: + if not xs: + return "" + return " OK" if statistics.mean(xs) >= THRESHOLDS[key] else " ⚠" + + print(f"\n=== {model} ({runs} Läufe/Fall, heute={TODAY}) ===") + if errored: + print(f"(Hinweis: {errored} Einzelläufe mit Fehler übersprungen)") + print(f"Trigger-Recall (muss-suchen) {pct(recall):>5}{flag(recall, 'recall')} (Ziel ≥95%)") + print(f"Trigger-Precision (nicht-suchen) {pct(precision):>5}{flag(precision, 'precision')} (Ziel ≥80%)") + print(f"Wohlgeformtheit {pct(well_formed):>5}{flag(well_formed, 'well_formed')} (Ziel ≥99%)") + print(f"Vorrang-Treue {pct(priority):>5}{flag(priority, 'priority')} (Ziel ≥95%)") + print("\npro Kategorie:") + for cat, xs in sorted(per_cat.items()): + print(f" {cat:26} {pct(xs)}") + if offenders: + print("\nSchwächste Fälle (<80%):") + for oid, cat, rate in sorted(offenders, key=lambda x: x[2]): + print(f" {oid:24} {cat:26} {rate}") + + +if __name__ == "__main__": + arg_model = sys.argv[1] if len(sys.argv) > 1 else "deepseek/deepseek-v4-flash" + arg_runs = int(sys.argv[2]) if len(sys.argv) > 2 else 5 + asyncio.run(main(arg_model, arg_runs)) diff --git a/eval/tool_calling/tool.py b/eval/tool_calling/tool.py new file mode 100644 index 0000000..e6ead4b --- /dev/null +++ b/eval/tool_calling/tool.py @@ -0,0 +1,58 @@ +"""web_search-Tool-Definition + Trigger-Prompt. + +Der Beschreibungs-/Prompt-Text ist derselbe kalibrierte „braucht das Wissen +neuer als dein Cutoff"-Text, der später produktiv die Trigger-Leitlinie bildet. +Der Harness misst damit Modell UND Prompt gemeinsam. +""" + +WEB_SEARCH_TOOL = { + "type": "function", + "function": { + "name": "web_search", + "description": ( + "Look up real-world information that may be newer than your " + "knowledge or may have changed since your last update. Call it " + "whenever the true answer could plausibly have changed, INCLUDING " + "when no exact place or date is named. This covers: who currently " + "holds an office; where a living person now lives; whether someone " + "is still alive; current prices, rates or crypto; current weather " + "or outdoor conditions (even phrased as 'is it cold/raining right " + "now', using the user's location); sports results and standings; " + "the latest version or model of a product; recent news; and " + "time-sensitive logistics such as opening hours, schedules, and " + "public-transport or train/bus departure times. Do NOT use it for " + "timeless knowledge, opinions, jokes, small talk, the current " + "clock time or today's date (you already have those), anything " + "you can derive yourself, or anything about the user themselves." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Concise search query, in the user's language or English.", + }, + }, + "required": ["query"], + }, + }, +} + +SYSTEM_PROMPT = ( + "You are a voice assistant. Your knowledge was last updated around " + "{cutoff}. Today is {today}. When a question could require information " + "newer than {cutoff} that you cannot reason out yourself, call web_search " + "before answering. Never call web_search merely to find the current time " + "or today's date — you already have them. Tool results are authoritative " + "and newer than your memory: if a tool result conflicts with what you " + "believe, follow the tool and never contradict it." +) + + +def build_messages(case: dict, cutoff: str, today: str) -> list[dict]: + """Baut die Message-Liste: System-Prompt + (optionale) History + Nutzeräußerung.""" + msgs = [{"role": "system", + "content": SYSTEM_PROMPT.format(cutoff=cutoff, today=today)}] + msgs += case.get("history", []) + msgs.append({"role": "user", "content": case["utterance"]}) + return msgs From c36b884aabf0b02cb9dafe20077d51bc54f22066 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 21:43:24 +0200 Subject: [PATCH 2/8] feat(llm): Koreferenz-Vorstufe + Tool-/Sonar-Metriken (Weg 2, Schritt 5) Schliesst die im Tool-Calling-Eval isolierte Restkante (nl + Pronomen-aus- History, z. B. "Leeft hij nog?" -> "Leeft Rutger Hauer nog?") und macht die Web-Suche metrisch beobachtbar. - Decontextualizer (app/pipeline/decontextualizer.py): loest Pronomen der letzten Aeusserung anhand des Verlaufs auf. Bewusst gegated (kurze Folgefrage MIT Pronomen UND History) -> kein Extra-Call im Normalfall; best effort (bei Fehler Original behalten). - ToolCallingLLM nutzt die Vorstufe vor complete()/stream() und zaehlt tool_calls_total{tool=...}. - SonarTool zaehlt sonar_calls_total{status=ok|error}. - Verdrahtung: Registry openrouter-tools reicht den Decontextualizer durch. Damit ist v1 von Weg 2 vollstaendig. Tests: 290 gruen. Co-Authored-By: Claude Opus 4.8 --- app/dependencies.py | 2 + app/pipeline/decontextualizer.py | 84 +++++++++++++++++++++++++++++++ app/providers/llm/tool_calling.py | 14 +++++- app/tools/web_search.py | 6 +++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 app/pipeline/decontextualizer.py diff --git a/app/dependencies.py b/app/dependencies.py index 7d3cb4e..f4df509 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -28,6 +28,7 @@ from app.providers.fallback import ( FallbackLLMProvider, FallbackTTSProvider, ) +from app.pipeline.decontextualizer import Decontextualizer from app.pipeline.input_cleaner import InputCleaner from app.pipeline.spoken_response_adapter import SpokenResponseAdapter from app.pipeline.tts_normalizer import TTSNormalizer @@ -63,6 +64,7 @@ LLM_REGISTRY = { s.openrouter_api_key, s.openrouter_llm_model, tools=[SonarTool(s.openrouter_api_key)], + decontextualizer=Decontextualizer(s.openrouter_api_key, s.openrouter_llm_model), ), "local-openai-compatible": lambda s: LocalOpenAICompatibleLLM( s.local_llm_base_url, diff --git a/app/pipeline/decontextualizer.py b/app/pipeline/decontextualizer.py new file mode 100644 index 0000000..e507f1f --- /dev/null +++ b/app/pipeline/decontextualizer.py @@ -0,0 +1,84 @@ +"""Koreferenz-Vorstufe: löst Pronomen der letzten Äußerung anhand des Verlaufs auf. + +Schließt die im Tool-Calling-Eval isolierte Restkante (nl + Pronomen-aus-History, +z. B. „Leeft hij nog?" → „Leeft Rutger Hauer nog?"). Bewusst **gegated** (kurze +Folgefrage MIT Pronomen UND vorhandener History), damit nicht jeder Turn einen +Extra-Call kostet. Siehe Docs/weg2-tool-calling.md §5.3. +""" +import logging + +import httpx + +logger = logging.getLogger(__name__) + +ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" + +# Pronomen/Referenzwörter (lowercase, mehrsprachig) für die Gate-Heuristik. +_PRONOUNS = { + "er", "sie", "es", "der", "die", "das", "den", "dem", "deren", "dessen", + "ihn", "ihm", "ihr", # de + "he", "she", "it", "they", "him", "her", "them", "that", "those", "these", # en + "hij", "ze", "zij", "het", "die", "dat", "hem", "haar", "hen", "hun", # nl + "il", "elle", "ils", "elles", "lui", "celui", "celle", # fr + "él", "ella", "ellos", "ese", "esa", "lei", "loro", "quello", # es/it +} +_MAX_WORDS = 8 + +_SYSTEM = ( + "You are a coreference resolver. Given a short conversation and the user's " + "latest message, output ONLY that latest message rewritten so it stands on " + "its own: resolve pronouns and references to the concrete names or entities " + "mentioned earlier. Keep the original language and meaning. Do NOT answer it; " + "only rewrite. If it is already self-contained, output it unchanged." +) + + +def _words(text: str) -> list[str]: + return [w for w in "".join(c.lower() if (c.isalpha() or c == " ") else " " + for c in text).split() if w] + + +class Decontextualizer: + def __init__(self, api_key: str, model: str, timeout: float = 15.0): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + self.timeout = timeout + + def _gated(self, text: str, history) -> bool: + """Nur kurze Folgefragen mit Pronomen und vorhandener History.""" + if not history or not text: + return False + words = _words(text) + if not words or len(words) > _MAX_WORDS: + return False + return any(w in _PRONOUNS for w in words) + + def _render(self, history: list[dict]) -> str: + lines = [] + for m in history[-6:]: + who = "User" if m.get("role") == "user" else "Assistant" + lines.append(f"{who}: {m.get('content', '')}") + return "\n".join(lines) + + async def run(self, text: str, history: list[dict] | None = None, + language: str | None = None) -> str: + if not self.api_key or not self._gated(text, history): + return text + user = (f"Conversation:\n{self._render(history)}\n\n" + f"Latest message: {text}\n\nRewritten self-contained message:") + payload = {"model": self.model, "temperature": 0.0, + "messages": [{"role": "system", "content": _SYSTEM}, + {"role": "user", "content": user}]} + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client: + resp = await client.post(ENDPOINT, json=payload, headers=headers) + resp.raise_for_status() + out = (resp.json()["choices"][0]["message"]["content"] or "").strip() + except Exception as exc: # noqa: BLE001 — best effort, bei Fehler Original behalten + logger.warning("Decontextualizer fehlgeschlagen: %s", exc) + return text + # Schutz vor Ausreißern (Erklärungen statt Rewrite): nur Plausibles übernehmen. + if not out or len(out) > len(text) + 200: + return text + return out.strip().strip('"') diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 3d52f92..b3d90ab 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -17,6 +17,7 @@ from datetime import date import httpx +from app.metrics import metrics from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder from app.providers.llm.openrouter import SYSTEM_PROMPT as PERSONA_PROMPT @@ -126,7 +127,8 @@ class _StreamAcc: class ToolCallingLLM(LLMProvider): def __init__(self, api_key: str, model: str, tools: list, knowledge_cutoff: str = "fall 2024", max_rounds: int = 3, - max_retries: int = 4, temperature: float = 0.3): + max_retries: int = 4, temperature: float = 0.3, + decontextualizer=None): self.api_key = (api_key or "").strip() self.model = (model or "").strip() self.tools = {t.name: t for t in tools} @@ -134,6 +136,13 @@ class ToolCallingLLM(LLMProvider): self.max_rounds = max(1, max_rounds) self.max_retries = max(1, max_retries) self.temperature = temperature + self.decontextualizer = decontextualizer + + async def _resolve_text(self, text: str, history, language) -> str: + """Koreferenz-Vorstufe (gegated) — löst Pronomen aus der History auf.""" + if self.decontextualizer is not None and history: + return await self.decontextualizer.run(text, history, language) + return text def _initial_messages(self, text: str, history, language) -> list[dict]: if not self.api_key: @@ -181,6 +190,7 @@ class ToolCallingLLM(LLMProvider): """Führt einen Tool-Aufruf aus und liefert den tool-Message-Inhalt.""" fn = tool_call.get("function", {}) name = fn.get("name", "") + metrics.inc("tool_calls_total", {"tool": name or "unknown"}) tool = self.tools.get(name) if tool is None: logger.warning("Unbekanntes Tool angefragt: %r", name) @@ -196,6 +206,7 @@ class ToolCallingLLM(LLMProvider): async def complete(self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None) -> str: + text = await self._resolve_text(text, history, language) messages = self._initial_messages(text, history, language) for _ in range(self.max_rounds): @@ -228,6 +239,7 @@ class ToolCallingLLM(LLMProvider): durch. `on_tool_start(language)` (optional) feuert, bevor ein Tool läuft — Aufhänger für den ephemeren Filler (Schritt 4b). """ + text = await self._resolve_text(text, history, language) messages = self._initial_messages(text, history, language) for _ in range(self.max_rounds): diff --git a/app/tools/web_search.py b/app/tools/web_search.py index 2162674..fa1ca67 100644 --- a/app/tools/web_search.py +++ b/app/tools/web_search.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field import httpx +from app.metrics import metrics from app.providers.llm.base import lang_instruction logger = logging.getLogger(__name__) @@ -58,6 +59,11 @@ class SonarTool: {"role": "user", "content": query.strip()}] async def run(self, query: str, language: str | None = None) -> ToolResult: + result = await self._search(query, language) + metrics.inc("sonar_calls_total", {"status": "ok" if result.ok else "error"}) + return result + + async def _search(self, query: str, language: str | None = None) -> ToolResult: if not query or not query.strip(): return ToolResult(text=_NO_DATA, ok=False) if not self.api_key: From 96dcd87ca82786ba0c57b7b9b08604ca8c2a423a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 22:34:15 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(fillers):=20zuf=C3=A4llige,=20=C3=BCbe?= =?UTF-8?q?rsetzte=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"]) From 6cd6e783aa153a1cfb2cd9d1ca81abe3b6926c4d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 22:58:03 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(search):=20deterministischer=20Backsto?= =?UTF-8?q?p=20f=C3=BCr=20heikle=20Kategorien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schliesst die ~5-15 % Erst-Miss bei Fragen, deren veraltete Antwort konfident-falsch waere (Amtstraeger, Wohnort lebender Personen, "lebt X noch"). - app/pipeline/search_backstop.py: should_force_search() erkennt solche Phrasierungen per mehrsprachiger Regex (de/en/nl + etwas fr/es) mit Gegenwarts-Schutz ("wer ist", nicht "wer war"). 22 Offline-Tests. - ToolCallingLLM: bei Treffer wird die Suche DETERMINISTISCH erzwungen. Wichtig: NICHT per tool_choice-Forcen (das honoriert das Modell nicht zuverlaessig -> beobachtet: ignoriert, veraltete Antwort). Stattdessen fuehrt der Wrapper die Suche selbst aus und speist das Ergebnis als synthetischen Tool-Turn ein (_inject_forced_search). Metrik search_forced_total. Filler/Geduld feuern auch hier. - Haertung: _chat faengt non-JSON-200 ab (war ein Crash-/Hänger-Ausloeser, betrifft auch Produktion). Live verifiziert: "Wer ist Bundeskanzler?" sucht jetzt 3/3 -> Merz. Tests: 312 gruen. Doc: §5.7. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 15 +++++++++ app/pipeline/search_backstop.py | 52 +++++++++++++++++++++++++++++++ app/providers/llm/tool_calling.py | 41 ++++++++++++++++++++---- tests/test_search_backstop.py | 46 +++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/pipeline/search_backstop.py create mode 100644 tests/test_search_backstop.py diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 611dbd3..67730e9 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -122,6 +122,21 @@ separat zählen — Kostensicht **und** Live-Beobachtung der Trigger-Rate. **Kein** eigenes Sonar-Kontingent in v1 (erst Metrik-Sicht; `quota.py`-Anbindung später bei Bedarf). +### 5.7 Such-Backstop (heikle Kategorien) +Das Modell self-triggert zu ~93 %; die Misses liegen in den Kategorien, in denen +eine veraltete Erstantwort **konfident-falsch** ist (Amtsträger, Wohnort lebender +Personen, „lebt X noch"). `app/pipeline/search_backstop.py` erkennt solche +Phrasierungen per mehrsprachiger Regex (`should_force_search`, Gegenwarts-Schutz: +„wer **ist**", nicht „wer **war**" → historische Fragen triggern nicht; offline +getestet). Bei Treffer erzwingt `ToolCallingLLM` die Suche **deterministisch**. + +**Wichtige Designentscheidung:** *Nicht* per `tool_choice`-Forcen — das honoriert +das Modell nicht zuverlässig (beobachtet: forced choice ignoriert → veraltete +Antwort). Stattdessen führt der Wrapper die Suche **selbst direkt** aus und speist +das Ergebnis als synthetischen Tool-Turn ein (`_inject_forced_search`); das Modell +formuliert daraus. Metrik: `search_forced_total`. Recall-optimiert (lieber eine +überflüssige Suche als eine konfident falsche Antwort). + ## 6. Die zwei harten Stellen (bewusst benannt) - **Streaming + Tool-Erkennung.** Beim gestreamten ersten Call kommen diff --git a/app/pipeline/search_backstop.py b/app/pipeline/search_backstop.py new file mode 100644 index 0000000..8e27fa4 --- /dev/null +++ b/app/pipeline/search_backstop.py @@ -0,0 +1,52 @@ +"""Deterministischer Such-Backstop für die heiklen Kategorien. + +Das Modell self-triggert die Web-Suche im Eval zu ~93 %. Die verbleibenden Misses +liegen ausgerechnet in den Kategorien, in denen eine veraltete Erstantwort +**konfident-falsch** ist (Amtsträger, Wohnort lebender Personen, „lebt X noch"). +Für klar erkennbare Phrasierungen dieser Kategorien erzwingt der Backstop den +web_search-Aufruf (`tool_choice`) — deterministisch, ohne Zusatz-Call. + +Bewusst auf Recall optimiert (lieber eine überflüssige Suche als eine konfident +falsche Antwort); gelegentliches Über-Triggern ist akzeptabel. Mehrsprachig +(de/en/nl im Kern, etwas fr/es). Offline testbar (reine Regex). +""" +import re + +# Amts-/Rollenbegriffe, deren Inhaber wechseln. +_OFFICE = ( + r"(?:bundeskanzler(?:in)?|kanzler(?:in)?|pr[äa]sident(?:in)?|president|papst|pope|" + r"ministerpr[äa]sident(?:in)?|minister-?president|premierminister|premier|" + r"regierungschef(?:in)?|regeringsleider|kanselier|au[ßs]enminister(?:in)?|" + r"k[öo]nig(?:in)?|queen|king|kaiser|paus|premier ?minister)" +) +# Gegenwarts-/Aktualitäts-Marker. +_NOW = r"(?:aktuell\w*|derzeit\w*|momentan|heute|jetzt|gerade|amtierend\w*|"\ + r"current\w*|now|tegenwoordig|nu|huidige|actuel\w*|actual\w*)" + +_PATTERNS = [ + # Amtsträger: "wer ist ", "aktueller ", " ... aktuell/jetzt". + # Bewusst Gegenwart (wer IST, nicht wer WAR) -> historische Fragen triggern nicht. + re.compile(rf"\b(?:wer (?:ist|sind)|who (?:is|are)|wie hei[sß]t|wie is|wie heet)\b" + rf".{{0,30}}\b{_OFFICE}\b", re.I), + re.compile(rf"\b{_NOW}\s+{_OFFICE}\b", re.I), + re.compile(rf"\b{_OFFICE}\b.{{0,30}}\b{_NOW}\b", re.I), + # Wohnort lebender Person + re.compile(r"\bwo\s+(?:wohnt|lebt|residiert)\b", re.I), + re.compile(r"\bwhere\s+(?:does|do|is|are)\b.{0,40}\blive[sd]?\b", re.I), + re.compile(r"\bwaar\s+woont\b", re.I), + re.compile(r"\bo[ùu]\s+(?:habite|vit)\b", re.I), + re.compile(r"\bd[óo]nde\s+vive\b", re.I), + # Lebt X noch / still alive + re.compile(r"\b(?:lebt|leeft)\b.{0,40}\b(?:noch|nog)\b", re.I), + re.compile(r"\bnoch\s+am\s+leben\b", re.I), + re.compile(r"\bstill\s+alive\b", re.I), + re.compile(r"\b(?:encore|toujours)\s+(?:en\s+vie|vivant)\b", re.I), + re.compile(r"\b(?:sigue|todav[íi]a)\b.{0,20}\bviv[oa]\b", re.I), +] + + +def should_force_search(text: str | None) -> bool: + """True, wenn der Text klar eine heikle, veränderliche Tatsache abfragt.""" + if not text: + return False + return any(p.search(text) for p in _PATTERNS) diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index b3d90ab..c665638 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -18,6 +18,7 @@ from datetime import date import httpx from app.metrics import metrics +from app.pipeline.search_backstop import should_force_search from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder from app.providers.llm.openrouter import SYSTEM_PROMPT as PERSONA_PROMPT @@ -175,13 +176,18 @@ class ToolCallingLLM(LLMProvider): await asyncio.sleep(min(2.0 * attempt, 30.0)) continue resp.raise_for_status() - data = resp.json() - if "choices" not in data: # transiente Fehler kommen teils als 200 mit {"error":...} + try: + data = resp.json() + except Exception: # noqa: BLE001 — non-JSON-200 kommt transient vor + data = None + # Transiente Fehler kommen teils als HTTP 200 mit {"error":...} oder + # gar nicht-JSON-Body -> als retrybar behandeln. + if not isinstance(data, dict) or "choices" not in data: if attempt < self.max_retries: - last_error = RuntimeError(f"Antwort ohne 'choices': {str(data)[:200]}") + last_error = RuntimeError(f"Ungültige/leere Antwort: {resp.text[:200]}") await asyncio.sleep(min(2.0 * attempt, 30.0)) continue - raise RuntimeError(f"OpenRouter-Antwort ohne 'choices': {str(data)[:300]}") + raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[:300]}") return data["choices"][0]["message"] raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") @@ -204,12 +210,30 @@ class ToolCallingLLM(LLMProvider): # result.citations -> UI-Bubble (Fast-follow); hier (noch) nicht durchgereicht. return result.text + async def _inject_forced_search(self, messages: list[dict], query: str, + language: str | None) -> None: + """Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das + Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als + `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).""" + tool = self.tools.get("web_search") + if tool is None: + return + metrics.inc("tool_calls_total", {"tool": "web_search"}) + result = await tool.run(query, language=language) + messages.append({"role": "assistant", "content": None, "tool_calls": [ + {"id": "forced_0", "type": "function", + "function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]}) + messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text}) + async def complete(self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None) -> str: text = await self._resolve_text(text, history, language) messages = self._initial_messages(text, history, language) + if should_force_search(text): + metrics.inc("search_forced_total") + await self._inject_forced_search(messages, text, language) - for _ in range(self.max_rounds): + for _round in range(self.max_rounds): msg = await self._chat(messages, allow_tools=True) tool_calls = msg.get("tool_calls") if not tool_calls: @@ -241,8 +265,13 @@ class ToolCallingLLM(LLMProvider): """ text = await self._resolve_text(text, history, language) messages = self._initial_messages(text, history, language) + if should_force_search(text): + metrics.inc("search_forced_total") + if on_tool_start is not None: + await on_tool_start(language) # Filler deckt die erzwungene Suche + await self._inject_forced_search(messages, text, language) - for _ in range(self.max_rounds): + for _round in range(self.max_rounds): acc = _StreamAcc() async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): yield piece diff --git a/tests/test_search_backstop.py b/tests/test_search_backstop.py new file mode 100644 index 0000000..b559d69 --- /dev/null +++ b/tests/test_search_backstop.py @@ -0,0 +1,46 @@ +"""Offline-Tests für den deterministischen Such-Backstop (heikle Kategorien).""" +import pytest + +from app.pipeline.search_backstop import should_force_search + +FORCE = [ + "Wer ist Bundeskanzler?", + "Wer ist aktuell Bundeskanzler?", + "Who is the president of the United States?", + "Wie heißt der Papst?", + "Wie is de huidige minister-president van Nederland?", + "Wer ist gerade Papst?", + "Wo wohnt Heino?", + "Wo lebt Heino heute?", + "Where does Madonna live these days?", + "Waar woont André Rieu tegenwoordig?", + "Lebt Heino noch?", + "Is Brigitte Bardot still alive?", + "Leeft Willeke Alberti nog?", +] + +NO_FORCE = [ + "Was ist die Hauptstadt von Frankreich?", + "Erzähl mir einen Witz.", + "Wie alt ist Heino?", + "Wer war der erste Bundeskanzler?", # historisch (Vergangenheit) + "Wann hat Heino Geburtstag?", + "Wie geht es dir heute?", + "Kannst du mir ein Rezept für Kartoffelsuppe geben?", + "Wer hat den Faust geschrieben?", +] + + +@pytest.mark.parametrize("text", FORCE) +def test_forces_on_heikle_kategorien(text): + assert should_force_search(text) is True, text + + +@pytest.mark.parametrize("text", NO_FORCE) +def test_no_force_on_harmless(text): + assert should_force_search(text) is False, text + + +def test_empty_and_none(): + assert should_force_search(None) is False + assert should_force_search("") is False From fdf65e8623673324a4d340ec72749a795542fc96 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 23:08:48 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(fillers):=20Beruhigungss=C3=A4tze=20au?= =?UTF-8?q?ch=20im=20Ger=C3=A4te-TTS-Modus=20sprechen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wurde der Filler im "Im Gerät"-Modus (Web Speech API) nur angezeigt, aber nicht gesprochen (Server-Audio ist dort aus). Jetzt eigener Kanal: - Orchestrator: chat_stream bekommt on_filler; _emit_filler bevorzugt den dedizierten Filler-Kanal (sonst Fallback on_token). Ephemer wie bisher. - ws.py: on_filler -> {type:"filler", text}. - Frontend (app.js): {type:"filler"} -> transient in der STATUS-Zeile (nicht in der Antwort-Bubble) und im Geräte-Modus sofort speechSynthesis.speak() OHNE cancel (mehrere Filler -> Queue; die Antwort cancelt spaeter und uebernimmt). Abgesichert ueber deviceVoicesReady()/deviceVoiceReady(lang) — keine Verschaerfung der bekannten deviceVoiceReady-Regression. Server-TTS-Modus unveraendert (Filler zusaetzlich als "Satz null" gesprochen), zeigt den Filler nun ebenfalls in der Status-Zeile statt in der Bubble. Tests: 312 gruen; JS-Syntax geprueft. Doc §5.4. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 15 +++++++++++---- app/api/ws.py | 7 +++++++ app/core/orchestrator.py | 11 ++++++++--- app/web/app.js | 10 ++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 67730e9..bb04e81 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -102,10 +102,17 @@ Fallback Englisch. `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). +**Ephemeralität (Invariante):** Filler laufen über den `on_filler`-Callback, nicht +über den Delta-Stream — sie landen **nicht** in `trace.semantic_response` und +**nicht** im gespeicherten History-Turn. + +**Kanal:** Der Orchestrator emittiert Filler über `on_filler`; die WS-Schicht sendet +`{type:"filler", text}`. Das Frontend zeigt ihn **transient in der Status-Zeile** +(nicht in der Antwort-Bubble) und spricht ihn im **Geräte-TTS-Modus** sofort per +`speechSynthesis.speak()` **ohne `cancel`** (mehrere Filler laufen in die Queue; die +Antwort `cancelt` später und übernimmt) — abgesichert über `deviceVoicesReady()`/ +`deviceVoiceReady(lang)` (keine Verschärfung der bekannten Regression). Im Server-TTS- +Modus wird der Filler zusätzlich als „Satz null" über die Chunk-Queue gesprochen. ### 5.5 Konfiguration & Opt-out `web_search_enabled: bool = True` — **global an per Default**, pro Nutzer/Profil diff --git a/app/api/ws.py b/app/api/ws.py index ebdb075..8201c8d 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -136,6 +136,12 @@ async def _run_turn( audio_seq += 1 await websocket.send_bytes(chunk) + # Filler (Beruhigungs-/Geduldssätze) als eigener Event: Status-Zeile + Geräte-TTS. + on_filler = None + if stream or audio_stream: + async def on_filler(filler_text): + await websocket.send_json({"type": "filler", "text": filler_text}) + try: if stream or audio_stream: trace, audio = await orchestrator.chat_stream( @@ -146,6 +152,7 @@ async def _run_turn( history=llm_context, on_token=on_token, on_audio=on_audio, + on_filler=on_filler, text_only=text_only, ) else: diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index a315f51..871a156 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -187,6 +187,7 @@ class Orchestrator: history: list[dict] | None = None, on_token=None, on_audio=None, + on_filler=None, text_only: bool = False, ): """Wie chat_text, aber gestreamt. @@ -249,12 +250,16 @@ class Orchestrator: patience_task: asyncio.Task | None = None async def _emit_filler(phrase: str) -> None: - """Ephemer: über on_token (Anzeige) + _dispatch (Server-TTS, "Satz null"). + """Ephemer: dedizierter Filler-Kanal (Anzeige/Status + Geräte-TTS) + + _dispatch (Server-TTS, "Satz null"). Läuft bewusst NICHT über den Token-Stream -> nie in parts/ - semantic_response/History. + semantic_response/History. on_filler bevorzugt (eigener Event, damit + Geräte-TTS ihn sprechen kann); sonst Fallback auf on_token (Anzeige). """ - if on_token: + if on_filler is not None: + await on_filler(phrase) + elif on_token is not None: await on_token(phrase + " ") if chunker is not None: await _dispatch(phrase) diff --git a/app/web/app.js b/app/web/app.js index 45ae652..2b26ae0 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -679,6 +679,16 @@ function runTurn(path, onopen) { case "ack": routeLang = (msg.route && msg.route.language) || null; break; + case "filler": + // Beruhigungs-/Geduldssatz: transient in der Status-Zeile (nicht in der + // Antwort-Bubble) und im Geräte-Modus sofort sprechen — OHNE cancel, damit + // mehrere Filler in die SpeechSynthesis-Queue laufen; die Antwort (semantic) + // canceled später und übernimmt. + if (msg.text) statusEl.textContent = msg.text; + if (msg.text && isDeviceMode() && deviceVoicesReady() && deviceVoiceReady(turnLang())) { + try { speechSynthesis.speak(_makeUtterance(msg.text, turnLang())); } catch (e) {} + } + break; case "audio": // Ankuendigung: der naechste Binaer-Frame ist ein satzweiser TTS-Chunk. expectChunk = true; From e753788a23596940a456e5b2904d6a15482f02ef Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 00:11:24 +0200 Subject: [PATCH 6/8] feat(citations): Web-Such-Quellen unter der Antwort-Bubble anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar liefert Citation-URLs; bisher verworfen, jetzt bis in die UI gereicht. - ToolCallingLLM: _run_tool/_inject_forced_search geben Citations zurueck; stream() sammelt sie ueber einen on_citations-Callback. - Orchestrator: chat_stream reicht on_citations durch (via _stream_supports) und legt die gesammelten URLs in PipelineTrace.citations. - schemas: PipelineTrace.citations. - ws.py: Citations im semantic-Event. - Frontend (app.js): renderCitations() zeigt bis zu 4 Quellen-Links (Hostname, neuer Tab) unter der Antwort-Bubble. Nur ueber den Streaming-Pfad (= Web-UI). Live verifiziert: "Wer ist Bundeskanzler?" -> 15 Quellen gesammelt (Bundeskanzler.de, Wikipedia, ...). Tests: 312 gruen; JS-Syntax geprueft. Doc §8. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 7 +++++-- app/api/ws.py | 3 ++- app/core/orchestrator.py | 10 ++++++++++ app/providers/llm/tool_calling.py | 32 +++++++++++++++++++------------ app/schemas.py | 1 + app/web/app.js | 27 ++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index bb04e81..3eed303 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -171,8 +171,11 @@ wird zur Tool-Rangordnung). Tool-Client) + Koreferenz-Vorstufe + Registry-Eintrag + `web_search_enabled` (global an, Opt-out) + Filler für **Server-TTS** + Tool/Sonar-Metriken. schedule_hours-honest-punt akzeptiert. -- **Fast-follow:** Filler für **Gerät-TTS** (Event-Protokoll) → Citations in die - UI-Bubble → ggf. deterministischer Logistik-Nudge. +- **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für + **Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations` + → `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort). +- **Fast-follow (offen):** „no-tool-endpoints"-404-Härtung (tool-unfähiges Modell + bewusst plain statt still stale), ggf. deterministischer Logistik-Nudge. - **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. diff --git a/app/api/ws.py b/app/api/ws.py index 8201c8d..db320df 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -176,7 +176,8 @@ async def _run_turn( maybe_schedule_extraction(store, user.id, session_id) await websocket.send_json( - {"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response} + {"type": "semantic", "text": trace.semantic_response, + "spoken": trace.spoken_response, "citations": trace.citations} ) # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). if not audio_stream and not text_only: diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index 871a156..c044be5 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -289,6 +289,13 @@ class Orchestrator: except (asyncio.CancelledError, Exception): pass + collected_citations: list[str] = [] + + async def _on_citations(urls) -> None: + for u in (urls or []): + if u not in collected_citations: + collected_citations.append(u) + if queue is not None: consumer_task = asyncio.create_task(_consume()) @@ -297,6 +304,8 @@ class Orchestrator: stream_kwargs = {"history": history, "language": effective_language} if _stream_supports(stream_fn, "on_tool_start"): stream_kwargs["on_tool_start"] = _on_tool_start + if _stream_supports(stream_fn, "on_citations"): + stream_kwargs["on_citations"] = _on_citations 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 @@ -318,6 +327,7 @@ class Orchestrator: trace.semantic_response = "".join(parts) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") + trace.citations = collected_citations trace.spoken_response = await self.spoken_adapter.run( trace.semantic_response, diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index c665638..4bc4ae8 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -192,38 +192,39 @@ class ToolCallingLLM(LLMProvider): raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") - async def _run_tool(self, tool_call: dict, language: str | None) -> str: - """Führt einen Tool-Aufruf aus und liefert den tool-Message-Inhalt.""" + async def _run_tool(self, tool_call: dict, language: str | None) -> tuple[str, list]: + """Führt einen Tool-Aufruf aus; liefert (tool-Message-Inhalt, Citations).""" fn = tool_call.get("function", {}) name = fn.get("name", "") metrics.inc("tool_calls_total", {"tool": name or "unknown"}) tool = self.tools.get(name) if tool is None: logger.warning("Unbekanntes Tool angefragt: %r", name) - return f"ERROR: unknown tool {name!r}." + return f"ERROR: unknown tool {name!r}.", [] try: args = json.loads(fn.get("arguments") or "{}") except json.JSONDecodeError: args = {} query = str(args.get("query", "")).strip() result = await tool.run(query, language=language) - # result.citations -> UI-Bubble (Fast-follow); hier (noch) nicht durchgereicht. - return result.text + return result.text, list(getattr(result, "citations", []) or []) async def _inject_forced_search(self, messages: list[dict], query: str, - language: str | None) -> None: + language: str | None) -> list: """Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als - `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).""" + `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig). + Liefert die Citations zurück.""" tool = self.tools.get("web_search") if tool is None: - return + return [] metrics.inc("tool_calls_total", {"tool": "web_search"}) result = await tool.run(query, language=language) messages.append({"role": "assistant", "content": None, "tool_calls": [ {"id": "forced_0", "type": "function", "function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]}) messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text}) + return list(getattr(result, "citations", []) or []) async def complete(self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None) -> str: @@ -243,7 +244,7 @@ class ToolCallingLLM(LLMProvider): break # leer ohne Tool-Call -> finale Runde erzwingen messages.append(msg) # Assistant-Message mit tool_calls (unverändert zurück) for tc in tool_calls: - result_text = await self._run_tool(tc, language) + result_text, _cites = await self._run_tool(tc, language) messages.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": result_text}) @@ -254,9 +255,14 @@ class ToolCallingLLM(LLMProvider): raise RuntimeError("ToolCallingLLM returned an empty response") return content + @staticmethod + async def _emit_citations(cites: list, on_citations) -> None: + if cites and on_citations is not None: + await on_citations(cites) + async def stream(self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, - on_tool_start=None, **kwargs) -> AsyncIterator[str]: + on_tool_start=None, on_citations=None, **kwargs) -> AsyncIterator[str]: """Gestreamter Loop: Text-Deltas durchreichen; bei tool_call Sonar+nächste Runde. Kein Latenz-Regress im Normalfall (kein Tool): die Antwort streamt direkt @@ -269,7 +275,8 @@ class ToolCallingLLM(LLMProvider): metrics.inc("search_forced_total") if on_tool_start is not None: await on_tool_start(language) # Filler deckt die erzwungene Suche - await self._inject_forced_search(messages, text, language) + cites = await self._inject_forced_search(messages, text, language) + await self._emit_citations(cites, on_citations) for _round in range(self.max_rounds): acc = _StreamAcc() @@ -281,7 +288,8 @@ class ToolCallingLLM(LLMProvider): await on_tool_start(language) # 4b: Filler sofort sprechen/anzeigen messages.append(acc.assistant_message()) for tc in acc.tool_calls: - result_text = await self._run_tool(tc, language) + result_text, cites = await self._run_tool(tc, language) + await self._emit_citations(cites, on_citations) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result_text}) diff --git a/app/schemas.py b/app/schemas.py index 4d3baa0..6a782e6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -34,6 +34,7 @@ class PipelineTrace(BaseModel): semantic_response: str | None = None spoken_response: str | None = None tts_ready_text: str | None = None + citations: list[str] = [] # Quellen-URLs der Web-Suche (für die UI-Bubble) class SpeakRequest(BaseModel): diff --git a/app/web/app.js b/app/web/app.js index 2b26ae0..f449547 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -513,6 +513,32 @@ function addMessage(role, text, meta = {}) { return div; } +// Quellen-Links der Web-Suche unter die Antwort-Bubble (max. 4, idempotent). +function renderCitations(bubble, urls) { + if (!bubble || !Array.isArray(urls) || !urls.length) return; + let box = bubble._citationsEl; + if (!box) { + box = document.createElement("div"); + box.className = "citations mt-1 text-xs text-slate-500 dark:text-slate-400 " + + "flex flex-wrap gap-x-2 gap-y-0.5"; + bubble._citationsEl = box; + bubble.appendChild(box); + } + box.innerHTML = ""; + const label = document.createElement("span"); + label.textContent = "Quellen:"; + box.appendChild(label); + urls.slice(0, 4).forEach((u) => { + let host; + try { host = new URL(u).hostname.replace(/^www\./, ""); } catch { host = u; } + const a = document.createElement("a"); + a.href = u; a.target = "_blank"; a.rel = "noopener noreferrer"; + a.textContent = host; + a.className = "underline hover:text-slate-700 dark:hover:text-white"; + box.appendChild(a); + }); +} + // ---------- Vorlesen / Replay ---------- function setReplayPlaying(btn, on) { if (!btn) return; @@ -704,6 +730,7 @@ function runTurn(path, onopen) { case "semantic": if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() }); if (msg.text) answerEl._textEl.textContent = msg.text; + if (msg.citations) renderCitations(answerEl, msg.citations); // Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen. // Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen // bleibt der Originaltext. From 48a79da12f5dcb2a3a1150e5f755eb0f211ea1c5 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 00:21:38 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(llm):=20H=C3=A4rtung=20gegen=20tool-un?= =?UTF-8?q?f=C3=A4higes=20Modell=20(no-tool-endpoints-404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waehlt jemand (Admin/Preset) ein Modell ohne Tool-faehigen Endpoint, lieferte OpenRouter ein 404 "No endpoints found that support tool use" -> bisher 4x Retry mit Backoff und danach stille, konfident-veraltete Fallback-Antwort. - ToolCallingLLM erkennt das strukturelle 404 (_looks_tool_unsupported), merkt es fuer den Turn (_tools_unsupported), deaktiviert Tools und antwortet SOFORT plain (kein Haenger). Log-Warnung + Metrik tool_unsupported_total. - Backstop-Inject jetzt als SYSTEM-Kontext statt synthetischem tool_call: das funktioniert mit tool-faehigen UND tool-unfaehigen Modellen (letztere lehnen tool-Messages mit 405 ab). Folge: heikle Kategorien bleiben auch auf einem tool-unfaehigen Modell korrekt; nur nicht-heikle Volatil-Fragen fallen plain. Live verifiziert gegen mistral-2501 (tool-unfaehig): 3.7s statt Haenger, Backstop liefert weiter "Merz"; gegen mistral-3.2 kein Regress (1 Suche). Tests: 312 gruen. Doc §5.7/§5.8/§8. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 22 ++++++++--- app/providers/llm/tool_calling.py | 65 +++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 3eed303..8e7679e 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -140,9 +140,21 @@ getestet). Bei Treffer erzwingt `ToolCallingLLM` die Suche **deterministisch**. **Wichtige Designentscheidung:** *Nicht* per `tool_choice`-Forcen — das honoriert das Modell nicht zuverlässig (beobachtet: forced choice ignoriert → veraltete Antwort). Stattdessen führt der Wrapper die Suche **selbst direkt** aus und speist -das Ergebnis als synthetischen Tool-Turn ein (`_inject_forced_search`); das Modell -formuliert daraus. Metrik: `search_forced_total`. Recall-optimiert (lieber eine -überflüssige Suche als eine konfident falsche Antwort). +das Ergebnis als **System-Kontext** ein (`_inject_forced_search`); das Modell +formuliert daraus. System-Kontext (statt synthetischem tool_call) funktioniert mit +tool-fähigen **und** tool-unfähigen Modellen (letztere lehnen tool-Messages mit 405 +ab). Metrik: `search_forced_total`. Recall-optimiert (lieber eine überflüssige +Suche als eine konfident falsche Antwort). + +### 5.8 Härtung: tool-unfähiges Modell +Wählt jemand (Admin/Preset) ein Modell ohne Tool-fähigen Endpoint, liefert +OpenRouter ein 404 „No endpoints found that support tool use". `ToolCallingLLM` +erkennt das (`_looks_tool_unsupported`), **merkt es für den Turn** (`_tools_unsupported`), +deaktiviert Tools und antwortet **sofort plain** (kein Retry-Hänger, keine stille +Stale-Antwort durch die Fallback-Kette). Log-Warnung + Metrik `tool_unsupported_total`. +Wichtig: Der **Backstop bleibt wirksam** (er speist Fakten als System-Kontext ein, +nicht als tool_call) → heikle Kategorien bleiben auch auf einem tool-unfähigen Modell +korrekt; nur nicht-heikle Volatil-Fragen (Wetter/Preise) fallen auf plain zurück. ## 6. Die zwei harten Stellen (bewusst benannt) @@ -174,8 +186,8 @@ wird zur Tool-Rangordnung). - **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für **Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations` → `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort). -- **Fast-follow (offen):** „no-tool-endpoints"-404-Härtung (tool-unfähiges Modell - bewusst plain statt still stale), ggf. deterministischer Logistik-Nudge. +- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8). +- **Fast-follow (offen):** ggf. deterministischer Logistik-Nudge (`schedule_hours`). - **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 4bc4ae8..27033cf 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -26,6 +26,16 @@ logger = logging.getLogger(__name__) ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" _RETRY_STATUS = {404, 429, 500, 502, 503} +# OpenRouter-404, wenn das Modell keinen Tool-fähigen Endpoint hat. +_TOOL_UNSUPPORTED_HINT = "support tool use" + + +class _ToolsUnsupported(Exception): + """Strukturelles 404: das gewählte Modell kann (über OpenRouter) kein Tool-Calling.""" + + +def _looks_tool_unsupported(status: int, body: str, has_tools: bool) -> bool: + return has_tools and status == 404 and _TOOL_UNSUPPORTED_HINT in (body or "").lower() # Tool-Schema inkl. Trigger-Kategorien — wortgleich zur im Eval validierten Fassung. WEB_SEARCH_TOOL = { @@ -138,6 +148,17 @@ class ToolCallingLLM(LLMProvider): self.max_retries = max(1, max_retries) self.temperature = temperature self.decontextualizer = decontextualizer + # Wird gesetzt, sobald das Modell ein "no tool endpoints"-404 liefert → + # für den Rest des Turns ohne Tools (kein Retry-Hänger, keine stille Stale). + self._tools_unsupported = False + + def _mark_tools_unsupported(self, body: str) -> None: + if not self._tools_unsupported: + logger.warning("Modell %r kann kein Tool-Calling → web_search für diesen Turn " + "deaktiviert (plain). Backstop nutzt weiter direkte Suche. %s", + self.model, (body or "")[:160]) + metrics.inc("tool_unsupported_total") + self._tools_unsupported = True async def _resolve_text(self, text: str, history, language) -> str: """Koreferenz-Vorstufe (gegated) — löst Pronomen aus der History auf.""" @@ -161,7 +182,7 @@ class ToolCallingLLM(LLMProvider): async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict: """Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls).""" payload = {"model": self.model, "messages": messages, "temperature": self.temperature} - if allow_tools: + if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -171,6 +192,13 @@ class ToolCallingLLM(LLMProvider): for attempt in range(1, self.max_retries + 1): async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(ENDPOINT, json=payload, headers=headers) + # Modell ohne Tool-Endpoint: einmalig merken, Tools entfernen, sofort + # ohne Tools erneut (kein Retry-Hänger, keine stille Stale). + if _looks_tool_unsupported(resp.status_code, resp.text, "tools" in payload): + self._mark_tools_unsupported(resp.text) + payload.pop("tools", None) + payload.pop("tool_choice", None) + continue if resp.status_code in _RETRY_STATUS and attempt < self.max_retries: last_error = RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:200]}") await asyncio.sleep(min(2.0 * attempt, 30.0)) @@ -212,7 +240,9 @@ class ToolCallingLLM(LLMProvider): async def _inject_forced_search(self, messages: list[dict], query: str, language: str | None) -> list: """Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das - Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als + Ergebnis als **System-Kontext** einspeisen. Bewusst KEIN synthetischer + tool_call: System-Kontext funktioniert mit tool-fähigen UND tool-unfähigen + Modellen (letztere lehnen tool-Messages mit 405 ab). Verlässlicher als `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig). Liefert die Citations zurück.""" tool = self.tools.get("web_search") @@ -220,10 +250,12 @@ class ToolCallingLLM(LLMProvider): return [] metrics.inc("tool_calls_total", {"tool": "web_search"}) result = await tool.run(query, language=language) - messages.append({"role": "assistant", "content": None, "tool_calls": [ - {"id": "forced_0", "type": "function", - "function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]}) - messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text}) + messages.append({ + "role": "system", + "content": ("Aktuelle Web-Suchergebnisse zur Nutzerfrage (maßgeblich, neuer als " + "dein Trainingswissen):\n" + result.text + + "\n\nBeantworte die Frage des Nutzers auf Basis dieser Ergebnisse."), + }) return list(getattr(result, "citations", []) or []) async def complete(self, text: str, history: list[dict] | None = None, @@ -280,8 +312,14 @@ class ToolCallingLLM(LLMProvider): for _round in range(self.max_rounds): acc = _StreamAcc() - async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): - yield piece + try: + async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): + yield piece + except _ToolsUnsupported: + # Modell ohne Tool-Endpoint -> Runde plain (ohne Tools) wiederholen. + acc = _StreamAcc() + async for piece in self._stream_chat(messages, allow_tools=False, acc=acc): + yield piece if not acc.tool_calls: return # war Text -> fertig (durchgereicht) if on_tool_start is not None: @@ -303,7 +341,7 @@ class ToolCallingLLM(LLMProvider): """Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls).""" payload = {"model": self.model, "messages": messages, "temperature": self.temperature, "stream": True} - if allow_tools: + if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -312,9 +350,12 @@ class ToolCallingLLM(LLMProvider): async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream("POST", ENDPOINT, json=payload, headers=headers) as resp: if resp.status_code >= 400: - body = await resp.aread() - raise RuntimeError( - f"OpenRouter {resp.status_code}: {body.decode(errors='replace')[:200]}") + body = (await resp.aread()).decode(errors="replace") + # Modell ohne Tool-Endpoint: merken + signalisieren (Caller wiederholt ohne Tools). + if _looks_tool_unsupported(resp.status_code, body, "tools" in payload): + self._mark_tools_unsupported(body) + raise _ToolsUnsupported() + raise RuntimeError(f"OpenRouter {resp.status_code}: {body[:200]}") async for line in resp.aiter_lines(): if not line.startswith("data:"): continue From b70d6f364aa49dc8690a3084087fe295df35c954 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 00:43:08 +0200 Subject: [PATCH 8/8] feat: schedule_hours-Backstop, zentrale Datum/Uhrzeit, Filler-Timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) schedule_hours-Nudge: search_backstop erkennt jetzt auch Oeffnungszeiten, "hat X geoeffnet", Notdienst und Fahrplaene/naechste Abfahrt (de/en/nl/fr) und erzwingt dafuer die Suche. +6 Offline-Tests. 2) Zentrale Datum-/Uhrzeit-Auskunft (app/core/clock.py, now_context()): jedes Modul kann das aktuelle Datum + Uhrzeit (lokale TZ, Wochentag) erfragen. In die System-Prompts aller antwortenden LLMs injiziert (ToolCallingLLM, OpenRouter, lokal) -> relative Zeitangaben ("heute", "morgen", "in 3 Stunden", "naechsten Montag") werden aufgeloest. Live: "Welcher Wochentag/Datum?" -> korrekt OHNE Web-Suche. 3) Filler-Timing: Mindestabstand 10 s zwischen Opening und Geduldssaetzen (FILLER_PATIENCE_INTERVAL 4->10). Sobald die Antwort vorliegt, wird ein anstehender Filler/Geduldssatz NICHT mehr ausgegeben (answer_started-Gate). Tests: 318 gruen. Doc §5.4/§5.7/§8. Co-Authored-By: Claude Opus 4.8 --- Docs/weg2-tool-calling.md | 14 +++++++---- app/core/clock.py | 25 ++++++++++++++++++++ app/core/orchestrator.py | 18 ++++++++------ app/pipeline/search_backstop.py | 14 +++++++++++ app/providers/llm/local_openai_compatible.py | 2 ++ app/providers/llm/openrouter.py | 5 ++-- app/providers/llm/tool_calling.py | 7 +++--- tests/test_local_llm_messages.py | 7 ++++-- tests/test_search_backstop.py | 7 ++++++ 9 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 app/core/clock.py diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 8e7679e..8c75449 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -99,8 +99,11 @@ Sprachen werden beim ersten Bedarf einmalig übersetzt. Fällt die Übersetzung 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. +`FILLER_PATIENCE_INTERVAL` Sekunden (Mindestabstand **10 s** zwischen Opening und +Geduldssätzen bzw. zwischen Geduldssätzen) einen zufälligen PATIENCE-Satz +nachschiebt. Sobald die Antwort vorliegt (erstes Antwort-Delta), wird die Schleife +abgeräumt **und** ein anstehender Filler/Geduldssatz NICHT mehr ausgegeben +(`answer_started`-Gate in `_emit_filler`). **Ephemeralität (Invariante):** Filler laufen über den `on_filler`-Callback, nicht über den Delta-Stream — sie landen **nicht** in `trace.semantic_response` und @@ -186,8 +189,11 @@ wird zur Tool-Rangordnung). - **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für **Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations` → `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort). -- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8). -- **Fast-follow (offen):** ggf. deterministischer Logistik-Nudge (`schedule_hours`). +- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8); `schedule_hours`-Nudge + (Öffnungszeiten/Fahrpläne jetzt im Backstop); zentrale Datum-/Uhrzeit-Auskunft + (`app/core/clock.py` → `now_context()` in alle LLM-System-Prompts injiziert, damit + relative Zeitangaben aufgelöst werden). +- **Fast-follow (offen):** — - **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. diff --git a/app/core/clock.py b/app/core/clock.py new file mode 100644 index 0000000..71cc8ca --- /dev/null +++ b/app/core/clock.py @@ -0,0 +1,25 @@ +"""Zentrale Datum-/Uhrzeit-Auskunft für alle Programmteile. + +Jedes Modul kann hiermit das aktuelle Datum und die Uhrzeit (lokale Zeitzone) +erfragen, um relative Zeitangaben ("heute", "morgen", "in 3 Stunden", "nächsten +Montag") in absolute Werte umzurechnen. Wird u. a. in die LLM-System-Prompts +injiziert (`now_context()`), damit die Modelle relative Zeitangaben auflösen können. +""" +from datetime import datetime + +_WEEKDAYS_EN = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + +def now() -> datetime: + """Aktuelle lokale, zeitzonenbewusste Zeit.""" + return datetime.now().astimezone() + + +def now_context() -> str: + """Knapper, eindeutiger Datum-/Uhrzeit-Hinweis (Englisch) für LLM-System-Prompts.""" + n = now() + tz = n.strftime("%Z") or "local" + return (f"The current date and time is {n.strftime('%Y-%m-%d')} " + f"({_WEEKDAYS_EN[n.weekday()]}) {n.strftime('%H:%M')} ({tz}). " + f"Use this to resolve relative dates and times such as 'today', " + f"'tomorrow', 'next Monday', or 'in 3 hours'.") diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index c044be5..40762a2 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -6,9 +6,9 @@ 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 +# Mindestabstand (Sekunden) zwischen Beruhigungssatz und Geduldssätzen bzw. +# zwischen Geduldssätzen, bei noch laufender Recherche. +FILLER_PATIENCE_INTERVAL = 10.0 def _stage(name: str): @@ -248,15 +248,18 @@ class Orchestrator: await queue.put(sentence) patience_task: asyncio.Task | None = None + answer_started = False async def _emit_filler(phrase: str) -> None: """Ephemer: dedizierter Filler-Kanal (Anzeige/Status + Geräte-TTS) + _dispatch (Server-TTS, "Satz null"). Läuft bewusst NICHT über den Token-Stream -> nie in parts/ - semantic_response/History. on_filler bevorzugt (eigener Event, damit - Geräte-TTS ihn sprechen kann); sonst Fallback auf on_token (Anzeige). + semantic_response/History. Sobald die Antwort vorliegt, wird ein + anstehender Filler/Geduldssatz NICHT mehr ausgegeben. """ + if answer_started: + return if on_filler is not None: await on_filler(phrase) elif on_token is not None: @@ -307,8 +310,9 @@ class Orchestrator: if _stream_supports(stream_fn, "on_citations"): stream_kwargs["on_citations"] = _on_citations 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 + if not answer_started: + answer_started = True # Antwort liegt vor -> keine weiteren Filler + await _cancel_patience() parts.append(delta) if on_token: await on_token(delta) diff --git a/app/pipeline/search_backstop.py b/app/pipeline/search_backstop.py index 8e27fa4..4a8ff33 100644 --- a/app/pipeline/search_backstop.py +++ b/app/pipeline/search_backstop.py @@ -42,6 +42,20 @@ _PATTERNS = [ re.compile(r"\bstill\s+alive\b", re.I), re.compile(r"\b(?:encore|toujours)\s+(?:en\s+vie|vivant)\b", re.I), re.compile(r"\b(?:sigue|todav[íi]a)\b.{0,20}\bviv[oa]\b", re.I), + + # Öffnungszeiten / „hat X geöffnet" (Gegenwart/Logistik) + re.compile(r"\b(?:öffnungszeiten|opening hours|openingstijden|horaires? d.ouverture)\b", re.I), + re.compile(r"\b(?:geöffnet|geschlossen)\b", re.I), + re.compile(r"\bnoch\s+(?:offen|auf|geöffnet)\b", re.I), + re.compile(r"\bis\b.{0,30}\bopen\b", re.I), + re.compile(r"\b(?:geopend|open)\b.{0,12}\b(?:vandaag|nu)\b", re.I), + re.compile(r"\bnotdienst\b", re.I), + # Fahrpläne / nächste Abfahrt + re.compile(r"\b(?:wann|wanneer|hoe laat|when|what time|à quelle heure)\b.{0,40}" + r"\b(?:bus|z[üu]ge?|bahn|tram|s-?bahn|u-?bahn|train|trein|metro|f[äa]hre|veerboot|flug|flight)\b", re.I), + re.compile(r"\b(?:nächste[rn]?|next|volgende|prochain\w*)\b.{0,15}" + r"\b(?:bus|zug|bahn|train|trein|tram|f[äa]hre|metro)\b", re.I), + re.compile(r"\b(?:abfahrt\w*|departure|vertrek\w*)\b", re.I), ] diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index eb7761d..af0e9db 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator import httpx +from app.core import clock from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -41,6 +42,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): system_parts: list[str] = [] if self.system_prompt: system_parts.append(self.system_prompt) + system_parts.append(clock.now_context()) # aktuelles Datum/Uhrzeit (relative Angaben auflösen) rest: list[dict] = [] for msg in history or []: if msg.get("role") == "system": diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index d047692..722f788 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator import httpx +from app.core import clock from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -100,10 +101,10 @@ class OpenRouterLLMProvider(LLMProvider): if not text or not text.strip(): raise ValueError("LLM input text is empty") - system_content = SYSTEM_PROMPT + system_content = f"{SYSTEM_PROMPT}\n\n{clock.now_context()}" instr = lang_instruction(language) if instr: - system_content = f"{SYSTEM_PROMPT}\n\n{instr}" + system_content = f"{system_content}\n\n{instr}" messages = [{"role": "system", "content": system_content}] if history: diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 27033cf..97e8667 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -13,10 +13,10 @@ import asyncio import json import logging from collections.abc import AsyncIterator -from datetime import date import httpx +from app.core import clock from app.metrics import metrics from app.pipeline.search_backstop import should_force_search from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder @@ -75,7 +75,7 @@ WEB_SEARCH_TOOL = { # zu früh als zu spät) — das verschiebt im Zweifel Richtung „suchen", die sichere # Seite (höherer Recall). Im Eval mit genau dieser Formulierung validiert. _TRIGGER_AND_PRIORITY = ( - "Your knowledge was last updated around {cutoff}. Today is {today}. " + "Your knowledge was last updated around {cutoff}. " "When a question could require information newer than {cutoff} that you " "cannot reason out yourself, call web_search before answering. Never call " "web_search merely to find the current time or today's date — you already " @@ -86,8 +86,7 @@ _TRIGGER_AND_PRIORITY = ( def _system_prompt(cutoff: str, language: str | None) -> str: - today = date.today().isoformat() - parts = [PERSONA_PROMPT, _TRIGGER_AND_PRIORITY.format(cutoff=cutoff, today=today)] + parts = [PERSONA_PROMPT, clock.now_context(), _TRIGGER_AND_PRIORITY.format(cutoff=cutoff)] instr = lang_instruction(language) if instr: parts.append(instr) diff --git a/tests/test_local_llm_messages.py b/tests/test_local_llm_messages.py index 4c5ea46..c1330ce 100644 --- a/tests/test_local_llm_messages.py +++ b/tests/test_local_llm_messages.py @@ -24,7 +24,10 @@ def test_single_system_message_when_history_has_system(): assert msgs[-1] == {"role": "user", "content": "Wie geht es dir?"} -def test_no_system_message_without_prompt_or_history(): +def test_clock_system_message_without_prompt_or_history(): + # Datum/Uhrzeit-Kontext wird immer als (einzige) fuehrende System-Nachricht gesetzt. llm = _llm(system_prompt="") msgs = llm._build_messages("Hallo", None) - assert msgs == [{"role": "user", "content": "Hallo"}] + assert msgs[0]["role"] == "system" + assert "current date and time" in msgs[0]["content"].lower() + assert msgs[1:] == [{"role": "user", "content": "Hallo"}] diff --git a/tests/test_search_backstop.py b/tests/test_search_backstop.py index b559d69..9b616ab 100644 --- a/tests/test_search_backstop.py +++ b/tests/test_search_backstop.py @@ -17,6 +17,13 @@ FORCE = [ "Lebt Heino noch?", "Is Brigitte Bardot still alive?", "Leeft Willeke Alberti nog?", + # schedule_hours / Logistik + "Hat die Arztpraxis heute Nachmittag noch geöffnet?", + "Welche Apotheke hat heute Nacht Notdienst?", + "What are the opening hours of the British Museum?", + "Wann fährt der nächste Bus ins Zentrum?", + "Wanneer vertrekt de volgende trein naar Utrecht?", + "Wann geht der nächste Zug nach München?", ] NO_FORCE = [