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:
parent
19b3998948
commit
703a695bed
19 changed files with 1470 additions and 9 deletions
0
app/tools/__init__.py
Normal file
0
app/tools/__init__.py
Normal file
115
app/tools/web_search.py
Normal file
115
app/tools/web_search.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue