#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten

Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:

Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment

Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.

Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
  drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
  triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
  durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
  Assessments die sich durch Re-Analyse ändern)

Tests: 194/194 grün.

Refs: #47, #60
This commit is contained in:
Dotty Dotter 2026-04-10 10:35:01 +02:00
parent 8c27c302f7
commit a821c19202
5 changed files with 90 additions and 53 deletions

View File

@ -200,6 +200,17 @@ async def get_assessment(drucksache: str) -> Optional[dict]:
return None 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]: async def get_all_assessments(bundesland: str = None) -> list[dict]:
"""Get all assessments from database, optionally filtered by Bundesland. """Get all assessments from database, optionally filtered by Bundesland.

View File

@ -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 ``add_highlight_annot``. Returns the serialized PDF bytes, or None
if the programme/page can't be resolved. if the programme/page can't be resolved.
Returns a tuple ``(pdf_bytes, found_page)`` where ``found_page`` is the Returns a tuple ``(pdf_bytes, found_page, highlighted)`` where
1-indexed page where the highlight was placed (may differ from ``seite`` ``found_page`` is the 1-indexed page number and ``highlighted`` is
if the text was found on a different page). Returns ``(None, 0)`` if the True if the text was found and annotated. Returns ``(None, 0, False)``
programme/page can't be resolved. if the programme/page can't be resolved.
Args: Args:
programm_id: Key into PROGRAMME registry validated by caller. 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) info = PROGRAMME.get(programm_id)
if not info: if not info:
return None, 0 return None, 0, False
pdf_filename = info.get("pdf") pdf_filename = info.get("pdf")
if not pdf_filename: if not pdf_filename:
return None, 0 return None, 0, False
referenzen = Path(__file__).parent / "static" / "referenzen" referenzen = Path(__file__).parent / "static" / "referenzen"
pdf_path = referenzen / pdf_filename pdf_path = referenzen / pdf_filename
if not pdf_path.exists(): if not pdf_path.exists():
return None, 0 return None, 0, False
needle = (query or "").strip()[:200] needle = (query or "").strip()[:200]
src = fitz.open(str(pdf_path)) src = fitz.open(str(pdf_path))
try: try:
if seite < 1 or seite > len(src): if seite < 1 or seite > len(src):
return None, 0 return None, 0, False
# Suche den Needle auf der angegebenen Seite. Falls dort nichts # Suche den Needle auf der angegebenen Seite. Falls dort nichts
# gefunden wird (Pre-#60-Assessments haben oft falsche Seiten- # 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: if annot is not None:
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
annot.update() 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: finally:
src.close() src.close()

View File

@ -32,8 +32,9 @@ logger = logging.getLogger(__name__)
from .config import settings from .config import settings
from .database import ( from .database import (
init_db, get_job, create_job, update_job, init_db, get_job, create_job, update_job,
get_all_assessments, get_assessment, upsert_assessment, import_json_assessments, get_all_assessments, get_assessment, delete_assessment,
search_assessments upsert_assessment, import_json_assessments,
search_assessments,
) )
from .parlamente import get_adapter, ADAPTERS from .parlamente import get_adapter, ADAPTERS
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
@ -596,7 +597,12 @@ async def quellen_page(request: Request):
@app.get("/api/wahlprogramm-cite") @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. """Render eine Wahlprogramm-Seite mit gelb hervorgehobener Zitat-Stelle.
Issue #47: Klick auf eine Zitat-Quelle im Report soll direkt zur 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: if seite < 1 or seite > 2000:
raise HTTPException(status_code=400, detail="Ungültige Seitennummer") 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: if pdf_bytes is None:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail="Wahlprogramm-PDF oder Seite nicht verfügbar", 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"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta http-equiv="refresh" content="15;url=/#assessment={ds}">
<title>Wird neu analysiert</title>
<style>body{{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5}}
.box{{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}}
.spinner{{width:40px;height:40px;border:4px solid #ddd;border-top:4px solid #009da5;border-radius:50%;animation:spin 1s linear infinite;margin:1rem auto}}
@keyframes spin{{to{{transform:rotate(360deg)}}}}</style></head>
<body><div class="box">
<div class="spinner"></div>
<h2>Zitat nicht verifizierbar</h2>
<p>Der Antrag <strong>{ds}</strong> wird mit der aktuellen Pipeline<br>
neu analysiert, um verifizierte Zitate zu erzeugen.</p>
<p style="color:#666;font-size:0.9rem">Automatische Weiterleitung in 15 Sekunden</p>
</div></body></html>""")
info = PROGRAMME[pid] info = PROGRAMME[pid]
safe_name = info.get("pdf", f"{pid}.pdf") safe_name = info.get("pdf", f"{pid}.pdf")
return Response( return Response(
@ -654,10 +697,7 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str
media_type="application/pdf", media_type="application/pdf",
headers={ headers={
"Content-Disposition": f'inline; filename="{safe_name}"', "Content-Disposition": f'inline; filename="{safe_name}"',
"Cache-Control": "public, max-age=86400", "Cache-Control": "public, max-age=3600",
# 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).
"X-Found-Page": str(found_page), "X-Found-Page": str(found_page),
}, },
) )

