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.
Adapter-Capabilities-Matrix (#93) erweitert um detailliertes Feld-
Mapping: Pro Adapter welches API-/HTML-/JSON-Feld zu welchem
Drucksache-Feld wird (title, datum, fraktionen, drucksache, link, typ)
mit konkreten Beispielwerten. 12 Adapter-Sektionen.
ADR 0005: Keycloak SSO mit Dev-Bypass — dokumentiert die Entscheidung
für Read/Write-Trennung (GET offen, POST mit JWT) und den Dev-Modus
(Auth deaktiviert wenn KEYCLOAK_URL nicht gesetzt).
Auth-Tests: 7 neue Tests für Token-Extraction, Auth-Enabled-Detection,
_pick_best_title (letztere skipped wenn slowapi nicht installiert).
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
caddy-gitea-pages verworfen — dessen URL-Schema ({owner}.{domain}/{repo})
passt nicht für Single-Project-Hosting ohne DNS-Wildcards auf Sub-Sub-
Domains. Stattdessen simples nginx:alpine mit statischem Volume-Mount.
URL: https://docs.toppyr.de/gwoe-antragspruefer/
Der nginx-Container mounted docs-site/ nach
/usr/share/nginx/html/gwoe-antragspruefer/ — Traefik routet alles auf
Host docs.toppyr.de an den Container, nginx served den Pfad 1:1.
Skaliert für weitere Repos: einfach ein zweites Volume-Mount für
/usr/share/nginx/html/anderes-repo/ einrichten.
SSL: Traefik, nicht nginx/caddy.
DNS: *.toppyr.de Wildcard deckt docs.toppyr.de ab.
Update-Workflow:
cd webapp && mkdocs build
scp -r site/* vserver:/opt/gwoe-antragspruefer/docs-site/
Caddyfile.docs entfernt (war caddy-gitea-pages-spezifisch).
Refs: #62
mkdocs Material-Theme konfiguriert (mkdocs.yml). Build-Output wird in
den gh-pages-Branch gepusht, von dort served caddy-gitea-pages den
statischen Content als separater Container unter docs.gwoe.toppyr.de.
Neuer docker-compose-Service gwoe-docs:
- Image: ghcr.io/d7z-project/caddy-gitea-pages:nightly
- Liest automatisch aus dem gh-pages-Branch via Gitea-API
- Traefik-Labels für docs.gwoe.toppyr.de (SSL via Let's Encrypt)
- Token via GITEA_TOKEN in .env (bereits auf dem Server hinterlegt)
Wildcard-DNS *.toppyr.de zeigt bereits auf den VServer — kein
DNS-Eingriff nötig, Traefik + Let's Encrypt erledigen den Rest.
Doku-Update-Workflow:
1. ADR oder docs/ editieren
2. `mkdocs build` lokal
3. `git checkout gh-pages && cp -r site/* . && git add -A && git commit && git push`
4. caddy-gitea-pages refreshed automatisch
.gitignore: site/ ausgeschlossen (Build-Artefakt).
Refs: #62 (Phase 2)
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
Architektur-Entscheidung aus Issue #62: Diátaxis-Framework für Doku-
Pflege ohne Drift. Pflege im Repo, ADRs immutable, Stale-Snapshots
explizit als Archiv markiert.
Phase 1 — Architecture Decision Records:
- docs/README.md — Diátaxis-Index, Erklärung was wo dokumentiert wird
- docs/adr/README.md — ADR-Workflow + Index
- docs/adr/template.md — Vorlage für neue ADRs
- docs/adr/0001-llm-citation-binding.md — Issue #60 Doppel-Fix-Story
(A=ENUM-Anker, B=server-seitige Rekonstruktion, warum Option C verworfen)
- docs/adr/0002-adapter-architecture.md — ParlamentAdapter-Basisklasse
+ Registry, Klassen vs. Strategy vs. Modul-pro-Adapter
- docs/adr/0003-citation-property-tests.md — Sub-D Strategie, warum
Property-Test gegen echte PDFs statt Schema-Tests oder Online-Verify
- docs/adr/0004-deployment-workflow.md — Docker-Compose + Volumes
Standard-Workflow + SN-XML-Sonderpfad + Container-UTC-Gotcha
Phase 3 — Stale Doku archiviert:
- DOKUMENTATION.md (24.März, Skript-Architektur vor Webapp-Migrate)
→ docs/archive/DOKUMENTATION-2026-03-24.md
- STATUS-2026-03-28.md (Tagesstand-Snapshot)
→ docs/archive/STATUS-2026-03-28.md
- README.md (28.März, listet nur NRW-Adapter, vor 16 weiteren BLs)
→ docs/archive/README-2026-03-28.md
- docs/archive/README.md erklärt warum die Files da sind und warum
niemand sie überschreiben oder ersetzen sollte
Plus neue Top-Level-README.md im Project-Root (außerhalb git, da
project-root kein Repo ist) als Folder-Index für den User.
CLAUDE.md ergänzt um Doku-Sektion mit Verweis auf docs/adr/.
Phase 2 (mkdocs Setup) folgt separat — braucht eine Docker-Image-
Erweiterung, die ich nicht autark einrollen will ohne Decision.
Tests: 194/194 grün (keine Code-Änderung).
Refs: #62
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)