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
4.5 KiB
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:
quelleper Token-Coverage-Match auf denPROGRAMME-Eintrag mappen.- Seitennummer aus
quelleextrahieren. - Per
fitzdie PDF-Seite lesen, Whitespace + Soft-Hyphen normalisieren. textmuss als Substring (oder 5-Wort-Anker) in der Seite vorkommen.- 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 integrationgegen 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.dbvom 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.