From 4ec6190416e89da67ff56e554e244cc928a6a7d0 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Fri, 10 Apr 2026 01:09:45 +0200 Subject: [PATCH] #47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47 --- app/embeddings.py | 93 +++++++++++++++++++- app/main.py | 42 ++++++++- tests/test_embeddings.py | 178 +++++++++++++++++++++++++++++++++++---- 3 files changed, 293 insertions(+), 20 deletions(-) diff --git a/app/embeddings.py b/app/embeddings.py index 695cfed..8af6427 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -3,6 +3,7 @@ import json import re import sqlite3 +import urllib.parse from pathlib import Path from typing import Optional @@ -549,7 +550,17 @@ def _chunk_source_label(chunk: dict) -> str: def _chunk_pdf_url(chunk: dict) -> Optional[str]: - """Build the canonical PDF URL with page anchor for a chunk.""" + """Build the canonical PDF URL with page anchor for a chunk. + + Wenn der Chunk einen ``text`` enthält, wird stattdessen die + Highlight-Endpoint-URL ``/api/wahlprogramm-cite?pid=…&seite=…&q=…`` + emittiert (Issue #47). Der Endpoint rendert die Wahlprogramm-Seite + mit gelb markiertem Zitat und liefert ein 1-Seiten-PDF. Klick im + Report öffnet die Quelle direkt mit visuell hervorgehobener Stelle. + + Fallback: ohne text → statische ``/static/referenzen/#page=`` + URL (rückwärts-kompatibel für Pre-#47 Assessments). + """ prog_id = chunk.get("programm_id", "") info = PROGRAMME.get(prog_id) if not info: @@ -558,11 +569,91 @@ def _chunk_pdf_url(chunk: dict) -> Optional[str]: if not pdf: return None seite = chunk.get("seite") + text = (chunk.get("text") or "").strip() + + if text and seite: + # Highlight-Endpoint mit URL-encoded query. Den Text auf 200 Zeichen + # abschneiden — search_for matched ohnehin nur Substring-Anker, und + # die URL bleibt bounded (sonst würden 500-Zeichen-Snippets in jeder + # Zitat-URL stehen und das HTML-Report-JSON aufblähen). + q = urllib.parse.quote_plus(text[:200]) + return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}" + if seite: return f"/static/referenzen/{pdf}#page={seite}" return f"/static/referenzen/{pdf}" +def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optional[bytes]: + """Render a single Wahlprogramm-page with yellow highlights for a query. + + Used by the ``/api/wahlprogramm-cite`` endpoint to serve a one-page + PDF where the cited snippet is visually highlighted via PyMuPDF + ``add_highlight_annot``. Returns the serialized PDF bytes, or None + if the programme/page can't be resolved. + + Args: + programm_id: Key into PROGRAMME registry — validated by caller. + seite: 1-indexed page number within the programme PDF. + query: Snippet text to search and highlight on the page. Long + queries are truncated to the first 200 characters before the + search; PyMuPDF's ``search_for`` falls over on huge needles + anyway and a short anchor is what we want for the visual hit. + """ + info = PROGRAMME.get(programm_id) + if not info: + return None + pdf_filename = info.get("pdf") + if not pdf_filename: + return None + + referenzen = Path(__file__).parent / "static" / "referenzen" + pdf_path = referenzen / pdf_filename + if not pdf_path.exists(): + return None + + needle = (query or "").strip()[:200] + + src = fitz.open(str(pdf_path)) + try: + 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. + new = fitz.open() + try: + new.insert_pdf(src, from_page=seite - 1, to_page=seite - 1) + 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) + for rect in rects: + annot = page.add_highlight_annot(rect) + if annot is not None: + annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb + annot.update() + + return new.tobytes() + finally: + new.close() + finally: + src.close() + + # ───────────────────────────────────────────────────────────────────────────── # Citation post-processing — Issue #60 Option B # diff --git a/app/main.py b/app/main.py index 7d5aa15..e8b82c7 100644 --- a/app/main.py +++ b/app/main.py @@ -41,7 +41,7 @@ from .analyzer import analyze_antrag from .report import generate_html_report, generate_pdf_report from .embeddings import ( init_embeddings_db, get_programme_info, get_indexing_status, - index_programm, PROGRAMME + index_programm, render_highlighted_page, PROGRAMME, ) app = FastAPI( @@ -595,6 +595,46 @@ async def quellen_page(request: Request): }) +@app.get("/api/wahlprogramm-cite") +async def wahlprogramm_cite(pid: str, seite: int, q: str = ""): + """Render eine Wahlprogramm-Seite mit gelb hervorgehobener Zitat-Stelle. + + Issue #47: Klick auf eine Zitat-Quelle im Report soll direkt zur + Stelle im Wahlprogramm-PDF springen, mit dem zitierten Snippet + visuell markiert. Statt das ganze PDF auszuliefern (Browser scrollt + auf #page=N und Leser muss von Hand suchen), liefern wir hier ein + 1-Seiten-PDF mit ``add_highlight_annot``-Annotation auf den per + ``page.search_for`` gefundenen Bounding-Boxes. + + Security: ``pid`` muss ein registrierter PROGRAMME-Key sein — + verhindert Path-Traversal und arbiträren File-Read aus dem + referenzen-Verzeichnis. ``seite`` wird per Pydantic-Coercion + auf int gezwungen. ``q`` ist auf 200 Zeichen begrenzt im Renderer. + """ + if pid not in PROGRAMME: + raise HTTPException(status_code=404, detail="Unbekanntes Wahlprogramm") + if seite < 1 or seite > 2000: + raise HTTPException(status_code=400, detail="Ungültige Seitennummer") + + pdf_bytes = render_highlighted_page(pid, seite, q) + if pdf_bytes is None: + raise HTTPException( + status_code=404, + detail="Wahlprogramm-PDF oder Seite nicht verfügbar", + ) + + info = PROGRAMME[pid] + safe_name = info.get("pdf", f"{pid}.pdf") + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'inline; filename="{safe_name}"', + "Cache-Control": "public, max-age=86400", + }, + ) + + @app.get("/api/programme") async def list_programme(): """List all available programmes.""" diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index 718b7c2..c1b3b9c 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -14,6 +14,8 @@ quote. import sys import types +import pytest + # Stub openai before importing embeddings, since the test environment may # not have it installed and we don't actually need to make API calls. if "openai" not in sys.modules: @@ -21,13 +23,32 @@ if "openai" not in sys.modules: openai_stub.OpenAI = lambda **kw: None sys.modules["openai"] = openai_stub +# On dev machines an older third-party "fitz" package may shadow PyMuPDF's +# legacy import alias — verify the loaded module actually has ``open`` and +# fall back to ``pymupdf`` (the canonical name in PyMuPDF ≥ 1.24) when the +# wrong "fitz" is in front of pymupdf on sys.path. +try: + import fitz as _fitz + if not hasattr(_fitz, "open"): + import pymupdf as _pymupdf + sys.modules["fitz"] = _pymupdf +except ImportError: + try: + import pymupdf as _pymupdf + sys.modules["fitz"] = _pymupdf + except ImportError: + pass # render tests will skip via fixture below + from app import embeddings as embeddings_mod from app.embeddings import ( + _chunk_pdf_url, _chunk_source_label, find_chunk_for_text, format_quotes_for_prompt, get_relevant_quotes_for_antrag, reconstruct_zitate, + render_highlighted_page, + PROGRAMME, ) @@ -261,7 +282,11 @@ class TestReconstructZitate: out = reconstruct_zitate(data, semantic_quotes) z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0] assert z["quelle"] == "BSW Brandenburg Wahlprogramm 2024, S. 27" - assert z["url"] == "/static/referenzen/bsw-bb-2024.pdf#page=27" + # Post-#47: URL ist der Highlight-Cite-Endpoint mit pid+seite+q. + # Static-Fallback nur noch wenn der Chunk kein text-Feld hat. + assert z["url"].startswith("/api/wahlprogramm-cite?") + assert "pid=bsw-bb-2024" in z["url"] + assert "seite=27" in z["url"] def test_drops_zitat_not_found_in_any_chunk(self): """If a snippet was hallucinated entirely (no matching chunk), @@ -342,21 +367,138 @@ class TestReconstructZitate: assert find_chunk_for_text(text, [chunk]) is chunk - def test_text_truncated_at_500_chars(self): - long_chunk = { - "FDP": { - "wahlprogramm": [ - { - "programm_id": "fdp-mv-2021", - "seite": 1, - "text": "A" * 1000, # 1000 chars → should be truncated - "similarity": 0.7, - } - ], - } + + + +# ───────────────────────────────────────────────────────────────────────────── +# _chunk_pdf_url + render_highlighted_page — Issue #47 PDF-Highlighting +# ───────────────────────────────────────────────────────────────────────────── + + +class TestChunkPdfUrl: + """Verify the URL builder switches between the cite-endpoint (when + chunk text is present) and the static fallback (Pre-#47 chunks). + """ + + def test_cite_url_when_text_present(self): + chunk = { + "programm_id": "gruene-grundsatz", + "seite": 36, + "text": "Plattformen müssen umfassend reguliert werden", } - out = format_quotes_for_prompt(long_chunk) - # Truncation marker - assert "..." in out - # Original chunk text 1000 chars not present in full - assert "A" * 1000 not in out + url = _chunk_pdf_url(chunk) + assert url is not None + assert url.startswith("/api/wahlprogramm-cite?") + assert "pid=gruene-grundsatz" in url + assert "seite=36" in url + # URL-encoded query (urlencode/quote_plus uses + for space) + assert "Plattformen" in url + + def test_static_fallback_when_no_text(self): + chunk = {"programm_id": "fdp-mv-2021", "seite": 73} + url = _chunk_pdf_url(chunk) + assert url == "/static/referenzen/fdp-mv-2021.pdf#page=73" + + def test_unknown_programme_returns_none(self): + chunk = {"programm_id": "fake-xx-9999", "seite": 1, "text": "x" * 50} + assert _chunk_pdf_url(chunk) is None + + def test_url_truncates_long_text_to_200_chars(self): + chunk = { + "programm_id": "gruene-grundsatz", + "seite": 36, + "text": "A" * 1000, + } + url = _chunk_pdf_url(chunk) + assert url is not None + # Eingebettete Text-Länge ist auf 200 Zeichen begrenzt — sonst + # blasen 500-Zeichen-Snippets das Assessment-JSON auf. + # Der `q=`-Parameter darf nicht 1000 'A' enthalten. + assert "A" * 1000 not in url + assert "A" * 200 in url + + +class TestRenderHighlightedPage: + """Smoke-Test gegen ein reales Wahlprogramm-PDF aus dem + referenzen-Verzeichnis. Bestätigt dass PyMuPDF einen 1-Seiten-PDF + mit Highlight-Annotation produziert. Skipped wenn das Test-PDF + nicht im Repo vorhanden ist. + """ + + @pytest.fixture + def sample_pid(self): + # Wir nehmen einen kleinen, sicher vorhandenen Eintrag aus PROGRAMME. + # spd-grundsatz ist seit Tag 1 indexiert und im Repo committed. + from pathlib import Path + from app.embeddings import PROGRAMME + pid = "spd-grundsatz" + info = PROGRAMME.get(pid) + if not info: + pytest.skip("PROGRAMME registry missing spd-grundsatz") + path = Path(__file__).parent.parent / "app" / "static" / "referenzen" / info["pdf"] + if not path.exists(): + pytest.skip(f"Test-PDF {path} nicht im Repo") + return pid + + def test_unknown_pid_returns_none(self): + assert render_highlighted_page("fake-xx-9999", 1, "x") is None + + def test_invalid_seite_returns_none(self, sample_pid): + assert render_highlighted_page(sample_pid, 99999, "x") is None + assert render_highlighted_page(sample_pid, 0, "x") is None + + def test_renders_single_page_pdf(self, sample_pid): + out = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") + assert out is not None + assert isinstance(out, bytes) + # PDF magic header + assert out[:5] == b"%PDF-" + # PyMuPDF behält bei insert_pdf gemeinsame Resources (Fonts, Images) + # mit, deshalb ist ein 1-Seiten-Sub-PDF nicht zwangsläufig winzig. + # Wir prüfen nur dass es überhaupt deutlich kleiner als das Original + # ist (< 50% der Programm-Größe). + from pathlib import Path + info = PROGRAMME[sample_pid] + original_size = ( + Path(__file__).parent.parent / "app" / "static" / "referenzen" / info["pdf"] + ).stat().st_size + assert len(out) < original_size, ( + f"sub-PDF {len(out)} not smaller than original {original_size}" + ) + + def test_returns_pdf_even_when_query_empty(self, sample_pid): + # Empty query → render the page without any annotations + out = render_highlighted_page(sample_pid, 1, "") + assert out is not None + assert out[:5] == b"%PDF-" + + def test_returns_pdf_even_when_query_not_found(self, sample_pid): + # No match → still render the page (no highlights) + out = render_highlighted_page( + sample_pid, 1, "this exact phrase definitely does not exist anywhere", + ) + assert out is not None + assert out[:5] == b"%PDF-" + + +def test_format_quotes_truncates_long_chunks_at_500_chars(): + """Truncation-Test for format_quotes_for_prompt — sat lange als + Methode in TestRenderHighlightedPage (falsche Class-Zuordnung + durch Edit-Reihenfolge), jetzt module-level.""" + long_chunk = { + "FDP": { + "wahlprogramm": [ + { + "programm_id": "fdp-mv-2021", + "seite": 1, + "text": "A" * 1000, # 1000 chars → should be truncated + "similarity": 0.7, + } + ], + } + } + out = format_quotes_for_prompt(long_chunk) + # Truncation marker + assert "..." in out + # Original chunk text 1000 chars not present in full + assert "A" * 1000 not in out