diff --git a/app/database.py b/app/database.py index 6f17556..716f770 100644 --- a/app/database.py +++ b/app/database.py @@ -200,6 +200,17 @@ async def get_assessment(drucksache: str) -> Optional[dict]: return None +async def delete_assessment(drucksache: str) -> bool: + """Delete an assessment by drucksache ID. Used by the cite-endpoint + to trigger re-analysis of Pre-#60 hallucinated assessments.""" + async with aiosqlite.connect(settings.db_path) as db: + cursor = await db.execute( + "DELETE FROM assessments WHERE drucksache = ?", (drucksache,) + ) + await db.commit() + return cursor.rowcount > 0 + + async def get_all_assessments(bundesland: str = None) -> list[dict]: """Get all assessments from database, optionally filtered by Bundesland. diff --git a/app/embeddings.py b/app/embeddings.py index ff82426..b0b006c 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -592,10 +592,10 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona ``add_highlight_annot``. Returns the serialized PDF bytes, or None if the programme/page can't be resolved. - Returns a tuple ``(pdf_bytes, found_page)`` where ``found_page`` is the - 1-indexed page where the highlight was placed (may differ from ``seite`` - if the text was found on a different page). Returns ``(None, 0)`` if the - programme/page can't be resolved. + Returns a tuple ``(pdf_bytes, found_page, highlighted)`` where + ``found_page`` is the 1-indexed page number and ``highlighted`` is + True if the text was found and annotated. Returns ``(None, 0, False)`` + if the programme/page can't be resolved. Args: programm_id: Key into PROGRAMME registry — validated by caller. @@ -607,22 +607,22 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona """ info = PROGRAMME.get(programm_id) if not info: - return None, 0 + return None, 0, False pdf_filename = info.get("pdf") if not pdf_filename: - return None, 0 + return None, 0, False referenzen = Path(__file__).parent / "static" / "referenzen" pdf_path = referenzen / pdf_filename if not pdf_path.exists(): - return None, 0 + return None, 0, False needle = (query or "").strip()[:200] src = fitz.open(str(pdf_path)) try: if seite < 1 or seite > len(src): - return None, 0 + return None, 0, False # Suche den Needle auf der angegebenen Seite. Falls dort nichts # gefunden wird (Pre-#60-Assessments haben oft falsche Seiten- @@ -657,28 +657,9 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona if annot is not None: annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb annot.update() - elif needle: - # Kein Match — halluziniertes Zitat aus Pre-#60-Assessment. - # Sichtbare Notiz-Annotation am Seitenkopf, damit der User - # versteht warum nichts markiert ist. - note_rect = fitz.Rect(20, 20, 400, 55) - note_text = ( - "Textstelle nicht im Dokument auffindbar — " - "das Zitat wurde möglicherweise vom LLM paraphrasiert. " - "Eine Re-Analyse des Antrags würde verifizierte Zitate erzeugen." - ) - annot = page.add_freetext_annot( - note_rect, - note_text, - fontsize=9, - fontname="helv", - text_color=(0.5, 0.2, 0.0), - fill_color=(1.0, 0.95, 0.8), - ) - if annot is not None: - annot.update() - return src.tobytes(), target_page_idx + 1 + highlighted = bool(needle and rects) + return src.tobytes(), target_page_idx + 1, highlighted finally: src.close() diff --git a/app/main.py b/app/main.py index 3852ced..c0449ed 100644 --- a/app/main.py +++ b/app/main.py @@ -32,8 +32,9 @@ logger = logging.getLogger(__name__) from .config import settings from .database import ( init_db, get_job, create_job, update_job, - get_all_assessments, get_assessment, upsert_assessment, import_json_assessments, - search_assessments + get_all_assessments, get_assessment, delete_assessment, + upsert_assessment, import_json_assessments, + search_assessments, ) from .parlamente import get_adapter, ADAPTERS from .bundeslaender import alle_bundeslaender @@ -596,7 +597,12 @@ async def quellen_page(request: Request): @app.get("/api/wahlprogramm-cite") -async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str = ""): +async def wahlprogramm_cite( + request: Request, + background_tasks: BackgroundTasks, + pid: str = "", pdf: str = "", seite: int = 1, q: str = "", + ds: str = "", bl: str = "", +): """Render eine Wahlprogramm-Seite mit gelb hervorgehobener Zitat-Stelle. Issue #47: Klick auf eine Zitat-Quelle im Report soll direkt zur @@ -640,13 +646,50 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str if seite < 1 or seite > 2000: raise HTTPException(status_code=400, detail="Ungültige Seitennummer") - pdf_bytes, found_page = render_highlighted_page(pid, seite, q) + pdf_bytes, found_page, highlighted = render_highlighted_page(pid, seite, q) if pdf_bytes is None: raise HTTPException( status_code=404, detail="Wahlprogramm-PDF oder Seite nicht verfügbar", ) + # Issue #47: Wenn das Zitat nicht im PDF auffindbar ist UND wir die + # Drucksache kennen, ist das Assessment wahrscheinlich ein Pre-#60- + # Halluzinations-Opfer. Automatische Re-Analyse triggern und dem + # User eine Warte-Seite zeigen statt ein PDF ohne Highlights. + if not highlighted and q and ds and bl: + existing = await get_assessment(ds) + if existing: + adapter = get_adapter(bl) + if adapter: + # Altes Assessment löschen und neu analysieren + await delete_assessment(ds) + job_id = str(uuid.uuid4()) + await create_job(job_id, f"Re-Analyse {ds} (Zitat nicht verifizierbar)", bl, "qwen-plus") + text = await adapter.download_text(ds) + if text: + doc = await adapter.get_document(ds) + background_tasks.add_task( + run_drucksache_analysis, + job_id, ds, text, bl, "qwen-plus", doc, + ) + # HTML-Warte-Seite mit Auto-Redirect zurück zum Assessment + return HTMLResponse(f""" + + +Wird neu analysiert… + +
+
+

