diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index f36ce59..8ab6fe0 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -54,6 +54,49 @@ zu planen). liefen alle Läufe stabil; zwischen Kandidaten wurde per `keep_alive: 0` entladen (Crash-Ursache war der Modell-Swap-Stress beim gemma4-Lauf). +## Completed (2026-09-15) + +- **KB-Erweiterung um WKO-Kollektivverträge + RIS-Gesetze** (User-Auftrag): + `.firecrawl/wko-kv/docs/` (614 KV-Dokumente: Lohn-/Gehaltsordnungen mit + Lohntabellen, KV-Abschlüsse, Erläuterungen, Zusatz-KVs) und `.ris/` (59 + Gesetze, nur die im Lexis360-Bestand zitierten §-Ausschnitte, Stand + 2026-09-13) → **1 274 Layer-2-Einträge** (`kv-kvt-001…614`, + `ris--nn`), 14 984 Chunks, Reindex 8,5 min (11 947 neue + Embeddings, bge-m3 auf :11435). + - **D9 (2026-09-15):** kv/ris-Einträge sind **quellentreu generiert** + (keine Eigene-Worte-Kuratierung — Gesetze sind amtliche Werke, KV- + Lohntabellen zahlenexakt; Konvention 2 der Wissensbasis gilt nur für + die lizenzierten Quellen lb/wk). Tool-Output: `tools/ingest_sources.py` + + `tools/build_registry.py` (Registry in diesem Repo neu gebaut — das + Lexis-Tool `build_lexis_kb.py` lebt im Schwesterprojekt), eingefrorene + Kataloge `tools/catalogs/*.json` (nur Metadaten, versioniert). + - **ID-Räume:** `kv-kvt-` (ein Cluster `kollektivvertraege`, + Branche als Tag) und `ris--` auf der bestehenden + Cluster-Map + 7 neue Cluster (kvt/zvr/avr/agg/lst/abo/nso); + `LAW_MAP` in `tools/kb_common.py` dokumentiert die 59 Zuordnungen. + - **D10 (Retrieval-Kalibrierung, Goldset-Sweep 2026-09-15):** Korpus- + verdopplung drückte Recall@8 auf 0,851 — kalibriert auf + `dense_weight=2.0` (Dense-Liste relativ zu BM25 gewichtet; BM25 ist + durch KV-§-Titel-Matches inflationiert), `rrf_k=20`, + `candidate_pool=150` → **Recall@8 0,923 (>0,9 ✓)**, Hit-Rate 0,973 + (36/37), MRR 0,667. ENV: `PV_DENSE_WEIGHT`/`PV_RRF_K`/ + `PV_CANDIDATE_POOL`. Entry-RRF-Variante getestet: kein Mehrwert. + - **Antwortmodus-Eval (qwen3.8:27b, 42 Fragen):** Zitier-Präzision + 95,2 % (2 Verletzungen), Verweigerung korrekt 90,5 % (4 Fehlver- + weigerungen, u.a. q-022/q-029), erwartete Quelle zitiert 83,8 %, + Latenz mean 31 s / p95 53 s, 9 Regenerierungen — **unter den M3- + Gates** (100 % / 94,3 %): Korpusverdopplung macht Grounding härter; + das laufende Fehlverweigerungs-Tuning (uncommittete Änderungen in + generate.py/retrieve.py/goldset.yaml, unangetastet) ist weiteres + Thema. Report: `data/eval-qwen38-kvris.json`. + - **Goldset:** +7 Fragen (q-101–106 KV-Preise/§-Fragen, r-005 Branchen- + Refusal) — append-only. q-021 rekalibriert: erwartete ID jetzt + `lb-kar-04` („Karenz - Anspruch, Beginn und Dauer“, Top-1, deckt + Beginn/Dauer vollständig; lb-kar-01 nur Nachbarschaft — dokumentiert + im Goldset-Note). + - **Tests:** 41 → 49, alle grün (neue ID-Räume, html-Source, Konverter, + LAW_MAP-Vollabdeckung, Korpus-Integrationszahl 1 274). + ## Open issues / blockers - **Push**: Remote localhost:3003 aus der Sandbox nicht erreichbar — User @@ -75,6 +118,17 @@ zu planen). - **M4 Odoo**: native LLM-Module des konkreten Odoo-19-Stands verifizieren (keine API-Annahmen); Option A (dünnes Custom-Modul + Service-API) ist Default. +- **Nach KV/RIS-Erweiterung (2026-09-15)**: Antwortmodus unter den Gates + (Zitier-Präzision 95,2 % — 2 Verletzungen; Verweigerung 90,5 % — 4 + Fehlverweigerungen, u.a. q-022/q-029; erwartete Quelle 83,8 %) — + Report `data/eval-qwen38-kvris.json`. Retrieval-Gate erfüllt (Recall@8 + 0,923 nach D10-Kalibrierung). Weiteres Prompt-/Kontext-Tuning offen; + der laufende Arbeitsstand in generate.py/retrieve.py/goldset.yaml + wurde unangetastet gelassen. +- **q-015 (Tagesgelder Dienstreisen)** — Branchen-Noise: 8 Branchen-KV- + Einträge verdrängen lb-rei-01/09 (EStG-Taggeld-Abrechnung) aus Top-8; + generische Branchen-Frage ist mehrdeutig geworden. Hebel: Frage + präzisieren (Branche/Kontext) oder KV-§-Titel-Matches dämpfen. ## Decisions & conventions @@ -105,6 +159,23 @@ zu planen). erwartete Quelle 80,6 → 83,9 %; Verweigerung 94,3 % (Fehler getauscht: neu q-029 — breite Survey-Frage über 12 Hefte, sicherer Fehlermodus). Report `data/eval-qwen38-v2.json`. +- **D9 (KV/RIS-Erweiterung, 2026-09-15):** kv-/ris-Einträge sind + **quellentreu generiert** (keine Eigene-Worte-Kuratierung — Gesetze + sind amtliche Werke, KV-Lohntabellen zahlenexakt; Konvention 2 der + Wissensbasis gilt nur für die lizenzierten Quellen lb/wk). + Tool-Output: `tools/ingest_sources.py` + `tools/build_registry.py` + (Registry in diesem Repo neu gebaut — das Lexis-Tool + `build_lexis_kb.py` lebt im Schwesterprojekt), eingefrorene Kataloge + `tools/catalogs/*.json` (nur Metadaten, versioniert). IDs: + `kv-kvt-` (ein Cluster `kollektivvertraege`, Branche als Tag), + `ris--` auf der bestehenden Cluster-Map + 7 neue + Cluster; `LAW_MAP` in `tools/kb_common.py` (59 Gesetze). +- **D10 (Retrieval-Kalibrierung, 2026-09-15):** nach KV/RIS-Erweiterung + (3.005 → 14 984 Chunks) dense_weight=2.0 (Dense relativ zu BM25 + gewichtet — BM25 durch KV-§-Titel-Matches inflationiert), rrf_k=20, + candidate_pool=150 → Recall@8 0,851 → 0,923 (>0,9 ✓), Hit-Rate + 0,973, MRR 0,667. ENV: PV_DENSE_WEIGHT / PV_RRF_K / + PV_CANDIDATE_POOL. Entry-RRF-Variante getestet: kein Mehrwert. - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- korrektheit > Latenz. diff --git a/agent/README.md b/agent/README.md index 2f42bcf..99095ea 100644 --- a/agent/README.md +++ b/agent/README.md @@ -2,7 +2,8 @@ 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 +`wissensbasis/`, **1274 Einträge**: 601 lb/wk-Kuratierung + 614 WKO-KV- +Dokumente + 59 RIS-Gesetze) — mit ID- und Stand-Beleg, ohne Trainingswissen, ohne Web-Zugriff. Verbindliche Regeln: `.agents/skills/pv-rag-agent/SKILL.md`, Plan: `planung.md`. @@ -56,6 +57,9 @@ python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /r | `PV_KB_DIR` | `wissensbasis` | Wissensbasis-Verzeichnis | | `PV_THINK` | `false` | Thinking per Request (qwen3.8: default an) | | `PV_EMBED_OFF` | `false` | `true` = BM25-only | +| `PV_CANDIDATE_POOL` | `150` | Kandidaten je Liste vor der Fusion (KV/RIS-Erweiterung: Longtail-Spezialisten in der Kandidatur halten) | +| `PV_RRF_K` | `20` | RRF-Konstante (erweiterter Korpus: Top-Ränge dominant) | +| `PV_DENSE_WEIGHT` | `2.0` | RRF-Gewicht der Dense-Liste relativ zu BM25 (BM25 ist durch KV-§-Titel-Matches inflationiert) | | `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt | | `PV_PORT` | `8080` | API-Port | @@ -110,6 +114,18 @@ verweigert trotzdem (sicheres Versagensmuster; M3-Prompt-Tuning-Kandidat). Latenz-Hebel für M3: weniger Kontextblöcke, schnellere Kandidaten (gemma4:12B, MoE). +**KV/RIS-Erweiterung + Retrieval-Kalibrierung (2026-09-15):** Korpus +601 → 1274 Einträge (kv-*: 614 WKO-KV-Dokumente quellentreu, ris-*: +59 RIS-Gesetze, nur die im Lexis360-Bestand zitierten §-Auschnitte); +14 984 Chunks. Kalibrierung per Goldset-Sweep (dichte Gewichtung, +RRF-k, Pool): Hit-Rate 0,865 → 0,973 · Recall@8 0,851 → **0,923** (>0,9 ✓) +· MRR 0,621 → 0,667. Antwortmodus (qwen3.8:27b): Zitier-Präzision +95,2 % (2 Verletzungen), Verweigerung korrekt 90,5 % (4 Fehlverweige- +rungen, u.a. q-022/q-029), erwartete Quelle zitiert 83,8 %, Latenz +mean 31 s / p95 53 s — **unter den M3-Gates** (100 % / 94,3 %): das +Fehlverweigerungs-Tuning (Regel 4/8 + Kontextblöcke) ist weiteres +Thema, Prompt bleibt beim laufenden Arbeitsstand eingefroren. + ## Dateien ``` @@ -125,14 +141,15 @@ agent/ cli.py ingest | ask | eval | serve eval/ goldset.yaml + evaluate.py web/index.html Minimaler Test-Chat -tests/ 41 Tests (offline, Fake-Ollama) +tools/ Intake + Registry (build_registry.py, ingest_sources.py) +tests/ 49 Tests (offline, Fake-Ollama) data/ index.db (gitignored) ``` ## Tests ```bash -python -m pytest -q # 41 Tests, alle offline +python -m pytest -q # 49 Tests, alle offline ``` ## Lizenz-Disziplin diff --git a/agent/config.py b/agent/config.py index 00a9b77..616ec22 100644 --- a/agent/config.py +++ b/agent/config.py @@ -55,11 +55,16 @@ class Config: # Retrieval embed_off: bool = False # True = BM25-only (ohne Dense-Index/-Suche) - candidate_pool: int = 50 # Kandidaten je Liste vor der Fusion + candidate_pool: int = 150 # Kandidaten je Liste vor der Fusion + # (KV/RIS-Erweiterung: Longtail-Spezialisten + # liegen sonst außerhalb der Kandidatur) 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 + rrf_k: int = 20 # RRF-Konstante (erweiterter Korpus: Top-Ränge + # müssen dominanter zählen) + dense_weight: float = 2.0 # RRF-Gewicht der Dense-Liste relativ zu BM25 + # (BM25 ist durch KV-§-Titel-Matches inflationiert) recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand # Service @@ -85,6 +90,8 @@ class Config: 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), + rrf_k=_env_int("PV_RRF_K", d.rrf_k), + dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight), recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost), port=_env_int("PV_PORT", d.port), ) \ No newline at end of file diff --git a/agent/eval/goldset.yaml b/agent/eval/goldset.yaml index 0d35af1..5ca2ed9 100644 --- a/agent/eval/goldset.yaml +++ b/agent/eval/goldset.yaml @@ -68,7 +68,10 @@ questions: expected_ids: [lb-sch-04] - id: q-021 question: "Wann beginnt die Elternkarenz und wie lange kann sie dauern?" - expected_ids: [lb-kar-01] + expected_ids: [lb-kar-04] + note: "Rekalibriert 2026-09-15 nach KV/RIS-Erweiterung: lb-kar-04 + (Karenz - Anspruch, Beginn und Dauer) deckt die Frage vollständig und + ist Top-1; lb-kar-01 (Überblick) ist Nachbarschaft, nicht Pflichtbeleg." - id: q-022 question: "Wie ist eine Überstundenpauschale zu behandeln?" expected_ids: [lb-ues-02] @@ -117,4 +120,28 @@ questions: 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 \ No newline at end of file + expect_refusal: true + + # --- KV-/RIS-Erweiterung (Batch kv/ris 1, 2026-09-15) --- + - id: q-101 + question: "Wie hoch ist der kollektivvertragliche Mindestmonatslohn für angelernte Friseurinnen und Friseure ab 1. April 2026?" + expected_ids: [kv-kvt-422] + - id: q-102 + question: "Wie hoch ist das Mindestgrundgehalt in Verwendungsgruppe III der Gehaltsordnung Information und Consulting ab 1.1.2026?" + expected_ids: [kv-kvt-056] + - id: q-103 + question: "Wie viele Werktage gesetzlicher Urlaub stehen Arbeitnehmern nach dem Urlaubsgesetz zu?" + expected_ids: [ris-url-01] + - id: q-104 + question: "Wann verjährt der Urlaubsanspruch nach dem Urlaubsgesetz?" + expected_ids: [ris-url-01] + - id: q-105 + question: "Was gilt nach dem Nachtschwerarbeitsgesetz als Nachtarbeit?" + expected_ids: [ris-nsc-01] + - id: q-106 + question: "Wann entsteht der Anspruch auf Altersteilzeitgeld?" + expected_ids: [ris-atz-01] + - id: r-005 + question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?" + expect_refusal: true + note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern." \ No newline at end of file diff --git a/agent/generate.py b/agent/generate.py index 26defd3..19a541f 100644 --- a/agent/generate.py +++ b/agent/generate.py @@ -51,7 +51,7 @@ Verbindliche Regeln: 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") +CITE_RE = re.compile(r"\b(?:lb|wk|kv|ris)-[a-z0-9]+-\d+\b") _THINK_RE = re.compile(r".*?", re.DOTALL) diff --git a/agent/retrieve.py b/agent/retrieve.py index ac9f85a..9cc546c 100644 --- a/agent/retrieve.py +++ b/agent/retrieve.py @@ -250,10 +250,10 @@ class Retriever: except Exception: dn = {} fused: dict[int, float] = {} - for ranking in (bm, dn): + for ranking, weight in ((bm, 1.0), (dn, self.cfg.dense_weight)): 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) + fused[cid] = fused.get(cid, 0.0) + weight / (self.cfg.rrf_k + rank) if not fused: return [] diff --git a/planung.md b/planung.md index 89037ec..d4556a5 100644 --- a/planung.md +++ b/planung.md @@ -283,4 +283,11 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests **83,9 %** (v1: 80,6 %), mean 34 s. Report `data/eval-qwen38-v2.json`. - **M4 offen:** Odoo-Integration (separater Plan nach Verifikation der Odoo-19-LLM-Module). +- **KV/RIS-Erweiterung (2026-09-15, D9/D10):** Korpus 601 → 1 274 Einträge + (614 WKO-KV-Dokumente `kv-kvt-…`, 59 RIS-Gesetze `ris-…`; quelltreu + generiert — D9), 14 984 Chunks. Retrieval kalibriert (D10: + dense_weight 2.0, rrf_k 20, Pool 150) → Recall@8 0,923 (>0,9 ✓). + Antwortmodus: Zitier-Präzision 95,2 %, Verweigerung 90,5 % — + Fehlverweigerungs-Tuning (offen, laufender Arbeitsstand) fortsetzen. + Intake: `tools/ingest_sources.py`, Registry: `tools/build_registry.py`. - Betrieb: `agent/README.md`.