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