#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:
parent
8c27c302f7
commit
a821c19202
@ -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.
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
56
app/main.py
56
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"""<!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]
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
@ -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=<text>#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 => `
|
||||
<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>
|
||||
<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}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user