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:
parent
96dcd87ca8
commit
6cd6e783aa
4 changed files with 148 additions and 6 deletions
52
app/pipeline/search_backstop.py
Normal file
52
app/pipeline/search_backstop.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue