Compare commits

...
Author SHA1 Message Date
b70d6f364a feat: schedule_hours-Backstop, zentrale Datum/Uhrzeit, Filler-Timing
1) schedule_hours-Nudge: search_backstop erkennt jetzt auch Oeffnungszeiten,
   "hat X geoeffnet", Notdienst und Fahrplaene/naechste Abfahrt (de/en/nl/fr) und
   erzwingt dafuer die Suche. +6 Offline-Tests.

2) Zentrale Datum-/Uhrzeit-Auskunft (app/core/clock.py, now_context()): jedes
   Modul kann das aktuelle Datum + Uhrzeit (lokale TZ, Wochentag) erfragen.
   In die System-Prompts aller antwortenden LLMs injiziert (ToolCallingLLM,
   OpenRouter, lokal) -> relative Zeitangaben ("heute", "morgen", "in 3 Stunden",
   "naechsten Montag") werden aufgeloest. Live: "Welcher Wochentag/Datum?" ->
   korrekt OHNE Web-Suche.

3) Filler-Timing: Mindestabstand 10 s zwischen Opening und Geduldssaetzen
   (FILLER_PATIENCE_INTERVAL 4->10). Sobald die Antwort vorliegt, wird ein
   anstehender Filler/Geduldssatz NICHT mehr ausgegeben (answer_started-Gate).

Tests: 318 gruen. Doc §5.4/§5.7/§8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:43:08 +02:00
48a79da12f feat(llm): Härtung gegen tool-unfähiges Modell (no-tool-endpoints-404)
Waehlt jemand (Admin/Preset) ein Modell ohne Tool-faehigen Endpoint, lieferte
OpenRouter ein 404 "No endpoints found that support tool use" -> bisher 4x Retry
mit Backoff und danach stille, konfident-veraltete Fallback-Antwort.

- ToolCallingLLM erkennt das strukturelle 404 (_looks_tool_unsupported), merkt
  es fuer den Turn (_tools_unsupported), deaktiviert Tools und antwortet SOFORT
  plain (kein Haenger). Log-Warnung + Metrik tool_unsupported_total.
- Backstop-Inject jetzt als SYSTEM-Kontext statt synthetischem tool_call: das
  funktioniert mit tool-faehigen UND tool-unfaehigen Modellen (letztere lehnen
  tool-Messages mit 405 ab). Folge: heikle Kategorien bleiben auch auf einem
  tool-unfaehigen Modell korrekt; nur nicht-heikle Volatil-Fragen fallen plain.

Live verifiziert gegen mistral-2501 (tool-unfaehig): 3.7s statt Haenger,
Backstop liefert weiter "Merz"; gegen mistral-3.2 kein Regress (1 Suche).
Tests: 312 gruen. Doc §5.7/§5.8/§8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:21:38 +02:00
e753788a23 feat(citations): Web-Such-Quellen unter der Antwort-Bubble anzeigen
Sonar liefert Citation-URLs; bisher verworfen, jetzt bis in die UI gereicht.

- ToolCallingLLM: _run_tool/_inject_forced_search geben Citations zurueck;
  stream() sammelt sie ueber einen on_citations-Callback.
- Orchestrator: chat_stream reicht on_citations durch (via _stream_supports)
  und legt die gesammelten URLs in PipelineTrace.citations.
- schemas: PipelineTrace.citations.
- ws.py: Citations im semantic-Event.
- Frontend (app.js): renderCitations() zeigt bis zu 4 Quellen-Links
  (Hostname, neuer Tab) unter der Antwort-Bubble.

Nur ueber den Streaming-Pfad (= Web-UI). Live verifiziert: "Wer ist
Bundeskanzler?" -> 15 Quellen gesammelt (Bundeskanzler.de, Wikipedia, ...).
Tests: 312 gruen; JS-Syntax geprueft. Doc §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:11:24 +02:00
fdf65e8623 feat(fillers): Beruhigungssätze auch im Geräte-TTS-Modus sprechen
Bisher wurde der Filler im "Im Gerät"-Modus (Web Speech API) nur angezeigt,
aber nicht gesprochen (Server-Audio ist dort aus). Jetzt eigener Kanal:

- Orchestrator: chat_stream bekommt on_filler; _emit_filler bevorzugt den
  dedizierten Filler-Kanal (sonst Fallback on_token). Ephemer wie bisher.
- ws.py: on_filler -> {type:"filler", text}.
- Frontend (app.js): {type:"filler"} -> transient in der STATUS-Zeile (nicht
  in der Antwort-Bubble) und im Geräte-Modus sofort speechSynthesis.speak()
  OHNE cancel (mehrere Filler -> Queue; die Antwort cancelt spaeter und
  uebernimmt). Abgesichert ueber deviceVoicesReady()/deviceVoiceReady(lang) —
  keine Verschaerfung der bekannten deviceVoiceReady-Regression.

Server-TTS-Modus unveraendert (Filler zusaetzlich als "Satz null" gesprochen),
zeigt den Filler nun ebenfalls in der Status-Zeile statt in der Bubble.

Tests: 312 gruen; JS-Syntax geprueft. Doc §5.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:08:48 +02:00
6cd6e783aa feat(search): deterministischer Backstop für heikle Kategorien
Schliesst die ~5-15 % Erst-Miss bei Fragen, deren veraltete Antwort
konfident-falsch waere (Amtstraeger, Wohnort lebender Personen, "lebt X noch").

