M1+M2: RAG-Pipeline mit verbindlichem Grounding
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.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""Fixtures: Mini-Wissensbasis (3 Einträge), Index, Fake-Ollama-Client.
|
||||
|
||||
Alle Tests laufen offline — kein Ollama, kein Netz. Die Mini-KB folgt dem
|
||||
verbindlichen Layer-2-Schema (Frontmatter + H2-Sektionen + kb.json-Gate).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.config import Config
|
||||
|
||||
DOC_ATZ = """---
|
||||
id: lb-min-01
|
||||
batch: 1
|
||||
title: "Altersteilzeit - Überblick"
|
||||
work: "Lexis Briefings Personalrecht"
|
||||
chapter: "Beschäftigungsverhältnisse"
|
||||
topic: altersteilzeit
|
||||
author: "Marek"
|
||||
stand: 2026-01
|
||||
source:
|
||||
pdf: ".lexis360/Lexis360_test_atz.pdf"
|
||||
text: ".lexis360/md/test_atz.md"
|
||||
legal_bases: ["AlVG", "AZG § 19e"]
|
||||
tags: [altersteilzeit, lohnausgleich]
|
||||
cross_refs: ["lb-min-02"]
|
||||
---
|
||||
|
||||
# Altersteilzeit – Überblick
|
||||
|
||||
*Lexis Briefings Personalrecht, Marek, Stand Jänner 2026 (lb-min-01).*
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Altersteilzeit ist eine Teilzeit, bei der der Arbeitnehmer zusätzlich zum
|
||||
Teilzeitentgelt einen Lohnausgleich erhält; das AMS ersetzt dem Arbeitgeber
|
||||
einen Teil der Zusatzkosten (Altersteilzeitgeld).
|
||||
|
||||
## Kernwerte & Fristen (Stand 2026-01)
|
||||
|
||||
| Wert / Regel | Detail |
|
||||
|---|---|
|
||||
| Ersatzquote | 28,5 % der ersetzbaren Kosten (Stand 2026-01) |
|
||||
|
||||
## Rechtsgrundlagen
|
||||
|
||||
- AZG § 19e (Kündigungszuschlag)
|
||||
|
||||
## Verweise
|
||||
|
||||
- lb-min-02
|
||||
"""
|
||||
|
||||
DOC_URL = """---
|
||||
id: lb-min-02
|
||||
batch: 1
|
||||
title: "Urlaubsanspruch und Verbrauch"
|
||||
work: "Lexis Briefings Personalrecht"
|
||||
chapter: "Urlaub & Karenzierung"
|
||||
topic: urlaub
|
||||
author: "Marek"
|
||||
stand: 2026-07
|
||||
source:
|
||||
pdf: ".lexis360/Lexis360_test_urlaub.pdf"
|
||||
text: ".lexis360/md/test_urlaub.md"
|
||||
legal_bases: ["UrlG"]
|
||||
tags: [urlaub, urlaubsentgelt]
|
||||
cross_refs: ["lb-min-01"]
|
||||
---
|
||||
|
||||
# Urlaubsanspruch und Verbrauch
|
||||
|
||||
*Lexis Briefings Personalrecht, Marek, Stand Juli 2026 (lb-min-02).*
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Der gesetzliche Urlaubsanspruch beträgt fünf Werktage Wochenurlaub je
|
||||
Dienstjahr; das Urlaubsentgelt ist wie Entgeltfortzahlung zu berechnen.
|
||||
|
||||
## Kernwerte & Fristen (Stand 2026-07)
|
||||
|
||||
| Wert / Regel | Detail |
|
||||
|---|---|
|
||||
| Mindesturlaub | 5 Wochen je Dienstjahr (Stand 2026-07) |
|
||||
|
||||
## Verweise
|
||||
|
||||
- lb-min-01
|
||||
"""
|
||||
|
||||
DOC_WIKU = """---
|
||||
id: wk-min-01
|
||||
batch: 1
|
||||
title: "WIKU Praxisfall: Lohnverrechnung"
|
||||
work: "WIKU Fachbroschüre"
|
||||
chapter: "WIKU Fachbroschüre"
|
||||
topic: lohnverrechnung
|
||||
author: "Wilhelm Kurzböck"
|
||||
stand: 2026-03
|
||||
source:
|
||||
pdf: ".wiku/test_lohnverrechnung.pdf"
|
||||
text: ".wiku/md/test_lohnverrechnung.md"
|
||||
legal_bases: ["EStG § 25"]
|
||||
tags: [lohnverrechnung, praxisfall]
|
||||
cross_refs: ["lb-min-01"]
|
||||
---
|
||||
|
||||
# WIKU Praxisfall: Lohnverrechnung
|
||||
|
||||
*WIKU Fachbroschüre, Wilhelm Kurzböck, Stand März 2026 (wk-min-01).*
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Praxisfall zur laufenden Lohnverrechnung: Abrechnungsperiode und
|
||||
Beitragsgrundlagen sind monatlich festzulegen.
|
||||
|
||||
## Verweise
|
||||
|
||||
- lb-min-01
|
||||
"""
|
||||
|
||||
MIN_DOCS = {
|
||||
"altersteilzeit_uberblick.md": DOC_ATZ,
|
||||
"urlaubsanspruch.md": DOC_URL,
|
||||
"wiku_lohnverrechnung.md": DOC_WIKU,
|
||||
}
|
||||
MIN_IDS = ["lb-min-01", "lb-min-02", "wk-min-01"]
|
||||
|
||||
|
||||
def write_mini_kb(root: Path) -> Path:
|
||||
doc = root / "dokumente"
|
||||
doc.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in MIN_DOCS.items():
|
||||
(doc / name).write_text(content, encoding="utf-8")
|
||||
kb = {
|
||||
"n_entries": len(MIN_IDS),
|
||||
"entries": [{"id": i, "title": i} for i in MIN_IDS],
|
||||
}
|
||||
(root / "kb.json").write_text(
|
||||
json.dumps(kb, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""Skriptbarer Chat-Client; embed() schlägt fehl (Dense bleibt ungetestet)."""
|
||||
|
||||
def __init__(self, answers: list[str] | None = None):
|
||||
self.answers = list(answers or [])
|
||||
self.calls = 0
|
||||
self.last_messages: list | None = None
|
||||
|
||||
def chat(self, model, messages, **kwargs):
|
||||
self.calls += 1
|
||||
self.last_messages = messages
|
||||
if not self.answers:
|
||||
raise AssertionError("FakeOllama: keine skriptierte Antwort mehr")
|
||||
return self.answers.pop(0)
|
||||
|
||||
def embed(self, model, texts):
|
||||
raise RuntimeError("embed nicht verfügbar (offline Test)")
|
||||
|
||||
def is_up(self):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mini_kb(tmp_path):
|
||||
return write_mini_kb(tmp_path / "kb")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mini_cfg(tmp_path, mini_kb) -> Config:
|
||||
return Config(
|
||||
kb_dir=str(mini_kb),
|
||||
db_path=str(tmp_path / "index.db"),
|
||||
embed_off=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mini_index(mini_cfg) -> Config:
|
||||
from agent.ingest import build_index
|
||||
|
||||
stats = build_index(mini_cfg)
|
||||
assert stats.n_entries == 3
|
||||
assert stats.n_chunks >= 7
|
||||
assert stats.embed_error is None
|
||||
return mini_cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ollama():
|
||||
return FakeOllama
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests: Grounding — Post-Validierung, Verweigerungspflicht, Regenerierung.
|
||||
|
||||
Der kritische Teil der Pipeline: keine Antwort mit ungültigen Zitaten
|
||||
verlässt answer_question.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from agent.generate import (
|
||||
REFUSAL_MESSAGE,
|
||||
UNCERTAIN_MESSAGE,
|
||||
answer_question,
|
||||
build_user_content,
|
||||
looks_like_refusal,
|
||||
strip_think,
|
||||
validate_answer,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateAnswer:
|
||||
def test_valid_citation_passes(self):
|
||||
assert validate_answer("ATZ ist Teilzeit [lb-min-01].", ["lb-min-01"]) == []
|
||||
|
||||
def test_unknown_id_is_violation(self):
|
||||
v = validate_answer("ATZ ist xyz [lb-atz-99].", ["lb-min-01"])
|
||||
assert any("lb-atz-99" in x for x in v)
|
||||
|
||||
def test_no_citation_is_violation(self):
|
||||
v = validate_answer("ATZ ist eine Teilzeit.", ["lb-min-01"])
|
||||
assert any("keine KB-ID" in x for x in v)
|
||||
|
||||
def test_refusal_without_citation_is_ok(self):
|
||||
assert validate_answer(REFUSAL_MESSAGE, ["lb-min-01"]) == []
|
||||
|
||||
def test_bare_id_mention_is_detected(self):
|
||||
assert validate_answer("Siehe lb-min-02 für Details.", ["lb-min-02"]) == []
|
||||
|
||||
|
||||
class TestRefusalDetection:
|
||||
def test_refusal_phrase(self):
|
||||
assert looks_like_refusal("Dazu enthält die Wissensbasis keine Aussage.")
|
||||
|
||||
def test_refusal_phrase_with_typos_folds(self):
|
||||
assert looks_like_refusal("Dazu enthält die Wissensbasis keine Aussage!")
|
||||
|
||||
def test_normal_answer_is_no_refusal(self):
|
||||
assert not looks_like_refusal("Der Anspruch besteht [lb-min-01].")
|
||||
|
||||
def test_strip_think_removes_block(self):
|
||||
open_tag = "<" + "think" + ">"
|
||||
close_tag = "</" + "think" + ">"
|
||||
text = open_tag + "Reasoning here" + close_tag + "Antwort [lb-min-01]."
|
||||
out = strip_think(text)
|
||||
assert "Reasoning" not in out
|
||||
assert out.strip().startswith("Antwort [lb-min-01].")
|
||||
|
||||
|
||||
class TestUserContent:
|
||||
def test_blocks_contain_metadata_header(self, mini_index):
|
||||
from agent.retrieve import Retriever
|
||||
|
||||
r = Retriever(mini_index)
|
||||
try:
|
||||
results = r.search("Altersteilzeit Lohnausgleich")
|
||||
content = build_user_content("Was ist ATZ?", results)
|
||||
assert "Block 1 — [lb-min-01]" in content
|
||||
assert "Stand: 2026-01" in content
|
||||
assert "Frage: Was ist ATZ?" in content
|
||||
finally:
|
||||
r.close()
|
||||
|
||||
|
||||
class TestAnswerQuestion:
|
||||
def test_happy_path_verified(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(
|
||||
answers=["Altersteilzeit ist eine Teilzeit mit Lohnausgleich "
|
||||
"[lb-min-01]. (Stand 2026-01)"]
|
||||
)
|
||||
result = answer_question(
|
||||
"Was ist Altersteilzeit?", mini_index, client=client
|
||||
)
|
||||
assert result["verified"] is True
|
||||
assert result["refused"] is False
|
||||
assert result["citations"] == ["lb-min-01"]
|
||||
assert result["sources"][0]["id"] == "lb-min-01"
|
||||
assert result["sources"][0]["stand"] == "2026-01"
|
||||
assert client.calls == 1
|
||||
|
||||
def test_hallucinated_id_regenerates_then_refuses(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(answers=[
|
||||
"ATZ gilt ab 60. Lebensjahr [lb-atz-99].",
|
||||
"ATZ gilt ab 60. Lebensjahr, siehe [lb-atz-99].",
|
||||
])
|
||||
result = answer_question(
|
||||
"Was ist Altersteilzeit?", mini_index, client=client
|
||||
)
|
||||
assert result["refused"] is True
|
||||
assert result["verified"] is False
|
||||
assert result["answer"] == UNCERTAIN_MESSAGE
|
||||
assert result["regenerations"] == 1
|
||||
assert result["citations"] == []
|
||||
assert "draft" in result and "lb-atz-99" in result["draft"]
|
||||
|
||||
def test_regeneration_can_recover(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(answers=[
|
||||
"ATZ gilt ab 60 [lb-atz-99].",
|
||||
"ATZ ist Teilzeit mit Lohnausgleich [lb-min-01].",
|
||||
])
|
||||
result = answer_question(
|
||||
"Was ist Altersteilzeit?", mini_index, client=client
|
||||
)
|
||||
assert result["verified"] is True
|
||||
assert result["regenerations"] == 1
|
||||
assert result["citations"] == ["lb-min-01"]
|
||||
|
||||
def test_empty_retrieval_refuses_deterministically(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(answers=["sollte nie aufgerufen werden"])
|
||||
result = answer_question(
|
||||
"Wie hoch ist der Wechselkurs von Bermuda-Dollar?", mini_index,
|
||||
client=client,
|
||||
)
|
||||
assert result["refused"] is True
|
||||
assert result["answer"] == REFUSAL_MESSAGE
|
||||
assert result["verified"] is True
|
||||
assert client.calls == 0 # kein LLM-Call bei leerem Retrieval
|
||||
|
||||
def test_model_refusal_is_kept(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(answers=[
|
||||
f"Zu dieser Frage: {REFUSAL_MESSAGE}"
|
||||
])
|
||||
result = answer_question(
|
||||
"Was ist Altersteilzeit?", mini_index, client=client
|
||||
)
|
||||
assert result["refused"] is True
|
||||
assert result["verified"] is True # Regel-4-konforme Verweigerung
|
||||
assert client.calls == 1
|
||||
|
||||
def test_top_k_limits_context(self, mini_index, fake_ollama):
|
||||
client = fake_ollama(answers=["Teilzeit [lb-min-01]."])
|
||||
result = answer_question(
|
||||
"Altersteilzeit Urlaub Lohnverrechnung", mini_index,
|
||||
client=client, top_k=1,
|
||||
)
|
||||
main = [s for s in result["sources"]]
|
||||
assert result["n_context"] >= 1
|
||||
# Haupt-Blöcke auf top_k begrenzt; cross_ref-Erweiterungen dürfen dazu
|
||||
assert len([s for s in main]) <= result["n_context"]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests: Index-Bau (Chunking, FTS, Metadaten, Schema-Gates)."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.ingest import SCHEMA, build_index
|
||||
from agent.kb import KbValidationError
|
||||
|
||||
|
||||
def test_build_index_chunks_and_fts(mini_index):
|
||||
con = sqlite3.connect(mini_index.db_path)
|
||||
try:
|
||||
n_chunks = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
||||
n_fts = con.execute("SELECT COUNT(*) FROM chunks_fts").fetchone()[0]
|
||||
n_entries = con.execute(
|
||||
"SELECT COUNT(DISTINCT entry_id) FROM chunks"
|
||||
).fetchone()[0]
|
||||
assert n_entries == 3
|
||||
assert n_chunks == n_fts and n_chunks >= 7
|
||||
row = con.execute(
|
||||
"SELECT entry_id, section, norm FROM chunks WHERE entry_id='lb-min-01' "
|
||||
"AND section LIKE 'Kernwerte%'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
# Umlaut-Folding im FTS-Text: "Lohnausgleich" normalisiert auffindbar
|
||||
assert "lohnausgleich" in row[2]
|
||||
meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
|
||||
assert meta["n_entries"] == "3"
|
||||
assert meta["embed_model"] == "" # embed_off=True
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_norm_contains_tags_and_legal_bases(mini_index):
|
||||
con = sqlite3.connect(mini_index.db_path)
|
||||
try:
|
||||
norm = con.execute(
|
||||
"SELECT norm FROM chunks WHERE entry_id='lb-min-01' "
|
||||
"AND section='Zusammenfassung'"
|
||||
).fetchone()[0]
|
||||
assert "alvg" in norm # legal_bases im FTS-Text
|
||||
assert "azg" in norm and "19e" in norm
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_rebuild_is_idempotent(mini_index):
|
||||
stats = build_index(mini_index)
|
||||
assert stats.n_entries == 3
|
||||
con = sqlite3.connect(mini_index.db_path)
|
||||
try:
|
||||
assert con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] == stats.n_chunks
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_build_index_aborts_on_gate_error(mini_cfg):
|
||||
"""Gate-Fehler (Registry kaputt) bricht den Ingest ab — kein halber Index."""
|
||||
from pathlib import Path
|
||||
|
||||
(Path(mini_cfg.kb_dir) / "kb.json").write_text(
|
||||
'{"n_entries": 0, "entries": []}', encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(KbValidationError):
|
||||
build_index(mini_cfg)
|
||||
|
||||
|
||||
def test_vectors_table_cached_across_rebuilds(mini_index):
|
||||
"""Die Vektoren-Tabelle bleibt beim Rebuild erhalten (Cache-Garantie)."""
|
||||
con = sqlite3.connect(mini_index.db_path)
|
||||
try:
|
||||
con.execute(
|
||||
"INSERT INTO vectors(content_hash, model, dim, vec) "
|
||||
"VALUES ('deadbeef', 'bge-m3', 2, x'000000003f800000')"
|
||||
) # 0.0, 1.0
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
build_index(mini_index)
|
||||
con = sqlite3.connect(mini_index.db_path)
|
||||
try:
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM vectors WHERE content_hash='deadbeef'"
|
||||
).fetchone()[0] == 1
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_schema_creates_fts5(tmp_path):
|
||||
con = sqlite3.connect(tmp_path / "s.db")
|
||||
try:
|
||||
con.executescript(SCHEMA)
|
||||
con.execute("INSERT INTO chunks_fts(rowid, norm) VALUES (1, 'testtext')")
|
||||
hits = con.execute(
|
||||
"SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH '\"testtext\"'"
|
||||
).fetchall()
|
||||
assert hits == [(1,)]
|
||||
finally:
|
||||
con.close()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests: Layer-2-Parsing, Sektionen, Cross-Ref-Integrität, kb.json-Gate."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.kb import KbValidationError, load_entry, load_kb, parse_frontmatter, split_sections
|
||||
from tests.conftest import DOC_ATZ, MIN_IDS, write_mini_kb
|
||||
|
||||
|
||||
def test_parse_frontmatter_and_sections():
|
||||
meta, body = parse_frontmatter(DOC_ATZ)
|
||||
assert meta["id"] == "lb-min-01"
|
||||
assert meta["stand"] == "2026-01"
|
||||
sections = split_sections(body)
|
||||
titles = [s.title for s in sections]
|
||||
assert "Zusammenfassung" in titles
|
||||
assert titles[1].startswith("Kernwerte & Fristen")
|
||||
assert all(s.text for s in sections)
|
||||
|
||||
|
||||
def test_split_sections_ignores_h1_and_intro():
|
||||
sections = split_sections("# Titel\n\n*Quellzeile*\n\n## A\n\nText A\n\n## B\n\nText B")
|
||||
assert [s.title for s in sections] == ["A", "B"]
|
||||
assert sections[0].text == "Text A"
|
||||
|
||||
|
||||
def test_load_entry_minimal_doc(tmp_path):
|
||||
p = tmp_path / "doc.md"
|
||||
p.write_text(DOC_ATZ, encoding="utf-8")
|
||||
e = load_entry(p)
|
||||
assert e.id == "lb-min-01"
|
||||
assert e.tags == ["altersteilzeit", "lohnausgleich"]
|
||||
assert e.cross_refs == ["lb-min-02"]
|
||||
assert len(e.sections) >= 3
|
||||
|
||||
|
||||
def test_gate_rejects_out_of_sync_registry(tmp_path):
|
||||
root = write_mini_kb(tmp_path / "kb")
|
||||
kb = json.loads((root / "kb.json").read_text(encoding="utf-8"))
|
||||
kb["entries"].append({"id": "lb-min-99"})
|
||||
(root / "kb.json").write_text(json.dumps(kb), encoding="utf-8")
|
||||
with pytest.raises(KbValidationError, match="out of sync"):
|
||||
load_kb(root)
|
||||
|
||||
|
||||
def test_gate_rejects_dangling_cross_refs(tmp_path):
|
||||
root = write_mini_kb(tmp_path / "kb")
|
||||
doc = root / "dokumente" / "altersteilzeit_uberblick.md"
|
||||
doc.write_text(
|
||||
DOC_ATZ.replace('cross_refs: ["lb-min-02"]', 'cross_refs: ["lb-min-42"]'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(KbValidationError, match="dangling cross_refs"):
|
||||
load_kb(root)
|
||||
|
||||
|
||||
def test_gate_rejects_invalid_stand(tmp_path):
|
||||
root = write_mini_kb(tmp_path / "kb")
|
||||
doc = root / "dokumente" / "altersteilzeit_uberblick.md"
|
||||
doc.write_text(DOC_ATZ.replace("stand: 2026-01", "stand: Jänner 2026"), encoding="utf-8")
|
||||
with pytest.raises(KbValidationError, match="not YYYY-MM"):
|
||||
load_kb(root)
|
||||
|
||||
|
||||
def test_real_corpus_loads_and_matches_registry():
|
||||
"""Integrationstest gegen die echte Wissensbasis (Gate inklusive)."""
|
||||
entries = load_kb("wissensbasis", verify_registry=True)
|
||||
ids = {e.id for e in entries}
|
||||
assert len(entries) == 601
|
||||
assert "lb-atz-07" in ids and "wk-akt-01" in ids
|
||||
atz = [e for e in entries if e.id == "lb-atz-07"][0]
|
||||
assert atz.topic == "altersteilzeit"
|
||||
assert any(s.title.startswith("Kernwerte") for s in atz.sections)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests: Textnormalisierung und FTS-Query-Bau."""
|
||||
from agent.normalize import fts_query, normalize_text, tokenize
|
||||
|
||||
|
||||
def test_normalize_folds_german_diacritics():
|
||||
assert normalize_text("Gehälter Ärger Größe Übung Ökonomie") == (
|
||||
"gehalter arger grosse ubung okonomie"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_keeps_digits_and_section_sign():
|
||||
assert normalize_text("AZG § 19e (Stand 2026-01)") == "azg § 19e (stand 2026-01)"
|
||||
|
||||
|
||||
def test_tokenize_splits_alphanumeric():
|
||||
assert tokenize("Lohnausgleich, AZG §19e") == ["lohnausgleich", "azg", "19e"]
|
||||
|
||||
|
||||
def test_fts_query_drops_stopwords_and_quotes_terms():
|
||||
q = fts_query("Wie hoch ist die SV-Beitragsgrundlage?")
|
||||
assert '"beitragsgrundlage"' in q
|
||||
assert '"sv"' in q
|
||||
assert '"wie"' not in q
|
||||
assert '"ist"' not in q
|
||||
|
||||
|
||||
def test_fts_query_empty_and_stopword_only():
|
||||
assert fts_query("") == ""
|
||||
assert fts_query("Wie ist der die das?") == ""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user