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