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:
2026-09-14 16:53:04 +02:00
parent bf8191b013
commit 2cba72aeb0
26 changed files with 2574 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
# Agent-Memory — pv-agent
Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
(agent-memory-Skill). Ergänzen, nicht überschreiben.
## Current focus
M1 (Index + Retrieval) und M2 (Ollama-Generierung + Grounding + API) sind
implementiert. Ausstehend: Host-Validierung mit Ollama (Dense-Index,
Antwortmodus-Eval) und Modell-Bake-off (M3). Odoo-Integration (M4) ist
separat zu planen.
## Completed (2026-09-14)
- **Planung** (`planung.md`): Architektur, Grounding-Regeln, Modellfeld
(Bake-off: qwen3.8:27b primär, qwen3:32b, gemma3:27b, mistral-small3.2:24b,
qwen3:14b als Latenz-Untergrenze), Meilensteine M1M4.
- **Skill** `.agents/skills/pv-rag-agent/SKILL.md`: verbindliche Regeln für
die Implementierung (Grounding, Architektur-Entscheidungen, Gates,
Modellwechsel-Protokoll).
- **Commits**: `25eb285` (Wissensbasis-Import 601 Layer-2-Einträge +
.gitignore), `bf8191b` (Planung + Skills). Noch nicht gepusht — Remote
`http://localhost:3003/fegger/pv-agent.git` ist aus der Zed-Sandbox nicht
erreichbar; User muss vom Host pushen.
- **Implementierung M1+M2** (`agent/`-Paket): kb.py (Parsing + kb.json-Gate),
ingest.py (3005 Chunks aus 601 Einträgen, FTS5 + Vektoren-Cache),
retrieve.py (Hybrid BM25+Dense/RRF, Stand-Boost, cross_ref-Erweiterung),
generate.py (Systemprompt, Post-Validierung, 1× Regenerierung, dann
Verweigerung), ollama_client.py (embed/chat, think-Flag-Fallback),
api.py (/ask /health /reindex), cli.py, eval/ (Goldset 31 Fragen,
evaluate.py), web/index.html, 41 offline Tests (grün).
- **Baseline BM25-only**: Hit-Rate 0,871 · Recall@8 0,855 · MRR 0,476
(31 Fragen). 4 Fehltreffer: Komposita/Stamm-Schwächen (aliquotiert↔
Aliquotierung, Mindestlohngesetz) — Dense-Suche soll diese beheben.
## Open issues / blockers
- **Ollama aus Zed-Sandbox nicht erreichbar** (100.183.83.12:11435 und
localhost:3003 beide geblockt): Dense-Index, Antwortmodus-Eval und
Bake-off müssen auf dem Host laufen. Kommandos: `agent/README.md`
Abschnitt „Deployment auf dem Host".
- **Modelle noch nicht gepullt**: auf dem Host `ollama pull qwen3.8:27b`,
`ollama pull bge-m3` (plus Bake-off-Kandidaten).
- **Reranker** (bge-reranker-v2-m3): API-Unterstützung der installierten
Ollama-Version prüfen — Design funktioniert ohne.
- **qwen3.8-Think-Parameter**: `think: false` wird im Request gesendet
(Auto-Fallback ohne Flag bei 400/404); exaktes Verhalten am Host testen.
- **M4 Odoo**: native LLM-Module des konkreten Odoo-19-Stands verifizieren
(keine API-Annahmen); Option A (dünnes Custom-Modul + Service-API) ist
Default.
## Decisions & conventions
- **D1 (Planung):** Schlanke Eigen-Pipeline statt LangChain/LlamaIndex —
Grounding-Kontrolle schlägt Framework-Komfort bei 601 Dokumenten.
- **D2:** Retrieval-Korpus ist **nur Layer 2**; Layer 1 bleibt aus Prompts
(Lizenz); Antworten zitieren `[kb-id]` + `(Stand YYYY-MM)`.
- **D3:** Umlaut-Folding für FTS (NFKD, ß→ss) — gilt konsistent für Index
und Query; ASCII-Slug-Konvention der Wissensbasis bleibt davon unberührt.
- **D4:** Post-Validierung strikt: zitierte IDs ⊆ Retrieved-Set (Block-Kopf-
IDs); Fließtext-Verweis-IDs sind KEINE Belege (Systemprompt-Regel 2) —
Verstoß → 1× Regenerierung → Verweigerung (UNCERTAIN_MESSAGE).
- **D5:** Vektoren-Tabelle ist Cache (Content-Hash × Modell), Rebuild
löscht sie nicht; `--no-embed` setzt `embed_off` im Config-Copy.
- **D6:** Leeres Retrieval → deterministische Verweigerung ohne LLM-Call.
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
korrektheit > Latenz.
## Files that matter right now
- `planung.md` — Plan + Entscheidungspunkte (Abschnitt 12) + Stand.
- `.agents/skills/pv-rag-agent/SKILL.md` — verbindliche Regeln.
- `agent/README.md` — Betrieb, Konfiguration, Host-Schritte.
- `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert).
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung).
- `wissensbasis/README.md` — Layer-2-Schema (unverändert gültig).
+1
View File
@@ -11,3 +11,4 @@ data/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.pytest_cache/
+114
View File
@@ -0,0 +1,114 @@
# PV RAG Agent
Lokaler RAG-Agent für österreichische Personalverrechnung: beantwortet
Fragen **ausschließlich** aus der kuratierten Wissensbasis (Layer 2,
`wissensbasis/`, 601 Einträge) — mit ID- und Stand-Beleg, ohne
Trainingswissen, ohne Web-Zugriff. Verbindliche Regeln:
`.agents/skills/pv-rag-agent/SKILL.md`, Plan: `planung.md`.
## Architektur (Kurzfassung)
```
wissensbasis/dokumente/*.md ──ingest──▶ data/index.db
├─ chunks (FTS5, BM25, Umlaut-Folding)
├─ vectors (bge-m3, Content-Hash-Cache)
└─ Metadaten (stand, topic, tags, …)
Frage ──retrieve──▶ Hybrid BM25+Dense (RRF) + cross_ref-Erweiterung
──generate──▶ Ollama (Systemprompt, Zitierpflicht)
──validate──▶ zitierte IDs ⊆ Retrieved-Set? sonst 1× regenerieren, dann verweigern
```
- **Nur Layer 2** als Korpus (kuratiert, lizenzkonform). Layer-1-Volltexte
(`.lexis360/`, `.wiku/`) bleiben außen vor (offener Lizenzpunkt).
- **Kein Ausweg nach außen:** keine Tools, kein Browsing — der einzige
HTTP-Client spricht mit Ollama.
- **Verweigerungspflicht:** leeres/schwaches Retrieval → deterministische
Antwort „Dazu enthält die Wissensbasis keine Aussage." (kein LLM-Call).
## Schnellstart
```bash
pip install -r requirements.txt
# 1) Index bauen (mit Embeddings, wenn Ollama erreichbar)
python -m agent.cli ingest # --no-embed erzwingt BM25-only
# 2) Frage im Terminal
python -m agent.cli ask "Wie hoch ist die AMS-Ersatzquote bei geblockter Altersteilzeit?"
# 3) Goldset-Evaluation (offline: Retrieval-Metriken)
python -m agent.cli eval
# inkl. Antworten + Verweigerungsfälle (benötigt Ollama):
python -m agent.cli eval --answers --json-out data/eval-report.json
# 4) HTTP-API + Test-Chat
python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /reindex)
```
## Konfiguration (Umgebungsvariablen)
| Variable | Default | Bedeutung |
|---|---|---|
| `OLLAMA_URL` | `http://100.183.83.12:11435` | Ollama-Server (Custom-Port!) |
| `PV_ANSWER_MODEL` | `qwen3.8:27b` | Antwortmodell (provisorisch bis Bake-off M3) |
| `PV_EMBED_MODEL` | `bge-m3` | Embedding-Modell |
| `PV_DB_PATH` | `data/index.db` | SQLite-Index |
| `PV_KB_DIR` | `wissensbasis` | Wissensbasis-Verzeichnis |
| `PV_THINK` | `false` | Thinking per Request (qwen3.8: default an) |
| `PV_EMBED_OFF` | `false` | `true` = BM25-only |
| `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
| `PV_PORT` | `8080` | API-Port |
## Deployment auf dem Host (Ollama-Maschine)
```bash
# Modelle einmalig pullen
ollama pull qwen3.8:27b
ollama pull bge-m3
curl http://100.183.83.12:11435/api/tags # Erreichbarkeit + Modelle
# Vollständiger Index (BM25 + Dense) — danach eval, Ziel: Recall@8 > 0,9
python -m agent.cli ingest
python -m agent.cli eval
python -m agent.cli eval --answers # Zitier-Präzision, Verweigerungen, Latenz
```
## Baseline (2026-09-14, BM25-only, ohne Dense)
Goldset (31 Fragen, `agent/eval/goldset.yaml`): Hit-Rate 0,871 ·
Recall@8 0,855 · MRR 0,476. Die vier Fehltreffer sind klassische
BM25-Schwächen (Komposita: „aliquotiert"↔„Aliquotierung";
„Mindestlohngesetz") — genau die Fälle, die die Dense-Suche abdecken soll.
Hybrid-Messung auf dem Host aussteht (Ollama aus der Zed-Sandbox nicht
erreichbar).
## Dateien
```
agent/
config.py Env-Konfiguration
kb.py Layer-2-Parsing + kb.json-Gate
normalize.py Umlaut-Folding, FTS-Query-Bau
ingest.py Index-Bau (chunks + FTS5 + Vektoren-Cache)
retrieve.py Hybrid-Retrieval (BM25 + Dense, RRF, cross_refs)
ollama_client.py Ollama-HTTP (embed + chat, think-Fallback)
generate.py Systemprompt, Post-Validierung, Verweigerung
api.py FastAPI (/ask, /health, /reindex)
cli.py ingest | ask | eval | serve
eval/ goldset.yaml + evaluate.py
web/index.html Minimaler Test-Chat
tests/ 41 Tests (offline, Fake-Ollama)
data/ index.db (gitignored)
```
## Tests
```bash
python -m pytest -q # 41 Tests, alle offline
```
## Lizenz-Disziplin
`.lexis360/`, `.wiku/`, `.firecrawl/`, `.ris/` sind lokal und unversioniert
(`.gitignore`). Der Index enthält ausschließlich Layer-2-Kuratierung;
Layer-1-Prompts wären ein Lizenzverstoß und sind im Code nicht vorgesehen.
+8
View File
@@ -0,0 +1,8 @@
"""PV RAG Agent — Wissensbasis-Copilot für österreichische Personalverrechnung.
Schlanke RAG-Pipeline über die kuratierte Wissensbasis (Layer 2):
Ingest -> SQLite-Index (FTS5-BM25 + Dense-Vektoren) -> Hybrid-Retrieval ->
Ollama-Generierung mit verbindlichen Grounding-Regeln.
Verbindliche Regeln: siehe .agents/skills/pv-rag-agent/SKILL.md und planung.md.
"""
+156
View File
@@ -0,0 +1,156 @@
"""FastAPI-Oberfläche des PV RAG Agent.
Endpunkte:
POST /ask — Frage -> belegte Antwort (oder Verweigerung)
GET /health — Index- und Ollama-Status
POST /reindex — Index-Neuaufbau (nach neuem Wissensbasis-Batch)
"""
from __future__ import annotations
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from .config import Config
from .generate import answer_question
from .ingest import build_index
from .ollama_client import OllamaClient
from .retrieve import Retriever
class AskRequest(BaseModel):
question: str = Field(min_length=3, max_length=2000)
top_k: int | None = Field(default=None, ge=1, le=20)
class SourceOut(BaseModel):
id: str
title: str
section: str | None = None
stand: str | None = None
work: str | None = None
class AskResponse(BaseModel):
question: str
answer: str
refused: bool
verified: bool
citations: list[str]
sources: list[SourceOut]
n_context: int
model: str
latency_ms: int
regenerations: int = 0
class AppState:
def __init__(self) -> None:
self.cfg: Config | None = None
self.client: OllamaClient | None = None
self.retriever: Retriever | None = None
self.lock = threading.Lock()
def ensure(self) -> Config:
if self.cfg is None:
self.cfg = Config.from_env()
return self.cfg
def get_client(self) -> OllamaClient:
if self.client is None:
cfg = self.ensure()
self.client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
return self.client
def get_retriever(self) -> Retriever:
if self.retriever is None:
cfg = self.ensure()
self.retriever = Retriever(cfg)
return self.retriever
def reset_retriever(self) -> None:
if self.retriever is not None:
self.retriever.close()
self.retriever = None
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.rag = AppState()
yield
rag: AppState = app.state.rag
if rag.retriever:
rag.retriever.close()
if rag.client:
rag.client.close()
app = FastAPI(title="PV RAG Agent", version="0.1.0", lifespan=lifespan)
@app.post("/ask", response_model=AskResponse)
def ask(req: AskRequest) -> AskResponse:
rag: AppState = app.state.rag
cfg = rag.ensure()
try:
result = answer_question(
req.question, cfg,
client=rag.get_client(),
retriever=rag.get_retriever(),
top_k=req.top_k,
)
except RuntimeError as e: # Index fehlt
raise HTTPException(status_code=503, detail=str(e)) from e
except Exception as e: # Ollama nicht erreichbar o. Ä.
raise HTTPException(
status_code=503,
detail=f"Antwortgenerierung fehlgeschlagen: {type(e).__name__}: {e}",
) from e
result.pop("draft", None)
return AskResponse(**result)
@app.get("/health")
def health() -> dict:
rag: AppState = app.state.rag
cfg = rag.ensure()
out: dict = {"service": "pv-rag-agent", "config": {
"ollama_url": cfg.ollama_url,
"answer_model": cfg.answer_model,
"embed_model": cfg.embed_model,
}}
try:
retriever = rag.get_retriever()
out["index"] = retriever.stats()
except RuntimeError as e:
out["index"] = {"error": str(e)}
client = rag.get_client()
out["ollama_up"] = client.is_up()
if out["ollama_up"]:
try:
out["ollama_models"] = client.list_models()
except Exception:
out["ollama_models"] = None
return out
@app.post("/reindex")
def reindex() -> dict:
rag: AppState = app.state.rag
cfg = rag.ensure()
with rag.lock:
stats = build_index(cfg, client=rag.get_client())
rag.reset_retriever()
result = stats.as_dict()
result["warning"] = (
"Index ohne Dense-Vektoren aufgebaut (Ollama-Embedding nicht verfügbar) — "
"BM25-only. 'ollama pull " + cfg.embed_model + "' prüfen und erneut reindexen."
if stats.embed_error else None
)
return result
+124
View File
@@ -0,0 +1,124 @@
"""CLI des PV RAG Agent.
python -m agent.cli ingest [--no-embed] Index (neu) aufbauen
python -m agent.cli ask "Frage?" [--top-k N] [--json]
python -m agent.cli eval [--answers] [--limit N] [--k 8] [--json-out FILE]
python -m agent.cli serve [--host 0.0.0.0]
"""
from __future__ import annotations
import argparse
import json
import sys
from .config import Config
def _cmd_ingest(args: argparse.Namespace, cfg: Config) -> int:
import dataclasses
from .ingest import build_index
client = None
if args.no_embed:
cfg = dataclasses.replace(cfg, embed_off=True)
elif not cfg.embed_off:
from .ollama_client import OllamaClient
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
if not client.is_up():
print(
f"[warn] Ollama unter {cfg.ollama_url} nicht erreichbar — "
"Index wird BM25-only aufgebaut.",
file=sys.stderr,
)
try:
stats = build_index(cfg, client=client)
finally:
if client is not None:
client.close()
print(json.dumps(stats.as_dict(), indent=2, ensure_ascii=False))
if stats.embed_error:
print(f"[warn] {stats.embed_error}", file=sys.stderr)
return 0
def _cmd_ask(args: argparse.Namespace, cfg: Config) -> int:
from .generate import answer_question
try:
result = answer_question(args.question, cfg, top_k=args.top_k)
except Exception as e:
print(
f"[Fehler] Antwortgenerierung fehlgeschlagen: "
f"{type(e).__name__}: {e}\n"
f"Ollama erreichbar unter {cfg.ollama_url}? 'curl {cfg.ollama_url}/api/tags'",
file=sys.stderr,
)
return 2
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
print(result["answer"])
if result["sources"]:
print("\nQuellen:")
for s in result["sources"]:
print(f" - {s['id']}{s['title']} ({s['stand']})")
status = "VERWEIGERT" if result["refused"] else (
"OK" if result["verified"] else "UNVERIFIZIERT"
)
print(
f"\n[{status} · {result['model']} · {result['latency_ms']} ms · "
f"{result['n_context']} Kontextblöcke]"
)
return 0
def _cmd_eval(args: argparse.Namespace, cfg: Config) -> int:
from .eval.evaluate import run_eval
return run_eval(cfg, args)
def _cmd_serve(args: argparse.Namespace, cfg: Config) -> int:
import uvicorn
uvicorn.run("agent.api:app", host=args.host, port=cfg.port, log_level="info")
return 0
def main(argv: list[str] | None = None) -> int:
cfg = Config.from_env()
parser = argparse.ArgumentParser(prog="agent.cli", description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
p_ingest = sub.add_parser("ingest", help="Index (neu) aufbauen")
p_ingest.add_argument("--no-embed", action="store_true",
help="Keine Embeddings erzeugen (BM25-only)")
p_ask = sub.add_parser("ask", help="Frage stellen")
p_ask.add_argument("question")
p_ask.add_argument("--top-k", type=int, default=None)
p_ask.add_argument("--json", action="store_true")
p_eval = sub.add_parser("eval", help="Goldset-Evaluation")
p_eval.add_argument("--answers", action="store_true",
help="inkl. Antwortgenerierung (benötigt Ollama)")
p_eval.add_argument("--limit", type=int, default=None)
p_eval.add_argument("--k", type=int, default=8, help="K für Recall@k")
p_eval.add_argument("--json-out", default=None)
p_serve = sub.add_parser("serve", help="HTTP-API starten")
p_serve.add_argument("--host", default="127.0.0.1")
args = parser.parse_args(argv)
handlers = {
"ingest": _cmd_ingest,
"ask": _cmd_ask,
"eval": _cmd_eval,
"serve": _cmd_serve,
}
return handlers[args.cmd](args, cfg)
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
"""Konfiguration des PV RAG Agent (alle Werte per Umgebungsvariable übersteuerbar)."""
from __future__ import annotations
import os
from dataclasses import dataclass
def _env_str(name: str, default: str) -> str:
v = os.environ.get(name)
return v if v not in (None, "") else default
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool(name: str, default: bool) -> bool:
v = os.environ.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on")
@dataclass
class Config:
# Pfade (relativ zum Repo-Root, sofern nicht absolut)
kb_dir: str = "wissensbasis"
db_path: str = "data/index.db"
# Ollama (Custom-Port 11435 — nicht "korrigieren", s. Skill)
ollama_url: str = "http://100.183.83.12:11435"
embed_model: str = "bge-m3"
answer_model: str = "qwen3.8:27b" # provisorisch bis Bake-off (M3)
# Generierung
temperature: float = 0.1
num_ctx: int = 16384
num_predict: int = 1024
think: bool = False # Thinking per Request abschalten (Latenz)
chat_timeout_s: float = 300.0
embed_timeout_s: float = 240.0
# Retrieval
embed_off: bool = False # True = BM25-only (ohne Dense-Index/-Suche)
candidate_pool: int = 50 # Kandidaten je Liste vor der Fusion
context_blocks: int = 8 # Kontext-Blöcke im Prompt
cross_ref_expand: int = 3 # Top-Einträge, deren cross_refs ergänzt werden
cross_ref_max_extra: int = 6 # Obergrenze der Ergänzungen
rrf_k: int = 60
recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand
# Service
port: int = 8080
@classmethod
def from_env(cls) -> "Config":
d = cls()
return cls(
kb_dir=_env_str("PV_KB_DIR", d.kb_dir),
db_path=_env_str("PV_DB_PATH", d.db_path),
ollama_url=_env_str("OLLAMA_URL", d.ollama_url),
embed_model=_env_str("PV_EMBED_MODEL", d.embed_model),
answer_model=_env_str("PV_ANSWER_MODEL", d.answer_model),
temperature=_env_float("PV_TEMPERATURE", d.temperature),
num_ctx=_env_int("PV_NUM_CTX", d.num_ctx),
num_predict=_env_int("PV_NUM_PREDICT", d.num_predict),
think=_env_bool("PV_THINK", d.think),
chat_timeout_s=_env_float("PV_CHAT_TIMEOUT_S", d.chat_timeout_s),
embed_timeout_s=_env_float("PV_EMBED_TIMEOUT_S", d.embed_timeout_s),
embed_off=_env_bool("PV_EMBED_OFF", d.embed_off),
candidate_pool=_env_int("PV_CANDIDATE_POOL", d.candidate_pool),
context_blocks=_env_int("PV_CONTEXT_BLOCKS", d.context_blocks),
cross_ref_expand=_env_int("PV_CROSS_REF_EXPAND", d.cross_ref_expand),
cross_ref_max_extra=_env_int("PV_CROSS_REF_MAX_EXTRA", d.cross_ref_max_extra),
recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost),
port=_env_int("PV_PORT", d.port),
)
+1
View File
@@ -0,0 +1 @@
"""Eval-Paket: Goldset + Metriken für Retrieval und belegte Antworten."""
+178
View File
@@ -0,0 +1,178 @@
"""Goldset-Evaluation.
Modus 1 (offline, ohne Ollama): Retrieval-Metriken — Recall@k, Hit-Rate, MRR.
Modus 2 (--answers, benötigt Ollama): Zitier-Präzision (validated),
Verweigerungskorrektheit, erwartete Quelle zitiert, Latenz.
Kriterien laut Skill: Recall@8 > 0,9; Zitier-Präzision 100 %;
Verweigerungen korrekt.
"""
from __future__ import annotations
import json
import statistics
from pathlib import Path
import yaml
from ..config import Config
from ..retrieve import Retriever
GOLDSET_PATH = Path(__file__).parent / "goldset.yaml"
def load_goldset(path: Path = GOLDSET_PATH) -> list[dict]:
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
questions = data.get("questions", [])
if not questions:
raise SystemExit(f"goldset leer: {path}")
return questions
def evaluate_retrieval(cfg: Config, questions: list[dict], k: int = 8) -> list[dict]:
retriever = Retriever(cfg)
rows: list[dict] = []
try:
for q in questions:
if q.get("expect_refusal"):
continue # Verweigerungsfälle werden nur im Antwortmodus gemessen
expected = set(q.get("expected_ids", []))
if not expected:
continue
results = retriever.search(q["question"], n_entries=k)
retrieved = [r.entry_id for r in results]
hits = expected & set(retrieved)
rank = next(
(retrieved.index(e) + 1 for e in expected if e in retrieved), None
)
rows.append(
{
"id": q["id"],
"question": q["question"],
"expected": sorted(expected),
"retrieved": retrieved,
"recall": len(hits) / len(expected),
"hit": bool(hits),
"mrr": (1.0 / rank) if rank else 0.0,
}
)
finally:
retriever.close()
return rows
def summarize_retrieval(rows: list[dict]) -> dict:
if not rows:
return {"n": 0}
return {
"n": len(rows),
"hit_rate": round(sum(r["hit"] for r in rows) / len(rows), 4),
"mean_recall_at_k": round(statistics.mean(r["recall"] for r in rows), 4),
"mrr": round(statistics.mean(r["mrr"] for r in rows), 4),
}
def evaluate_answers(cfg: Config, questions: list[dict]) -> list[dict]:
from ..generate import answer_question # Ollama nötig — hier erst importieren
rows: list[dict] = []
for q in questions:
result = answer_question(q["question"], cfg)
expected = set(q.get("expected_ids", []))
should_refuse = bool(q.get("expect_refusal"))
rows.append(
{
"id": q["id"],
"question": q["question"],
"refused": result["refused"],
"should_refuse": should_refuse,
"refusal_correct": result["refused"] == should_refuse,
"verified": result["verified"],
"citations": result["citations"],
"expected_cited": (
any(c in expected for c in result["citations"]) if expected else None
),
"regenerations": result["regenerations"],
"latency_ms": result["latency_ms"],
}
)
return rows
def summarize_answers(rows: list[dict]) -> dict:
if not rows:
return {"n": 0}
lat = [r["latency_ms"] for r in rows]
return {
"n": len(rows),
"citation_precision": round(
sum(r["verified"] for r in rows) / len(rows), 4
),
"refusal_correct_rate": round(
sum(r["refusal_correct"] for r in rows) / len(rows), 4
),
"expected_cited_rate": round(
sum(bool(r["expected_cited"]) for r in rows)
/ max(1, sum(1 for r in rows if r["expected_cited"] is not None)),
4,
),
"latency_ms_mean": round(statistics.mean(lat)),
"latency_ms_p95": round(sorted(lat)[int(len(lat) * 0.95) - 1]),
"regenerations_total": sum(r["regenerations"] for r in rows),
}
def run_eval(cfg: Config, args) -> int:
questions = load_goldset()
if args.limit:
questions = questions[: args.limit]
mode = "retrieval + Antworten" if args.answers else "nur Retrieval (offline)"
print(f"Goldset: {len(questions)} Fragen · Modus: {mode} · k={args.k}")
print(f"Antwortmodell: {cfg.answer_model} · Embedding: {cfg.embed_model}")
print("-" * 78)
report: dict = {"mode": mode, "k": args.k, "model": cfg.answer_model}
rows = evaluate_retrieval(cfg, questions, k=args.k)
for r in rows:
mark = "" if r["hit"] else ""
print(
f"{mark} {r['id']:8s} recall={r['recall']:.2f} "
f"mrr={r['mrr']:.2f} {r['question'][:56]}"
)
if not r["hit"]:
print(f" erwartet: {', '.join(r['expected'])}")
print(f" erhalten: {', '.join(r['retrieved'][:args.k])}")
summary = summarize_retrieval(rows)
report["retrieval"] = {"summary": summary, "rows": rows}
print("-" * 78)
print(f"Retrieval: n={summary['n']} Hit-Rate={summary['hit_rate']} "
f"Recall@{args.k}={summary['mean_recall_at_k']} MRR={summary['mrr']}")
if args.answers:
arows = evaluate_answers(cfg, questions)
for r in arows:
mark = "" if (r["verified"] and r["refusal_correct"]) else ""
print(
f"{mark} {r['id']:8s} refused={r['refused']} verified={r['verified']} "
f"zit={','.join(r['citations'][:4])} {r['latency_ms']}ms"
)
asummary = summarize_answers(arows)
report["answers"] = {"summary": asummary, "rows": arows}
print("-" * 78)
print(
f"Antworten: n={asummary['n']} Zitier-Präzision={asummary['citation_precision']} "
f"Verweigerung korrekt={asummary['refusal_correct_rate']} "
f"erwartete Quelle zitiert={asummary['expected_cited_rate']}"
)
print(
f"Latenz: mean={asummary['latency_ms_mean']}ms p95={asummary['latency_ms_p95']}ms "
f"Regenerierungen={asummary['regenerations_total']}"
)
if args.json_out:
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
Path(args.json_out).write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Report geschrieben: {args.json_out}")
return 0
+116
View File
@@ -0,0 +1,116 @@
# Goldset für die Evaluation des PV RAG Agent.
# expected_ids: verifiziert gegen wissensbasis/kb.json (Titel-Suche).
# expect_refusal: Frage darf/darf nicht aus der Wissensbasis beantwortet
# werden — Antwortmodus (eval --answers) muss verweigern.
# q-002 ist der bekannte Korpuskonflikt (ATZ-Ersatzquote 28,5 vs. 27,5 %).
questions:
- id: q-001
question: "Was ist Altersteilzeit und wie funktioniert der Lohnausgleich?"
expected_ids: [lb-atz-07, lb-atz-04]
- id: q-002
question: "Wie hoch ist die AMS-Ersatzquote bei geblockter Altersteilzeit?"
expected_ids: [lb-atz-07, lb-atz-09, lb-atz-12]
note: "Korpuskonflikt — beide Werte mit ⚠ nennen."
- id: q-003
question: "Für welche Arbeitnehmer ist die Altersteilzeit förderbar?"
expected_ids: [lb-atz-03]
- id: q-004
question: "Wie viele Wochen gesetzlicher Urlaub stehen einem Arbeitnehmer zu?"
expected_ids: [lb-url-05]
- id: q-005
question: "Wie wird das Urlaubsentgelt beim Wechsel zwischen Teilzeit und Vollzeit berechnet?"
expected_ids: [lb-url-13]
- id: q-006
question: "Wie lange dauert die Entgeltfortzahlung im Krankenstand?"
expected_ids: [lb-krs-08]
- id: q-007
question: "Welche Fristen gelten für die Auflösung in der Probezeit?"
expected_ids: [lb-bnd-37]
- id: q-008
question: "Welche Verfügungsmöglichkeiten gibt es bei der Abfertigung neu?"
expected_ids: [lb-end-03]
- id: q-009
question: "Welche Bezugsbestandteile sind beitragsfrei nach § 49 Abs. 3 ASVG?"
expected_ids: [lb-sva-03, lb-sva-04]
- id: q-010
question: "Wie hoch sind die Sozialversicherungs-Beitragssätze für Dienstnehmer?"
expected_ids: [lb-sva-06]
- id: q-011
question: "Was gilt als Nachtschwerarbeit und welche Folgen hat das?"
expected_ids: [lb-nsc-01]
- id: q-012
question: "Wie sind Mitarbeiterrabatte abgabenrechtlich zu behandeln?"
expected_ids: [lb-sac-02]
- id: q-013
question: "Wie wird die Privatnutzung eines Dienstwagens besteuert?"
expected_ids: [lb-sac-03]
- id: q-014
question: "Unter welchen Voraussetzungen gibt es Pendlerförderung?"
expected_ids: [lb-pen-01]
- id: q-015
question: "Wie werden Tagesgelder bei Dienstreisen abgerechnet?"
expected_ids: [lb-rei-09]
- id: q-016
question: "Wie läuft eine GPLB ab?"
expected_ids: [lb-gpl-01]
- id: q-017
question: "Was ist bei der Entsendung von Arbeitnehmern ins Ausland zu beachten?"
expected_ids: [lb-grz-06, lb-grz-07]
- id: q-018
question: "Wie funktioniert die betriebliche Vorsorgekasse mit Beitragszahlung?"
expected_ids: [lb-vor-02, lb-vor-03]
- id: q-019
question: "Welche Pflichten gelten bei der Einstellung von Lehrlingen?"
expected_ids: [lb-leh-03]
- id: q-020
question: "Welche Beschäftigungsverbote gelten für Schwangere?"
expected_ids: [lb-sch-04]
- id: q-021
question: "Wann beginnt die Elternkarenz und wie lange kann sie dauern?"
expected_ids: [lb-kar-01]
- id: q-022
question: "Wie ist eine Überstundenpauschale zu behandeln?"
expected_ids: [lb-ues-02]
- id: q-023
question: "Was muss ein Dienstzeugnis enthalten?"
expected_ids: [lb-bso-04]
- id: q-024
question: "Was ist bei Kurzarbeit arbeitsrechtlich zu beachten?"
expected_ids: [lb-azm-04, lb-azm-05, lb-azm-06]
- id: q-025
question: "Was passiert mit den Ansprüchen der Arbeitnehmer beim Betriebsübergang?"
expected_ids: [lb-ins-01]
- id: q-026
question: "Wie sind Nachzahlungen abzurechnen?"
expected_ids: [lb-naz-01]
- id: q-027
question: "Was ist beim Einlangen einer Lohnpfändung zu beachten?"
expected_ids: [lb-pfa-01, lb-pfa-02]
- id: q-028
question: "Wird die Sonderzahlung bei Eintritt oder Austritt aliquotiert?"
expected_ids: [lb-son-01]
- id: q-029
question: "Welche Neuerungen behandelt WIKU Personal aktuell 2026?"
expected_ids: [wk-akt-01]
- id: q-030
question: "Wer zahlt das Wochengeld und wie wird es berechnet?"
expected_ids: [lb-msf-03]
- id: q-031
question: "Was regelt das Mindestlohngesetz und für wen gilt es?"
expected_ids: [lb-ent-09]
# --- Verweigerungsfälle (Antwortmodus) ---
- id: r-001
question: "Wie hoch ist der aktuelle EUR-USD-Wechselkurs?"
expect_refusal: true
- id: r-002
question: "Wer war Bundeskanzler Österreichs im Jahr 2000?"
expect_refusal: true
- id: r-003
question: "Bis wann muss die Umsatzsteuervoranmeldung abgegeben werden?"
expect_refusal: true
note: "Schwerer Fall: 13 Chunks erwähnen 'Umsatzsteuer' beiläufig — Retrieval nicht leer, aber inhaltlich nicht gedeckt."
- id: r-004
question: "Wie bereite ich einen Pitch für Investoren vor?"
expect_refusal: true
+218
View File
@@ -0,0 +1,218 @@
"""Antwort-Generierung mit verbindlichen Grounding-Regeln.
Kern der Pipeline: Systemprompt (nur Kontext, Zitierpflicht, Verweigerung),
kontrollierte cross_ref-Erweiterung und Post-Validierung — jede zitierte ID
muss im Retrieved-Set stehen, sonst eine Regenerierung, dann Verweigerung.
Keine Antwort verlässt die Pipeline mit ungültigen Zitaten.
"""
from __future__ import annotations
import re
import time
from .config import Config
from .normalize import normalize_text
from .ollama_client import OllamaClient
from .retrieve import ChunkResult, Retriever
REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage."
UNCERTAIN_MESSAGE = (
"⚠ Zu dieser Frage kann ich keine verlässlich belegte Antwort "
"aus der Wissensbasis geben."
)
SYSTEM_PROMPT = """Du bist ein präziser Assistent für österreichische Personalverrechnung.
Du beantwortest Fragen AUSSCHLIESSLICH auf Basis der nummerierten Kontextblöcke
aus der internen Wissensbasis.
Verbindliche Regeln:
1. Jede fachliche Aussage muss durch die Kontextblöcke gedeckt sein. Verwende
KEIN Wissen aus deinem Training und ergänze nichts aus eigenem Wissen.
2. Belege jede fachliche Aussage mit der KB-ID in eckigen Klammern, z. B.
[lb-atz-07]. Zitiere NUR die IDs aus den Block-Köpfen („Block N — [id] …“).
IDs, die nur im Fließtext als Verweis genannt werden, sind Querverweise
und KEINE Belege.
3. Gib jeden Wert mit seinem Stand an, z. B. „28,5 % (Stand 2026-01)“.
4. Beantworten die Kontextblöcke die Frage nicht, antworte exakt:
„Dazu enthält die Wissensbasis keine Aussage.“ — und schlage nichts vor.
5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und
kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf.
6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt.
7. Antworte auf Deutsch, prägnant (Stichpunkte, wo sinnvoll), und füge am
Ende eine Zeile „Quellen:“ mit den verwendeten IDs (ID — Titel, Stand) an.
Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort."""
CITE_RE = re.compile(r"\b(?:lb|wk)-[a-z0-9]+-\d+\b")
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
def strip_think(text: str) -> str:
"""Entfernt <think>-Blöcke defensiv (falls Thinking nicht abschaltbar war)."""
return _THINK_RE.sub("", text).strip()
def looks_like_refusal(answer: str) -> bool:
folded = normalize_text(answer)
return (
"keine aussage" in folded
or "keine verlasslich belegte" in folded
or "nicht in der wissensbasis" in folded
)
def build_user_content(question: str, results: list[ChunkResult]) -> str:
blocks = []
for i, r in enumerate(results, 1):
header = (
f"Block {i} — [{r.entry_id}] {r.title} · Abschnitt: {r.section} "
f"· Stand: {r.stand} · Werk: {r.work}"
)
blocks.append(f"{header}\n{r.text}")
context = "\n\n---\n\n".join(blocks)
return f"Kontextblöcke aus der Wissensbasis:\n\n{context}\n\nFrage: {question}"
def validate_answer(answer: str, allowed_ids: list[str]) -> list[str]:
"""Regel-2/4-Prüfung: zitierte IDs ⊆ Kontext; keine unbelegte Fachantwort."""
cited = set(CITE_RE.findall(answer))
violations: list[str] = []
unknown = sorted(cited - set(allowed_ids))
if unknown:
violations.append(f"zitierte IDs außerhalb des Kontexts: {', '.join(unknown)}")
if not cited and not looks_like_refusal(answer):
violations.append("keine KB-ID zitiert")
return violations
def _source_rows(results: list[ChunkResult]) -> list[dict]:
rows = []
seen: set[str] = set()
for r in results:
if r.entry_id in seen:
continue
seen.add(r.entry_id)
rows.append(
{
"id": r.entry_id,
"title": r.title,
"section": r.section,
"stand": r.stand,
"work": r.work,
"source": r.source,
}
)
return rows
def answer_question(
question: str,
cfg: Config,
client: OllamaClient | None = None,
retriever: Retriever | None = None,
top_k: int | None = None,
) -> dict:
"""Vollständiger Ask-Zyklus: Retrieval -> Prompt -> LLM -> Post-Validierung."""
t0 = time.perf_counter()
own_retriever = retriever is None
if retriever is None:
retriever = Retriever(cfg)
try:
results = retriever.search(question, n_entries=top_k)
finally:
if own_retriever:
retriever.close()
def finish(answer, refused, verified, citations, regenerations=0, draft=None):
return {
"question": question,
"answer": answer,
"refused": refused,
"verified": verified,
"citations": citations,
"sources": _source_rows(results),
"n_context": len(results),
"model": cfg.answer_model,
"regenerations": regenerations,
"latency_ms": round((time.perf_counter() - t0) * 1000),
"draft": draft,
}
if not results:
# Verweigerungspflicht: leeres Retrieval -> deterministische Antwort
return finish(REFUSAL_MESSAGE, refused=True, verified=True, citations=[])
allowed = [r.entry_id for r in results]
by_id = {r.entry_id: r for r in results}
if client is None:
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_content(question, results)},
]
def chat(msgs):
return strip_think(
client.chat(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=cfg.num_predict,
think=cfg.think,
)
)
final = chat(messages)
violations = validate_answer(final, allowed)
regenerations = 0
if violations:
regenerations = 1
warn = (
"Deine letzte Antwort verstieß gegen die Regeln: "
+ "; ".join(violations)
+ f". Erlaubte KB-IDs sind ausschließlich: {', '.join(sorted(set(allowed)))}. "
"Beantworte die Frage erneut und zitiere nur diese IDs — oder verweigere "
f"mit dem vorgesehenen Satz („{REFUSAL_MESSAGE}“)."
)
retry = chat(
messages
+ [{"role": "assistant", "content": final},
{"role": "user", "content": warn}]
)
retry_violations = validate_answer(retry, allowed)
if not retry_violations:
final = retry
violations = []
else:
return finish(
UNCERTAIN_MESSAGE,
refused=True,
verified=False,
citations=[],
regenerations=regenerations,
draft=retry,
)
citations = sorted(set(CITE_RE.findall(final)))
refused = looks_like_refusal(final)
sources = [
{
"id": cid,
"title": by_id[cid].title,
"section": by_id[cid].section,
"stand": by_id[cid].stand,
"work": by_id[cid].work,
}
for cid in citations
if cid in by_id
]
return finish(
final, refused=refused, verified=not violations,
citations=citations, regenerations=regenerations,
)
+202
View File
@@ -0,0 +1,202 @@
"""Index-Bau: Layer-2-Einträge -> SQLite (FTS5-BM25 + Dense-Vektoren).
Chunking: H2-Sektion je Eintrag (Parent-Child: Retrieval auf Sektion,
Kontext = Sektion + Metadatenkopf). Die Vektoren-Tabelle ist ein Cache
(Content-Hash) und überlebt Rebuilds — ein Reindex bettet nur Neues ein.
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from .config import Config
from .kb import KBEntry, Section, load_kb
from .normalize import normalize_text
SCHEMA_VERSION = 1
EMBED_BATCH = 32
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS chunks (
chunk_id INTEGER PRIMARY KEY,
entry_id TEXT NOT NULL,
section TEXT NOT NULL,
text TEXT NOT NULL,
norm TEXT NOT NULL,
content_hash TEXT NOT NULL,
title TEXT, work TEXT, chapter TEXT, topic TEXT,
stand TEXT, batch INTEGER,
tags TEXT, legal_bases TEXT, cross_refs TEXT,
source_pdf TEXT, source_text TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunks_entry ON chunks(entry_id);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(norm);
CREATE TABLE IF NOT EXISTS vectors (
content_hash TEXT NOT NULL,
model TEXT NOT NULL,
dim INTEGER NOT NULL,
vec BLOB NOT NULL,
PRIMARY KEY (content_hash, model)
);
"""
@dataclass
class IndexStats:
n_entries: int = 0
n_chunks: int = 0
n_embedded: int = 0
embed_error: str | None = None
duration_s: float = 0.0
kb_dir: str = ""
db_path: str = ""
schema_version: int = SCHEMA_VERSION
def as_dict(self) -> dict:
return asdict(self)
def embed_text(title: str, section_title: str, text: str) -> str:
"""Einheitlicher Embedding-Input (Title + Abschnitt + Body)."""
return f"{title}\n{section_title}\n\n{text}"
def content_hash_for(title: str, section_title: str, text: str) -> str:
return hashlib.sha256(
embed_text(title, section_title, text).encode("utf-8")
).hexdigest()
def norm_text_for(entry: KBEntry, section: Section) -> str:
"""FTS-Text: Titel + Abschnitt + Tags + Rechtsgrundlagen + Kapitel + Body."""
parts = [
entry.title,
section.title,
" ".join(entry.tags),
" ".join(entry.legal_bases),
entry.chapter,
section.text,
]
return normalize_text("\n".join(p for p in parts if p))
def _insert_chunk(con: sqlite3.Connection, entry: KBEntry, section: Section) -> int:
h = content_hash_for(entry.title, section.title, section.text)
cur = con.execute(
"""INSERT INTO chunks (
entry_id, section, text, norm, content_hash,
title, work, chapter, topic, stand, batch,
tags, legal_bases, cross_refs, source_pdf, source_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
entry.id, section.title, section.text, norm_text_for(entry, section), h,
entry.title, entry.work, entry.chapter, entry.topic, entry.stand,
entry.batch,
json.dumps(entry.tags, ensure_ascii=False),
json.dumps(entry.legal_bases, ensure_ascii=False),
json.dumps(entry.cross_refs, ensure_ascii=False),
entry.source.get("pdf", ""), entry.source.get("text", ""),
),
)
chunk_id = cur.lastrowid
con.execute(
"INSERT INTO chunks_fts(rowid, norm) VALUES (?, ?)",
(chunk_id, norm_text_for(entry, section)),
)
return chunk_id
def _embed_missing(cfg: Config, con: sqlite3.Connection, client) -> tuple[int, str | None]:
"""Bettet alle Chunk-Hashes ein, die für cfg.embed_model fehlen."""
rows = con.execute(
"SELECT DISTINCT content_hash, title, section, text FROM chunks"
).fetchall()
unique: dict[str, str] = {}
for h, title, section, text in rows:
if h not in unique:
unique[h] = embed_text(title, section, text)
have = {
r[0] for r in con.execute(
"SELECT content_hash FROM vectors WHERE model = ?",
(cfg.embed_model,),
).fetchall()
}
todo = [h for h in unique if h not in have]
if not todo:
return 0, None
n = 0
for i in range(0, len(todo), EMBED_BATCH):
batch = todo[i:i + EMBED_BATCH]
texts = [unique[h] for h in batch]
try:
embs = client.embed(cfg.embed_model, texts)
except Exception as e: # httpx/Ollama-Fehler -> BM25-only weiterlaufen
return n, f"embedding failed at batch {i // EMBED_BATCH + 1}: {e}"
for h, vec in zip(batch, embs):
arr = np.asarray(vec, dtype=np.float32)
con.execute(
"INSERT OR REPLACE INTO vectors(content_hash, model, dim, vec) "
"VALUES (?, ?, ?, ?)",
(h, cfg.embed_model, int(arr.shape[0]), arr.tobytes()),
)
n += 1
return n, None
def build_index(cfg: Config, client=None) -> IndexStats:
"""Vollständiger Rebuild von chunks/FTS; Vektoren-Cache bleibt erhalten."""
t0 = time.perf_counter()
entries = load_kb(cfg.kb_dir, verify_registry=True)
db_path = Path(cfg.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
stats = IndexStats(kb_dir=str(cfg.kb_dir), db_path=str(db_path))
con = sqlite3.connect(db_path)
try:
con.executescript(SCHEMA)
con.execute("DELETE FROM chunks")
con.execute("DELETE FROM chunks_fts")
con.execute("DELETE FROM meta")
for entry in entries:
for section in entry.sections:
_insert_chunk(con, entry, section)
con.commit()
stats.n_entries = len(entries)
stats.n_chunks = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
if not cfg.embed_off:
if client is None:
from .ollama_client import OllamaClient
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
try:
stats.n_embedded, stats.embed_error = _embed_missing(cfg, con, client)
except Exception as e:
stats.embed_error = f"{type(e).__name__}: {e}"
con.executemany(
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
[
("schema_version", str(SCHEMA_VERSION)),
("built_at", datetime.now(timezone.utc).isoformat()),
("kb_dir", str(cfg.kb_dir)),
("embed_model", "" if cfg.embed_off else cfg.embed_model),
("n_entries", str(stats.n_entries)),
("n_chunks", str(stats.n_chunks)),
],
)
con.commit()
finally:
con.close()
stats.duration_s = round(time.perf_counter() - t0, 2)
return stats
+164
View File
@@ -0,0 +1,164 @@
"""Layer-2-Wissensbasis: Parsing (Frontmatter + H2-Sektionen) und kb.json-Gate.
Die Layer-2-Frontmatter ist die Single Source of Truth; kb.json ist deren
generierte, validierte Projektion. Der Gate bricht den Ingest bei Abweichung
ab (Fehlermeldung nennt die Regenerierung der Registry als nächsten Schritt).
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
ID_RE = re.compile(r"^(lb|wk)-[a-z0-9]+-\d+$")
STAND_RE = re.compile(r"^\d{4}-\d{2}$")
REQUIRED_KEYS = (
"id", "batch", "title", "work", "chapter", "topic", "author",
"stand", "source", "legal_bases", "tags", "cross_refs",
)
class KbValidationError(Exception):
"""Wissensbasis oder Registry ist inkonsistent — Ingest wird abgebrochen."""
@dataclass
class Section:
title: str
text: str
@dataclass
class KBEntry:
id: str
title: str
work: str
chapter: str
topic: str
author: str
stand: str
batch: int
source: dict
legal_bases: list
tags: list
cross_refs: list
path: Path
sections: list = field(default_factory=list)
def parse_frontmatter(raw: str) -> tuple[dict, str]:
lines = raw.splitlines()
if not lines or lines[0].strip() != "---":
raise KbValidationError("missing frontmatter delimiter '---'")
for i in range(1, len(lines)):
if lines[i].strip() == "---":
meta = yaml.safe_load("\n".join(lines[1:i]))
body = "\n".join(lines[i + 1:])
break
else:
raise KbValidationError("unterminated frontmatter")
if not isinstance(meta, dict):
raise KbValidationError("frontmatter is not a mapping")
return meta, body
def split_sections(body: str) -> list[Section]:
"""H2-Sektionen als Chunks; H1-Titel und Quellzeile fallen weg."""
sections: list[Section] = []
current_title: str | None = None
current: list[str] = []
for line in body.splitlines():
if line.startswith("## "):
if current_title is not None:
sections.append(Section(current_title, "\n".join(current).strip()))
current_title = line[3:].strip()
current = []
elif line.startswith("# "):
continue
elif current_title is not None:
current.append(line)
if current_title is not None:
sections.append(Section(current_title, "\n".join(current).strip()))
return [s for s in sections if s.text]
def load_entry(path: Path) -> KBEntry:
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
missing = [k for k in REQUIRED_KEYS if k not in meta]
if missing:
raise KbValidationError(f"{path.name}: missing frontmatter keys {missing}")
for key in ("id", "title", "work", "chapter", "topic", "author", "stand"):
if not isinstance(meta[key], str) or not meta[key].strip():
raise KbValidationError(f"{path.name}: empty '{key}'")
if not ID_RE.match(meta["id"]):
raise KbValidationError(f"{path.name}: invalid id '{meta['id']}'")
if not STAND_RE.match(meta["stand"]):
raise KbValidationError(f"{path.name}: stand '{meta['stand']}' not YYYY-MM")
if not isinstance(meta["source"], dict) or not {"pdf", "text"} <= set(meta["source"]):
raise KbValidationError(f"{path.name}: source needs pdf+text")
for key in ("legal_bases", "tags", "cross_refs"):
if not isinstance(meta[key], list):
raise KbValidationError(f"{path.name}: '{key}' must be a list")
entry = KBEntry(
id=meta["id"],
title=meta["title"],
work=meta["work"],
chapter=meta["chapter"],
topic=meta["topic"],
author=meta["author"],
stand=meta["stand"],
batch=int(meta["batch"]),
source=dict(meta["source"]),
legal_bases=list(meta["legal_bases"]),
tags=list(meta["tags"]),
cross_refs=list(meta["cross_refs"]),
path=path,
)
entry.sections = split_sections(body)
return entry
def load_kb(kb_dir: str | Path, verify_registry: bool = True) -> list[KBEntry]:
"""Lädt alle Layer-2-Einträge, prüft Cross-Ref-Integrität und das kb.json-Gate."""
root = Path(kb_dir)
doc_dir = root / "dokumente"
paths = sorted(doc_dir.glob("*.md"))
if not paths:
raise KbValidationError(f"no Layer-2 documents under {doc_dir}")
entries: dict[str, KBEntry] = {}
for p in paths:
e = load_entry(p)
if e.id in entries:
raise KbValidationError(
f"duplicate id {e.id}: {entries[e.id].path.name} and {p.name}"
)
entries[e.id] = e
for e in entries.values():
dangling = [ref for ref in e.cross_refs if ref not in entries]
if dangling:
raise KbValidationError(f"{e.id}: dangling cross_refs {dangling}")
if verify_registry:
_registry_gate(root, entries)
return list(entries.values())
def _registry_gate(root: Path, entries: dict[str, KBEntry]) -> None:
reg_path = root / "kb.json"
if not reg_path.is_file():
raise KbValidationError("kb.json missing — regenerate with --registry")
registry = json.loads(reg_path.read_text(encoding="utf-8"))
reg_ids = [e.get("id") for e in registry.get("entries", []) if e.get("id")]
reg_set = set(reg_ids)
kb_set = set(entries)
if reg_set != kb_set:
kb_only = sorted(kb_set - reg_set)[:5]
reg_only = sorted(reg_set - kb_set)[:5]
raise KbValidationError(
"kb.json out of sync with Layer 2 "
f"(docs-only: {kb_only}, registry-only: {reg_only}; "
f"registry n={registry.get('n_entries')}, docs n={len(entries)}) — "
"regenerate the registry first (build_lexis_kb.py --registry)"
)
+43
View File
@@ -0,0 +1,43 @@
"""Deutsch-affine Textnormalisierung für FTS5-Index und -Anfragen.
Konvention (einmalig, konsistent): lowercase, Diakritika via NFKD entfernen
(ä→a, ü→u), ß→ss. Dies gilt für die FTS-Spalte `norm` und die Query gleich.
Die ASCII-Slug-Konvention der Wissensbasis (Umlaute "fallen") betrifft nur
`topic`/`tags`/Dateinamen, nicht die Volltextsuche.
"""
from __future__ import annotations
import re
import unicodedata
# Stopwörter in normalisierter Form (nach Fold), klein halten.
STOPWORDS = frozenset(
"""der die das und oder ein eine einen einem einer eines den dem des ist im in
von fur mit auf zu zum zur an am beim wie was wann wird werden kann muss
sind hat nicht man als auch aus bei sein ihre ihr es sie er doch noch nur
schon sehr mehr hier da durch fuer wird""".split()
)
_TOKEN_RE = re.compile(r"[a-z0-9]+")
def normalize_text(s: str) -> str:
"""Lowercase, Diakritika entfernen (NFKD), ß→ss."""
s = unicodedata.normalize("NFKD", s.casefold())
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
return s.replace("ß", "ss")
def tokenize(s: str) -> list[str]:
return _TOKEN_RE.findall(normalize_text(s))
def fts_query(question: str, min_len: int = 2) -> str:
"""OR-verknüpfte FTS5-Phrasen aus normalisierten Termen; '' wenn leer."""
terms: list[str] = []
seen: set[str] = set()
for t in tokenize(question):
if len(t) >= min_len and t not in STOPWORDS and t not in seen:
seen.add(t)
terms.append(t)
return " OR ".join(f'"{t}"' for t in terms)
+108
View File
@@ -0,0 +1,108 @@
"""Schmaler Ollama-HTTP-Client (Chat + Embeddings).
Keine Tools, kein Browsing, keine Web-Hooks — der Agent hat architektonisch
keinen Weg aus der Wissensbasis hinaus (Grounding-Regel 1).
"""
from __future__ import annotations
from typing import Any
import httpx
class OllamaError(RuntimeError):
pass
class OllamaClient:
def __init__(self, base_url: str, embed_timeout_s: float = 240.0,
chat_timeout_s: float = 300.0):
self.base_url = base_url.rstrip("/")
self.embed_timeout_s = embed_timeout_s
self.chat_timeout_s = chat_timeout_s
# Per-Request-Timeouts mit kurzem Connect-Budget — ein unerreichbarer
# Server muss in Sekunden, nicht Minuten scheitern.
self.embed_timeout = httpx.Timeout(embed_timeout_s, connect=10.0)
self.chat_timeout = httpx.Timeout(chat_timeout_s, connect=10.0)
self._client = httpx.Client(timeout=self.chat_timeout)
def close(self) -> None:
self._client.close()
# -- Infrastruktur ------------------------------------------------------
def is_up(self) -> bool:
try:
r = self._client.get(f"{self.base_url}/api/tags", timeout=5.0)
return r.status_code == 200
except httpx.HTTPError:
return False
def list_models(self) -> list[str]:
r = self._client.get(f"{self.base_url}/api/tags")
r.raise_for_status()
return [m.get("model", "") for m in r.json().get("models", [])]
# -- Embeddings ---------------------------------------------------------
def embed(self, model: str, texts: list[str]) -> list[list[float]]:
"""Batch-Embeddings via /api/embed; Fallback auf Legacy /api/embeddings."""
r = self._client.post(
f"{self.base_url}/api/embed",
json={"model": model, "input": texts},
timeout=self.embed_timeout,
)
if r.status_code == 404:
out: list[list[float]] = []
for t in texts:
rr = self._client.post(
f"{self.base_url}/api/embeddings",
json={"model": model, "prompt": t},
timeout=self.embed_timeout,
)
rr.raise_for_status()
emb = rr.json().get("embedding")
if not emb:
raise OllamaError("legacy /api/embeddings returned no embedding")
out.append(emb)
return out
r.raise_for_status()
emb = r.json().get("embeddings")
if not isinstance(emb, list) or len(emb) != len(texts):
raise OllamaError(f"unexpected /api/embed response for {len(texts)} inputs")
return emb
# -- Chat ---------------------------------------------------------------
def chat(self, model: str, messages: list[dict],
temperature: float = 0.1, num_ctx: int = 16384,
num_predict: int = 1024, think: bool = False) -> str:
"""POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback."""
body: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_ctx": num_ctx,
"num_predict": num_predict,
},
}
if think is not None:
body["think"] = bool(think)
r = self._client.post(
f"{self.base_url}/api/chat", json=body, timeout=self.chat_timeout
)
if r.status_code in (400, 404) and "think" in body:
# Ältere Ollama-Versionen kennen das think-Flag nicht -> Retry ohne.
body.pop("think")
r = self._client.post(
f"{self.base_url}/api/chat", json=body, timeout=self.chat_timeout
)
r.raise_for_status()
data = r.json()
msg = data.get("message") or {}
content = msg.get("content") or ""
if not content.strip():
raise OllamaError(f"empty response from {model} (keys: {list(data.keys())})")
return content
+252
View File
@@ -0,0 +1,252 @@
"""Hybrid-Retrieval: BM25 (FTS5) + Dense (bge-m3) -> RRF-Fusion,
milde Stand-Aktualitätsgewichtung und kontrollierte cross_ref-Erweiterung.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .config import Config
from .normalize import fts_query
from .ollama_client import OllamaClient
@dataclass
class ChunkResult:
chunk_id: int
entry_id: str
section: str
text: str
title: str
stand: str
work: str
chapter: str
topic: str
tags: list = field(default_factory=list)
legal_bases: list = field(default_factory=list)
cross_refs: list = field(default_factory=list)
batch: int = 0
score: float = 0.0
source: str = "fused" # bm25 | dense | fused | cross_ref
class Retriever:
def __init__(self, cfg: Config, db_path: str | None = None, client=None):
self.cfg = cfg
self.db_path = str(db_path or cfg.db_path)
if not Path(self.db_path).is_file():
raise RuntimeError(
f"index fehlt ({self.db_path}) — zuerst 'python -m agent.cli ingest' ausführen"
)
self._con = sqlite3.connect(self.db_path)
self._con.row_factory = sqlite3.Row
self._client = client
self._owns_client = client is None
self._mat: np.ndarray | None = None
self._mat_chunk_ids: list[int] | None = None
row = self._con.execute("SELECT MIN(stand), MAX(stand) FROM chunks").fetchone()
self._stand_min = int((row[0] or "2026-01").replace("-", ""))
self._stand_max = int((row[1] or "2026-01").replace("-", ""))
def close(self) -> None:
self._con.close()
if self._owns_client and self._client is not None:
self._client.close()
# -- Index-Kennzahlen ---------------------------------------------------
def stats(self) -> dict:
n_chunks = self._con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
n_entries = self._con.execute(
"SELECT COUNT(DISTINCT entry_id) FROM chunks"
).fetchone()[0]
n_vec = self._con.execute(
"SELECT COUNT(*) FROM vectors WHERE model = ?",
(self.cfg.embed_model,),
).fetchone()[0]
meta = dict(self._con.execute("SELECT key, value FROM meta").fetchall())
return {
"n_entries": n_entries,
"n_chunks": n_chunks,
"n_vectors": n_vec,
"dense_available": n_vec > 0 and not self.cfg.embed_off,
"stand_min": str(self._stand_min),
"stand_max": str(self._stand_max),
"built_at": meta.get("built_at"),
}
# -- Einzelverfahren ----------------------------------------------------
def _bm25(self, question: str, limit: int) -> dict[int, float]:
q = fts_query(question)
if not q:
return {}
rows = self._con.execute(
"SELECT rowid, bm25(chunks_fts) AS rank FROM chunks_fts "
"WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?",
(q, limit),
).fetchall()
# bm25(): kleinere Werte = besser -> negieren für "größer = besser"
return {r["rowid"]: -float(r["rank"]) for r in rows}
def _dense(self, question: str, limit: int) -> dict[int, float]:
if self.cfg.embed_off:
return {}
self._ensure_matrix()
if self._mat is None or len(self._mat) == 0:
return {}
if self._client is None:
self._client = OllamaClient(
self.cfg.ollama_url,
embed_timeout_s=self.cfg.embed_timeout_s,
chat_timeout_s=self.cfg.chat_timeout_s,
)
try:
qvec = np.asarray(
self._client.embed(self.cfg.embed_model, [question])[0],
dtype=np.float32,
)
except Exception:
return {} # Ollama nicht erreichbar -> BM25-only weiter
qn = np.linalg.norm(qvec)
if qn == 0:
return {}
sims = self._mat_norm @ (qvec / qn)
order = np.argsort(-sims)[:limit]
return {self._mat_chunk_ids[i]: float(sims[i]) for i in order}
def _ensure_matrix(self) -> None:
if self._mat is not None:
return
rows = self._con.execute(
"SELECT c.chunk_id, v.vec, v.dim FROM chunks c "
"JOIN vectors v ON v.content_hash = c.content_hash AND v.model = ?",
(self.cfg.embed_model,),
).fetchall()
if not rows:
self._mat = np.zeros((0, 1), dtype=np.float32)
self._mat_chunk_ids = []
return
ids = [r[0] for r in rows]
mat = np.vstack(
[np.frombuffer(r[1], dtype=np.float32) for r in rows]
)
norms = np.linalg.norm(mat, axis=1, keepdims=True)
self._mat = mat
self._mat_norm = mat / np.where(norms == 0, 1.0, norms)
self._mat_chunk_ids = ids
# -- Metadaten & Fusion -------------------------------------------------
def _stand_factor(self, stand: str) -> float:
if self._stand_max <= self._stand_min:
return 0.0
try:
s = int(stand.replace("-", ""))
except (ValueError, AttributeError):
return 0.0
f = (s - self._stand_min) / (self._stand_max - self._stand_min)
return min(1.0, max(0.0, f))
def _fetch_chunks(self, chunk_ids: list[int]) -> dict[int, sqlite3.Row]:
out: dict[int, sqlite3.Row] = {}
for i in range(0, len(chunk_ids), 500):
part = chunk_ids[i:i + 500]
qm = ",".join("?" * len(part))
for r in self._con.execute(
f"SELECT * FROM chunks WHERE chunk_id IN ({qm})", part
).fetchall():
out[r["chunk_id"]] = r
return out
def _row_to_result(self, row: sqlite3.Row, score: float, source: str) -> ChunkResult:
return ChunkResult(
chunk_id=row["chunk_id"],
entry_id=row["entry_id"],
section=row["section"],
text=row["text"],
title=row["title"],
stand=row["stand"],
work=row["work"],
chapter=row["chapter"],
topic=row["topic"],
tags=json.loads(row["tags"]),
legal_bases=json.loads(row["legal_bases"]),
cross_refs=json.loads(row["cross_refs"]),
batch=row["batch"],
score=score,
source=source,
)
def _best_chunk_of_entry(self, entry_id: str) -> ChunkResult | None:
rows = self._con.execute(
"SELECT * FROM chunks WHERE entry_id = ? "
"ORDER BY CASE WHEN section LIKE 'Zusammenfassung%' THEN 0 ELSE 1 END, "
"chunk_id LIMIT 1",
(entry_id,),
).fetchall()
if not rows:
return None
return self._row_to_result(rows[0], 0.0, "cross_ref")
# -- öffentliche Suche --------------------------------------------------
def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]:
"""Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung)."""
n = n_entries or self.cfg.context_blocks
pool = self.cfg.candidate_pool
bm = self._bm25(question, pool)
try:
dn = self._dense(question, pool)
except Exception:
dn = {}
fused: dict[int, float] = {}
for ranking in (bm, dn):
ordered = sorted(ranking.items(), key=lambda kv: -kv[1])
for rank, (cid, _) in enumerate(ordered):
fused[cid] = fused.get(cid, 0.0) + 1.0 / (self.cfg.rrf_k + rank)
if not fused:
return []
rows = self._fetch_chunks(list(fused))
results: list[ChunkResult] = []
for cid, score in fused.items():
if cid not in rows:
continue
source = "fused" if (cid in bm and cid in dn) else (
"bm25" if cid in bm else "dense"
)
score += self.cfg.recency_boost * self._stand_factor(rows[cid]["stand"])
results.append(self._row_to_result(rows[cid], score, source))
results.sort(key=lambda r: -r.score)
# Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup)
main: list[ChunkResult] = []
seen: set[str] = set()
for r in results:
if r.entry_id in seen:
continue
seen.add(r.entry_id)
main.append(r)
if len(main) >= n:
break
# cross_ref-Erweiterung (kontrolliert, markiert, begrenzt)
extra: list[ChunkResult] = []
budget = self.cfg.cross_ref_max_extra
for r in main[: self.cfg.cross_ref_expand]:
for ref in r.cross_refs:
if budget <= 0:
break
if ref in seen:
continue
er = self._best_chunk_of_entry(ref)
if er is not None:
extra.append(er)
seen.add(ref)
budget -= 1
return main + extra
+16
View File
@@ -230,3 +230,19 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
3. **Layer 1:** bewusst außen vor in Phase A — einverstanden? 3. **Layer 1:** bewusst außen vor in Phase A — einverstanden?
4. **Odoo-Version für Phase B:** Odoo 19 Enterprise (passend zu 4. **Odoo-Version für Phase B:** Odoo 19 Enterprise (passend zu
`l10n_at_hr_payroll*`) — bitte bestätigen. `l10n_at_hr_payroll*`) — bitte bestätigen.
## 13. Umsetzungsstand (2026-09-14)
- **M1 erledigt (offline):** Ingest + Index (601 Einträge → 3.005 Chunks,
FTS5-BM25) + Hybrid-Retrieval (Dense-Code vorhanden, am Host zu messen).
Goldset 31 Fragen (IDs gegen kb.json verifiziert, inkl. ATZ-Konfliktfall
und 4 Verweigerungsfälle). Baseline BM25-only: Hit-Rate 0,871 ·
Recall@8 0,855 · MRR 0,476 — 4 Fehltreffer sind Komposita-/Stamm-
Muster, die die Dense-Suche abdecken soll. 41 Unit-Tests grün.
- **M2 implementiert:** Ollama-Client (embed/chat, think-Fallback),
Systemprompt mit Zitierpflicht, Post-Validierung (1× Regenerierung, dann
Verweigerung), `/ask`-API + CLI + Test-Chat. **Host-Validierung mit
echtem Ollama noch offen** (aus der Zed-Sandbox nicht erreichbar).
- **M3 offen:** Bake-off auf dem Host (qwen3.8:27b vs. qwen3:32b vs.
gemma3:27b vs. mistral-small3.2:24b; qwen3:14b als Latenz-Untergrenze).
- Betrieb/Host-Schritte: `agent/README.md`.
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115
uvicorn>=0.30
httpx>=0.27
PyYAML>=6.0
numpy>=2.0
pytest>=8.0
+200
View File
@@ -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
+146
View File
@@ -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"]
+99
View File
@@ -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()
+74
View File
@@ -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)
+29
View File
@@ -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?") == ""
+82
View File
@@ -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)
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PV RAG Agent — Test-Chat</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 780px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.2rem; }
#log { display: flex; flex-direction: column; gap: 0.8rem; margin: 1.5rem 0; }
.msg { border: 1px solid rgba(128,128,128,.35); border-radius: 8px; padding: .7rem .9rem; white-space: pre-wrap; }
.q { background: rgba(128,128,128,.12); }
.src { font-size: .78rem; margin-top: .4rem; display: flex; flex-wrap: wrap; gap: .3rem; }
.chip { border-radius: 999px; padding: .1rem .5rem; background: rgba(128,128,128,.15); }
.warn { border-color: #c08020; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; border-radius: 6px; border: 1px solid rgba(128,128,128,.5); }
button { padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
.meta { font-size: .75rem; opacity: .7; }
</style>
</head>
<body>
<h1>PV RAG Agent — Wissensbasis Personalverrechnung</h1>
<p class="meta">Antworten ausschließlich aus der kuratierten Wissensbasis, mit ID- und Stand-Beleg.</p>
<div id="log"></div>
<form id="f">
<input id="q" placeholder="Frage zur österreichischen Personalverrechnung …" autocomplete="off" required>
<button>Senden</button>
</form>
<script>
const log = document.getElementById("log");
function add(cls, html) {
const d = document.createElement("div");
d.className = "msg " + cls;
d.innerHTML = html;
log.appendChild(d);
}
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const q = document.getElementById("q").value.trim();
if (!q) return;
add("q", q);
document.getElementById("q").value = "";
try {
const r = await fetch("/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: q }),
});
if (!r.ok) {
add("warn", "Fehler " + r.status + ": " + (await r.text()));
return;
}
const d = await r.json();
const chips = (d.sources || []).map(s =>
`<span class="chip">${s.id} · ${s.stand || ""}</span>`).join("");
add(d.refused ? "warn" : "",
`${d.answer.replace(/</g, "&lt;")}` +
(d.sources && d.sources.length ? `<div class="src">${chips}</div>` : "") +
`<div class="meta">Modell: ${d.model} · ${d.latency_ms} ms · ` +
`${d.verified ? "zitiergeprüft" : "UNVERIFIZIERT"}${d.refused ? " · verweigert" : ""}</div>`);
} catch (err) {
add("warn", "Netzwerkfehler: " + err);
}
});
</script>
</body>
</html>