View File

@ -1502,24 +1502,26 @@
// Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments // Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments
// (statische /static/referenzen/X.pdf#page=N) und nativ für // (statische /static/referenzen/X.pdf#page=N) und nativ für
// Post-#47 (die schon /api/wahlprogramm-cite enthalten). // 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 '#'; 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')) { if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/); const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : ''; 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 // Statische URL umschreiben
// → /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=<text>#page=N
// Das volle PDF mit Highlight-Annotation wird ausgeliefert,
// #page=N lässt den Browser direkt zur Fundstelle scrollen.
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/); const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
if (m && z.text) { if (m && z.text) {
const pdf = m[1]; const pdf = m[1];
const page = m[2]; const page = m[2];
const q = encodeURIComponent((z.text || '').substring(0, 200)); 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; return z.url;
} }
@ -1529,7 +1531,7 @@
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => ` const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => `
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #f8f9fa; border-left: 3px solid #889e33; font-size: 0.85rem;"> <div style="margin: 0.5rem 0; padding: 0.5rem; background: #f8f9fa; border-left: 3px solid #889e33; font-size: 0.85rem;">
<em>"${z.text}"</em><br> <em>"${z.text}"</em><br>
<a href="${makeCiteUrl(z)}" target="_blank" style="color: #009da5; font-size: 0.8rem;"> <a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;">
📄 ${z.quelle} 📄 ${z.quelle}
</a> </a>
</div> </div>

View File

@ -441,33 +441,36 @@ class TestRenderHighlightedPage:
return pid return pid
def test_unknown_pid_returns_none(self): 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 assert pdf_bytes is None
def test_invalid_seite_returns_none(self, sample_pid): 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 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 assert pdf_bytes2 is None
def test_renders_full_pdf_with_highlight(self, sample_pid): 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 pdf_bytes is not None
assert isinstance(pdf_bytes, bytes) assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b"%PDF-" assert pdf_bytes[:5] == b"%PDF-"
assert found_page >= 1 assert found_page >= 1
assert highlighted is True
def test_returns_pdf_even_when_query_empty(self, sample_pid): 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 is not None
assert pdf_bytes[:5] == b"%PDF-" assert pdf_bytes[:5] == b"%PDF-"
assert highlighted is False
def test_returns_pdf_even_when_query_not_found(self, sample_pid): def test_returns_pdf_when_query_not_found_flagged_unhighlighted(self, sample_pid):
pdf_bytes, found_page = render_highlighted_page( pdf_bytes, _, highlighted = render_highlighted_page(
sample_pid, 1, "this exact phrase definitely does not exist anywhere", sample_pid, 1, "this exact phrase definitely does not exist anywhere",
) )
assert pdf_bytes is not None assert pdf_bytes is not None
assert pdf_bytes[:5] == b"%PDF-" assert pdf_bytes[:5] == b"%PDF-"
assert highlighted is False
def test_format_quotes_truncates_long_chunks_at_500_chars(): def test_format_quotes_truncates_long_chunks_at_500_chars():