feat(agent): support cost questions and thinking fallback

This commit is contained in:
2026-09-17 00:02:38 +02:00
parent af8bfcaa6a
commit 5090f07880
5 changed files with 200 additions and 35 deletions
+10
View File
@@ -185,6 +185,16 @@ questions:
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]
- id: q-128
question: "Mein Mitarbeiter verdient EUR 3000 brutto. Ich möchte ihm einmalig 500,- bar auszahlen. Was ist hier die günstigste Lösung?"
expected_ids: [wk-akt-04, lb-son-04, lb-sva-06]
note: "Gestaltungsfrage mit konkretem Einzelfall und Umlaut-freier Schreibweise (guenstigste loesung): Deterministischer Plan muss Kostendimensionen beisteuern — Lohnsteuer-Behandlung der einmaligen Barzahlung (lb-son-04) und Arbeitgeberbelastung (lb-sva-06). Keine pauschale Empfehlung ohne Abgabenvergleich; Antwort soll mit belegten Sätzen rechnen."
tags: [decision-support, cost-calculation, umlaut-free]
- id: q-129
question: "Mein Mitarbeiter verdient EUR 3000 brutto. Ich möchte ihm einmalig 500,- bar auszahlen. Wieviel kostet mich das?"
expected_ids: [lb-son-04, lb-sva-06]
note: "Reine Kostenaufstellung: Arbeitgeberkosten der Barzahlung Schritt für Schritt aus belegten Sätzen (Lohnsteuer, AG-SV inkl. DB/DZ) rechnen; Annahmen aus dem Kontext als solche nennen."
tags: [decision-support, cost-calculation, umlaut-free]
# --- Komplexe Fragen (Stufe 1, M6: Multi-Query + Temporal-Intent) ---
- id: q-110
+51 -25
View File
@@ -12,7 +12,7 @@ import time
from .config import Config
from .normalize import normalize_text
from .ollama_client import OllamaClient
from .ollama_client import OllamaClient, OllamaError
from .query_planner import SubQuery, is_decision_support, plan_queries
from .retrieve import ChunkResult, Retriever
@@ -71,9 +71,18 @@ Verbindliche Regeln:
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
Lohnnebenkosten, Voraussetzungen sowie Stand. Nennt die Frage konkrete
Eckdaten (z. B. Bruttolohn, Betrag, Einmaligkeit, Barzahlung), wende die
belegten Sätze unmittelbar auf diesen Fall an: rechne mit den im Kontext
genannten Sätzen die Arbeitgeberkosten Schritt für Schritt (Lohnsteuer,
SV einschließlich DB und DZ, allfällige KommSt) und gib die Gesamtbelastung
an. Belege jede in die Rechnung eingehende Zahl mit [ID] und Stand; steht
ein Rechenwert nur allgemein im Kontext (z. B. Jahressechstel,
Bemessungsgrundlage), nenne diese Annahme explizit. Fehlt hingegen ein für
die Berechnung wesentlicher Parameter, stelle am Ende genau EINE gezielte
Rückfrage — nicht zusätzlich, wenn die Rechnung mit den Kontextwerten
möglich ist. Nenne Quellenwidersprüche
gemäß Regel 5. 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
@@ -268,7 +277,11 @@ def answer_question(
if cfg.planner_enabled:
try:
sub_queries, planned, qtype = plan_queries(question, client, cfg)
sub_queries = sub_queries[: cfg.planner_max_queries] or sub_queries[:1]
if not is_decision_support(question):
# LLM-Pläne bleiben auf die Maximalzahl begrenzt; der
# deterministische Abgaben-Vergleichsplan liefert nur gezielte
# Queries (max. 5) und darf sie vollständig behalten.
sub_queries = sub_queries[: cfg.planner_max_queries] or sub_queries[:1]
except Exception:
sub_queries, planned, qtype = [SubQuery(text=question)], False, "specific"
@@ -332,28 +345,41 @@ def answer_question(
def chat(msgs, num_predict: int | None = None):
"""Ein Chat-Zug; liefert (gestrippter Content, done_reason).
Bei done_reason='length' (Antwort bei num_predict abgeschnitten)
sind Zitationen ggf. unvollstaendig — der Aufrufer ruft einmal mit
doppeltem Budget neu (technischer Retry, kein Regel-Regeneration)."""
sind Zitationen ggf. unvollständig — der Aufrufer ruft einmal mit
doppeltem Budget neu (technischer Retry, kein Regel-Regeneration).
Liefert das Modell bei aktivem Thinking leeren Content (Antwort nur
im thinking-Feld bzw. Budget im Thinking aufgebraucht), wird einmal
ohne Thinking wiederholt."""
budget = num_predict or cfg.num_predict
if hasattr(client, "chat_full"):
raw, done_reason = client.chat_full(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=budget,
think=cfg.think,
def call(use_think: bool):
if hasattr(client, "chat_full"):
return client.chat_full(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=budget,
think=use_think,
)
return (
client.chat(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=budget,
think=use_think,
),
"stop",
)
else:
raw = client.chat(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=budget,
think=cfg.think,
)
done_reason = "stop"
try:
raw, done_reason = call(cfg.think)
except OllamaError:
if not cfg.think:
raise
raw, done_reason = call(False)
return strip_think(raw), done_reason
def chat_with_length_retry(msgs):
+66 -10
View File
@@ -13,6 +13,8 @@ import re
from dataclasses import dataclass
from datetime import date
from .normalize import normalize_text
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
@@ -20,9 +22,30 @@ 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)
# Muster gelten auf normalize_text(question) (NFKD-Folding). Neben dem
# gefoldeten "gunstig/losung" decken die Alternativen auch die ue-Schreibweise
# ab ("guenstigste loesung"), die Nutzer häufig ohne Umlaute eingeben.
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)",
r"gu(e)?nstig(e|er|ste)?\s+lo(e)?sung"
r"|abgabenbegu(e)?nstigt"
r"|steuerfrei.*(?:auszahl|pra(e)?m)"
r"|(?:zusa(e)?tzl?ich|extra).*(?:auszahl|pra(e)?m)"
r"|\beinmalig(e|n|r|s)?\b.{0,80}?\bauszah"
r"|\bbar\s+auszah|\bbarauszah"
r"|\b(?:wie\s*viel|wieviel)\b.{0,60}?\bkostet\b"
r"|\bkostet\s+mich\b"
r"|\bmitarbeiterpra(e)?m",
re.IGNORECASE,
)
BROAD_RE = re.compile(
r"gu(e)?nstig|m(?:o|e)?glichkeiten|\bstatt\b|alternativ", re.IGNORECASE
)
COST_INTENT_RE = re.compile(
r"\b(?:wie\s*viel|wieviel)\b.{0,60}?\bkostet\b"
r"|\bkostet\s+mich\b"
r"|\beinmalig(e|n|r|s)?\b.{0,80}?\bauszah"
r"|\bbar\s+auszah|\bbarauszah",
re.IGNORECASE,
)
@@ -59,8 +82,12 @@ class SubQuery:
def is_decision_support(question: str) -> bool:
"""Erkennt Gestaltungsfragen, die den deterministischen Plan benötigen."""
return bool(DECISION_SUPPORT_RE.search(question))
"""Erkennt Gestaltungsfragen, die den deterministischen Plan benötigen.
Die Frage wird vorher normalisiert, damit auch Schreibweisen ohne Umlaute
("guenstigste loesung") erkannt werden.
"""
return bool(DECISION_SUPPORT_RE.search(normalize_text(question)))
def should_plan(question: str) -> bool:
@@ -97,6 +124,7 @@ def decision_support_plan(
"""
if not is_decision_support(question):
return None
folded = normalize_text(question)
explicit_year = YEAR_RE.search(question)
year = explicit_year.group(0) if explicit_year else default_year
year_text = f" {year}" if year else ""
@@ -126,13 +154,41 @@ def decision_support_plan(
stand_year=year,
scope="gesetz",
)
broad = bool(
re.search(r"günstig|möglichkeiten|statt|alternative", question, re.IGNORECASE)
# Kostenaufstellung: einmalige Barzahlung -> Lohnsteuer-Behandlung der
# Bezüge plus Arbeitgeberbelastung (SV-Beitragssätze, DB/DZ, KommSt).
cost_tax = SubQuery(
text=(
"einmalige Bezüge Sonderzahlungen Lohnsteuer Jahressechstel "
"6 Prozent steuerfrei 620 Freibetrag Zuwendung EStG"
),
stand_year=year,
scope="gesetz",
)
# 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"
cost_lnk = SubQuery(
text=(
"Beitragssätze des jeweiligen Jahres für Dienstnehmer "
"Dienstgeberanteil Sozialversicherungsbeiträge DB DZ Zuschlag "
"zum Dienstgeberbeitrag Kommunalsteuer Lohnnebenkosten Zuwendung"
),
stand_year=year,
scope="gesetz",
)
broad = bool(BROAD_RE.search(folded))
cost_intent = bool(COST_INTENT_RE.search(folded))
queries = [direct]
if broad and not cost_intent:
# Ein Gestaltungsvergleich ist kein Korpus-Survey: Die gezielten
# Queries passen in den normalen Antwortpfad und vermeiden unnötiges
# Map-Reduce samt zusätzlicher Latenz/Fehlverweigerungsrisiko.
queries += [cash_and_benefits, purpose_bound]
elif broad:
# Explizite Kostenaufstellung: Die Zukunftssicherungs-Query verdrängt
# bei Slots für vier Queries die Beitrags-/Lohnsteuerquellen; die
# zweckgebundenen Alternativen bleiben über purpose_bound abgedeckt.
queries.append(purpose_bound)
if cost_intent:
queries += [cost_tax, cost_lnk]
return queries[:4], "specific"
def parse_plan(raw: str, original: str) -> tuple[list[SubQuery], str]:
+41
View File
@@ -53,6 +53,10 @@ def test_system_prompt_requires_contextual_decision_support():
assert "genau EINE gezielte" in SYSTEM_PROMPT
assert "Regelnummern niemals" in SYSTEM_PROMPT
assert "spekuliere nicht mit Trainingswissen" in SYSTEM_PROMPT
# Anwendung auf den konkreten Einzelfall (Kostenaufstellung)
assert "Arbeitgeberkosten Schritt für Schritt" in SYSTEM_PROMPT
assert "nenne diese Annahme explizit" in SYSTEM_PROMPT
assert "nicht zusätzlich, wenn die Rechnung" in SYSTEM_PROMPT
class TestDecisionSupportValidation:
@@ -244,6 +248,43 @@ def test_trim_results_drops_tail_under_budget():
assert trim_results(blocks, None) is blocks
def test_thinking_only_empty_content_retries_without_think(mini_index):
"""think=true: qwen3.8 lieferte fallweise leeren content (Antwort nur im
thinking-Feld). Ein OllamaError auf dem think-Zug -> einmaliger Retry ohne
thinking; die Anfrage scheitert nicht mehr mit 503."""
from agent.ollama_client import OllamaError
class ThinkingClient:
def __init__(self):
self.think_flags = []
def chat_full(self, model, messages, temperature=0.1, num_ctx=16384,
num_predict=1024, think=False):
self.think_flags.append(think)
if think:
raise OllamaError(
"empty response from qwen3.8:27b (keys: ['message'])"
)
return "Barauszahlung ist lohnsteuerpflichtig [lb-min-01].", "stop"
def chat(self, *a, **k):
raise AssertionError("chat() sollte via chat_full laufen")
def close(self):
pass
import dataclasses
cfg = dataclasses.replace(mini_index, think=True)
client = ThinkingClient()
result = answer_question(
"Was passiert bei einer Barauszahlung?", cfg, client=client
)
assert result["verified"] is True
assert result["citations"] == ["lb-min-01"]
assert client.think_flags == [True, False]
def test_length_retry_doubles_budget(mini_index):
"""done_reason='length' (abgeschnittene Antwort) -> ein technischer
Retry mit doppeltem num_predict; zaehlt nicht als Regel-Regenerierung."""
+32
View File
@@ -9,6 +9,7 @@ from agent.generate import answer_question
from agent.query_planner import (
SubQuery,
decision_support_plan,
is_decision_support,
parse_plan,
plan_queries,
should_plan,
@@ -37,6 +38,37 @@ def test_should_plan_gate():
assert not should_plan("Wie hoch ist der KV-Mindestlohn im Friseurgewerbe?")
def test_decision_support_matches_umlaut_free_phrasings():
"""Nutzer geben Umlaute oft als ae/oe/ue ein; der Trigger muss beide
Schreibweisen erkennen und für Kostenaufstellungen eigene Queries liefern."""
q1 = (
"mein mitarbeiter verdient EUR 3000 brutto. ich moechte ihm einmalig "
"500,- bar auszahlen, was ist hier die guenstigste loesung?"
)
q2 = (
"mein mitarbeiter verdient EUR 3000 brutto. ich moechte ihm einmalig "
"500,- bar auszahlen, wieviel kostet mich das?"
)
assert is_decision_support(q1)
assert is_decision_support(q2)
subs1, qtype1 = decision_support_plan(q1, default_year="2026")
assert qtype1 == "specific"
assert len(subs1) == 4 # Vergleichs- + Kostendimension, Zukunftssicherung fällt weg
assert all(s.scope == "gesetz" for s in subs1)
texts1 = [s.text for s in subs1]
assert any("einmalige Bezüge" in t for t in texts1)
assert any("Dienstgeberbeitrag" in t for t in texts1)
subs2, qtype2 = decision_support_plan(q2, default_year="2026")
assert qtype2 == "specific"
assert len(subs2) == 3
texts = [s.text for s in subs2]
assert any("einmalige Bezüge" in t for t in texts)
assert any("Dienstgeberbeitrag" in t for t in texts)
assert all(s.scope == "gesetz" for s in subs2)
def test_decision_support_plan_splits_direct_payment_and_alternatives():
planned = decision_support_plan(
"Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. "