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

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