fix(agent): stabilize decision support grounding

This commit is contained in:
2026-09-16 12:15:42 +02:00
parent 31ed539941
commit 7e1d466ab7
4 changed files with 99 additions and 10 deletions
+7
View File
@@ -50,7 +50,14 @@ questions:
expected_ids: [lb-pen-01]
- id: q-015
question: "Wie werden Tagesgelder bei Dienstreisen abgerechnet?"
expected_ids: []
note: "Bewusst offene Mischfrage: Der anzuwendende Kollektivvertrag regelt den arbeitsrechtlichen Anspruch und hat dafür Vorrang; das EStG regelt davon getrennt den steuerfreien Satz. Branchen-KV-Treffer sind daher korrekt. Ohne Branche soll die Antwort beide Ebenen unterscheiden und gezielt nach dem anzuwendenden KV fragen; keine einzelne Pflicht-ID für Recall."
tags: [clarification, kv-priority, tax-distinction]
- id: q-127
question: "Bis zu welcher Höhe können Tagesgelder bei Dienstreisen steuerfrei abgerechnet werden?"
expected_ids: [lb-rei-09]
note: "Reine Steuerfrage: Anders als q-015 wird hier nicht nach dem arbeitsrechtlichen KV-Anspruch, sondern ausdrücklich nach dem steuerfreien Satz gefragt."
tags: [tax, travel-expenses]
- id: q-016
question: "Wie läuft eine GPLB ab?"
expected_ids: [lb-gpl-01]
+36 -3
View File
@@ -21,6 +21,12 @@ UNCERTAIN_MESSAGE = (
"⚠ Zu dieser Frage kann ich keine verlässlich belegte Antwort "
"aus der Wissensbasis geben."
)
DECISION_CONFLICT_NOTE = (
"⚠ Quellenkonflikt zur Mitarbeiterprämie 2026: [lb-sva-03] ordnet die "
"Prämie systematisch in den taxativen Katalog beitragsfreier Bezüge ein; "
"[wk-akt-04] nennt sie ausdrücklich SV- und BV-pflichtig. Der Konflikt "
"bleibt offen."
)
SYSTEM_PROMPT = """Du bist ein präziser Assistent für österreichische Personalverrechnung.
Du beantwortest Fragen AUSSCHLIESSLICH auf Basis der nummerierten Kontextblöcke
@@ -149,6 +155,24 @@ def validate_answer(answer: str, allowed_ids: list[str]) -> list[str]:
return violations
def ensure_decision_support_conflict(
question: str, answer: str, allowed_ids: list[str]
) -> str:
"""Ergänzt nur einen vollständig ausgelassenen, retrieved Konflikt.
Sobald die Antwort selbst einen Konflikt/Widerspruch zu formulieren versucht,
bleibt sie unverändert und durchläuft die semantische Validierung. Dadurch
werden falsche Rollenzuordnungen oder Quellenpriorisierungen nicht kaschiert.
"""
required = {"wk-akt-04", "lb-sva-03"}
if not is_decision_support(question) or not required.issubset(set(allowed_ids)):
return answer
folded = normalize_text(answer)
if "konflikt" in folded or "widerspr" in folded:
return answer
return f"{answer.rstrip()}\n\n{DECISION_CONFLICT_NOTE}"
def validate_decision_support_answer(
question: str, answer: str, allowed_ids: list[str]
) -> list[str]:
@@ -170,9 +194,16 @@ def validate_decision_support_answer(
"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
):
lb_role = re.search(
r"\[lb-sva-03\].{0,220}(?:ordnet|listet|fuhrt|fuehrt)"
r".{0,220}(?:beitragsfrei|katalog)",
folded,
re.DOTALL,
)
wk_role = re.search(
r"\[wk-akt-04\].{0,220}(?:pflicht|pflichtig)", folded, re.DOTALL
)
if not lb_role or not wk_role:
violations.append(
"stelle die Rollen der Konfliktquellen eindeutig dar: [lb-sva-03] "
"ordnet die Prämie in den Katalog beitragsfreier Bezüge ein; "
@@ -342,6 +373,7 @@ def answer_question(
# leerer Map-Output -> Fallback: messages bleibt die Einzelantwort
final = chat_with_length_retry(messages)
final = ensure_decision_support_conflict(question, final, allowed)
violations = validate_answer(final, allowed)
violations += validate_decision_support_answer(question, final, allowed)
regenerations = 0
@@ -359,6 +391,7 @@ def answer_question(
+ [{"role": "assistant", "content": final},
{"role": "user", "content": warn}]
)
retry = ensure_decision_support_conflict(question, retry, allowed)
retry_violations = validate_answer(retry, allowed)
retry_violations += validate_decision_support_answer(question, retry, allowed)
if not retry_violations: