Beobachtung beim Force-Regen: alle 2 Retries scheiterten mit
"Invalid control character at: line 3 column 275". qwen-max produziert
JSON mit rohen \n statt \\n im body-String, was json.loads sprengt.
Zwei Fixes parallel:
**1. response_format={"type": "json_object"}** als optionaler Mode im
LlmRequest. PM-Generator setzt das jetzt. DashScope unterstuetzt das
fuer qwen-max + qwen-plus und zwingt valide JSON-Strings.
**2. Newline-Recovery als Fallback** im QwenBewerter:
`_recover_unescaped_newlines` iteriert char-weise mit String-Tracking,
ersetzt unescaped \n/\r/\t in Strings durch \\n/\\r/\\t. Backslash-
Folgen bleiben unangetastet. Wird vor dem Retry-Re-throw versucht.
Bewertungs-Pfad (analyzer.py) bekommt json_object_mode=False als Default,
um die bewaehrte Retry-Semantik nicht zu aendern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: Template erwartete data.running, data.queued, data.failed.
API liefert aber data.jobs (mit status-Feld pro Job). Daher waren
alle drei Tabellen IMMER leer, selbst bei laufenden Jobs.
Fix:
- jobs nach status filtern (running | queued/pending | completed | failed)
- Neue Sektion "Zuletzt abgeschlossen" — vorher gar nicht angezeigt
(20 completed Jobs auf dev waren unsichtbar)
- 4. Stat-Kachel "Abgeschlossen (Total)" mit data.processed_total
- Konfig-Info-Zeile: workers_running, max_size, avg_job_duration_seconds,
estimated_wait_seconds — alles vorher ungenutzt im API-Response
- Spalte "Gestartet" → "Dauer (s)" (Daten-mismatch, started_at gibt's
im API nicht)
- Wartende Jobs: bundesland-Spalte raus (nicht im API), durch
Job-ID-Kurzform ersetzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Topbar zeigt jetzt:
- Username (wie bisher)
- "ADMIN"-Badge (teal) wenn user.roles enthaelt 'admin' oder 'gwoe-admin'
- Tooltip mit allen Rollen beim Hover
Macht sichtbar, ob man Admin-Rechte hat — wichtig fuer Sichtbarkeit
von /v2/batch und /v2/admin/* Eintraegen.
Plus: Rolle gwoe-admin in Keycloak (Realm collaboration) angelegt
+ User tobias zugewiesen. Auth-Code prueft realm_access.roles auf
'admin' ODER 'gwoe-admin'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Feedback: "Welche Meldungen werden da angezeigt? Es wurden ja viel
mehr indiziert."
**1. Transparenz-Banner im News-Tab**
Zeigt jetzt explizit:
- "X News angezeigt"
- "Y News im Zeitraum (mit Embedding)"
- "Z News insgesamt embedded"
- Hinweis wenn only_relevant aktiv ist
- Hinweis wenn top_k limitierend ist
**2. Chart als Filter** — Klick auf einen Tag im News-Volumen-Chart
wechselt zum News-Tab und filtert auf diesen Tag.
- Chart bekommt onClick-Handler ueber getElementsAtEventForMode
- Cursor wechselt bei Hover ueber Datenpunkte
- Im News-Tab erscheint Pill "Tag: 2026-05-01 [× Tag-Filter entfernen]"
**3. Backend `single_date`-Param**
`aggregate_top_themen(single_date="YYYY-MM-DD")` filtert auf genau
diesen Tag (overrides days_window). Endpoint: `/api/aktuelle-themen/top
?date=YYYY-MM-DD`. Response neu: `n_in_window`, `n_shown`,
`filter.single_date`.
**4. Default top_k 20 → 50** (max 200), damit weniger oft auf
"top_k limitierend" gestoßen wird.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Feedback nach Live-Test: PMs waren kuerzer + nicht anschaulicher.
Im Output stand "Score von 4,0/10", "in den Bereichen Buerger:innen,
Wirtschaft, Staat, Gesellschaft und Natur" (Matrix-Zeilen D+E),
"staerkt Solidaritaet, Wuerde und Demokratie" (GWÖ-Werte-Liste),
Floskeln wie "innovative Loesungen" und "faktenbasierter Dialog".
Komplett-Refactor:
**ABSOLUT VERBOTEN im PM-Text:**
- Numerische Scores ("GWÖ-Score 4/10", "X von 10 Punkten")
- GWÖ-Wert-Listen als Aufzaehlung
- Beruehrungsgruppen-Sprache ("Bereiche Buerger, Wirtschaft, Staat, ...")
- Matrix-Codes ("Feld D2", "A1")
- GWÖ-Begriffe als Schlagwort (max 1× pro Begriff, nur konkret)
- Floskeln (zukunftsweisend, innovativ, faktenbasierter Dialog, ...)
**PFLICHT: Mindestens 3 Buerger:innen-Lebenslagen mit konkreter Wirkung:**
- Familien mit Kindern (Beträge, KiTa-Plätze)
- Pflegebeduerftige + Angehoerige (Wartezeiten, Kosten)
- Auszubildende / Studierende (Abbruchrisiko, BAföG)
- Pendler:innen (Spritpreis, ÖPNV-Tarif)
- Mieter:innen (Mietniveau, Nebenkosten)
- Rentner:innen / Geringverdiener:innen (Kaufkraft in Euro)
- Selbststaendige / kleine Betriebe (Buerokratie-Stunden, Steuern)
Pro Lebenslage: konkreter quantifizierter Effekt
("verlaengert Wartezeit auf Heimplatz von 8 auf 12 Wochen",
"spart einer vierkoepfigen Familie etwa 1.800 €/Jahr").
**Few-Shot:** Schlechtes Beispiel + Gutes Beispiel im Prompt.
Das gute Beispiel zeigt 30%-Abbrecherquote, 2 Stunden Beratung,
800 zusaetzliche Pflegekraefte in 5 Jahren — konkret quantifizierte
Wirkungen aus echten Zahlen.
**Laenger:** 320–380 Worte (vorher 220–280) — konkrete Beispiele
brauchen Platz.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**1. Default min_similarity 0.40 statt 0.50.** Live-Test auf dev:
mit 0.50 zeigt only_relevant=true 0 buckets, weil zu strikt fuer die
aktuelle Sparse-Datenlage (77 Bewertungen × 30 News). Mit 0.40 bleiben
1 high + 2 mid News pro 7-Tage-Fenster — genau die kuratierte Sicht,
die wir wollen.
**2. PM-System-Prompt umgeschrieben** als Pressereferent statt
Redakteur. User-Wunsch: "Bürger:innen anschaulich machen, was sich
durch den Antrag konkret im Leben vor Ort aendert".
Pflicht-Elemente im neuen Prompt:
- Konkrete Alltagswirkung (mindestens 2 Beispiele aus Lebenslagen:
Pflegekraefte, Familien, Mieter:innen, Pendler:innen, ...)
- GWÖ-Verbesserungspotential bei nicht voll ueberzeugenden Antraegen
(was fehlt, wie ginge es besser aus GWÖ-Sicht)
- Bei negativen Antraegen: klar benennen was verschlechtert wird,
konkret quantifiziert wo moeglich
- 220–280 Worte (vorher 200–250)
- Aktive Verben, kurze Saetze, keine Floskeln
- Strukturierter Aufbau: Lead → Beispiele + GWÖ-Bewertung →
Verbesserungspotential → Forderung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Feedback nach Live-Test:
**1. Idempotenz** — Pressemitteilungen wurden ungespeichert generiert,
doppelter Klick erzeugte doppelten Draft + LLM-Kosten.
- Neuer Helper `_find_existing_draft(drucksache, news_url)` der den
neuesten Draft fuer das Paar zurueckgibt
- `generate_draft()` prueft per Default zuerst den Lookup, liefert
existing zurueck mit `_was_existing=True` (kein LLM-Call)
- `force=True` Parameter fuer bewusste Neu-Generierung
- Endpoint nimmt `?force=true` Query-Param entgegen
- UI: Modal zeigt klar "Bestehender Entwurf vs Neu generiert" Banner,
mit "Neu generieren"-Button im existing-Banner
**2. Premium-Modell statt Default** — User wollte hoehere Sprachqualitaet
("Opus oder sowas"). Da das Projekt Qwen via DashScope nutzt (kein
Anthropic), Wechsel auf `settings.llm_model_premium` (qwen-max).
- Tradeoff: ~3× teurer (~6 Cent statt 2 Cent) und ~2× langsamer
(~30 s statt 15 s) — aber spuerbare Qualitaetsverbesserung in
Pressemitteilungs-Diktion
- confirm-Dialog im Frontend nennt jetzt 6 Cent + 30 s
**3. Wrapper-Verbesserungen** — `auto-fetch-news.sh` aufgeraeumt:
- Container-Check (skip wenn down) analog zu run-digest.sh
- START/END-Timestamps
- Ausfuehrliche cron-install-Doku im Header
- Auto-Backfill: wenn erster Run >= 100 Embeddings (Limit gehit),
wird embed_pending_articles bis zu 500 weitere nachgeholt
Tests: 5 neue (idempotency, force, _find_existing_draft × 3). Suite
1053 gruen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stimm-Index pro Fraktion über Quartale. Linien-Chart pro Fraktion,
Lücken bei Quartalen mit n<3 (Ja UND Nein). Macht sichtbar, ob sich die
Gemeinwohl-Affinität einer Fraktion innerhalb der Wahlperiode verschiebt.
- `_quarter_for(datum)` Helper: ISO-Datum → "YYYY-Qn".
- `aggregate_stimm_index_zeitreihe()` analog zu pro_wert/pro_gruppe,
aber nach Quartal-Bucket statt Achse.
- `GET /api/auswertungen/stimm-index-zeitreihe?parteien=CDU,SPD,...`
- 4. Sub-Section im Stimmverhalten-Tab: Multi-Linien-Chart mit
Partei-Farben (CDU schwarz, SPD rot, GRÜNE grün, FDP gelb, AfD blau,
LINKE pink, BSW lila, SSW navy, BVB-FW orange).
Bei aktueller Sparse-Datenmenge (35 Assessments × 4 Quartale) ist der
Chart heute meist leer — Infrastruktur ist ready, fuellt sich automatisch
mit Issue #44 Batch-Bewertung.
Tests: 10 neue (4 _quarter_for, 6 aggregate). Suite jetzt 1005 grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase-2-Erweiterungen des Stimmverhalten-Tabs:
**1. Empfehlungs-Konsistenz (#167):**
Pro Fraktion: Anteil der Anträge mit GWÖ-Empfehlung
"Uneingeschränkt unterstützen" oder "Unterstützen mit Änderungen",
bei denen die Fraktion trotzdem NEIN gestimmt hat. Orthogonal zur
Heuchelei-Quote — prüft NICHT gegen Wahlprogramm-Treue, sondern gegen
die GWÖ-Empfehlung des Systems.
- `aggregate_empfehlungs_konsistenz()` in app/auswertungen.py
- `GET /api/auswertungen/empfehlungs-konsistenz`
- 5. Chart-Sub-Section im Stimmverhalten-Tab (rote Bar Chart, 0..100%)
**2. CSV-Export (Phase-1-Querschnitts-TODO):**
Long-Format-CSV mit Spalten: drucksache, bundesland, wahlperiode, datum,
gwoe_score, empfehlung, partei, vote, ist_antragsteller. Macht alle
Stimmverhalten-Aussagen wissenschaftlich auswertbar (R/pandas/Excel).
- `export_stimmverhalten_csv()` in app/auswertungen.py
- `GET /api/auswertungen/stimmverhalten.csv` mit
Filter-Parametern bundesland/wahlperiode/exclude_antragsteller
- "CSV-Export"-Button im Stimmverhalten-Tab neben dem Toggle
**Tests:** 27 Stimmverhalten-Tests (war 18, +4 Empfehlungs-Konsistenz,
+5 CSV-Export). Fixture um `empfehlung`-Spalte erweitert.
Suite: 989 Tests grün (war 980).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neue Auswertungs-Sicht: Welche Fraktionen stimmen häufiger gemeinwohl-
orientierten Anträgen zu? Verschneidet GWÖ-Bewertung pro Antrag mit
dem tatsächlichen Plenum-Stimmverhalten der Fraktionen.
Vier Aussagen, alle hinter dem neuen Tab "Stimmverhalten":
1. **Gemeinwohl-Stimm-Index** pro Fraktion: Ø-GWÖ-Score der JA-Anträge
minus Ø-GWÖ-Score der NEIN-Anträge. Domain −10..+10. Positiv = stimmt
eher Gemeinwohl-affinen Anträgen zu.
2. **Heuchelei-Quote** pro Fraktion: Anteil der Anträge mit
wahlprogramm_score ≥ 7 (passt zum eigenen Wahlprogramm), bei denen
die Fraktion trotzdem NEIN gestimmt hat.
3. **Stimm-Index pro GWÖ-Wert** als Heatmap: 5 Spalten (Würde,
Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie) aus den
gwoe_matrix-Suffix-Spalten. Domain −5..+5 pro Zelle.
4. **Cross-BL-Vergleich** als Grouped Bar: gleiche Fraktion in
mehreren Ländern. Nur Fraktionen in ≥2 BL mit ausreichender
Datenbasis.
Querschnitt:
- `exclude_antragsteller=True` per Default (Toggle-Checkbox in UI),
weil Antragsteller-Fraktionen quasi immer JA stimmen → würde Index
verzerren. Toggle macht den Effekt sichtbar.
- `min_n=5` pro Fraktion fuer Stimm-Index, n=3 fuer Heatmaps.
Fraktionen unter dem Cutoff werden als "Nicht aussagekräftig" separat
gelistet.
- Caveat-Banner mit `n_assessments_matched` über jedem Chart.
Implementation:
- `app/auswertungen.py`: `_load_assessments_with_votes()` JOIN-Helper
+ 4 Aggregat-Funktionen analog zu `aggregate_matrix`-Pattern.
Reuse: `normalize_partei` für Aliasing (BÜNDNIS 90/DIE GRÜNEN →
GRÜNE), `wahlperiode_for` für WP-Filter.
- `app/main.py`: 4 neue read-only GET-Endpoints unter
`/api/auswertungen/stimm-index|heuchelei|stimm-index-pro-wert|
stimm-index-cross-bl`.
- `app/templates/v2/screens/auswertungen.html`: 4. Tab "Stimmverhalten"
mit 4 Sub-Sektionen, Chart.js Bars + HTML-Heatmap-Tabelle.
- `tests/test_auswertungen_stimmverhalten.py`: 18 neue Tests
(Fixture-DB mit 13 Assessments + 13 Vote-Results, Edge-Cases:
GRÜNE-positiver-Index, AfD-negativer-Index, exclude_antragsteller-
Effekt, min_n-Cutoff, leere DB).
Sparse-Data-Realität: aktuell 35 Assessments im prod, dünne Datenbasis
fuer einige Fraktionen. Feature wächst mit Issue #44 Batch-Bewertung.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Saarland publiziert keine Wortprotokolle, sondern eigene HTML-Seiten
mit strukturierten Abstimmungsergebnissen pro Sitzung:
<p>Drucksache 17/2076 ... in Erster Lesung mit Stimmenmehrheit
angenommen ... [SPD: dafür; CDU und AfD: dagegen]</p>
Daher Input ist HTML, nicht PDF. Parser nutzt LI-Block-Iteration und
extrahiert pro Block:
- Drucksache aus "Drucksache N/M"
- Status aus "(einstimmig|mit Stimmenmehrheit)? (angenommen|abgelehnt)"
- Vote-Block aus "[SPD: dafür; CDU: dagegen; AfD: Enthaltung]"
- einstimmig=True falls Status enthaelt "einstimmig"
Vote-Bracket-Parser (eigenstaendig vs. Reden-Stil-Parser anderer BL):
- Splits per ; → "Phrase: Status"
- Phrase per Wortgrenzen-Regex auf {SPD,CDU,AfD} matchen
- Status-Map: dafür→ja, dagegen→nein, Enthaltung→enthaltung
URL-Pattern (nicht direkt vorhersagbar wegen Datums-Slug):
https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/
Auto-Ingest via Index-Scrape (analog HH/HE/SH):
- /aktuelles/mitteilungen/ scrape
- WP16-URLs (mit "wahlperiode-vom") ueberspringen
- Pro neue Sitzung: HTML herunterladen, ingest_pdf-API auf .html-Datei
Tests: 18 SL-Tests (Verifikation Sitzung 46 → 18 Votes mit korrekten
JA/NEIN/ENTH-Listen). Stand: 9 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifiziert auf WP20 Sitzungen 115 + 116. Format ist TH-aehnlich:
Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen|so beschlossen)"
Vote-Block (Q+A im Reden-Stil):
- JA: "Wer dem zustimmen will ... Das sind die Fraktionen von X"
- NEIN: "Wer stimmt dagegen? ... Das sind die Fraktionen von Y"
- ENTH: "Wer enthaelt sich? ... Z"
Drucksachen-Lookup: rueckwaerts vom Anchor
Besonderheiten:
- SSW (5%-Huerden-befreit) als feste Fraktion
- "Damit ist die Ausschussueberweisung einstimmig so beschlossen" → ergebnis="ueberwiesen"
- "Das sind alle anderen Fraktionen" → NEIN als Komplement von JA inferiert
- Soft-Hyphen-Reparatur (PDF-Zeilenumbruch "zustim- men" → "zustimmen")
- _last_match-Helper, weil 1500-char-Window mehrere Vote-Bloecke enthalten kann
(TH-Limitierung gefixed)
URL-Pattern (verifiziert):
https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf
Datum-Anteile (YYYY-Pfad + MM-YY-Suffix) machen URL-Vorhersage unmoeglich
→ Auto-Ingest-Cron via Index-Scrape (analog HH/HE):
https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/
Tests: 23 SH-Tests + Stub-Registry-Test angepasst.
Stand: 7 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fuenfter produktiver Parser nach NRW + BUND + BE + HH.
URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42):
https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf
Anchor-Sprache (BE-aehnlich):
Wer dem zustimmt, ... Das sind die Stimmen aus den Fraktionen der
CDU, BSW, SPD und Die Linke. Wer stimmt gegen ...? Das sind die
Stimmen aus der Fraktion der AfD. Damit ist [...] mehrheitlich
angenommen.
Pattern:
- Result-Anchor: Damit ist [Subjekt] (mehrheitlich|einstimmig)?
(angenommen|abgelehnt)
- Vote-Block: Wer dem zustimmt / Wer stimmt gegen / Wer enthaelt sich
- Drucksachen-Lookup: 'Drucksache 8/N' rueckwaerts
Fraktions-Mapping WP8 (ab Mai 2024): CDU, AfD, BSW, Linke, SPD
(WP7-Faktionen GRUENE/FDP fuer Backfill ebenfalls im Mapping).
Cron-PROTO_TARGETS um TH-WP8 erweitert. Stub-Test angepasst.
Vierter produktiver Plenarprotokoll-Parser nach NRW + BUND + BE.
Hamburg publiziert kompakte Beschlussprotokolle (Tabellen-Form mit
Vote-Block pro Beschluss):
... mehrheitlich mit den Stimmen der SPD und GRUENEN gegen die
Stimmen der CDU und AfD bei Enthaltung der Linken angenommen
Pattern:
- einstimmig (angenommen|abgelehnt) — alle Fraktionen
- mehrheitlich mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z
(angenommen|abgelehnt)
Fraktions-Mapping WP23: SPD, GRUENE, CDU, AfD, Linke
URL-Discovery laeuft ueber die Protokoll-Liste der Buergerschaft
(Blob-IDs via Index-Page-Scrape). Cron-Eintrag erst sobald
URL-Discovery-Skript hier integriert ist.
Stub-Test angepasst (HH raus aus STUB_BL_CODES).
Dritter vollwertiger Plenarprotokoll-Parser nach NRW + BUND.
URL-Pattern verifiziert (WP19 Sitzungen 1, 10, 50, 80, 100):
https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf
Anchor-Sprache (NRW-aehnlich, mit Berliner-Eigenheit 'pro forma'):
Wer den Antrag auf Drucksache 19/X annehmen moechte, ... – Das sind
die Fraktionen Buendnis 90/Die Gruenen und Die Linke.
Wer stimmt dagegen? – Das sind die Fraktionen der CDU, SPD und AfD.
Wer enthaelt sich, pro forma? – Das ist niemand.
Damit ist der Antrag abgelehnt.
Pattern:
- Result-Anchor: Damit ist [Antrag/Aenderungsantrag/Gesetzentwurf/...]
(angenommen|abgelehnt)
- Vote-Block: 3 Q+A-Paare im Reden-Stil (annehmen moechte / dagegen /
enthaelt sich)
- Drucksachen-Lookup: 'Drucksache 19/N(-suffix)' rueckwaerts (1500-char Fenster)
Fraktions-Mapping WP19:
- Buendnis 90/Die Gruenen → GRÜNE
- Die Linke → LINKE
- CDU, SPD, AfD, FDP
21 Tests in test_protokoll_parsers_be.py.
Cron-PROTO_TARGETS erweitert um BE WP19 (~80 Sitzungen).
Stub-Test angepasst.
905 Tests gruen (889 → 905, +16 fuer BE).
Vertiefte Probe (WP17 Sitzung 50): BW stimmt 'pro Artikel'
('Damit ist Artikel 1 einstimmig zugestimmt'), nicht pro Drucksache.
Das ist andere Datenmodellierung als NRW (Drucksache→Vote) und BUND
(Beschlussempfehlung→Vote). Ein BW-Parser braucht entweder:
- Aggregations-Heuristik: alle Artikel angenommen → DS angenommen
- Schema-Erweiterung um 'artikel'-Spalte fuer per-Artikel-Records
Implementer muss vor Start mit Maintainer abstimmen, welcher Weg
gegangen wird. BW bleibt Stub bis Designwahl getroffen ist.
Erste Probe (Sitzung 184) war Aussprache, daher 0 Beschluss-Anchors.
Sitzung 30 (572k chars, 5 angenommen-Anchors) zeigt die echte
BT-Vote-Sprache:
'Die Beschlussempfehlung ist mit den Stimmen der Koalitions-
fraktionen und der Fraktion Die Linke gegen die Stimmen der
CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion angenommen.'
Pattern-Erkennung:
- Anchor-Verb 'angenommen' oder 'abgelehnt' am Satzende
- Vote-Block: 'mit den Stimmen [...] gegen die Stimmen [...]
bei Enthaltung [...]'
- Fraktions-Phrasen: 'Fraktion X', 'X-Fraktion', 'Koalitionsfraktionen'
- Drucksachen rueckwaerts vom Anchor (oft 100+ Zeichen vorher)
Wichtig: BT-Anchor-Sprache ist viel laenger als NRW — Regex-Begrenzung
muss 200+ Zeichen tolerieren.
Sample-Sitzungen mit Beschluessen: WP20 30, 100, 150.
Heutige Probe von WP17 Sitzung 50 (618 KB PDF) ergab:
URL-Pattern bestaetigt:
https://www.landtag-bw.de/.../WP{wp}/Plp/{wp}_{n:04}.pdf
4-stellige Sitzungs-Nr mit Padding (anders als NRW unkpaddet)
Anchor-Phrasen-Stichprobe:
'einstimmig zugestimmt' x5 — Haupt-Anchor (NRW: 'angenommen')
'Damit ist [...] einstimmig' x2 — NRW-aehnliche Struktur
'angenommen' x1 — nur in einer Rede, KEIN Beschluss-Anchor!
'Drucksache 17/N' x35 — DS-Pattern wie NRW
'zugestimmt' x19 — dominierende Vote-Phrase
Fraktions-Auflistung pro Vote in BW deutlich weniger detailliert als
NRW — Parser wird oft nur 'einstimmig' / 'mit Mehrheit' extrahieren
koennen, kein ja/nein/enthaltung-Breakdown pro Fraktion.
Fuer den naechsten Implementer (BW-Session) wertvolle Vorarbeit.
Pro BL zeigt die Tabelle nun:
- Doku-System (wie bisher)
- Drucksachen: alle aktiv (Adapter laufen)
- Plenum-Votes: 'aktiv' wenn Parser registriert (NRW), sonst 'Stub'
Plus Erklär-Hinweis: 'Plenum-Votes = fraktions-aggregierte
Abstimmungsergebnisse aus den Plenarprotokollen (#106). Stubs sind
Tracking-Stellen fuer kuenftige Implementierungen (Issues #148-#163).'
main.py reicht supported_bundeslaender() aus protokoll_parsers an die
Template-Context durch (plenum_vote_parsers-Set).
Pro BL (BUND + 15 Laender) ein Modul app/protokoll_parsers/<bl>.py mit:
- Recherche-Findings im Docstring (Doku-System, Base-URL, Format,
URL-Discovery-Status, Familie, Aufwand-Schaetzung)
- parse_protocol() raised NotImplementedError mit Hinweis auf Issue-Tracker
- *Nicht* in PROTOKOLL_PARSERS-Registry → Auto-Ingest-Cron uebersieht sie
Tracking-Issues #148-#163 auf Gitea, jeweils mit den Recherche-Findings
und einer Checkliste fuer die Implementer-Session.
Roadmap-Doc (docs/protokoll-parser-roadmap.md) aktualisiert mit
Stub→Issue-Mapping-Tabelle.
Wenn der Implementer pro BL fertig ist:
1. NotImplementedError durch echten Parser ersetzen
2. Eintrag in app/protokoll_parsers/__init__.py::PROTOKOLL_PARSERS
3. PROTO_TARGETS in scripts/auto-ingest-protocols.sh ergaenzen
787 Tests gruen, NRW unveraendert.
User-Frage zur Transparenz-Seite: 'Welcher Prompt wird ausgefuehrt?
Der System-Prompt ist deutlich umfangreicher.' Antwort: keiner allein —
beide werden in einem API-Call zusammen gesendet und gemeinsam
ausgewertet.
Auf /methodik#prompts neu vor den details-Bloecken:
- Erklaerung 'in einem einzigen API-Call', beide ins Kontextfenster
- 2-Spalten-Tabelle 'System (Wer/wie)' vs. 'User (Was)'
- Begruendung der Trennung (Caching, Compliance, Wartbarkeit)
- Code-Referenz zu qwen_bewerter.py:83-85 mit messages-Aufbau
Reine UI-Aenderung, keine Code-Logik betroffen.
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)
Strukturelle Lösung für die LLM-Halluzinations-Cases aus #60:
A — ENUM-Anker
- format_quotes_for_prompt nummeriert jeden retrievten Chunk als [Q1], [Q2], …
- Neue ZITATEREGEL im Prompt erzwingt vier Bedingungen:
1. Jedes Zitat MUSS auf genau einen [Qn]-Chunk verweisen
2. Der text-String MUSS eine wörtliche, zusammenhängende Passage von
min. 5 Wörtern aus genau diesem Chunk sein
3. Die quelle MUSS exakt das Source-Label des gewählten Chunks sein
4. Wenn kein Chunk passt: leeres zitate-Array — lieber 0 als erfunden
- analyzer.py:get_system_prompt: Wichtige-Regeln-Block zieht den selben
Mechanismus nach, damit das LLM den [Qn]-Anker auch im System-Prompt
sieht und nicht nur im User-Prompt.
C — Recall-Boost
- analyzer.py:run_analysis: top_k_per_partei 2 → 5. In den drei Cases
aus #60 lagen die "richtigen" Seiten (S.36, S.37) bisher außerhalb
des Top-3-Windows; mit Top-5 erhöht sich die Wahrscheinlichkeit, dass
sie überhaupt im Kontext landen.
Hintergrund — die Halluzinationen waren KEIN Embedding-Bug:
Die retrievten Chunks für Case 1 enthielten S.58 (richtige Seite, falscher
Snippet) — das LLM hat den Snippet aus seinem Trainingswissen über
GRÜNE-Wahlprogramme rekonstruiert statt aus dem retrievten Chunk-Text zu
zitieren. Cases 2/3 hatten die zitierten Seiten gar nicht im Top-3-Window —
das LLM hat sowohl Seite als auch Snippet halluziniert. ENUM-Anker
verhindert beides strukturell, weil ein nicht-existenter [Qn] sofort
als Cheating sichtbar wäre.
Tests:
- test_chunks_get_enum_ids
- test_zitateregel_mentions_enum_anchor
- 179/179 grün
Refs: #60, #54 (Sub-D), #50 (Umbrella E2E)
Root cause: der #55-Refactor (eb045d0) hat in get_relevant_quotes_for_antrag
``partei_upper`` zu ``partei_lookup`` umbenannt — aber die Dict-Write-Zeile
``results[partei_upper] = ...`` wurde übersehen. Bei jedem Aufruf knallt seither
ein NameError, der in analyzer.py vom breiten ``except Exception`` verschluckt
und still auf die Keyword-Fallback-Suche umgeleitet wird. Konsequenz: 100% der
Assessments seit eb045d0 (inkl. autonomer Roadmap-Run #59) liefen ohne
Embedding-Retrieval — daher die LLM-Halluzinationen aus #60.
Fix:
- embeddings.py:528: partei_upper → partei_lookup
- analyzer.py:249: NameError/AttributeError/TypeError/KeyError nicht mehr
schlucken. Programmierfehler im Embedding-Pfad sollen hart fehlschlagen,
damit die nächste Refactor-Regression nicht wieder 24h still degradiert
läuft. Echte Network-/API-Exceptions fallen weiterhin auf den
Keyword-Pfad zurück.
- tests/test_embeddings.py: Regression-Test, der get_relevant_quotes_for_antrag
mit gemockten chunks aufruft und sicherstellt, dass die Funktion nicht
crasht und ein populiertes Result liefert. Hätte den Bug bei eb045d0
sofort gefangen.
Refs: #60, #55, #59
Reaktiviert die in Phase J vertagte Adapter-Implementation: statt
ASP.NET-Postbacks zu simulieren (blockt durch __VIEWSTATE-Komplexität
plus robots.txt: Disallow: /), liest die neue ``SNEdasXmlAdapter``-
Klasse einen wöchentlich manuell aus EDAS exportierten XML-Dump.
Workflow:
1. User exportiert in der EDAS-Suchmaske mit Filter "Dokumententyp =
Antr" einen XML-Dump (bis zu 2500 Treffer/Export, sortiert
newest-first nach Datum)
2. Datei wird unter ``data/sn-edas-export.xml`` abgelegt (ins
persistent volume des prod-containers)
3. ``search()``/``get_document()`` lesen die XML-Datei lokal — keine
Server-Calls gegen edas.landtag.sachsen.de
4. ``download_text()`` resolved die echte PDF-URL on-demand über einen
einzelnen GET gegen ``viewer_navigation.aspx`` (single GET, kein
Postback) und holt dann das PDF von ``ws.landtag.sachsen.de/images``
XML-Schema (ISO-8859-1):
- ``<ID>`` interne EDAS-Doc-ID
- ``<Wahlperiode>``, ``<Dokumentenart>``, ``<Dokumentennummer>``
- ``<Fundstelle>`` z.B. ``"Antr CDU, BSW, SPD 01.10.2024 Drs 8/2"`` —
enthält Typ, Urheber und Datum, parsen via Regex
- ``<Titel>`` Volltext-Titel
PDF-URL-Schema (extrahiert aus dem viewer_navigation.aspx onLoad-
Handler): ``ws.landtag.sachsen.de/images/{wp}_Drs_{nr}_{...}.pdf``
mit variablen Suffix-Komponenten — wir machen die Resolution lazy.
Mapper-Erweiterung:
- ``parteien.PARTEIEN``-Tabelle um ``BÜNDNISGRÜNE``/``Bündnisgrüne``
ergänzt — der Sachsen-spezifische zusammengeschriebene Eigenname der
GRÜNEN-Fraktion (sonst wären 8/2100 etc. mit leerer Fraktionen-Liste
rausgekommen)
BL-Eintrag:
- ``SN.aktiv = True``
- ``doku_system="EDAS-XML-Export"`` (klare Klassifikation, dass es
KEIN normaler Webcrawler ist)
- Test ``test_sn_is_eigensystem_not_parldok`` umbenannt in
``test_sn_uses_xml_export_not_parldok``
Live-Probe lokal:
```
search('Klima', limit=5):
8/2100 2025-03-17 | [GRÜNE] | Fahrradoffensive Sachsen ...
7/192 2019-10-11 | [LINKE] | Erste Schritte zur Klimager...
7/2067 2020-03-19 | [CDU, SPD, GRÜNE] | Sächsische Waldbesitzer ...
```
176 Unit-Tests grün. Container braucht beim Deploy einen XML-Upload
ins data/-Volume — separater scp-Schritt.
Refs: #26, #38, #59 (Phase J revived)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schließt #21 (HB-Scraper) und #33 (UI-Aktivierung). Eigenständige
``PARiSHBAdapter``-Klasse für paris.bremische-buergerschaft.de.
Backend (HAR-Trace TEMP/paris.bremische-buergerschaft.de.har):
- Single-POST gegen ``/starweb/paris/servlet.starweb`` mit
form-urlencoded Body
- ``path=paris/LISSHFL.web``, ``format=LISSH_BrowseVorgang_Report``
- ``01_LISSHFL_Themen=<query>`` (Volltext-Thesaurus)
- ``02_LISSHFL_PARL=S OR L`` (Stadt + Landtag in einem Rutsch)
- ``03_LISSHFL_WP=21`` (aktuelle Wahlperiode; Multi-WP-Range
timeout-t den Server bei 60s)
- Wildcards (``*``) timeout-en ebenfalls — bei leerer Query verwenden
wir das hochfrequente Stoppwort ``"der"`` als Catch-all
Hit-Format aus dem Single-Page-HTML:
- ``<tbody name="RecordRepeater"><tr name="Repeat_TYP">``
- Title in ``<h2><a>``
- ``Drs <b>21/730 S</b>`` mit S/L-Suffix für Stadtbürgerschaft vs
Landtag — Drucksachen-IDs werden als ``21/730S`` (ohne Space)
gespeichert
- ``Änderungsantrag vom 23.02.2026`` (Typ + Datum)
- Fraktionen-Liste nach ``<br/>``
- PDF-Link mit ``target="new"`` auf bremische-buergerschaft.de
Pipeline:
- ``search()`` mit client-side ``"antrag"``-Filter (analog #61),
fängt ``"Antrag"``, ``"Änderungsantrag"`` etc.
- ``get_document()`` linearer Lookup
- ``download_text()`` PDF-via-fitz
BL-Eintrag in ``bundeslaender.py``:
- ``HB.aktiv = True``
- ``doku_system="PARiS"`` (statt der alten Klassifikation "StarWeb" —
PARiS ist eine deutlich abweichende Servlet-Variante, kein eUI)
- ``drucksache_format="21/1234S"``
- Test ``test_hb_is_starweb_not_paris`` umbenannt in
``test_hb_is_paris_starweb_variant``, prüft jetzt auf "PARiS"
Live-Probe:
```
21/730S 2026-02-23 | [SPD,GRÜNE,LINKE] | Änderungsantrag | Haushaltsgesetze ...
21/1449 2025-11-05 | [SPD,GRÜNE,LINKE] | Antrag | Finanzierung der Bremischen Häfen
21/555S 2025-06-17 | [CDU] | Antrag | Clima-Campus zügig beantworten
```
176 Unit-Tests grün, Live-Verifikation Sub-A im Container nach Deploy.
Refs: #21, #33, #59 (Phase I)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Berlin-PARDOK ist von Schriftlichen Anfragen dominiert und liefert ohne
server-side ETYPF-Filter (BE: document_type=None) bei chunksize=100 nur
1-2 Anträge zurück. Damit reicht das Window selbst für limit=20 nicht
aus, um z.B. die A100-Antrag-Drucksache 19/2650 zu finden — und
get_document() liefert None.
Floor bewusst hoch auf 1500 angehoben (vorher 100/500). Bei einem
typischen Verhältnis 1:30 Antrag/Anfrage in BE liefert das ~50 Anträge,
genug für robuste Lookups in den letzten 24 Monaten.
176 Unit-Tests grün.
Refs: #61 Bug 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BE-Adapter hat document_type=None (eigene ETYPF-Werte werden vom
Berliner PARDOK nicht akzeptiert), wodurch der Server alle Doku-Typen
zurückliefert. Das 200-Result-Window war damit vollständig von
'Schriftliche Anfrage'-Hits aushungernd, sodass Anträge wie 19/2650 nie
ans Frontend kamen — und get_document() für genau diese Drucksachen
None lieferte.
Patch: client-side 'antrag'-Substring-Filter läuft jetzt unabhängig
vom Server-Filter (vorher nur wenn document_type gesetzt war). BB/RP
und alle PortalaAdapter-Instanzen profitieren mit.
176 Unit-Tests grün, Live-Verifikation Sub-B im Container nach Deploy.
Refs: #61 Bug 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei aus #61 identifizierte Production-Bugs gefixt:
- **Bug 4 (BB Datum)**: BB.wahlperiode_start vom 2024-10-23 (Konstituie-
rende Sitzung) auf 2024-09-22 (Wahltag) zurückgesetzt. Damit fällt
die Geschäftsordnungs-Drucksache 8/2 vom 2024-10-17 in den
Plausibilitäts-Check. Ist auch semantisch sauberer — die WP fängt
mit der Wahl an, nicht mit der formalen Konstituierung.
- **Bug 2/3 (BB/RP Type-Filter leakt Kleine Anfrage / Beschluss-
empfehlung)**: Server-side ETYPF/DTYPF-Filter ist best-effort über
die portala-Instanzen — BB+RP lassen die nicht-Antrag-Typen durch.
Client-side strict-filter im PortalaAdapter.search() nach Aufruf von
_parse_hit_list_html: nur Hits, deren typ-String das Substring
"antrag" enthält, kommen weiter. Substring-Match (nicht exact),
damit "Antrag gemäß § 79 GO" und ähnliche Subtypen passieren.
176 Unit-Tests grün, Live-Verifikation via Sub-A im Container nach
Deploy.
Refs: #61 (Bug 2, 3, 4)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Live-Verifikation in der Container-DB hat aufgedeckt, dass TH ParlDok
für sehr frische Vorlagen (z.B. 8/1594, datum 2026-03-31, allowed=false)
``link``/``prelink`` als leeren String liefert — das PDF ist noch nicht
zur Veröffentlichung freigegeben.
Bisheriges Verhalten: Adapter konstruierte einen Drucksache-Eintrag mit
``link=''``, der dann durch die Pipeline rutschte und im Frontend als
unklickbarer Eintrag erschien. ``download_text()`` würde später an
``not doc.link`` scheitern, was die Analyse blockt.
Sauberer Skip an der Quelle: ``_hit_to_drucksache`` returnt None, wenn
weder ``link`` noch ``prelink`` einen Pfad liefern. Das ist konsistent
mit den anderen None-Returns für unbrauchbare Hits (kein lp, kein
number).
Lokal verifiziert: 176 Unit-Tests grün. Live-Verifikation gegen
Production folgt nach Deploy via Sub-A-Test im Container.
Refs: #61 (Bug 1: TH leerer Link)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sachsen-Adapter (#26/#38) ist Eigensystem mit ASP.NET-Webforms-Postbacks
(__VIEWSTATE/__CALLBACKID, siehe bundeslaender.py:343-348) und braucht
HAR-Aufnahme → Blocker für autonome Bearbeitung. Phase E entsprechend
substituiert mit der Frontend-Erweiterung der Auswertungen.
- Matrix-Zellen sind jetzt klickbar (`cell-with-data`-Klasse +
hover-outline mit Blue-Border)
- Klick öffnet ein Modal, das `/api/auswertungen/zeitreihe?
bundesland=...&partei=...` aufruft und die Score-Entwicklung dieser
(BL, Partei)-Kombination über alle bekannten WPs als Tabelle rendert
- ESC-Taste oder Backdrop-Klick schließt das Modal
- Schließt damit den Frontend-Loop für die in Phase C gebauten
Backend-Endpoints
(CLAUDE.md-Sync separat — die Datei liegt im Projekt-Root außerhalb
des Webapp-Git-Repos.)
Refs: #59 (Phase E substituted)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schließt #16 (UI: zwei klar getrennte Suchen) und ergänzt den Header
um den Link auf das neue Auswertungen-Dashboard aus Phase C.
- Search-Row in `index.html` aufgespalten in zwei untereinanderliegende
Inputs: oben "Suche in geprüften Anträgen (DB)" mit Live-Debouncing
(wie bisher), unten "Im Landtag suchen (live)" mit Enter-Trigger und
expliziter Such-Button. Beide Felder schreiben in dieselbe Liste,
sind aber visuell und semantisch klar getrennt.
- `searchLandtag()` zieht jetzt aus `landtag-search-input` statt aus
dem DB-Suchfeld
- `changeBundesland()` resettet zusätzlich das Landtag-Feld
- Header: neuer `📈 Auswertungen`-Link neben `📚 Quellen`
Refs: #16, #59 (Phase D)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zentrale `app/parteien.py` als Single Source of Truth für die Partei-
Auflösung:
- `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen
bekannten Aliasen, optionalem `bundesland_scope` und Government-
Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW,
BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der
generische FREIE WÄHLER-Eintrag).
- `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups
mit Government-Vorrang und FW-Familien-Disambiguierung
- `extract_fraktionen(text, *, bundesland=None)` als Funnel für die
vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer-
partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope-
Filter (SSW nur in SH, BVB-FW nur in BB, etc.).
- `display_name(canonical, *, long=False)` für UI/PDF — kurze Form
bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN"
statt "GRÜNE" etc.
Adapter-Migration in `app/parlamente.py`:
- Vier nahezu identische `_normalize_fraktion()`-Methoden in
PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter
durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit
`self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt.
- `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen
für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`,
also keine Call-Site-Änderung nötig.
`app/embeddings.py:496` Workaround-Hack entfernt:
- `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen
`normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen
dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und
Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise
überall garantiert kanonisch.
Tests:
- Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW-
Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion,
Government-Marker, Tabellen-Konsistenz
- `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6
statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4
Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter
BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation
157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die
kanonischen Keys sind exakt die in der DB stehenden Strings, kein
Migrations-Schritt nötig.
Refs: #55, #59 (Phase B)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sechs der zehn aktiven Bundesländer hatten bisher keine Wahlprogramme
indexiert (alle sechs heute neu aktiviert: BW/HH/TH in Phase 1, SH/BB/RP
in Phase 2). Antrag-Analysen für diese BL fielen damit auf föderale
Grundsatzprogramme als Fallback zurück.
Beschafft via abgeordnetenwatch.de für die jeweils laufende WP:
- TH WP8 (LTW 01.09.2024): CDU, AfD, LINKE, BSW, SPD — 5 PDFs
- BB WP8 (LTW 22.09.2024): SPD, AfD, CDU, BSW — 4 PDFs
- HH WP23 (Bürgerschaftswahl 02.03.2025): SPD, CDU, GRÜNE, LINKE, AfD — 5 PDFs
- SH WP20 (LTW 08.05.2022): CDU, SPD, GRÜNE, FDP, SSW — 5 PDFs
- BW WP17 (LTW 14.03.2021): GRÜNE, CDU, AfD, SPD, FDP — 5 PDFs
- RP WP18 (LTW 14.03.2021): SPD, CDU, AfD, GRÜNE, FREIE WÄHLER, FDP — 6 PDFs
Insgesamt 30 PDFs in app/static/referenzen/, plus 30 Einträge in
WAHLPROGRAMME[bl][partei] und embeddings.PROGRAMME.
Naming-Schema wie etabliert: <partei>-<bl>-<jahr>.pdf, also
spd-th-2024.pdf, fw-rp-2021.pdf etc.
Wichtig zu Memory feedback_legislaturprogramme: alle BL nutzen das
Programm der LAUFENDEN Wahlperiode, NICHT Programme aus späteren
Wahlen. BW und RP wählen am 08.03.2026 / 22.03.2026 neu — der
18./19. Landtag konstituiert sich erst, daher sind die 17./18. WP
mit den 2021er Programmen weiterhin laufend bis zur Konstituierung.
Indexierung im prod-Container ist NICHT Teil dieses Commits — muss
separat ausgeführt werden:
ssh vserver 'docker exec gwoe-antragspruefer python -c "
from app.embeddings import index_programm
from pathlib import Path
d = Path(\"/app/app/static/referenzen\")
for pid in [
\"cdu-th-2024\",\"afd-th-2024\",\"linke-th-2024\",\"bsw-th-2024\",\"spd-th-2024\",
\"spd-bb-2024\",\"afd-bb-2024\",\"cdu-bb-2024\",\"bsw-bb-2024\",
\"spd-hh-2025\",\"cdu-hh-2025\",\"gruene-hh-2025\",\"linke-hh-2025\",\"afd-hh-2025\",
\"cdu-sh-2022\",\"spd-sh-2022\",\"gruene-sh-2022\",\"fdp-sh-2022\",\"ssw-sh-2022\",
\"gruene-bw-2021\",\"cdu-bw-2021\",\"afd-bw-2021\",\"spd-bw-2021\",\"fdp-bw-2021\",
\"spd-rp-2021\",\"cdu-rp-2021\",\"afd-rp-2021\",\"gruene-rp-2021\",\"fw-rp-2021\",\"fdp-rp-2021\",
]:
index_programm(pid, d)
"'
77 pytest tests passing — der File-Existenz-Check in test_wahlprogramme.py
hätte einen Tippfehler im PDF-Namen sofort gefangen.
Erledigt UI-Aktivierungs-Issues #37 (TH), #39 (BB), #40 (HH), #32 (SH),
#41 (BW), #42 (RP).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Riesige Überraschung aus dem BB-HAR-Trace: Brandenburg ist NICHT
StarWeb wie in dokukratie und bundeslaender.py klassifiziert,
sondern läuft auch auf dem portala/eUI-Backend. Endpoint
/portal/browse.tt.json mit db_id=lbb.lissh. Das alte
/starweb/LBB/ELVIS/-Frontend ist nur Legacy.
Folgeprobing offenbarte: RP/opal.rlp.de läuft ebenfalls portala
(db_id=rlp.lissh, 46759 hits in WP18), ebenso NI/HE/BB. Damit ist
Phase 2 großteils KEIN StarWeb-Adapter-Bau, sondern PortalaAdapter-
Wiederverwendung mit konfigurierbaren Parametern.
Activated via Registry-Einträge:
- "BB" → PortalaAdapter(base_url=parlamentsdokumentation.brandenburg.de,
db_id=lbb.lissh, wahlperiode=8). Nutzt die BE-Card-Variante des
Hit-Parsers (efxRecordRepeater).
- "RP" → PortalaAdapter(base_url=opal.rlp.de, db_id=rlp.lissh,
wahlperiode=18). NICHT mit dem NRW OPAL verwechseln — anderer
Markenname, andere Engine.
PortalaAdapter erweitert um zwei neue Konstruktor-Parameter mit
backward-kompatiblen Defaults:
- typ_filter: Optional[str] = "DOKDBE"
Wenn None, wird die TYP=<value>-Klausel weggelassen. Manche
Instanzen (HE/hlt.lis) lehnen DOKDBE ab.
- omit_date_filter: bool = False
Wenn True, wird der DAT/DDAT/SDAT-Term weggelassen. HE
und ähnliche Instanzen haben andere Date-Field-Namen.
Plus _parse_hit_list_cards Date-Regex erweitert: zusätzlich zum
"vom DD.MM.YYYY"-Pattern (BE) jetzt auch "DD.MM.YYYY"-plain
(BB schreibt Datum vor Drucksachen-Nummer ohne "vom"-Marker).
Smoke-Test (lokal):
BB q="": 5 hits in 5.9s
BB q="Schule": 5 hits (Pflegeschulen, Genderverbot, Hochschulen)
RP q="": 5 hits in 4.1s (Entlastung, Bildungschancen)
RP q="Schule": 5 hits (Hochschulbau, G9-Gymnasien, Leistungsgerechtigkeit)
bundeslaender.py: BB.doku_system "StarWeb"→"portala", RP analog,
beide aktiv=True. Anmerkungen mit dem portala-Verweis und der
Klarstellung "OPAL/RLP ≠ NRW OPAL" erweitert.
NICHT in diesem Commit:
- HE: portala-Backend (hlt.lis) ist erreichbar, aber das HE-Card-
Layout ist anders (Title direkt im <h3> statt <h3><span>, kein
<span class="h6"> für Meta) — eigener Parser-Pfad nötig, deferred.
- NI: nilas.niedersachsen.de/portal/ ist eine Login-Page, das
öffentliche Backend ist nicht zugänglich — deferred.
- HB: kein /portal/-Endpoint, bleibt das alte StarWeb-Servlet —
braucht eigenen HAR-Trace, deferred.
- BB als StarWeb-Template (#27) ist hinfällig, weil BB portala ist.
Phase 2 (3/6) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SH läuft auf der ältesten der vier Backend-Familien: Starfinder-CGI
auf lissh.lvn.parlanet.de. URL-basiert (nicht stateful wie das
moderne StarWeb-Servlet von BB/HE/NI/RP/HB), Latin-1-encoding,
flat HTML-Tabelle als Hit-Format. Eigener Adapter weil das Schema
fundamental anders ist als alles andere.
Endpoint:
http://lissh.lvn.parlanet.de/cgi-bin/starfinder/0
?path=lisshfl.txt&id=FASTLINK&pass=&search=WP=20+AND+dtyp=antrag
&format=WEBKURZFL
Hit-Format pro <tr class="tabcol*">:
<b>{TITLE}</b><br>
Antrag {URHEBER} {DD.MM.YYYY} Drucksache <a href="{PDF}">{N/M}</a>
Quelle: dokukratie/sh.yml + Live-Probing.
Encoding: Server liefert iso-8859-1 ohne korrektes Content-Type-
Header. Adapter dekodiert resp.content explizit als latin-1.
SSW-Detection im _normalize_fraktion: SH ist das einzige BL mit
SSW-Fraktion (von der 5%-Hürde befreit), pattern ist \\bSSW\\b
analog zu \\bAfD\\b.
Free-Text-Suche client-seitig (siehe #18) — server-side query-
syntax mit (term) im starfinder-search-Param wird vom Server nicht
als Volltext interpretiert, einheitlich mit allen anderen aktiven
Adaptern.
Smoke-Test (lokal):
SH q="": 8 hits in 14.4s
SH q="Schule": 8 hits in 14.8s (Schulentwicklung Westküste,
Hochschulen, queere Vielfalt an Schule etc.)
SH q="Klima": 8 hits (klimafreundlich, Klimafolgen,
Strategischer Aktionsplan)
SH q="Bildung": 8 hits (berufliche Bildung, Holocaust-Wissen)
bundeslaender.py::SH.aktiv = True. doku_base_url auf
lissh.lvn.parlanet.de korrigiert (ehemaliger landtag.ltsh.de-
Eintrag passte nicht zum echten Endpoint).
Damit ist Phase 2 (1/6) angefangen — als Nebenpfad, weil das
StarWeb-Servlet (#27 BB als Template für 5 weitere) ohne HAR-
Trace nicht sauber reverse-engineerbar war.
Phase 2 (1/6) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Thüringen läuft auf parldok.thueringer-landtag.de mit ParlDok 8.3.5
(J3S GmbH) — exakt dieselbe Version wie MV. Aber TH packt seine
Anträge unter zusammengesetzten type-Strings ("Antrag gemäß § 79 GO",
"Antrag gemäß § 74 (2) GO") und kind="Vorlage" statt der MV-Variante
kind="Drucksache"/type="Antrag". Strict-Match auf "Antrag" hat 0
Treffer geliefert.
Lösung: ParLDokAdapter um zwei Konstruktor-Parameter erweitert:
- document_typ_substring=True → Substring-Match auf type-Feld
("Antrag" matched "Antrag gemäß § 79 GO", "Alternativantrag" usw.)
- kinds=["Drucksache", "Vorlage"] → erweiterte kind-Liste
Defaults sind backward-kompatibel (Substring-Match aus, kinds nur
Drucksache), sodass MV und HH unverändert weiterlaufen.
_hit_matches_filters() als zentraler Filter-Helper extrahiert,
search() und get_document() nutzen ihn — get_document() überspringt
ihn allerdings, weil dort beliebige Drucksachen aufrufbar sein müssen,
unabhängig vom search-Time-Filter.
Hostname-Korrektur: parldok.thueringen.de redirected per 303 auf
parldok.thueringer-landtag.de. doku_base_url in bundeslaender.py
auf den neuen Host umgestellt.
Smoke-Test (lokal):
TH q="": 8 hits in 3.3s
TH q="Schule": 2 hits in 25.7s (Lernmittelbeschaffung, Modernisierung
Bund-Länder-Vereinbarung — beide Schul-bezogen)
TH q="Klima": 0 hits (keine in den letzten 1000 Drucksachen)
Damit ist Phase 1 (3/3) komplett. Nächstes Phase-2 Issue: #27 BB als
StarWebAdapter-Template.
Phase 1 (3/3) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hamburg's parldok runs ParlDok 8.3.1 (J3S GmbH) — kompatibel mit
der MV-Variante (8.3.5). Selber /parldok/Fulltext/Search-Endpoint,
selbe Body-Schema, selbes Hit-Format. Dadurch ist der existierende
ParLDokAdapter aus #4 ohne Code-Änderungen wiederverwendbar.
Eingetragen wurde nur:
- ADAPTERS["HH"] = ParLDokAdapter(base_url=buergerschaft-hh.de,
wahlperiode=23, prefix=/parldok, document_typ="Antrag")
- bundeslaender.py::HH.aktiv = True
Smoke-Test (lokal):
HH q="": 8 hits in 1.5s, jüngste WP23-Anträge sortiert newest-first
HH q="Schule": 1 hit in 13.2s (HH ist klein, WP23 erst seit März 2025,
HH nutzt eher "Kita"/"Bildung"/"Lehrkräfte" im Titel)
HH q="Klima": 2 hits
Verifikation HH ist 8.x:
curl https://www.buergerschaft-hh.de/parldok/ | grep generator
→ "ParlDok 8.3.1, entwickelt von der J3S GmbH"
Dies ist der zweite Phase-1-Win — ein nahezu kostenloser Adapter-
Reuse weil das Backend identisch ist. Anders als BW (#29), das eine
eigene PARLISAdapter-Klasse brauchte, braucht HH gar keinen neuen
Code.
Phase 1 (2/3) aus Roadmap-Issue #49.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>