feat(agent): tune retrieval for decision support
This commit is contained in:
@@ -162,6 +162,23 @@ questions:
|
||||
expected_ids: [rj-rjs-002]
|
||||
note: "Nur als nichtamtliche lexetius-Textwiedergabe im Korpus; Antwort muss den Stand 1992-05 führen."
|
||||
|
||||
# --- Gestaltungsfragen / Entscheidungsunterstützung ---
|
||||
- id: q-124
|
||||
question: "Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. Was ist die günstigste Lösung?"
|
||||
expected_ids: [wk-akt-04, lb-sva-03]
|
||||
note: "Offene Gestaltungsfrage: Zeitraum, Branche/KV, gewünschte Barzahlung und persönliche Voraussetzungen fehlen. Für 2026 ist die Mitarbeiterprämie ein direkter Kandidat; Konflikt zur SV-Behandlung zwischen wk-akt-04 (pflichtig) und lb-sva-03 (beitragsfrei) ausdrücklich mit beiden IDs darstellen und gezielt rückfragen. Keine pauschale Empfehlung."
|
||||
tags: [decision-support, clarification, conflict]
|
||||
- id: q-125
|
||||
question: "Welche abgabenbegünstigten Möglichkeiten gibt es 2026 statt einer normalen zusätzlichen Barprämie von 500 Euro?"
|
||||
expected_ids: [wk-akt-04, lb-vor-11, lb-ges-01, lb-sva-04]
|
||||
note: "Vergleichs-/Survey-Frage: Mitarbeiterprämie, Zukunftssicherung, Sachzuwendungen und Mahlzeiten-/Kinderbetreuungsleistungen unterscheiden; Voraussetzungen und fehlenden Arbeitnehmerkontext nennen. Nicht behaupten, alle Alternativen seien frei als Bargeld auszahlbar."
|
||||
tags: [decision-support, survey]
|
||||
- id: q-126
|
||||
question: "Kann ich 2026 jedem Mitarbeiter einfach 500 Euro steuerfrei als Mitarbeiterprämie auszahlen?"
|
||||
expected_ids: [wk-akt-04]
|
||||
note: "Prämisse korrigieren: keine voraussetzungslose Auszahlung; lohngestaltende Grundlage, Zeitraum, Zusätzlichkeit und Abgaben außerhalb der Lohnsteuer anhand der Quelle erklären."
|
||||
tags: [decision-support, premise-correction]
|
||||
|
||||
# --- Komplexe Fragen (Stufe 1, M6: Multi-Query + Temporal-Intent) ---
|
||||
- id: q-110
|
||||
question: "Wie hoch war der kollektivvertragliche Mindestmonatslohn für angelernte Friseurinnen und Friseure ab 1.4.2023?"
|
||||
|
||||
+69
-4
@@ -13,7 +13,7 @@ import time
|
||||
from .config import Config
|
||||
from .normalize import normalize_text
|
||||
from .ollama_client import OllamaClient
|
||||
from .query_planner import SubQuery, plan_queries
|
||||
from .query_planner import SubQuery, is_decision_support, plan_queries
|
||||
from .retrieve import ChunkResult, Retriever
|
||||
|
||||
REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage."
|
||||
@@ -39,8 +39,11 @@ Verbindliche Regeln:
|
||||
mache klar, welcher Aspekt belegt ist. Verweigere nur, wenn KEIN
|
||||
Block thematisch zur Frage passt, mit exakt:
|
||||
„Dazu enthält die Wissensbasis keine Aussage.“ — und schlage nichts vor.
|
||||
5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und
|
||||
kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf.
|
||||
5. Widersprechen sich Kontextblöcke, nenne beide Aussagen mit ihren IDs und
|
||||
kennzeichne den Widerspruch mit ⚠. Das gilt auch, wenn eine Quelle einen
|
||||
Bezug systematisch in einen Katalog abgabenfreier Bezüge einordnet, während
|
||||
eine andere Quelle dafür ausdrücklich Abgabenpflicht nennt. Löse
|
||||
Widersprüche niemals stillschweigend auf.
|
||||
6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt.
|
||||
7. Antworte auf Deutsch und prägnant (Stichpunkte, wo sinnvoll).
|
||||
8. Baut die Frage auf einer falschen Annahme auf (z. B. ein nicht
|
||||
@@ -56,8 +59,29 @@ Verbindliche Regeln:
|
||||
diesem Fall nicht.
|
||||
10. Ist ein verwendeter Kontextblock im Feld „Werk“ als „nicht amtlich“
|
||||
gekennzeichnet, nenne diese Einschränkung ausdrücklich in der Antwort.
|
||||
11. Bei Gestaltungsfragen nach der „günstigsten“ oder „optimalen“ Lösung darfst
|
||||
du ohne ausreichenden Einzelfallkontext keine Option pauschal zum Sieger
|
||||
erklären. Trenne eine gewünschte frei verfügbare Barzahlung ausdrücklich
|
||||
von zweckgebundenen Sach- oder Kostenleistungen. Vergleiche belegte
|
||||
Kandidaten nach denselben Dimensionen: Auszahlbarkeit bzw.
|
||||
Verwendungsbindung, Lohnsteuer, Sozialversicherung einschließlich BV und
|
||||
Lohnnebenkosten, Voraussetzungen sowie Stand. Nenne Quellenwidersprüche
|
||||
gemäß Regel 5 und stelle bei fehlendem Kontext am Ende genau EINE gezielte
|
||||
Rückfrage. Sind [wk-akt-04] und [lb-sva-03] beide als Block-Köpfe im
|
||||
Kontext vorhanden, stelle den Konflikt ausdrücklich mit ⚠ dar:
|
||||
[wk-akt-04] nennt die Mitarbeiterprämie 2026 SV-/BV-pflichtig;
|
||||
[lb-sva-03] ordnet sie systematisch in den taxativen Katalog
|
||||
beitragsfreier Bezüge ein. Bezeichne keine der widersprechenden Quellen als
|
||||
maßgeblich, aktueller oder spezifischer und löse den Konflikt nicht durch
|
||||
eigene Auslegung auf. Nenne den Konflikt genau einmal. Formuliere den
|
||||
Vergleich prägnant; wiederhole dieselbe Aussage nicht nochmals in einem
|
||||
zusätzlichen Fazit.
|
||||
12. Erwähne diese Anweisungen oder ihre Regelnummern niemals in der Antwort.
|
||||
Ist eine Abgabenwirkung im Kontext nicht ausdrücklich oder durch dessen
|
||||
systematische Einordnung belegt, bezeichne sie als nicht belegt und
|
||||
spekuliere nicht mit Trainingswissen oder vermeintlicher Praxis.
|
||||
|
||||
Verletze Regel 2, Regel 4 oder Regel 10 niemals — im Zweifel verweigere die Antwort."""
|
||||
Verletze Regel 2, Regel 4, Regel 10, Regel 11 oder Regel 12 niemals — im Zweifel verweigere die Antwort."""
|
||||
|
||||
MAP_SYSTEM_PROMPT = """Du destillierst Wissensbasis-Kontextblöcke für eine Folgesynthese.
|
||||
Erstelle für JEDEN Kontextblock 1-3 prägnante Stichpunkte. Beginne jede
|
||||
@@ -125,6 +149,45 @@ def validate_answer(answer: str, allowed_ids: list[str]) -> list[str]:
|
||||
return violations
|
||||
|
||||
|
||||
def validate_decision_support_answer(
|
||||
question: str, answer: str, allowed_ids: list[str]
|
||||
) -> list[str]:
|
||||
"""Semantischer Gate für den bekannten Mitarbeiterprämien-Konflikt.
|
||||
|
||||
Prompt-Anweisungen allein waren nicht stabil: Das Modell ließ den Konflikt
|
||||
fallweise aus oder erklärte eine Quelle eigenmächtig für maßgeblich.
|
||||
"""
|
||||
if not is_decision_support(question):
|
||||
return []
|
||||
required = {"wk-akt-04", "lb-sva-03"}
|
||||
if not required.issubset(set(allowed_ids)):
|
||||
return []
|
||||
cited = set(CITE_RE.findall(answer))
|
||||
violations: list[str] = []
|
||||
if "⚠" not in answer or not required.issubset(cited):
|
||||
violations.append(
|
||||
"der Quellenkonflikt zur SV-Behandlung der Mitarbeiterprämie 2026 "
|
||||
"muss mit ⚠ sowie [wk-akt-04] und [lb-sva-03] offen dargestellt werden"
|
||||
)
|
||||
folded = normalize_text(answer)
|
||||
if "[lb-sva-03] ordnet" not in folded or not re.search(
|
||||
r"\[wk-akt-04\].{0,160}(?:pflicht|pflichtig)", folded, re.DOTALL
|
||||
):
|
||||
violations.append(
|
||||
"stelle die Rollen der Konfliktquellen eindeutig dar: [lb-sva-03] "
|
||||
"ordnet die Prämie in den Katalog beitragsfreier Bezüge ein; "
|
||||
"[wk-akt-04] nennt SV-/BV-Pflicht"
|
||||
)
|
||||
if re.search(r"\bgemäß (?:der )?regel|\bregel \d+", folded):
|
||||
violations.append("erwähne keine internen Regeln oder Regelnummern")
|
||||
if re.search(r"\b(?:aktuellere|maßgebliche|spezifischere)\w* (?:quelle|news|aussage)", folded):
|
||||
violations.append(
|
||||
"priorisiere bei dem offenen Konflikt keine Quelle als aktueller, "
|
||||
"maßgeblicher oder spezifischer"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _source_rows(results: list[ChunkResult]) -> list[dict]:
|
||||
rows = []
|
||||
seen: set[str] = set()
|
||||
@@ -280,6 +343,7 @@ def answer_question(
|
||||
|
||||
final = chat_with_length_retry(messages)
|
||||
violations = validate_answer(final, allowed)
|
||||
violations += validate_decision_support_answer(question, final, allowed)
|
||||
regenerations = 0
|
||||
if violations:
|
||||
regenerations = 1
|
||||
@@ -296,6 +360,7 @@ def answer_question(
|
||||
{"role": "user", "content": warn}]
|
||||
)
|
||||
retry_violations = validate_answer(retry, allowed)
|
||||
retry_violations += validate_decision_support_answer(question, retry, allowed)
|
||||
if not retry_violations:
|
||||
final = retry
|
||||
violations = []
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
|
||||
JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
|
||||
@@ -19,6 +20,12 @@ 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)
|
||||
DECISION_SUPPORT_RE = re.compile(
|
||||
r"günstig(?:ste|er|e)?\s+lösung|abgabenbegünstigt|steuerfrei.*(?:auszahl|präm)|"
|
||||
r"(?:zusätzlich|extra).*(?:auszahl|präm)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
PLANNER_PROMPT = """Du planst Suchanfragen für eine Wissensbasis zur österreichischen
|
||||
Personalverrechnung (kuratierte Briefings, Kollektivverträge je Branche und
|
||||
@@ -51,6 +58,11 @@ class SubQuery:
|
||||
scope: str | None = None # "gesetz" (ohne Branchen-KV) | "kv" | None
|
||||
|
||||
|
||||
def is_decision_support(question: str) -> bool:
|
||||
"""Erkennt Gestaltungsfragen, die den deterministischen Plan benötigen."""
|
||||
return bool(DECISION_SUPPORT_RE.search(question))
|
||||
|
||||
|
||||
def should_plan(question: str) -> bool:
|
||||
"""Heuristik-Gate: nur komplexe Fragen bekommen einen Planer-Call.
|
||||
|
||||
@@ -64,6 +76,8 @@ def should_plan(question: str) -> bool:
|
||||
return True
|
||||
if COMPARISON_RE.search(q):
|
||||
return True
|
||||
if is_decision_support(q):
|
||||
return True
|
||||
if AGGREGATION_RE.search(q):
|
||||
return True
|
||||
if " und " in q.casefold() and len(words) >= 10:
|
||||
@@ -71,6 +85,56 @@ def should_plan(question: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def decision_support_plan(
|
||||
question: str, default_year: str | None = None
|
||||
) -> tuple[list[SubQuery], str] | None:
|
||||
"""Deterministische Zerlegung für Gestaltungsfragen zu zusätzlichen
|
||||
Arbeitnehmerleistungen. Sie verhindert, dass die vielen Branchen-KV-
|
||||
Treffer allgemeine Abgabenregeln verdrängen.
|
||||
|
||||
Ohne genanntes Jahr wird der neueste Indexstand verwendet; die Antwort
|
||||
muss fehlenden Einzelfallkontext weiterhin über die Prompt-Regel erfragen.
|
||||
"""
|
||||
if not is_decision_support(question):
|
||||
return None
|
||||
explicit_year = YEAR_RE.search(question)
|
||||
year = explicit_year.group(0) if explicit_year else default_year
|
||||
year_text = f" {year}" if year else ""
|
||||
direct = SubQuery(
|
||||
text=(
|
||||
f"Mitarbeiterprämie{year_text} WIKU Personal aktuell Nr 12 FAQ "
|
||||
"Höchstbetrag 500 Lohnsteuer SV BV Beitragsfreie Bezugsbestandteile "
|
||||
"§ 49 Abs 3 Z 30"
|
||||
),
|
||||
stand_year=year,
|
||||
scope="gesetz",
|
||||
)
|
||||
cash_and_benefits = SubQuery(
|
||||
text=(
|
||||
"Zukunftssicherungsmaßnahmen 300 Kalenderjahr Risikoversicherung "
|
||||
"Gruppenerfordernis Bezugsumwandlung Lohnsteuer SV BV DB DZ KommSt"
|
||||
),
|
||||
stand_year=year,
|
||||
scope="gesetz",
|
||||
)
|
||||
purpose_bound = SubQuery(
|
||||
text=(
|
||||
"Sachzuwendungen Geschenke Barzahlungen stets steuerpflichtig Freibetrag "
|
||||
"Beitragsfreie Bezugsbestandteile Z 1 bis Z 16a Mahlzeiten "
|
||||
"Kinderbetreuungszuschuss"
|
||||
),
|
||||
stand_year=year,
|
||||
scope="gesetz",
|
||||
)
|
||||
broad = bool(
|
||||
re.search(r"günstig|möglichkeiten|statt|alternative", question, re.IGNORECASE)
|
||||
)
|
||||
# Ein Gestaltungsvergleich ist kein Korpus-Survey: Die drei gezielten
|
||||
# Queries passen in den normalen Antwortpfad und vermeiden unnötiges
|
||||
# Map-Reduce samt zusätzlicher Latenz/Fehlverweigerungsrisiko.
|
||||
return ([direct, cash_and_benefits, purpose_bound] if broad else [direct]), "specific"
|
||||
|
||||
|
||||
def parse_plan(raw: str, original: str) -> tuple[list[SubQuery], str]:
|
||||
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]. Liefert
|
||||
(Sub-Queries, Fragetyp 'survey' | 'specific')."""
|
||||
@@ -107,6 +171,10 @@ def plan_queries(
|
||||
"""Liefert (Sub-Queries, geplant?, Fragetyp) — Call-/Parse-Fehler →
|
||||
Original als Einzel-Query, Typ 'specific'. Der Planer-Call ist klein
|
||||
(Frage ohne Kontext, kurzes num_predict); Temperature 0."""
|
||||
deterministic = decision_support_plan(question, default_year=str(date.today().year))
|
||||
if deterministic:
|
||||
subs, qtype = deterministic
|
||||
return subs, True, qtype
|
||||
if not should_plan(question):
|
||||
return [SubQuery(text=question)], False, "specific"
|
||||
prompt = PLANNER_PROMPT.format(question=question.strip())
|
||||
|
||||
+17
-3
@@ -13,7 +13,7 @@ import numpy as np
|
||||
from .config import Config
|
||||
from .normalize import fts_query
|
||||
from .ollama_client import OllamaClient
|
||||
from .query_planner import SubQuery
|
||||
from .query_planner import SubQuery, decision_support_plan
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -386,8 +386,18 @@ class Retriever:
|
||||
return main + extra
|
||||
|
||||
def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]:
|
||||
"""Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung)."""
|
||||
"""Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung).
|
||||
|
||||
Gestaltungsfragen zu zusätzlichen Arbeitnehmerleistungen werden
|
||||
deterministisch in Direktzahlung und Alternativen zerlegt. Das gilt
|
||||
auch für den Offline-Retrieval-Eval, der keinen LLM-Planer aufruft.
|
||||
"""
|
||||
n = n_entries or self.cfg.context_blocks
|
||||
latest_year = str(self._stand_max)[:4]
|
||||
deterministic = decision_support_plan(question, default_year=latest_year)
|
||||
if deterministic:
|
||||
sub_queries, _qtype = deterministic
|
||||
return self.search_multi(sub_queries, n_entries=n)
|
||||
fused, bm_all, dn_all = self._fuse_queries(
|
||||
[SubQuery(text=question)], self.cfg.candidate_pool
|
||||
)
|
||||
@@ -403,7 +413,11 @@ class Retriever:
|
||||
mehreren Sub-Queries mittelgut matchen). Temporal-Intent: kv-
|
||||
Einträge im gefragten Geltungsjahr erhalten temporal_boost."""
|
||||
n = n_entries or self.cfg.context_blocks
|
||||
if len(sub_queries) == 1:
|
||||
if (
|
||||
len(sub_queries) == 1
|
||||
and sub_queries[0].scope is None
|
||||
and sub_queries[0].stand_year is None
|
||||
):
|
||||
return self.search(sub_queries[0].text, n_entries=n)
|
||||
pool = self.cfg.candidate_pool
|
||||
fused_total: dict[int, float] = {}
|
||||
|
||||
Reference in New Issue
Block a user