diff --git a/app/dependencies.py b/app/dependencies.py index 7d3cb4e..f4df509 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -28,6 +28,7 @@ from app.providers.fallback import ( FallbackLLMProvider, FallbackTTSProvider, ) +from app.pipeline.decontextualizer import Decontextualizer from app.pipeline.input_cleaner import InputCleaner from app.pipeline.spoken_response_adapter import SpokenResponseAdapter from app.pipeline.tts_normalizer import TTSNormalizer @@ -63,6 +64,7 @@ LLM_REGISTRY = { 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, diff --git a/app/pipeline/decontextualizer.py b/app/pipeline/decontextualizer.py new file mode 100644 index 0000000..e507f1f --- /dev/null +++ b/app/pipeline/decontextualizer.py @@ -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('"') diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 3d52f92..b3d90ab 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -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): diff --git a/app/tools/web_search.py b/app/tools/web_search.py index 2162674..fa1ca67 100644 --- a/app/tools/web_search.py +++ b/app/tools/web_search.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field import httpx +from app.metrics import metrics from app.providers.llm.base import lang_instruction logger = logging.getLogger(__name__) @@ -58,6 +59,11 @@ class SonarTool: {"role": "user", "content": query.strip()}] async def run(self, query: str, language: str | None = None) -> ToolResult: + result = await self._search(query, language) + metrics.inc("sonar_calls_total", {"status": "ok" if result.ok else "error"}) + return result + + async def _search(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: