# 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.