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

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