From eb1a7688765e268633b6d2c933182314b14ff5f2 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 15 Sep 2026 00:22:41 +0200 Subject: [PATCH] Prompt v2 + Kontext-Section-Prioritaet: Fehlverweigerungen behoben (D8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnose: q-008 bekam die 'Verweise'-Navigationssektion von lb-end-03 als Kontext (BM25-Langennormalisierung bevorzugt duenne Chunks) -> inhaltlose Bloecke -> korrekte Verweigerung nach Regel 4. q-031 enthaelt eine falsche Praemisse (kein 'Mindestlohngesetz' in Oesterreich; Wissensbasis sagt: kein gesetzlich betraglich festgelegtes Mindestentgelt). Fixes: (a) retrieve.py _representative_chunk - pro Eintrag beste Inhalts- sektion (Zusammenfassung > Kernwerte > Rechtsgrundlagen > Payroll > sonstige > Verweise), Section-Swap aus dem Index falls nur 'Verweise' rangiert; (b) generate.py Prompt v2 - Regel 4 erlaubt Teilantworten, Regel 8 verlangt Prämisse-Korrektur mit Muster-Beispiel. Bestaetigungslauf qwen3.8:27b (35 Fragen): Zitier-Praezision 100 % (8 Regenerierungen, alle geheilt), Verweigerung korrekt 94,3 %, erwartete Quelle 83,9 % (v1 80,6 %), mean 34 s. q-008 + q-031 behoben; verbleibender bekannter Fall q-029 (breite Survey-Frage, sicherer Fehlermodus). Retrieval-Metriken unveraendert (Recall@8 0,952). 41 Tests gruen. --- .agents/MEMORY.md | 23 ++++++-- agent/README.md | 12 +++- agent/eval/goldset.yaml | 4 ++ agent/generate.py | 11 +++- agent/retrieve.py | 58 +++++++++++++++++-- planung.md | 10 ++++ requirements.txt | 3 +- tools/kb_common.py | 121 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 tools/kb_common.py diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 59a2c76..f36ce59 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -58,11 +58,13 @@ zu planen). - **Push**: Remote localhost:3003 aus der Sandbox nicht erreichbar — User pusht vom Host. -- **Fehlverweigerungen**: q-008 (Abfertigung/Verfügungsmöglichkeiten), - q-031 (Mindestlohngesetz) — breite Fragen, Retrieval erfolgreich, Modell - verweigert. M3-Tuning: Regel-4-Formulierung lockern („behandle den - behandelten Teil“) oder Kontextblöcke erhöhen. Vorher Prompt einfrieren - für den Bake-off-Vergleich. +- **Fehlverweigerungen — Stand nach Prompt v2:** q-008 und q-031 sind + **behoben** (Section-Priorität + Regel 8). Verbleibend: q-029 + („Welche Neuerungen behandelt WIKU Personal aktuell 2026?“) — breite + Survey-Frage über 12 Hefte; Kontext enthält echte Zusammenfassungen, + Modell verweigert trotzdem (sicherer Fehlermodus). Mögliche spätere + Hebel: Survey-Rule im Prompt, mehr Kontextblöcke oder Map-Reduce-artige + Zusammenfassung. Weitere Iterationen erst mit neuen Evaluationsdaten. - **Latenz**: mean 32 s/ Antwort ist hoch (Dense 27B + große Prompts). Hebel: weniger Kontextblöcke (aktuell 8+6), schnellere Kandidaten. - **Cloud-Modelle** (`*:cloud` auf der Ollama-Instanz) sind für Antworten @@ -92,6 +94,17 @@ zu planen). (100 % Zitier-Präzision, 94,3 % Verweigerung korrekt). `gemma4:26b` als dokumentierter Latenz-Kandidat; ein Modellwechsel läuft nur erneut über das dokumentierte Protokoll (Skill). +- **D8 (Prompt-Tuning 2026-09-14):** Prompt v2 + Kontext-Section- + Priorität. (a) Regel 4 erlaubt Teilantworten bei unvollständiger Deckung; + Regel 8 verlangt Prämisse-Korrektur statt Verweigerung (Muster-Beispiel + „Mindestlohngesetz“). (b) `_representative_chunk` wählt pro Eintrag die + beste **Inhaltssektion** (Zusammenfassung > Kernwerte > Rechtsgrundlagen + > Payroll > sonstige; „Verweise“ nur als letzter Rückgriff — BM25 + rangiert die dünnen Navigations-Chunks bevorzugt, q-008-Ursache). + Ergebnis v1→v2: Zitier-Präzision 100 % gehalten; q-008 + q-031 behoben; + 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`. - **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 7de1822..2f42bcf 100644 --- a/agent/README.md +++ b/agent/README.md @@ -79,9 +79,15 @@ MRR 0,690 — M1-Ziel >0,9 erreicht (BM25-only war 0,855; die vier BM25-Fehltreffer behebt die Dense-Suche alle). **Antworten** (Bake-off-Sieger qwen3.8:27b, Thinking aus, Temperatur 0,1): -**Zitier-Präzision 100 %** (4 Zitierverletzungen wurden von der -Post-Validierung abgefangen und regeneriert) · Verweigerung korrekt -94,3 % · erwartete Quelle zitiert 80,6 % · Latenz mean 32 s / p95 53 s. +**Zitier-Präzision 100 %** · Verweigerung korrekt 94,3 % · erwartete Quelle +zitiert **83,9 %** · Latenz mean 34 s / p95 54 s. + +**Prompt v2 + Kontext-Section-Priorität (2026-09-14):** Teilantworten bei +unvollständiger Deckung erlaubt (Regel 4), Prämisse-Korrektur statt +Verweigerung (Regel 8), pro Eintrag beste Inhaltssektion als Kontextblock +(Zusammenfassung > Kernwerte > … > Verweise zuletzt — Navigations-Chunks +lösen keine Fehlverweigerungen mehr aus). v1→v2: q-008 + q-031 behoben, +erwartete Quelle 80,6 % → 83,9 %, Zitier-Präzision unverändert 100 %. **Modell-Bake-off (M3, 2026-09-14)** — Entscheidung: **qwen3.8:27b** (Protokoll: Zitier-Präzision → Verweigerungskorrektheit → Latenz): diff --git a/agent/eval/goldset.yaml b/agent/eval/goldset.yaml index 78ca1c2..0d35af1 100644 --- a/agent/eval/goldset.yaml +++ b/agent/eval/goldset.yaml @@ -99,6 +99,10 @@ questions: - id: q-031 question: "Was regelt das Mindestlohngesetz und für wen gilt es?" expected_ids: [lb-ent-09] + note: "Falsche Prämisse — ideale Antwort korrigiert: kein gesetzlich + betraglich festgelegtes Mindestentgelt; Untergrenze via KV/Satzung/ + Mindestlohntarif (BEA) belegt aus lb-ent-09." + tags: [premise-correction] # --- Verweigerungsfälle (Antwortmodus) --- - id: r-001 diff --git a/agent/generate.py b/agent/generate.py index 30ee970..26defd3 100644 --- a/agent/generate.py +++ b/agent/generate.py @@ -33,12 +33,21 @@ Verbindliche Regeln: 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: +4. Beantworte die Frage mit den fachlichen Aussagen der thematisch + relevanten Blöcke — auch wenn sie die Frage nur teilweise decken; + mache klar, welcher Aspekt belegt ist. Verweigere nur, wenn KEIN + Block thematisch zur Frage passt, mit 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 und prägnant (Stichpunkte, wo sinnvoll). +8. Baut die Frage auf einer falschen Annahme auf (z. B. ein nicht + existierendes Gesetz), korrigiere die Annahme anhand der Blöcke und + gib die zutreffende, belegte Aussage. Muster: Auf „Was regelt das + Mindestlohngesetz?“ antworte sinngemäß „Ein Mindestlohngesetz existiert + laut Kontext nicht; stattdätzlich gilt …“ — mit Beleg [ID]. + Verweigere in diesem Fall nicht. Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort.""" diff --git a/agent/retrieve.py b/agent/retrieve.py index 586f6db..ac9f85a 100644 --- a/agent/retrieve.py +++ b/agent/retrieve.py @@ -34,6 +34,29 @@ class ChunkResult: source: str = "fused" # bm25 | dense | fused | cross_ref +def _section_priority(section: str) -> int: + """Kontext-Sektionen priorisieren: Inhalt vor Navigation. + + „Verweise“-Sektionen sind Navigationslisten (KB-IDs) — sie tragen + Retrieval-Signal (Stichworte), sind aber als Kontextblock wertlos und + provozieren Fehlverweigerungen. BM25-Längennormalisierung rangiert sie + bevorzugt, daher wird pro Eintrag bewusst die beste Inhaltssektion + gewählt (Fix 2026-09-14, q-008). + """ + s = (section or "").casefold() + if s.startswith("zusammenfassung"): + return 0 + if s.startswith("kernwerte"): + return 1 + if s.startswith("rechtsgrundlagen"): + return 2 + if s.startswith("payroll"): + return 3 + if s.startswith("verweise"): + return 5 + return 4 + + class Retriever: def __init__(self, cfg: Config, db_path: str | None = None, client=None): self.cfg = cfg @@ -182,6 +205,28 @@ class Retriever: source=source, ) + def _representative_chunk( + self, entry_id: str, ranked: list[ChunkResult] + ) -> ChunkResult: + """Beste Inhaltssektion des Eintrags als Kontextblock. + + Bevorzugt die rangierte (gefundene) Sektion mit bester Priorität; + traf der Eintrag nur über „Verweise“, wird seine beste Inhalts- + sektion aus dem Index nachgeladen (source="section-swap"). + """ + content = [c for c in ranked if _section_priority(c.section) < 5] + if content: + return min(content, key=lambda c: (_section_priority(c.section), -c.score)) + rows = self._con.execute( + "SELECT * FROM chunks WHERE entry_id = ? AND section NOT LIKE 'Verweise%' " + "ORDER BY CASE WHEN section LIKE 'Zusammenfassung%' THEN 0 " + "WHEN section LIKE 'Kernwerte%' THEN 1 ELSE 2 END, chunk_id LIMIT 1", + (entry_id,), + ).fetchall() + if rows: + return self._row_to_result(rows[0], 0.0, "section-swap") + return ranked[0] # Eintrag hat nur Verweise-Sektionen + def _best_chunk_of_entry(self, entry_id: str) -> ChunkResult | None: rows = self._con.execute( "SELECT * FROM chunks WHERE entry_id = ? " @@ -224,16 +269,19 @@ class Retriever: 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) + # Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup). + # Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags, + # nicht die Rangfolge-Beste (vgl. _section_priority). main: list[ChunkResult] = [] seen: set[str] = set() + by_entry: dict[str, list[ChunkResult]] = {} for r in results: - if r.entry_id in seen: - continue - seen.add(r.entry_id) - main.append(r) + by_entry.setdefault(r.entry_id, []).append(r) + for entry_id, chunks in by_entry.items(): if len(main) >= n: break + seen.add(entry_id) + main.append(self._representative_chunk(entry_id, chunks)) # cross_ref-Erweiterung (kontrolliert, markiert, begrenzt) extra: list[ChunkResult] = [] diff --git a/planung.md b/planung.md index edc7cad..89037ec 100644 --- a/planung.md +++ b/planung.md @@ -271,6 +271,16 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests vorhandener Zitate) und 37,5 s mean — Rang 5 von 6, schlägt qwen3.8 in keiner Kennzahl. q-008 und q-031 verweigern alle Top-Kandidaten — Prompt-/Retrieval-Tuning-Thema, kein Modellthema. + + **Prompt v2 + Kontext-Tuning (2026-09-14):** (a) Regel 4 erlaubt + Teilantworten, Regel 8 verlangt Prämisse-Korrektur mit Muster-Beispiel; + (b) Kontextblöcke pro Eintrag = beste Inhaltssektion statt bester + Rangfolge-Chunk („Verweise“-Sektionen zuletzt — BM25-Längennormalisierung + rangiert die dünnen Navigations-Chips bevorzugt, Ursache q-008). + Bestätigungslauf qwen3.8:27b (35 Fragen): Zitier-Präzision **100 %**, + Verweigerung korrekt 94,3 % (q-008/q-031 behoben; neuer bekannter Fall + q-029 — breite Survey-Frage, sicherer Fehlermodus), erwartete Quelle + **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). - Betrieb: `agent/README.md`. diff --git a/requirements.txt b/requirements.txt index 1fd44b4..f7954c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn>=0.30 httpx>=0.27 PyYAML>=6.0 numpy>=2.0 -pytest>=8.0 \ No newline at end of file +pytest>=8.0 +beautifulsoup4>=4.12 \ No newline at end of file diff --git a/tools/kb_common.py b/tools/kb_common.py new file mode 100644 index 0000000..82e5adf --- /dev/null +++ b/tools/kb_common.py @@ -0,0 +1,121 @@ +"""Gemeinsame Konstanten für die Wissensbasis-Tools (Intake + Registry). + +Quellen-ID-Räume: `lb` (Lexis Briefings), `wk` (WIKU Personal), `kv` +(WKO.at Kollektivvertrag-Seiten), `ris` (RIS-Gesetze). Die bestehende +Cluster-Map (topic -> ID-Präfix) der lb/wk-Einträge wird von +`build_registry.py` aus den Layer-2-Frontmatterdaten abgeleitet; nur die +Cluster der neuen Quellen sind hier deklariert. + +LAW_MAP begründet je Gesetz topic/chapter aus dem tatsächlichen Inhalt +der .ris-Dateien (Stand 2026-09-15, vgl. §-Inventar der Quelldateien): +Gesetze ohne natürlichen Cluster bekommen einen eigenen Rechtsgebiet- +Cluster oder landen bewusst im Sammel-Cluster `normen-sonstige`. +""" +from __future__ import annotations + +import re + +# Alle ID-Räume des Korpus (Schema-Beschreibung für kb.json). +ID_SPACES = { + "lb": "Lexis Briefings Personalrecht (Lexis 360-Export)", + "wk": "WIKU Personal (Fachbroschüren, Arbeitsunterlagen, Casebook, Personal aktuell)", + "kv": "WKO.at — Kollektivvertrag-Dokumente (Lohn-/Gehaltsordnungen, KV-Texte)", + "ris": "RIS — Rechtsinformationssystem des Bundes (Gesetzes-§-Auschnitte)", +} + +# Neue Cluster der Quellen kv/ris: slug -> (ID-Präfix, Anzeigename). +NEW_CLUSTERS = { + "kollektivvertraege": ("kvt", "Kollektivverträge (WKO.at)"), + "zivilrecht-normen": ("zvr", "Zivilrechtliche Normen (ABGB, ZPO & Co.)"), + "arbeitsvertragsrecht": ("avr", "Arbeitsvertragsrecht (AVRAG)"), + "arbeitsgerichtsbarkeit": ("agg", "Arbeits- und Sozialgerichtsbarkeit (ASGG)"), + "lohnsteuer": ("lst", "Lohnsteuer & Einkommensteuer (EStG)"), + "abgabenverfahren": ("abo", "Abgabenverfahren (BAO)"), + "normen-sonstige": ("nso", "Weitere Gesetze und Verordnungen"), +} + +# Werk-Namen der neuen Quellen (Frontmatter `work`). +WORK_KV = "WKO.at – Kollektivvertrag" +WORK_RIS = "RIS – Rechtsinformationssystem des Bundes" + +# .ris/ -> (topic, chapter, lfd. Nummer im Cluster). +# Nummern sind fix (dann eingefroren); neue Gesetze hängen in ihrem Cluster +# an der höchsten Nummer an. +LAW_MAP: dict[str, tuple[str, str, int]] = { + "ABGB": ("zivilrecht-normen", "Zivilrecht", 1), + "IPRG": ("zivilrecht-normen", "Zivilrecht", 2), + "KSchG": ("zivilrecht-normen", "Verbraucherschutz", 3), + "ZPO": ("zivilrecht-normen", "Zivilrecht", 4), + "APG": ("pension", "Pensionsrecht", 1), + "APSG": ("arbeitszeitmodelle", "Arbeitsrecht (Kurzarbeit)", 1), + "APflG": ("lehrlinge", "Arbeitsrecht (Lehrverhältnis)", 1), + "BAG": ("lehrlinge", "Arbeitsrecht (Lehrverhältnis)", 2), + "ARG": ("ruhezeiten", "Arbeitsrecht", 1), + "ASGG": ("arbeitsgerichtsbarkeit", "Arbeits- und Sozialgerichtsbarkeit", 1), + "ASVG": ("beitragsrecht-asvg", "Sozialversicherungsrecht", 1), + "ASchG": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 1), + "BS-V": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 2), + "BauV": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 3), + "AVRAG": ("arbeitsvertragsrecht", "Arbeitsrecht", 1), + "AZG": ("arbeitszeitgrenzen", "Arbeitsrecht", 1), + "AktG": ("vorstand", "Gesellschaftsrecht", 1), + "AlVG": ("altersteilzeit", "Sozialversicherungsrecht", 1), + "AngG": ("beendigungsarten", "Arbeitsrecht", 1), + "LAG": ("beendigungsarten", "Arbeitsrecht", 2), + "ArbIG": ("normen-sonstige", "Arbeitsrecht", 1), + "RStDG": ("normen-sonstige", "Dienstrecht", 2), + "StGB": ("normen-sonstige", "Strafrecht", 3), + "StPO": ("normen-sonstige", "Strafrecht", 4), + "UGB": ("normen-sonstige", "Gesellschaftsrecht", 5), + "ArbVG": ("betriebsrat", "Arbeitsverfassungsrecht", 1), + "AÜG": ("arbeitskrafteuberlassung", "Arbeitsrecht", 1), + "BAO": ("abgabenverfahren", "Steuerrecht", 1), + "BBG": ("behinderte", "Sozialrecht", 1), + "BEinstG": ("behinderte", "Sozialrecht", 2), + "BMSVG": ("vorsorgeleistungen", "Betriebliche Vorsorge", 1), + "PKG": ("vorsorgeleistungen", "Betriebliche Vorsorge", 2), + "FlexKapGG": ("vorsorgeleistungen", "Gesellschaftsrecht", 3), + "BSVG": ("bauerliche-sozialversicherung", "Sozialversicherungsrecht", 1), + "BUAG": ("endabrechnung", "Arbeitsrecht", 1), + "DHG": ("dienstnehmerhaftung", "Arbeitsrecht", 1), + "EFZG": ("krankenstand", "Arbeitsrecht", 1), + "EO": ("lohnpfandung", "Exekutionsrecht", 1), + "EStG": ("lohnsteuer", "Steuerrecht", 1), + "FLAG": ("kinderbetreuungsgeld", "Familienleistungen", 1), + "KBGG": ("kinderbetreuungsgeld", "Familienleistungen", 2), + "FamZeitbG": ("familienzeit", "Familienleistungen", 1), + "GSVG": ("gsvg-fsvg", "Sozialversicherungsrecht", 1), + "GewO": ("gewerbe", "Gewerberecht", 1), + "GlBG": ("gleichbehandlung", "Arbeitsrecht", 1), + "GmbHH_PLACEHOLDER": ("geschaftsfuhrer", "Gesellschaftsrecht", 1), + "IESG": ("insolvenz-betriebsubergang", "Insolvenzrecht", 1), + "KJBG": ("jugendliche", "Jugendschutz", 1), + "LSD-BG": ("lohndumping", "Arbeitsrecht", 1), + "MSchG": ("schwangerschaft", "Mutterschutz", 1), + "MSchV": ("schwangerschaft", "Mutterschutz", 2), + "NSchG": ("nachtschwerarbeit", "Arbeitsrecht", 1), + "Sachbezugswerteverordnung": ("sachbezuge", "Steuerrecht", 1), + "Schwerarbeitsverordnung": ("schwerarbeit", "Arbeitsrecht", 1), + "UrlG": ("urlaub", "Arbeitsrecht", 1), + "TAG": ("urlaub", "Arbeitsrecht", 2), + "VBG": ("urlaub", "Dienstrecht", 3), + "VKG": ("karenz", "Familienrecht", 1), + "ZDG": ("prasenzdienst", "Zivildienst", 1), +} + +DATE_RE = re.compile(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})") +YEAR_RE = re.compile(r"(20\d\d)") + + +def ascii_slug(text: str) -> str: + """Wissensbasis-Konvention: Umlaute auf Basisbuchstabe (ü→u, nicht ue), + ß→ss, Rest klein; alles außer a-z0-9 und Bindestrich fällt weg.""" + repl = {"ä": "a", "ö": "o", "ü": "u", "Ä": "a", "Ö": "o", "Ü": "u", "ß": "ss"} + for k, v in repl.items(): + text = text.replace(k, v) + return re.sub(r"[^a-z0-9-]+", "-", text.lower()).strip("-") + + +def stand_from_date(date_match: tuple[str, str, str]) -> str: + d, m, y = date_match + return f"{y}-{int(m):02d}" \ No newline at end of file