gwoe-antragspruefer/tests/integration/test_citations_substring.py
Dotty Dotter 73a7f76472 Add E2E functional acceptance test suite (#50, #51, #52, #53, #54)
Vier Sub-Issues unter Umbrella #50 — opt-in via 'pytest -m integration',
Default-Suite (77 Unit-Tests) bleibt unberührt.

- Sub-Issue A (#51): test_adapters_live.py — pro aktivem BL Reachability,
  Drucksache-ID-Format, Type-Filter, Datum-/Fraktion-Plausibilität,
  PDF-Link-HEAD-Probe (slow). NI als xfail (Login-Wall).
- Sub-Issue B (#52): test_frontend_xref.py + ground_truth.py — pro BL
  ein manuell kuratiertes Frontend-Sample (Drucksache + Title-Substring +
  Fraktionen + Datum + PDF-URL), gegen das adapter.get_document() gespiegelt
  wird. Fängt Bug-Klasse 14 (Cross-Bundesland-Match).
- Sub-Issue C (#53): test_wahlprogramme_indexed.py — Indexing-Status pro
  aktivem BL aus embeddings.db, PDF-Inhalts-Plausibilität (14 Marker +
  Wahlperioden-Horizont), expliziter Anti-Marker für Bug-Klasse 8
  (CDU-BE 2021 vs 2026 PDF-Tausch durch abgeordnetenwatch).
- Sub-Issue D (#54): test_citations_substring.py — Property-Verification:
  jedes vom LLM zitierte Snippet muss als (whitespace-normalisierter)
  Substring auf der angegebenen PDF-Seite vorhanden sein. Strict-Match
  mit Truncation-Marker-Toleranz, kein Fuzzy. Liest reale Assessments
  aus gwoe-antraege.db. Fängt Bug-Klassen 7/10/17 (Halluzination).

Architektur: separates tests/integration/ Verzeichnis mit eigenem
conftest.py, das die Stubs der Unit-Suite (fitz/bs4/openai/pydantic_settings)
gezielt entfernt und auf echte Module umstellt — mit Fallback-Skip via
pytest.require_module wenn lokale Dev-Maschine die Prod-Deps nicht hat.

206 neue Integration-Tests, 13 Helper-Unit-Tests. 77 Unit-Tests bleiben grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:00:20 +02:00

398 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Sub-Issue D — Citation Property-Verification.
Pro reales Assessment in der ``gwoe-antraege.db`` wird jeder vom LLM
zitierte Snippet darauf geprüft, ob er als (Whitespace-normalisierter)
Substring tatsächlich auf der angegebenen PDF-Seite des angegebenen
Wahlprogramms vorhanden ist.
Das ist die kritischste Test-Klasse — fängt **direkt** die Bug-Klasse 7
(LLM halluziniert "FDP NRW Wahlprogramm 2022, S. 75" als Quelle für ein
MV-FDP-Antrag-Zitat) und alle künftigen Prompt-Drifts. Es ist die
einzige der vier Sub-Issues, die sich nicht auf die LLM-Quellenangabe
verlässt, sondern ihren tatsächlichen Wahrheitsgehalt prüft.
Match-Strategie (vom User bestätigt): **strict substring** —
Whitespace normalisiert, lowercased, mit Toleranz nur für LLM-typische
Truncation-Marker (`...` am Anfang/Ende des Zitats). Keine Fuzzy-
Matches, kein Jaccard, kein 80%-Overlap.
Workflow:
1. Lade die N neuesten Assessments pro aktivem BL aus ``gwoe-antraege.db``
2. Pro Assessment: parse ``wahlprogramm_scores`` (JSON), iteriere über
alle ``zitate`` jeder Fraktion
3. Pro Zitat:
- ``quelle`` parsen → Programm-ID via Match gegen ``PROGRAMME[*].name``
- Wenn kein Match: **Test fail** "halluzinierte Quelle"
- Seitennummer aus ``quelle`` extrahieren
- PDF-Seite via fitz lesen
- ``zitat['text']`` muss Substring der Seite sein
Bug-Klassen, die diese Datei abdeckt:
- 7 (LLM-Halluzination, alle Varianten)
- 10 (Source-Erfindung)
- 17 (Cross-Bundesland-Zitat — Programm-Match prüft auch ``bundesland``)
Issue: #54 (Sub-Issue D des Umbrella #50)
"""
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Optional
import pytest
from app.bundeslaender import aktive_bundeslaender
from app.embeddings import PROGRAMME
from app.wahlprogramme import REFERENZEN_PATH
pytestmark = pytest.mark.integration
# ─────────────────────────────────────────────────────────────────────────────
# Helpers — die Test-Logik teilt sich in vier reine Funktionen
# ─────────────────────────────────────────────────────────────────────────────
_RE_PAGE_NUMBER = re.compile(r"S\.\s*(\d+)|Seite\s+(\d+)", re.IGNORECASE)
_RE_TRUNCATION = re.compile(r"^\s*\.{2,}|\.{2,}\s*$")
_RE_WHITESPACE = re.compile(r"\s+")
def _normalize(text: str) -> str:
"""Lowercased, whitespace-collapsed text for substring matching."""
return _RE_WHITESPACE.sub(" ", text or "").strip().lower()
def _strip_truncation_markers(text: str) -> str:
"""Remove leading/trailing ``...`` (and similar truncation markers)
from a snippet so the substring check tolerates LLM-typical
elision but nothing else."""
return _RE_TRUNCATION.sub("", (text or "")).strip()
def _resolve_quelle_to_programm_id(quelle: str) -> Optional[str]:
"""Match a quelle-Label like ``"FDP Mecklenburg-Vorpommern Wahlprogramm 2021, S. 73"``
to a key in ``PROGRAMME``.
Strategy: scan all PROGRAMME[*].name entries and pick the one whose
name is the longest substring of ``quelle``. This tolerates the
"..., S. 73" suffix and small whitespace/dash variants. Returns
``None`` if nothing matches — that's the explicit "LLM hat eine
Quelle erfunden, die in PROGRAMME nicht existiert"-Signal.
"""
if not quelle:
return None
quelle_lower = _normalize(quelle)
best: tuple[int, Optional[str]] = (0, None)
for pid, info in PROGRAMME.items():
name = info.get("name", "")
if not name:
continue
name_lower = _normalize(name)
if name_lower in quelle_lower and len(name_lower) > best[0]:
best = (len(name_lower), pid)
return best[1]
def _extract_page_number(quelle: str) -> Optional[int]:
"""Pull the ``S. <n>`` page number out of a quelle string."""
if not quelle:
return None
m = _RE_PAGE_NUMBER.search(quelle)
if not m:
return None
page_str = m.group(1) or m.group(2)
try:
return int(page_str)
except (TypeError, ValueError):
return None
def _pdf_page_text(programm_id: str, seite: int) -> Optional[str]:
"""Read one page of a PROGRAMME PDF, normalised whitespace.
Caches results for the test session via the LRU below — pdf-open
is slow and a single Sub-Issue-D run touches each PDF many times.
"""
info = PROGRAMME.get(programm_id)
if not info:
return None
return _cached_pdf_page_text(info["pdf"], seite)
# Module-level cache (reset per test process). Pytest spawns one process per
# session by default, so this is shared across all tests in this module.
_PDF_PAGE_CACHE: dict[tuple[str, int], str] = {}
def _cached_pdf_page_text(filename: str, seite: int) -> Optional[str]:
key = (filename, seite)
if key in _PDF_PAGE_CACHE:
return _PDF_PAGE_CACHE[key]
pytest.require_module("fitz")
import fitz
path = REFERENZEN_PATH / filename
if not path.exists():
return None
pdf = fitz.open(str(path))
try:
if seite < 1 or seite > len(pdf):
return None
text = pdf[seite - 1].get_text()
finally:
pdf.close()
normalised = _normalize(text)
_PDF_PAGE_CACHE[key] = normalised
return normalised
def _is_substring(needle: str, haystack: str) -> bool:
"""Strict substring check after normalization + truncation marker
stripping. The min length 20 chars guard avoids matching trivial
snippets like "ja" or "und"."""
needle_clean = _strip_truncation_markers(needle)
needle_norm = _normalize(needle_clean)
if len(needle_norm) < 20:
return True # zu kurz für aussagekräftigen Substring-Test
return needle_norm in (haystack or "")
# ─────────────────────────────────────────────────────────────────────────────
# Helper unit-tests (die Helper selbst sind nicht trivial, also testen wir sie)
# ─────────────────────────────────────────────────────────────────────────────
class TestHelpers:
def test_resolve_quelle_existing_programme(self):
# Echtes Beispiel aus prod (FDP MV Wahlprogramm 2021)
pid = _resolve_quelle_to_programm_id(
"FDP Mecklenburg-Vorpommern Wahlprogramm 2021, S. 73"
)
assert pid == "fdp-mv-2021"
def test_resolve_quelle_returns_none_for_hallucinated_source(self):
# Eine ausgedachte Quelle, die in PROGRAMME nicht existiert
pid = _resolve_quelle_to_programm_id(
"FDP Sankt-Pauli Hafenwirtschaftsprogramm 1997, S. 42"
)
assert pid is None
def test_resolve_quelle_picks_longest_match_when_multiple_partial(self):
# Mehrere "FDP ... Wahlprogramm"-Einträge in PROGRAMME — der längste
# Substring-Match (inkl. BL-Kürzel + Jahr) muss gewinnen, sodass
# NRW-Quellen nicht versehentlich auf MV gemappt werden.
pid = _resolve_quelle_to_programm_id("FDP NRW Wahlprogramm 2022, S. 5")
assert pid == "fdp-nrw-2022"
def test_extract_page_number_canonical(self):
assert _extract_page_number("CDU MV Wahlprogramm 2021, S. 33") == 33
def test_extract_page_number_seite_long_form(self):
assert _extract_page_number("Foo Bar Programm, Seite 7") == 7
def test_extract_page_number_returns_none_when_missing(self):
assert _extract_page_number("CDU MV Wahlprogramm 2021") is None
def test_normalize_collapses_whitespace_and_lowercases(self):
assert _normalize(" HELLO\n\n WORLD ") == "hello world"
def test_strip_truncation_markers_removes_leading_dots(self):
assert _strip_truncation_markers("... echte aussage") == "echte aussage"
def test_strip_truncation_markers_removes_trailing_dots(self):
assert _strip_truncation_markers("echte aussage ...") == "echte aussage"
def test_is_substring_strict_lowercase_match(self):
assert _is_substring("Klimaschutz", "wir wollen klimaschutz und mehr")
def test_is_substring_tolerates_truncation_markers(self):
assert _is_substring("...mehr klimaschutz...", "wir wollen mehr klimaschutz und gerechtigkeit")
def test_is_substring_short_needles_pass(self):
# Zu kurz für aussagekräftigen Test → True (statt false-positive)
assert _is_substring("ja", "egal was hier steht")
def test_is_substring_returns_false_when_clearly_absent(self):
assert not _is_substring(
"ein ganz langer satz der so nirgends in der quelle steht und definitiv nicht passt",
"wir wollen mehr klimaschutz",
)
# ─────────────────────────────────────────────────────────────────────────────
# Sample Loader — liest reale Assessments aus der gwoe-antraege.db
# ─────────────────────────────────────────────────────────────────────────────
def _gwoe_db_path() -> Optional[Path]:
"""Resolve to the local prod-DB if mounted, or return None.
Looks at the same path as the prod-Container (``data/gwoe-antraege.db``
relative to the webapp root). Local dev machines without a copy will
skip the citation tests cleanly.
"""
p = Path(__file__).resolve().parent.parent.parent / "data" / "gwoe-antraege.db"
return p if p.exists() else None
def _load_recent_assessments(limit_per_bl: int = 5) -> list[dict]:
"""Read the most recent assessments per active BL from gwoe-antraege.db.
Returns the parsed wahlprogramm_scores and minimal metadata for the
citation iteration. Skips silently if the DB isn't available locally.
"""
db = _gwoe_db_path()
if db is None:
return []
out: list[dict] = []
conn = sqlite3.connect(db)
try:
active_codes = [bl.code for bl in aktive_bundeslaender()]
for code in active_codes:
rows = conn.execute(
"""
SELECT drucksache, bundesland, wahlprogramm_scores
FROM assessments
WHERE bundesland = ? AND wahlprogramm_scores IS NOT NULL
ORDER BY updated_at DESC
LIMIT ?
""",
(code, limit_per_bl),
).fetchall()
for ds, bl, ws_json in rows:
try:
ws = json.loads(ws_json) if ws_json else []
except json.JSONDecodeError:
continue
out.append({"drucksache": ds, "bundesland": bl, "wahlprogramm_scores": ws})
finally:
conn.close()
return out
_ASSESSMENTS_SAMPLE = _load_recent_assessments(limit_per_bl=5)
# ─────────────────────────────────────────────────────────────────────────────
# Main test — pro Zitat in jedem Sample-Assessment
# ─────────────────────────────────────────────────────────────────────────────
def _flat_zitate(assessment: dict) -> list[tuple[str, str, dict]]:
"""Flatten an assessment to a list of (fraktion, kind, zitat) tuples
where kind is 'wahlprogramm' or 'parteiprogramm'."""
out: list[tuple[str, str, dict]] = []
for score_entry in assessment.get("wahlprogramm_scores") or []:
fraktion = score_entry.get("fraktion") or "?"
for kind in ("wahlprogramm", "parteiprogramm"):
block = score_entry.get(kind) or {}
for z in block.get("zitate") or []:
out.append((fraktion, kind, z))
return out
def _all_citations() -> list[tuple[str, str, str, str, dict]]:
"""Cartesian-flatten all sample-assessments × all zitate to one
parametrize-friendly list. Returns tuples of:
(drucksache, bundesland, fraktion, kind, zitat-dict)."""
out: list[tuple[str, str, str, str, dict]] = []
for a in _ASSESSMENTS_SAMPLE:
for fraktion, kind, zitat in _flat_zitate(a):
out.append((a["drucksache"], a["bundesland"], fraktion, kind, zitat))
return out
_CITATIONS = _all_citations()
_CITATION_IDS = [
f"{ds}-{bl}-{fr}-{kind}-{i}" for i, (ds, bl, fr, kind, _) in enumerate(_CITATIONS)
]
@pytest.mark.skipif(
_gwoe_db_path() is None,
reason="lokale gwoe-antraege.db nicht vorhanden — Sub-D läuft nur in einer "
"Umgebung mit prod-DB-Kopie (siehe data/ Volume im prod-Container)",
)
@pytest.mark.skipif(
not _CITATIONS,
reason="keine Assessments mit zitaten in der lokalen DB gefunden",
)
@pytest.mark.parametrize(
("drucksache", "bundesland", "fraktion", "kind", "zitat"),
_CITATIONS,
ids=_CITATION_IDS,
)
def test_zitat_is_substring_of_named_pdf_page(
drucksache: str,
bundesland: str,
fraktion: str,
kind: str,
zitat: dict,
):
"""Property-Verification: jedes vom LLM zitierte Snippet muss als
Substring auf der angegebenen PDF-Seite tatsächlich vorhanden sein.
Wenn dieser Test fehlschlägt, ist genau einer der drei Fehler-
Modi aufgetreten:
1. **Halluzinierte Quelle**: das Programm in ``zitat['quelle']``
existiert in PROGRAMME nicht (Bug-Klasse 7/10)
2. **Halluzinierte Seite**: das Programm existiert, aber die
angegebene Seite enthält den Snippet nicht
3. **Halluzinierter Inhalt**: das Programm + die Seite sind real,
aber der Snippet ist eine Erfindung des LLM
Alle drei Modi sind echte Bugs in der LLM-Pipeline.
"""
quelle = zitat.get("quelle", "")
text = zitat.get("text", "")
if not quelle or not text:
pytest.skip(f"{drucksache}/{fraktion}/{kind}: zitat ohne quelle oder text")
pid = _resolve_quelle_to_programm_id(quelle)
assert pid is not None, (
f"halluzinierte Quelle in {drucksache}/{fraktion}/{kind}: "
f"{quelle!r} matched keinen PROGRAMME-Eintrag"
)
# Bonus-Check für Bug-Klasse 17 (Cross-Bundesland-Zitat): das aufgelöste
# Programm muss zu dem Bundesland des Antrags passen, oder ein
# Grundsatzprogramm sein (bundesland=None).
prog_info = PROGRAMME.get(pid, {})
prog_bl = prog_info.get("bundesland")
if prog_bl is not None and prog_bl != bundesland:
pytest.fail(
f"Cross-Bundesland-Zitat in {drucksache} ({bundesland}): das LLM "
f"zitiert aus {pid} (bundesland={prog_bl}) — das ist Bug-Klasse 17"
)
page = _extract_page_number(quelle)
if page is None:
pytest.skip(
f"{drucksache}/{fraktion}/{kind}: keine Seitennummer in quelle "
f"{quelle!r}, kann substring-check nicht ausführen"
)
page_text = _pdf_page_text(pid, page)
assert page_text is not None, (
f"PDF-Seite {page} in {pid} nicht lesbar (PDF zu kurz oder fehlt)"
)
if not _is_substring(text, page_text):
# Diff für die Fehlermeldung — gekürzt um die Output-Logs sauber zu halten
snippet_preview = text[:200].strip().replace("\n", " ")
page_preview = page_text[:200].replace("\n", " ")
pytest.fail(
f"Zitat in {drucksache}/{fraktion}/{kind} nicht auf "
f"{pid} S.{page} auffindbar:\n"
f" zitiert: {snippet_preview!r}\n"
f" PDF-Seite enthält: {page_preview!r}"
)