feat(agent): support cost questions and thinking fallback

This commit is contained in:
2026-09-17 00:02:38 +02:00
parent d9c612e354
commit cfa2f37986
5 changed files with 200 additions and 35 deletions
+10
View File
@@ -185,6 +185,16 @@ questions:
expected_ids: [wk-akt-04] 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." 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] 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) --- # --- Komplexe Fragen (Stufe 1, M6: Multi-Query + Temporal-Intent) ---
- id: q-110 - id: q-110
+51 -25
View File
@@ -12,7 +12,7 @@ import time
from .config import Config from .config import Config
from .normalize import normalize_text 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 .query_planner import SubQuery, is_decision_support, plan_queries
from .retrieve import ChunkResult, Retriever from .retrieve import ChunkResult, Retriever
@@ -71,9 +71,18 @@ Verbindliche Regeln:
von zweckgebundenen Sach- oder Kostenleistungen. Vergleiche belegte von zweckgebundenen Sach- oder Kostenleistungen. Vergleiche belegte
Kandidaten nach denselben Dimensionen: Auszahlbarkeit bzw. Kandidaten nach denselben Dimensionen: Auszahlbarkeit bzw.
Verwendungsbindung, Lohnsteuer, Sozialversicherung einschließlich BV und Verwendungsbindung, Lohnsteuer, Sozialversicherung einschließlich BV und
Lohnnebenkosten, Voraussetzungen sowie Stand. Nenne Quellenwidersprüche Lohnnebenkosten, Voraussetzungen sowie Stand. Nennt die Frage konkrete
gemäß Regel 5 und stelle bei fehlendem Kontext am Ende genau EINE gezielte Eckdaten (z. B. Bruttolohn, Betrag, Einmaligkeit, Barzahlung), wende die
Rückfrage. Sind [wk-akt-04] und [lb-sva-03] beide als Block-Köpfe im 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: Kontext vorhanden, stelle den Konflikt ausdrücklich mit ⚠ dar:
[wk-akt-04] nennt die Mitarbeiterprämie 2026 SV-/BV-pflichtig; [wk-akt-04] nennt die Mitarbeiterprämie 2026 SV-/BV-pflichtig;
[lb-sva-03] ordnet sie systematisch in den taxativen Katalog [lb-sva-03] ordnet sie systematisch in den taxativen Katalog
@@ -268,7 +277,11 @@ def answer_question(
if cfg.planner_enabled: if cfg.planner_enabled:
try: try:
sub_queries, planned, qtype = plan_queries(question, client, cfg) 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: except Exception:
sub_queries, planned, qtype = [SubQuery(text=question)], False, "specific" sub_queries, planned, qtype = [SubQuery(text=question)], False, "specific"
@@ -332,28 +345,41 @@ def answer_question(
def chat(msgs, num_predict: int | None = None): def chat(msgs, num_predict: int | None = None):
"""Ein Chat-Zug; liefert (gestrippter Content, done_reason). """Ein Chat-Zug; liefert (gestrippter Content, done_reason).
Bei done_reason='length' (Antwort bei num_predict abgeschnitten) Bei done_reason='length' (Antwort bei num_predict abgeschnitten)
sind Zitationen ggf. unvollstaendig — der Aufrufer ruft einmal mit sind Zitationen ggf. unvollständig — der Aufrufer ruft einmal mit
doppeltem Budget neu (technischer Retry, kein Regel-Regeneration).""" 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 budget = num_predict or cfg.num_predict
if hasattr(client, "chat_full"):
raw, done_reason = client.chat_full( def call(use_think: bool):
cfg.answer_model, if hasattr(client, "chat_full"):
msgs, return client.chat_full(
temperature=cfg.temperature, cfg.answer_model,
num_ctx=cfg.num_ctx, msgs,
num_predict=budget, temperature=cfg.temperature,
think=cfg.think, 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( try:
cfg.answer_model, raw, done_reason = call(cfg.think)
msgs, except OllamaError:
temperature=cfg.temperature, if not cfg.think:
num_ctx=cfg.num_ctx, raise
num_predict=budget, raw, done_reason = call(False)
think=cfg.think,
)
done_reason = "stop"
return strip_think(raw), done_reason return strip_think(raw), done_reason
def chat_with_length_retry(msgs): def chat_with_length_retry(msgs):
+66 -10
View File
@@ -13,6 +13,8 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from .normalize import normalize_text
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b") YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
JSON_RE = re.compile(r"\{.*\}", re.DOTALL) JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
@@ -20,9 +22,30 @@ AGGREGATION_RE = re.compile(
r"neuerungen|übersicht|zusammenfassung|alle\s|übersicht", re.I r"neuerungen|übersicht|zusammenfassung|alle\s|übersicht", re.I
) )
COMPARISON_RE = re.compile(r"unterschied|vergleic|\bbzw\.|\bsowie\b", 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( DECISION_SUPPORT_RE = re.compile(
r"günstig(?:ste|er|e)?\s+lösung|abgabenbegünstigt|steuerfrei.*(?:auszahl|präm)|" r"gu(e)?nstig(e|er|ste)?\s+lo(e)?sung"
r"(?:zusätzlich|extra).*(?:auszahl|präm)", 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, re.IGNORECASE,
) )
@@ -59,8 +82,12 @@ class SubQuery:
def is_decision_support(question: str) -> bool: def is_decision_support(question: str) -> bool:
"""Erkennt Gestaltungsfragen, die den deterministischen Plan benötigen.""" """Erkennt Gestaltungsfragen, die den deterministischen Plan benötigen.
return bool(DECISION_SUPPORT_RE.search(question))
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: def should_plan(question: str) -> bool:
@@ -97,6 +124,7 @@ def decision_support_plan(
""" """
if not is_decision_support(question): if not is_decision_support(question):
return None return None
folded = normalize_text(question)
explicit_year = YEAR_RE.search(question) explicit_year = YEAR_RE.search(question)
year = explicit_year.group(0) if explicit_year else default_year year = explicit_year.group(0) if explicit_year else default_year
year_text = f" {year}" if year else "" year_text = f" {year}" if year else ""
@@ -126,13 +154,41 @@ def decision_support_plan(
stand_year=year, stand_year=year,
scope="gesetz", scope="gesetz",
) )
broad = bool( # Kostenaufstellung: einmalige Barzahlung -> Lohnsteuer-Behandlung der
re.search(r"günstig|möglichkeiten|statt|alternative", question, re.IGNORECASE) # 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 cost_lnk = SubQuery(
# Queries passen in den normalen Antwortpfad und vermeiden unnötiges text=(
# Map-Reduce samt zusätzlicher Latenz/Fehlverweigerungsrisiko. "Beitragssätze des jeweiligen Jahres für Dienstnehmer "
return ([direct, cash_and_benefits, purpose_bound] if broad else [direct]), "specific" "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]: 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 "genau EINE gezielte" in SYSTEM_PROMPT
assert "Regelnummern niemals" in SYSTEM_PROMPT assert "Regelnummern niemals" in SYSTEM_PROMPT
assert "spekuliere nicht mit Trainingswissen" 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: class TestDecisionSupportValidation:
@@ -244,6 +248,43 @@ def test_trim_results_drops_tail_under_budget():
assert trim_results(blocks, None) is blocks 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): def test_length_retry_doubles_budget(mini_index):
"""done_reason='length' (abgeschnittene Antwort) -> ein technischer """done_reason='length' (abgeschnittene Antwort) -> ein technischer
Retry mit doppeltem num_predict; zaehlt nicht als Regel-Regenerierung.""" 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 ( from agent.query_planner import (
SubQuery, SubQuery,
decision_support_plan, decision_support_plan,
is_decision_support,
parse_plan, parse_plan,
plan_queries, plan_queries,
should_plan, should_plan,
@@ -37,6 +38,37 @@ def test_should_plan_gate():
assert not should_plan("Wie hoch ist der KV-Mindestlohn im Friseurgewerbe?") 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(): def test_decision_support_plan_splits_direct_payment_and_alternatives():
planned = decision_support_plan( planned = decision_support_plan(
"Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. " "Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. "