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

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