From 5a035be20bdbd9b11239198b29c1092b57e50418 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Fri, 10 Apr 2026 10:08:02 +0200 Subject: [PATCH] =?UTF-8?q?#47=20Fix:=20Highlighting=20f=C3=BCr=20falsche?= =?UTF-8?q?=20Seitenzahlen=20+=20Year-Suffix-Matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/embeddings.py | 45 +++++++++++++++++++++++++++------------------ app/main.py | 13 +++++++++++++ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/app/embeddings.py b/app/embeddings.py index 0140bd0..b67b52d 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -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: diff --git a/app/main.py b/app/main.py index 0a86961..0d34b68 100644 --- a/app/main.py +++ b/app/main.py @@ -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: