mirror of
http://100.103.83.12:3003/fegger/pv-agent.git
synced 2026-09-17 14:46:24 +00:00
feat(agent): support cost questions and thinking fallback
This commit is contained in:
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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. "
|
||||
|
||||
Reference in New Issue
Block a user