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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
284
app/providers/llm/tool_calling.py
Normal file
284
app/providers/llm/tool_calling.py
Normal 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"])
|
||||
|
|
@ -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
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