fix(agent): stabilize decision support grounding

This commit is contained in:
2026-09-16 12:15:42 +02:00
parent 50ba153904
commit 509b2f7544
4 changed files with 99 additions and 10 deletions
+34 -7
View File
@@ -5,11 +5,10 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
## Current focus
Retrieval-/Antwort-Tuning für komplexe Gestaltungsfragen läuft (D16).
Goldset q-124126 und deterministischer Decision-Support-Pfad sind umgesetzt;
offen ist der vollständige 53-Fragen-Antwortmodus-Eval. Danach folgen die
bewusste Publikations-/Lizenzfreigabe der quellentreuen Rechtsprechungs-
Volltexte und M4 (API-first Odoo-Integration inkl. separat zu planender
Decision-Support-Tuning (D16) ist stabilisiert und im 53-Fragen-
Bestätigungslauf zitierseitig validiert. Verbleibend ist die bekannte
stochastische Survey-Fehlverweigerung q-029; danach folgen Rechtsprechungs-
Publikations-/Lizenzfreigabe und M4 (API-first Odoo-Integration inkl.
Privacy-Grenze für Lohndaten).
## Completed (2026-09-14)
@@ -145,8 +144,36 @@ Privacy-Grenze für Lohndaten).
erzwingt ein semantischer Post-Validation-Gate ⚠ + beide IDs + korrekte
Rollen und verhindert interne Regelverweise bzw. Quellenpriorisierung.
Gezielte Real-Läufe: q-124/q-125 nicht verweigert, zitiergültig; q-126 war
bereits sauber. Tests **72 grün**; `git diff --check` sauber. Vollständiger
Antwortmodus-Eval steht noch aus.
bereits sauber. Tests **72 grün**; `git diff --check` sauber.
- **D16 Voll-Eval (2026-09-16):** 53 Fragen mit `qwen3.8:27b`; Report
`data/eval-qwen38-decision-support.json` (lokal/gitignored). Retrieval:
Hit-Rate 0,9583, Recall@8 **0,9271**, MRR 0,6266. Antworten:
Zitier-Präzision **98,11 %**, Verweigerung korrekt **98,11 %**, erwartete
Quelle 91,67 %, mean 40,3 s / p95 98,4 s, 4 Regenerierungen. Einziger
Gate-Fehler: q-125 eskalierte nach zwei semantisch unzureichenden Antworten
zu `UNCERTAIN` (`verified=false`). Direkte Wiederholung von q-125 war mit
einer Regenerierung vollständig, zitiergültig und stellte den Konflikt
korrekt dar — verbleibend ist stochastische Gate-/Regenerierungs-Flakiness,
kein Retrieval-Fehler. q-124 und q-126 bestanden im Voll-Eval.
- **D16 Stabilisierung/Bestätigung (2026-09-16):** Bei vollständig
ausgelassenem, aber retrieved Mitarbeiterprämien-Konflikt ergänzt die
Pipeline deterministisch eine feste, ausschließlich aus `lb-sva-03` und
`wk-akt-04` formulierte ⚠-Notiz. Eigene Konfliktversuche bleiben ungeändert
und werden weiter auf Rollenfehler/Priorisierung geprüft. Der semantische
Gate akzeptiert gleichwertige Verben (`ordnet`, `listet`, `führt`) statt
fragiler exakter Wortwahl. Sonden: q-124 3/3 ohne Regenerierung, q-125 3/3
verifiziert, q-126 verifiziert. Voller Bestätigungslauf (53 Fragen), Report
`data/eval-qwen38-decision-support-confirm.json`: Zitier-Präzision **100 %**,
erwartete Quelle 93,75 %, mean 40,3 s / p95 69,6 s, 3 Regenerierungen;
q-124126 alle bestanden. Verweigerungskorrektheit 98,11 % nur wegen der
bekannten stochastischen Survey-Fehlverweigerung q-029; direkte q-029-
Wiederholung antwortete verifiziert (aber sehr langsam, 280 s).
- **q-015 fachlich rekalibriert:** Eine allgemeine Tagesgeldfrage mischt zwei
Ebenen: Der KV regelt vorrangig den arbeitsrechtlichen Anspruch, das EStG den
steuerfreien Satz. Branchen-KV-Treffer sind deshalb korrekt; q-015 hat keine
künstliche Einzel-Pflicht-ID mehr. Neue q-127 prüft ausdrücklich nur die
steuerliche Höhe und findet `lb-rei-09`. Goldset 54 Fragen; Offline-Retrieval
nun Hit-Rate 0,9792, Recall@8 **0,9479**, MRR 0,6653. Tests **75 grün**.
## Open issues / blockers
+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:
+22
View File
@@ -12,6 +12,7 @@ from agent.generate import (
UNCERTAIN_MESSAGE,
answer_question,
build_user_content,
ensure_decision_support_conflict,
looks_like_refusal,
strip_think,
trim_results,
@@ -61,6 +62,20 @@ class TestDecisionSupportValidation:
)
allowed = ["wk-akt-04", "lb-sva-03"]
def test_appends_fully_omitted_retrieved_conflict(self):
answer = ensure_decision_support_conflict(
self.question, "Die Prämie ist lohnsteuerfrei [wk-akt-04].", self.allowed
)
assert "⚠ Quellenkonflikt" in answer
assert "[lb-sva-03] ordnet" in answer
assert validate_decision_support_answer(self.question, answer, self.allowed) == []
def test_does_not_mask_existing_conflict_attempt(self):
original = "⚠ Widerspruch: Beide Quellen [wk-akt-04] [lb-sva-03]."
assert ensure_decision_support_conflict(
self.question, original, self.allowed
) == original
def test_requires_conflict_when_both_sources_are_in_context(self):
violations = validate_decision_support_answer(
self.question, "Nur lohnsteuerfrei [wk-akt-04].", self.allowed
@@ -74,6 +89,13 @@ class TestDecisionSupportValidation:
)
assert validate_decision_support_answer(self.question, answer, self.allowed) == []
def test_accepts_equivalent_listet_wording(self):
answer = (
"⚠ [wk-akt-04] nennt die Prämie SV- und BV-pflichtig. "
"[lb-sva-03] listet sie im Katalog als beitragsfreien Bezug."
)
assert validate_decision_support_answer(self.question, answer, self.allowed) == []
def test_rejects_reversed_source_roles_and_prompt_leakage(self):
answer = (
"⚠ [wk-akt-04] und [lb-sva-03] ordnen die Prämie als beitragsfrei "