feat(llm): Web-Suche per Tool-Calling (Weg 2) — Sonar + ToolCallingLLM

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-29 21:37:08 +02:00
commit 703a695bed
19 changed files with 1470 additions and 9 deletions

3
.gitignore vendored
View file

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

View file

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

151
Docs/weg2-tool-calling.md Normal file
View file

@ -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 (~13 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

0
app/tools/__init__.py Normal file
View file

115
app/tools/web_search.py Normal file
View file

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

View file

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

View file

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

105
eval/tool_calling/client.py Normal file
View file

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

View file

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

58
eval/tool_calling/tool.py Normal file
View file

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