Stufe 1: komplexe Fragen via Query-Planer, Per-Query-Slots, Scope-Filter (D12, M6)
- Query-Planer (agent/query_planner.py): Heuristik-Gate (Jahr, Vergleichs-/ Aggregationsmarker, Laenge) entscheidet ueber einen kleinen LLM-Call, der komplexe Fragen in 1-3 Sub-Queries zerlegt (JSON, temp 0; Fehler -> Original als Einzel-Query). Je Sub-Query optional stand_year und scope. - Multi-Query-Retrieval: je Sub-Query BM25+Dense mit RRF-Summe; Per-Query-Slots (2 je Sub-Query) sichern jeden Frageaspekt im Kontext (sonst dominieren Eintraege, die in mehreren Sub-Queries mittelgut matchen — q-113-Befund). Scope-Filter: 'gesetz' (nur Lexis/WIKU/RIS) und 'kv' (nur Branchen-KV) mit Fallback auf unscoped bei leerem Ergebnis. Temporal-Intent: stand_year + temporal_boost (default 0, FTS-jahr-Tag-Signal reichte). - Grounding unveraendert: eine Retrieved-Menge (Union), eine Antwort, Post-Validierung ueber die Union, Verweigerungspflicht unveraendert. - Goldset 42 -> 46: q-110 (Temporal 2023; 2024er-Lohnordnung existiert im Korpus nicht - Mantelvertrag ohne Lohntabelle, korrekt verweigert), q-111 (2025), q-112 (Abfertigung-Vergleich), q-113 (Gesetz+KV). - Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 % (Gate >94,3 % erfuellt), erwartete Quelle 90,2 %, Latenz mean 33,5 s. - Tests 50 -> 57. Reports lokal: data/eval-qwen38-stage1*.json. API-first festgehalten (D13-Vorbereitung): Odoo bleibt duenner Client; Lohndaten-Zugriff in M4 erfordert Privacy-Neubewertung.
This commit is contained in:
@@ -181,6 +181,8 @@ def mini_cfg(tmp_path, mini_kb) -> Config:
|
||||
kb_dir=str(mini_kb),
|
||||
db_path=str(tmp_path / "index.db"),
|
||||
embed_off=True,
|
||||
planner_enabled=False, # Tests skripten Chat-Antworten; Planer
|
||||
# separat testbar (siehe test_generate/test_query_planner)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests: Query-Planer (Stufe 1) — Heuristik-Gate, JSON-Parsing, Fallback,
|
||||
Multi-Query-Fusion und Integration in answer_question."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.config import Config
|
||||
from agent.generate import answer_question
|
||||
from agent.query_planner import SubQuery, parse_plan, plan_queries, should_plan
|
||||
from tests.conftest import FakeOllama
|
||||
|
||||
|
||||
def test_should_plan_gate():
|
||||
# Jahreszahl -> temporale Frage -> planen
|
||||
assert should_plan("Wie hoch war der Mindestlohn für Friseure im Jahr 2024?")
|
||||
# Vergleich
|
||||
assert should_plan("Was ist der Unterschied zwischen Abfertigung neu und alt?")
|
||||
# lang / mehrfach
|
||||
assert should_plan(
|
||||
"Wie wird die Überstundenpauschale behandelt und wie wirkt sie sich "
|
||||
"auf die Sozialversicherung und die Lohnsteuer aus?"
|
||||
)
|
||||
# einfach -> Single-Shot
|
||||
assert not should_plan("Wie viele Werktage Urlaub stehen Arbeitnehmern zu?")
|
||||
assert not should_plan("Was ist Altersteilzeit?")
|
||||
assert not should_plan("Wie hoch ist der KV-Mindestlohn im Friseurgewerbe?")
|
||||
|
||||
|
||||
def test_parse_plan_valid_and_fallback():
|
||||
subs = parse_plan(
|
||||
'Vorab: {"queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, '
|
||||
'{"text": "lohnberechnung friseur", "stand_year": null}]}',
|
||||
original="Originalfrage?",
|
||||
)
|
||||
assert [s.text for s in subs] == ["mindestlohn friseur", "lohnberechnung friseur"]
|
||||
assert subs[0].stand_year == "2024"
|
||||
assert subs[1].stand_year is None
|
||||
|
||||
# Fallbacks: kaputtes JSON, leeres Array, leere Texte
|
||||
for raw in ("kein json", '{"queries": []}', '{"queries": [{"text": ""}]}'):
|
||||
subs = parse_plan(raw, original="Originalfrage?")
|
||||
assert len(subs) == 1 and subs[0].text == "Originalfrage?"
|
||||
|
||||
# Ungueltiges Jahr -> None erzwingen, Text bleibt
|
||||
subs = parse_plan(
|
||||
'{"queries": [{"text": "x", "stand_year": "98"}]}', original="orig"
|
||||
)
|
||||
assert subs == [SubQuery(text="x", stand_year=None)]
|
||||
|
||||
|
||||
def test_parse_plan_caps_at_three_queries():
|
||||
raw = json.dumps(
|
||||
{"queries": [{"text": f"q{i}"} for i in range(5)]}
|
||||
)
|
||||
subs = parse_plan(raw, original="orig")
|
||||
assert len(subs) == 3
|
||||
|
||||
|
||||
def test_plan_queries_simple_question_no_llm_call():
|
||||
client = FakeOllama(answers=[]) # darf nicht aufgerufen werden
|
||||
cfg = Config(planner_enabled=True)
|
||||
subs, planned = plan_queries("Was ist Altersteilzeit?", client, cfg)
|
||||
assert planned is False
|
||||
assert subs == [SubQuery(text="Was ist Altersteilzeit?")]
|
||||
assert client.calls == 0
|
||||
|
||||
|
||||
def test_plan_queries_uses_planner_and_falls_back_on_error():
|
||||
cfg = Config(planner_enabled=True)
|
||||
client = FakeOllama(
|
||||
answers=['{"queries": [{"text": "atz lohnausgleich"}, {"text": "atz altersteilzeitgeld", "stand_year": null}]}']
|
||||
)
|
||||
subs, planned = plan_queries(
|
||||
"Wie funktioniert der Lohnausgleich bei Altersteilzeit und was ersetzt das AMS?", client, cfg
|
||||
)
|
||||
assert planned is True and len(subs) == 2
|
||||
assert subs[0].text == "atz lohnausgleich"
|
||||
|
||||
# Fehler -> Originalfrage, geplant False
|
||||
failing = FakeOllama(answers=[])
|
||||
failing.chat = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline"))
|
||||
subs, planned = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg)
|
||||
assert planned is False
|
||||
assert subs[0].text.startswith("Wie funktioniert")
|
||||
|
||||
|
||||
def test_answer_question_planner_integration(mini_index):
|
||||
"""Planer an: 1. Chat = Planer-JSON, 2. Chat = Antwort. Beide Sub-Queries
|
||||
fusionieren in einer Retrieved-Menge (Zitier-Validierung ueber die Union)."""
|
||||
cfg = Config(
|
||||
kb_dir=mini_index.kb_dir,
|
||||
db_path=mini_index.db_path,
|
||||
embed_off=True,
|
||||
planner_enabled=True,
|
||||
planner_max_queries=2,
|
||||
)
|
||||
client = FakeOllama(answers=[
|
||||
json.dumps({"queries": [
|
||||
{"text": "altersteilzeit lohnausgleich", "stand_year": None},
|
||||
{"text": "urlaubsanspruch", "stand_year": None},
|
||||
]}),
|
||||
"Antwort mit Beleg [lb-min-01] und [lb-min-02].",
|
||||
])
|
||||
result = answer_question(
|
||||
"Wie funktioniert der Lohnausgleich bei Altersteilzeit und wie viel Urlaub bleibt?",
|
||||
cfg, client=client,
|
||||
)
|
||||
assert client.calls == 2 # Planer + Antwort
|
||||
assert result["verified"] is True
|
||||
assert result["refused"] is False
|
||||
assert set(result["citations"]) == {"lb-min-01", "lb-min-02"}
|
||||
assert len(result["planned_queries"]) == 2
|
||||
|
||||
|
||||
def test_search_multi_fuses_across_queries(mini_index):
|
||||
"""Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus
|
||||
beiden Themen erscheinen im Kontext."""
|
||||
from agent.retrieve import Retriever
|
||||
|
||||
cfg = Config(kb_dir=mini_index.kb_dir, db_path=mini_index.db_path, embed_off=True)
|
||||
r = Retriever(cfg)
|
||||
subs = [SubQuery(text="Altersteilzeit Lohnausgleich"),
|
||||
SubQuery(text="Urlaubsanspruch")]
|
||||
results = r.search_multi(subs, n_entries=8)
|
||||
ids = {res.entry_id for res in results}
|
||||
assert {"lb-min-01", "lb-min-02"} <= ids # beide Themen vertreten
|
||||
r.close()
|
||||
Reference in New Issue
Block a user