Zitat nicht verifizierbar

+

Der Antrag {ds} wird mit der aktuellen Pipeline
+neu analysiert, um verifizierte Zitate zu erzeugen.

+

Automatische Weiterleitung in 15 Sekunden…

+
""") + info = PROGRAMME[pid] safe_name = info.get("pdf", f"{pid}.pdf") return Response( @@ -654,10 +697,7 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str media_type="application/pdf", headers={ "Content-Disposition": f'inline; filename="{safe_name}"', - "Cache-Control": "public, max-age=86400", - # found_page als Header mitgeben, damit das Frontend den - # #page=N Fragment korrekt setzen kann (bei page-redirect - # nach Fallback-Suche auf anderer Seite als angefordert). + "Cache-Control": "public, max-age=3600", "X-Found-Page": str(found_page), }, ) diff --git a/app/templates/index.html b/app/templates/index.html index 7ddbea9..b6c5cd9 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1502,24 +1502,26 @@ // Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments // (statische /static/referenzen/X.pdf#page=N) und nativ für // Post-#47 (die schon /api/wahlprogramm-cite enthalten). - function makeCiteUrl(z) { + // makeCiteUrl baut die Highlight-URL und hängt ds+bl an, + // damit der Server bei nicht-auffindbaren Zitaten automatisch + // eine Re-Analyse triggern kann (#47 + #60). + function makeCiteUrl(z, ds, bl) { if (!z || !z.url) return '#'; - // Schon eine Cite-URL? #page=N anhängen falls nicht vorhanden. + const extra = (ds && bl) ? `&ds=${encodeURIComponent(ds)}&bl=${encodeURIComponent(bl)}` : ''; + // Schon eine Cite-URL? ds/bl anhängen + #page=N. if (z.url.includes('/api/wahlprogramm-cite')) { const m = z.url.match(/seite=(\d+)/); const page = m ? m[1] : ''; - return page ? z.url + '#page=' + page : z.url; + const base = z.url.split('#')[0]; + return base + extra + (page ? '#page=' + page : ''); } - // Statische URL umschreiben: /static/referenzen/X.pdf#page=N - // → /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=#page=N - // Das volle PDF mit Highlight-Annotation wird ausgeliefert, - // #page=N lässt den Browser direkt zur Fundstelle scrollen. + // Statische URL umschreiben const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/); if (m && z.text) { const pdf = m[1]; const page = m[2]; const q = encodeURIComponent((z.text || '').substring(0, 200)); - return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}#page=${page}`; + return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`; } return z.url; } @@ -1529,7 +1531,7 @@ const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => `
"${z.text}"
- + 📄 ${z.quelle}
diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index bb133ad..6354f89 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -441,33 +441,36 @@ class TestRenderHighlightedPage: return pid def test_unknown_pid_returns_none(self): - pdf_bytes, page = render_highlighted_page("fake-xx-9999", 1, "x") + pdf_bytes, page, highlighted = render_highlighted_page("fake-xx-9999", 1, "x") assert pdf_bytes is None def test_invalid_seite_returns_none(self, sample_pid): - pdf_bytes, _ = render_highlighted_page(sample_pid, 99999, "x") + pdf_bytes, _, _ = render_highlighted_page(sample_pid, 99999, "x") assert pdf_bytes is None - pdf_bytes2, _ = render_highlighted_page(sample_pid, 0, "x") + pdf_bytes2, _, _ = render_highlighted_page(sample_pid, 0, "x") assert pdf_bytes2 is None def test_renders_full_pdf_with_highlight(self, sample_pid): - pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") + pdf_bytes, found_page, highlighted = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") assert pdf_bytes is not None assert isinstance(pdf_bytes, bytes) assert pdf_bytes[:5] == b"%PDF-" assert found_page >= 1 + assert highlighted is True def test_returns_pdf_even_when_query_empty(self, sample_pid): - pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "") + pdf_bytes, _, highlighted = render_highlighted_page(sample_pid, 1, "") assert pdf_bytes is not None assert pdf_bytes[:5] == b"%PDF-" + assert highlighted is False - def test_returns_pdf_even_when_query_not_found(self, sample_pid): - pdf_bytes, found_page = render_highlighted_page( + def test_returns_pdf_when_query_not_found_flagged_unhighlighted(self, sample_pid): + pdf_bytes, _, highlighted = render_highlighted_page( sample_pid, 1, "this exact phrase definitely does not exist anywhere", ) assert pdf_bytes is not None assert pdf_bytes[:5] == b"%PDF-" + assert highlighted is False def test_format_quotes_truncates_long_chunks_at_500_chars():