Architektur-Entscheidung aus Issue #62: Diátaxis-Framework für Doku- Pflege ohne Drift. Pflege im Repo, ADRs immutable, Stale-Snapshots explizit als Archiv markiert. Phase 1 — Architecture Decision Records: - docs/README.md — Diátaxis-Index, Erklärung was wo dokumentiert wird - docs/adr/README.md — ADR-Workflow + Index - docs/adr/template.md — Vorlage für neue ADRs - docs/adr/0001-llm-citation-binding.md — Issue #60 Doppel-Fix-Story (A=ENUM-Anker, B=server-seitige Rekonstruktion, warum Option C verworfen) - docs/adr/0002-adapter-architecture.md — ParlamentAdapter-Basisklasse + Registry, Klassen vs. Strategy vs. Modul-pro-Adapter - docs/adr/0003-citation-property-tests.md — Sub-D Strategie, warum Property-Test gegen echte PDFs statt Schema-Tests oder Online-Verify - docs/adr/0004-deployment-workflow.md — Docker-Compose + Volumes Standard-Workflow + SN-XML-Sonderpfad + Container-UTC-Gotcha Phase 3 — Stale Doku archiviert: - DOKUMENTATION.md (24.März, Skript-Architektur vor Webapp-Migrate) → docs/archive/DOKUMENTATION-2026-03-24.md - STATUS-2026-03-28.md (Tagesstand-Snapshot) → docs/archive/STATUS-2026-03-28.md - README.md (28.März, listet nur NRW-Adapter, vor 16 weiteren BLs) → docs/archive/README-2026-03-28.md - docs/archive/README.md erklärt warum die Files da sind und warum niemand sie überschreiben oder ersetzen sollte Plus neue Top-Level-README.md im Project-Root (außerhalb git, da project-root kein Repo ist) als Folder-Index für den User. CLAUDE.md ergänzt um Doku-Sektion mit Verweis auf docs/adr/. Phase 2 (mkdocs Setup) folgt separat — braucht eine Docker-Image- Erweiterung, die ich nicht autark einrollen will ohne Decision. Tests: 194/194 grün (keine Code-Änderung). Refs: #62
129 lines
5.6 KiB
Markdown
129 lines
5.6 KiB
Markdown
# 0001 — LLM-Citations server-seitig binden statt prompt-seitig
|
|
|
|
| | |
|
|
|---|---|
|
|
| **Status** | accepted |
|
|
| **Datum** | 2026-04-10 |
|
|
| **Refs** | Issue #60, Commits eb045d0/ed64399/db3ada9/6ced7ae, Sub-D Tests |
|
|
|
|
## Kontext
|
|
|
|
Der GWÖ-Antragsprüfer lässt ein LLM (Qwen Plus via DashScope) parlamentarische
|
|
Anträge bewerten. Pro Antrag werden via Embedding-Retrieval relevante Chunks
|
|
aus den Wahl- und Grundsatzprogrammen der involvierten Parteien zugespielt.
|
|
Das LLM gibt im JSON-Output `wahlprogramm_scores[*].wahlprogramm.zitate[*]`
|
|
mit jeweils `text`, `quelle` (Source-Label, z.B. "GRÜNE NRW Wahlprogramm
|
|
2022, S. 58") und `url` zurück.
|
|
|
|
Beim ersten Live-Lauf des Sub-D Citation-Property-Tests (siehe ADR 0003)
|
|
gegen die Prod-DB wurden drei Halluzinations-Cases gefunden: das LLM hatte
|
|
Snippets erfunden und unter realen Source-Labels zitiert. Issue #60 wurde
|
|
geöffnet.
|
|
|
|
## Optionen
|
|
|
|
Drei strukturell unterschiedliche Wege, das LLM zu binden:
|
|
|
|
### Option A — Schärferer Prompt
|
|
|
|
Die ZITATEREGEL im User-Prompt wird verschärft. Konkret:
|
|
|
|
1. Jeder retrievte Chunk wird mit einer ENUM-ID `[Q1]`, `[Q2]`, … getaggt.
|
|
2. Das LLM wird angewiesen, jedes Zitat per `[Qn]`-Tag an einen Chunk zu
|
|
binden, den `text` wörtlich aus diesem Chunk zu kopieren und das
|
|
Source-Label exakt zu übernehmen.
|
|
3. Top-K wird von 2 → 5 erhöht, damit die "richtige" Seite häufiger im
|
|
Retrieval-Window landet.
|
|
|
|
**Vorteile:** prompt-only, kleiner Diff, keine Server-Pipeline-Änderung.
|
|
|
|
**Nachteile:** ENUM-Anker im Prompt ist ein **weicher** Hint. Das LLM darf
|
|
zwar den Snippet aus Chunk Qn nehmen, aber es gibt nichts, was es daran
|
|
hindert, die Seite aus Chunk Qm in `quelle` zu schreiben. Cross-Mix bleibt
|
|
möglich.
|
|
|
|
### Option B — Server-seitige Quellen-Rekonstruktion
|
|
|
|
Nach dem LLM-Call werden die emittierten Zitate Server-seitig nachgearbeitet:
|
|
|
|
1. Für jedes Zitat im Output-JSON wird der `text` per Substring oder
|
|
5-Wort-Anker gegen **alle** retrievten Chunks gematcht.
|
|
2. Bei Match: `quelle` und `url` werden aus dem matchenden Chunk **konstruiert**
|
|
und der LLM-Output für diese Felder verworfen.
|
|
3. Bei kein-Match: das ganze Zitat wird verworfen.
|
|
|
|
**Vorteile:** strukturell — der LLM hat keine Möglichkeit mehr, eine falsche
|
|
Quelle anzugeben. Die einzigen zwei Outcomes sind "korrekt zitiert" oder
|
|
"verworfen".
|
|
|
|
**Nachteile:** zusätzlicher Server-seitiger Schritt nach `json.loads` und
|
|
vor Pydantic-Validation. Match-Logik muss konsistent mit Sub-D bleiben.
|
|
|
|
### Option C — Schema-Änderung mit `quote_id`-Feld
|
|
|
|
Statt `quelle` direkt zu emittieren, soll das LLM ein neues Feld `quote_id`
|
|
(z.B. `"Q3"`) liefern, aus dem der Server `quelle`/`url` rekonstruiert.
|
|
|
|
**Vorteile:** explizit schema-modelliert, keine Heuristik.
|
|
|
|
**Nachteile:** invasiv (Pydantic-Modell, JSON-Schema, Frontend, DB-Migration);
|
|
verlässt sich darauf, dass das LLM die ID korrekt setzt — wenn es lügt,
|
|
ist die Bindung futsch.
|
|
|
|
## Entscheidung
|
|
|
|
**A + B kombiniert.** Option A liefert die Vorab-Härtung im Prompt — sie
|
|
fängt den Großteil der Cases (etwa 80%). Option B ist die strukturelle
|
|
Backstop, die die restlichen 20% (echter Snippet aus Qn, falsche Seite
|
|
aus Qm) verlässlich abdeckt. Option C wurde verworfen, weil der Schema-
|
|
Eingriff den Aufwand nicht rechtfertigt, solange B die LLM-Eingabe ohnehin
|
|
ignoriert.
|
|
|
|
Konkret in dieser Reihenfolge implementiert:
|
|
|
|
1. **`db3ada9`** — Option A: ENUM-Anker `[Q1]/[Q2]/…` im Prompt + Top-K 2→5.
|
|
Live-Verifikation der drei Original-Cases aus #60: 13/13 ok.
|
|
2. **Sub-D Live-Run gegen Prod-DB**: 45/46 grün, ein neuer Case (BB 8/673)
|
|
gefunden mit "echter Snippet, falsche Seite". Beweis dass A allein nicht
|
|
reicht.
|
|
3. **`6ced7ae`** — Option B: `embeddings.reconstruct_zitate(data, semantic_quotes)`
|
|
nach `json.loads` und vor Pydantic-Validation. Helpers `find_chunk_for_text`
|
|
und `_normalize_for_match` mit identischer Logik wie Sub-D. Bei Match wird
|
|
`_chunk_source_label` und `_chunk_pdf_url` angewendet, bei No-Match wird
|
|
das Zitat gedroppt.
|
|
4. Sub-D Re-Run nach B: **52/52 grün** über NRW/LSA/BE/MV/BB/BUND.
|
|
|
|
## Konsequenzen
|
|
|
|
### Positiv
|
|
|
|
- **Strukturelle Garantie**: nach `reconstruct_zitate` kann ein im DB
|
|
gespeichertes Zitat nur noch eines von zwei Dingen sein: korrekt zitiert
|
|
aus einem real retrievten Chunk, oder gar nicht da.
|
|
- **Sub-D als CI-Gate**: das Property-Test-Pattern fängt jede künftige
|
|
Regression dieser Bug-Klasse.
|
|
- **Server vertraut LLM nicht mehr** für `quelle`/`url`. Künftige Modell-
|
|
Wechsel oder Prompt-Drift können die Quellen-Korrektheit nicht mehr brechen.
|
|
|
|
### Negativ
|
|
|
|
- **Fehlende Match-Heuristik = stilles Verschwinden**: Wenn der LLM einen
|
|
echten Chunk leicht paraphrasiert (z.B. Komma anders gesetzt), verwirft
|
|
Option B das Zitat. Die 5-Wort-Anker-Fallback fängt das in den meisten
|
|
Fällen, aber Edge-Cases bleiben. Mitigation: doctest-style Sub-D-Run pro
|
|
Deploy + Monitor der `wahlprogramm_scores[*].zitate`-Counts pro Assessment.
|
|
- **Code-Pfad-Komplexität**: drei Schichten (Prompt-ENUM, Server-Postprocess,
|
|
Sub-D-Test) statt einer. Dafür ist jede Schicht isoliert testbar.
|
|
- **`embeddings.py` hat jetzt mehr Verantwortung** als nur Embedding-Retrieval
|
|
— auch Match-Helpers und Source-Label-Konstruktion. Akzeptabel solange das
|
|
Modul thematisch geschlossen bleibt; bei weiterem Wachstum als
|
|
`citations.py` extrahieren.
|
|
|
|
### Folgen für andere ADRs
|
|
|
|
- **ADR 0003** (Sub-D Citation-Property-Tests) ist die Test-Säule dieser
|
|
Entscheidung — wenn 0003 jemals supersedet wird, bricht 0001 ohne den
|
|
Test-Backstop möglicherweise still.
|
|
- Neue Adapter (zukünftige Bundesländer) müssen sich nicht extra um
|
|
Citation-Korrektheit kümmern; der Server-Postprocess ist adapter-agnostisch.
|