Stufe 1: komplexe Fragen via Query-Planer, Per-Query-Slots, Scope-Filter (D12, M6)
- Query-Planer (agent/query_planner.py): Heuristik-Gate (Jahr, Vergleichs-/ Aggregationsmarker, Laenge) entscheidet ueber einen kleinen LLM-Call, der komplexe Fragen in 1-3 Sub-Queries zerlegt (JSON, temp 0; Fehler -> Original als Einzel-Query). Je Sub-Query optional stand_year und scope. - Multi-Query-Retrieval: je Sub-Query BM25+Dense mit RRF-Summe; Per-Query-Slots (2 je Sub-Query) sichern jeden Frageaspekt im Kontext (sonst dominieren Eintraege, die in mehreren Sub-Queries mittelgut matchen — q-113-Befund). Scope-Filter: 'gesetz' (nur Lexis/WIKU/RIS) und 'kv' (nur Branchen-KV) mit Fallback auf unscoped bei leerem Ergebnis. Temporal-Intent: stand_year + temporal_boost (default 0, FTS-jahr-Tag-Signal reichte). - Grounding unveraendert: eine Retrieved-Menge (Union), eine Antwort, Post-Validierung ueber die Union, Verweigerungspflicht unveraendert. - Goldset 42 -> 46: q-110 (Temporal 2023; 2024er-Lohnordnung existiert im Korpus nicht - Mantelvertrag ohne Lohntabelle, korrekt verweigert), q-111 (2025), q-112 (Abfertigung-Vergleich), q-113 (Gesetz+KV). - Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 % (Gate >94,3 % erfuellt), erwartete Quelle 90,2 %, Latenz mean 33,5 s. - Tests 50 -> 57. Reports lokal: data/eval-qwen38-stage1*.json. API-first festgehalten (D13-Vorbereitung): Odoo bleibt duenner Client; Lohndaten-Zugriff in M4 erfordert Privacy-Neubewertung.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""Query-Planer für komplexe Fragen (Stufe 1, M6).
|
||||
|
||||
Ein kleiner LLM-Call zerlegt komplexe Fragen in 1-3 unabhängige Sub-Queries
|
||||
(+ optional Geltungsjahr). Ein Heuristik-Gate entscheidet, ob der Planer-Call
|
||||
überhaupt läuft — einfache Fragen bleiben deterministischer Single-Shot
|
||||
(keine Zusatz-Latenz). Parse-/Call-Fehler fallen auf die Original-Frage
|
||||
zurück; die Grounding-Regeln werden dadurch nie berührt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
|
||||
JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
|
||||
|
||||
AGGREGATION_RE = re.compile(
|
||||
r"neuerungen|übersicht|zusammenfassung|alle\s|übersicht", re.I
|
||||
)
|
||||
COMPARISON_RE = re.compile(r"unterschied|vergleic|\bbzw\.|\bsowie\b", re.I)
|
||||
|
||||
PLANNER_PROMPT = """Du planst Suchanfragen für eine Wissensbasis zur österreichischen
|
||||
Personalverrechnung (kuratierte Briefings, Kollektivverträge je Branche und
|
||||
Geltungsjahr, Gesetzesparagraphen).
|
||||
|
||||
Frage: "{question}"
|
||||
|
||||
Zerlege die Frage in 1-3 unabhängige Suchanfragen, die zusammen die Frage
|
||||
beantworten. Regeln:
|
||||
- Besteht die Frage aus einem Suchthema, gib genau eine Suchanfrage zurück
|
||||
(dann die Frage unverändert, leicht stichwortartig gekürzt).
|
||||
- Suchbegriffe statt Sätze (ohne Frageformulierung, ohne "Kollektivvertrag"-
|
||||
Wortballast).
|
||||
- Fragt die Frage nach einem bestimmten Jahr (Geltung/Stand), setze
|
||||
"stand_year" auf dieses vierstellige Jahr, sonst null.
|
||||
- Setze "scope" je Suchanfrage: "gesetz", wenn nach der gesetzlichen/
|
||||
allgemeinen Grundlage gefragt ist (nur Gesetze und Fachbriefings, ohne
|
||||
Branchen-Kollektivverträge); "kv", wenn ausdrücklich nach kollektivvertrag-
|
||||
lichen Branchenregelungen gefragt ist; null für alles andere.
|
||||
- Antworte ausschließlich mit JSON, ohne Erklärung:
|
||||
{{"queries": [{{"text": "...", "stand_year": null, "scope": null}}]}}"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubQuery:
|
||||
text: str
|
||||
stand_year: str | None = None # YYYY, wenn nach einem Geltungsjahr gefragt
|
||||
scope: str | None = None # "gesetz" (ohne Branchen-KV) | "kv" | None
|
||||
|
||||
|
||||
def should_plan(question: str) -> bool:
|
||||
"""Heuristik-Gate: nur komplexe Fragen bekommen einen Planer-Call.
|
||||
|
||||
Signale: Jahreszahl, Vergleichs-/Aggregationsmarker, langer Text,
|
||||
Mehrfach-Konjunktion. Einfache Fragen bleiben Single-Shot (Latenz)."""
|
||||
q = question.strip()
|
||||
if YEAR_RE.search(q):
|
||||
return True
|
||||
words = q.split()
|
||||
if len(words) >= 14:
|
||||
return True
|
||||
if COMPARISON_RE.search(q):
|
||||
return True
|
||||
if AGGREGATION_RE.search(q):
|
||||
return True
|
||||
if " und " in q.casefold() and len(words) >= 10:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_plan(raw: str, original: str) -> list[SubQuery]:
|
||||
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]."""
|
||||
try:
|
||||
match = JSON_RE.search(raw)
|
||||
if not match:
|
||||
raise ValueError("kein JSON-Objekt")
|
||||
data = json.loads(match.group(0))
|
||||
items = data.get("queries")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("leeres Plan-Array")
|
||||
subs: list[SubQuery] = []
|
||||
for item in items[:3]:
|
||||
text = str(item.get("text", "")).strip()
|
||||
if not text:
|
||||
raise ValueError("leere Sub-Query")
|
||||
year = item.get("stand_year")
|
||||
year = str(year) if year and re.fullmatch(r"20\d{2}", str(year)) else None
|
||||
scope = item.get("scope")
|
||||
scope = scope if scope in ("gesetz", "kv") else None
|
||||
subs.append(SubQuery(text=text, stand_year=year, scope=scope))
|
||||
return subs
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return [SubQuery(text=original)]
|
||||
|
||||
|
||||
def plan_queries(
|
||||
question: str,
|
||||
client,
|
||||
cfg,
|
||||
) -> tuple[list[SubQuery], bool]:
|
||||
"""Liefert (Sub-Queries, geplant?) — Call-/Parse-Fehler → Original als
|
||||
Einzel-Query. Der Planer-Call ist klein (Frage ohne Kontext, kurzes
|
||||
num_predict); Temperature 0."""
|
||||
if not should_plan(question):
|
||||
return [SubQuery(text=question)], False
|
||||
prompt = PLANNER_PROMPT.format(question=question.strip())
|
||||
try:
|
||||
raw = client.chat(
|
||||
cfg.planner_model or cfg.answer_model,
|
||||
[
|
||||
{"role": "system", "content": "Du antwortest ausschließlich mit JSON."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0.0,
|
||||
num_ctx=cfg.num_ctx,
|
||||
num_predict=cfg.planner_num_predict,
|
||||
think=False,
|
||||
)
|
||||
except Exception:
|
||||
return [SubQuery(text=question)], False
|
||||
return parse_plan(raw, question), True
|
||||
Reference in New Issue
Block a user