# 0003 — Sub-D Property-Verification: Zitate als Substring der zitierten PDF-Seite

| | |
|---|---|
| **Status** | accepted |
| **Datum** | 2026-04-10 |
| **Refs** | Issues #50, #54, #60; tests/integration/test_citations_substring.py |

## Kontext

Der LLM-Output enthält pro Assessment N Zitate, jedes mit `text`, `quelle`
(z.B. "GRÜNE NRW Wahlprogramm 2022, S. 58") und `url`. Wahrscheinlich
korrekt — aber wie verifizieren wir das, ohne jedes einzeln händisch
nachzuschlagen?

Die naheliegenden Test-Optionen sind alle unbefriedigend:

- **Mock-LLM-Tests**: prüfen das Schema, sagen aber nichts über die
  inhaltliche Korrektheit.
- **Snapshot-Tests** der LLM-Outputs: drift mit jedem Modell-Update.
- **Manuelles Stichprobenchecken**: skaliert nicht über mehrere BLs.

## Optionen

### Option A — Schema-only Tests (was wir vorher hatten)

Pydantic validiert dass jedes Zitat die Felder `text`, `quelle`, `url` hat
und `url` mit `/static/referenzen/` beginnt. Erkennt syntaktische
Korruption, aber keine Halluzinationen.

### Option B — Property-Test gegen die echten PDFs

Pro Zitat in der Prod-DB:
1. `quelle` per Token-Coverage-Match auf den `PROGRAMME`-Eintrag mappen.
2. Seitennummer aus `quelle` extrahieren.
3. Per `fitz` die PDF-Seite lesen, Whitespace + Soft-Hyphen normalisieren.
4. `text` muss als Substring (oder 5-Wort-Anker) in der Seite vorkommen.
5. Bug-Klasse 17 (Cross-Bundesland-Zitat): das aufgelöste Programm muss
   zum Bundesland des Antrags passen, oder ein Grundsatzprogramm sein.

**Vorteile:** prüft die einzige Eigenschaft die wirklich zählt — "war
das was zitiert wird auch wirklich da". Findet Halluzinationen direkt.

**Nachteile:** braucht eine lokale Kopie der `gwoe-antraege.db` und der
Wahlprogramm-PDFs. Test ist Pydantic-Schema-übergreifend (Integration,
nicht Unit). Skipped sauber wenn DB nicht gemounted ist.

### Option C — Online-Verifikation pro Assessment-Insert

Im `analyze_antrag`-Flow direkt nach LLM-Call jedes Zitat verifizieren
und bei Failure abbrechen oder retry.

**Vorteile:** kein "stale data in DB"-Risiko.

**Nachteile:** fügt Latenz und Komplexität in den Hot-Path. Die
Verifikation ist O(N×M), wo N=Zitate und M=Wahlprogramm-Pages.

## Entscheidung

**Option B als pytest-Integration-Test** — `tests/integration/test_citations_substring.py`,
parametrisiert per `_load_recent_assessments(limit_per_bl=5)` × `_flat_zitate()`.

**Strict substring** als Default-Match (Whitespace + Soft-Hyphen normalisiert,
LLM-Truncation-Marker `...` toleriert), **5-Wort-Anker als Fallback** für
geringfügige Wort-Drift wie "LLM hat mittendrin gekürzt". Min-Length-Guard
von 20 Zeichen verhindert false-positive Matches auf trivialen Snippets.

Marker `pytestmark = pytest.mark.integration` — der Test läuft nicht in
der Default-Suite, sondern explizit per `pytest -m integration`. Skipped
wenn `webapp/data/gwoe-antraege.db` nicht existiert (Dev-Setup ohne
DB-Kopie).

Match-Helpers (`_normalize`, `_is_substring`, `_resolve_quelle_to_programm_id`,
`_extract_page_number`) sind eigene Unit-Tests in `TestHelpers` — die Match-
Logik selbst ist nicht-trivial und braucht ihre Eigenkontrolle.

## Konsequenzen

### Positiv

- **Findet Halluzinationen direkt**: Issue #60 wurde durch den ersten
  Live-Lauf dieses Tests entdeckt (3 von 36 Citations failed), ohne
  dass ein Mensch Wahlprogramm-PDFs aufmachen musste.
- **Re-runnable als Regression-Gate**: nach jedem Deploy einmal `pytest -m
  integration` gegen die DB → 0 Failures = OK.
- **Test-Logik = Production-Logik**: ADR 0001 Option B (`reconstruct_zitate`)
  nutzt **identische** Match-Heuristiken (`find_chunk_for_text`,
  `_normalize_for_match`). Damit kann der Test nichts fangen, was die
  Production nicht auch fangen würde, und umgekehrt — kein Test-/Prod-Drift.

### Negativ

- **Lokale DB-Kopie nötig**: vor jedem Sub-D-Run muss `data/gwoe-antraege.db`
  vom Container gepullt werden. CI-Integration steht aus.
- **Test ist langsam-ish**: ~50 Citations × ein PDF-Open pro Programm ist
  bei den ~30 indexierten Programmen ~250ms im Ganzen, nicht trivial aber
  nicht prohibitiv.
- **Token-Coverage-Heuristik für Quelle-zu-Programm-Mapping** kann false-
  positive bei sehr ähnlichen Programmen werden (z.B. CDU NRW 2022 vs. CDU
  Niedersachsen 2022 — würde durch Bundesland-Bonus-Check abgefangen).

### Folgen für andere ADRs

- **ADR 0001** ist von ADR 0003 abhängig — wenn dieser Test entfernt würde,
  hätte der LLM-Citation-Postprocess keinen Backstop und neue Halluzinations-
  Bug-Klassen würden still durchrutschen.
