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

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