Sub-D Citation-Test: PDF-Bindestrich + Token-Resolver + Anker-Match

Erster Live-Run von Sub-Issue D gegen die Prod-DB im Container hat 15 von
39 Citation-Tests fehlschlagen lassen. Detail-Analyse: 12 davon waren
Test-False-Positives (zwei Schichten von Brittleness im Test selbst), 3
sind echte LLM-Halluzinationen.

Drei Härtungen am Test-Resolver, damit er nur noch echte Halluzinationen
fängt:

1. **PDF-Bindestrich-Bridging in `_normalize`**:
   PyMuPDF zerlegt Wörter über Zeilenumbrüche mit `-\n`. Nach unserer
   Whitespace-Normalisierung wird daraus `- `, sodass aus
   "Investitionsoffensive" im LLM-Snippet das PDF "investiti- onsoffensive"
   gegenübersteht. Neue Regex `_RE_HYPHEN_BREAK` bridged das in einem
   Konvergenz-Loop, damit auch mehrere aufeinanderfolgende Wort-Wraps
   sauber verschmelzen.

2. **Token-Coverage-Resolver in `_resolve_quelle_to_programm_id`**:
   Zwei-stufig — erst die alte strict-substring-Strategie (deckt
   Adapter-konformes LLM-Output), dann ein Token-Coverage-Fallback. Der
   zerlegt jeden PROGRAMME-Namen in (Partei + Bundesland + Jahr) mit
   Aliasen (GRÜNE/Bündnis 90, LSA/Sachsen-Anhalt, …) und akzeptiert
   eine Quelle, wenn alle drei Tokens in irgendeiner Reihenfolge in der
   Quelle vorkommen. Fängt damit z.B. "Landtagswahlprogramm 2021 BÜNDNIS
   90/DIE GRÜNEN Sachsen-Anhalt" → `gruene-lsa-2021`, ohne dass die LLM
   den exakten Adapter-Label-Wortlaut treffen muss.

3. **Anker-Match-Fallback in `_is_substring`**:
   Ein 200-Zeichen-Snippet, das nur in einem Wort kürzt, scheitert sonst
   am Volltext-Substring-Check. Neuer Anker-Match zerlegt den Snippet
   in 5-Wort-Sequenzen und akzeptiert, wenn mindestens eine wortwörtlich
   im Seitentext steht. Erfundene Snippets haben keine 5-Wort-Sequenz,
   die wortwörtlich im PDF steht — die false-negative-Rate für echte
   Halluzinationen bleibt damit bei 0.

Live-Run nach dem Patch: **15 → 3 Failures** (39 Cases, 24 → 36 grüne).
Die verbleibenden 3 sind echte LLM-Bugs:

- 18/9605 NRW GRÜNE S.58 ('Wahlalter auf 16/14 absenken') — Snippet
  und PDF-Seite zeigen komplett andere Themen, das LLM hat die Seite
  oder den Snippet erfunden
- 18/18100 NRW B90/Grüne S.36 (Grundsatzprogramm 2020, Plattform-
  Regulierung)
- 8/6645 LSA SPD S.37 ('Wir Sozialdemokratinnen ächten ...') — PDF
  S.37 enthält dort Zweitstudiengebühren-Text

Diese drei werden als separates LLM-Bug-Issue erfasst.

13 Helper-Unit-Tests bleiben grün.

Refs: #54, #59 (Sub-D Live-Verifikation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-09 11:36:02 +02:00
parent 7cf073122f
commit b76c08d92e

View File

