From 6cd6e783aa153a1cfb2cd9d1ca81abe3b6926c4d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 22:58:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(search):=20deterministischer=20Backstop=20?= =?UTF-8?q?f=C3=BCr=20heikle=20Kategorien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docs/weg2-tool-calling.md | 15 +++++++++ app/pipeline/search_backstop.py | 52 +++++++++++++++++++++++++++++++ app/providers/llm/tool_calling.py | 41 ++++++++++++++++++++---- tests/test_search_backstop.py | 46 +++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/pipeline/search_backstop.py create mode 100644 tests/test_search_backstop.py diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 611dbd3..67730e9 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -122,6 +122,21 @@ 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 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). + ## 6. Die zwei harten Stellen (bewusst benannt) - **Streaming + Tool-Erkennung.** Beim gestreamten ersten Call kommen diff --git a/app/pipeline/search_backstop.py b/app/pipeline/search_backstop.py new file mode 100644 index 0000000..8e27fa4 --- /dev/null +++ b/app/pipeline/search_backstop.py @@ -0,0 +1,52 @@ +"""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 ", "aktueller ", " ... 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), +] + + +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) diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index b3d90ab..c665638 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -18,6 +18,7 @@ from datetime import date import httpx 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 @@ -175,13 +176,18 @@ class ToolCallingLLM(LLMProvider): 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":...} + 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"Antwort ohne 'choices': {str(data)[:200]}") + last_error = RuntimeError(f"Ungültige/leere Antwort: {resp.text[:200]}") await asyncio.sleep(min(2.0 * attempt, 30.0)) continue - raise RuntimeError(f"OpenRouter-Antwort ohne 'choices': {str(data)[:300]}") + raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[:300]}") return data["choices"][0]["message"] raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") @@ -204,12 +210,30 @@ class ToolCallingLLM(LLMProvider): # result.citations -> UI-Bubble (Fast-follow); hier (noch) nicht durchgereicht. return result.text + async def _inject_forced_search(self, messages: list[dict], query: str, + language: str | None) -> None: + """Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das + Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als + `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).""" + 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": "assistant", "content": None, "tool_calls": [ + {"id": "forced_0", "type": "function", + "function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]}) + messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text}) + 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 _ in range(self.max_rounds): + 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: @@ -241,8 +265,13 @@ class ToolCallingLLM(LLMProvider): """ 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 + await self._inject_forced_search(messages, text, language) - for _ in range(self.max_rounds): + for _round in range(self.max_rounds): acc = _StreamAcc() async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): yield piece diff --git a/tests/test_search_backstop.py b/tests/test_search_backstop.py new file mode 100644 index 0000000..b559d69 --- /dev/null +++ b/tests/test_search_backstop.py @@ -0,0 +1,46 @@ +"""Offline-Tests für den deterministischen Such-Backstop (heikle Kategorien).""" +import pytest + +from app.pipeline.search_backstop import should_force_search + +FORCE = [ + "Wer ist Bundeskanzler?", + "Wer ist aktuell Bundeskanzler?", + "Who is the president of the United States?", + "Wie heißt der Papst?", + "Wie is de huidige minister-president van Nederland?", + "Wer ist gerade Papst?", + "Wo wohnt Heino?", + "Wo lebt Heino heute?", + "Where does Madonna live these days?", + "Waar woont André Rieu tegenwoordig?", + "Lebt Heino noch?", + "Is Brigitte Bardot still alive?", + "Leeft Willeke Alberti nog?", +] + +NO_FORCE = [ + "Was ist die Hauptstadt von Frankreich?", + "Erzähl mir einen Witz.", + "Wie alt ist Heino?", + "Wer war der erste Bundeskanzler?", # historisch (Vergangenheit) + "Wann hat Heino Geburtstag?", + "Wie geht es dir heute?", + "Kannst du mir ein Rezept für Kartoffelsuppe geben?", + "Wer hat den Faust geschrieben?", +] + + +@pytest.mark.parametrize("text", FORCE) +def test_forces_on_heikle_kategorien(text): + assert should_force_search(text) is True, text + + +@pytest.mark.parametrize("text", NO_FORCE) +def test_no_force_on_harmless(text): + assert should_force_search(text) is False, text + + +def test_empty_and_none(): + assert should_force_search(None) is False + assert should_force_search("") is False