- app/pipeline/search_backstop.py: should_force_search() erkennt solche
  Phrasierungen per mehrsprachiger Regex (de/en/nl + etwas fr/es) mit
  Gegenwarts-Schutz ("wer ist", nicht "wer war"). 22 Offline-Tests.
- ToolCallingLLM: bei Treffer wird die Suche DETERMINISTISCH erzwungen.
  Wichtig: NICHT per tool_choice-Forcen (das honoriert das Modell nicht
  zuverlaessig -> beobachtet: ignoriert, veraltete Antwort). Stattdessen
  fuehrt der Wrapper die Suche selbst aus und speist das Ergebnis als
  synthetischen Tool-Turn ein (_inject_forced_search). Metrik
  search_forced_total. Filler/Geduld feuern auch hier.
- Haertung: _chat faengt non-JSON-200 ab (war ein Crash-/Hänger-Ausloeser,
  betrifft auch Produktion).

Live verifiziert: "Wer ist Bundeskanzler?" sucht jetzt 3/3 -> Merz.
Tests: 312 gruen. Doc: §5.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:58:03 +02:00
96dcd87ca8 feat(fillers): zufällige, übersetzte Senioren-Beruhigungssätze + Geduld-Schleife
Ersetzt die statischen 3-Satz-Filler durch einen kreativen, weniger
roboterhaften Mechanismus für schnelles Feedback waehrend der Web-Suche.

- app/pipeline/fillers.py: EINZIGE Pflege-Stelle. Englische Master-Listen
  (12 OPENING + 16 PATIENCE). Zur Laufzeit einmal je Sprache uebersetzt und
  gecacht (durchgaengig hoefliche Anrede "Sie"/"vous"/"usted"...), Fallback
  Englisch. Zufaellige Auswahl statt fester Reihenfolge.
- Geduld-Schleife: bei laengerer Recherche schiebt ein Hintergrund-Task alle
  FILLER_PATIENCE_INTERVAL Sekunden einen zufaelligen PATIENCE-Satz nach;
  Abbruch beim ersten Antwort-Delta und im finally (keine verwaisten Tasks).
- Ephemer wie bisher: laeuft ueber on_token/_dispatch, nie in
  semantic_response/History.
- Standardsprache wird beim Start vorgewaermt (warmup.py) -> erster Such-Turn
  ohne Uebersetzungs-Latenz. dependencies: get_fillers()-Singleton.

Doc: Docs/weg2-tool-calling.md §5.4. Tests: 290 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:34:15 +02:00
c36b884aab feat(llm): Koreferenz-Vorstufe + Tool-/Sonar-Metriken (Weg 2, Schritt 5)
Schliesst die im Tool-Calling-Eval isolierte Restkante (nl + Pronomen-aus-
History, z. B. "Leeft hij nog?" -> "Leeft Rutger Hauer nog?") und macht die
Web-Suche metrisch beobachtbar.

- Decontextualizer (app/pipeline/decontextualizer.py): loest Pronomen der
  letzten Aeusserung anhand des Verlaufs auf. Bewusst gegated (kurze
  Folgefrage MIT Pronomen UND History) -> kein Extra-Call im Normalfall;
  best effort (bei Fehler Original behalten).
- ToolCallingLLM nutzt die Vorstufe vor complete()/stream() und zaehlt
  tool_calls_total{tool=...}.
- SonarTool zaehlt sonar_calls_total{status=ok|error}.
- Verdrahtung: Registry openrouter-tools reicht den Decontextualizer durch.

Damit ist v1 von Weg 2 vollstaendig. Tests: 290 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:43:24 +02:00
703a695bed 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>
2026-06-29 21:37:08 +02:00
29 changed files with 2113 additions and 17 deletions

3
.gitignore vendored
View file

