2cba72aeb0
agent/-Paket: Ingest (601 Layer-2-Eintraege -> 3005 Chunks, FTS5-BM25 + Vektoren-Cache), Hybrid-Retrieval (RRF, Stand-Boost, cross_ref-Erweiterung), Ollama-Client (embed/chat, think-Flag-Fallback, kurzes Connect-Budget), Systemprompt mit Zitierpflicht, Post-Validierung (zitierte IDs gemaess Retrieved-Set, 1x Regenerierung, dann Verweigerung), FastAPI (/ask, /health, /reindex), CLI, Goldset (31 Fragen, IDs gegen kb.json verifiziert, inkl. ATZ-Konfliktfall + 4 Verweigerungsfaelle), Eval-Suite, Test-Chat. 41 Offline-Tests gruen. Baseline BM25-only: Hit-Rate 0,871 / Recall@8 0,855 / MRR 0,476. Hybrid-Messung, Antwortmodus-Eval und Modell-Bake-off (M3) auf dem Host ausstaendig (Ollama aus der Zed-Sandbox nicht erreichbar). MEMORY.md und planung.md Umsetzungsstand aktualisiert.
82 lines
2.9 KiB
Python
82 lines
2.9 KiB
Python
"""Tests: Hybrid-Retrieval (BM25-only offline): Fusion, Entry-Dedup,
|
|
cross_ref-Erweiterung, leeres Retrieval."""
|
|
import pytest
|
|
|
|
from agent.retrieve import Retriever
|
|
|
|
|
|
@pytest.fixture
|
|
def retriever(mini_index):
|
|
r = Retriever(mini_index)
|
|
yield r
|
|
r.close()
|
|
|
|
|
|
def test_search_finds_expected_entry(retriever):
|
|
results = retriever.search("Was ist Altersteilzeit und Lohnausgleich?")
|
|
assert results, "Retrieval sollte Treffer liefern"
|
|
assert results[0].entry_id == "lb-min-01"
|
|
assert results[0].stand == "2026-01"
|
|
# Beim Section-Schnitt entscheidet BM25-Längennormalisierung; hier zählt
|
|
# der richtige Eintrag, nicht der konkrete Abschnitt.
|
|
|
|
|
|
def test_search_cross_ref_expansion(retriever):
|
|
"""Top-Treffer lb-min-02 → cross_ref lb-min-01 wird als Erweiterung ergänzt."""
|
|
results = retriever.search("Urlaubsanspruch fünf Werktage")
|
|
main = [r for r in results if r.source != "cross_ref"]
|
|
extra = [r for r in results if r.source == "cross_ref"]
|
|
assert main and main[0].entry_id == "lb-min-02"
|
|
assert any(r.entry_id == "lb-min-01" for r in extra)
|
|
|
|
|
|
def test_search_no_match_returns_empty(retriever):
|
|
results = retriever.search("kanadische quellensteuer bermuda")
|
|
assert results == []
|
|
|
|
|
|
def test_search_dedups_entries(mini_index):
|
|
"""Pro Eintrag höchstens ein Haupt-Chunk im Kontext (Entry-Level-Dedup)."""
|
|
r = Retriever(mini_index)
|
|
try:
|
|
results = r.search("Lohnausgleich Urlaubsentgelt Lohnverrechnung",
|
|
n_entries=2)
|
|
main_ids = [x.entry_id for x in results if x.source != "cross_ref"]
|
|
assert len(main_ids) == len(set(main_ids))
|
|
assert len(main_ids) <= 2
|
|
finally:
|
|
r.close()
|
|
|
|
|
|
def test_recency_boost_prefers_newer_stand(retriever):
|
|
"""Milde Aktualitätsgewichtung: bei Gleichstand gewinnt der neuere Stand.
|
|
|
|
'Urlaubsanspruch' (2026-07) sollte vor 'Altersteilzeit' (2026-01)
|
|
landen, wenn beide im Kontext sind und der Query beide trifft.
|
|
"""
|
|
results = retriever.search("Urlaubsanspruch Altersteilzeit")
|
|
main = [r for r in results if r.source != "cross_ref"]
|
|
if {r.entry_id for r in main} >= {"lb-min-01", "lb-min-02"}:
|
|
# Beide im Kontext -> Reihenfolge prüfen ist nur bei Score-Nähe sinnvoll;
|
|
# hier reicht die Existenz-Annahme, der Boost ist bewusst minimal.
|
|
assert main[0].entry_id in {"lb-min-01", "lb-min-02"}
|
|
|
|
|
|
def test_stats_report(mini_index):
|
|
r = Retriever(mini_index)
|
|
try:
|
|
s = r.stats()
|
|
assert s["n_entries"] == 3
|
|
assert s["dense_available"] is False # embed_off=True
|
|
assert s["stand_min"] == "202601"
|
|
assert s["stand_max"] == "202607"
|
|
finally:
|
|
r.close()
|
|
|
|
|
|
def test_retriever_requires_index(tmp_path):
|
|
from agent.config import Config
|
|
|
|
cfg = Config(kb_dir="wissensbasis", db_path=str(tmp_path / "missing.db"))
|
|
with pytest.raises(RuntimeError, match="ingest"):
|
|
Retriever(cfg) |