Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block,
decisive-Outcome-Selection) in app/marker.py extrahiert. Template
ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik
testbar ohne Render-Kontext.
Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown-
Renderers in aktuelle-themen.html — fuer Tests und potenzielle
Server-side-Render-Optionen (z.B. PM-Mail).
Tests:
- tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome,
consistency_state inkl. Multi-Vote, ambivalente Empfehlung,
Edge-Cases.
- tests/test_pm_render.py (21 Cases): Bold, Italic, Listen,
HTML-Escape, Paragraph-Splitting, snake_case-Schutz.
Refs: ADR 0010, ADR 0011
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wenn ein Antrag mehrere Plenum-Votes hat (Überweisung → Endabstimmung),
nimmt der Konsistenz-Block jetzt das erste mit angenommen/abgelehnt/
bestätigt. Vorher wurde stur [0] verwendet — das war oft "überwiesen"
und der Block blieb leer trotz vorhandenem Endbeschluss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Direkt unter "Abstimmungsergebnis" steht ein Hinweis-Block:
- "Mehrheit kontra GWÖ-Empfehlung" (rot) wenn Empfehlung "unterstützen"
und Beschluss "abgelehnt" oder umgekehrt.
- "Mehrheit deckt sich mit GWÖ-Empfehlung" (grün) bei aligniertem Fall.
- Bei "überwiesen" oder ambivalenter Empfehlung kein Block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neben jeder NEIN-Fraktion erscheint ein dezentes ⚠ wenn der eigene
Wahlprogramm-Score >= 7 lag. Tooltip nennt den Score. Macht im Detail
sichtbar wer gegen das eigene Programm stimmt — gleicher Befund wie im
Stimmverhalten-Tab, aber pro Antrag punktgenau.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PM-Prompt erlaubt nun max. eine Markdown-Bold-Markierung pro Absatz
(Schluessel-Zahl/Effekt). Force-Regen-Test bestaetigt: qwen-max liefert
**30 %** wie im Beispiel; renderPmBody im Frontend rendert das als
<strong>. Smoketests gegen die neuen Endpoints (score-histogram x4,
admin/stand x2 Auth-Walls) absichern Regressionen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sechs zusammengehoerige UX/Performance-Erweiterungen:
**1. /v2/admin/stand — System-Stand-Dashboard**
KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM-
Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source-
Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in
einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion.
**2. /auswertungen Score-Histogram-Tab**
4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint
/api/auswertungen/score-histogram liefert Buckets, optional gefiltert
nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter.
**3. PM-Body Markdown-Rendering**
Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ /
- list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown-
Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann
zu Tags umgesetzt.
**4. Performance-Cache fuer themen_matching**
TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster.
Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in
news_aggregator.run_aggregator nach erfolgreichem Insert/Embed.
4 neue Tests fuer cache_get/set/clear-Verhalten.
**5. Stimmverhalten Banner Live-Update**
Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s
GET /api/queue/status, Banner zeigt pending + elapsed live. Bei
pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu
laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird.
**6. Antrag-Detail Cluster-Indicator**
News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster
und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge
mit Hover-Tooltip der anderen Cluster-Members. Macht thematische
Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab.
Suite: 1088 → 1092 grün (4 Cache-Tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Beobachtung im Draft #6: qwen-max nutzte einsame Anfuehrungs-
zeichen (") als Paragraph-Trenner statt \\n\\n. Optisch wirkte das
wie inkorrekte JSON-Escapes mitten im Text.
Zwei Mechanismen:
**1. Prompt-Erweiterung:**
Neuer Abschnitt "Paragraphen-Formatierung" mit explizitem Beispiel:
`"body": "Lead.\\n\\nWirkung 1.\\n\\nWirkung 2.\\n\\n..."`. Klar:
keine Anfuehrungszeichen oder Sonderzeichen als Trenner.
**2. Post-Process-Heuristik:**
Regex `([.!?])"([A-ZÄÖÜ])` → `\\1\\n\\n\\2`. Wenn ein " genau zwischen
Punkt+Whitespace und Großbuchstabe steht, ist es wahrscheinlich ein
Trenn-Klumpen, kein semantischer Anfuehrer. Wird durch echten
Paragraph-Break ersetzt.
Konservativ: nur dieses spezifische Pattern wird touched. Echte
Quotes (z.B. "Es ist Zeit, …", sagt X) bleiben unangetastet, weil sie
nicht direkt nach Satzschluss-Punkt stehen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**News-Match-Box im Antrag-Detail:**
Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine
Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der
letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button
mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben).
Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den
News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und
bleibt komplett versteckt wenn keine Matches existieren (kein Noise).
**Test-Coverage fuer die heutigen Backend-Aenderungen:**
`tests/test_llm_bewerter.py`:
- 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr,
outside-string, makes-invalid-valid, preserves-already-escaped)
- 2 Tests fuer `json_object_mode` pass-through (off → kein Param,
on → response_format={"type":"json_object"})
- 1 Integration: Recovery greift im bewerte()-Loop ohne Retry
`tests/test_endpoints_smoke.py`:
- Vote-Orphans-Endpoint (GET) Smoke
- Vote-Orphans-Auto-Rate Auth-Wall
- Batch-Analyze Auth-Wall (incl. ALL-Modus)
- Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster,
drafts-list, drafts-versions) — 8 Tests
`tests/test_batch_helpers.py`:
- 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks
(already-rated skip, no-adapter, limit-cap, empty-text-skip)
Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil
FastAPI nicht importbar, greifen aber gegen dev/CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Datenlage auf dev: 7281 Plenum-Votes, 96 Bewertungen, nur 19 Matches.
Stimmverhalten-Tab zeigt fast nichts, weil die meisten Vote-Drucksachen
keine Bewertung haben. Issue #172 schliesst die Luecke.
**Banner im Stimmverhalten-Tab:**
- Zeigt Anzahl + Verteilung pro BL der "Vote-only"-Drucksachen
- Nur sichtbar wenn count > 0
- Aktion: "Auto-Bewerten Top-N" mit Limit-Selector (5/10/20)
**Endpoint `GET /api/auswertungen/vote-orphans`:**
LEFT JOIN plenum_vote_results vs assessments, count + by_bundesland +
Top-N items sortiert nach parsed_at desc.
**Endpoint `POST /api/auswertungen/vote-orphans/auto-rate`:**
Admin-only, rate-limited 3/min. Nimmt Top-N Orphans, lädt Antragstext
per Adapter, enqueued einen Bewertungs-Job pro Drucksache. Defaults
limit=10, max 50. Per-skipped-reason-Liste in der Response (Adapter
fehlt, Empty-Text, Queue-full, etc.).
**Tests:** 4 neue (`TestGetVoteOrphans`), Suite 1071 gruen.
Helper `_enqueue_for_bl` aus dem Batch-Endpoint wird hier indirekt
wiederverwendet (gleiche Job-Queue-Pipeline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Wunsch: Batch-Analyse soll auch Anträge aus mehreren BL gleichzeitig
ranziehen koennen, nicht nur einen einzelnen.
- Neue Dropdown-Option "— Alle aktiven Bundesländer (Limit verteilt) —"
als Default
- Backend: bei `bundesland=ALL` iteriert ueber `aktive_bundeslaender()`
und verteilt das Limit proportional (limit // N pro BL).
- Helper `_enqueue_for_bl()` extrahiert die BL-spezifische Logik.
- Adapter-Fehler pro BL werden geloggt + skipt, blockieren nicht die
anderen BL.
- Response-Erweiterung: `per_bundesland`-Liste mit Per-BL-Stats
(enqueued / skipped_existing / error).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Wunsch: Stimmverhalten-Tab soll Querschnitt ueber alle BL zeigen
koennen, auch wenn der globale Header-BL-Filter auf einem einzelnen BL
steht. Bisher: Tab nutzte v2GetGlobalBl() → bei Header=BW wurde nur BW
angezeigt, bei Datensparse 0 Zeilen.
Aenderungen:
- Lokaler BL-Selector im Stimmverhalten-Caveat-Bereich.
Default-Option: "— Alle Bundeslaender —"
- svGetBl() Helper liest den lokalen Selector
- loadStimmverhalten + loadMatrixHeatmap + downloadStimmverhaltenCsv
nutzen svGetBl() statt v2GetGlobalBl()
- v2-bl-changed Event triggert das Stimmverhalten-Panel NICHT mehr
(eigener Filter)
Andere Tabs (BL × Partei, Themen × Fraktion) reagieren weiter auf den
globalen BL-Filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beobachtung beim ersten Pressereferent-Output: qwen-max liefert
manchmal literale Backslash-n Sequenzen (2 chars: \\ + n) statt echter
Newline-Bytes im JSON-Body. Auch mit response_format=json_object aktiv.
Post-Process im PM-Generator: \\n / \\r / \\t Sequenzen durch echte
Newlines / CR / Tab ersetzen. Konservativ (nur diese drei).
Macht das Modal richtig formatiert mit Paragraphen-Breaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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