2026-06-29 21:37:08 +02:00
|
|
|
|
# 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 (~1–3 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
|
2026-06-29 22:34:15 +02:00
|
|
|
|
verdrahtet ihn auf zwei Pools (`app/pipeline/fillers.py`): **OPENING** (sofort beim
|
|
|
|
|
|
Such-Start) und **PATIENCE** (bei längerer Recherche nachgeschoben). Beide werden
|
|
|
|
|
|
**zufällig** gewählt (schnelles Feedback, nicht roboterhaft) und gehen über
|
|
|
|
|
|
`spoken_adapter`/`tts_normalizer`/`tts` → `on_audio` **und** `on_token`.
|
|
|
|
|
|
|
|
|
|
|
|
**Eine Pflege-Stelle, Englisch:** Die Master-Sätze stehen als englische Listen in
|
|
|
|
|
|
`fillers.py` (`OPENING_PHRASES`/`PATIENCE_PHRASES`). Zur Laufzeit werden sie **einmal
|
|
|
|
|
|
je Sprache übersetzt und gecacht** (`FillerPhrases`, Übersetzer = OpenRouter-Modell,
|
|
|
|
|
|
durchgängig höfliche Anrede „Sie"/„vous"/„usted"…). Die **Standardsprache wird beim
|
|
|
|
|
|
Start vorgewärmt** (`warmup.py`) → erster Such-Turn ohne Übersetzungs-Latenz; andere
|
|
|
|
|
|
Sprachen werden beim ersten Bedarf einmalig übersetzt. Fällt die Übersetzung aus →
|
|
|
|
|
|
Fallback Englisch.
|
|
|
|
|
|
|
|
|
|
|
|
**Geduld-Schleife:** Beim Tool-Start läuft ein Hintergrund-Task, der alle
|
|
|
|
|
|
`FILLER_PATIENCE_INTERVAL` Sekunden einen zufälligen PATIENCE-Satz nachschiebt; er
|
|
|
|
|
|
wird beim ersten Antwort-Delta (Antwort beginnt) und im `finally` abgeräumt.
|
|
|
|
|
|
|
2026-06-29 23:08:48 +02:00
|
|
|
|
**Ephemeralität (Invariante):** Filler laufen über den `on_filler`-Callback, nicht
|
|
|
|
|
|
über den Delta-Stream — sie landen **nicht** in `trace.semantic_response` und
|
|
|
|
|
|
**nicht** im gespeicherten History-Turn.
|
|
|
|
|
|
|
|
|
|
|
|
**Kanal:** Der Orchestrator emittiert Filler über `on_filler`; die WS-Schicht sendet
|
|
|
|
|
|
`{type:"filler", text}`. Das Frontend zeigt ihn **transient in der Status-Zeile**
|
|
|
|
|
|
(nicht in der Antwort-Bubble) und spricht ihn im **Geräte-TTS-Modus** sofort per
|
|
|
|
|
|
`speechSynthesis.speak()` **ohne `cancel`** (mehrere Filler laufen in die Queue; die
|
|
|
|
|
|
Antwort `cancelt` später und übernimmt) — abgesichert über `deviceVoicesReady()`/
|
|
|
|
|
|
`deviceVoiceReady(lang)` (keine Verschärfung der bekannten Regression). Im Server-TTS-
|
|
|
|
|
|
Modus wird der Filler zusätzlich als „Satz null" über die Chunk-Queue gesprochen.
|
2026-06-29 21:37:08 +02:00
|
|
|
|
|
|
|
|
|
|
### 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).
|
|
|
|
|
|
|
2026-06-29 22:58:03 +02:00
|
|
|
|
### 5.7 Such-Backstop (heikle Kategorien)
|
|
|
|
|
|
Das Modell self-triggert zu ~93 %; die Misses liegen in den Kategorien, in denen
|
|
|
|
|
|
eine veraltete Erstantwort **konfident-falsch** ist (Amtsträger, Wohnort lebender
|
|
|
|
|
|
Personen, „lebt X noch"). `app/pipeline/search_backstop.py` erkennt solche
|
|
|
|
|
|
Phrasierungen per mehrsprachiger Regex (`should_force_search`, Gegenwarts-Schutz:
|
|
|
|
|
|
„wer **ist**", nicht „wer **war**" → historische Fragen triggern nicht; offline
|
|
|
|
|
|
getestet). Bei Treffer erzwingt `ToolCallingLLM` die Suche **deterministisch**.
|
|
|
|
|
|
|
|
|
|
|
|
**Wichtige Designentscheidung:** *Nicht* per `tool_choice`-Forcen — das honoriert
|
|
|
|
|
|
das Modell nicht zuverlässig (beobachtet: forced choice ignoriert → veraltete
|
|
|
|
|
|
Antwort). Stattdessen führt der Wrapper die Suche **selbst direkt** aus und speist
|
|
|
|
|
|
das Ergebnis als synthetischen Tool-Turn ein (`_inject_forced_search`); das Modell
|
|
|
|
|
|
formuliert daraus. Metrik: `search_forced_total`. Recall-optimiert (lieber eine
|
|
|
|
|
|
überflüssige Suche als eine konfident falsche Antwort).
|
|
|
|
|
|
|
2026-06-29 21:37:08 +02:00
|
|
|
|
## 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)
|