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>
This commit is contained in:
Dieter Schlüter 2026-06-29 22:58:03 +02:00
commit 6cd6e783aa
4 changed files with 148 additions and 6 deletions

View file

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

View file

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

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

View file

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