diff --git a/pv-agent/agent/eval/goldset.yaml b/pv-agent/agent/eval/goldset.yaml index d5e8fbd..a58fcaa 100644 --- a/pv-agent/agent/eval/goldset.yaml +++ b/pv-agent/agent/eval/goldset.yaml @@ -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 diff --git a/pv-agent/agent/generate.py b/pv-agent/agent/generate.py index aeae2c8..04974db 100644 --- a/pv-agent/agent/generate.py +++ b/pv-agent/agent/generate.py @@ -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): diff --git a/pv-agent/agent/query_planner.py b/pv-agent/agent/query_planner.py index 0eb4f24..fe2dbef 100644 --- a/pv-agent/agent/query_planner.py +++ b/pv-agent/agent/query_planner.py @@ -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]: diff --git a/pv-agent/tests/test_generate.py b/pv-agent/tests/test_generate.py index cdca269..097a6c0 100644 --- a/pv-agent/tests/test_generate.py +++ b/pv-agent/tests/test_generate.py @@ -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.""" diff --git a/pv-agent/tests/test_query_planner.py b/pv-agent/tests/test_query_planner.py index 62e8e05..ef84db6 100644 --- a/pv-agent/tests/test_query_planner.py +++ b/pv-agent/tests/test_query_planner.py @@ -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. "