#47 Fix: Highlighting für falsche Seitenzahlen + Year-Suffix-Matching

Zwei Bugs aus User-Test:

1. "Unbekanntes Wahlprogramm" bei Klick auf Grünes Grundsatzprogramm:
   Pre-#60 Assessments haben halluzinierte Dateinamen wie
   "gruene-grundsatzprogramm-2020.pdf" statt "gruene-grundsatzprogramm.pdf".
   Fix: Year-Suffix-Stripping im Reverse-Lookup (X-YYYY.pdf → X.pdf).

2. "Eine Seite, aber kein Highlighting": Pre-#60 Assessments haben oft
   falsche Seitennummern. search_for findet nichts auf der falschen Seite.
   Fix: wenn die angegebene Seite leer ist, ALLE Seiten durchsuchen und
   die erste mit einem Treffer nehmen. So funktioniert Highlighting auch
   bei halluzinierten Seitenzahlen retroaktiv. Performance: ~50ms pro PDF
   (Grundsatzprogramme haben ~100-160 Seiten), akzeptabel für on-demand.

Tests: 194/194 grün.

Refs: #47
This commit is contained in:
Dotty Dotter 2026-04-10 10:08:02 +02:00
parent 47897e13cd
commit 5a035be20b
2 changed files with 40 additions and 18 deletions

View File

@ -619,28 +619,37 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
if seite < 1 or seite > len(src):
return None
# Single-page Sub-PDF erzeugen — hält den Response klein und
# schließt versehentliche Cross-Page-Highlights aus.
# Suche den Needle auf der angegebenen Seite. Falls dort nichts
# gefunden wird (Pre-#60-Assessments haben oft falsche Seiten-
# nummern), durchsuchen wir ALLE Seiten und nehmen die erste
# mit einem Treffer — so funktioniert Highlighting auch bei
# halluzinierten Seitenzahlen retroaktiv.
target_page_idx = seite - 1
if needle:
clean = needle.replace("\u00ad", "")
# Versuch 1: angegebene Seite
rects = src[target_page_idx].search_for(clean)
if not rects:
words = clean.split()
anchor = " ".join(words[:5]) if len(words) >= 5 else clean
rects = src[target_page_idx].search_for(anchor)
# Versuch 2: wenn Seite leer, alle Seiten durchsuchen
if not rects:
for i in range(len(src)):
rects = src[i].search_for(anchor if len(words) >= 5 else clean)
if rects:
target_page_idx = i
break
# Single-page Sub-PDF erzeugen
new = fitz.open()
try:
new.insert_pdf(src, from_page=seite - 1, to_page=seite - 1)
new.insert_pdf(src, from_page=target_page_idx, to_page=target_page_idx)
page = new[0]
if needle:
# PyMuPDF ist tolerant gegen Whitespace, aber Soft-Hyphen
# bricht den Match — analog zu _normalize_for_match
# entfernen wir \xad vor dem search_for.
clean = needle.replace("\u00ad", "")
rects = page.search_for(clean)
if not rects:
# Fallback: nur die ersten 5 Wörter als Anker — analog
# zu find_chunk_for_text. Wenn der LLM den Snippet
# mid-sentence gekürzt hat, bricht der Volltext-Match,
# aber 5-Wort-Sequenz findet die Stelle trotzdem.
words = clean.split()
if len(words) >= 5:
anchor = " ".join(words[:5])
rects = page.search_for(anchor)
if needle and rects:
# Rects beziehen sich auf src-Page-Koordinaten — nach insert_pdf
# sind die Koordinaten identisch (gleiche MediaBox).
for rect in rects:
annot = page.add_highlight_annot(rect)
if annot is not None:

View File

@ -617,11 +617,24 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str
auf int gezwungen. ``q`` ist auf 200 Zeichen begrenzt im Renderer.
"""
# Reverse-Lookup: pdf-Filename → programm_id, falls nur pdf angegeben.
# Zwei Stufen: exakter Match, dann fuzzy (Year-Suffix-Stripping), weil
# Pre-#47 Assessments halluzinierte Dateinamen haben können, z.B.
# "gruene-grundsatzprogramm-2020.pdf" statt "gruene-grundsatzprogramm.pdf".
if not pid and pdf:
# Stage 1: exakt
for p, info in PROGRAMME.items():
if info.get("pdf") == pdf:
pid = p
break
# Stage 2: Year-Suffix stripping (z.B. "X-2020.pdf" → "X.pdf")
if not pid:
import re
stripped = re.sub(r"-\d{4}\.pdf$", ".pdf", pdf)
if stripped != pdf:
for p, info in PROGRAMME.items():
if info.get("pdf") == stripped:
pid = p
break
if pid not in PROGRAMME:
raise HTTPException(status_code=404, detail="Unbekanntes Wahlprogramm")
if seite < 1 or seite > 2000: