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>
This commit is contained in:
Dieter Schlüter 2026-06-29 21:43:24 +02:00
commit c36b884aab
4 changed files with 105 additions and 1 deletions

View file

@ -17,6 +17,7 @@ from datetime import date
import httpx
from app.metrics import metrics
from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder
from app.providers.llm.openrouter import SYSTEM_PROMPT as PERSONA_PROMPT
@ -126,7 +127,8 @@ class _StreamAcc:
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):
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}
@ -134,6 +136,13 @@ class ToolCallingLLM(LLMProvider):
self.max_rounds = max(1, max_rounds)
self.max_retries = max(1, max_retries)
self.temperature = temperature
self.decontextualizer = decontextualizer
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:
@ -181,6 +190,7 @@ class ToolCallingLLM(LLMProvider):
"""Führt einen Tool-Aufruf aus und liefert den tool-Message-Inhalt."""
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)
@ -196,6 +206,7 @@ class ToolCallingLLM(LLMProvider):
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)
for _ in range(self.max_rounds):
@ -228,6 +239,7 @@ class ToolCallingLLM(LLMProvider):
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)
for _ in range(self.max_rounds):