Prompt v2 + Kontext-Section-Prioritaet: Fehlverweigerungen behoben (D8)

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.
This commit is contained in:
2026-09-15 00:22:41 +02:00
parent 6cd37429f3
commit eb1a768876
8 changed files with 227 additions and 15 deletions
+18 -5
View File
@@ -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.
+9 -3
View File
@@ -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):
+4
View File
@@ -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
+10 -1
View File
@@ -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."""
+53 -5
View File
@@ -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] = []
+10
View File
@@ -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`.
+1
View File
@@ -4,3 +4,4 @@ httpx>=0.27
PyYAML>=6.0
numpy>=2.0
pytest>=8.0
beautifulsoup4>=4.12
+121
View File
@@ -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/<Datei ohne .md> -> (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}"