Architektur-Refactor zur Vorbereitung BL-uebergreifender Parser:
- app/protokoll_parser_nrw.py → app/protokoll_parsers/nrw.py
- app/ingest_votes_nrw.py → app/ingest_votes.py (BL-uebergreifend)
- Neue app/protokoll_parsers/__init__.py mit:
- PROTOKOLL_PARSERS-Dict (BL-Code → Parser-Funktion, derzeit nur NRW)
- parse_protocol(bundesland, pdf_path) als BL-uebergreifender Einstieg
- supported_bundeslaender()-Helper
- NotImplementedError mit hilfreicher Message bei unbekanntem BL
CLI bekommt --supported-Flag fuer BL-Discovery:
python -m app.ingest_votes --supported → 'NRW'
ADR 0009 dokumentiert das Muster (Sub-Package + Funktions-Registry,
analog zu ADR 0002 fuer ParlamentAdapter). Folge-BL bekommen je
eine eigene Datei und einen Eintrag in PROTOKOLL_PARSERS — kein
Refactoring der Bestands-Logik.
Tests:
- 7 neue Tests in test_protokoll_parsers.py fuer Registry und Dispatch
- Bestehende NRW-Tests umbenannt zu test_protokoll_parsers_nrw.py,
Imports angepasst — keine Verhaltens-Aenderung
- Bestehende Ingest-Tests umbenannt zu test_ingest_votes.py
642 Tests gruen, kein Verhaltens-Drift.
Antrag-Detail-Endpoint liest plenum_votes via get_plenum_votes() und
reicht sie an antrag_detail.html durch.
Block rendert pro Plenum-Abstimmung eine Karte:
- Ergebnis (angenommen/abgelehnt/...) farb-kodiert
- 'einstimmig'-Annotation falls gesetzt
- Quelle (Protokoll-ID, mit URL als Tooltip)
- Fraktions-Chips fuer Ja/Nein/Enthaltung
Mehrfach-Abstimmungen einer Drucksache (Ueberweisung + finale
Beschlussfassung) erzeugen mehrere Karten — chronologisch via
parsed_at DESC im Repository sortiert.
Block erscheint nur, wenn Eintraege existieren (kein leerer Header).
Hintergrund: abgeordnetenwatch hatte das CDU-BE-2023-PDF unter dem alten
Slug-Namen gegen das CDU-BE-2026-Wahlprogramm ersetzt — ohne den
Datei-Namen zu aendern. Die Embedding-Indexierung haette das anachronistische
Programm uebernommen, ohne dass es jemand bemerkt.
Loesung: app/wahlprogramm-shas.lock.json pinnt nach erstem erfolgreichen
Download den SHA-256 jedes Programmes. Spaetere Aufrufe von
fetch_and_verify() vergleichen den Server-Inhalt gegen den Lock; bei
Abweichung wird abgebrochen mit klarer Fehlermeldung. Nur mit explizitem
Maintainer-Override (--accept-new-sha) wird der Lock aktualisiert.
CLI:
python -m app.wahlprogramm_fetch --pin-existing
seedet den Lock einmalig aus den vorhandenen PDFs (52 Eintraege).
python -m app.wahlprogramm_fetch --fetch BL PARTEI [--accept-new-sha]
laedt mit Lock-Pruefung; --accept-new-sha bei bewusstem Update.
6 neue Tests in test_wahlprogramm_fetch.py decken den Pferdetausch-
Block, das initiale Pinnen, das Migration-Szenario (PDF da, Lock leer)
und den --accept-new-sha-Override ab.
Closes#138
System- und User-Prompt-Template stehen jetzt collapsed unter dem
neuen Abschnitt 'LLM-Prompts'. Der User-Prompt wird auf eine eigene
Konstante USER_PROMPT_TEMPLATE umgestellt und via .format(...) gerendert,
sodass das gleiche Template auf der Methodik-Seite gezeigt werden kann
ohne den f-string-Code zu duplizieren.
Closes#145
Adapter liefert fraktionen schon mit, das Frontend ignorierte sie bisher.
Treffer-Zeile bekommt jetzt unter dem Titel kleine Teal-Chips fuer jede
einreichende Fraktion (Beispiel: 'CDU SPD' bei kollektiven Antraegen).
Stylistisch konsistent zum Score-Chip-System (color-mix mit ecg-teal),
mono Font, uppercase 10px — bleibt auch bei vielen Fraktionen lesbar.
Closes#146
Symptom: Monitoring-Scan zeigte bei SL seen=0 errors=OK, obwohl der
Umbraco-Backend HTTP 500 zurueckgab. Im _post_search wurde 5xx via
'logger.error + return []' geschluckt, sodass der Monitoring-Layer
die Fehlerursache nicht in monitoring_daily_summary persistierte.
Fix: bei resp.status_code != 200 httpx.HTTPStatusError raisen — das
propagiert durch search() ueber _search_adapter ins outer except in
daily_scan, das den Fehlertext in summary.errors schreibt.
Regression-Test test_search_propagates_http_500.
Closes#142
Dev-Container setzt GITEA_FEEDBACK_LABELS=feedback,dev, damit
Feedback-Issues aus gwoe-dev.toppyr.de unterscheidbar markiert werden.
Label-Farben: feedback rot, dev gelb, Sonst grau.
Teil der Container-Duplikation fuer v1.x-Entwicklung.
- Auflösung: scale = window.devicePixelRatio (statt min:2 cap) — Retina-scharf
- Vor dem html2canvas-Capture werden v2-feedback-{modal,overlay,btn} auf
display:none gesetzt; finally-Block stellt UI zurueck. Damit ist die
ausgegraute Modal-Schicht nicht im Bild
- Capture nur des sichtbaren Viewports (width/height/x/y/windowWidth/Height
explizit), spart Bandbreite + zeigt was der User wirklich sieht
- MAX_W 800 -> 1600, JPEG 0.7 -> 0.85, imageSmoothingQuality high
- requestAnimationFrame x2 vor capture, damit Browser den Reflow vor dem Snap fertig hat
- app_version 1.0.1 -> 1.0.2 (Cache-Buster)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser-Cache zeigte alte v2.css ohne v2-menu-toggle-display:none-Regel.
Mit ?v=1.0.0 wird auf Versionsspruenge sauber neu geladen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auf Desktop ist die Sidebar permanent — der Burger-Button hatte dort keine
Funktion. display: none default + @media max-width:900px → inline-flex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Klick öffnet /api/auth/forgot-password → 302 zur Keycloak-Reset-Page mit
client_id + redirect_uri (auf eigene Domain). Keycloak schickt Mail mit
Reset-Link, User setzt neues Passwort, kommt zurück.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: User registriert -> Keycloak-User mit enabled=false angelegt -> KEINE
Mail bis Admin manuell freischaltet. UX-Luecke: User weiss zwischen Klick und
Admin-Freischaltung nicht, ob etwas passiert ist.
Jetzt: nach erfolgreichem Keycloak-User-Create wird sofort eine Bestaetigungs-
Mail an die angegebene Adresse geschickt mit Hinweis auf den 3-Schritt-Flow
(Anmeldung -> Admin-Freischaltung -> Passwort-Setzen-Mail). Plain-Text + HTML.
Fehler beim Mail-Versand wird geloggt aber nicht weitergereicht — User-Anlage
ist davon unabhaengig.
Response-Message angepasst: 'Wir haben dir eine Bestaetigung per E-Mail geschickt.'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beide Routes liefern jetzt all_canonical_keys() (ohne Landesregierung) als Dropdown-
Optionen. Verhindert Tippfehler und gibt nur tatsaechlich erkannte Parteien zur Auswahl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend (Filter sind seit jeher da):
- /api/feed.xml?bundesland=&partei=&limit=
- /api/subscriptions GET/POST/DELETE
UI:
- /v2/feed: Form mit BL/Partei/Limit, generiert Feed-URL live, Buttons Oeffnen/
URL-Kopieren/In-Feedly. Default-BL aus Header-Selektor uebernommen
- /v2/abos: Liste eigener Abos + Form zum Anlegen/Loeschen, BL-Dropdown,
Partei-Freitext, Frequenz daily/weekly
- Sidebar 'Daten'-Gruppe um beide Eintraege erweitert (statt Direkt-Link auf
/api/feed.xml)
- Beide Routen mit Depends(require_auth) — Anonyme bekommen 401-Redirect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Topbar:
- height: 32px (statt auto), line-height: 1, alle children max 24px
- Topbar-Icons explizit auf 12x12 (statt 14)
- selects/buttons/a mit fester Hoehe 22px, padding 2px 6px
Landtag-Suche:
- search_landtag filtert jetzt Drucksachen aus, deren Titel typische
Frage-Praefixe haben (Welche/Wie viele/Wann/Was/Hat/Ist/...) oder mit '?'
enden — bei NRW-OPAL liefert der Adapter alle als 'sonstige', daher
Title-Heuristik. Server-side, damit alle Adapter profitieren.
- Neuer Helper drucksache_typen.likely_kleine_anfrage_titel()
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: '— Pruefen' + '— Daten'-Labels waren sichtbar, aber alle Eintraege darin
hidden — nur ein verlorener Header. Jetzt: ganzer Gruppen-Container hinter
{% if is_authenticated %} → Anonymous-User sieht nur 'Lesen'-Gruppe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: Button-Text 'Übersprungen', der Grund nur als Tooltip — User versteht
nicht warum. Jetzt: 'Nicht abstimmbar' + sichtbare Italic-Begruendung unter der
Zeile mit dem konkreten Reason-Text vom Server (Backend liefert reason, typ
und typ_normiert).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: filterte stale-Jobs raus, bei leerer aktiver Queue display:none → User sah nichts.
Jetzt: immer sichtbar mit 'Queue leer · N Worker bereit' wenn nichts aktiv.
Tooltip zeigt Stale-Jobs als 'letzter Lauf'-Liste, wenn keine aktiven Jobs da sind.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser-PDF-Reader (Chrome, Firefox) ignorieren das von /OpenAction-Eintrag im
PDF-Catalog (#88f9c7d) komplett. Der zuverlaessige Weg: URL-Hash-Anker '#page=N'.
Drei Stellen angepasst:
- redline_utils.build_pdf_href: haengt #page={seite} an die URL
- embeddings._build_zitat_url (rebind): analog
- v2/components/quote_card.html: bei alten DB-Eintraegen ohne Hash wird er
on-the-fly aus dem 'seite='-Query-Param erzeugt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: /api/wahlprogramm-cite lieferte das gesamte PDF mit Highlight-Annot
auf der gefundenen Seite, aber der Browser-PDF-Viewer landete auf Seite 1.
Sieht User: 'PDF oeffnet, aber falsche Seite'.
Jetzt: doc.xref_set_key(catalog, 'OpenAction', '[<page-ref> 0 R /Fit]')
schreibt eine PDF-Open-Action ins Dokument-Catalog. Reader springt beim
Oeffnen direkt auf target_page_idx, ohne dass Browser-Hash-Anker noetig sind.
Plus: Topbar select/button padding-top/bottom 1px, links 0px (User: 'nur so
hoch wie noetig').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- render_highlighted_page: führende Seitenzahl-Tokens ('44 Gute Bildung …')
vor search_for entfernen — LLMs ziehen den Header oft ins Zitat mit, was
PyMuPDFs Volltext-Match scheitern lässt
- v2-Topbar: padding 4px -> 2px, line-height 1.2, min-height entfernt
(auto-size, nur so hoch wie noetig)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundesland-Auswahl:
- Topbar: einziger BL-Selektor mit localStorage.gwoe.bl-Persistenz
- BL-Felder entfernt aus durchsuchen.html, landtag_suche.html, neu.html, auswertungen.html
- Screens hoeren auf v2-bl-changed CustomEvent + initial via window.v2GetGlobalBl()
Sichtbarkeit (Sidebar):
- Durchsuchen + Tags: immer
- Merkliste / Neuer Antrag / Landtag-Suche / Auswertungen / Export / Feed: nur eingeloggt
- Cluster + Batch-Analyse + Administration: nur Admin
Server-Side Schutz:
- _v2_template_context()-Helper liefert is_authenticated, is_admin, v2_bundeslaender
- HTML-Routen mit Depends(require_auth) bzw. require_admin
- 401/403-Browser-Requests redirecten auf /?login=1 statt JSON-Error
Queue-Widget (#149):
- Neues Component-Partial v2/components/queue_widget.html
- Statusbar unten links + Hover-Tooltip mit den letzten 20 Jobs
- 5s-Polling auf /api/queue/status, blendet sich aus wenn keine Jobs
Smoke-Test angepasst an neue Auth-Erwartungen (302 fuer auth-protected Routen).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: Klick 'Analysieren' -> POST /api/analyze-drucksache -> sofort
window.location.href = '/antrag/{ds}' -> aber Job laeuft noch im Hintergrund
-> Detail-Seite zeigt 'Antrag nicht gefunden'.
Jetzt:
- already_checked -> sofortiger Redirect
- skipped (nicht abstimmbar) -> Hinweistext im Form
- queued -> Polling auf /status/{job_id} alle 2s, max 3 Min
- completed -> Redirect zur Detail-Seite
- failed/rejected -> Fehlermeldung mit Grund
Anwendung in v2/screens/landtag_suche.html + v2/screens/neu.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registrierung:
- POST /api/auth/register: erstellt User in Keycloak mit enabled=false
- GET /api/auth/pending-users: Liste nicht-freigeschalteter User (Admin)
- POST /api/auth/approve-user: User freischalten (Admin)
- Registrierungs-Dialog im Hamburger-Menü
- Admin: "Freischaltungen"-Button (nur sichtbar mit admin-Rolle)
Matrix:
- Zeilen-Header klickbar → Erklärung der Berührungsgruppe mit
konkretem Lebensalltag-Beispiel
- Spalten-Header klickbar → Erklärung des Werts mit Staatsprinzip
- Feld-Erklärungen: 25 konkrete Bürger:innen-Texte (Schule, Bus,
Miete, Steuer, Spielplatz...)
- Spalten nummeriert: "1. Menschenwürde" etc.
Neue Issues angelegt:
#104 Zeitreihe, #105 Clustering, #106 Abstimmungsverhalten,
#107 Vergleichsansicht, #108 Empfehlungen, #109 Share-Buttons
Klick auf jedes Matrix-Feld öffnet ein Modal mit:
- Feld-Code + voller Name (z.B. "D4: Soziale Gestaltung")
- Zeile + Spalte in Klartext
- "Was bedeutet das für Bürger:innen?" Erklärung (25 Texte)
- Falls bewertet: Aspekt aus der LLM-Analyse + Rating-Farbe
- Falls nicht bewertet: "Dieses Feld wird vom Antrag nicht berührt"
Spaltenüberschriften: "1. Menschenwürde" statt nur "Menschenwürde"
- _shutting_down Flag: sperrt enqueue() bei Shutdown → User bekommt
"Server wird neu gestartet" statt stilles Einreihen in tote Queue
- graceful_shutdown wartet NUR auf processing-Jobs (nicht ganze Queue)
- Queued-Jobs bleiben in DB als stale → User kann nach Restart re-triggern
- Timeout 15 min (900s) — ein LLM-Call dauert max ~120s
- stop_grace_period: 15m in docker-compose
- get_queue_status() meldet shutting_down für UI-Feedback
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
reconstruct_zitate droppt Zitate nicht mehr bei No-Match, sondern
markiert sie als verified=false. Das ist ehrlicher: paraphrasierte
Zitate sind wertvoller Kontext, sie brauchen nur ein visuelles
Unterscheidungsmerkmal.
UI:
- Verifizierte Zitate: grüner solid Border, "✓ verifiziert"
- Paraphrasierte Zitate: gelber dashed Border, "~ paraphrasiert
(nicht wörtlich im Programm)"
- Warning-Text: "Zu diesem Themenkomplex konnten keine konkreten
Formulierungen im Wahlprogramm gefunden werden"
- Antragsteller:in / Landesregierung als farbige Badges
Zitat-Model: neues Optional[bool] Feld "verified".
Tests: 206 passed (test_drops angepasst auf neues Verhalten).
Fußzeile unter jedem Assessment-Detail jetzt mit:
- Bewertungsdatum ("Bewertet am DD.MM.YYYY") aus updated_at
- Quelle + Modell (batch-reanalyze / webapp, qwen-plus)
- "Neu bewerten"-Button (Auth-pflichtig, ausgegraut ohne Login)
Flow: Klick → DELETE /api/assessment/delete → POST /api/analyze-drucksache
→ Queue → pollAnalysis → Detail neu laden
Neuer DELETE-Endpoint /api/assessment/delete mit require_auth.
API-Response erweitert um updatedAt, source, model für beide
Endpoints (list + single assessment).
Tests: 206 passed.
Refs: #97
Befund #4 aus dem Security-Audit (PII/LLM-Content im Container-Log):
Die letzten 10 print()-Aufrufe in app/{report,embeddings,parlamente}.py
durch strukturiertes Logging (logger.warning/exception/info) ersetzt.
Betroffen:
- report.py: 2× print in _append_original_antrag → logger.exception
- embeddings.py: 3× print in index_programm → logger.warning/info/exception
- parlamente.py: 5× print in NRWAdapter → logger.error/exception
logger.exception statt print+traceback: Stack-Trace wird automatisch
angehängt, ohne den LLM-Content oder Antrags-Details als Volltext zu
leaken (nur die Drucksache-ID als Kontext-Parameter).
Audit-Status nach diesem Commit: alle 7 adressierbaren Befunde aus #57
sind gefixt (1 Rate-Limit, 2/6 XSS/XXE, 3 Path-Traversal, 4 PII-Log,
5 CSRF via Auth, 7 Search-DoS). Befund 8 (Secrets als ENV) ist
akzeptiertes Risiko für Single-Server-Docker.
Tests: 201 passed, 5 skipped.
Bug 1 — NRW-Titel "Drucksache XX/YYYYY":
NRW's get_document machte nur HEAD-Request auf die PDF-URL und gab
title="Drucksache 18/18085" zurück — keinen echten Titel. Fix: nutzt
jetzt search(drucksache) um den echten Eintrag von OPAL zu holen.
Fallback: leerer Titel statt generischer, damit der LLM-Titel nicht
überschrieben wird. Plus _pick_best_title Helper: doc.title nur
übernehmen wenn es ein echter Titel ist (nicht "Drucksache XX").
Bug 2 — Nur Antragsteller im Passungsprofil, keine Regierungsfraktionen:
Der LLM ignorierte die "UND Regierungsfraktionen"-Anweisung im Prompt.
Fix: explizite PFLICHT-FRAKTIONEN-Zeile im User-Prompt:
"Du MUSST folgende Fraktionen in wahlprogrammScores bewerten: SPD, CDU, GRÜNE"
(dedupliziert aus fraktionen + regierungsfraktionen).
Tests: 194/194 grün.
Batch-Re-Analyse muss nochmal laufen mit den Fixes (21 bereits fertig,
15 noch offen — werden alle erneut benötigt weil die Titel/Fraktionen
in den neuen Assessments falsch sind).
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
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
Wenn search_for den Zitat-Text in keiner Seite findet (Pre-#60
halluzinierte Snippets die nie im PDF standen), wird jetzt statt
stilles Nicht-Highlighting eine sichtbare FreeText-Annotation am
Seitenkopf platziert: "Textstelle nicht im Dokument auffindbar —
das Zitat wurde möglicherweise vom LLM paraphrasiert."
Damit versteht der User sofort warum kein Gelb-Highlighting da ist.
Die echte Lösung ist Re-Analyse mit der neuen Pipeline (reconstruct_
zitate erzeugt verifizierte Zitate), aber bis dahin ist die Notiz
der ehrliche UX-Fallback.
Tests: 194/194 grün.
Refs: #47
User-Feedback: "Kontext geht verloren wenn nur 1 Seite kommt".
Änderung: render_highlighted_page liefert jetzt das GESAMTE Wahlprogramm-
PDF mit gelber Highlight-Annotation auf der Fundstelle, statt eines
1-Seiten-Auszugs. Der Browser öffnet das vollständige Programm.
Frontend hängt #page=N an die URL → Browser scrollt direkt zur
Fundstelle. found_page wird als X-Found-Page Header mitgeliefert,
falls der Text auf einer anderen Seite als angefordert gefunden wurde
(Pre-#60 halluzinierte Seitennummern).
Return-Typ geändert: (bytes, int) statt bytes — zweiter Wert ist die
1-indexed Seitennummer wo der Treffer tatsächlich liegt.
Tests angepasst: Tuple-Unpacking, Size-Check entfernt (volles PDF ist
größer als 1-Seiten-Extract, der alte Vergleich war obsolet).
Refs: #47
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
Problem: Alle Assessments in der Prod-DB haben Pre-#47-URLs
(/static/referenzen/X.pdf#page=N). Die _chunk_pdf_url-Änderung wirkt
nur auf NEUE Analysen, die noch nicht stattgefunden haben.
Fix (zwei Seiten):
1. Endpoint /api/wahlprogramm-cite akzeptiert jetzt auch pdf=<filename>
als Alternative zu pid=<programm_id>. Reverse-Lookup über PROGRAMME-
Registry: pdf-Filename → programm_id. Damit können die statischen
URLs aus Pre-#47-Assessments trotzdem an den Cite-Endpoint geleitet
werden.
2. Frontend: neue JS-Funktion makeCiteUrl(z) die JEDE Zitat-URL on-the-
fly umschreibt:
- /static/referenzen/X.pdf#page=N + z.text
→ /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=<urlencoded text>
- /api/wahlprogramm-cite?... → durchreichen (schon Cite-URL)
- Fallback: URL unverändert
Funktioniert retroaktiv für ALLE ~31 Assessments in der DB, ohne
Re-Analyse. Sobald ein User auf ein Zitat klickt, wird die Seite
des Wahlprogramms mit gelber Markierung gerendert.
Tests: 194/194 grün.
Refs: #47
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
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
Stub durch echten Adapter ersetzt. Recon + Implementierung in einem
Wurf, weil das Backend deutlich freundlicher ist als bei SL/NI:
- Server-side rendered HTML, keine SPA, keine Auth, keine Cookies
- TYPO3 mit ext-solr unter /parlament/dokumente/drucksachen
- Filter direkt als URL-Query-Params (q, dokumentenart, wahlperiodeid[],
sort, anzahl_treffer, page)
- 17.598 Drucksachen in WP19, davon ~10-15% Anträge — wir holen pro
Page 100 Hits, paginieren bis 3 Pages und filtern client-seitig auf
<p>Antrag …</p> (analog zu SL/HE)
Pattern-Extraktion über drei Regexen aus dem stabilen result-block:
<div class="row result">
<h4><a href="…pdf">Drucksache Nr. 19/<NR> vom DD.MM.YYYY</a></h4>
<p>Antrag <FRAKTION>[, <FRAKTION2>]</p>
<h5><strong>TITLE</strong></h5>
</div>
Drucksachen-Lookup: q=<drucksache> matched die Nummer im Volltext und
liefert sie als einzigen Hit — wie bei SL und HB, kein dedizierter
GetById-Endpoint nötig.
Smoke-Test im Container:
search("Schule", 5) → 5 Anträge in WP19 (SPD/FW-BAYERN+CSU/GRÜNE/AfD/AfD)
get_document(19/11388) → match
download_text(19/11388) → 4694 chars echter Antrags-Volltext
search("", 5) → 5 newest Anträge mit korrektem date-DESC sort
Free-Voters-Disambiguation funktioniert über den #55 Parteinamen-Mapper:
"FREIE WÄHLER" auf Bayerns Liste wird zu "FW-BAYERN" canonicalized
(separat von "FREIE WÄHLER" in RP und "BVB-FW" in BB).
Tests: 185/185 grün.
UI-Aktivierung erfolgt separat in #35 (blockiert auf diesem Commit
und auf den BY-WP19-Wahlprogrammen — CSU, GRÜNE, AfD, SPD, FDP, FW).
Refs: #23, #49 (Roadmap Phase 3)
Reverse-Engineering aus HAR-Capture (User-Browser, /suche?searchValue=Schule):
- Endpoint: POST /umbraco/aawSearchSurfaceController/SearchSurface/GetSearchResults/
- Content-Type: application/x-www-form-urlencoded; charset=UTF-8 mit rohem
JSON im Body (Kendo-Konvention von $.ajax ohne expliziten contentType)
- Body MUSS Sections={} und Sort={} als leere Dicts haben — sobald
Sections.Print/etc. gesetzt sind, antwortet der Server mit HTTP 500
(eigene Stunden in der Sackgasse, bis HAR den minimalen Body zeigte)
- Body-Schema: {Filter:{Periods:[17]}, Pageination:{Skip,Take}, Sections:{},
Sort:{}, OnlyTitle:false, Value:<query>, CurrentSearchTab:0}
Response-Mapping (FilteredResult[*]):
- DocumentNumber → drucksache (e.g. "17/11")
- Title → title
- DocumentType → typ; client-side gefiltert auf "Antrag" (Print-Section
enthält Anfragen + Anträge + Gesetzentwürfe gemischt, ~30-50% sind Anträge)
- Publisher (kollektive Anträge: "CDU"/"SPD") + DocumentAuthor
(individuelle MdL: "Name, Vorname (CDU);…") via parteien.extract_fraktionen
- PublicDate (ISO mit T-Suffix) → datum (auf 10 Zeichen abgeschnitten)
- FilePath: ``/file.ashx?FileId=…&FileName=…`` ist ein HTML-Iframe-Wrapper
(455 Bytes), nicht das PDF! Echter Binär-Endpoint ist
``/Downloadfile.ashx`` (Großbuchstabe!) mit denselben Query-Parametern.
Der Wrapper hat mich beim ersten Smoke-Test mit "no objects found"
angeschmissen, der Iframe-Hint im HTML hat den Trick verraten.
Drucksachen-Lookup nutzt ``Value=<drucksache>``: der Server matcht die
Nummer im Volltext und liefert sie zuverlässig als ersten Hit. Kein
dedizierter GetById-Endpoint vorhanden.
Smoke-Test gegen prod (im Container):
- search("Schule", limit=5) → 2 Anträge in WP17 (140 Print-Hits gesamt,
Antrag-Filter auf 2/140 — der Rest sind Anfragen/Gesetzentwürfe):
17/11 [CDU] "Schule als Lern- und Bildungsort weiter stärken …"
17/419 [AfD] "Eine gute Bildungspolitik als wesentlicher Bestandteil …"
- get_document("17/11") → match
- download_text("17/11") → 3520 chars echter Antrags-Volltext (Header,
Fraktion, Resolutionstext)
Tests: 185/185 grün (keine Regression).
UI-Aktivierung erfolgt separat in #31 (blockiert auf diesem Commit).
Refs: #19, #49 (Roadmap Phase 3)
Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen
Halluzinations-Case gezeigt, den A+C nicht gefangen hat:
BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche
im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des
Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen
Q-IDs.
Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im
Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle
aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern,
solange wir der LLM-Selbstauskunft vertrauen.
Fix (Option B aus dem ursprünglichen Plan):
`embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im
analyzer **nach** json.loads aber **vor** Pydantic-Validation:
1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste.
2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks
matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`,
identische Logik wie Sub-D Test).
3. Match → quelle/url server-seitig durch _chunk_source_label und
_chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN.
4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert).
Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt
keinen Pfad mehr zu "echter Text, falsche quelle".
Tests:
- TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei
hallucinated, no-op bei leeren chunks, anchor-match-Fallback,
short-needle und soft-hyphen Edge-Cases
- 185/185 grün (179 + 6 neu)
Refs: #60, #54 (Sub-D)