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:
parent
7cf073122f
commit
b76c08d92e
@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user