@ -39,3 +39,6 @@ Ideen/
# Local voice models
config/voices/*.onnx
config/voices/*.onnx.json
# Eval-Roh-Ergebnisse (Tool-Calling-Harness, instanz-/laufabhängig)
eval/tool_calling/results_*.json

View file

@ -874,7 +874,7 @@ default_language = "de"
default_input_endpoint = "local-default"
default_output_endpoint = "local-default"
openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501"
openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct"
llm_fallback = "openrouter"
# WICHTIG: Piper als TTS-Fallback. Greift, wenn Cartesia eine Sprache nicht kann
@ -1636,7 +1636,7 @@ Priorität der Wartezeit bei 429:
Für zuverlässigen Betrieb das **Bezahlmodell** verwenden:
```toml
openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501"
openrouter_llm_model = "mistralai/mistral-small-3.2-24b-instruct"
```
Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe

207
Docs/weg2-tool-calling.md Normal file
View file

@ -0,0 +1,207 @@
# 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
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 (Mindestabstand **10 s** zwischen Opening und
Geduldssätzen bzw. zwischen Geduldssätzen) einen zufälligen PATIENCE-Satz
nachschiebt. Sobald die Antwort vorliegt (erstes Antwort-Delta), wird die Schleife
abgeräumt **und** ein anstehender Filler/Geduldssatz NICHT mehr ausgegeben
(`answer_started`-Gate in `_emit_filler`).
**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.
### 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).
### 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 **System-Kontext** ein (`_inject_forced_search`); das Modell
formuliert daraus. System-Kontext (statt synthetischem tool_call) funktioniert mit
tool-fähigen **und** tool-unfähigen Modellen (letztere lehnen tool-Messages mit 405
ab). Metrik: `search_forced_total`. Recall-optimiert (lieber eine überflüssige
Suche als eine konfident falsche Antwort).
### 5.8 Härtung: tool-unfähiges Modell
Wählt jemand (Admin/Preset) ein Modell ohne Tool-fähigen Endpoint, liefert
OpenRouter ein 404 „No endpoints found that support tool use". `ToolCallingLLM`
erkennt das (`_looks_tool_unsupported`), **merkt es für den Turn** (`_tools_unsupported`),
deaktiviert Tools und antwortet **sofort plain** (kein Retry-Hänger, keine stille
Stale-Antwort durch die Fallback-Kette). Log-Warnung + Metrik `tool_unsupported_total`.
Wichtig: Der **Backstop bleibt wirksam** (er speist Fakten als System-Kontext ein,
nicht als tool_call) → heikle Kategorien bleiben auch auf einem tool-unfähigen Modell
korrekt; nur nicht-heikle Volatil-Fragen (Wetter/Preise) fallen auf plain zurück.
## 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.
- **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für
**Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations`
→ `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort).
- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8); `schedule_hours`-Nudge
(Öffnungszeiten/Fahrpläne jetzt im Backstop); zentrale Datum-/Uhrzeit-Auskunft
(`app/core/clock.py` → `now_context()` in alle LLM-System-Prompts injiziert, damit
relative Zeitangaben aufgelöst werden).
- **Fast-follow (offen):** —
- **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)

View file

@ -136,6 +136,12 @@ async def _run_turn(
audio_seq += 1
await websocket.send_bytes(chunk)
# Filler (Beruhigungs-/Geduldssätze) als eigener Event: Status-Zeile + Geräte-TTS.
on_filler = None
if stream or audio_stream:
async def on_filler(filler_text):
await websocket.send_json({"type": "filler", "text": filler_text})
try:
if stream or audio_stream:
trace, audio = await orchestrator.chat_stream(
@ -146,6 +152,7 @@ async def _run_turn(
history=llm_context,
on_token=on_token,
on_audio=on_audio,
on_filler=on_filler,
text_only=text_only,
)
else:
@ -169,7 +176,8 @@ async def _run_turn(
maybe_schedule_extraction(store, user.id, session_id)
await websocket.send_json(
{"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response}
{"type": "semantic", "text": trace.semantic_response,
"spoken": trace.spoken_response, "citations": trace.citations}
)
# Im text_only-Modus kommt kein Audio (Gerät spricht selbst).
if not audio_stream and not text_only:

View file

@ -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)

25
app/core/clock.py Normal file
View file

@ -0,0 +1,25 @@
"""Zentrale Datum-/Uhrzeit-Auskunft für alle Programmteile.
Jedes Modul kann hiermit das aktuelle Datum und die Uhrzeit (lokale Zeitzone)
erfragen, um relative Zeitangaben ("heute", "morgen", "in 3 Stunden", "nächsten
Montag") in absolute Werte umzurechnen. Wird u. a. in die LLM-System-Prompts
injiziert (`now_context()`), damit die Modelle relative Zeitangaben auflösen können.
"""
from datetime import datetime
_WEEKDAYS_EN = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def now() -> datetime:
"""Aktuelle lokale, zeitzonenbewusste Zeit."""
return datetime.now().astimezone()
def now_context() -> str:
"""Knapper, eindeutiger Datum-/Uhrzeit-Hinweis (Englisch) für LLM-System-Prompts."""
n = now()
tz = n.strftime("%Z") or "local"
return (f"The current date and time is {n.strftime('%Y-%m-%d')} "
f"({_WEEKDAYS_EN[n.weekday()]}) {n.strftime('%H:%M')} ({tz}). "
f"Use this to resolve relative dates and times such as 'today', "
f"'tomorrow', 'next Monday', or 'in 3 hours'.")

View file

@ -1,9 +1,15 @@
import asyncio
import inspect
from app.schemas import AudioChunk, PipelineTrace
from app.pipeline.sentence_chunker import SentenceChunker
from app.pipeline.fillers import FillerPhrases
from app.metrics import timer, metrics
# Mindestabstand (Sekunden) zwischen Beruhigungssatz und Geduldssätzen bzw.
# zwischen Geduldssätzen, bei noch laufender Recherche.
FILLER_PATIENCE_INTERVAL = 10.0
def _stage(name: str):
return timer("stage_duration_seconds", {"stage": name})
@ -21,10 +27,22 @@ _LANG_NAMES = {
"ru": "русский", "zh": "中文",
}
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,
normalize_level: str = "full"):
normalize_level: str = "full", fillers=None):
self.stt = stt
self.llm = llm
self.tts = tts
@ -32,6 +50,8 @@ class Orchestrator:
self.spoken_adapter = spoken_adapter
self.tts_normalizer = tts_normalizer
self.normalize_level = normalize_level
# Ohne externe Verdrahtung: englische Master-Sätze (kein Crash in Tests).
self.fillers = fillers or FillerPhrases()
async def _emit_to_output(self, audio: bytes, output) -> None:
"""Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt.
@ -167,6 +187,7 @@ class Orchestrator:
history: list[dict] | None = None,
on_token=None,
on_audio=None,
on_filler=None,
text_only: bool = False,
):
"""Wie chat_text, aber gestreamt.
@ -226,12 +247,72 @@ class Orchestrator:
await consumer_task
await queue.put(sentence)
patience_task: asyncio.Task | None = None
answer_started = False
async def _emit_filler(phrase: str) -> None:
"""Ephemer: dedizierter Filler-Kanal (Anzeige/Status + Geräte-TTS) +
_dispatch (Server-TTS, "Satz null").
Läuft bewusst NICHT über den Token-Stream -> nie in parts/
semantic_response/History. Sobald die Antwort vorliegt, wird ein
anstehender Filler/Geduldssatz NICHT mehr ausgegeben.
"""
if answer_started:
return
if on_filler is not None:
await on_filler(phrase)
elif on_token is not None:
await on_token(phrase + " ")
if chunker is not None:
await _dispatch(phrase)
async def _patience_loop(lang: str | None) -> None:
# Bei längerer Recherche zufällige Geduldssätze nachschieben.
try:
while True:
await asyncio.sleep(FILLER_PATIENCE_INTERVAL)
await _emit_filler(await self.fillers.patience(lang))
except asyncio.CancelledError:
return
async def _on_tool_start(lang: str | None) -> None:
nonlocal patience_task
await _emit_filler(await self.fillers.opening(lang))
if patience_task is None or patience_task.done():
patience_task = asyncio.create_task(_patience_loop(lang))
async def _cancel_patience() -> None:
nonlocal patience_task
task, patience_task = patience_task, None
if task is not None and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
collected_citations: list[str] = []
async def _on_citations(urls) -> None:
for u in (urls or []):
if u not in collected_citations:
collected_citations.append(u)
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
if _stream_supports(stream_fn, "on_citations"):
stream_kwargs["on_citations"] = _on_citations
async for delta in stream_fn(trace.cleaned_transcript or "", **stream_kwargs):
if not answer_started:
answer_started = True # Antwort liegt vor -> keine weiteren Filler
await _cancel_patience()
parts.append(delta)
if on_token:
await on_token(delta)
@ -250,6 +331,7 @@ class Orchestrator:
trace.semantic_response = "".join(parts)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.citations = collected_citations
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
@ -277,8 +359,9 @@ class Orchestrator:
trace.tts_ready_text, voice=voice, language=effective_language
)
finally:
# Bei Fehler/Abbruch den noch laufenden Consumer-Task abräumen,
# damit kein verwaister Task zurückbleibt.
# Geduldsschleife und Consumer-Task abräumen, damit keine verwaisten
# Tasks zurückbleiben (bei Fehler/Abbruch).
await _cancel_patience()
if consumer_task is not None and not consumer_task.done():
consumer_task.cancel()
try:

View file

@ -43,3 +43,14 @@ async def warmup_local_models() -> None:
logger.info("warmup: faster-whisper-Modell geladen")
except Exception:
logger.exception("warmup: STT-Vorladen fehlgeschlagen (ignoriert)")
# Filler-Sätze der Standardsprache vorab übersetzen/cachen, damit der erste
# Such-Turn sie sofort (ohne Übersetzungs-Latenz) sprechen kann.
try:
from app.runtime_config import runtime_settings as rs
if rs.web_search_enabled and rs.openrouter_api_key:
from app.dependencies import get_fillers
await get_fillers().warm(rs.default_language)
logger.info("warmup: Filler-Sätze (%s) übersetzt/gecacht", rs.default_language)
except Exception:
logger.exception("warmup: Filler-Vorwärmen fehlgeschlagen (ignoriert)")

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
@ -26,6 +28,8 @@ from app.providers.fallback import (
FallbackLLMProvider,
FallbackTTSProvider,
)
from app.pipeline.decontextualizer import Decontextualizer
from app.pipeline.fillers import FillerPhrases, make_openrouter_translator
from app.pipeline.input_cleaner import InputCleaner
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
from app.pipeline.tts_normalizer import TTSNormalizer
@ -45,6 +49,20 @@ def get_store() -> Store:
_store = SQLiteStore(settings.db_path)
return _store
# Filler-Sätze (Web-Suche): Modul-Singleton mit Übersetzungs-Cache.
_fillers: FillerPhrases | None = None
def get_fillers(cfg=None) -> FillerPhrases:
global _fillers
if _fillers is None:
cfg = cfg or runtime_settings
_fillers = FillerPhrases(
translate_pool=make_openrouter_translator(cfg.openrouter_api_key, cfg.openrouter_llm_model)
)
return _fillers
# ---------------------------------------------------------------------------
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError.
@ -56,6 +74,13 @@ 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)],
decontextualizer=Decontextualizer(s.openrouter_api_key, s.openrouter_llm_model),
),
"local-openai-compatible": lambda s: LocalOpenAICompatibleLLM(
s.local_llm_base_url,
s.local_llm_api_key,
@ -156,8 +181,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 +273,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 +284,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 +325,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 +338,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,14 +379,21 @@ 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(),
tts_normalizer=TTSNormalizer(),
normalize_level=_resolve_normalize_level(route.tts_provider, cfg),
fillers=get_fillers(cfg),
)

View file

@ -0,0 +1,84 @@
"""Koreferenz-Vorstufe: löst Pronomen der letzten Äußerung anhand des Verlaufs auf.
Schließt die im Tool-Calling-Eval isolierte Restkante (nl + Pronomen-aus-History,
z. B. „Leeft hij nog?" → „Leeft Rutger Hauer nog?"). Bewusst **gegated** (kurze
Folgefrage MIT Pronomen UND vorhandener History), damit nicht jeder Turn einen
Extra-Call kostet. Siehe Docs/weg2-tool-calling.md §5.3.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
# Pronomen/Referenzwörter (lowercase, mehrsprachig) für die Gate-Heuristik.
_PRONOUNS = {
"er", "sie", "es", "der", "die", "das", "den", "dem", "deren", "dessen",
"ihn", "ihm", "ihr", # de
"he", "she", "it", "they", "him", "her", "them", "that", "those", "these", # en
"hij", "ze", "zij", "het", "die", "dat", "hem", "haar", "hen", "hun", # nl
"il", "elle", "ils", "elles", "lui", "celui", "celle", # fr
"él", "ella", "ellos", "ese", "esa", "lei", "loro", "quello", # es/it
}
_MAX_WORDS = 8
_SYSTEM = (
"You are a coreference resolver. Given a short conversation and the user's "
"latest message, output ONLY that latest message rewritten so it stands on "
"its own: resolve pronouns and references to the concrete names or entities "
"mentioned earlier. Keep the original language and meaning. Do NOT answer it; "
"only rewrite. If it is already self-contained, output it unchanged."
)
def _words(text: str) -> list[str]:
return [w for w in "".join(c.lower() if (c.isalpha() or c == " ") else " "
for c in text).split() if w]
class Decontextualizer:
def __init__(self, api_key: str, model: str, timeout: float = 15.0):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
self.timeout = timeout
def _gated(self, text: str, history) -> bool:
"""Nur kurze Folgefragen mit Pronomen und vorhandener History."""
if not history or not text:
return False
words = _words(text)
if not words or len(words) > _MAX_WORDS:
return False
return any(w in _PRONOUNS for w in words)
def _render(self, history: list[dict]) -> str:
lines = []
for m in history[-6:]:
who = "User" if m.get("role") == "user" else "Assistant"
lines.append(f"{who}: {m.get('content', '')}")
return "\n".join(lines)
async def run(self, text: str, history: list[dict] | None = None,
language: str | None = None) -> str:
if not self.api_key or not self._gated(text, history):
return text
user = (f"Conversation:\n{self._render(history)}\n\n"
f"Latest message: {text}\n\nRewritten self-contained message:")
payload = {"model": self.model, "temperature": 0.0,
"messages": [{"role": "system", "content": _SYSTEM},
{"role": "user", "content": user}]}
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client:
resp = await client.post(ENDPOINT, json=payload, headers=headers)
resp.raise_for_status()
out = (resp.json()["choices"][0]["message"]["content"] or "").strip()
except Exception as exc: # noqa: BLE001 — best effort, bei Fehler Original behalten
logger.warning("Decontextualizer fehlgeschlagen: %s", exc)
return text
# Schutz vor Ausreißern (Erklärungen statt Rewrite): nur Plausibles übernehmen.
if not out or len(out) > len(text) + 200:
return text
return out.strip().strip('"')

146
app/pipeline/fillers.py Normal file
View file

@ -0,0 +1,146 @@
"""Senioren-Beruhigungssätze für die Web-Suche — englische Master-Quelle.
DIES IST DIE EINZIGE STELLE zum Pflegen der Sätze. Zur Laufzeit werden sie einmal
je Sprache übersetzt und gecacht (die Standardsprache wird beim Serverstart
vorgewärmt → sofortige Ausgabe ohne Verzögerung). Zwei Pools:
- OPENING: sofort beim Such-Start ("Einen Moment, ich schaue kurz nach.")
- PATIENCE: bei längerer Recherche nachgeschoben ("Bitte noch etwas Geduld …")
Beide werden ZUFÄLLIG gewählt, damit schnelles Feedback kommt und es nicht
roboterhaft eintönig klingt. Siehe Docs/weg2-tool-calling.md §5.4.
"""
import asyncio
import logging
import random
import re
import httpx
logger = logging.getLogger(__name__)
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
# --- Master-Sätze (Englisch — einzige Quelle der Wahrheit) ------------------
OPENING_PHRASES = [
"One moment, let me look that up for you.",
"Just a second, I'll check that for you right away.",
"Let me find that out for you — one moment.",
"Good question — let me check the latest on that.",
"Hold on just a moment, I'm looking it up.",
"Let me quickly find the current information for you.",
"One little moment while I check that for you.",
"I'll go and look that up for you right now.",
"Let me see what the latest is — just a moment.",
"Give me a second, I'll find that out for you.",
"I'm checking that for you now, one moment please.",
"Let me look that up so I can tell you exactly.",
]
PATIENCE_PHRASES = [
"Thank you for your patience, I'm almost there.",
"Just a little longer, please — I'm still looking.",
"Bear with me a moment, I'm nearly done.",
"Almost ready — thank you for waiting.",
"I'm still checking, just a few more seconds.",
"Please hold on a little longer, I'm getting there.",
"Nearly finished — thank you for your patience.",
"Just a moment more, I want to get this right for you.",
"I'm still looking it up, please stay with me.",
"It's taking a little longer, but I'm almost done.",
"Thank you for waiting — I'll have it for you shortly.",
"Hang on just a bit longer, I'm nearly there.",
"I haven't forgotten you — I'm still checking.",
"Almost there now, thank you for being so patient.",
"Just finishing up — one more moment, please.",
"I'm making sure I get it right for you — nearly done.",
]
_LANG_DISPLAY = {
"de": "German", "en": "English", "fr": "French", "es": "Spanish",
"it": "Italian", "nl": "Dutch", "pt": "Portuguese", "pl": "Polish",
"ar": "Arabic", "ru": "Russian", "zh": "Chinese",
}
def _parse_numbered(text: str, n: int) -> list[str] | None:
"""Liest eine nummerierte Liste; liefert genau n Zeilen oder None (→ Fallback)."""
out = []
for raw in (text or "").splitlines():
s = re.sub(r"^\s*\d+[.)]\s*", "", raw.strip()).strip().strip('"').strip("„“”")
if s:
out.append(s)
return out[:n] if len(out) >= n else None
def make_openrouter_translator(api_key: str, model: str):
"""Liefert eine async translate_pool(phrases, language) -> list[str] | None."""
api_key = (api_key or "").strip()
model = (model or "").strip()
async def translate_pool(phrases: list[str], language: str) -> list[str] | None:
if not api_key or not model:
return None
target = _LANG_DISPLAY.get(language, language)
numbered = "\n".join(f"{i + 1}. {p}" for i, p in enumerate(phrases))
prompt = (
f"Translate each numbered phrase into {target}. These are short, warm, "
f"reassuring things a voice assistant says to an elderly person while it "
f"looks something up. Keep them natural and spoken. ALWAYS use the polite, "
f"formal form of address (German 'Sie', not 'du'; French 'vous'; Spanish "
f"'usted'; Italian 'Lei'; Dutch 'u') consistently. Same count, same order, "
f"no quotes, no extra words. Output ONLY a numbered list of the translations."
f"\n\n{numbered}"
)
payload = {"model": model, "temperature": 0.4,
"messages": [{"role": "user", "content": prompt}]}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(ENDPOINT, json=payload, headers=headers)
resp.raise_for_status()
text = resp.json()["choices"][0]["message"]["content"]
return _parse_numbered(text, len(phrases))
return translate_pool
class FillerPhrases:
"""Zufällige, in die Zielsprache übersetzte Beruhigungssätze (einmal je Sprache gecacht)."""
def __init__(self, translate_pool=None):
self._translate_pool = translate_pool
self._cache: dict[str, dict] = {
"en": {"opening": OPENING_PHRASES, "patience": PATIENCE_PHRASES}}
self._locks: dict[str, asyncio.Lock] = {}
async def warm(self, language: str | None) -> None:
"""Vorab übersetzen (z. B. Standardsprache beim Serverstart)."""
await self._ensure(language)
async def _ensure(self, language: str | None) -> str:
lang = (language or "en").lower()
if lang in self._cache:
return lang
if self._translate_pool is None:
return "en" # ohne Übersetzer: englische Master-Sätze
lock = self._locks.setdefault(lang, asyncio.Lock())
async with lock:
if lang in self._cache:
return lang
opening = patience = None
try:
opening = await self._translate_pool(OPENING_PHRASES, lang)
patience = await self._translate_pool(PATIENCE_PHRASES, lang)
except Exception as exc: # noqa: BLE001 — best effort, Fallback Englisch
logger.warning("Filler-Übersetzung (%s) fehlgeschlagen: %s", lang, exc)
self._cache[lang] = {"opening": opening or OPENING_PHRASES,
"patience": patience or PATIENCE_PHRASES}
return lang
async def opening(self, language: str | None) -> str:
lang = await self._ensure(language)
return random.choice(self._cache[lang]["opening"])
async def patience(self, language: str | None) -> str:
lang = await self._ensure(language)
return random.choice(self._cache[lang]["patience"])

View file

@ -0,0 +1,66 @@
"""Deterministischer Such-Backstop für die heiklen Kategorien.
Das Modell self-triggert die Web-Suche im Eval zu ~93 %. Die verbleibenden Misses
liegen ausgerechnet in den Kategorien, in denen eine veraltete Erstantwort
**konfident-falsch** ist (Amtsträger, Wohnort lebender Personen, „lebt X noch").
Für klar erkennbare Phrasierungen dieser Kategorien erzwingt der Backstop den
web_search-Aufruf (`tool_choice`) — deterministisch, ohne Zusatz-Call.
Bewusst auf Recall optimiert (lieber eine überflüssige Suche als eine konfident
falsche Antwort); gelegentliches Über-Triggern ist akzeptabel. Mehrsprachig
(de/en/nl im Kern, etwas fr/es). Offline testbar (reine Regex).
"""
import re
# Amts-/Rollenbegriffe, deren Inhaber wechseln.
_OFFICE = (
r"(?:bundeskanzler(?:in)?|kanzler(?:in)?|pr[äa]sident(?:in)?|president|papst|pope|"
r"ministerpr[äa]sident(?:in)?|minister-?president|premierminister|premier|"
r"regierungschef(?:in)?|regeringsleider|kanselier|au[ßs]enminister(?:in)?|"
r"k[öo]nig(?:in)?|queen|king|kaiser|paus|premier ?minister)"
)
# Gegenwarts-/Aktualitäts-Marker.
_NOW = r"(?:aktuell\w*|derzeit\w*|momentan|heute|jetzt|gerade|amtierend\w*|"\
r"current\w*|now|tegenwoordig|nu|huidige|actuel\w*|actual\w*)"
_PATTERNS = [
# Amtsträger: "wer ist <Amt>", "aktueller <Amt>", "<Amt> ... aktuell/jetzt".
# Bewusst Gegenwart (wer IST, nicht wer WAR) -> historische Fragen triggern nicht.
re.compile(rf"\b(?:wer (?:ist|sind)|who (?:is|are)|wie hei[sß]t|wie is|wie heet)\b"
rf".{{0,30}}\b{_OFFICE}\b", re.I),
re.compile(rf"\b{_NOW}\s+{_OFFICE}\b", re.I),
re.compile(rf"\b{_OFFICE}\b.{{0,30}}\b{_NOW}\b", re.I),
# Wohnort lebender Person
re.compile(r"\bwo\s+(?:wohnt|lebt|residiert)\b", re.I),
re.compile(r"\bwhere\s+(?:does|do|is|are)\b.{0,40}\blive[sd]?\b", re.I),
re.compile(r"\bwaar\s+woont\b", re.I),
re.compile(r"\bo[ùu]\s+(?:habite|vit)\b", re.I),
re.compile(r"\bd[óo]nde\s+vive\b", re.I),
# Lebt X noch / still alive
re.compile(r"\b(?:lebt|leeft)\b.{0,40}\b(?:noch|nog)\b", re.I),
re.compile(r"\bnoch\s+am\s+leben\b", re.I),
re.compile(r"\bstill\s+alive\b", re.I),
re.compile(r"\b(?:encore|toujours)\s+(?:en\s+vie|vivant)\b", re.I),
re.compile(r"\b(?:sigue|todav[íi]a)\b.{0,20}\bviv[oa]\b", re.I),
# Öffnungszeiten / „hat X geöffnet" (Gegenwart/Logistik)
re.compile(r"\b(?:öffnungszeiten|opening hours|openingstijden|horaires? d.ouverture)\b", re.I),
re.compile(r"\b(?:geöffnet|geschlossen)\b", re.I),
re.compile(r"\bnoch\s+(?:offen|auf|geöffnet)\b", re.I),
re.compile(r"\bis\b.{0,30}\bopen\b", re.I),
re.compile(r"\b(?:geopend|open)\b.{0,12}\b(?:vandaag|nu)\b", re.I),
re.compile(r"\bnotdienst\b", re.I),
# Fahrpläne / nächste Abfahrt
re.compile(r"\b(?:wann|wanneer|hoe laat|when|what time|à quelle heure)\b.{0,40}"
r"\b(?:bus|z[üu]ge?|bahn|tram|s-?bahn|u-?bahn|train|trein|metro|f[äa]hre|veerboot|flug|flight)\b", re.I),
re.compile(r"\b(?:nächste[rn]?|next|volgende|prochain\w*)\b.{0,15}"
r"\b(?:bus|zug|bahn|train|trein|tram|f[äa]hre|metro)\b", re.I),
re.compile(r"\b(?:abfahrt\w*|departure|vertrek\w*)\b", re.I),
]
def should_force_search(text: str | None) -> bool:
"""True, wenn der Text klar eine heikle, veränderliche Tatsache abfragt."""
if not text:
return False
return any(p.search(text) for p in _PATTERNS)

View file

@ -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

View file

@ -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)

View file

@ -2,6 +2,7 @@ from collections.abc import AsyncIterator
import httpx
from app.core import clock
from app.providers.llm.base import (
LLMProvider,
lang_instruction,
@ -41,6 +42,7 @@ class LocalOpenAICompatibleLLM(LLMProvider):
system_parts: list[str] = []
if self.system_prompt:
system_parts.append(self.system_prompt)
system_parts.append(clock.now_context()) # aktuelles Datum/Uhrzeit (relative Angaben auflösen)
rest: list[dict] = []
for msg in history or []:
if msg.get("role") == "system":
@ -102,6 +104,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(

View file

@ -4,6 +4,7 @@ from collections.abc import AsyncIterator
import httpx
from app.core import clock
from app.providers.llm.base import (
LLMProvider,
lang_instruction,
@ -100,10 +101,10 @@ class OpenRouterLLMProvider(LLMProvider):
if not text or not text.strip():
raise ValueError("LLM input text is empty")
system_content = SYSTEM_PROMPT
system_content = f"{SYSTEM_PROMPT}\n\n{clock.now_context()}"
instr = lang_instruction(language)
if instr:
system_content = f"{SYSTEM_PROMPT}\n\n{instr}"
system_content = f"{system_content}\n\n{instr}"
messages = [{"role": "system", "content": system_content}]
if history:
@ -175,6 +176,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,

View file

@ -0,0 +1,373 @@
"""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
import httpx
from app.core import clock
from app.metrics import metrics
from app.pipeline.search_backstop import should_force_search
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}
# OpenRouter-404, wenn das Modell keinen Tool-fähigen Endpoint hat.
_TOOL_UNSUPPORTED_HINT = "support tool use"
class _ToolsUnsupported(Exception):
"""Strukturelles 404: das gewählte Modell kann (über OpenRouter) kein Tool-Calling."""
def _looks_tool_unsupported(status: int, body: str, has_tools: bool) -> bool:
return has_tools and status == 404 and _TOOL_UNSUPPORTED_HINT in (body or "").lower()
# 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}. "
"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:
parts = [PERSONA_PROMPT, clock.now_context(), _TRIGGER_AND_PRIORITY.format(cutoff=cutoff)]
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,
decontextualizer=None):
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
self.decontextualizer = decontextualizer
# Wird gesetzt, sobald das Modell ein "no tool endpoints"-404 liefert →
# für den Rest des Turns ohne Tools (kein Retry-Hänger, keine stille Stale).
self._tools_unsupported = False
def _mark_tools_unsupported(self, body: str) -> None:
if not self._tools_unsupported:
logger.warning("Modell %r kann kein Tool-Calling → web_search für diesen Turn "
"deaktiviert (plain). Backstop nutzt weiter direkte Suche. %s",
self.model, (body or "")[:160])
metrics.inc("tool_unsupported_total")
self._tools_unsupported = True
async def _resolve_text(self, text: str, history, language) -> str:
"""Koreferenz-Vorstufe (gegated) — löst Pronomen aus der History auf."""
if self.decontextualizer is not None and history:
return await self.decontextualizer.run(text, history, language)
return text
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 and not self._tools_unsupported:
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)
# Modell ohne Tool-Endpoint: einmalig merken, Tools entfernen, sofort
# ohne Tools erneut (kein Retry-Hänger, keine stille Stale).
if _looks_tool_unsupported(resp.status_code, resp.text, "tools" in payload):
self._mark_tools_unsupported(resp.text)
payload.pop("tools", None)
payload.pop("tool_choice", None)
continue
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()
try:
data = resp.json()
except Exception: # noqa: BLE001 — non-JSON-200 kommt transient vor
data = None
# Transiente Fehler kommen teils als HTTP 200 mit {"error":...} oder
# gar nicht-JSON-Body -> als retrybar behandeln.
if not isinstance(data, dict) or "choices" not in data:
if attempt < self.max_retries:
last_error = RuntimeError(f"Ungültige/leere Antwort: {resp.text[:200]}")
await asyncio.sleep(min(2.0 * attempt, 30.0))
continue
raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[: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) -> tuple[str, list]:
"""Führt einen Tool-Aufruf aus; liefert (tool-Message-Inhalt, Citations)."""
fn = tool_call.get("function", {})
name = fn.get("name", "")
metrics.inc("tool_calls_total", {"tool": name or "unknown"})
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)
return result.text, list(getattr(result, "citations", []) or [])
async def _inject_forced_search(self, messages: list[dict], query: str,
language: str | None) -> list:
"""Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das
Ergebnis als **System-Kontext** einspeisen. Bewusst KEIN synthetischer
tool_call: System-Kontext funktioniert mit tool-fähigen UND tool-unfähigen
Modellen (letztere lehnen tool-Messages mit 405 ab). Verlässlicher als
`tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).
Liefert die Citations zurück."""
tool = self.tools.get("web_search")
if tool is None:
return []
metrics.inc("tool_calls_total", {"tool": "web_search"})
result = await tool.run(query, language=language)
messages.append({
"role": "system",
"content": ("Aktuelle Web-Suchergebnisse zur Nutzerfrage (maßgeblich, neuer als "
"dein Trainingswissen):\n" + result.text +
"\n\nBeantworte die Frage des Nutzers auf Basis dieser Ergebnisse."),
})
return list(getattr(result, "citations", []) or [])
async def complete(self, text: str, history: list[dict] | None = None,
session_id: str | None = None, language: str | None = None) -> str:
text = await self._resolve_text(text, history, language)
messages = self._initial_messages(text, history, language)
if should_force_search(text):
metrics.inc("search_forced_total")
await self._inject_forced_search(messages, text, language)
for _round 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, _cites = 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
@staticmethod
async def _emit_citations(cites: list, on_citations) -> None:
if cites and on_citations is not None:
await on_citations(cites)
async def stream(self, text: str, history: list[dict] | None = None,
session_id: str | None = None, language: str | None = None,
on_tool_start=None, on_citations=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).
"""
text = await self._resolve_text(text, history, language)
messages = self._initial_messages(text, history, language)
if should_force_search(text):
metrics.inc("search_forced_total")
if on_tool_start is not None:
await on_tool_start(language) # Filler deckt die erzwungene Suche
cites = await self._inject_forced_search(messages, text, language)
await self._emit_citations(cites, on_citations)
for _round in range(self.max_rounds):
acc = _StreamAcc()
try:
async for piece in self._stream_chat(messages, allow_tools=True, acc=acc):
yield piece
except _ToolsUnsupported:
# Modell ohne Tool-Endpoint -> Runde plain (ohne Tools) wiederholen.
acc = _StreamAcc()
async for piece in self._stream_chat(messages, allow_tools=False, 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, cites = await self._run_tool(tc, language)
await self._emit_citations(cites, on_citations)
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 and not self._tools_unsupported:
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()).decode(errors="replace")
# Modell ohne Tool-Endpoint: merken + signalisieren (Caller wiederholt ohne Tools).
if _looks_tool_unsupported(resp.status_code, body, "tools" in payload):
self._mark_tools_unsupported(body)
raise _ToolsUnsupported()
raise RuntimeError(f"OpenRouter {resp.status_code}: {body[: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"])

View file

@ -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"),

View file

@ -34,6 +34,7 @@ class PipelineTrace(BaseModel):
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
citations: list[str] = [] # Quellen-URLs der Web-Suche (für die UI-Bubble)
class SpeakRequest(BaseModel):

0
app/tools/__init__.py Normal file
View file

Some files were not shown because too many files have changed in this diff Show more