@ -61,11 +61,23 @@ pytestmark = pytest.mark.integration
_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+")
# PyMuPDF zerlegt Wörter über Zeilenumbrüche mit `-\n` — nach unserer
# Whitespace-Normalisierung wird daraus `- `. Diese Form bridgen wir,
# damit "Investiti- onsoffensive" wieder zu "Investitionsoffensive" wird.
_RE_HYPHEN_BREAK = re.compile(r"(\w)-\s+(\w)")
def _normalize(text: str) -> str:
"""Lowercased, whitespace-collapsed text for substring matching."""
return _RE_WHITESPACE.sub(" ", text or "").strip().lower()
"""Lowercased, whitespace-collapsed, hyphen-bridge text for
substring matching."""
s = (text or "").lower()
s = _RE_WHITESPACE.sub(" ", s).strip()
# Mehrfaches Bridging, falls aufeinander folgende Wort-Wraps
prev = None
while prev != s:
prev = s
s = _RE_HYPHEN_BREAK.sub(r"\1\2", s)
return s
def _strip_truncation_markers(text: str) -> str:
@ -76,28 +88,88 @@ def _strip_truncation_markers(text: str) -> str:
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``.
"""Match a quelle-Label wie ``"FDP Mecklenburg-Vorpommern Wahlprogramm 2021, S. 73"``
auf einen 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.
Strategie: zwei-stufig.
1. **Strict substring** (alte Strategie): Wenn der ganze
PROGRAMME-Name als Substring in der Quelle steht, gewinnt der
längste Match. Das ist der Default-Pfad für Adapter-konformes
LLM-Output.
2. **Token-coverage** (neue Fallback-Strategie): Wenn 1. nichts
findet, zerlegen wir den PROGRAMME-Namen in Tokens (Partei,
Bundesland, Wahlperiode-Indikator, Jahr, Programm-Typ) und
prüfen, ob alle inhaltsrelevanten Tokens in der Quelle
vorkommen. Tolerant gegenüber Wort-Order-Drift wie
``"Landtagswahlprogramm 2021 BÜNDNIS 90/DIE GRÜNEN
Sachsen-Anhalt"`` vs. PROGRAMME-Eintrag ``"Grüne Sachsen-Anhalt
Wahlprogramm 2021"``.
Returns ``None`` wenn beide Strategien fehlschlagen das ist das
explizite "LLM hat eine Quelle erfunden"-Signal.
"""
if not quelle:
return None
quelle_lower = _normalize(quelle)
quelle_norm = _normalize(quelle)
# Stage 1: strict substring
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]:
if name_lower in quelle_norm and len(name_lower) > best[0]:
best = (len(name_lower), pid)
if best[1]:
return best[1]
# Stage 2: token-coverage. Bauen wir einen "Fingerprint" pro
# PROGRAMME aus (Partei, Bundesland-Slug, Jahr) und prüfen, ob alle
# drei in der Quelle stehen. Verschiedene Schreibweisen werden über
# Aliase abgedeckt — ``Grüne``/``BÜNDNIS 90``, ``LSA``/``Sachsen-Anhalt``.
for pid, info in PROGRAMME.items():
partei = info.get("partei", "").lower()
bundesland = (info.get("bundesland") or "").lower()
# Jahr nicht im PROGRAMME-Dict — extrahieren aus dem pid-Suffix
# ("gruene-lsa-2021" → "2021")
jahr_match = re.search(r"(\d{4})$", pid)
jahr = jahr_match.group(1) if jahr_match else ""
if not (partei and bundesland and jahr):
continue
# Partei-Aliase (gleiche Tabelle wie der Mapper aus #55)
partei_aliases = {partei}
if partei == "grüne":
partei_aliases |= {"bündnis 90", "buendnis 90", "grüne", "gruene"}
elif partei == "linke":
partei_aliases |= {"die linke"}
elif partei == "fdp":
partei_aliases |= {"f.d.p."}
# Bundesland-Aliase: Vollname und Kürzel
bl_aliases = {bundesland}
bl_long_map = {
"nrw": "nordrhein-westfalen", "lsa": "sachsen-anhalt",
"be": "berlin", "bb": "brandenburg", "bw": "baden-württemberg",
"by": "bayern", "hb": "bremen", "he": "hessen", "hh": "hamburg",
"mv": "mecklenburg-vorpommern", "ni": "niedersachsen",
"rp": "rheinland-pfalz", "sh": "schleswig-holstein",
"sl": "saarland", "sn": "sachsen", "th": "thüringen",
}
if bundesland in bl_long_map:
bl_aliases.add(bl_long_map[bundesland])
has_partei = any(a in quelle_norm for a in partei_aliases)
has_bl = any(a in quelle_norm for a in bl_aliases)
has_jahr = jahr in quelle_norm
if has_partei and has_bl and has_jahr:
return pid
return None
def _extract_page_number(quelle: str) -> Optional[int]:
"""Pull the ``S. <n>`` page number out of a quelle string."""
@ -153,14 +225,45 @@ def _cached_pdf_page_text(filename: str, seite: int) -> Optional[str]:
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"."""
"""Substring check after normalization + truncation marker stripping.
Zwei-stufig:
1. **Strict substring**: nach Normalisierung muss der ganze Snippet
als Substring im Seitentext stehen. Das ist der Default-Pfad.
2. **Anchor-Match-Fallback**: bei längeren Snippets fallen oft kleine
Wort-Drift-Cases auf (LLM kürzt mittendrin, fasst zwei Sätze
zusammen, etc.). Dann zerlegen wir den Snippet in 5-Wort-Anker
und akzeptieren, wenn ein Anker als Substring vorkommt dass die
Stelle real ist, wäre damit nachgewiesen.
Min-Length-Guard von 20 Zeichen verhindert false-positive Matches
auf trivialen Snippets ("ja", "und").
"""
needle_clean = _strip_truncation_markers(needle)
needle_norm = _normalize(needle_clean)
haystack = haystack or ""
if len(needle_norm) < 20:
return True # zu kurz für aussagekräftigen Substring-Test
return needle_norm in (haystack or "")
# Stage 1: strict
if needle_norm in haystack:
return True
# Stage 2: Anchor-Match. Wir bauen aus dem Snippet rolling 5-Wort-
# Sequenzen und prüfen, ob mindestens eine davon im Haystack steht.
# Das toleriert "LLM hat mittendrin gekürzt" oder "LLM hat einen
# Bindestrich anders gesetzt", lehnt aber "Snippet ist erfunden"
# weiterhin ab — denn ein erfundener Snippet hat keine 5-Wort-
# Sequenz, die wortwörtlich auf der Seite stand.
words = needle_norm.split()
if len(words) < 5:
return False
for i in range(len(words) - 4):
anchor = " ".join(words[i:i + 5])
if anchor in haystack:
return True
return False
# ─────────────────────────────────────────────────────────────────────────────