Compare commits

...

10 Commits

Author SHA1 Message Date
Dotty Dotter
c38bca615d ops: Daily DB-Backup-Script + Cron 03:00 (Release 1.0)
- scripts/backup-db.sh: Online-Backup via Pythons sqlite3.backup()
  (atomar, async-safe, kein sqlite3-CLI im Container noetig)
- gzip-compressed Backups in /opt/gwoe-antragspruefer/backups/
- 30-Tage-Retention + monatlicher Snapshot bleibt erhalten
- Host-Cron 0 3 * * * (vor dem Mail-Digest 07:00)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:00:52 +02:00
Dotty Dotter
20b33c7560 release(1.0): README + CHANGELOG + DATA-LICENSE (CC-BY-4.0)
- README.md: aktuelle Inhalte (16 BL + BT, alle Features), Doppel-Lizenz, Schnellstart, Statistiken
- CHANGELOG.md: vollstaendige 1.0-Notes mit Hinzugefuegt/Geaendert/Bekannte-Einschraenkungen/Sicherheit
- DATA-LICENSE: Creative-Commons-Attribution-4.0-International fuer Bewertungs-Daten/Reports
- LICENSE (MIT) bleibt unveraendert fuer den Quellcode

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:59:28 +02:00
Dotty Dotter
b4fe3488e0 ops: Dockerfile + docker-compose Anpassungen, ADR-Index aktuell
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Dotty Dotter
2dec009b5c docs+ops: ADRs 0006/0008, DDD-Bewertung, Zugriffsrechte, Smoke-Test, Cron-Scripts
ADRs:
- 0006 Embedding-Modell-Migration v3->v4 (#123)
- 0008 DDD-Lightweight-Migration (#136)

Analysen:
- ddd-bewertung.md (1237 Zeilen) — vollstaendige DDD-Analyse mit Tages-Roadmap
- protokoll-parser-v6-machbarkeit.md (418 Zeilen) — #106 Phase 2 Vorbereitung

Reference:
- zugriffsrechte.md — 63 Routes x 3 User-Status, UI-Sichtbarkeits-Matrix

Ops:
- scripts/deploy.sh — mit Uptime-Kuma-Wartungsmodus (#149)
- scripts/run-digest.sh — taeglicher Mail-Digest-Cron
- scripts/run-monitoring-scan.sh — Monitoring-Scan-Cron (noch nicht aktiv)
- scripts/smoke-test.sh — Gesamt-Funktionspruefung
- pytest.ini: integration/slow/e2e Markers, addopts not-integration

Tests/integration/: Live-Adapter-Tests + Frontend-XRef + Citation-Substring
                    + Wahlprogramm-Indexed (4 Live-Test-Suites, marker-opt-in)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Dotty Dotter
2902164eff test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Dotty Dotter
565849bd84 feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
  (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
  Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze

Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
  /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie

Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
Dotty Dotter
58731af83c feat(db): Merkliste server-seitig + Monitoring-Tabellen + abgeordnetenwatch
- merkliste(user_id, antrag_id, created_at, notiz) (#140 Schema)
- monitoring_scans + monitoring_daily_summary (#135)
- abgeordnetenwatch_polls + abgeordnetenwatch_votes (#106)
- merkliste_add/remove/list/bulk_add Funktionen
- list_all_subscriptions() fuer Admin-View
- get_abstimmungsverhalten(drucksache, bundesland) JOIN-Aggregation
- merkliste, fehlende_programme, share_*, monitoring-Spalten via ALTER TABLE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:16 +02:00
Dotty Dotter
8f0f6d6e32 refactor(#136): DDD-Lightweight Tag 1-4 (Ports, Adapter, Repositories, Domain-Verhalten)
ADR 0008: Lightweight-Migration ohne Package-Split

- ports/llm_bewerter.py: Protocol + LlmRequest-Dataclass
- adapters/qwen_bewerter.py: Qwen/DashScope-Adapter mit Retry-Loop
- repositories/{antrag,bewertung,abonnement}_repository.py: Protocol + Sqlite-Impl + InMemory-Fake
- analyzer.py refactored: nimmt Optional[LlmBewerter], AsyncOpenAI-Import raus
- models.py: 5 Domain-Methoden auf Bewertung/MatrixEntry
  (ist_ablehnung, hat_fundamental_kritisches_feld, verletzt_score_cap, ...)
- analyzer loggt WARNING wenn LLM Score-Cap-Invariante verletzt

Folge-PR: Callsite-Migration in main.py (~21 direkte database.*-Aufrufe)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:16 +02:00
Dotty Dotter
2c0e94d29d feat(#106,#135,#128): Monitoring + abgeordnetenwatch + Wahlprogramm-Check
- monitoring.py: taeglicher Scan-Adapter aller aktiven BL, kein Auto-Fetch (#135)
- monitoring_digest.html: Mail-Template mit '0-Kontext'-Hinweis
- abgeordnetenwatch.py + sync_*.py: Phase 1 Roll-Call-Voting (#106)
  - 17 Parlamente (16 BL + BT)
  - 9 BL-spezifische Drucksachen-Patterns + Date-Title-Fallback
  - 28977 Votes fuer BUND in DB
- wahlprogramm_check.py: fehlende Programme erkennen (#128)
- NI-Skip-Liste, NRW Empty-Query-Fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:16 +02:00
Dotty Dotter
ad1db2a924 feat: 16 BL-Adapter, Drucksache-Typen, Mail-Digest, Clustering, Redline-Parser
- 16 aktive BL-Adapter + BUND (parlamente.py 3397 LOC)
- drucksache_typen.py: BL-spezifische Typ-Normalisierung (#127)
- mail.py: SMTP + Daily-Digest (#124)
- clustering.py: Embedding-Naehe-Graph + Bubble-Chart (#105)
- redline_utils.py: §INS§/§DEL§-Parser + PDF-Cite-URL-Builder
- embeddings v3->v4 Migration (#123, ADR 0006)
- chart.js + d3.v7 als statische Assets fuer Auswertungen-Cluster

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:54:50 +02:00
155 changed files with 21896 additions and 822 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ reports/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
site/ site/
.coverage

100
CHANGELOG.md Normal file
View File

@ -0,0 +1,100 @@
# Changelog
Alle markanten Änderungen pro Release. Format an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) angelehnt, semantisches Versioning.
## [1.0.0] — 2026-04-21
Erstes konsolidiertes Release nach längerer 0.x-Entwicklungsphase. Live unter
<https://gwoe.toppyr.de/>.
### Hinzugefügt — Frontend (v2)
- **Komplettes Redesign** auf das ECOnGOOD Corporate Design (Manual Juni 2024) — Tokens-Datei, Avenir/Nunito-Sans-Stack, Phosphor-Icon-Set, Dark-Mode mit `data-theme`-Attribut (#114, #139)
- **AppShell** mit zwei-Spalten-Layout (Sidebar 230 px, Main), Drawer auf Mobile, Navigation in vier Gruppen LESEN/PRÜFEN/DATEN/ADMINISTRATION
- **Server-Side-Routing** für Antragsdetail (`/antrag/{drucksache}`), keine reine Client-Seite mehr
- **Login-Modal** in der Topbar mit Tabs Anmelden/Registrieren via Direct-Access-Grant — kein Keycloak-Redirect mehr (#129)
- **Keyboard-Shortcuts** j/k/Enter/Esc/?/⏎ im Listenmodus mit Help-Modal
- **Sort-Dropdown** mit acht Optionen (Score/Datum/Drs.-Nr./Titel je asc/desc), localStorage-persistiert
- **Antragsdetail vollständig** mit ScoreHero, Matrix-Mini 5×5 (klickbar mit Erklärungs-Modal), Programm-Treue-Tabelle pro Fraktion (auch ohne Zitate), §INS§/§DEL§-Redline-Parser, Versionshistorie, namentlichem Abstimmungsverhalten als Balken pro Fraktion (#106 Phase 1)
- **Bookmarks/Voting/Kommentare/Share/Re-Analyze** alle in v2-Detail integriert mit Auth-Modal-Fallback
- **Live-Landtag-Suche** als eigener Screen `/v2/landtag-suche`
- **Admin-Panel** mit drei Screens (Freischaltungen, Queue mit 5 s Auto-Refresh, Abos für alle User)
- **Open-Graph-Bilder** pro Antrag (1200×630 PNG, Playwright-gerendert, SHA-Cache) (#141)
### Hinzugefügt — Backend
- **16 Landesparlamente + Bundestag** als Adapter (BUND, NRW, BE, HH, BW, RP, LSA, MV, HB, HE, BY, SL, TH, BB, SN, SH; NI deferred wegen Login)
- **abgeordnetenwatch.de-Integration** Phase 1 für strukturierte Roll-Call-Votes — 28 977 BT-Votes in DB, Drucksachen-Match via 9 BL-spezifische URL-Patterns + Datum/Titel-Fallback (#106)
- **Drucksachen-Typen-Normalisierung** filtert Anträge/Gesetzentwürfe von Kleinen Anfragen etc. (#127)
- **Embeddings v3 → v4** Modell-Migration mit WRITE/READ-Pattern (ADR 0006)
- **DDD-Lightweight-Migration** Tag 1-4: `LlmBewerter`-Port, `QwenBewerter`-Adapter, drei Repositories (Antrag/Bewertung/Abonnement), Domain-Verhalten auf Pydantic-Modellen (ADR 0008, #136)
- **Mail-Digest** mit täglichem Cron 07:00, BL/Partei-Filter pro User-Abo (#124)
- **Monitoring-Scan** aller Adapter mit Kosten-Schätzung — Beobachtung ohne Auto-Fetch, Mail-Report mit „0-Kontext"-Hinweis (#135)
- **Merkliste server-seitig** mit Migration aus localStorage (#140)
- **Wahlprogramm-Auto-Download** halbautomatisch mit SHA-Gate, kuratierte URL-Liste, Admin-UI (#138)
- **Fehlende Wahlprogramme** automatisch im Assessment markiert + UI-Hinweis (#128)
- **Clustering** via Embedding-Nähe-Graph mit Bubble-Chart (#105)
- **Background-Queue** mit drei parallelen Workern, Graceful Shutdown 15 min, Job-Persistenz (#99)
- **Voting + Kommentare** mit Visibility-Modi (öffentlich/angemeldet/nur ich) (#94)
- **RSS/Atom-Feed** für neue Bewertungen (#125)
### Hinzugefügt — Tests & Doku
- **574 Tests, 13 skipped** — Unit-Suite < 2 s, plus Integration/E2E unter Markern
- **Bug-Regression-Tests** für fünf historische Fixes (PRAGMA-Cursor, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- **Live-Adapter-Tests** + Frontend-Cross-Validation + Citation-Substring-Tests (`pytest -m integration`)
- **Playwright-E2E-Tests** (`pytest -m e2e`)
- **Smoke-Test-Script** `scripts/smoke-test.sh` für Gesamt-Funktionsprüfung gegen Live-System
- **8 ADRs** dokumentiert, plus DDD-Bewertung (1 237 LOC) und Protokoll-Parser-v6-Machbarkeit (418 LOC)
- **Zugriffsrechte-Doc** mit 63 Routes × User-Status-Matrix
- **Doppel-Lizenz** Code MIT + Daten/Bewertungen CC-BY-4.0
### Geändert
- `/` zeigt jetzt v2-Frontend, classic unter `/classic` weiterhin erreichbar
- Auswertungen mit BL-Filter (#137 fix)
- Direkt-Verlinkbarkeit (`/antrag/{drs}`) als Permalinks ersetzen Query-Parameter (#132)
- Social-Media-Texte werden vom LLM erzeugt und in DB gespeichert (#133)
- v5-Prompt mit PFLICHT-FRAKTIONEN aller LT-Fraktionen, nicht nur Antragsteller+Regierung
- Citation-Binding server-seitig: Quellen-Label der Zitate werden gegen die tatsächlich abgerufenen Chunks rekonstruiert (ADR 0001)
- Mail-Digest-Template mit „0-Kontext"-Hinweis falls keine neuen Drucksachen seit letztem Scan
- Login als HttpOnly-Cookie + separate `rt`-Cookie für Refresh-Token (`/api/auth/logout`-Route für sauberen Cookie-Reset)
### Bekannte Einschränkungen
- **NI (Niedersachsen)** im Monitoring-Scan geskippt — NILAS-Portal ist Login-protected, HAR-Capture nötig (#22)
- **Saarland-Adapter** swallowt manche httpx-Exceptions tiefer im Code als der erste Fix-Layer (#142)
- **Drucksachen-Match in MV/BY/BB/TH/HH/SL** für abgeordnetenwatch-Polls noch lückenhaft — deren `field_intro`-HTML enthält keine PDF-Links, der Datum+Titel-Fallback hängt von vorheriger Indexierung ab
- **Plenarprotokoll-Parser v6** für nicht-namentliche Abstimmungen ist Phase 2, nicht in 1.0 (#106 follow-up)
- **DDD-Callsite-Migration** in `main.py` (~21 direkte Database-Aufrufe → Repository-Dependency-Injection) als Folge-PR offen (#136 follow-up)
### Sicherheit
- **Security-Headers** (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
- **Rate-Limiting** auf teuren POST-Endpoints (10/min auf `/api/analyze-drucksache`)
- **Eingabe-Validatoren** (Drucksachen-Format-Regex, Such-Query-Längen-Cap)
- **JWT-Validation** über Keycloak JWKS, `azp`-Check statt `aud` für Public Clients (49c1b92)
### Statistik
- 11 789 LOC Python in `app/`
- 23 Module, 8 Templates-Verzeichnisse
- 71 produktive Bewertungen in der Live-DB
- 85 Wahlprogramme indexiert (Embeddings v4, ~50 000 Chunks)
- 28 977 abgeordnetenwatch-Votes
- 574 Tests, 0 Regressions
---
## [0.x] — Pre-Release-Phase
Frühere Iterationen. Siehe `git log` für Detail-Historie. Wesentliche Meilensteine:
- v3 → v4 Embedding-Migration (#123)
- Clustering + Force-Graph (#105, #108)
- Bookmarks + Kommentare (#94)
- Methodik-/Transparenz-Seite (#96)
- Keycloak SSO (#43)
- Multi-BL-Adapter (#22 Reihe, #72-#87)
[1.0.0]: https://repo.toppyr.de/tobias/gwoe-antragspruefer/releases/tag/v1.0.0

57
DATA-LICENSE Normal file
View File

@ -0,0 +1,57 @@
Datenrechte für GWÖ-Antragsprüfer
================================================================================
Copyright (c) 2026 Tobias Rödel und Mitwirkende
Dieses Werk umfasst alle vom GWÖ-Antragsprüfer **erzeugten** Inhalte:
- Bewertungen (Assessments) im JSON-Format
- GWÖ-Score-Werte und Matrix-Zuordnungen
- Begründungstexte und Empfehlungen
- Verbesserungsvorschläge im Redline-Format
- Themen-Tags, Stärken/Schwächen-Listen
- Aggregations-Tabellen und Auswertungs-Daten
- Generierte PDF-Berichte
Diese Inhalte sind lizenziert unter der
Creative Commons Attribution 4.0 International License (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/deed.de
Du darfst:
- Teilen — das Material in jedwedem Format oder Medium vervielfältigen und
weiterverbreiten
- Bearbeiten — das Material remixen, verändern und darauf aufbauen
und zwar für beliebige Zwecke, auch kommerziell.
Unter folgenden Bedingungen:
- Namensnennung — Du musst angemessene Urheber- und Rechteangaben machen,
einen Link zur Lizenz beifügen und angeben, ob Änderungen vorgenommen
wurden. Empfohlene Quellangabe:
"GWÖ-Antragsprüfer · gwoe.toppyr.de · CC BY 4.0"
- Keine weiteren Einschränkungen — Du darfst keine zusätzlichen Klauseln
oder technische Verfahren einsetzen, die anderen rechtlich irgendetwas
untersagen, was die Lizenz erlaubt.
================================================================================
NICHT von dieser Lizenz gedeckt:
- Quellcode des GWÖ-Antragsprüfers selbst — siehe LICENSE (MIT).
- Original-Antrags-PDFs und Plenarprotokolle der Landesparlamente und des
Bundestags — diese unterliegen den jeweiligen Veröffentlichungs-
Bedingungen ihrer Quellen. Sie werden vom Antragsprüfer ausschließlich
zur Bewertung referenziert, nicht weiterverbreitet.
- Wahlprogramme und Grundsatzprogramme der politischen Parteien — diese
sind urheberrechtlich geschützt und gehören den jeweiligen Parteien.
Indexierte Snippets werden im Rahmen des Zitatrechts (§ 51 UrhG)
zur Verifikation der Bewertungen genutzt.
- Logos und CD-Elemente der Gemeinwohl-Ökonomie / ECOnGOOD — diese
unterliegen den Markenrichtlinien der ECOnGOOD-Föderation.
================================================================================
Kontakt für Lizenzfragen: mail@tobiasroedel.de

View File

@ -18,8 +18,12 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code only (data/reports are mounted as volumes) # Copy application code only (data/reports are mounted as volumes)
COPY app/ ./app/ COPY app/ ./app/
# Create directories for volumes # Create non-root user and directories (#119 Security)
RUN mkdir -p /app/data /app/reports RUN adduser --disabled-password --gecos '' --uid 1000 appuser \
&& mkdir -p /app/data /app/reports \
&& chown -R appuser:appuser /app
USER appuser
# Environment # Environment
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

233
README.md
View File

@ -4,145 +4,144 @@
![Python](https://img.shields.io/badge/Python-3.12-blue) ![Python](https://img.shields.io/badge/Python-3.12-blue)
![FastAPI](https://img.shields.io/badge/FastAPI-0.109-green) ![FastAPI](https://img.shields.io/badge/FastAPI-0.109-green)
![License](https://img.shields.io/badge/License-MIT-yellow) ![Code-License](https://img.shields.io/badge/Code-MIT-yellow)
![Data-License](https://img.shields.io/badge/Daten-CC--BY--4.0-blue)
![Version](https://img.shields.io/badge/Release-1.0.0-success)
## 🎯 Was ist das? Live unter <https://gwoe.toppyr.de/>.
Der GWÖ-Antragsprüfer analysiert Anträge aus Landesparlamenten (aktuell NRW) und bewertet sie nach den Kriterien der **Gemeinwohl-Ökonomie (GWÖ)**: ## Was macht das Tool?
- **GWÖ-Score (0-10)**: Wie gut entspricht der Antrag den GWÖ-Werten? Der GWÖ-Antragsprüfer analysiert Anträge aus deutschen Landesparlamenten und dem Bundestag und bewertet sie nach den Kriterien der **Gemeinwohl-Ökonomie (GWÖ)**:
- **Matrix-Zuordnung**: Welche Felder der GWÖ-Matrix werden adressiert?
- **Programmtreue**: Passt der Antrag zu Wahl- und Parteiprogrammen?
- **Verbesserungsvorschläge**: Konkrete Textänderungen mit GWÖ-Begründung
## ✨ Features - **GWÖ-Score (010)** — Wie gut entspricht der Antrag den GWÖ-Werten?
- **Matrix-Zuordnung** — Welche der 25 Felder der GWÖ-Matrix für Gemeinden werden adressiert?
- **Programm-Treue** — Passt der Antrag zum Wahl- und Grundsatzprogramm jeder Fraktion?
- **Verbesserungsvorschläge** — Konkrete Textänderungen mit GWÖ-Begründung im Redline-Format
- **Zitate mit Verifikation** — Belege aus den Wahl-/Grundsatzprogrammen, server-seitig gegen Original-Chunks geprüft (siehe ADR 0001)
- 🔍 **Landtag-Suche**: Direkte Anbindung an OPAL (NRW Parlamentsdokumentation) ## Aktive Datenquellen (Stand Release 1.0)
- 📊 **GWÖ-Matrix-Visualisierung**: 5×5-Tabelle mit Bewertungssymbolen
- 🏷️ **Tag-Wolke**: Filter nach Themen mit Multi-Select
- 🎯 **Partei-Filter**: Durchschnittswerte pro Fraktion
- 📄 **PDF-Export**: Professionelle Berichte im GWÖ-Design
- 🔒 **Security**: CSP, CORS, Rate Limiting
## 🚀 Schnellstart **16 Bundesländer + Bundestag** — alle aktiven Adapter:
| BL | Wahlperiode | Quelle |
|---|---|---|
| BUND | 21 (20252029) | bundestag.de DIP |
| BW | 17 (20212026) | PARLIS |
| BY | 19 (20232028) | Bayern Landtag |
| BE | 19 (20232026) | Berlin AGH |
| BB | 8 (20242029) | StarWeb |
| HB | 21 (20232027) | ParlDok |
| HH | 23 (20252029) | ParlDok |
| HE | 21 (20242029) | Hessen Landtag |
| MV | 8 (20212026) | ParlDok |
| NI | — | NILAS (login-protected, deferred) |
| NRW | 18 (20222027) | OPAL |
| RP | 18 (20212026) | StarWeb |
| LSA | 8 (20212026) | StarWeb |
| SL | 17 (20222027) | Umbraco |
| SN | 8 (20242029) | XML-Export |
| SH | 20 (20222027) | Schleswig-Holstein |
| TH | 8 (20242029) | StarWeb |
Plus **abgeordnetenwatch.de**-Integration für strukturierte namentliche Abstimmungen (alle 16 BL + BT).
## Features
### Frontend (v2, ECOnGOOD-CD)
- **Listenansicht** mit Score-Band-Filter, BL-Chip-Filter, Sort-Dropdown (8 Optionen), Live-Suche
- **Antragsdetail** mit ScoreHero, Matrix 5×5, Zitaten, Redline-Diff, Programm-Treue pro Fraktion, Versionshistorie, namentlichem Abstimmungsverhalten (wenn vorhanden)
- **Bookmark-Liste** (server-seitig pro User), **Kommentare**, **Voting**, **Share-Buttons** (Threads/X/Mastodon mit LLM-Texten), **Re-Analyze**
- **Auswertungen** mit BL×Partei-Matrix, Themen×Fraktion-Heatmap, Cluster-Bubble-Chart
- **Tag-Cloud**, **Cluster-Liste**, **Landtag-Live-Suche**, **Methodik**, **Quellen**
- **Admin-Panel** Freischaltungen / Queue / Abos / Wahlprogramme
- **Dark-Mode**, **Phosphor-Icons**, Avenir/Nunito-Sans, **Keyboard-Shortcuts** (j/k/Enter/Esc/?/⏎)
### Backend
- **FastAPI** + Jinja2 + Vanilla JS (kein Build-Tool)
- **SQLite** mit aiosqlite (Source of Truth)
- **Qwen-Plus** (DashScope) für die LLM-Bewertung — austauschbar via `LlmBewerter`-Port (ADR 0008)
- **Embeddings v4** für die Zitat-Verifikation (ADR 0006)
- **Keycloak SSO** mit Direct-Access-Grant (Login-Modal in der App, kein Redirect)
- **Background-Queue** mit 3 parallelen Workern + Graceful Shutdown
- **Daily-Digest-Mail** für Abonnent:innen
- **Monitoring-Scan** aller Adapter mit Kosten-Schätzung — Beobachtung ohne Auto-Fetch
- **OG-Cards** (Open-Graph-Bilder pro Antrag, Playwright-gerendert)
- **WeasyPrint** für PDF-Reports
### Tests
- **574 Tests, 13 skipped** — Unit + Integration + Property + Bug-Regression + DDD
- Live-Adapter-Tests gegen alle 17 Quellen (`pytest -m integration`)
- Citation-Substring-Verification gegen Original-PDFs
- E2E-Browser-Tests via Playwright (`pytest -m e2e`)
## Architektur
Detailliert in [`docs/`](docs/):
- [`docs/adr/`](docs/adr/) — Architecture Decision Records (8 ADRs)
- [`docs/analysen/ddd-bewertung.md`](docs/analysen/ddd-bewertung.md) — DDD-Analyse + Migrations-Roadmap
- [`docs/reference/zugriffsrechte.md`](docs/reference/zugriffsrechte.md) — 63 Routes × User-Status-Matrix
- [`docs/reference/api.md`](docs/reference/api.md) — API-Reference
DDD-Lightweight-Migration ist **Tag 1-4 abgeschlossen** (Ports, Adapter, Repositories, Domain-Verhalten — siehe ADR 0008). Callsite-Migration in `main.py` ist Folge-PR.
## Schnellstart
### Voraussetzungen ### Voraussetzungen
- Python 3.12+ - Docker + Docker Compose
- Docker & Docker Compose - Python 3.12 (für lokale Tests)
- DashScope API-Key (Qwen LLM) - DashScope API-Key (Qwen)
- Keycloak (optional, für Login)
### Installation ### Installation
```bash ```bash
# Repository klonen git clone https://repo.toppyr.de/tobias/gwoe-antragspruefer
git clone https://github.com/tobiasroedel/gwoe-antragspruefer.git cd gwoe-antragspruefer/webapp
cd gwoe-antragspruefer cp .env.example .env # API-Keys eintragen
docker compose up -d --build
# Environment-Variablen
cp .env.example .env
# DASHSCOPE_API_KEY eintragen
# Mit Docker starten
docker compose up -d
# Oder lokal entwickeln
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
``` ```
Die App läuft auf http://localhost:8000 App auf <http://localhost:8000>.
## 📁 Projektstruktur ### Tests
```
webapp/
├── app/
│ ├── main.py # FastAPI-Endpoints
│ ├── analyzer.py # LLM-Analyse-Logik
│ ├── database.py # SQLite-Persistenz
│ ├── models.py # Pydantic-Modelle
│ ├── parlamente.py # Landtag-Adapter (OPAL)
│ ├── report.py # PDF-Generierung
│ ├── config.py # Settings
│ ├── kontext/ # GWÖ-Matrix, Wahlprogramme
│ ├── templates/ # Jinja2-HTML
│ └── static/ # CSS, JS, Assets
├── data/ # SQLite-DBs (Volume)
├── reports/ # Generierte PDFs (Volume)
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
```
## 🔧 Konfiguration
### Environment-Variablen
| Variable | Beschreibung | Default |
|----------|--------------|---------|
| `DASHSCOPE_API_KEY` | Alibaba DashScope API-Key | (required) |
| `LLM_MODEL_DEFAULT` | Standard-Modell | `qwen-plus-latest` |
| `LLM_MODEL_PREMIUM` | Premium-Modell | `qwen-max` |
### Unterstützte Bundesländer
| Code | Name | Status |
|------|------|--------|
| NRW | Nordrhein-Westfalen | ✅ Aktiv |
| BY | Bayern | 🔜 Geplant |
| BW | Baden-Württemberg | 🔜 Geplant |
## 📊 API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/` | Web-UI |
| GET | `/api/assessments` | Alle Bewertungen |
| GET | `/api/assessment?drucksache=18/12345` | Einzelne Bewertung |
| POST | `/api/analyze-drucksache` | Neue Analyse starten |
| GET | `/api/search?q=Klima` | Interne Suche |
| GET | `/api/search-landtag?q=Klima` | Landtag-Suche |
| GET | `/api/assessment/pdf?drucksache=18/12345` | PDF-Download |
## 🧠 GWÖ-Prompt (v5)
Der Analyse-Prompt basiert auf:
- **GWÖ-Matrix 2.0 für Gemeinden** (Arbeitsbuch)
- **ECOnGOOD Corporate Design Manual 2024**
- **Wahlprogramme** der NRW-Landtagsparteien 2022
Ausgabe-Format:
- GWÖ-Score mit Matrix-Feldern und Symbolen (++/+/○//)
- Wahlprogramm- und Parteiprogrammtreue
- **Verbesserungsvorschläge im Redline-Format** (Original → Vorschlag → Begründung)
- Themen-Tags für Kategorisierung
## 🛠️ Entwicklung
```bash ```bash
# Tests ausführen python3 -m pytest tests/ -q # Unit-Suite (574 Tests, < 2 s)
pytest python3 -m pytest tests/ -m integration # Live-Adapter-Tests (langsam)
./scripts/smoke-test.sh # Gesamt-Funktionsprüfung gegen Live
# Linting
ruff check app/
# Type-Checking
mypy app/
``` ```
## 📝 Lizenz ### Deploy (Server)
MIT License - siehe [LICENSE](LICENSE) ```bash
./scripts/deploy.sh # mit Uptime-Kuma-Wartungsmodus
./scripts/run-digest.sh # Daily-Mail-Digest (Cron 07:00)
./scripts/run-monitoring-scan.sh # Monitoring-Scan (manuell oder Cron)
```
## 🙏 Credits ## Lizenz
- [Gemeinwohl-Ökonomie](https://econgood.org) - Matrix und Arbeitsbücher Zwei getrennte Lizenzen:
- [Alibaba DashScope](https://dashscope.aliyuncs.com) - Qwen LLM API
- [Landtag NRW](https://www.landtag.nrw.de) - OPAL-Dokumentation
--- - **Quellcode** — [MIT](LICENSE)
- **Bewertungs-Daten und -Berichte** (Assessments, Matrix-Zuordnungen, Verbesserungsvorschläge, Themen-Tags etc.) — [CC BY 4.0](DATA-LICENSE)
**Entwickelt von Tobias Rödel** · [tobiasroedel.de](https://tobiasroedel.de) Wahlprogramme und Antrags-PDFs der Parlamente unterliegen der jeweiligen Urheber-Lizenz der Quelle und werden hier nur zur Verifikation referenziert.
## Mitwirken
Issues unter <https://repo.toppyr.de/tobias/gwoe-antragspruefer>. Pull Requests willkommen — beachte ADR 0004 (Deployment-Workflow) und die Test-Konventionen in `pytest.ini`.
## Statistiken (Stand Release 1.0)
- 16 BL + Bundestag aktiv
- 85 Wahlprogramme indexiert (Embeddings v4)
- 71 produktive Bewertungen in der Live-DB
- 28 977 abgeordnetenwatch-Votes (BUND)
- 11 789 LOC Python in `app/`

285
app/abgeordnetenwatch.py Normal file
View File

@ -0,0 +1,285 @@
"""Adapter für abgeordnetenwatch.de API v2 (#106 Phase 1).
Liefert strukturierte Abstimmungsdaten (namentliche Abstimmungen)
pro Bundesland + Bundestag. Daten werden lokal in abgeordnetenwatch_polls
und abgeordnetenwatch_votes gecacht.
API-Docs: https://www.abgeordnetenwatch.de/api/v2
"""
from __future__ import annotations
import logging
import re
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
# Mapping unserer BL-Codes auf abgeordnetenwatch parliament-IDs.
# IDs aus GET /api/v2/parliaments (Stand April 2026).
PARLIAMENT_ID: dict[str, int] = {
"BT": 5, # Bundestag (auch "BUND")
"BUND": 5, # Alias
"NRW": 4,
"BE": 2, # Berlin
"HH": 3, # Hamburg
"BW": 6, # Baden-Württemberg
"RP": 7, # Rheinland-Pfalz
"LSA": 8, # Sachsen-Anhalt
"MV": 9, # Mecklenburg-Vorpommern
"HB": 10, # Bremen
"HE": 11, # Hessen
"NI": 12, # Niedersachsen
"BY": 13, # Bayern
"SL": 14, # Saarland
"TH": 15, # Thüringen
"BB": 16, # Brandenburg
"SN": 17, # Sachsen
"SH": 18, # Schleswig-Holstein
}
_BASE = "https://www.abgeordnetenwatch.de/api/v2"
# Drucksachen-Extraktion aus field_intro-HTML — pro Landtag eigenes URL-/
# Dateinamen-Schema. Reihenfolge: erst Generic-Pattern "WP/NR" probieren
# (BUND, HE), dann BL-spezifische Patterns aus den Drucksachen-PDF-URLs.
_DS_PATTERNS: list[re.Pattern] = [
# Generic: "20/12345" — BUND, HE und ähnliche
re.compile(r"\b(\d{1,2})/(\d{3,5})\b"),
# NRW: MMD18-2142.pdf
re.compile(r"MMD(\d{1,2})-(\d{3,5})\.pdf", re.IGNORECASE),
# BE: d19-0564.pdf
re.compile(r"/d(\d{1,2})-(\d{4})\.pdf", re.IGNORECASE),
# BW: 17_7713_D.pdf
re.compile(r"/(\d{1,2})_(\d{3,5})_D\.pdf", re.IGNORECASE),
# HB: D21L0568.pdf (D<wp>L<nr>)
re.compile(r"/D(\d{1,2})L(\d{3,5})\.pdf", re.IGNORECASE),
# SH: drucksache-20-00187.pdf
re.compile(r"drucksache-(\d{1,2})-(\d{3,5})\.pdf", re.IGNORECASE),
# SL: Gs17_0503.pdf
re.compile(r"/Gs(\d{1,2})_(\d{3,5})\.pdf", re.IGNORECASE),
# LSA: wp8/drs/d0145… (Reihenfolge: wp dann nr)
re.compile(r"/wp(\d{1,2})/drs/d(\d{3,5})", re.IGNORECASE),
# SN: dok_nr=2150&...&leg_per=8 — params können in beliebiger Reihenfolge auftreten
re.compile(r"dok_nr=(\d{3,5}).*leg_per=(\d{1,2})", re.IGNORECASE),
# RP: 538-18.pdf (Reihenfolge: nr-wp)
re.compile(r"/(\d{3,5})-(\d{1,2})\.pdf", re.IGNORECASE),
]
def extract_drucksache_from_intro(html: str) -> Optional[str]:
"""Extrahiert die erste Drucksachen-Nummer aus dem field_intro-HTML.
Probiert mehrere Landtags-spezifische URL-Patterns durch (NRW MMD<wp>-<nr>,
BW <wp>_<nr>_D.pdf, etc.) und gibt die erste Fundstelle als
"<wp>/<nr>"-String zurück. Reihenfolge im Match-Tupel ist immer (wp, nr)
die Patterns selbst kümmern sich um eventuelle URL-Reihenfolgen-Eigenheiten
(RP hat z.B. nr-wp, SN hat dok_nr=...&leg_per=..., dort drehen wir).
"""
if not html:
return None
for pat in _DS_PATTERNS:
m = pat.search(html)
if not m:
continue
# Spezialfall RP: nr-wp im URL → drehen, damit Output wp/nr
if "-" in m.re.pattern and m.re.pattern.startswith("/(\\d{3,5})"):
return f"{m.group(2)}/{m.group(1)}"
# Spezialfall SN: dok_nr (Gruppe 1) + leg_per (Gruppe 2) → wp/nr
if "dok_nr" in m.re.pattern:
return f"{m.group(2)}/{m.group(1)}"
# Standard: (wp, nr)
return f"{m.group(1)}/{m.group(2)}"
return None
async def fallback_drucksache_by_date_title(
datum: Optional[str],
titel: Optional[str],
bundesland: str,
) -> Optional[str]:
"""Fallback-Drucksachen-Lookup via Datum + Titel gegen die Assessments-DB.
Wird aufgerufen wenn ``extract_drucksache_from_intro`` kein Pattern findet
(betrifft MV/BY/BB/TH/HH/SL deren intro-HTML keine PDF-URLs enthält).
Sucht Assessments für ``bundesland`` innerhalb von ±14 Tagen um ``datum``
und einem Titel-Substring-Match. Gibt die Drucksachen-Nummer des ersten
Treffers zurück oder ``None``.
Args:
datum: ISO-Datum des Polls (``field_poll_date``, z.B. ``"2026-04-01"``).
titel: Label/Titel des Polls (wird als LIKE-Substring geprüft).
bundesland: Unser BL-Code (z.B. ``"MV"``).
Returns:
Drucksachen-Nummer als String (z.B. ``"7/1234"``) oder ``None``.
"""
if not datum or not titel:
return None
# Titel-Substring: nur die ersten 40 Zeichen für den LIKE-Match verwenden,
# da Poll-Labels und Assessment-Titel leicht voneinander abweichen können.
titel_substr = titel.strip()[:40]
from .config import settings as _settings
import aiosqlite as _aio
async with _aio.connect(_settings.db_path) as db:
cur = await db.execute(
"""
SELECT drucksache FROM assessments
WHERE bundesland = ?
AND ABS(julianday(datum) - julianday(?)) < 14
AND LOWER(title) LIKE ?
ORDER BY ABS(julianday(datum) - julianday(?))
LIMIT 1
""",
(bundesland.upper(), datum, f"%{titel_substr.lower()}%", datum),
)
row = await cur.fetchone()
if row:
logger.debug(
"fallback_drucksache_by_date_title: %s/%s%s",
bundesland, datum, row[0],
)
return row[0]
return None
async def fetch_polls(bundesland_code: str, limit: int = 100) -> list[dict]:
"""Holt aktuelle Abstimmungen für ein Bundesland von abgeordnetenwatch.
Gibt eine Liste von Poll-Dicts zurück; jedes Dict enthält zusätzlich
den geparsten Key ``drucksache`` (kann None sein).
Args:
bundesland_code: Unser BL-Code (z.B. "NRW", "BT", "BUND").
limit: Maximale Anzahl Polls; wird als range_end übergeben.
Returns:
Liste von Poll-Dicts mit den Feldern aus der API plus ``drucksache``.
Raises:
ValueError: Wenn der bundesland_code nicht in PARLIAMENT_ID ist.
httpx.HTTPError: Bei Netzwerkproblemen.
"""
parliament_id = PARLIAMENT_ID.get(bundesland_code.upper())
if parliament_id is None:
raise ValueError(
f"Unbekannter BL-Code '{bundesland_code}'. "
f"Bekannte Codes: {sorted(PARLIAMENT_ID.keys())}"
)
async with httpx.AsyncClient(timeout=30.0) as client:
# Zuerst aktuellen ParliamentPeriod für das Parlament holen —
# /polls filtert nach field_legislature (period-id), NICHT parliament-id.
pp_resp = await client.get(
f"{_BASE}/parliament-periods",
params={"parliament": parliament_id, "type": "legislature", "range_end": 5},
)
pp_resp.raise_for_status()
periods = (pp_resp.json() or {}).get("data") or []
# Aktuelle Periode: sortiere nach start-date desc, nimm die neueste
current = sorted(
periods,
key=lambda x: x.get("start_date_period") or "",
reverse=True,
)
if not current:
logger.warning("Keine ParliamentPeriod für %s (parliament_id=%d)",
bundesland_code, parliament_id)
return []
period_id = current[0]["id"]
# Polls für diese Periode
resp = await client.get(
f"{_BASE}/polls",
params={"field_legislature": period_id, "range_end": limit},
)
resp.raise_for_status()
data = resp.json()
polls_raw: list[dict] = data.get("data") or []
polls = []
for p in polls_raw:
intro_html = p.get("field_intro") or ""
polls.append({
"id": p.get("id"),
"label": p.get("label") or p.get("field_poll_date", ""),
"field_poll_date": p.get("field_poll_date"),
"field_accepted": p.get("field_accepted"),
"field_topics": p.get("field_topics") or [],
"field_intro": intro_html,
"field_legislature": p.get("field_legislature") or {},
"drucksache": extract_drucksache_from_intro(intro_html),
})
logger.info(
"abgeordnetenwatch: %d polls für %s (parliament_id=%d)",
len(polls), bundesland_code, parliament_id,
)
return polls
async def fetch_votes_for_poll(poll_id: int) -> list[dict]:
"""Holt namentliche Einzelstimmen für eine Abstimmung.
Args:
poll_id: ID der Abstimmung (aus polls[].id).
Returns:
Liste von Vote-Dicts mit den Feldern:
poll_id, politician_id, politician_name, partei, vote.
vote ist einer von: "yes", "no", "abstain", "no_show".
Raises:
httpx.HTTPError: Bei Netzwerkproblemen.
"""
# /votes?poll=X funktioniert (empirisch ermittelt);
# NICHT field_poll (500) und NICHT /polls/{id}?related_data=votes
# (liefert leeres related_data). Einfaches ?poll=<id>.
url = f"{_BASE}/votes"
params = {"poll": poll_id, "range_end": 1000}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
data = resp.json()
votes_raw: list[dict] = data.get("data") or []
votes = []
for v in votes_raw:
politician = v.get("mandate") or v.get("politician") or {}
politician_id = politician.get("id") or v.get("mandate_id")
politician_name = politician.get("label") or politician.get("name") or ""
# Partei aus politician.party oder fraction
partei = ""
party = politician.get("party") or {}
if isinstance(party, dict):
partei = party.get("label") or party.get("short_label") or ""
fraction = v.get("fraction") or {}
if not partei and isinstance(fraction, dict):
partei = fraction.get("full_name") or fraction.get("label") or ""
vote_value = (v.get("vote") or "").lower()
# API liefert "yes"/"no"/"abstain"/"no_show" — direkt übernehmen
if vote_value not in ("yes", "no", "abstain", "no_show"):
vote_value = "no_show"
votes.append({
"poll_id": poll_id,
"politician_id": politician_id,
"politician_name": politician_name,
"partei": partei,
"vote": vote_value,
})
logger.info(
"abgeordnetenwatch: %d votes für poll_id=%d", len(votes), poll_id
)
return votes

11
app/adapters/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""Adapter: konkrete Implementierungen der Ports.
Vorläufig enthält dieses Modul nur den Qwen-LLM-Adapter. Perspektivisch
wandern die 17 Parlaments-Adapter aus ``parlamente.py`` hierher (eigener
Folge-PR, weil das eine umfangreichere Umstellung ist und die
Adapter-ABC dort bereits existiert siehe ADR 0002).
"""
from .qwen_bewerter import QwenBewerter
__all__ = ["QwenBewerter"]

View File

@ -0,0 +1,111 @@
"""QwenBewerter — Produktions-Adapter für den LlmBewerter-Port.
Kapselt den ``AsyncOpenAI``-Client gegen die DashScope-API, den Retry-
Loop mit Temperatur-Escalation und das Markdown-Fence-Stripping. Die
Retry-Semantik bleibt identisch zu ``analyzer.py`` vor der Migration:
bis zu ``max_retries`` Versuche, Temperatur steigt um 0.1 pro Versuch.
Der Adapter gibt den geparsten ``dict`` zurück Pydantic-Validierung,
Citation-Binding und Missing-Programme-Check bleiben Sache des Callers
in ``analyzer.py``.
"""
from __future__ import annotations
import hashlib
import json
import logging
from typing import Optional
from ..config import settings
from ..ports.llm_bewerter import LlmRequest
logger = logging.getLogger(__name__)
def _content_fingerprint(content: str) -> str:
"""Log-sicherer Identifier ohne PII-Leak (Issue #57 Befund #4)."""
if not content:
return "len=0"
h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8]
return f"len={len(content)} sha1={h}"
def _strip_markdown_fences(content: str) -> str:
"""Entfernt Markdown-Code-Fences, die Qwen trotz Prompt manchmal ergänzt.
In Sync mit ``analyzer.py`` vor der Migration; Einheitstests in
``tests/test_analyzer.py`` spiegeln exakt diese Logik.
"""
content = content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content.rsplit("```", 1)[0]
if content.startswith("```json"):
content = content[7:]
return content.strip()
class QwenBewerter:
"""LlmBewerter-Adapter für Qwen Plus (via DashScope)."""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
client=None,
) -> None:
"""Konstruktor-Injection erlaubt Tests, einen Mock-Client zu reichen
ohne Netzwerk-Zugriff. Prod nutzt den Default: Settings aus
``config.py`` + ``AsyncOpenAI``."""
self._api_key = api_key or settings.dashscope_api_key
self._base_url = base_url or settings.dashscope_base_url
self._client = client # lazy-created in .bewerte() wenn nicht gesetzt
def _get_client(self):
if self._client is not None:
return self._client
# Lazy-Import, damit die Test-Suite ohne ``openai``-Paket laufen kann.
from openai import AsyncOpenAI
self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)
return self._client
async def bewerte(self, request: LlmRequest) -> dict:
"""Führt den LLM-Call aus, bis JSON-Parse klappt oder Retries erschöpft."""
client = self._get_client()
last_error: Optional[Exception] = None
for attempt in range(request.max_retries):
response = await client.chat.completions.create(
model=request.model,
messages=[
{"role": "system", "content": request.system_prompt},
{"role": "user", "content": request.user_prompt},
],
temperature=request.base_temperature + (attempt * 0.1),
max_tokens=request.max_tokens,
)
content = response.choices[0].message.content.strip()
content = _strip_markdown_fences(content)
try:
return json.loads(content)
except json.JSONDecodeError as e:
last_error = e
logger.warning(
"LLM JSON parse error attempt %d/%d (%s) — content %s",
attempt + 1, request.max_retries, e,
_content_fingerprint(content),
)
if attempt >= request.max_retries - 1:
logger.error(
"LLM JSON parsing exhausted retries, content %s",
_content_fingerprint(content),
)
raise
# Unreachable — letzter Versuch hat raised. Für Typcheck.
assert last_error is not None
raise last_error

View File

@ -1,16 +1,23 @@
"""LLM-based analysis of parliamentary motions against GWÖ matrix.""" """LLM-based analysis of parliamentary motions against GWÖ matrix.
Seit ADR 0008: Die reinen LLM-Calls laufen über den ``LlmBewerter``-Port
(``app/ports/llm_bewerter.py``); der Default-Adapter ist ``QwenBewerter``
(``app/adapters/qwen_bewerter.py``). Citation-Binding, Missing-Programme-
Check und Pydantic-Validierung bleiben hier in der Application-Schicht.
"""
import hashlib import hashlib
import json import json
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional
from openai import AsyncOpenAI
from .config import settings from .config import settings
from .models import Assessment from .models import Assessment
from .bundeslaender import BUNDESLAENDER from .bundeslaender import BUNDESLAENDER
from .wahlprogramm_check import check_missing_programmes
from .ports.llm_bewerter import LlmBewerter, LlmRequest
from .wahlprogramme import ( from .wahlprogramme import (
find_relevant_quotes, find_relevant_quotes,
format_quote_for_prompt, format_quote_for_prompt,
@ -30,12 +37,28 @@ def _content_fingerprint(content: str) -> str:
"""Cheap, log-safe identifier for an LLM response: length + first 8 chars """Cheap, log-safe identifier for an LLM response: length + first 8 chars
of SHA-1. Lets us correlate retries without ever leaking the LLM's of SHA-1. Lets us correlate retries without ever leaking the LLM's
actual output (which may contain sensitive Antrags-Inhalte). Issue actual output (which may contain sensitive Antrags-Inhalte). Issue
#57 Befund #4.""" #57 Befund #4.
Wird nach ADR 0008 nur noch für post-LLM-Diagnostik (Pydantic-Validation)
gebraucht; der LLM-Retry-Loop selbst loggt in ``QwenBewerter``.
"""
if not content: if not content:
return "len=0" return "len=0"
h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8] h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8]
return f"len={len(content)} sha1={h}" return f"len={len(content)} sha1={h}"
def get_default_bewerter() -> LlmBewerter:
"""Lazy-Instanziierung des Default-Adapters.
Der Adapter-Import ist lazy, damit Tests ohne ``openai``-Paket und ohne
DashScope-Credentials laufen (das Stubbing in ``conftest.py`` reicht,
solange niemand Top-Level importiert).
"""
from .adapters.qwen_bewerter import QwenBewerter
return QwenBewerter()
# Load context files # Load context files
KONTEXT_DIR = Path(__file__).parent / "kontext" KONTEXT_DIR = Path(__file__).parent / "kontext"
@ -152,7 +175,11 @@ Antworte NUR mit einem JSON-Objekt im folgenden Format (keine Markdown-Codeblöc
"verbesserungspotenzial": "gering | mittel | hoch | fundamental", "verbesserungspotenzial": "gering | mittel | hoch | fundamental",
"themen": ["Bildung", "Soziales"], "themen": ["Bildung", "Soziales"],
"antragZusammenfassung": "1-2 Sätze Kernaussage", "antragZusammenfassung": "1-2 Sätze Kernaussage",
"antragKernpunkte": ["Punkt 1", "Punkt 2", "Punkt 3"] "antragKernpunkte": ["Punkt 1", "Punkt 2", "Punkt 3"],
"konfidenz": "hoch | mittel | niedrig",
"shareThreads": "Schlagkräftiger Post für Threads/Instagram (max 500 Zeichen). Emoji, Engagement, CTA, konkret auf den Antrag bezogen. Hashtags: #Gemeinwohl #GWÖ + 2-3 thematische.",
"shareTwitter": "Prägnanter Tweet für X/Twitter (max 280 Zeichen). Knackig, pointiert, mit Emoji und 2 Hashtags.",
"shareMastodon": "Sachlicher aber ansprechender Post für Mastodon (max 500 Zeichen). Informativ, quellenbasiert, mit Kontext."
} }
## Wichtige Regeln ## Wichtige Regeln
@ -165,7 +192,11 @@ Antworte NUR mit einem JSON-Objekt im folgenden Format (keine Markdown-Codeblöc
- Wenn EIN Feld -3 hat Gesamtscore maximal 4/10 - Wenn EIN Feld -3 hat Gesamtscore maximal 4/10
- Bei "Ablehnen" Score 0-2/10 - Bei "Ablehnen" Score 0-2/10
- Bei "Uneingeschränkt unterstützen" Score 8-10/10 - Bei "Uneingeschränkt unterstützen" Score 8-10/10
- **Matrix-Felder**: Bewertung -5 bis +5 (Symbole: / / / + / ++)""" - **Matrix-Felder**: Bewertung -5 bis +5 (Symbole: / / / + / ++)
- **Konfidenz**: Selbsteinschätzung der Bewertungssicherheit:
- "hoch": Antrag ist eindeutig, GWÖ-Bezug klar, genügend Kontext
- "mittel": Antrag ist mehrdeutig oder berührt Nischenthemen
- "niedrig": Antrag ist sehr kurz, unklar oder fachfremd Bewertung unsicher"""
def get_bundesland_context(bundesland: str) -> str: def get_bundesland_context(bundesland: str) -> str:
@ -220,14 +251,31 @@ Bei Oppositionsanträgen: Bewerte zusätzlich, ob die Regierungsfraktionen zusti
""" """
async def analyze_antrag(text: str, bundesland: str = "NRW", model: str = "qwen-plus") -> Assessment: async def analyze_antrag(
"""Analyze a parliamentary motion using the LLM.""" text: str,
bundesland: str = "NRW",
client = AsyncOpenAI( model: str = "qwen-plus",
api_key=settings.dashscope_api_key, bewerter: Optional[LlmBewerter] = None,
base_url=settings.dashscope_base_url, ) -> Assessment:
) """Analyze a parliamentary motion using the LLM.
Args:
text: Antrag-Volltext (plain).
bundesland: BL-Code aus ``bundeslaender.py``.
model: LLM-Modell (wird vom Default-Adapter ``QwenBewerter``
akzeptiert; andere Adapter können eigene Modell-Namen nutzen).
bewerter: ``LlmBewerter``-Implementierung. Default: ``QwenBewerter``
(DashScope/Qwen). Tests reichen hier ``FakeLlmBewerter``.
Nach ADR 0008: der HTTP-Call samt Retry-Loop lebt im Adapter; hier
bleibt nur noch die Application-Logik (Prompt-Komposition, Semantic-
Search, Citation-Binding, Missing-Programme-Check, Pydantic-Validation
und Domain-Invarianten-Warnings).
"""
if bewerter is None:
bewerter = get_default_bewerter()
system_prompt = get_system_prompt() system_prompt = get_system_prompt()
bundesland_context = get_bundesland_context(bundesland) bundesland_context = get_bundesland_context(bundesland)
@ -303,58 +351,46 @@ Programme bekannter sind. Findest du oben für eine Partei keinen passenden Chun
Ausgabe als reines JSON ohne Markdown-Codeblöcke.""" Ausgabe als reines JSON ohne Markdown-Codeblöcke."""
# Retry loop for JSON parsing errors # LLM-Call über den Port. Retry-Loop + Markdown-Stripping wohnen im
max_retries = 3 # Adapter (``QwenBewerter``). Bei exhausted retries wirft er
last_error = None # json.JSONDecodeError — wir lassen das durchpropagieren wie vor der
# Migration.
for attempt in range(max_retries): request = LlmRequest(
response = await client.chat.completions.create( system_prompt=system_prompt,
model=model, user_prompt=user_prompt,
messages=[ model=model,
{"role": "system", "content": system_prompt}, )
{"role": "user", "content": user_prompt}, data = await bewerter.bewerte(request)
],
temperature=0.3 + (attempt * 0.1), # Slightly increase temp on retry # Issue #60 Option B — server-side reconstruction of citation quelle/url
max_tokens=4000, # from the actually retrieved chunks, before Pydantic validation. Der LLM
# ist nicht mehr Quelle für die Quellen-Labels; wir ersetzen sie durch
# das kanonische _chunk_source_label und droppen Zitate ohne Chunk-Match.
if semantic_quotes:
data = reconstruct_zitate(data, semantic_quotes)
# #128: Fehlende Wahlprogramme server-seitig erkennen und eintragen. Der
# LLM bekommt diese Information nicht — sie basiert auf der lokalen
# Registry, nicht auf dem LLM-Wissen.
missing = check_missing_programmes(bundesland, landtagsfraktionen)
if missing:
logger.warning(
"Fehlende Wahlprogramme für %s in %s: %s",
landtagsfraktionen, bundesland, missing,
) )
data["fehlendeProgramme"] = missing
content = response.choices[0].message.content.strip()
# Pydantic-Validation: harter Check auf Schema-Drift.
# Remove markdown code blocks if present assessment = Assessment.model_validate(data)
if content.startswith("```"):
content = content.split("\n", 1)[1] # Tag-4-Invarianten-Warnings (ADR 0008): Verstöße gegen das Score-Cap
if content.endswith("```"): # werden geloggt, aber nicht geworfen — das LLM soll lernen, nicht der
content = content.rsplit("```", 1)[0] # Produktivbetrieb brechen.
if content.startswith("```json"): if assessment.verletzt_score_cap():
content = content[7:] logger.warning(
content = content.strip() "Assessment %s verletzt Score-Cap: gwoe_score=%.1f bei "
"fundamental-kritischem Matrix-Feld (rating≤-4)",
try: assessment.drucksache, assessment.gwoe_score,
# Parse JSON )
data = json.loads(content)
# Issue #60 Option B — server-side reconstruction of citation return assessment
# quelle/url from the actually retrieved chunks, before Pydantic
# validation. The LLM is no longer trusted for the citation source
# label; we replace it with the canonical _chunk_source_label of
# the chunk whose text actually contains the cited snippet, and
# drop any zitat that can't be located in any retrieved chunk.
if semantic_quotes:
data = reconstruct_zitate(data, semantic_quotes)
# Convert to Assessment model
return Assessment.model_validate(data)
except json.JSONDecodeError as e:
last_error = e
logger.warning(
"LLM JSON parse error attempt %d/%d (%s) — content %s",
attempt + 1, max_retries, e, _content_fingerprint(content),
)
if attempt < max_retries - 1:
continue
else:
# Letzter Fehlversuch — Fingerprint reicht zur Forensik;
# Volltext darf nicht ins Log, weil er Antrag-Inhalte enthält
logger.error(
"LLM JSON parsing exhausted retries, content %s",
_content_fingerprint(content),
)
raise

View File

@ -77,6 +77,7 @@ def _load_assessments(db_path: Optional[Path] = None) -> list[dict]:
def aggregate_matrix( def aggregate_matrix(
filter_wp: Optional[str] = None, filter_wp: Optional[str] = None,
filter_bl: Optional[str] = None,
db_path: Optional[Path] = None, db_path: Optional[Path] = None,
) -> dict: ) -> dict:
"""Aggregate assessments to a 2D matrix. """Aggregate assessments to a 2D matrix.
@ -89,12 +90,16 @@ def aggregate_matrix(
"<bl>": {"<partei>": {"n": int, "avg": float}} "<bl>": {"<partei>": {"n": int, "avg": float}}
}, },
"filter_wp": <filter_wp> | None, "filter_wp": <filter_wp> | None,
"filter_bl": <filter_bl> | None,
"total": int, "total": int,
}`` }``
``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``; ``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``;
nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine
WP-Einschränkung (alle WPs zusammen). WP-Einschränkung (alle WPs zusammen).
``filter_bl`` schränkt auf ein Bundesland ein (z.B. ``"NRW"``);
``None`` = alle Bundesländer.
""" """
rows = _load_assessments(db_path) rows = _load_assessments(db_path)
@ -108,6 +113,8 @@ def aggregate_matrix(
bl = row["bundesland"] bl = row["bundesland"]
if not bl: if not bl:
continue continue
if filter_bl is not None and bl != filter_bl:
continue
if filter_wp is not None: if filter_wp is not None:
wp = wahlperiode_for(row["datum"], bl) wp = wahlperiode_for(row["datum"], bl)
if wp != filter_wp: if wp != filter_wp:
@ -134,10 +141,28 @@ def aggregate_matrix(
"parteien": sorted(parteien), "parteien": sorted(parteien),
"cells": cells, "cells": cells,
"filter_wp": filter_wp, "filter_wp": filter_wp,
"filter_bl": filter_bl,
"total": total, "total": total,
} }
# ─────────────────────────────────────────────────────────────────────────────
# 1b. Hilfsfunktion: Liste aller bekannten Wahlperioden
# ─────────────────────────────────────────────────────────────────────────────
def get_wahlperioden(db_path: Optional[Path] = None) -> list[str]:
"""Gibt alle bekannten Wahlperioden aus den vorhandenen Assessments zurück,
aufsteigend sortiert."""
rows = _load_assessments(db_path)
wps: set[str] = set()
for r in rows:
wp = wahlperiode_for(r["drucksache"], r["bundesland"])
if wp:
wps.add(wp)
return sorted(wps)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 2. Zeitreihe pro (BL, Partei) über alle Wahlperioden # 2. Zeitreihe pro (BL, Partei) über alle Wahlperioden
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View File

@ -233,6 +233,30 @@ async def require_admin(request: Request) -> dict:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
async def keycloak_admin_token() -> str:
"""Holt ein Admin-Token vom Keycloak-Master-Realm.
Verwendet die Credentials aus den Umgebungsvariablen KEYCLOAK_ADMIN_USER
und KEYCLOAK_ADMIN_PASSWORD. Wirft HTTPException bei Fehlschlag.
"""
import httpx
if not settings.keycloak_admin_user or not settings.keycloak_admin_password:
raise HTTPException(status_code=500, detail="Keycloak-Admin-Credentials nicht konfiguriert")
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{settings.keycloak_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": settings.keycloak_admin_user,
"password": settings.keycloak_admin_password,
},
)
if resp.status_code != 200:
raise HTTPException(status_code=500, detail="Keycloak-Verbindung fehlgeschlagen")
return resp.json()["access_token"]
def keycloak_login_url(redirect_uri: str) -> str: def keycloak_login_url(redirect_uri: str) -> str:
"""Baut die Keycloak-Login-URL für den Browser-Redirect.""" """Baut die Keycloak-Login-URL für den Browser-Redirect."""
if not _is_auth_enabled(): if not _is_auth_enabled():
@ -245,3 +269,32 @@ def keycloak_login_url(redirect_uri: str) -> str:
f"&response_type=code" f"&response_type=code"
f"&scope=openid profile email" f"&scope=openid profile email"
) )
async def direct_login(username: str, password: str) -> dict:
"""Login via Keycloak Direct Access Grant (#129).
Gibt bei Erfolg {access_token, refresh_token, expires_in} zurück.
Wirft HTTPException bei Fehler (falsche Credentials, Account gesperrt, etc.).
"""
if not _is_auth_enabled():
raise HTTPException(status_code=400, detail="Auth nicht aktiviert")
token_url = f"{_keycloak_issuer()}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
token_url,
data={
"grant_type": "password",
"client_id": settings.keycloak_client_id,
"username": username,
"password": password,
"scope": "openid profile email",
},
)
if resp.status_code == 401:
error = resp.json().get("error_description", "Ungültige Anmeldedaten")
raise HTTPException(status_code=401, detail=error)
if resp.status_code != 200:
error = resp.json().get("error_description", f"Keycloak-Fehler ({resp.status_code})")
raise HTTPException(status_code=resp.status_code, detail=error)
return resp.json()

312
app/clustering.py Normal file
View File

@ -0,0 +1,312 @@
"""Antrag-Clustering via Cosine-Similarity + Union-Find (#105).
Nutzt die v4-Embeddings aus assessments.summary_embedding (gefüllt durch #123)
und baut eine hierarchische Cluster-Struktur ohne externe Dependencies
(kein sklearn, kein numpy für <500 Assessments ist pure Python ausreichend).
Algorithmus: Connected-Components via Union-Find über Kanten mit
Cosine-Similarity threshold. Level 0 = alle Anträge, Level 1 tighter Cluster.
Bei Clustern > 30 wird rekursiv mit höherem Threshold nachgeteilt.
"""
import json
import logging
import math
from collections import Counter
from typing import Optional
import aiosqlite
from .config import settings
logger = logging.getLogger(__name__)
# Cosine-Similarity-Thresholds
# Empirisch kalibriert an der Prod-DB (57 Assessments, 2026-04-11):
# 0.50 → 6 sinnvolle Cluster + 26 singletons (bester Default)
# 0.55 → 5 tighter Cluster
# 0.60 → 4 kleine Cluster, zu streng (die meisten themenähnlichen
# Anträge fallen raus)
# 0.70+ → fast alle singletons
# v4-Embeddings auf deutschen Parlamentsanträgen clustern bei ~0.50.
DEFAULT_THRESHOLD = 0.55
SUBCLUSTER_THRESHOLD = 0.70
MAX_CLUSTER_SIZE = 30 # darüber: sub-clustern
# ─── Math-Helpers ───────────────────────────────────────────────────────────
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
class UnionFind:
"""Klassisches Union-Find mit Path-Compression."""
def __init__(self, n: int):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x: int) -> int:
root = x
while self.parent[root] != root:
root = self.parent[root]
# Path-Compression
while self.parent[x] != root:
self.parent[x], x = root, self.parent[x]
return root
def union(self, a: int, b: int) -> None:
ra, rb = self.find(a), self.find(b)
if ra == rb:
return
if self.rank[ra] < self.rank[rb]:
self.parent[ra] = rb
elif self.rank[ra] > self.rank[rb]:
self.parent[rb] = ra
else:
self.parent[rb] = ra
self.rank[ra] += 1
# ─── DB-Lader ───────────────────────────────────────────────────────────────
async def load_assessment_items(
bundesland: Optional[str] = None,
) -> list[dict]:
"""Lädt alle Assessments mit gefülltem summary_embedding."""
sql = """
SELECT drucksache, title, fraktionen, datum, link, bundesland,
gwoe_score, empfehlung, empfehlung_symbol, themen,
summary_embedding
FROM assessments
WHERE summary_embedding IS NOT NULL
"""
params: list = []
if bundesland:
sql += " AND bundesland = ?"
params.append(bundesland)
items = []
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(sql, params) as cur:
async for row in cur:
try:
vec = json.loads(bytes(row["summary_embedding"]).decode())
except Exception:
logger.warning("bad embedding for %s", row["drucksache"])
continue
items.append({
"drucksache": row["drucksache"],
"title": row["title"],
"fraktionen": json.loads(row["fraktionen"] or "[]"),
"datum": row["datum"],
"link": row["link"],
"bundesland": row["bundesland"],
"gwoe_score": row["gwoe_score"],
"empfehlung": row["empfehlung"],
"empfehlung_symbol": row["empfehlung_symbol"],
"themen": json.loads(row["themen"] or "[]"),
"embedding": vec,
})
return items
# ─── Clustering ─────────────────────────────────────────────────────────────
def _cluster_indices(items: list[dict], threshold: float) -> list[list[int]]:
"""Union-Find-Clustering: Knoten = Items, Kante = cosine ≥ threshold."""
n = len(items)
uf = UnionFind(n)
for i in range(n):
for j in range(i + 1, n):
if _cosine(items[i]["embedding"], items[j]["embedding"]) >= threshold:
uf.union(i, j)
groups: dict[int, list[int]] = {}
for i in range(n):
root = uf.find(i)
groups.setdefault(root, []).append(i)
# Sortiere Cluster absteigend nach Größe
return sorted(groups.values(), key=len, reverse=True)
def _dominant_fraktion(items: list[dict]) -> Optional[str]:
counts: Counter = Counter()
for item in items:
for f in item.get("fraktionen") or []:
counts[f] += 1
if not counts:
return None
return counts.most_common(1)[0][0]
def _cluster_label(items: list[dict]) -> str:
"""Generiert ein Cluster-Label aus den häufigsten Themen der Mitglieder.
Nimmt die Top-2-3 Themen die in der Mehrheit der Cluster-Mitglieder
vorkommen und kombiniert sie zu einem prägnanten Label.
Fallback: kürzester Titel.
"""
# Themen-Häufigkeit über alle Cluster-Mitglieder
themen_counts: Counter = Counter()
for item in items:
for thema in item.get("themen") or []:
themen_counts[thema] += 1
if themen_counts:
# Top-Themen die in ≥50% der Mitglieder vorkommen, max 3
threshold = max(1, len(items) // 2)
top = [t for t, c in themen_counts.most_common(5) if c >= threshold][:3]
if top:
return " · ".join(top)
# Fallback: kürzester Titel
titles = [i["title"] for i in items if i.get("title")]
if titles:
return min(titles, key=len)
return "Cluster"
def _cluster_summary(cluster_items: list[dict], include_edges: bool = False) -> dict:
"""Zusammenfassung eines Clusters für die API-Antwort."""
scores = [i["gwoe_score"] for i in cluster_items if i.get("gwoe_score") is not None]
avg_score = round(sum(scores) / len(scores), 1) if scores else None
out = {
"size": len(cluster_items),
"label": _cluster_label(cluster_items),
"dominant_fraktion": _dominant_fraktion(cluster_items),
"avg_gwoe_score": avg_score,
"drucksachen": [i["drucksache"] for i in cluster_items],
}
if include_edges:
# Detail-Items pro Mitglied (für Force-Graph-Rendering)
out["nodes"] = [
{
"drucksache": i["drucksache"],
"title": i["title"],
"bundesland": i["bundesland"],
"fraktionen": i["fraktionen"],
"gwoe_score": i["gwoe_score"],
"empfehlung": i["empfehlung"],
}
for i in cluster_items
]
# Pairwise Cosine-Similarity als Kanten
edges = []
for a in range(len(cluster_items)):
for b in range(a + 1, len(cluster_items)):
sim = _cosine(cluster_items[a]["embedding"], cluster_items[b]["embedding"])
edges.append({"a": a, "b": b, "sim": round(sim, 3)})
out["edges"] = edges
return out
async def build_hierarchy(
bundesland: Optional[str] = None,
threshold: float = DEFAULT_THRESHOLD,
subcluster_threshold: float = SUBCLUSTER_THRESHOLD,
max_cluster_size: int = MAX_CLUSTER_SIZE,
) -> dict:
"""Lädt Assessments, clustert sie hierarchisch und gibt eine serialisierbare
Struktur zurück:
{
"meta": {"total": N, "threshold": 0.70, ...},
"clusters": [
{"size": 12, "label": ..., "dominant_fraktion": ...,
"drucksachen": [...], "subclusters": [ ... ] | None},
...
],
"singletons": [drucksache, drucksache, ...]
}
Bei Clustern größer als max_cluster_size wird rekursiv mit
subcluster_threshold ein zweiter Durchgang gestartet.
"""
items = await load_assessment_items(bundesland=bundesland)
if not items:
return {
"meta": {"total": 0, "threshold": threshold, "bundesland": bundesland},
"clusters": [],
"singletons": [],
}
top_groups = _cluster_indices(items, threshold)
clusters_out: list[dict] = []
singletons_out: list[str] = []
for group in top_groups:
if len(group) == 1:
singletons_out.append(items[group[0]]["drucksache"])
continue
cluster_items = [items[i] for i in group]
entry = _cluster_summary(cluster_items, include_edges=True)
# Sub-Clustern falls zu groß
if len(cluster_items) > max_cluster_size:
sub_groups = _cluster_indices(cluster_items, subcluster_threshold)
subs = []
for sg in sub_groups:
if len(sg) == 1:
continue
subs.append(_cluster_summary([cluster_items[i] for i in sg]))
entry["subclusters"] = subs
else:
entry["subclusters"] = None
clusters_out.append(entry)
return {
"meta": {
"total": len(items),
"threshold": threshold,
"subcluster_threshold": subcluster_threshold,
"max_cluster_size": max_cluster_size,
"bundesland": bundesland,
"num_clusters": len(clusters_out),
"num_singletons": len(singletons_out),
},
"clusters": clusters_out,
"singletons": singletons_out,
}
# ─── Ähnlichkeits-Suche für #108 Teil B ─────────────────────────────────────
async def find_similar_assessments(drucksache: str, top_k: int = 5) -> list[dict]:
"""Findet die top_k ähnlichsten Assessments zu einem gegebenen per
Cosine-Similarity über das Summary-Embedding."""
items = await load_assessment_items()
target = next((i for i in items if i["drucksache"] == drucksache), None)
if target is None:
return []
scored = []
for other in items:
if other["drucksache"] == drucksache:
continue
sim = _cosine(target["embedding"], other["embedding"])
scored.append((sim, other))
scored.sort(key=lambda t: t[0], reverse=True)
return [
{
"drucksache": other["drucksache"],
"title": other["title"],
"bundesland": other["bundesland"],
"fraktionen": other["fraktionen"],
"gwoe_score": other["gwoe_score"],
"empfehlung": other["empfehlung"],
"similarity": round(sim, 3),
}
for sim, other in scored[:top_k]
]

View File

@ -20,15 +20,40 @@ class Settings(BaseSettings):
llm_model_default: str = "qwen-plus-latest" llm_model_default: str = "qwen-plus-latest"
llm_model_premium: str = "qwen-max" llm_model_premium: str = "qwen-max"
# Keycloak (TODO) # Embedding-Modell: neue Rows werden immer mit embedding_model_write geschrieben,
# Lese-Queries filtern nach embedding_model_read. Zwei Settings erlauben einen
# Zero-Downtime-Switch von v3 auf v4 (siehe Issue #123):
# Phase 1: write=v4, read=v3 → Prod läuft weiter, Reindex füllt v4-Rows
# Phase 2: write=v4, read=v4 → Switch aktiv, alte v3-Rows können gelöscht werden
embedding_model_write: str = "text-embedding-v4"
embedding_model_read: str = "text-embedding-v3"
embedding_dimensions: int = 1024
# Keycloak
keycloak_url: str = "" keycloak_url: str = ""
keycloak_realm: str = "" keycloak_realm: str = ""
keycloak_client_id: str = "" keycloak_client_id: str = ""
keycloak_admin_user: str = ""
keycloak_admin_password: str = ""
# Server # Server
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8000 port: int = 8000
# SMTP (Issue #124 E-Mail-Benachrichtigung)
# 1blu: smtp.1blu.de:465 SSL, username = Postfachname (NICHT E-Mail!),
# z.B. "q294440_0-gwoe-toppyr". Passwort via ENV SMTP_PASSWORD.
smtp_host: str = ""
smtp_port: int = 465
smtp_user: str = ""
smtp_password: str = ""
smtp_from_email: str = "noreply@toppyr.de"
smtp_from_name: str = "GWÖ-Antragsprüfer"
# URL-Basis für Links in Mails (Unsubscribe, Detail-Ansicht)
base_url: str = "https://gwoe.toppyr.de"
# Token für Unsubscribe-Links (HMAC-Secret)
unsubscribe_secret: str = "change-me-in-prod"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@ -63,12 +63,38 @@ async def init_db():
) )
""") """)
# Migration: drucksache-Spalte zu jobs (für Queue-Resume nach Restart) # Migrations
cursor = await db.execute("PRAGMA table_info(jobs)") cursor = await db.execute("PRAGMA table_info(jobs)")
cols = {r[1] for r in await cursor.fetchall()} job_cols = {r[1] for r in await cursor.fetchall()}
if "drucksache" not in cols: if "drucksache" not in job_cols:
await db.execute("ALTER TABLE jobs ADD COLUMN drucksache TEXT") await db.execute("ALTER TABLE jobs ADD COLUMN drucksache TEXT")
cursor = await db.execute("PRAGMA table_info(assessments)")
ass_cols = {r[1] for r in await cursor.fetchall()}
if "konfidenz" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN konfidenz TEXT")
# #123 Embedding-Migration: Assessment-Zusammenfassungen bekommen
# eigene Embeddings für Clustering (#105) und Ähnlichkeitssuche (#108).
if "summary_embedding" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN summary_embedding BLOB")
if "embedding_model" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN embedding_model TEXT")
# #127: Drucksache-Typ (Original vom Landtag + normiert)
if "typ" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN typ TEXT")
if "typ_normiert" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN typ_normiert TEXT")
# #133: Social-Media-Texte pro Antrag (vom LLM generiert)
if "share_threads" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN share_threads TEXT")
if "share_twitter" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN share_twitter TEXT")
if "share_mastodon" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN share_mastodon TEXT")
# #128: Fraktionen ohne hinterlegtes Wahlprogramm (JSON-Array)
if "fehlende_programme" not in ass_cols:
await db.execute("ALTER TABLE assessments ADD COLUMN fehlende_programme TEXT")
# Bookmarks (#94) # Bookmarks (#94)
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS bookmarks ( CREATE TABLE IF NOT EXISTS bookmarks (
@ -79,6 +105,20 @@ async def init_db():
) )
""") """)
# Merkliste — serverseitig persistent (#140)
await db.execute("""
CREATE TABLE IF NOT EXISTS merkliste (
user_id TEXT NOT NULL,
antrag_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
notiz TEXT,
PRIMARY KEY (user_id, antrag_id)
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_merkliste_user ON merkliste(user_id)"
)
# Kommentare (#94) # Kommentare (#94)
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS comments ( CREATE TABLE IF NOT EXISTS comments (
@ -95,6 +135,130 @@ async def init_db():
"CREATE INDEX IF NOT EXISTS idx_comments_drucksache ON comments(drucksache)" "CREATE INDEX IF NOT EXISTS idx_comments_drucksache ON comments(drucksache)"
) )
# Votes / Crowd-Validation (#112)
await db.execute("""
CREATE TABLE IF NOT EXISTS votes (
user_id TEXT NOT NULL,
drucksache TEXT NOT NULL,
target TEXT NOT NULL DEFAULT 'overall',
vote TEXT NOT NULL CHECK(vote IN ('up', 'down')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, drucksache, target)
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_votes_drucksache ON votes(drucksache)"
)
# Assessment-Versionshistorie (#110)
await db.execute("""
CREATE TABLE IF NOT EXISTS assessment_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drucksache TEXT NOT NULL,
version INTEGER NOT NULL,
gwoe_score REAL,
model TEXT,
snapshot TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_versions_drucksache ON assessment_versions(drucksache)"
)
# E-Mail-Abonnements (#124)
# bundesland/partei NULL = beides "alle". frequency = 'daily' (weitere
# später). last_sent als ISO-Timestamp, initial leer = sofort beim
# ersten Digest-Lauf eligible.
await db.execute("""
CREATE TABLE IF NOT EXISTS email_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
email TEXT NOT NULL,
bundesland TEXT,
partei TEXT,
frequency TEXT NOT NULL DEFAULT 'daily',
last_sent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_subs_user ON email_subscriptions(user_id)"
)
# Monitoring-Tabellen (#135)
await db.execute("""
CREATE TABLE IF NOT EXISTS monitoring_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bundesland TEXT NOT NULL,
drucksache TEXT NOT NULL,
title TEXT,
datum TEXT,
typ TEXT,
typ_normiert TEXT,
fraktionen TEXT, -- JSON array
link TEXT,
is_assessed INTEGER NOT NULL DEFAULT 0, -- JOIN-Flag, aktuell nicht im Report
seen_first_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(bundesland, drucksache)
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_monitoring_scans_bl ON monitoring_scans(bundesland)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_monitoring_scans_first ON monitoring_scans(seen_first_at)"
)
await db.execute("""
CREATE TABLE IF NOT EXISTS monitoring_daily_summary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_date TEXT NOT NULL, -- ISO-Datum YYYY-MM-DD
bundesland TEXT NOT NULL,
total_seen INTEGER NOT NULL DEFAULT 0,
new_count INTEGER NOT NULL DEFAULT 0,
errors TEXT, -- Adapter-Fehlermeldungen (NULL = kein Fehler)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(scan_date, bundesland)
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_monitoring_summary_date ON monitoring_daily_summary(scan_date)"
)
# abgeordnetenwatch-Abstimmungsdaten (#106 Phase 1)
await db.execute("""
CREATE TABLE IF NOT EXISTS abgeordnetenwatch_polls (
poll_id INTEGER PRIMARY KEY,
parliament_id INTEGER NOT NULL,
bundesland TEXT NOT NULL,
drucksache TEXT,
titel TEXT,
datum DATE,
accepted BOOLEAN,
topics TEXT,
legislature_label TEXT,
synced_at TIMESTAMP
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_aw_polls_ds "
"ON abgeordnetenwatch_polls(drucksache)"
)
await db.execute("""
CREATE TABLE IF NOT EXISTS abgeordnetenwatch_votes (
poll_id INTEGER NOT NULL,
politician_id INTEGER NOT NULL,
politician_name TEXT,
partei TEXT,
vote TEXT,
PRIMARY KEY (poll_id, politician_id),
FOREIGN KEY (poll_id) REFERENCES abgeordnetenwatch_polls(poll_id)
)
""")
await db.commit() await db.commit()
@ -134,6 +298,160 @@ async def get_bookmarks(user_id: str) -> list[str]:
return [r[0] for r in await rows.fetchall()] return [r[0] for r in await rows.fetchall()]
# ─── Merkliste-Functions (#140) ─────────────────────────────────────────────
async def merkliste_add(user_id: str, antrag_id: str, notiz: Optional[str] = None) -> dict:
"""Eintrag zur Merkliste hinzufügen (Upsert). Gibt den Eintrag zurück."""
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"""INSERT INTO merkliste (user_id, antrag_id, notiz)
VALUES (?, ?, ?)
ON CONFLICT(user_id, antrag_id) DO UPDATE SET notiz = COALESCE(excluded.notiz, merkliste.notiz)""",
(user_id, antrag_id, notiz),
)
await db.commit()
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT antrag_id, notiz, created_at FROM merkliste WHERE user_id=? AND antrag_id=?",
(user_id, antrag_id),
)
r = await row.fetchone()
return dict(r) if r else {"antrag_id": antrag_id, "notiz": notiz}
async def merkliste_remove(user_id: str, antrag_id: str) -> bool:
"""Eintrag aus der Merkliste entfernen. Gibt True zurück wenn gelöscht."""
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"DELETE FROM merkliste WHERE user_id=? AND antrag_id=?",
(user_id, antrag_id),
)
await db.commit()
return cur.rowcount > 0
async def merkliste_list(user_id: str) -> list[dict]:
"""Alle Merklisten-Einträge eines Users, sortiert nach created_at DESC."""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT antrag_id, notiz, created_at FROM merkliste WHERE user_id=? ORDER BY created_at DESC",
(user_id,),
)
return [dict(r) for r in await rows.fetchall()]
async def merkliste_bulk_add(user_id: str, entries: list[dict]) -> int:
"""Mehrere Einträge auf einmal hinzufügen (Upsert). Für localStorage-Migration.
Jeder Eintrag muss ``antrag_id`` enthalten; ``notiz`` ist optional.
Gibt die Anzahl verarbeiteter Einträge zurück.
"""
async with aiosqlite.connect(settings.db_path) as db:
count = 0
for entry in entries:
antrag_id = entry.get("antrag_id")
if not antrag_id:
continue
notiz = entry.get("notiz")
await db.execute(
"""INSERT INTO merkliste (user_id, antrag_id, notiz)
VALUES (?, ?, ?)
ON CONFLICT(user_id, antrag_id) DO NOTHING""",
(user_id, antrag_id, notiz),
)
count += 1
await db.commit()
return count
# ─── Email-Subscription-Functions (#124) ────────────────────────────────────
async def create_subscription(
user_id: str,
email: str,
bundesland: Optional[str] = None,
partei: Optional[str] = None,
frequency: str = "daily",
) -> int:
"""Neues Abo anlegen. Gibt die neue Abo-ID zurück."""
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"INSERT INTO email_subscriptions (user_id, email, bundesland, partei, frequency) "
"VALUES (?, ?, ?, ?, ?)",
(user_id, email, bundesland, partei, frequency),
)
await db.commit()
return cur.lastrowid
async def list_subscriptions(user_id: str) -> list[dict]:
"""Alle Abos eines Users."""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT id, email, bundesland, partei, frequency, last_sent, created_at "
"FROM email_subscriptions WHERE user_id=? ORDER BY created_at DESC",
(user_id,),
)
return [dict(r) for r in await rows.fetchall()]
async def list_all_subscriptions() -> list[dict]:
"""Alle Abos aller User — nur für Admin-Endpoints."""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT id, user_id, email, bundesland, partei, frequency, last_sent, created_at "
"FROM email_subscriptions ORDER BY created_at DESC"
)
return [dict(r) for r in await rows.fetchall()]
async def delete_subscription(user_id: str, sub_id: int) -> bool:
"""Abo löschen. Prüft, dass es dem User gehört."""
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"DELETE FROM email_subscriptions WHERE id=? AND user_id=?",
(sub_id, user_id),
)
await db.commit()
return cur.rowcount > 0
async def delete_subscription_by_id(sub_id: int) -> bool:
"""Abo per ID löschen (für Unsubscribe-Token-Link, kein User-Check)."""
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"DELETE FROM email_subscriptions WHERE id=?", (sub_id,)
)
await db.commit()
return cur.rowcount > 0
async def get_all_subscriptions_due(frequency: str = "daily") -> list[dict]:
"""Alle Abos der gegebenen Frequency die einen Digest brauchen
(last_sent IS NULL oder älter als 24h für daily)."""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT * FROM email_subscriptions "
"WHERE frequency = ? "
" AND (last_sent IS NULL OR datetime(last_sent) < datetime('now', '-23 hours'))",
(frequency,),
)
return [dict(r) for r in await rows.fetchall()]
async def mark_subscription_sent(sub_id: int) -> None:
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"UPDATE email_subscriptions SET last_sent = datetime('now') WHERE id = ?",
(sub_id,),
)
await db.commit()
# ─── Comment-Functions (#94) ──────────────────────────────────────────────── # ─── Comment-Functions (#94) ────────────────────────────────────────────────
async def add_comment(user_id: str, user_name: str, drucksache: str, async def add_comment(user_id: str, user_name: str, drucksache: str,
@ -201,6 +519,79 @@ async def delete_comment(comment_id: int, user_id: str) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
# ─── Assessment-History (#110) ──────────────────────────────────────────
async def get_assessment_history(drucksache: str) -> list[dict]:
"""Get version history for an assessment."""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT version, gwoe_score, model, created_at FROM assessment_versions "
"WHERE drucksache=? ORDER BY version DESC",
(drucksache,),
)
return [dict(r) for r in await rows.fetchall()]
# ─── Vote-Functions (#112 Crowd-Validation) ───────────────────────────────
async def upsert_vote(user_id: str, drucksache: str, target: str, vote: str) -> dict:
"""Set or toggle a vote. Returns current vote state."""
async with aiosqlite.connect(settings.db_path) as db:
existing = await db.execute(
"SELECT vote FROM votes WHERE user_id=? AND drucksache=? AND target=?",
(user_id, drucksache, target),
)
row = await existing.fetchone()
if row and row[0] == vote:
# Same vote again → remove (toggle off)
await db.execute(
"DELETE FROM votes WHERE user_id=? AND drucksache=? AND target=?",
(user_id, drucksache, target),
)
await db.commit()
return {"vote": None}
else:
await db.execute(
"INSERT OR REPLACE INTO votes (user_id, drucksache, target, vote) VALUES (?, ?, ?, ?)",
(user_id, drucksache, target, vote),
)
await db.commit()
return {"vote": vote}
async def get_votes(drucksache: str, user_id: str = None) -> dict:
"""Get aggregated votes for a drucksache + optional user's own votes."""
async with aiosqlite.connect(settings.db_path) as db:
# Aggregated counts per target
rows = await db.execute(
"SELECT target, vote, COUNT(*) as cnt FROM votes "
"WHERE drucksache=? GROUP BY target, vote",
(drucksache,),
)
counts = {}
for r in await rows.fetchall():
target, vote, cnt = r
if target not in counts:
counts[target] = {"up": 0, "down": 0}
counts[target][vote] = cnt
# User's own votes
my_votes = {}
if user_id:
rows = await db.execute(
"SELECT target, vote FROM votes WHERE drucksache=? AND user_id=?",
(drucksache, user_id),
)
for r in await rows.fetchall():
my_votes[r[0]] = r[1]
return {"counts": counts, "my_votes": my_votes}
async def create_job( async def create_job(
job_id: str, job_id: str,
input_preview: str, input_preview: str,
@ -263,11 +654,34 @@ async def get_user_jobs(user_id: str, limit: int = 50) -> list[dict]:
async def upsert_assessment(data: dict) -> bool: async def upsert_assessment(data: dict) -> bool:
"""Insert or update an assessment.""" """Insert or update an assessment. Archives old version if exists (#110)."""
import json import json
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
async with aiosqlite.connect(settings.db_path) as db: async with aiosqlite.connect(settings.db_path) as db:
# Alte Version archivieren, falls vorhanden
drucksache = data.get("drucksache")
db.row_factory = aiosqlite.Row
old = await db.execute("SELECT * FROM assessments WHERE drucksache=?", (drucksache,))
old_row = await old.fetchone()
if old_row:
old_dict = dict(old_row)
# Version = bisherige Anzahl + 1
ver_count = await db.execute(
"SELECT COUNT(*) FROM assessment_versions WHERE drucksache=?", (drucksache,)
)
version = (await ver_count.fetchone())[0] + 1
await db.execute(
"INSERT INTO assessment_versions (drucksache, version, gwoe_score, model, snapshot) "
"VALUES (?, ?, ?, ?, ?)",
(drucksache, version, old_dict.get("gwoe_score"), old_dict.get("model"),
json.dumps(old_dict, ensure_ascii=False, default=str)),
)
db.row_factory = None
# #123: Assessment-Embedding (optional, kann None sein wenn Embedding-
# API gerade down war — wird vom Backfill-Script später nachgezogen)
summary_embedding = data.get("summary_embedding") # bytes | None
embedding_model = data.get("embedding_model") # str | None
await db.execute(""" await db.execute("""
INSERT INTO assessments ( INSERT INTO assessments (
drucksache, title, fraktionen, datum, link, bundesland, drucksache, title, fraktionen, datum, link, bundesland,
@ -275,13 +689,24 @@ async def upsert_assessment(data: dict) -> bool:
wahlprogramm_scores, verbesserungen, staerken, schwaechen, wahlprogramm_scores, verbesserungen, staerken, schwaechen,
empfehlung, empfehlung_symbol, verbesserungspotenzial, empfehlung, empfehlung_symbol, verbesserungspotenzial,
themen, antrag_zusammenfassung, antrag_kernpunkte, themen, antrag_zusammenfassung, antrag_kernpunkte,
source, model, created_at, updated_at source, model, konfidenz,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) summary_embedding, embedding_model,
share_threads, share_twitter, share_mastodon,
fehlende_programme,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drucksache) DO UPDATE SET ON CONFLICT(drucksache) DO UPDATE SET
title = excluded.title, title = excluded.title,
gwoe_score = excluded.gwoe_score, gwoe_score = excluded.gwoe_score,
gwoe_begruendung = excluded.gwoe_begruendung, gwoe_begruendung = excluded.gwoe_begruendung,
gwoe_matrix = excluded.gwoe_matrix, gwoe_matrix = excluded.gwoe_matrix,
konfidenz = excluded.konfidenz,
summary_embedding = COALESCE(excluded.summary_embedding, assessments.summary_embedding),
embedding_model = COALESCE(excluded.embedding_model, assessments.embedding_model),
share_threads = COALESCE(excluded.share_threads, assessments.share_threads),
share_twitter = COALESCE(excluded.share_twitter, assessments.share_twitter),
share_mastodon = COALESCE(excluded.share_mastodon, assessments.share_mastodon),
fehlende_programme = excluded.fehlende_programme,
updated_at = excluded.updated_at updated_at = excluded.updated_at
""", ( """, (
data.get("drucksache"), data.get("drucksache"),
@ -306,6 +731,13 @@ async def upsert_assessment(data: dict) -> bool:
json.dumps(data.get("antragKernpunkte", [])), json.dumps(data.get("antragKernpunkte", [])),
data.get("source", "webapp"), data.get("source", "webapp"),
data.get("model"), data.get("model"),
data.get("konfidenz"),
summary_embedding,
embedding_model,
data.get("share_threads"),
data.get("share_twitter"),
data.get("share_mastodon"),
json.dumps(data.get("fehlendeProgramme", [])),
now, now now, now
)) ))
await db.commit() await db.commit()
@ -324,9 +756,10 @@ async def get_assessment(drucksache: str) -> Optional[dict]:
if row: if row:
d = dict(row) d = dict(row)
# Parse JSON fields # Parse JSON fields
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt", for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
"wahlprogramm_scores", "verbesserungen", "staerken", "wahlprogramm_scores", "verbesserungen", "staerken",
"schwaechen", "themen", "antrag_kernpunkte"]: "schwaechen", "themen", "antrag_kernpunkte",
"fehlende_programme"]:
if d.get(field): if d.get(field):
try: try:
d[field] = json.loads(d[field]) d[field] = json.loads(d[field])
@ -370,9 +803,10 @@ async def get_all_assessments(bundesland: str = None) -> list[dict]:
for row in rows: for row in rows:
d = dict(row) d = dict(row)
# Parse JSON fields # Parse JSON fields
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt", for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
"wahlprogramm_scores", "verbesserungen", "staerken", "wahlprogramm_scores", "verbesserungen", "staerken",
"schwaechen", "themen", "antrag_kernpunkte"]: "schwaechen", "themen", "antrag_kernpunkte",
"fehlende_programme"]:
if d.get(field): if d.get(field):
try: try:
d[field] = json.loads(d[field]) d[field] = json.loads(d[field])
@ -471,9 +905,10 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50
results = [] results = []
for row in rows: for row in rows:
d = dict(row) d = dict(row)
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt", for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
"wahlprogramm_scores", "verbesserungen", "staerken", "wahlprogramm_scores", "verbesserungen", "staerken",
"schwaechen", "themen", "antrag_kernpunkte"]: "schwaechen", "themen", "antrag_kernpunkte",
"fehlende_programme"]:
if d.get(field): if d.get(field):
try: try:
d[field] = json.loads(d[field]) d[field] = json.loads(d[field])
@ -491,5 +926,258 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50
continue continue
results.append(d) results.append(d)
return results return results
# ─── Monitoring-Functions (#135) ────────────────────────────────────────────
async def upsert_monitoring_scan(
bundesland: str,
drucksache: str,
title: Optional[str],
datum: Optional[str],
typ: Optional[str],
typ_normiert: Optional[str],
fraktionen: list,
link: Optional[str],
now: str,
) -> bool:
"""UPSERT für einen Monitoring-Treffer.
seen_first_at bleibt beim ersten INSERT stabil. last_seen_at wird
bei jedem Lauf aktualisiert. Gibt True zurück wenn der Eintrag neu
angelegt wurde (new), False bei Update (already known).
"""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"SELECT id FROM monitoring_scans WHERE bundesland=? AND drucksache=?",
(bundesland, drucksache),
)
existing = await cur.fetchone()
if existing:
await db.execute(
"UPDATE monitoring_scans SET last_seen_at=? WHERE bundesland=? AND drucksache=?",
(now, bundesland, drucksache),
)
await db.commit()
return False # bereits bekannt
else:
await db.execute(
"""INSERT INTO monitoring_scans
(bundesland, drucksache, title, datum, typ, typ_normiert,
fraktionen, link, seen_first_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
bundesland, drucksache, title, datum, typ, typ_normiert,
_json.dumps(fraktionen or [], ensure_ascii=False),
link, now, now,
),
)
await db.commit()
return True # neu
async def upsert_monitoring_summary(
scan_date: str,
bundesland: str,
total_seen: int,
new_count: int,
errors: Optional[str],
) -> None:
"""UPSERT tägliche Zusammenfassung pro Bundesland."""
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"""INSERT INTO monitoring_daily_summary
(scan_date, bundesland, total_seen, new_count, errors)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scan_date, bundesland) DO UPDATE SET
total_seen = excluded.total_seen,
new_count = excluded.new_count,
errors = excluded.errors""",
(scan_date, bundesland, total_seen, new_count, errors),
)
await db.commit()
async def get_monitoring_summary(scan_date: str) -> list[dict]:
"""Alle Zusammenfassungen für ein bestimmtes Datum."""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT * FROM monitoring_daily_summary WHERE scan_date=? ORDER BY bundesland",
(scan_date,),
)
return [dict(r) for r in await rows.fetchall()]
# ─── abgeordnetenwatch-Functions (#106) ─────────────────────────────────────
async def upsert_aw_poll(
poll_id: int,
parliament_id: int,
bundesland: str,
drucksache: Optional[str],
titel: Optional[str],
datum: Optional[str],
accepted: Optional[bool],
topics: list,
legislature_label: Optional[str],
synced_at: str,
) -> bool:
"""UPSERT für einen abgeordnetenwatch-Poll.
Gibt True zurück wenn der Eintrag neu angelegt wurde, False bei Update.
"""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"SELECT poll_id FROM abgeordnetenwatch_polls WHERE poll_id=?",
(poll_id,),
)
existing = await cur.fetchone()
if existing:
await db.execute(
"""UPDATE abgeordnetenwatch_polls
SET drucksache=?, titel=?, datum=?, accepted=?, topics=?,
legislature_label=?, synced_at=?
WHERE poll_id=?""",
(
drucksache, titel, datum,
1 if accepted else 0,
_json.dumps(topics or [], ensure_ascii=False),
legislature_label, synced_at, poll_id,
),
)
await db.commit()
return False
else:
await db.execute(
"""INSERT INTO abgeordnetenwatch_polls
(poll_id, parliament_id, bundesland, drucksache, titel,
datum, accepted, topics, legislature_label, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
poll_id, parliament_id, bundesland, drucksache, titel,
datum,
1 if accepted else 0,
_json.dumps(topics or [], ensure_ascii=False),
legislature_label, synced_at,
),
)
await db.commit()
return True
async def upsert_aw_vote(
poll_id: int,
politician_id: int,
politician_name: Optional[str],
partei: Optional[str],
vote: str,
) -> bool:
"""UPSERT für eine Einzelstimme.
Gibt True zurück wenn der Eintrag neu angelegt wurde, False bei Update.
"""
async with aiosqlite.connect(settings.db_path) as db:
cur = await db.execute(
"SELECT poll_id FROM abgeordnetenwatch_votes WHERE poll_id=? AND politician_id=?",
(poll_id, politician_id),
)
existing = await cur.fetchone()
if existing:
await db.execute(
"""UPDATE abgeordnetenwatch_votes
SET politician_name=?, partei=?, vote=?
WHERE poll_id=? AND politician_id=?""",
(politician_name, partei, vote, poll_id, politician_id),
)
await db.commit()
return False
else:
await db.execute(
"""INSERT INTO abgeordnetenwatch_votes
(poll_id, politician_id, politician_name, partei, vote)
VALUES (?, ?, ?, ?, ?)""",
(poll_id, politician_id, politician_name, partei, vote),
)
await db.commit()
return True
async def get_abstimmungsverhalten(drucksache: str) -> Optional[dict]:
"""Gibt Fraktions-Aggregate (Yes/No/Abstain pro Partei) zurück.
Sucht in abgeordnetenwatch_polls nach der Drucksache und aggregiert
die Stimmen aus abgeordnetenwatch_votes nach Partei.
Returns:
Dict mit ``poll_id``, ``titel``, ``datum``, ``accepted`` und
``fraktionen`` (Liste von {partei, yes, no, abstain, no_show}).
None wenn keine Abstimmungsdaten vorliegen.
"""
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT * FROM abgeordnetenwatch_polls WHERE drucksache=? LIMIT 1",
(drucksache,),
)
poll_row = await cur.fetchone()
if not poll_row:
return None
poll = dict(poll_row)
rows = await db.execute(
"""SELECT partei,
SUM(CASE WHEN vote='yes' THEN 1 ELSE 0 END) AS yes,
SUM(CASE WHEN vote='no' THEN 1 ELSE 0 END) AS no,
SUM(CASE WHEN vote='abstain' THEN 1 ELSE 0 END) AS abstain,
SUM(CASE WHEN vote='no_show' THEN 1 ELSE 0 END) AS no_show
FROM abgeordnetenwatch_votes
WHERE poll_id=?
GROUP BY partei
ORDER BY (yes + no + abstain + no_show) DESC""",
(poll["poll_id"],),
)
fraktionen = [
{
"partei": r["partei"] or "Unbekannt",
"yes": r["yes"],
"no": r["no"],
"abstain": r["abstain"],
"no_show": r["no_show"],
}
for r in await rows.fetchall()
]
return {
"poll_id": poll["poll_id"],
"titel": poll["titel"],
"datum": poll["datum"],
"accepted": bool(poll["accepted"]),
"fraktionen": fraktionen,
}
async def get_monitoring_new_today(scan_date: str) -> list[dict]:
"""Alle Einträge aus monitoring_scans, die am scan_date erstmals gesehen wurden."""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT * FROM monitoring_scans WHERE seen_first_at LIKE ? ORDER BY bundesland, drucksache",
(f"{scan_date}%",),
)
result = []
for r in await rows.fetchall():
d = dict(r)
if d.get("fraktionen"):
try:
d["fraktionen"] = _json.loads(d["fraktionen"])
except Exception:
pass
result.append(d)
return result

88
app/drucksache_typen.py Normal file
View File

@ -0,0 +1,88 @@
"""Drucksache-Typ-Normalisierung (#127).
Jeder Landtag hat eigene Bezeichnungen für Dokumenttypen. Dieses Modul
normalisiert sie auf einheitliche Kategorien und bestimmt ob eine
Drucksache abstimmbar ist (= GWÖ-Bewertung sinnvoll).
"""
# Normierte Kategorien
ANTRAG = "antrag"
GESETZENTWURF = "gesetzentwurf"
AENDERUNGSANTRAG = "aenderungsantrag"
DRINGLICHKEITSANTRAG = "dringlichkeitsantrag"
ENTSCHLIESSUNGSANTRAG = "entschliessungsantrag"
BESCHLUSSEMPFEHLUNG = "beschlussempfehlung"
KLEINE_ANFRAGE = "kleine_anfrage"
GROSSE_ANFRAGE = "grosse_anfrage"
UNTERRICHTUNG = "unterrichtung"
PETITION = "petition"
WAHLVORSCHLAG = "wahlvorschlag"
BERICHT = "bericht"
SONSTIGE = "sonstige"
ABSTIMMBARE_TYPEN = {
ANTRAG,
GESETZENTWURF,
AENDERUNGSANTRAG,
DRINGLICHKEITSANTRAG,
ENTSCHLIESSUNGSANTRAG,
}
# Übersetzungstabelle: Original-Typ (lowercase) → normierter Typ.
# Keys werden case-insensitive + substring-matched.
# Reihenfolge: spezifischere zuerst (z.B. "kleine anfrage" vor "anfrage").
_TYP_MAP = [
# Abstimmbar
("gesetzentwurf", GESETZENTWURF),
("änderungsantrag", AENDERUNGSANTRAG),
("aenderungsantrag", AENDERUNGSANTRAG),
("dringlichkeitsantrag", DRINGLICHKEITSANTRAG),
("entschließungsantrag", ENTSCHLIESSUNGSANTRAG),
("entschliessungsantrag", ENTSCHLIESSUNGSANTRAG),
("antrag gemäß", ANTRAG),
("antrag", ANTRAG),
# Nicht abstimmbar
("kleine anfrage", KLEINE_ANFRAGE),
("große anfrage", GROSSE_ANFRAGE),
("grosse anfrage", GROSSE_ANFRAGE),
("anfrage", KLEINE_ANFRAGE),
("beschlussempfehlung", BESCHLUSSEMPFEHLUNG),
("unterrichtung", UNTERRICHTUNG),
("bericht", BERICHT),
("mitteilung", UNTERRICHTUNG),
("vorlage", UNTERRICHTUNG),
("petition", PETITION),
("wahlvorschlag", WAHLVORSCHLAG),
("stellungnahme", SONSTIGE),
("drucksache", SONSTIGE),
]
def normalize_typ(original: str) -> str:
"""Normalisiert einen BL-spezifischen Typ-String auf eine Kategorie.
Case-insensitiv, Substring-Match, spezifischere Patterns zuerst.
"""
if not original:
return SONSTIGE
low = original.lower().strip()
for pattern, norm in _TYP_MAP:
if pattern in low:
return norm
return SONSTIGE
def ist_abstimmbar(typ_normiert: str) -> bool:
"""Prüft ob ein normierter Typ zur Abstimmung steht.
``sonstige`` wird durchgelassen (benefit of the doubt) wenn der
Adapter den Typ nicht bestimmen kann (z.B. NRW liefert nur
"Drucksache"), wird der echte Check erst beim Analysieren gemacht
(aus dem Dokument-Text).
"""
return typ_normiert in ABSTIMMBARE_TYPEN or typ_normiert == SONSTIGE
def ist_abstimmbar_original(original: str) -> bool:
"""Convenience: prüft direkt am Original-Typ-String."""
return ist_abstimmbar(normalize_typ(original))

View File

@ -15,9 +15,15 @@ from openai import OpenAI
from .config import settings from .config import settings
# Embedding model # Embedding-Modell (Issue #123 Migration v3 → v4):
EMBEDDING_MODEL = "text-embedding-v3" # WRITE = Modell für neue Embeddings (Reindex, neue Assessments, neue Queries)
EMBEDDING_DIMENSIONS = 1024 # READ = Modell, nach dem find_relevant_chunks filtert
# Zwei Settings erlauben Zero-Downtime-Switch. Während der Reindex läuft, bleibt
# READ auf v3 (Prod funktioniert), WRITE produziert v4 parallel. Nach Reindex:
# READ auf v4 flippen, alte v3-Rows löschen.
EMBEDDING_MODEL = settings.embedding_model_write
EMBEDDING_MODEL_READ = settings.embedding_model_read
EMBEDDING_DIMENSIONS = settings.embedding_dimensions
# Database path # Database path
EMBEDDINGS_DB = settings.data_dir / "embeddings.db" EMBEDDINGS_DB = settings.data_dir / "embeddings.db"
@ -325,6 +331,14 @@ def init_embeddings_db():
conn.execute("ALTER TABLE chunks ADD COLUMN bundesland TEXT") conn.execute("ALTER TABLE chunks ADD COLUMN bundesland TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_bundesland ON chunks(bundesland)") conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_bundesland ON chunks(bundesland)")
# Migration #123: model-Spalte ergänzen. Bestehende Rows bekommen das alte
# v3-Default, neue Rows werden mit EMBEDDING_MODEL (aus config) befüllt.
if "model" not in cols:
conn.execute(
"ALTER TABLE chunks ADD COLUMN model TEXT NOT NULL DEFAULT 'text-embedding-v3'"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_model ON chunks(model)")
# Backfill: Bundesland aus PROGRAMME-Registry für bestehende Zeilen # Backfill: Bundesland aus PROGRAMME-Registry für bestehende Zeilen
# nachtragen. Grundsatzprogramme bleiben NULL. # nachtragen. Grundsatzprogramme bleiben NULL.
for prog_id, info in PROGRAMME.items(): for prog_id, info in PROGRAMME.items():
@ -347,17 +361,50 @@ def get_client() -> OpenAI:
) )
def create_embedding(text: str) -> list[float]: def create_embedding(text: str, model: Optional[str] = None) -> list[float]:
"""Create embedding for text using Qwen.""" """Create embedding for text using Qwen.
Args:
model: Optionaler Override. Default = EMBEDDING_MODEL (write model).
Während der Migration #123 ruft find_relevant_chunks mit
EMBEDDING_MODEL_READ auf, damit Query-Embeddings im selben
Vektorraum wie die gespeicherten Chunks liegen.
"""
client = get_client() client = get_client()
response = client.embeddings.create( response = client.embeddings.create(
model=EMBEDDING_MODEL, model=model or EMBEDDING_MODEL,
input=text, input=text,
dimensions=EMBEDDING_DIMENSIONS, dimensions=EMBEDDING_DIMENSIONS,
) )
return response.data[0].embedding return response.data[0].embedding
# DashScope text-embedding-v4 erlaubt bis zu 10 Texte pro Batch-Call.
# 10 ist das harte Maximum — bei mehr gibt die API Fehler.
EMBEDDING_BATCH_SIZE = 10
def create_embeddings_batch(texts: list[str], model: Optional[str] = None) -> list[list[float]]:
"""Batch-Embedding — ein API-Call für bis zu EMBEDDING_BATCH_SIZE Texte.
Gibt die Embeddings in derselben Reihenfolge wie die Input-Liste zurück.
Rate-Limit-freundlich: statt 10 sequentielle Calls genügt einer.
"""
if not texts:
return []
if len(texts) > EMBEDDING_BATCH_SIZE:
raise ValueError(f"Batch zu groß: {len(texts)} > {EMBEDDING_BATCH_SIZE}")
client = get_client()
response = client.embeddings.create(
model=model or EMBEDDING_MODEL,
input=texts,
dimensions=EMBEDDING_DIMENSIONS,
)
# DashScope gibt die Embeddings in der Reihenfolge zurück, in der sie
# gesendet wurden (index-basiert). Wir sortieren defensiv nach index.
return [d.embedding for d in sorted(response.data, key=lambda d: d.index)]
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]: def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
"""Split text into overlapping chunks by words.""" """Split text into overlapping chunks by words."""
words = text.split() words = text.split()
@ -403,8 +450,13 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
conn = sqlite3.connect(EMBEDDINGS_DB) conn = sqlite3.connect(EMBEDDINGS_DB)
# Remove existing chunks for this program # Remove existing chunks for this program — nur für das aktuelle WRITE-
conn.execute("DELETE FROM chunks WHERE programm_id = ?", (programm_id,)) # Modell, damit parallel existierende v3-Rows während der #123-Migration
# nicht verloren gehen.
conn.execute(
"DELETE FROM chunks WHERE programm_id = ? AND model = ?",
(programm_id, EMBEDDING_MODEL),
)
# Extract and chunk # Extract and chunk
pages = extract_text_with_pages(pdf_path) pages = extract_text_with_pages(pdf_path)
@ -422,8 +474,8 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
embedding_blob = json.dumps(embedding).encode() embedding_blob = json.dumps(embedding).encode()
conn.execute(""" conn.execute("""
INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding, bundesland) INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding, bundesland, model)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
programm_id, programm_id,
info["partei"], info["partei"],
@ -432,6 +484,7 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
chunk_text_content, chunk_text_content,
embedding_blob, embedding_blob,
info.get("bundesland"), # NULL für Grundsatzprogramme info.get("bundesland"), # NULL für Grundsatzprogramme
EMBEDDING_MODEL,
)) ))
total_chunks += 1 total_chunks += 1
except Exception as e: except Exception as e:
@ -445,6 +498,38 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
return total_chunks return total_chunks
def create_assessment_embedding(
title: str,
zusammenfassung: Optional[str],
themen: Optional[list[str]],
bundesland: Optional[str] = None,
) -> tuple[Optional[bytes], Optional[str]]:
"""Erzeuge ein Assessment-Embedding für Clustering (#105) und Ähnlichkeit (#108).
Kombiniert Titel + Kurzfassung + Themen + Bundesland zu einem einzelnen
String und embedded ihn mit dem aktuellen WRITE-Modell. Gibt `(None, None)`
zurück wenn die Embedding-API fehlschlägt das Backfill-Script zieht
solche Assessments später nach.
"""
parts = [title or ""]
if zusammenfassung:
parts.append(zusammenfassung)
if themen:
parts.append(", ".join(themen))
if bundesland:
parts.append(f"Bundesland: {bundesland}")
text = "\n".join(p for p in parts if p).strip()
if not text:
return None, None
try:
vec = create_embedding(text, model=EMBEDDING_MODEL)
return json.dumps(vec).encode(), EMBEDDING_MODEL
except Exception:
logger.exception("create_assessment_embedding failed")
return None, None
def cosine_similarity(a: list[float], b: list[float]) -> float: def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors.""" """Calculate cosine similarity between two vectors."""
dot = sum(x * y for x, y in zip(a, b)) dot = sum(x * y for x, y in zip(a, b))
@ -471,14 +556,17 @@ def find_relevant_chunks(
berücksichtigt. Wenn None, kein Filter. berücksichtigt. Wenn None, kein Filter.
""" """
query_embedding = create_embedding(query) # Query-Embedding muss im selben Vektorraum wie die gespeicherten Chunks
# liegen — während der Migration #123 ist das EMBEDDING_MODEL_READ.
query_embedding = create_embedding(query, model=EMBEDDING_MODEL_READ)
conn = sqlite3.connect(EMBEDDINGS_DB) conn = sqlite3.connect(EMBEDDINGS_DB)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
# Build query # Build query — filtert auf das aktive READ-Modell, damit v3- und
sql = "SELECT * FROM chunks WHERE 1=1" # v4-Embeddings nicht gemischt werden (Cosine wäre Nonsens).
params = [] sql = "SELECT * FROM chunks WHERE model = ?"
params = [EMBEDDING_MODEL_READ]
if parteien: if parteien:
placeholders = ",".join("?" * len(parteien)) placeholders = ",".join("?" * len(parteien))

220
app/mail.py Normal file
View File

@ -0,0 +1,220 @@
"""Mail-Sending + Daily-Digest für E-Mail-Benachrichtigungen (#124).
Nutzt die Standard-Library `smtplib` (blockierend) in einem Thread-Executor,
damit kein zusätzlicher Dependency-Eintrag nötig ist. 1blu SMTP:
smtp.1blu.de:465 SSL, username = Postfachname (NICHT E-Mail!)
Credentials kommen aus settings.smtp_user / smtp_password via ENV.
Unsubscribe-Token: HMAC-SHA256 von sub_id + secret, URL-sicher base64-encoded.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import html
import logging
import smtplib
import ssl
from datetime import datetime
from email.message import EmailMessage
from .config import settings
logger = logging.getLogger(__name__)
# ─── Unsubscribe-Token ──────────────────────────────────────────────────────
def _unsubscribe_token(sub_id: int) -> str:
"""Erzeugt HMAC-Token für Unsubscribe-Link."""
msg = str(sub_id).encode()
sig = hmac.new(settings.unsubscribe_secret.encode(), msg, hashlib.sha256).digest()
return base64.urlsafe_b64encode(sig).decode().rstrip("=")[:22]
def verify_unsubscribe_token(sub_id: int, token: str) -> bool:
"""Verifiziert, dass der Token zur sub_id passt. Konstante Zeit."""
expected = _unsubscribe_token(sub_id)
return hmac.compare_digest(expected, token)
def unsubscribe_url(sub_id: int) -> str:
token = _unsubscribe_token(sub_id)
return f"{settings.base_url}/unsubscribe/{sub_id}/{token}"
# ─── SMTP-Send ──────────────────────────────────────────────────────────────
def _send_sync(to_email: str, subject: str, text_body: str, html_body: str) -> None:
"""Blockierender Send via smtplib."""
if not settings.smtp_host or not settings.smtp_user:
raise RuntimeError("SMTP nicht konfiguriert (settings.smtp_host/user leer)")
msg = EmailMessage()
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=ctx) as server:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
async def send_mail(to_email: str, subject: str, text_body: str, html_body: str) -> None:
"""Async-Wrapper — SMTP-Call läuft im Thread-Executor."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _send_sync, to_email, subject, text_body, html_body)
# ─── Digest-Komposition ─────────────────────────────────────────────────────
def _filter_assessments(rows: list[dict], bundesland: str | None, partei: str | None, since: str | None) -> list[dict]:
"""Filtert Assessment-Rows nach Abo-Kriterien."""
result = []
for r in rows:
if bundesland and (r.get("bundesland") or "") != bundesland:
continue
if partei:
fraktionen = r.get("fraktionen") or []
if not any(partei.upper() in (f or "").upper() for f in fraktionen):
continue
if since and (r.get("updated_at") or "") <= since:
continue
result.append(r)
return result
def compose_digest(sub: dict, assessments: list[dict]) -> tuple[str, str, str]:
"""Baut Subject, Text- und HTML-Body für einen Digest.
Returns: (subject, text_body, html_body)
"""
n = len(assessments)
filter_label_parts = []
if sub.get("bundesland"):
filter_label_parts.append(sub["bundesland"])
if sub.get("partei"):
filter_label_parts.append(sub["partei"])
filter_label = " · ".join(filter_label_parts) if filter_label_parts else "alle Bundesländer & Parteien"
subject = f"[GWÖ-Antragsprüfer] {n} neue Bewertung{'en' if n != 1 else ''}{filter_label}"
unsub = unsubscribe_url(sub["id"])
# Plaintext
text_lines = [
f"Neue Antragsbewertungen — Filter: {filter_label}",
"=" * 60,
"",
]
for a in assessments[:20]:
score = a.get("gwoe_score")
title = a.get("title") or a.get("drucksache")
emp = a.get("empfehlung") or ""
fraktionen = ", ".join(a.get("fraktionen") or [])
url = f"{settings.base_url}/?drucksache={a.get('drucksache')}"
text_lines.append(f"{title}")
text_lines.append(f" Score: {score}/10 — {emp}")
text_lines.append(f" Fraktionen: {fraktionen}")
text_lines.append(f" {url}")
text_lines.append("")
if n > 20:
text_lines.append(f"… und {n - 20} weitere. Alle anzeigen: {settings.base_url}")
text_lines.append("")
text_lines.append("")
text_lines.append(f"Abo verwalten: {settings.base_url}")
text_lines.append(f"Abbestellen: {unsub}")
text_body = "\n".join(text_lines)
# HTML
html_items = []
for a in assessments[:20]:
score = a.get("gwoe_score")
title = html.escape(a.get("title") or a.get("drucksache") or "")
emp = html.escape(a.get("empfehlung") or "")
fraktionen = html.escape(", ".join(a.get("fraktionen") or []))
zus = html.escape((a.get("antrag_zusammenfassung") or "")[:200])
url = html.escape(f"{settings.base_url}/?drucksache={a.get('drucksache')}")
html_items.append(f"""
<div style="border-left:3px solid #007a80;padding:8px 12px;margin:12px 0;background:#f9f9f9">
<a href="{url}" style="color:#007a80;text-decoration:none;font-weight:bold">{title}</a><br>
<span style="color:#666;font-size:0.9em">Score: <b>{score}/10</b> {emp} {fraktionen}</span><br>
<span style="color:#444;font-size:0.9em">{zus}</span>
</div>""")
more_link = ""
if n > 20:
more_link = f'<p><a href="{settings.base_url}">… und {n - 20} weitere ansehen</a></p>'
html_body = f"""<!DOCTYPE html>
<html><body style="font-family:Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#333">
<h2 style="color:#007a80">{n} neue Antragsbewertung{'en' if n != 1 else ''}</h2>
<p style="color:#666">Filter: <b>{html.escape(filter_label)}</b></p>
{''.join(html_items)}
{more_link}
<hr style="border:none;border-top:1px solid #ddd;margin:20px 0">
<p style="font-size:0.85em;color:#888">
<a href="{html.escape(settings.base_url)}" style="color:#888">Abo verwalten</a> &middot;
<a href="{html.escape(unsub)}" style="color:#888">Abbestellen</a>
</p>
</body></html>"""
return subject, text_body, html_body
async def run_daily_digest() -> dict:
"""Daily-Digest-Runner. Iteriert alle due Abos und verschickt.
Gibt Statistik zurück: {sent, failed, skipped_empty}.
"""
from .database import (
get_all_assessments,
get_all_subscriptions_due,
mark_subscription_sent,
)
stats = {"sent": 0, "failed": 0, "skipped_empty": 0}
subs = await get_all_subscriptions_due("daily")
if not subs:
logger.info("run_daily_digest: keine due subscriptions")
return stats
all_assessments = await get_all_assessments(None)
for sub in subs:
matches = _filter_assessments(
all_assessments,
bundesland=sub.get("bundesland"),
partei=sub.get("partei"),
since=sub.get("last_sent"),
)
if not matches:
stats["skipped_empty"] += 1
# Last-sent trotzdem setzen, damit wir nicht jede Minute wieder testen
await mark_subscription_sent(sub["id"])
continue
try:
subject, text_body, html_body = compose_digest(sub, matches)
await send_mail(sub["email"], subject, text_body, html_body)
await mark_subscription_sent(sub["id"])
stats["sent"] += 1
logger.info("digest sent to %s (%d items)", sub["email"], len(matches))
except Exception:
logger.exception("digest failed for sub_id=%s", sub["id"])
stats["failed"] += 1
return stats
if __name__ == "__main__":
# python -m app.mail → führt den Daily-Digest-Lauf aus
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
result = asyncio.run(run_daily_digest())
print(f"Digest-Lauf fertig: {result}")

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,37 @@ class MatrixEntry(BaseModel):
rating: int = Field(..., ge=-5, le=5) # Neue Skala: -5 bis +5 rating: int = Field(..., ge=-5, le=5) # Neue Skala: -5 bis +5
symbol: Optional[str] = None symbol: Optional[str] = None
# ─── Domain-Verhalten (ADR 0008) ──────────────────────────────────────
def ist_fundamental_kritisch(self) -> bool:
"""True, wenn das Feld einen fundamentalen Widerspruch zu
GWÖ-Werten beschreibt (rating -4).
Diese Regel triggert den Score-Cap: ein einziges fundamental-
kritisches Feld deckelt den Gesamt-Score auf 3/10 (siehe
``Assessment.verletzt_score_cap``).
"""
return self.rating <= -4
def to_symbol(self) -> str:
"""Berechnet das Matrix-Symbol aus dem Rating.
Quelle: analyzer.py System-Prompt Matrix-Feldwertung (Skala -5 bis +5)".
Der LLM liefert das Symbol heute selbst; diese Methode erlaubt
server-seitige Konsistenz-Prüfung und ist die Basis, um das
Symbol-Feld perspektivisch ganz aus dem LLM-Output zu entfernen.
"""
r = self.rating
if r >= 4:
return "++"
if r >= 1:
return "+"
if r == 0:
return ""
if r >= -3:
return ""
return ""
class Zitat(BaseModel): class Zitat(BaseModel):
text: str text: str
@ -99,9 +130,51 @@ class Assessment(BaseModel):
themen: list[str] = [] themen: list[str] = []
antrag_zusammenfassung: Optional[str] = Field(None, alias="antragZusammenfassung") antrag_zusammenfassung: Optional[str] = Field(None, alias="antragZusammenfassung")
antrag_kernpunkte: Optional[list[str]] = Field(None, alias="antragKernpunkte") antrag_kernpunkte: Optional[list[str]] = Field(None, alias="antragKernpunkte")
konfidenz: Optional[str] = Field(None, description="LLM-Selbsteinschätzung: hoch/mittel/niedrig")
share_threads: Optional[str] = Field(None, alias="shareThreads", description="Social-Post für Threads (max 500 Zeichen)")
share_twitter: Optional[str] = Field(None, alias="shareTwitter", description="Social-Post für X/Twitter (max 280 Zeichen)")
share_mastodon: Optional[str] = Field(None, alias="shareMastodon", description="Social-Post für Mastodon (max 500 Zeichen)")
# #128: Fraktionen ohne hinterlegtes Wahlprogramm — wird server-seitig
# nach dem LLM-Call befüllt, nicht vom LLM selbst.
fehlende_programme: Optional[list[str]] = Field(
default_factory=list,
alias="fehlendeProgramme",
description="Fraktionen ohne hinterlegtes Wahlprogramm für dieses Bundesland",
)
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
# ─── Domain-Verhalten (ADR 0008) ──────────────────────────────────────
def ist_ablehnung(self) -> bool:
"""True, wenn die Empfehlung „Ablehnen" lautet."""
return self.empfehlung == Empfehlung.ABLEHNEN
def ist_uneingeschraenkt_unterstuetzend(self) -> bool:
"""True, wenn die Empfehlung „Uneingeschränkt unterstützen" lautet."""
return self.empfehlung == Empfehlung.UNEINGESCHRAENKT
def hat_fundamental_kritisches_feld(self) -> bool:
"""True, wenn mindestens ein Matrix-Feld rating ≤ -4 hat.
Basis für ``verletzt_score_cap``. Nutzt die VO-Methode
``MatrixEntry.ist_fundamental_kritisch``.
"""
return any(m.ist_fundamental_kritisch() for m in self.gwoe_matrix)
def verletzt_score_cap(self) -> bool:
"""Prüft die Regel aus dem System-Prompt:
Wenn ein Matrix-Feld rating -4 hat, ist Gesamt-Score max. 3/10.
Der LLM-Prompt formuliert diese Regel als Soll-Anweisung; sie kann
trotzdem verletzt werden. Diese Methode macht die Regel server-
seitig prüfbar und ist der Anker für die Warning-Logik in
``analyzer.py`` (Tag-4-Schritt der DDD-Lightweight-Migration).
"""
return self.hat_fundamental_kritisches_feld() and self.gwoe_score > 3.0
# --- Matrix constants --- # --- Matrix constants ---

332
app/monitoring.py Normal file
View File

@ -0,0 +1,332 @@
"""Täglicher Monitoring-Scan für neue Landtags-Drucksachen (#135).
Nur Metadaten kein PDF-Download, kein LLM-Call.
Ablauf:
1. Iteriert alle aktiven Bundesländer via aktive_bundeslaender().
2. Ruft adapter.search("", limit=50) (Fallback: " " oder "*") auf.
3. UPSERTs Treffer in monitoring_scans. seen_first_at bleibt stabil,
last_seen_at wird immer gesetzt.
4. Aggregiert Ergebnisse in monitoring_daily_summary.
5. Gibt ScanResult zurück, aus dem run_monitoring_digest() den
Mail-Digest baut.
Kosten-Schätzung (Qwen Plus, Stand April 2026):
Quelle: https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-7b-14b-72b-api-pricing
Input: 0.0004 USD / 1 K Token
Output: 0.0012 USD / 1 K Token
Kurs: 1 USD = 0.93 EUR (Näherung April 2026)
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .bundeslaender import aktive_bundeslaender
logger = logging.getLogger(__name__)
# ─── Kosten-Schätzung ────────────────────────────────────────────────────────
# Preise aus DashScope-Dokumentation (USD, Stand April 2026):
# https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-7b-14b-72b-api-pricing
_QWEN_PLUS_INPUT_USD_PER_1K = 0.0004
_QWEN_PLUS_OUTPUT_USD_PER_1K = 0.0012
_USD_TO_EUR = 0.93 # Näherungskurs April 2026 (als Konstante OK für Schätzung)
# Default-Annahmen pro Analyse (Durchschnittswerte aus Produktionsbetrieb)
_DEFAULT_AVG_IN_TOKENS = 20_000
_DEFAULT_AVG_OUT_TOKENS = 3_000
def estimate_cost_qwen_plus(
n_new: int,
avg_in_tokens: int = _DEFAULT_AVG_IN_TOKENS,
avg_out_tokens: int = _DEFAULT_AVG_OUT_TOKENS,
) -> float:
"""Schätzt die Analysekosten in EUR für n_new neue Drucksachen (Qwen Plus).
Rechnet auf Basis der offiziellen DashScope-Preise, Umrechnung USDEUR
mit festem Näherungskurs. Ergebnis ist eine Schätzung, keine Garantie.
Args:
n_new: Anzahl neuer Drucksachen.
avg_in_tokens: Durchschnittliche Input-Token pro Antrag (Default 20 000).
avg_out_tokens: Durchschnittliche Output-Token pro Antrag (Default 3 000).
Returns:
Geschätzte Kosten in EUR.
"""
if n_new <= 0:
return 0.0
input_cost_usd = (avg_in_tokens / 1000) * _QWEN_PLUS_INPUT_USD_PER_1K * n_new
output_cost_usd = (avg_out_tokens / 1000) * _QWEN_PLUS_OUTPUT_USD_PER_1K * n_new
total_eur = (input_cost_usd + output_cost_usd) * _USD_TO_EUR
return round(total_eur, 4)
# ─── Datenklassen ────────────────────────────────────────────────────────────
@dataclass
class BundeslandScanResult:
"""Scan-Ergebnis für ein einzelnes Bundesland."""
bundesland: str
total_seen: int = 0
new_count: int = 0
error: str | None = None
@dataclass
class DailyScanResult:
"""Gesamtergebnis eines daily_scan()-Laufs."""
scan_date: str # YYYY-MM-DD
results: list[BundeslandScanResult] = field(default_factory=list)
new_total: int = 0 # Summe aller new_count
total_seen: int = 0 # Summe aller total_seen
estimated_cost_eur: float = 0.0
errors: list[str] = field(default_factory=list)
# ─── Adapter-Suche ───────────────────────────────────────────────────────────
DEFAULT_DAILY_LIMIT = 50
# Bundesländer, die vom täglichen Monitoring-Scan ausgenommen sind.
# NI (Niedersachsen): NILAS-Portal erfordert Login — unauthentifizierte Anfragen
# liefern Login-Page-HTML, das der JSON-Comment-Parser als ~50 Junk-Records parsed.
# Ausnahme bleibt bis ein gültiger HAR-Capture vorliegt (siehe Issue #22).
_MONITORING_SKIP: frozenset[str] = frozenset({"NI"})
async def _search_adapter(adapter, bundesland_code: str, limit: int = DEFAULT_DAILY_LIMIT) -> list:
"""Sucht via Adapter nach aktuellen Drucksachen.
Probiert der Reihe nach: leerer String, Leerzeichen, Sternchen
und fängt alle Exceptions ab, damit ein Adapter-Fehler den
Gesamt-Scan nicht abbricht. ``limit`` steuert pro-Adapter-Obergrenze;
für Initial-Seeding ggf. höher setzen.
"""
for query in ("", " ", "*"):
try:
results = await adapter.search(query, limit=limit)
return results
except Exception as e:
if query == "*":
# Alle Versuche gescheitert — Exception nach oben durchreichen
raise
logger.debug(
"%s: search(%r) fehlgeschlagen (%s), versuche nächsten Query",
bundesland_code, query, e,
)
return []
# ─── Haupt-Scan ──────────────────────────────────────────────────────────────
async def daily_scan(limit: int = DEFAULT_DAILY_LIMIT) -> DailyScanResult:
"""Täglicher Scan aller aktiven Bundesländer nach neuen Drucksachen.
Kein PDF-Download, kein LLM-Call nur Metadaten. ``limit`` gilt
pro Adapter; für Initial-Seeding größer setzen (z.B. 500).
"""
from .parlamente import ADAPTERS
from .database import upsert_monitoring_scan, upsert_monitoring_summary
now_utc = datetime.now(timezone.utc)
scan_date = now_utc.strftime("%Y-%m-%d")
now_iso = now_utc.strftime("%Y-%m-%dT%H:%M:%S")
result = DailyScanResult(scan_date=scan_date)
active_bls = aktive_bundeslaender()
for bl in active_bls:
if bl.code in _MONITORING_SKIP:
logger.debug("%s: Monitoring-Skip aktiv — übersprungen", bl.code)
continue
adapter = ADAPTERS.get(bl.code)
if adapter is None:
logger.debug("Kein Adapter für %s — übersprungen", bl.code)
continue
bl_result = BundeslandScanResult(bundesland=bl.code)
try:
docs = await _search_adapter(adapter, bl.code, limit=limit)
except Exception as exc:
err_msg = f"{type(exc).__name__}: {str(exc)[:500]}"
logger.exception("Adapter-Fehler bei %s", bl.code)
bl_result.error = err_msg
result.errors.append(f"{bl.code}: {err_msg}")
await upsert_monitoring_summary(
scan_date=scan_date,
bundesland=bl.code,
total_seen=0,
new_count=0,
errors=err_msg,
)
result.results.append(bl_result)
continue
bl_result.total_seen = len(docs)
new_this_bl = 0
for doc in docs:
try:
is_new = await upsert_monitoring_scan(
bundesland=doc.bundesland,
drucksache=doc.drucksache,
title=doc.title,
datum=doc.datum,
typ=doc.typ,
typ_normiert=doc.typ_normiert,
fraktionen=doc.fraktionen,
link=doc.link,
now=now_iso,
)
if is_new:
new_this_bl += 1
except Exception:
logger.exception(
"DB-UPSERT fehlgeschlagen für %s/%s — wird übersprungen",
bl.code, getattr(doc, "drucksache", "?"),
)
bl_result.new_count = new_this_bl
await upsert_monitoring_summary(
scan_date=scan_date,
bundesland=bl.code,
total_seen=bl_result.total_seen,
new_count=bl_result.new_count,
errors=None,
)
logger.info(
"%s: %d gesehen, %d neu",
bl.code, bl_result.total_seen, bl_result.new_count,
)
result.results.append(bl_result)
result.new_total = sum(r.new_count for r in result.results)
result.total_seen = sum(r.total_seen for r in result.results)
result.estimated_cost_eur = estimate_cost_qwen_plus(result.new_total)
return result
# ─── Mail-Digest ─────────────────────────────────────────────────────────────
async def run_monitoring_digest(recipient: str) -> dict:
"""Führt daily_scan() durch und verschickt den Ergebnis-Digest per Mail.
Args:
recipient: Empfänger-Adresse (typischerweise der Admin).
Returns:
dict mit Scan-Statistiken + {"mail_sent": bool}.
"""
from .mail import send_mail
from .database import get_monitoring_new_today
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
scan_result = await daily_scan()
# Neue Drucksachen für den heutigen Tag laden
new_docs = await get_monitoring_new_today(scan_result.scan_date)
# Mail-Inhalt via Template rendern
tmpl_dir = Path(__file__).resolve().parent / "templates"
env = Environment(loader=FileSystemLoader(str(tmpl_dir)), autoescape=True)
tmpl = env.get_template("monitoring_digest.html")
html_body = tmpl.render(
scan_date=scan_result.scan_date,
new_total=scan_result.new_total,
total_seen=scan_result.total_seen,
estimated_cost_eur=scan_result.estimated_cost_eur,
results=scan_result.results,
new_docs=new_docs,
errors=scan_result.errors,
)
# Plaintext-Variante
text_body = _render_plain(scan_result, new_docs)
subject = (
f"[GWÖ-Monitor] {scan_result.scan_date}"
f"{scan_result.new_total} neue Drucksachen"
+ (f" ({len(scan_result.errors)} Fehler)" if scan_result.errors else "")
)
mail_sent = False
try:
await send_mail(recipient, subject, text_body, html_body)
mail_sent = True
logger.info("Monitoring-Digest verschickt an %s", recipient)
except Exception:
logger.exception("Monitoring-Digest: Mail-Versand fehlgeschlagen")
return {
"scan_date": scan_result.scan_date,
"new_total": scan_result.new_total,
"total_seen": scan_result.total_seen,
"estimated_cost_eur": scan_result.estimated_cost_eur,
"error_count": len(scan_result.errors),
"mail_sent": mail_sent,
}
def _render_plain(scan_result: DailyScanResult, new_docs: list[dict]) -> str:
"""Baut den Plaintext-Part des Monitoring-Digests."""
from .config import settings
lines = [
f"GWÖ-Antragsprüfer — Monitoring-Digest {scan_result.scan_date}",
"=" * 60,
"",
f"Neue Drucksachen: {scan_result.new_total}",
f"Gesamt gesehen: {scan_result.total_seen}",
f"Kosten-Schätzung: {scan_result.estimated_cost_eur:.4f} EUR",
"",
]
if scan_result.errors:
lines.append(f"Fehler ({len(scan_result.errors)}):")
for e in scan_result.errors:
lines.append(f"{e}")
lines.append("")
lines.append("Bundesland-Übersicht:")
for r in scan_result.results:
status = f"{r.new_count} neu / {r.total_seen} gesehen"
if r.error:
status = f"✗ Fehler: {r.error[:80]}"
lines.append(f" {r.bundesland:6s} {status}")
lines.append("")
if new_docs:
lines.append(f"Neue Drucksachen ({len(new_docs)}):")
for doc in new_docs[:30]:
title = (doc.get("title") or doc.get("drucksache") or "")[:80]
bl = doc.get("bundesland", "")
drucks = doc.get("drucksache", "")
lines.append(f" [{bl}] {drucks}{title}")
if len(new_docs) > 30:
lines.append(f" … und {len(new_docs) - 30} weitere")
lines.append("")
lines.append(f"Webapp: {settings.base_url}")
return "\n".join(lines)
if __name__ == "__main__":
# python -m app.monitoring <empfaenger@example.com>
import sys
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
to = sys.argv[1] if len(sys.argv) > 1 else "mail@tobiasroedel.de"
stats = asyncio.run(run_monitoring_digest(to))
print(f"Monitoring-Scan fertig: {stats}")

121
app/og_card.py Normal file
View File

@ -0,0 +1,121 @@
"""Open-Graph-Bild-Rendering via Playwright (#141).
Rendert /v2/og-template?drucksache=X als PNG 1200×630.
Cache in data/og-cache/ mit Key SHA256(drucksache + updated_at).
Öffentliche API:
``render_og_card(drucksache, updated_at, base_url)``
PNG-Bytes oder None bei Fehler
``cache_key(drucksache, updated_at)``
Hex-String (SHA-256 Kurzform, 16 Zeichen)
``get_cached(drucksache, updated_at, cache_dir)``
Path der gecacheten Datei oder None
"""
from __future__ import annotations
import hashlib
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_DEFAULT_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "og-cache"
def cache_key(drucksache: str, updated_at: str) -> str:
"""Berechnet den Cache-Schlüssel als 16-stelligen SHA-256-Präfix.
Args:
drucksache: Drucksachen-ID (z.B. "NRW-18/1234").
updated_at: ISO-Zeitstempel des letzten Updates aus der Datenbank.
Returns:
16 Hex-Zeichen (64-Bit-Präfix des SHA-256).
"""
raw = f"{drucksache}|{updated_at}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def _cache_path(drucksache: str, updated_at: str, cache_dir: Path) -> Path:
key = cache_key(drucksache, updated_at)
safe_name = drucksache.replace("/", "_").replace(" ", "_")
return cache_dir / f"{safe_name}_{key}.png"
def get_cached(
drucksache: str,
updated_at: str,
cache_dir: Optional[Path] = None,
) -> Optional[Path]:
"""Gibt den Pfad der gecacheten PNG-Datei zurück, wenn sie existiert.
Args:
drucksache: Drucksachen-ID.
updated_at: ISO-Zeitstempel ändert sich dieser, ist der Cache ungültig.
cache_dir: Verzeichnis für den Cache. Standard: data/og-cache/.
Returns:
Path-Objekt wenn Treffer, sonst None.
"""
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
path = _cache_path(drucksache, updated_at, cache_dir)
return path if path.exists() else None
def render_og_card(
drucksache: str,
updated_at: str,
base_url: str = "http://127.0.0.1:8000",
cache_dir: Optional[Path] = None,
) -> Optional[bytes]:
"""Rendert die OG-Karte als PNG via Playwright und legt sie im Cache ab.
Bei Cache-Hit wird das Rendering übersprungen.
Args:
drucksache: Drucksachen-ID (URL-kodierbar).
updated_at: ISO-Zeitstempel für den Cache-Key.
base_url: Interne Basis-URL der App (Playwright greift darauf zu).
cache_dir: Cache-Verzeichnis. Standard: data/og-cache/.
Returns:
PNG-Bytes bei Erfolg, None bei Fehler.
"""
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
cache_dir.mkdir(parents=True, exist_ok=True)
cached = get_cached(drucksache, updated_at, cache_dir)
if cached:
logger.debug("OG-Cache-Hit für %s", drucksache)
return cached.read_bytes()
dest = _cache_path(drucksache, updated_at, cache_dir)
try:
from playwright.sync_api import sync_playwright
import urllib.parse
encoded = urllib.parse.quote(drucksache, safe="")
url = f"{base_url}/v2/og-template?drucksache={encoded}"
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1200, "height": 630})
page.goto(url, wait_until="networkidle", timeout=15000)
png_bytes = page.screenshot(
clip={"x": 0, "y": 0, "width": 1200, "height": 630},
type="png",
)
browser.close()
dest.write_bytes(png_bytes)
logger.info("OG-Karte gerendert: %s%s", drucksache, dest.name)
return png_bytes
except Exception:
logger.exception("Playwright-Render fehlgeschlagen für %s", drucksache)
return None

View File

@ -21,19 +21,43 @@ class Drucksache:
datum: str # ISO date datum: str # ISO date
link: str # PDF URL link: str # PDF URL
bundesland: str bundesland: str
typ: str = "Antrag" # Antrag, Anfrage, Beschlussempfehlung, etc. typ: str = "Antrag" # Original-Typ vom Landtag (z.B. "Kleine Anfrage", "Gesetzentwurf")
typ_normiert: str = "" # Normierter Typ (wird automatisch gesetzt)
def __post_init__(self):
from .drucksache_typen import normalize_typ
if not self.typ_normiert:
self.typ_normiert = normalize_typ(self.typ)
class ParlamentAdapter(ABC): class ParlamentAdapter(ABC):
"""Base adapter for searching parliament documents.""" """Base adapter for searching parliament documents."""
bundesland: str bundesland: str
name: str name: str
filter_abstimmbar: bool = True # #127: nur abstimmbare Typen zurückgeben
@abstractmethod @abstractmethod
async def search(self, query: str, limit: int = 20) -> list[Drucksache]: async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
"""Search for documents matching query.""" """Search for documents matching query."""
pass pass
def _filter_abstimmbar(self, docs: list[Drucksache]) -> list[Drucksache]:
"""Filtert nicht-abstimmbare Drucksachen heraus (#127).
Wird von Adaptern am Ende von search() aufgerufen. Lässt
nur Anträge, Gesetzentwürfe, Änderungsanträge etc. durch.
"""
if not self.filter_abstimmbar:
return docs
from .drucksache_typen import ist_abstimmbar
filtered = [d for d in docs if ist_abstimmbar(d.typ_normiert)]
if len(filtered) < len(docs):
logger.debug(
"%s: %d von %d Drucksachen als nicht-abstimmbar gefiltert",
self.bundesland, len(docs) - len(filtered), len(docs),
)
return filtered
@abstractmethod @abstractmethod
async def get_document(self, drucksache: str) -> Optional[Drucksache]: async def get_document(self, drucksache: str) -> Optional[Drucksache]:
@ -87,9 +111,16 @@ class NRWAdapter(ParlamentAdapter):
return (parts[0], filter_terms, False) return (parts[0], filter_terms, False)
def _matches_all_terms(self, doc: 'Drucksache', terms: list[str], is_exact: bool) -> bool: def _matches_all_terms(self, doc: 'Drucksache', terms: list[str], is_exact: bool) -> bool:
"""Check if document matches all search terms (AND logic).""" """Check if document matches all search terms (AND logic).
Empty, whitespace-only, or bare-wildcard (``*``) terms are treated as
match-all so that the monitoring path (query="", " ", "*") never filters
out valid results fetched from OPAL.
"""
# Wildcard / empty query — match everything
if not terms or all(not t.strip() or t.strip() == "*" for t in terms):
return True
searchable = f"{doc.title} {doc.drucksache} {' '.join(doc.fraktionen)} {doc.typ}".lower() searchable = f"{doc.title} {doc.drucksache} {' '.join(doc.fraktionen)} {doc.typ}".lower()
if is_exact: if is_exact:
# Exact phrase must appear # Exact phrase must appear
return terms[0] in searchable return terms[0] in searchable
@ -100,9 +131,18 @@ class NRWAdapter(ParlamentAdapter):
async def search(self, query: str, limit: int = 20) -> list[Drucksache]: async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
"""Search NRW Landtag documents via OPAL portal.""" """Search NRW Landtag documents via OPAL portal."""
results = [] results = []
# Parse query for AND logic # Parse query for AND logic
api_query, filter_terms, is_exact = self._parse_query(query) api_query, filter_terms, is_exact = self._parse_query(query)
# OPAL rejects empty dokNum with 0 results. For the monitoring path
# (query="" / " " / "*"), substitute the current year so OPAL returns
# recent documents. The filter_terms list stays [""] / [" "] / ["*"],
# and _matches_all_terms with "" or " " or "*" matches every document,
# so nothing is filtered out client-side.
if not api_query.strip() or api_query.strip() in ("*",):
from datetime import date as _date
api_query = str(_date.today().year)
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
try: try:
@ -3171,7 +3211,7 @@ class SaarlandAdapter(ParlamentAdapter):
return data.get("FilteredResult", []) or [] return data.get("FilteredResult", []) or []
except Exception: except Exception:
logger.exception("SL search request error") logger.exception("SL search request error")
return [] raise
async def search(self, query: str, limit: int = 20) -> list[Drucksache]: async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
"""Volltextsuche über die aktuelle Wahlperiode, gefiltert auf Anträge. """Volltextsuche über die aktuelle Wahlperiode, gefiltert auf Anträge.
@ -3181,10 +3221,15 @@ class SaarlandAdapter(ParlamentAdapter):
und Gesetzentwürfe), und kürzt auf ``limit``. Sortierung kommt und Gesetzentwürfe), und kürzt auf ``limit``. Sortierung kommt
relevance-based vom Server für die UI ist Relevanz zu einer relevance-based vom Server für die UI ist Relevanz zu einer
Query meist wertvoller als Date-DESC. Query meist wertvoller als Date-DESC.
Netzwerkfehler (Timeout, ConnectError, HTTP-Fehler) werden nicht
geschluckt sie propagieren, damit das Monitoring sie als
``errors``-Text in ``monitoring_daily_summary`` erfassen kann.
""" """
async with self._make_client() as client: async with self._make_client() as client:
# Take großzügig, weil der Antrag-Filter ~30-50% der Hits drosselt # Take großzügig, weil der Antrag-Filter ~30-50% der Hits drosselt
take = max(limit * 5, 30) take = max(limit * 5, 30)
# _post_search re-raises alle Netzwerkfehler (Fix #142)
items = await self._post_search(client, query, skip=0, take=take) items = await self._post_search(client, query, skip=0, take=take)
results: list[Drucksache] = [] results: list[Drucksache] = []

11
app/ports/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""Ports (Protocols) für externe Dienste — Teil der Hexagonal-Migration (ADR 0008).
Ein Port" ist hier ein ``typing.Protocol``, das einen Infrastruktur-
Zugang beschreibt (LLM-Call, Embedding-Search, Mail-Versand) ohne
konkrete Implementierung. Adapter in ``app/adapters/`` implementieren
die Ports gegen reale Systeme; Tests nutzen Fake-Implementierungen.
"""
from .llm_bewerter import LlmBewerter, LlmRequest
__all__ = ["LlmBewerter", "LlmRequest"]

48
app/ports/llm_bewerter.py Normal file
View File

@ -0,0 +1,48 @@
"""LlmBewerter — Port für den LLM-Call in der Antragsbewertung.
Trennt die *Rohantwort* des LLMs (JSON-String) vom umgebenden
Application-Flow (Retry, Prompt-Composition, Citation-Binding). Die
Retry-Logik samt Temperatur-Escalation bleibt Adapter-Detail ein
zweiter Adapter (Claude, OpenAI-kompatible Proxies) kann eine ganz
andere Strategie wählen.
Ein späterer Tag-Schritt (Kapitel 10.5 der DDD-Bewertung) kapselt
zusätzlich die JSON-Parse-Kaskade hinter dem Port; heute bekommt der
Caller noch einen JSON-String zurück.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
class LlmRequest:
"""Alles, was der Adapter zum Generieren der Bewertung braucht —
inkl. Retry-Verhalten auf der Adapter-Seite."""
system_prompt: str
user_prompt: str
model: str = "qwen-plus"
max_retries: int = 3
max_tokens: int = 4000
base_temperature: float = 0.3
@runtime_checkable
class LlmBewerter(Protocol):
"""Port: wandelt einen Prompt in einen JSON-String (LLM-Rohantwort).
Der Adapter kümmert sich um:
- Markdown-Fence-Entfernung,
- JSON-Parse-Retry mit steigender Temperatur,
- Content-Fingerprint-Logging zur Forensik.
Raises:
json.JSONDecodeError: wenn alle Retries scheitern. Höhere Schichten
behandeln das als Fehlschlag der Analyse.
"""
async def bewerte(self, request: LlmRequest) -> dict: ...

View File

@ -217,11 +217,14 @@ async def graceful_shutdown(timeout: int = 900):
timeout, sum(1 for j in _jobs.values() if j.get("status") == "processing")) timeout, sum(1 for j in _jobs.values() if j.get("status") == "processing"))
async def re_enqueue_pending(): async def re_enqueue_pending(analysis_callback=None):
"""Re-enqueue jobs that were queued or processing when the container died. """Re-enqueue jobs that were queued or processing when the container died.
Reads drucksache + bundesland from the jobs table and re-triggers Jobs WITH a drucksache column get re-enqueued automatically (if callback provided).
the full analysis pipeline. This makes the queue crash-safe. Jobs WITHOUT drucksache (legacy) get marked as stale and cleaned up.
Args:
analysis_callback: async function(job_id, drucksache, text, bundesland, model, doc)
""" """
import aiosqlite import aiosqlite
from .config import settings from .config import settings
@ -229,35 +232,72 @@ async def re_enqueue_pending():
async with aiosqlite.connect(settings.db_path) as db: async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
rows = await db.execute( rows = await db.execute(
"SELECT id, bundesland, input_preview FROM jobs " "SELECT id, bundesland, drucksache, model FROM jobs "
"WHERE status IN ('queued', 'processing') ORDER BY created_at" "WHERE status IN ('queued', 'processing') ORDER BY created_at"
) )
pending = await rows.fetchall() pending = await rows.fetchall()
if not pending: if not pending:
# Alte stale-Jobs ohne drucksache aufräumen
async with aiosqlite.connect(settings.db_path) as db:
deleted = await db.execute(
"DELETE FROM jobs WHERE status='stale' AND (drucksache IS NULL OR drucksache='')"
)
if deleted.rowcount > 0:
logger.info("Cleaned up %d legacy stale jobs without drucksache", deleted.rowcount)
await db.commit()
return return
logger.info("Re-enqueueing %d pending jobs from previous run", len(pending)) logger.info("Found %d pending jobs from previous run", len(pending))
# Importiere hier um Zirkularität zu vermeiden
from .parlamente import get_adapter from .parlamente import get_adapter
re_enqueued = 0 re_enqueued = 0
marked_stale = 0
for row in pending: for row in pending:
job_id = row["id"] job_id = row["id"]
bundesland = row["bundesland"] or "NRW" bundesland = row["bundesland"] or "NRW"
drucksache = row["drucksache"]
model = row["model"] or "qwen-plus"
# Drucksache aus input_preview extrahieren — das Feld enthält if not drucksache or not analysis_callback:
# die ersten 500 Zeichen des Antragstexts, aber wir brauchen # Legacy-Job ohne Drucksache oder kein Callback → stale markieren
# die Drucksache. Prüfe ob ein Assessment fehlt das diesen async with aiosqlite.connect(settings.db_path) as db:
# Job betrifft. Wenn ja: die Drucksache steht nicht im Job. await db.execute(
# Markiere als stale und der User kann manuell re-triggern. "UPDATE jobs SET status='stale', updated_at=datetime('now') WHERE id=?",
async with aiosqlite.connect(settings.db_path) as db: (job_id,),
await db.execute( )
"UPDATE jobs SET status='stale', updated_at=datetime('now') WHERE id=?", await db.commit()
(job_id,), marked_stale += 1
continue
# Job mit Drucksache → neu enqueuen
try:
adapter = get_adapter(bundesland)
doc = await adapter.get_document(drucksache)
if not doc:
raise ValueError(f"Drucksache {drucksache} nicht gefunden")
text = await adapter.download_text(drucksache)
if not text:
raise ValueError(f"PDF-Text für {drucksache} leer")
position = await enqueue(
job_id,
analysis_callback,
job_id, drucksache, text, bundesland, model, doc,
drucksache=drucksache,
) )
await db.commit() re_enqueued += 1
re_enqueued += 1 logger.info("Re-enqueued %s (%s) at position %d", drucksache, bundesland, position)
logger.info("Marked %d jobs as stale (re-trigger via UI)", re_enqueued) except Exception as e:
logger.warning("Could not re-enqueue %s (%s): %s — marking stale", drucksache, bundesland, e)
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"UPDATE jobs SET status='stale', error=?, updated_at=datetime('now') WHERE id=?",
(str(e)[:200], job_id),
)
await db.commit()
marked_stale += 1
logger.info("Re-enqueued %d jobs, marked %d stale", re_enqueued, marked_stale)

88
app/redline_utils.py Normal file
View File

@ -0,0 +1,88 @@
"""Redline-Parser-Hilfsfunktionen — keine FastAPI-Abhängigkeiten.
Wird von app.main._row_to_detail() und von Tests direkt importiert.
"""
from __future__ import annotations
import re
from urllib.parse import quote_plus
def parse_redline_segments(vorschlag: str | None) -> list[dict]:
"""Parst §INS§text§INS§/§DEL§text§DEL§-Marker sowie **text**- und
~~text~~-Markdown in eine Liste von {type, text}-Segmenten (ctx/ins/del).
Toleriert beide Formate gleichzeitig. Unausgewogene Marker bleiben als ctx.
Leerer oder None-Input liefert [].
Beispiel:
>>> parse_redline_segments("§ 3 §DEL§alt§DEL§ §INS§neu§INS§ Ende")
[{'type': 'ctx', 'text': '§ 3 '}, {'type': 'del', 'text': 'alt'},
{'type': 'ctx', 'text': ' '}, {'type': 'ins', 'text': 'neu'},
{'type': 'ctx', 'text': ' Ende'}]
"""
if not vorschlag:
return []
# Normalisierung: §INS§...§INS§ und §DEL§...§DEL§ → interne Tags
text = vorschlag
text = re.sub(r"§INS§(.*?)§INS§", r"<INS>\1</INS>", text, flags=re.DOTALL)
text = re.sub(r"§DEL§(.*?)§DEL§", r"<DEL>\1</DEL>", text, flags=re.DOTALL)
# Markdown-Konvention: **...** → ins, ~~...~~ → del
text = re.sub(r"\*\*(.*?)\*\*", r"<INS>\1</INS>", text, flags=re.DOTALL)
text = re.sub(r"~~(.*?)~~", r"<DEL>\1</DEL>", text, flags=re.DOTALL)
# Splitten an Tags, Typen zuordnen
segments: list[dict] = []
parts = re.split(r"(<INS>.*?</INS>|<DEL>.*?</DEL>)", text, flags=re.DOTALL)
for part in parts:
if not part:
continue
ins_m = re.fullmatch(r"<INS>(.*)</INS>", part, re.DOTALL)
del_m = re.fullmatch(r"<DEL>(.*)</DEL>", part, re.DOTALL)
if ins_m:
segments.append({"type": "ins", "text": ins_m.group(1)})
elif del_m:
segments.append({"type": "del", "text": del_m.group(1)})
else:
segments.append({"type": "ctx", "text": part})
return segments
def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
"""Gibt den pdf_href für ein Zitat zurück.
Bevorzugt das bereits gepflegte url-Feld. Falls leer, rekonstruiert
die URL aus dem quelle-Feld (Format: 'Titel · S. N' oder 'Titel, S. N')
über die WAHLPROGRAMME-Registry.
"""
url = zitat.get("url", "")
if url:
return url
quelle = zitat.get("quelle", "")
seite_m = re.search(r"[·,]?\s*S\.\s*(\d+)", quelle)
if not seite_m:
return ""
seite = seite_m.group(1)
# pid aus WAHLPROGRAMME-Registry ermitteln: Dateiname ohne .pdf
from .wahlprogramme import WAHLPROGRAMME
pid = ""
for bl_data in WAHLPROGRAMME.values():
for partei_data in bl_data.values():
titel = partei_data.get("titel", "")
partei_name = partei_data.get("partei", "")
file_name = partei_data.get("file", "")
if titel and (titel in quelle or partei_name in quelle):
pid = file_name.replace(".pdf", "")
break
if pid:
break
if not pid:
return ""
text = zitat.get("text", "")
q = " ".join(text.split()[:5])
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"

234
app/reindex_embeddings.py Normal file
View File

@ -0,0 +1,234 @@
"""Reindex-Script für die Embedding-Modell-Migration v3 → v4 (Issue #123).
Läuft im Container:
docker exec gwoe-antragspruefer python -m app.reindex_embeddings
Was es macht:
1. Alle Wahlprogramme + Grundsatzprogramme mit dem aktuellen EMBEDDING_MODEL
(aus settings.embedding_model_write, default 'text-embedding-v4') neu
indexieren. Schreibt neue Rows in chunks mit model='text-embedding-v4',
die bestehenden v3-Rows bleiben unberührt.
2. Alle Assessments backfillen: summary_embedding erzeugen wo NULL oder wo
embedding_model vom aktuellen abweicht.
3. Rate-Limit: 100ms zwischen Calls (= max 10 req/sec).
4. Fortschritts-Logging pro Programm/Assessment.
Nach erfolgreichem Lauf:
- settings.embedding_model_read auf 'text-embedding-v4' flippen (via ENV),
Container neu starten
- Script `cleanup_v3_rows.py` läuft DELETE FROM chunks WHERE model='text-embedding-v3'
"""
import asyncio
import json
import logging
import sqlite3
import time
from pathlib import Path
import aiosqlite
from .config import settings
from .embeddings import (
EMBEDDING_BATCH_SIZE,
EMBEDDING_MODEL,
EMBEDDINGS_DB,
PROGRAMME,
create_embedding,
create_embeddings_batch,
init_embeddings_db,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
RATE_LIMIT_SLEEP = 0.1 # 100ms = 10 req/sec
def reindex_programme(pdf_dir: Path) -> dict:
"""Re-index all programs with the current WRITE model."""
init_embeddings_db()
# Welche Programme sind bereits mit dem aktuellen Modell indexiert?
conn = sqlite3.connect(EMBEDDINGS_DB)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT programm_id, COUNT(*) AS n FROM chunks WHERE model = ? GROUP BY programm_id",
(EMBEDDING_MODEL,),
).fetchall()
already_done = {r["programm_id"]: r["n"] for r in rows}
conn.close()
stats = {"reindexed": 0, "skipped": 0, "failed": 0, "total_chunks": 0}
for prog_id, info in PROGRAMME.items():
if prog_id in already_done:
logger.info(
"SKIP %s — bereits %d chunks mit %s",
prog_id, already_done[prog_id], EMBEDDING_MODEL,
)
stats["skipped"] += 1
continue
pdf_path = pdf_dir / info["pdf"]
if not pdf_path.exists():
logger.warning("MISS %s — PDF fehlt: %s", prog_id, pdf_path)
stats["failed"] += 1
continue
try:
logger.info("INDEX %s (%s)", prog_id, info["pdf"])
n = _index_programm_with_ratelimit(prog_id, pdf_dir)
stats["reindexed"] += 1
stats["total_chunks"] += n
logger.info("DONE %s%d chunks", prog_id, n)
except Exception:
logger.exception("FAIL %s", prog_id)
stats["failed"] += 1
return stats
def _index_programm_with_ratelimit(programm_id: str, pdf_dir: Path) -> int:
"""Batch-Reindex: sammelt alle Chunks, embedded in Batches von
EMBEDDING_BATCH_SIZE (10) Texten pro API-Call. ~10× schneller als
Single-Call-Loop."""
import fitz
info = PROGRAMME[programm_id]
pdf_path = pdf_dir / info["pdf"]
conn = sqlite3.connect(EMBEDDINGS_DB)
# Nur die Rows des aktuellen Modells löschen (Migration-sicher)
conn.execute(
"DELETE FROM chunks WHERE programm_id = ? AND model = ?",
(programm_id, EMBEDDING_MODEL),
)
# Erst alle Chunks sammeln, dann in Batches embedden
doc = fitz.open(pdf_path)
pending: list[tuple[int, str]] = [] # (page_num, chunk_text)
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
if not text.strip():
continue
words = text.split()
i = 0
chunk_size, overlap = 400, 50
while i < len(words):
chunk = " ".join(words[i : i + chunk_size])
i += chunk_size - overlap
if len(chunk.split()) < 20:
continue
pending.append((page_num + 1, chunk))
doc.close()
total = 0
# Batches à BATCH_SIZE
for start in range(0, len(pending), EMBEDDING_BATCH_SIZE):
batch = pending[start : start + EMBEDDING_BATCH_SIZE]
texts = [t for _, t in batch]
try:
vecs = create_embeddings_batch(texts, model=EMBEDDING_MODEL)
time.sleep(RATE_LIMIT_SLEEP) # 100ms zwischen Batch-Calls
except Exception:
logger.exception("batch failed (programm %s, start %d)", programm_id, start)
continue
for (page_num, chunk), vec in zip(batch, vecs):
conn.execute(
"INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding, bundesland, model) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
programm_id,
info["partei"],
info["typ"],
page_num,
chunk,
json.dumps(vec).encode(),
info.get("bundesland"),
EMBEDDING_MODEL,
),
)
total += 1
# Commit pro Batch, damit im Crash-Fall nicht alles verloren ist
conn.commit()
conn.close()
return total
async def backfill_assessment_embeddings() -> dict:
"""Alle Assessments ohne Embedding (oder mit altem Modell) nachziehen."""
from .embeddings import create_assessment_embedding
stats = {"backfilled": 0, "skipped": 0, "failed": 0}
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT drucksache, title, antrag_zusammenfassung, themen, bundesland, embedding_model "
"FROM assessments"
)
rows = await cur.fetchall()
for row in rows:
if row["embedding_model"] == EMBEDDING_MODEL:
stats["skipped"] += 1
continue
try:
themen = json.loads(row["themen"] or "[]")
except Exception:
themen = []
blob, model = create_assessment_embedding(
title=row["title"] or "",
zusammenfassung=row["antrag_zusammenfassung"],
themen=themen,
bundesland=row["bundesland"],
)
time.sleep(RATE_LIMIT_SLEEP)
if blob is None:
stats["failed"] += 1
logger.warning("backfill FAIL %s", row["drucksache"])
continue
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"UPDATE assessments SET summary_embedding = ?, embedding_model = ? WHERE drucksache = ?",
(blob, model, row["drucksache"]),
)
await db.commit()
stats["backfilled"] += 1
if stats["backfilled"] % 20 == 0:
logger.info("backfill progress: %d", stats["backfilled"])
return stats
async def main():
pdf_dir = Path(__file__).resolve().parent / "static" / "referenzen"
logger.info("=" * 60)
logger.info("Reindex mit WRITE-Modell: %s", EMBEDDING_MODEL)
logger.info("PDF-Verzeichnis: %s", pdf_dir)
logger.info("=" * 60)
prog_stats = reindex_programme(pdf_dir)
logger.info("Programme fertig: %s", prog_stats)
logger.info("Backfill Assessment-Embeddings …")
ass_stats = await backfill_assessment_embeddings()
logger.info("Assessments fertig: %s", ass_stats)
logger.info("=" * 60)
logger.info("REINDEX KOMPLETT")
logger.info("Programme: %s", prog_stats)
logger.info("Assessments: %s", ass_stats)
logger.info("Nächster Schritt: settings.embedding_model_read auf %s setzen", EMBEDDING_MODEL)
logger.info("(ENV: EMBEDDING_MODEL_READ=%s, Container neu starten)", EMBEDDING_MODEL)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,44 @@
"""Repository-Pattern für Persistenz-Zugriff (ADR 0008).
Die Repositories kapseln direkte ``database.py``-Aufrufe hinter Protocols,
sodass Tests `InMemory*Repository` verwenden können und Callsites nicht
mehr jedes Schema-Detail kennen müssen.
Die konkreten `Sqlite*Repository`-Implementierungen delegieren heute noch
an die bestehenden Funktionen in ``database.py`` kein Big-Bang-Rewrite.
Schritt für Schritt wandern die direkten DB-Aufrufe in die Repositories.
"""
from .antrag_repository import (
AntragRepository,
SqliteAntragRepository,
InMemoryAntragRepository,
get_antrag_repository,
)
from .bewertung_repository import (
BewertungRepository,
SqliteBewertungRepository,
InMemoryBewertungRepository,
get_bewertung_repository,
)
from .abonnement_repository import (
AbonnementRepository,
SqliteAbonnementRepository,
InMemoryAbonnementRepository,
get_abonnement_repository,
)
__all__ = [
"AntragRepository",
"SqliteAntragRepository",
"InMemoryAntragRepository",
"get_antrag_repository",
"BewertungRepository",
"SqliteBewertungRepository",
"InMemoryBewertungRepository",
"get_bewertung_repository",
"AbonnementRepository",
"SqliteAbonnementRepository",
"InMemoryAbonnementRepository",
"get_abonnement_repository",
]

View File

@ -0,0 +1,138 @@
"""AbonnementRepository — Port für E-Mail-Digest-Abos (#124).
Kapselt die `email_subscriptions`-Tabelle. Der Name Abonnement" ist die
Ubiquitous-Language-Form (Kapitel 4 der DDD-Bewertung); intern heißt die
Tabelle weiter `email_subscriptions`.
"""
from __future__ import annotations
from typing import Optional, Protocol, runtime_checkable
from .. import database
@runtime_checkable
class AbonnementRepository(Protocol):
async def create(
self,
user_id: str,
email: str,
bundesland: Optional[str] = None,
partei: Optional[str] = None,
frequency: str = "daily",
) -> int: ...
async def list_by_user(self, user_id: str) -> list[dict]: ...
async def list_all(self) -> list[dict]: ...
async def list_due(self, frequency: str = "daily") -> list[dict]: ...
async def delete(self, user_id: str, sub_id: int) -> bool: ...
async def delete_by_id(self, sub_id: int) -> bool: ...
async def mark_sent(self, sub_id: int) -> None: ...
class SqliteAbonnementRepository:
async def create(
self,
user_id: str,
email: str,
bundesland: Optional[str] = None,
partei: Optional[str] = None,
frequency: str = "daily",
) -> int:
return await database.create_subscription(
user_id, email, bundesland, partei, frequency,
)
async def list_by_user(self, user_id: str) -> list[dict]:
return await database.list_subscriptions(user_id)
async def list_all(self) -> list[dict]:
return await database.list_all_subscriptions()
async def list_due(self, frequency: str = "daily") -> list[dict]:
return await database.get_all_subscriptions_due(frequency)
async def delete(self, user_id: str, sub_id: int) -> bool:
return await database.delete_subscription(user_id, sub_id)
async def delete_by_id(self, sub_id: int) -> bool:
return await database.delete_subscription_by_id(sub_id)
async def mark_sent(self, sub_id: int) -> None:
await database.mark_subscription_sent(sub_id)
class InMemoryAbonnementRepository:
"""Test-Fake. Ignoriert ``last_sent``-Zeitberechnung — ``list_due`` gibt
einfach alle zurück, bei denen ``last_sent`` ``None`` ist. Für
Zeit-bezogene Tests explizit ``mark_sent`` nutzen."""
def __init__(self) -> None:
self._subs: list[dict] = []
self._next_id = 1
async def create(
self,
user_id: str,
email: str,
bundesland: Optional[str] = None,
partei: Optional[str] = None,
frequency: str = "daily",
) -> int:
sid = self._next_id
self._next_id += 1
self._subs.append({
"id": sid,
"user_id": user_id,
"email": email,
"bundesland": bundesland,
"partei": partei,
"frequency": frequency,
"last_sent": None,
"created_at": "",
})
return sid
async def list_by_user(self, user_id: str) -> list[dict]:
return [dict(s) for s in self._subs if s["user_id"] == user_id]
async def list_all(self) -> list[dict]:
return [dict(s) for s in self._subs]
async def list_due(self, frequency: str = "daily") -> list[dict]:
return [
dict(s) for s in self._subs
if s["frequency"] == frequency and s.get("last_sent") is None
]
async def delete(self, user_id: str, sub_id: int) -> bool:
for i, s in enumerate(self._subs):
if s["id"] == sub_id and s["user_id"] == user_id:
self._subs.pop(i)
return True
return False
async def delete_by_id(self, sub_id: int) -> bool:
for i, s in enumerate(self._subs):
if s["id"] == sub_id:
self._subs.pop(i)
return True
return False
async def mark_sent(self, sub_id: int) -> None:
for s in self._subs:
if s["id"] == sub_id:
s["last_sent"] = "sent"
_default_abonnement_repo: AbonnementRepository = SqliteAbonnementRepository()
def get_abonnement_repository() -> AbonnementRepository:
return _default_abonnement_repo

View File

@ -0,0 +1,135 @@
"""AntragRepository — Persistenz-Port für Assessment-Datensätze (#136, ADR 0008).
Der Name `AntragRepository` ist bewusst auf die Domäne bezogen: aus Sicht
der Anwendung speichern wir eine Bewertung *zu einem Antrag* die
Drucksachen-ID ist der Identifier. Intern zugreifen wir auf die
`assessments`-Tabelle.
Für Bewertungs-Versionen (assessment_versions) siehe `BewertungRepository`.
"""
from __future__ import annotations
from typing import Optional, Protocol, runtime_checkable
from .. import database
@runtime_checkable
class AntragRepository(Protocol):
"""Port für den Zugriff auf Antrags-Bewertungen.
Rückgabe-Typ bleibt vorerst ``dict`` (wie heute von ``database.get_assessment``
geliefert), um die Umstellung möglichst diff-arm zu halten. Ein
Domain-Objekt-Wrapper (Kapitel 3.2 der DDD-Bewertung) kommt als
Tag-6-Schritt. Wichtig: callsites sollen *nicht* weiter ``database.*``
direkt importieren.
"""
async def save(self, data: dict) -> bool: ...
async def get(self, drucksache: str) -> Optional[dict]: ...
async def list(self, bundesland: Optional[str] = None) -> list[dict]: ...
async def search(
self, query: str, bundesland: Optional[str] = None, limit: int = 50,
) -> list[dict]: ...
async def delete(self, drucksache: str) -> bool: ...
class SqliteAntragRepository:
"""Produktions-Implementation. Delegiert an ``database.py``.
Hält bewusst *keinen* Connection-Pool ``database.py`` öffnet pro
Aufruf eine Connection (``aiosqlite.connect``). Bei Performance-
Regressionen später zentralisieren.
"""
async def save(self, data: dict) -> bool:
return await database.upsert_assessment(data)
async def get(self, drucksache: str) -> Optional[dict]:
return await database.get_assessment(drucksache)
async def list(self, bundesland: Optional[str] = None) -> list[dict]:
return await database.get_all_assessments(bundesland)
async def search(
self, query: str, bundesland: Optional[str] = None, limit: int = 50,
) -> list[dict]:
return await database.search_assessments(query, bundesland, limit)
async def delete(self, drucksache: str) -> bool:
return await database.delete_assessment(drucksache)
class InMemoryAntragRepository:
"""Test-Fake. Keine Datei, kein I/O — in-process Dict.
Bei mehrfachem ``save`` für dieselbe Drucksache wird überschrieben
(wie im produktiven UPSERT). Versionierung simuliert das Fake bewusst
nicht dafür gibt es ``BewertungRepository`` als separaten Port.
"""
def __init__(self, initial: Optional[list[dict]] = None) -> None:
self._store: dict[str, dict] = {}
for d in initial or []:
ds = d.get("drucksache")
if ds:
self._store[ds] = dict(d)
async def save(self, data: dict) -> bool:
ds = data.get("drucksache")
if not ds:
raise ValueError("save(): data.drucksache ist Pflicht")
self._store[ds] = dict(data)
return True
async def get(self, drucksache: str) -> Optional[dict]:
row = self._store.get(drucksache)
return dict(row) if row else None
async def list(self, bundesland: Optional[str] = None) -> list[dict]:
rows = list(self._store.values())
if bundesland and bundesland != "ALL":
rows = [r for r in rows if r.get("bundesland") == bundesland]
# Sortierung analog zu database.get_all_assessments: gwoe_score desc
rows.sort(key=lambda r: (r.get("gwoe_score") or 0), reverse=True)
return [dict(r) for r in rows]
async def search(
self, query: str, bundesland: Optional[str] = None, limit: int = 50,
) -> list[dict]:
q = (query or "").lower()
out: list[dict] = []
for r in self._store.values():
if bundesland and bundesland != "ALL" and r.get("bundesland") != bundesland:
continue
hay = " ".join([
str(r.get("title") or ""),
str(r.get("drucksache") or ""),
" ".join(r.get("fraktionen") or []) if isinstance(r.get("fraktionen"), list) else str(r.get("fraktionen") or ""),
" ".join(r.get("themen") or []) if isinstance(r.get("themen"), list) else str(r.get("themen") or ""),
]).lower()
if q in hay:
out.append(dict(r))
out.sort(key=lambda r: (r.get("gwoe_score") or 0), reverse=True)
return out[:limit]
async def delete(self, drucksache: str) -> bool:
return self._store.pop(drucksache, None) is not None
# ─── FastAPI-Dependency ─────────────────────────────────────────────────────
_default_antrag_repo: AntragRepository = SqliteAntragRepository()
def get_antrag_repository() -> AntragRepository:
"""FastAPI-``Depends()``-Provider. In Tests via
``app.dependency_overrides[get_antrag_repository] = lambda: InMemoryAntragRepository()``
überschreibbar.
"""
return _default_antrag_repo

View File

@ -0,0 +1,64 @@
"""BewertungRepository — Port für die Versionshistorie einer Bewertung.
Eine Bewertung" ist die vollständige Assessment-Instanz; der
`BewertungRepository` greift auf die Snapshot-Tabelle
``assessment_versions`` zu. Für die aktuellste Bewertung siehe
``AntragRepository``.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from .. import database
@runtime_checkable
class BewertungRepository(Protocol):
async def versions(self, drucksache: str) -> list[dict]: ...
class SqliteBewertungRepository:
"""Produktions-Implementation. Delegiert an ``database.py``."""
async def versions(self, drucksache: str) -> list[dict]:
return await database.get_assessment_history(drucksache)
class InMemoryBewertungRepository:
"""Test-Fake. Erlaubt per ``add_version`` händisches Bestücken.
Die produktive Versionierung passiert implizit in ``upsert_assessment``
(siehe database.py:580-598). Im Fake trennen wir das bewusst, weil
Tests oft explizit Versionshistorie befüllen wollen.
"""
def __init__(self) -> None:
self._versions: dict[str, list[dict]] = {}
def add_version(
self,
drucksache: str,
version: int,
gwoe_score: float,
model: str,
created_at: str = "",
) -> None:
self._versions.setdefault(drucksache, []).append({
"version": version,
"gwoe_score": gwoe_score,
"model": model,
"created_at": created_at,
})
async def versions(self, drucksache: str) -> list[dict]:
rows = list(self._versions.get(drucksache, []))
rows.sort(key=lambda r: r["version"], reverse=True)
return rows
_default_bewertung_repo: BewertungRepository = SqliteBewertungRepository()
def get_bewertung_repository() -> BewertungRepository:
return _default_bewertung_repo

20
app/static/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
app/static/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

32
app/static/v2/fonts.css Normal file
View File

@ -0,0 +1,32 @@
/*
* fonts.css Nunito Sans self-hosted (Fallback für Avenir)
* Google Fonts v19, Latin-Subset, woff2
* font-display: swap verhindert FOIT; size-adjust korrigiert metrischen
* Versatz gegenüber Avenir Next (ca. 95 % empirisch grob gemessen).
*/
/* ── Normal (variable font, deckt Gewichte 300900 ab) ──────────── */
@font-face {
font-family: "Nunito Sans";
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url("/static/v2/fonts/nunito-sans-latin-variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
size-adjust: 95%;
}
/* ── Italic 400 ─────────────────────────────────────────────────── */
@font-face {
font-family: "Nunito Sans";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/static/v2/fonts/nunito-sans-italic-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
size-adjust: 95%;
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,16.37a86.4,86.4,0,0,1,16,3V212.67a86.4,86.4,0,0,1-16,3Zm32,9.26a87.81,87.81,0,0,1,16,10.54V195.83a87.81,87.81,0,0,1-16,10.54ZM40,128a88.11,88.11,0,0,1,80-87.63V215.63A88.11,88.11,0,0,1,40,128Zm160,50.54V77.46a87.82,87.82,0,0,1,0,101.08Z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M48,180c0,11,7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,85.78,206.4,30.06,30.06,0,0,1,64,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,64,160C55.18,160,48,169,48,180Zm79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84-1.25-.81-1.23-1-1.12-1.9a4.57,4.57,0,0,1,2-3.67c4.6-3.12,15.34-1.73,19.82-.56A8,8,0,0,0,142,145.86c-2.12-.55-21-5.22-32.84,2.76a20.58,20.58,0,0,0-9,14.95c-2,15.88,13.65,20.41,23,23.11,12.06,3.49,13.12,4.92,12.78,7.59-.31,2.41-1.26,3.34-2.14,3.93-4.6,3.06-15.17,1.56-19.55.36A8,8,0,0,0,109.94,214a61.34,61.34,0,0,0,15.19,2c5.82,0,12.3-1,17.49-4.46a20.82,20.82,0,0,0,9.19-15.23C154,179,137.49,174.17,127.6,171.31Zm83.09-26.84a8,8,0,0,0-10.23,4.84L188,184.21l-12.47-34.9a8,8,0,0,0-15.07,5.38l20,56a8,8,0,0,0,15.07,0l20-56A8,8,0,0,0,210.69,144.47ZM216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-27.31-8L160,51.31V80Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-40-64a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,152Z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z"/></svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></svg>

After

Width:  |  Height:  |  Size: 640 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,112a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Zm77.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88.11,88.11,0,1,1,11.31-11.31l50.07,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/></svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M106.91,149.09A71.53,71.53,0,0,1,128,200a8,8,0,0,1-16,0,56,56,0,0,0-56-56,8,8,0,0,1,0-16A71.53,71.53,0,0,1,106.91,149.09ZM56,80a8,8,0,0,0,0,16A104,104,0,0,1,160,200a8,8,0,0,0,16,0A120,120,0,0,0,56,80Zm118.79,1.21A166.9,166.9,0,0,0,56,32a8,8,0,0,0,0,16A151,151,0,0,1,163.48,92.52,151,151,0,0,1,208,200a8,8,0,0,0,16,0A166.9,166.9,0,0,0,174.79,81.21ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M144,157.68a68,68,0,1,0-71.9,0c-20.65,6.76-39.23,19.39-54.17,37.17a8,8,0,0,0,12.25,10.3C50.25,181.19,77.91,168,108,168s57.75,13.19,77.87,37.15a8,8,0,0,0,12.25-10.3C183.18,177.07,164.6,164.44,144,157.68ZM56,100a52,52,0,1,1,52,52A52.06,52.06,0,0,1,56,100Zm197.66,33.66-32,32a8,8,0,0,1-11.32,0l-16-16a8,8,0,0,1,11.32-11.32L216,148.69l26.34-26.35a8,8,0,0,1,11.32,11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 465 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

104
app/static/v2/tokens.css Normal file
View File

@ -0,0 +1,104 @@
/*
* tokens.css ECOnGOOD Corporate Design Tokens (Manual Juni 2024)
* Alle v2-Komponenten referenzieren ausschließlich diese Variablen.
* Kein Hardcoded Hex-Wert in Screens oder Primitives.
*/
:root {
/* ── Vier Grundfarben (Manual Seite 5) ─────────────────────────── */
--ecg-dark: #5A5A5A; /* Dunkelgrau — primary text, PANTONE 425 U */
--ecg-green: #889E33; /* Grün — accent, PANTONE 583 U */
--ecg-blue: #009DA5; /* Blau — accent, PANTONE 320 U */
--ecg-light: #BFBFBF; /* Hellgrau — hairlines, infografiken */
--ecg-teal: #009DA5; /* Alias für --ecg-blue (Kompatibilität) */
/* ── Oberflächen ────────────────────────────────────────────────── */
--paper: #FFFFFF;
--surface: #F7F7F5;
--hairline: #E6E6E3;
/* ── Semantische Oberflächen-Aliase (Dark-Mode-fähig) ───────────── */
--ecg-card-bg: #FFFFFF; /* Karten-Hintergrund (= --paper) */
--ecg-bg-subtle: #F7F7F5; /* Subtiler Hintergrund (= --surface) */
--ecg-border: #E6E6E3; /* Rahmenfarbe (= --hairline) */
--ecg-text-muted: #8C8C8C; /* Gedämpfter Text */
/* ── Score-Band — Tints aus ECG-Grün / zurückhaltendem Warn-Rot ── */
--score-high-bg: #E8EED1;
--score-high-fg: #5E6F1F;
--score-mid-bg: #F1F1EE;
--score-mid-fg: #5A5A5A;
--score-low-bg: #F1DCDA;
--score-low-fg: #9A2A2A;
/* ── Score-Chip-Farben (Fraktions-Tabelle) ──────────────────────── */
--score-chip-green-bg: #CDDAA1; /* = --redline-ins-bg */
--score-chip-green-fg: #236020; /* AA 5.1:1 auf chip-green-bg */
--score-chip-mid-bg: #fff3cd;
--score-chip-mid-fg: #7d5a00;
--score-chip-red-bg: #EFC9C3; /* = --redline-del-bg */
--score-chip-red-fg: #a00000;
/* ── Redline-Farben (nur für diff-Markup, nie als UI-Chrome) ────── */
--redline-del-bg: #EFC9C3;
--redline-ins-bg: #CDDAA1;
--redline-contra: #9A2A2A;
/* ── Typografie ─────────────────────────────────────────────────── */
--font-sans: "Avenir Next", "Avenir", "Nunito Sans", Arial, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", monospace;
/* ── Spacing-Raster (4-px-Basis) ───────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ── Layout ─────────────────────────────────────────────────────── */
--sidebar-width: 230px;
--content-max: 1180px;
--breakpoint-mobile: 900px;
}
/* ── Dark-Mode-Variante ─────────────────────────────────────────────── */
[data-theme="dark"] {
--ecg-dark: #D0D0CC;
--ecg-light: #444440;
--paper: #1A1A18;
--surface: #222220;
--hairline: #333330;
/* ── Semantische Oberflächen-Aliase (Dark) ──────────────────────── */
--ecg-card-bg: #222220; /* = --surface */
--ecg-bg-subtle: #2A2A28;
--ecg-border: #333330; /* = --hairline */
--ecg-text-muted: #888884;
/* Score-Bänder im Dark Mode: Chroma halten, Lightness leicht erhöht */
--score-high-bg: #2A3010;
--score-high-fg: #AABE55;
--score-mid-bg: #252523;
--score-mid-fg: #C0C0BC;
--score-low-bg: #2E1515;
--score-low-fg: #E07070;
/* ── Score-Chip-Farben (Dark) ───────────────────────────────────── */
--score-chip-green-bg: #1E3010;
--score-chip-green-fg: #8FBF6F;
--score-chip-mid-bg: #2A2510;
--score-chip-mid-fg: #C9A840;
--score-chip-red-bg: #301010;
--score-chip-red-fg: #E07070;
/* ── Redline (Dark) — Chroma gedämpft, lesbar auf dunklem Ground ── */
--redline-del-bg: #3A1A18;
--redline-ins-bg: #1E2E0A;
--redline-contra: #C04040;
}

1048
app/static/v2/v2.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,157 @@
"""CLI-Sync-Skript für abgeordnetenwatch.de (#106 Phase 1).
Holt Polls + namentliche Stimmen für alle oder einen bestimmten BL-Code
und speichert sie via UPSERT in der lokalen SQLite-DB.
Aufruf:
python -m app.sync_abgeordnetenwatch [--bundesland NRW] [--limit 50]
Ohne --bundesland werden alle in PARLIAMENT_ID eingetragenen BL-Codes
abgearbeitet (BUND-Alias wird übersprungen, BT genügt).
Ausgabe:
NRW: 12 polls neu, 340 votes neu
BT: 0 polls neu, 0 votes neu
"""
from __future__ import annotations
import argparse
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
async def sync_bundesland(bundesland_code: str, limit: int) -> tuple[int, int]:
"""Synct einen BL-Code. Gibt (neue_polls, neue_votes) zurück."""
from .abgeordnetenwatch import (
PARLIAMENT_ID, fetch_polls, fetch_votes_for_poll,
fallback_drucksache_by_date_title,
)
from .database import init_db, upsert_aw_poll, upsert_aw_vote
await init_db()
parliament_id = PARLIAMENT_ID[bundesland_code.upper()]
synced_at = datetime.now(timezone.utc).isoformat()
polls = await fetch_polls(bundesland_code, limit=limit)
new_polls = 0
new_votes = 0
for poll in polls:
poll_id = poll.get("id")
if poll_id is None:
continue
legislature = poll.get("field_legislature") or {}
legislature_label = (
legislature.get("label") or legislature.get("name") or ""
if isinstance(legislature, dict) else str(legislature)
)
topics_raw = poll.get("field_topics") or []
topics = [
(t.get("label") or t.get("name") or str(t))
if isinstance(t, dict) else str(t)
for t in topics_raw
]
# Primär: Drucksache aus intro-HTML geparst; Fallback über Datum+Titel
# für BL ohne PDF-URL im intro (MV/BY/BB/TH/HH/SL — Fix #142 Phase 3).
drucksache = poll.get("drucksache")
if drucksache is None:
drucksache = await fallback_drucksache_by_date_title(
datum=poll.get("field_poll_date"),
titel=poll.get("label"),
bundesland=bundesland_code,
)
is_new_poll = await upsert_aw_poll(
poll_id=poll_id,
parliament_id=parliament_id,
bundesland=bundesland_code.upper(),
drucksache=drucksache,
titel=poll.get("label"),
datum=poll.get("field_poll_date"),
accepted=poll.get("field_accepted"),
topics=topics,
legislature_label=legislature_label,
synced_at=synced_at,
)
if is_new_poll:
new_polls += 1
# Votes laden und speichern
try:
votes = await fetch_votes_for_poll(poll_id)
except Exception:
logger.exception("Fehler beim Laden von Votes für poll_id=%d", poll_id)
continue
for vote in votes:
politician_id = vote.get("politician_id")
if politician_id is None:
continue
is_new_vote = await upsert_aw_vote(
poll_id=poll_id,
politician_id=politician_id,
politician_name=vote.get("politician_name"),
partei=vote.get("partei"),
vote=vote.get("vote", "no_show"),
)
if is_new_vote:
new_votes += 1
return new_polls, new_votes
async def main(bundesland: str | None, limit: int) -> None:
from .abgeordnetenwatch import PARLIAMENT_ID
# Alle Codes ohne BUND-Alias (BT und BUND zeigen auf die selbe ID)
if bundesland:
codes = [bundesland.upper()]
else:
seen_ids: set[int] = set()
codes = []
for code, pid in PARLIAMENT_ID.items():
if pid not in seen_ids:
seen_ids.add(pid)
codes.append(code)
for code in codes:
try:
new_polls, new_votes = await sync_bundesland(code, limit)
print(f"{code:4s}: {new_polls} polls neu, {new_votes} votes neu")
except Exception:
logger.exception("Fehler beim Sync für %s", code)
print(f"{code:4s}: FEHLER (siehe Log)")
def _cli() -> None:
parser = argparse.ArgumentParser(
description="Sync abgeordnetenwatch-Abstimmungsdaten in die lokale DB."
)
parser.add_argument(
"--bundesland", "-b",
default=None,
help="BL-Code (z.B. NRW, BT). Ohne Angabe: alle Codes.",
)
parser.add_argument(
"--limit", "-n",
type=int,
default=100,
help="Maximale Anzahl Polls pro BL (default: 100).",
)
args = parser.parse_args()
asyncio.run(main(args.bundesland, args.limit))
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
_cli()

View File

@ -0,0 +1,19 @@
<!-- _header.html — gemeinsame Kopfzeile für Unterseiten (legal, methodik, quellen, auswertungen)
Variablen (alle optional mit Defaults):
app_name — Anwendungsname (immer verfügbar)
page_title — Seitentitel rechts neben dem App-Namen; leer = nicht anzeigen
back_url — Ziel des Zurück-Links; Default: /
back_label — Linktext; Default: "← {{ app_name }}"
header_nav — zusätzliche Nav-Links als HTML-String (selten benötigt)
-->
<div class="header">
<a href="{{ back_url | default('/') }}" style="color:var(--color-blue);text-decoration:none;">
{{ back_label | default('← ' + app_name) }}
</a>
{% if page_title %}
<h1 style="color:var(--color-blue);font-size:1.5rem;">{{ page_title }}</h1>
{% endif %}
{% if header_nav %}
<nav>{{ header_nav | safe }}</nav>
{% endif %}
</div>

View File

@ -8,7 +8,7 @@
:root { :root {
--color-darkgray: #5a5a5a; --color-darkgray: #5a5a5a;
--color-green: #889e33; --color-green: #889e33;
--color-blue: #009da5; --color-blue: #007a80;
--color-lightgray: #bfbfbf; --color-lightgray: #bfbfbf;
--color-bg: #f5f5f5; --color-bg: #f5f5f5;
--color-orange: #F7941D; --color-orange: #F7941D;
@ -160,12 +160,12 @@
</style> </style>
</head> </head>
<body> <body>
<div class="header"> {% set page_title = 'Auswertungen — Bundesland × Partei × Wahlperiode' %}
<h1>Auswertungen — Bundesland × Partei × Wahlperiode</h1> {% set header_nav = '<a href="/quellen">Quellen</a>' %}
<nav> {% include "_header.html" %}
<a href="/">← zurück zur Suche</a>
<a href="/quellen">Quellen</a> <div style="background:#e8f4f8;border-left:4px solid #007a80;padding:0.6rem 1.2rem;font-size:0.9rem;color:#333;">
</nav> Diese Seite ist auch direkt in der Haupt-App verfügbar: <a href="/?mode=auswertungen" style="color:#007a80;">zur integrierten Auswertungs-Ansicht →</a>
</div> </div>
<main> <main>
@ -198,6 +198,11 @@
</div> </div>
</div> </div>
<h2 style="margin-top:2rem;color:var(--color-blue);">Thema × Fraktion</h2>
<p style="font-size:0.85rem;color:#666;margin-bottom:1rem;">Ø-GWÖ-Score pro Thema und Fraktion. Klick auf eine Zelle für Details. Grün = GWÖ-freundlich, Rot = GWÖ-kritisch.</p>
<div id="themen-container"><div class="empty-state">Lade Themen-Matrix …</div></div>
<script src="/static/chart.umd.min.js"></script>
<script> <script>
const wpFilter = document.getElementById('wp-filter'); const wpFilter = document.getElementById('wp-filter');
const reloadBtn = document.getElementById('reload'); const reloadBtn = document.getElementById('reload');
@ -274,12 +279,43 @@
body.innerHTML = '<p style="color:#888;">Keine Daten für diese Kombination.</p>'; body.innerHTML = '<p style="color:#888;">Keine Daten für diese Kombination.</p>';
return; return;
} }
let html = '<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>'; // Chart + Tabelle
for (const row of z.wahlperioden) { body.innerHTML = '<canvas id="zeitreihe-chart" style="max-height:300px;margin-bottom:1rem;"></canvas>' +
html += `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`; '<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>' +
z.wahlperioden.map(row => `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`).join('') +
'</tbody></table>';
// Chart.js rendern
if (window.Chart) {
const ctx = document.getElementById('zeitreihe-chart');
new Chart(ctx, {
type: 'line',
data: {
labels: z.wahlperioden.map(r => 'WP ' + r.wp),
datasets: [{
label: `Ø GWÖ-Score ${partei} (${bundesland})`,
data: z.wahlperioden.map(r => r.avg),
borderColor: '#009da5',
backgroundColor: 'rgba(0,157,165,0.1)',
fill: true,
tension: 0.3,
pointRadius: 5,
}]
},
options: {
responsive: true,
scales: {
y: { min: 0, max: 10, title: { display: true, text: 'GWÖ-Score' } },
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => `n=${z.wahlperioden[ctx.dataIndex].n} Anträge`
}
}
}
}
});
} }
html += '</tbody></table>';
body.innerHTML = html;
} catch (e) { } catch (e) {
body.innerHTML = `<p style="color:#d00;">Fehler: ${e}</p>`; body.innerHTML = `<p style="color:#d00;">Fehler: ${e}</p>`;
} }
@ -298,6 +334,41 @@
}); });
loadMatrix(); loadMatrix();
loadThemenMatrix();
async function loadThemenMatrix() {
const container = document.getElementById('themen-container');
try {
const r = await fetch('/api/auswertungen/themen-matrix');
const data = await r.json();
if (!data.themen.length) {
container.innerHTML = '<div class="empty-state">Noch zu wenige Assessments für Themen-Analyse.</div>';
return;
}
let html = '<table class="matrix"><thead><tr><th class="row-header">Thema</th>';
for (const frak of data.fraktionen) {
html += `<th>${frak}</th>`;
}
html += '</tr></thead><tbody>';
for (const thema of data.themen) {
html += `<tr><th class="row-header">${thema}</th>`;
for (const frak of data.fraktionen) {
const cell = (data.cells[thema] || {})[frak];
if (cell) {
const cls = scoreClass(cell.avg);
html += `<td class="cell-with-data ${cls}" title="${thema} × ${frak}: Ø ${cell.avg}/10 (${cell.n} Anträge)">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
} else {
html += '<td class="empty"></td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div class="empty-state">Fehler: ${e}</div>`;
}
}
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

600
app/templates/legal.html Normal file
View File

@ -0,0 +1,600 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ app_name }}</title>
<style>
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #007a80;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Avenir', 'Segoe UI', sans-serif;
color: var(--color-darkgray);
line-height: 1.6;
background: var(--color-bg);
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
gap: 1rem;
}
.header h1 { color: var(--color-blue); font-size: 1.5rem; }
.header a { color: var(--color-blue); text-decoration: none; }
.container { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
h2 {
color: var(--color-blue);
margin: 2rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-blue);
}
h3 { color: var(--color-green); margin: 1.5rem 0 0.5rem; }
h4 { margin: 1rem 0 0.3rem; }
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.card p, .card ul { margin-bottom: 0.8rem; }
.card ul { padding-left: 1.5rem; }
.card li { margin-bottom: 0.3rem; }
.card a { color: var(--color-blue); }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
th, td { text-align: left; padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; }
th { color: var(--color-blue); font-size: 0.85rem; }
.footer {
text-align: center; padding: 2rem; color: #888;
font-size: 0.85rem; border-top: 1px solid #eee; margin-top: 2rem;
}
.footer a { color: var(--color-blue); text-decoration: none; }
</style>
</head>
<body>
{% set page_title = title %}
{% include "_header.html" %}
<div class="container">
{% if section == 'impressum' %}
<!-- ===== IMPRESSUM ===== -->
<h2>Impressum</h2>
<div class="card">
<h3>Angaben gemäß § 5 DDG</h3>
<p>
Tobias Rödel<br>
Rüggeweg 25<br>
58093 Hagen
</p>
</div>
<div class="card">
<h3>Kontakt</h3>
<p>
Telefon: 0170 3039817<br>
Telefax: 02331 9814882<br>
E-Mail: mail@tobiasroedel.de
</p>
</div>
<div class="card">
<h3>Umsatzsteuer-ID</h3>
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: DE421290194
</p>
</div>
<div class="card">
<h3>Berufshaftpflichtversicherung</h3>
<p>
exali GmbH<br>
Franz-Kobinger-Str. 9<br>
86157 Augsburg<br>
Geltungsraum: Deutschland
</p>
</div>
<div class="card">
<h3>Redaktionell verantwortlich</h3>
<p>
Tobias Rödel<br>
Rüggeweg 25<br>
58093 Hagen
</p>
</div>
<div class="card">
<h3>EU-Streitschlichtung</h3>
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>.
</p>
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
</div>
<div class="card">
<h3>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h3>
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
</div>
{% elif section == 'datenschutz' %}
<!-- ===== DATENSCHUTZERKLÄRUNG ===== -->
<h2>Datenschutzerklärung</h2>
<div class="card">
<h3>1. Datenschutz auf einen Blick</h3>
<h4>Allgemeine Hinweise</h4>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
personenbezogenen Daten passiert, wenn Sie diese Website besuchen.
Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert
werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen
Sie unserer unter diesem Text aufgeführten Datenschutzerklärung.
</p>
<h4>Datenerfassung auf dieser Website</h4>
<p><strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong></p>
<p>
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
Dessen Kontaktdaten können Sie dem Abschnitt „Hinweis zur verantwortlichen
Stelle" in dieser Datenschutzerklärung entnehmen.
</p>
<p><strong>Wie erfassen wir Ihre Daten?</strong></p>
<p>
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen
(z. B. bei der Registrierung). Andere Daten werden automatisch oder nach
Ihrer Einwilligung beim Besuch der Website durch unsere IT-Systeme erfasst.
Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem
oder Uhrzeit des Seitenaufrufs). Die Erfassung dieser Daten erfolgt
automatisch, sobald Sie diese Website betreten.
</p>
<p><strong>Wofür nutzen wir Ihre Daten?</strong></p>
<p>
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der
Website zu gewährleisten. Weitere Daten können zur Analyse Ihres
Nutzerverhaltens verwendet werden — wir setzen jedoch <strong>keine
Analyse-/Tracking-Tools</strong> ein.
</p>
<p><strong>Welche Rechte haben Sie bezüglich Ihrer Daten?</strong></p>
<p>
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger
und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben
außerdem ein Recht, die Berichtigung oder Löschung dieser Daten zu verlangen.
Wenn Sie eine Einwilligung zur Datenverarbeitung erteilt haben, können Sie
diese Einwilligung jederzeit für die Zukunft widerrufen. Außerdem haben Sie
das Recht, unter bestimmten Umständen die Einschränkung der Verarbeitung
Ihrer personenbezogenen Daten zu verlangen. Des Weiteren steht Ihnen ein
Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.
</p>
<p>Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit an uns wenden.</p>
</div>
<div class="card">
<h3>2. Hosting</h3>
<p>
Wir hosten die Inhalte unserer Website bei folgendem Anbieter:
</p>
<h4>Externes Hosting</h4>
<p>
Diese Website wird extern gehostet bei:<br>
<strong>netcup GmbH</strong><br>
Daimlerstraße 25, 76185 Karlsruhe, Deutschland
</p>
<p>
Die personenbezogenen Daten, die auf dieser Website erfasst werden, werden
auf den Servern des Hosters gespeichert. Hierbei kann es sich v. a. um
IP-Adressen, Kontaktanfragen, Meta- und Kommunikationsdaten, Nutzungsdaten
und sonstige Daten, die über eine Website generiert werden, handeln.
</p>
<p>
Das externe Hosting erfolgt zum Zwecke der Vertragserfüllung gegenüber
unseren potenziellen und bestehenden Nutzern (Art. 6 Abs. 1 lit. b DSGVO)
und im Interesse einer sicheren, schnellen und effizienten Bereitstellung
unseres Online-Angebots durch einen professionellen Anbieter (Art. 6 Abs. 1
lit. f DSGVO).
</p>
<h4>Auftragsverarbeitung</h4>
<p>
Wir haben einen Vertrag über Auftragsverarbeitung (AVV) zur Nutzung des
oben genannten Dienstes geschlossen. Hierbei handelt es sich um einen
datenschutzrechtlich vorgeschriebenen Vertrag, der gewährleistet, dass
dieser die personenbezogenen Daten unserer Websitebesucher nur nach unseren
Weisungen und unter Einhaltung der DSGVO verarbeitet.
</p>
<h4>Authentifizierung (Keycloak SSO)</h4>
<p>
Für die Benutzeranmeldung setzen wir Keycloak ein, eine Open-Source-Lösung
für Identity- und Access-Management. Keycloak läuft auf demselben Server
bei netcup. Bei der Registrierung werden Vorname, Nachname, E-Mail-Adresse
und Benutzername gespeichert. Diese Daten werden ausschließlich für die
Authentifizierung und Benutzerverwaltung verwendet.
</p>
<h4>Cookies</h4>
<p>
Diese Website verwendet ausschließlich <strong>funktional notwendige
Cookies</strong>. Ein Cookie (<code>access_token</code>) wird nach
erfolgreicher Anmeldung gesetzt und enthält ein JWT-Token zur
Authentifizierung. Es werden <strong>keine Tracking-Cookies</strong>,
keine Analyse-Cookies und keine Cookies von Drittanbietern gesetzt.
</p>
<table style="width:100%;font-size:0.9rem;border-collapse:collapse;margin:0.5rem 0;">
<thead>
<tr style="border-bottom:2px solid #ddd;">
<th style="text-align:left;padding:0.3rem;">Name</th>
<th style="text-align:left;padding:0.3rem;">Zweck</th>
<th style="text-align:left;padding:0.3rem;">Speicherdauer</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:0.3rem;"><code>access_token</code></td>
<td style="padding:0.3rem;">Authentifizierung (JWT)</td>
<td style="padding:0.3rem;">Session (max. 5 Minuten)</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h3>3. Allgemeine Hinweise und Pflichtinformationen</h3>
<h4>Datenschutz</h4>
<p>
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr
ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend
den gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
</p>
<p>
Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der
Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser
Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
</p>
<h4>Hinweis zur verantwortlichen Stelle</h4>
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
<p>
Tobias Rödel<br>
Rüggeweg 25<br>
58093 Hagen
</p>
<p>
Telefon: 0170 3039817<br>
E-Mail: mail@tobiasroedel.de
</p>
<p>
Verantwortliche Stelle ist die natürliche oder juristische Person, die allein
oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von
personenbezogenen Daten (z. B. Namen, E-Mail-Adressen o. Ä.) entscheidet.
</p>
<h4>Speicherdauer</h4>
<p>
Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer
genannt wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck
für die Datenverarbeitung entfällt. Wenn Sie ein berechtigtes Löschersuchen
geltend machen oder eine Einwilligung zur Datenverarbeitung widerrufen, werden
Ihre Daten gelöscht, sofern wir keine anderen rechtlich zulässigen Gründe
für die Speicherung Ihrer personenbezogenen Daten haben; in letzterem Fall
erfolgt die Löschung nach Fortfall dieser Gründe.
</p>
<h4>Allgemeine Hinweise zu den Rechtsgrundlagen der Datenverarbeitung auf dieser Website</h4>
<p>
Sofern Sie in die Datenverarbeitung eingewilligt haben, verarbeiten wir Ihre
personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. a DSGVO bzw.
Art. 9 Abs. 2 lit. a DSGVO. Im Falle einer ausdrücklichen Einwilligung in die
Übertragung personenbezogener Daten in Drittstaaten erfolgt die Datenverarbeitung
außerdem auf Grundlage von Art. 49 Abs. 1 lit. a DSGVO. Sofern die Verarbeitung
zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen
erforderlich ist, verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1
lit. b DSGVO. Ferner verarbeiten wir Ihre Daten, sofern diese zur Erfüllung
einer rechtlichen Verpflichtung erforderlich sind auf Grundlage von Art. 6
Abs. 1 lit. c DSGVO. Die Datenverarbeitung kann ferner auf Grundlage unseres
berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO erfolgen.
</p>
<h4>Widerruf Ihrer Einwilligung zur Datenverarbeitung</h4>
<p>
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung
möglich. Sie können eine bereits erteilte Einwilligung jederzeit widerrufen.
Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom
Widerruf unberührt.
</p>
<h4>Widerspruchsrecht gegen die Datenerhebung in besonderen Fällen (Art. 21 DSGVO)</h4>
<p>
<strong>Wenn die Datenverarbeitung auf Grundlage von Art. 6 Abs. 1 lit. e oder
f DSGVO erfolgt, haben Sie jederzeit das Recht, aus Gründen, die sich aus Ihrer
besonderen Situation ergeben, gegen die Verarbeitung Ihrer personenbezogenen Daten
Widerspruch einzulegen. Die jeweilige Rechtsgrundlage, auf denen eine Verarbeitung
beruht, entnehmen Sie dieser Datenschutzerklärung. Wenn Sie Widerspruch einlegen,
werden wir Ihre betroffenen personenbezogenen Daten nicht mehr verarbeiten, es sei
denn, wir können zwingende schutzwürdige Gründe für die Verarbeitung nachweisen,
die Ihre Interessen, Rechte und Freiheiten überwiegen oder die Verarbeitung dient
der Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Widerspruch
nach Art. 21 Abs. 1 DSGVO).</strong>
</p>
<h4>Beschwerderecht bei der zuständigen Aufsichtsbehörde</h4>
<p>
Im Falle von Verstößen gegen die DSGVO steht den Betroffenen ein Beschwerderecht
bei einer Aufsichtsbehörde zu. Das Beschwerderecht besteht unbeschadet anderweitiger
verwaltungsrechtlicher oder gerichtlicher Rechtsbehelfe.
</p>
<h4>Recht auf Datenübertragbarkeit</h4>
<p>
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder in
Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten
in einem gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die
direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt
dies nur, soweit es technisch machbar ist.
</p>
<h4>Auskunft, Berichtigung und Löschung</h4>
<p>
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht
auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten,
deren Herkunft und Empfänger und den Zweck der Datenverarbeitung und ggf. ein
Recht auf Berichtigung oder Löschung dieser Daten. Hierzu sowie zu weiteren
Fragen zum Thema personenbezogene Daten können Sie sich jederzeit an uns wenden.
</p>
<h4>Recht auf Einschränkung der Verarbeitung</h4>
<p>
Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen
Daten zu verlangen. Hierzu können Sie sich jederzeit an uns wenden.
</p>
<h4>SSL- bzw. TLS-Verschlüsselung</h4>
<p>
Diese Seite nutzt aus Sicherheitsgründen und zum Schutz der Übertragung
vertraulicher Inhalte, wie zum Beispiel Anfragen, die Sie an uns als
Seitenbetreiber senden, eine SSL- bzw. TLS-Verschlüsselung. Eine
verschlüsselte Verbindung erkennen Sie daran, dass die Adresszeile des
Browsers von „http://" auf „https://" wechselt und an dem Schloss-Symbol
in Ihrer Browserzeile.
</p>
<p>
Wenn die SSL- bzw. TLS-Verschlüsselung aktiviert ist, können die Daten,
die Sie an uns übermitteln, nicht von Dritten mitgelesen werden.
</p>
</div>
<div class="card">
<h3>4. Datenerfassung auf dieser Website</h3>
<h4>Server-Log-Dateien</h4>
<p>
Der Provider der Seiten erhebt und speichert automatisch Informationen in
so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns
übermittelt. Dies sind:
</p>
<ul>
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
<p>
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht
vorgenommen.
</p>
<p>
Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f
DSGVO. Der Websitebetreiber hat ein berechtigtes Interesse an der technisch
fehlerfreien Darstellung und der Optimierung seiner Website — hierzu müssen
die Server-Log-Dateien erfasst werden.
</p>
<h4>Cookies</h4>
<p>
Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine
Datenpakete und richten auf Ihrem Endgerät keinen Schaden an. Sie werden
entweder vorübergehend für die Dauer einer Sitzung (Session-Cookies) oder
dauerhaft (permanente Cookies) auf Ihrem Endgerät gespeichert.
</p>
<p>Diese Website verwendet ausschließlich folgende Cookies:</p>
<table>
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th><th>Typ</th></tr>
<tr>
<td><code>access_token</code></td>
<td>Authentifizierung (Keycloak-JWT nach Login)</td>
<td>Bis zum Schließen des Browsers</td>
<td>Session / Notwendig</td>
</tr>
<tr>
<td><code>theme</code></td>
<td>Speicherung der bevorzugten Darstellung (hell/dunkel)</td>
<td>Bis zur manuellen Löschung (localStorage)</td>
<td>Funktional</td>
</tr>
<tr>
<td><code>sortierung</code></td>
<td>Speicherung der bevorzugten Sortierung</td>
<td>Bis zur manuellen Löschung (localStorage)</td>
<td>Funktional</td>
</tr>
<tr>
<td><code>selectedBundesland</code></td>
<td>Speicherung des zuletzt gewählten Bundeslandes</td>
<td>Bis zur manuellen Löschung (localStorage)</td>
<td>Funktional</td>
</tr>
</table>
<p>
Wir setzen <strong>keine Tracking-, Analyse- oder Werbe-Cookies</strong> ein.
</p>
<p>
Die Speicherung von technisch notwendigen Cookies erfolgt auf Grundlage von
Art. 6 Abs. 1 lit. f DSGVO. Der Websitebetreiber hat ein berechtigtes
Interesse an der Speicherung von technisch notwendigen Cookies zur technisch
fehlerfreien und optimierten Bereitstellung seiner Dienste. Die funktionalen
Cookies (Theme, Sortierung, Bundesland) werden ausschließlich lokal im Browser
gespeichert (localStorage) und nicht an den Server übermittelt.
</p>
</div>
<div class="card">
<h3>5. Registrierung und Authentifizierung</h3>
<h4>Keycloak Single Sign-On (SSO)</h4>
<p>
Für die Benutzerregistrierung und -anmeldung nutzen wir einen
selbstgehosteten <strong>Keycloak-Server</strong> (sso.toppyr.de). Keycloak
ist eine Open-Source-Identitäts- und Zugriffsverwaltungslösung. Es findet
<strong>keine Datenübermittlung an Dritte</strong> statt — der Keycloak-Server
wird vom selben Betreiber auf derselben Infrastruktur betrieben.
</p>
<p>Bei der Registrierung und Anmeldung werden folgende Daten verarbeitet:</p>
<ul>
<li>Vorname, Nachname</li>
<li>E-Mail-Adresse</li>
<li>Benutzername</li>
<li>Gehashtes Passwort (serverseitig, nicht einsehbar)</li>
<li>Zeitpunkt der Registrierung und letzten Anmeldung</li>
</ul>
<p>
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO
(Vertragserfüllung / Bereitstellung des Dienstes) und Art. 6 Abs. 1 lit. a
DSGVO (Einwilligung durch aktive Registrierung).
</p>
<p>
Ihr Account kann auf Anfrage jederzeit gelöscht werden. Wenden Sie sich
dazu an die im Impressum genannte Kontaktadresse.
</p>
</div>
<div class="card">
<h3>6. Nutzung von KI-Diensten (Datenverarbeitung durch Dritte)</h3>
<h4>Alibaba Cloud / DashScope API</h4>
<p>
Für die automatisierte Analyse von Parlamentsanträgen nutzen wir das
Sprachmodell <strong>Qwen Plus</strong> über die DashScope-API der
<strong>Alibaba Cloud International</strong>
(dashscope-intl.aliyuncs.com).
</p>
<p>Dabei werden folgende Daten an den Dienst übermittelt:</p>
<ul>
<li>Der Volltext des zu analysierenden Parlamentsantrags (öffentlich zugängliches Dokument)</li>
<li>Relevante Ausschnitte aus öffentlich zugänglichen Wahlprogrammen</li>
</ul>
<p>
Es werden <strong>keine personenbezogenen Daten</strong> der Nutzer:innen an
DashScope übermittelt. Die übermittelten Texte sind ausschließlich öffentlich
zugängliche parlamentarische Dokumente und Wahlprogramme.
</p>
<p>
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO
(berechtigtes Interesse an der automatisierten Analyse öffentlicher
Parlamentsdokumente).
</p>
<p>
Weitere Informationen zum Datenschutz bei Alibaba Cloud:
<a href="https://www.alibabacloud.com/help/en/legal/latest/chinese-mainland-privacy-policy" target="_blank" rel="noopener">Alibaba Cloud Privacy Policy</a>.
</p>
<h4>Embedding-Verarbeitung</h4>
<p>
Für die Zuordnung von Wahlprogramm-Zitaten werden Textabschnitte über die
DashScope-API in numerische Vektoren (Embeddings) umgewandelt. Auch hierbei
werden ausschließlich öffentlich zugängliche Wahlprogramm-Texte verarbeitet,
keine personenbezogenen Daten.
</p>
</div>
<div class="card">
<h3>7. Gespeicherte Nutzungsdaten</h3>
<h4>Lesezeichen und Kommentare</h4>
<p>
Registrierte Nutzer:innen können Anträge mit Lesezeichen versehen und
Kommentare hinterlassen. Diese Daten werden in unserer Datenbank gespeichert
und sind mit dem Benutzerkonto verknüpft. Bei einer Löschung des Benutzerkontos
werden auch alle zugehörigen Lesezeichen und Kommentare gelöscht.
</p>
<p>
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO
(Bereitstellung der Funktionalität auf Nutzerwunsch).
</p>
<h4>Bewertungsdaten (Assessments)</h4>
<p>
Die durch die KI-Analyse erzeugten Bewertungen von Parlamentsanträgen
enthalten keine personenbezogenen Daten. Sie bestehen aus Bewertungsscores,
Begründungen und Zitaten aus öffentlich zugänglichen Dokumenten.
</p>
</div>
<div class="card">
<h3>8. Keine Nutzung von Analyse-Tools</h3>
<p>
Diese Website verwendet <strong>keine Analyse- oder Tracking-Dienste</strong>
wie Google Analytics, Matomo oder vergleichbare Tools. Es findet kein
Tracking des Nutzerverhaltens statt. Es werden keine Nutzungsprofile erstellt.
</p>
</div>
<div class="card">
<h3>9. Keine externen Schriften oder CDNs</h3>
<p>
Diese Website lädt <strong>keine Schriften von externen Servern</strong>
(z. B. Google Fonts). Alle verwendeten Schriftarten sind lokal gehostet
bzw. Systemschriftarten. Es findet daher keine Datenübertragung an
Schrift-Anbieter statt.
</p>
</div>
<div class="card">
<h3>10. Datenübermittlung an Parlaments-Server</h3>
<p>
Bei der Suche nach Parlamentsanträgen oder dem Herunterladen von
Antrags-PDFs werden Anfragen an die öffentlichen Dokumentationssysteme
der jeweiligen Landesparlamente weitergeleitet (z. B. OPAL NRW,
ParLDok Berlin, EDAS Sachsen). Dabei werden die von Ihnen eingegebenen
Suchbegriffe und Ihre IP-Adresse an den jeweiligen Parlaments-Server
übermittelt.
</p>
<p>
Diese Übermittlung ist technisch notwendig für die Bereitstellung des
Dienstes und erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.
Die Datenschutzbestimmungen der jeweiligen Landesparlamente gelten
zusätzlich.
</p>
</div>
{% endif %}
</div>
<div class="footer">
{{ app_name }} ·
<a href="/impressum">Impressum</a> ·
<a href="/datenschutz">Datenschutz</a> ·
<a href="/">Zurück zur Startseite</a>
</div>
</body>
</html>

View File

@ -8,7 +8,7 @@
:root { :root {
--color-darkgray: #5a5a5a; --color-darkgray: #5a5a5a;
--color-green: #889e33; --color-green: #889e33;
--color-blue: #009da5; --color-blue: #007a80;
--color-lightgray: #bfbfbf; --color-lightgray: #bfbfbf;
--color-bg: #f5f5f5; --color-bg: #f5f5f5;
--color-amber: #ffc107; --color-amber: #ffc107;
@ -45,6 +45,7 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08); box-shadow: 0 1px 3px rgba(0,0,0,.08);
} }
.card p + p { margin-top: 0.5rem; }
.matrix-grid { .matrix-grid {
display: grid; display: grid;
grid-template-columns: auto repeat(5, 1fr); grid-template-columns: auto repeat(5, 1fr);
@ -52,169 +53,391 @@
font-size: 0.8rem; font-size: 0.8rem;
margin: 1rem 0; margin: 1rem 0;
} }
.matrix-grid .cell { .matrix-grid .cell { padding: 0.4rem; text-align: center; background: #f8f9fa; border: 1px solid #e0e0e0; }
padding: 0.4rem; .matrix-grid .header-cell { background: var(--color-blue); color: white; font-weight: bold; }
text-align: center; .matrix-grid .row-header { background: var(--color-green); color: white; font-weight: bold; text-align: left; }
background: #f8f9fa;
border: 1px solid #e0e0e0;
}
.matrix-grid .header-cell {
background: var(--color-blue);
color: white;
font-weight: bold;
}
.matrix-grid .row-header {
background: var(--color-green);
color: white;
font-weight: bold;
text-align: left;
}
details { margin: 0.5rem 0; } details { margin: 0.5rem 0; }
details summary { details summary { cursor: pointer; color: var(--color-blue); font-weight: 600; padding: 0.3rem 0; }
cursor: pointer;
color: var(--color-blue);
font-weight: 600;
padding: 0.3rem 0;
}
details summary:hover { text-decoration: underline; } details summary:hover { text-decoration: underline; }
.pipeline-step { .pipeline-step {
display: flex; display: flex; align-items: flex-start; gap: 1rem;
align-items: flex-start; margin: 0.75rem 0; padding: 0.75rem;
gap: 1rem; background: #f8f9fa; border-radius: 6px;
margin: 0.75rem 0;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid var(--color-blue); border-left: 3px solid var(--color-blue);
} }
.step-num { .step-num {
background: var(--color-blue); background: var(--color-blue); color: white;
color: white; width: 28px; height: 28px; border-radius: 50%;
width: 28px; height: 28px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-weight: bold; font-weight: bold; font-size: 0.85rem; flex-shrink: 0;
font-size: 0.85rem;
flex-shrink: 0;
} }
.note { .note {
background: #fff3cd; background: #fff3cd; border-left: 3px solid var(--color-amber);
border-left: 3px solid var(--color-amber); padding: 0.75rem 1rem; margin: 1rem 0; border-radius: 4px; font-size: 0.9rem;
padding: 0.75rem 1rem;
margin: 1rem 0;
border-radius: 4px;
font-size: 0.9rem;
} }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; } table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
th, td { padding: 0.5rem; border: 1px solid #e0e0e0; text-align: left; font-size: 0.9rem; } th, td { padding: 0.5rem; border: 1px solid #e0e0e0; text-align: left; font-size: 0.9rem; }
th { background: #f0f0f0; } th { background: #f0f0f0; }
a { color: var(--color-blue); } a { color: var(--color-blue); }
.footer { text-align: center; padding: 2rem; color: #999; font-size: 0.85rem; } .footer { text-align: center; padding: 2rem; color: #999; font-size: 0.85rem; }
ul { margin: 0.5rem 0 0.5rem 1.5rem; }
li { margin: 0.3rem 0; }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> {% set page_title = 'Methodik' %}
<h1>{{ app_name }}</h1> {% set header_nav = '<a href="/">Startseite</a> <a href="/quellen">Quellen</a> <strong>Methodik</strong>' %}
<a href="/">Bewertungen</a> {% include "_header.html" %}
<a href="/auswertungen">Auswertungen</a>
<a href="/quellen">Quellen</a>
<strong>Methodik</strong>
</div>
<div class="container"> <div class="container">
<h2>Wie funktioniert der GWÖ-Antragsprüfer?</h2>
<h2>Was ist die Gemeinwohl-Ökonomie?</h2>
<div class="card"> <div class="card">
<p> <p>
Der GWÖ-Antragsprüfer bewertet Parlamentsanträge automatisch nach der Die <strong>Gemeinwohl-Ökonomie (GWÖ)</strong> ist ein Wirtschaftsmodell, das den
<strong>Gemeinwohl-Ökonomie Matrix 2.0 für Gemeinden</strong>. Jede Bewertung Erfolg wirtschaftlichen Handelns nicht am Gewinn, sondern am <strong>Beitrag zum
analysiert drei Dimensionen: GWÖ-Treue, Übereinstimmung mit Wahlprogrammen Gemeinwohl</strong> misst. Entwickelt von Christian Felber (2010), wird die GWÖ
und Übereinstimmung mit Grundsatzprogrammen der Parteien. von einer internationalen Bewegung mit über 11.000 Unterstützern, 4.500
Mitgliedern und 1.000 bilanzierten Organisationen getragen.
</p> </p>
<p style="margin-top: 0.5rem;">
Alle Bewertungen werden durch ein KI-Sprachmodell erzeugt und anschließend <h3>Das Bewertungsmodell: die Gemeinwohl-Bilanz</h3>
<strong>automatisch verifiziert</strong> — Zitate werden gegen die Originaltexte <p>
der Wahlprogramme geprüft. Wörtliche Treffer werden als <em>verifiziert</em> Das Kernstück ist die <strong>Gemeinwohl-Bilanz</strong>: ein standardisiertes
markiert, paraphrasierte Stellen als <em>nicht wörtlich im Programm</em> Bewertungsverfahren, das Organisationen anhand einer Matrix aus fünf Werten
gekennzeichnet. (Menschenwürde, Solidarität, ökologische Nachhaltigkeit, soziale Gerechtigkeit,
Transparenz &amp; Demokratie) und fünf Berührungsgruppen bewertet.
</p>
<p>
Ursprünglich wurde dieses Modell <strong>für Unternehmen</strong> entwickelt.
Die aktuelle <strong>Unternehmens-Matrix (Version 5.1)</strong> bewertet
Lieferketten, Mitarbeitende, Kund:innen, Eigentümer:innen und das
gesellschaftliche Umfeld. Über 1.000 Unternehmen in 35 Ländern haben bereits
eine Gemeinwohl-Bilanz erstellt.
</p>
<p style="font-size:0.9rem;">
<a href="https://germany.econgood.org/wp-content/uploads/sites/8/2025/02/ECOnGOOD_Arbeitsbuch_5_1.pdf" target="_blank">Arbeitsbuch Unternehmen 5.1 (PDF)</a> ·
<a href="https://germany.econgood.org/tools/gemeinwohl-matrix/" target="_blank">Matrix-Übersicht</a>
</p>
<h3>Adaption für die öffentliche Hand</h3>
<p>
Für <strong>Gemeinden und die öffentliche Hand</strong> gibt es seit 2017 eine
eigenständige Adaption: das <strong>Arbeitsbuch für Gemeinden Version 2.0</strong>.
Es überträgt die Unternehmens-Matrix auf kommunale Handlungsfelder:
statt „Kund:innen" stehen <em>Bürger:innen</em> im Fokus, statt „Lieferkette"
geht es um <em>öffentliche Beschaffung</em>, statt „Gewinnverteilung" um
<em>Haushalts- und Finanzpolitik</em>.
Eine aktualisierte Version 2.1.A wird seit 2023 im Pilotbetrieb erprobt.
</p>
<p style="font-size:0.9rem;">
<a href="https://germany.econgood.org/wp-content/uploads/sites/8/2022/05/Arbeitsbuch-Gemeinden_2.pdf" target="_blank">Arbeitsbuch Gemeinden 2.0 (PDF)</a> ·
<a href="https://germany.econgood.org/wp-content/uploads/sites/8/2024/04/20231103_Arbeitsbuch-2_1_A-final.pdf" target="_blank">Version 2.1.A Pilotfassung (PDF)</a>
</p>
<h3>Anwendung auf Parlamentsanträge</h3>
<p>
<strong>Dieser Antragsprüfer</strong> nutzt die Gemeinde-Matrix 2.0 als
Bewertungsrahmen und wendet sie systematisch auf Parlamentsanträge aller
deutschen Landtage und des Bundestags an. Das ist ein Anwendungsfall, der im
ursprünglichen GWÖ-Konzept nicht vorgesehen war — aber die gleiche Wertebasis
teilt: Parlamentsanträge gestalten die Rahmenbedingungen, unter denen Gemeinden
handeln. Ihre Gemeinwohl-Wirkung zu messen macht sie vergleichbar und transparent.
</p>
<p style="font-size:0.9rem;color:var(--color-muted);">
Mehr zur Bewegung:
<a href="https://germany.econgood.org" target="_blank">GWÖ Deutschland</a> ·
<a href="https://austria.econgood.org/gemeinden/" target="_blank">GWÖ Gemeinden Österreich</a> ·
<a href="https://germany.econgood.org/tools/gemeinwohl-matrix/" target="_blank">Arbeitsmaterialien</a>
</p> </p>
</div> </div>
<h2>Die GWÖ-Matrix 2.0</h2> <h2>Was macht der GWÖ-Antragsprüfer?</h2>
<div class="card"> <div class="card">
<p>Die Matrix besteht aus <strong>5 Berührungsgruppen</strong> (Zeilen) und <p>
<strong>5 Werten</strong> (Spalten) = 25 Themenfelder:</p> Der Antragsprüfer wendet die GWÖ-Matrix systematisch auf <strong>Parlamentsanträge
aller 16 Landtage und des Bundestags</strong> an. Jeder Antrag wird automatisch
analysiert und erhält:
</p>
<ul>
<li><strong>GWÖ-Score (010)</strong> — wie stark fördert oder widerspricht der Antrag den fünf Gemeinwohl-Werten?</li>
<li><strong>25-Felder-Matrix</strong> — detaillierte Bewertung für jede Kombination aus Berührungsgruppe und Wert</li>
<li><strong>Wahlprogramm-Treue</strong> — wie gut passt der Antrag zu den Wahl- und Grundsatzprogrammen der Fraktionen, belegt mit verifizierten Zitaten?</li>
<li><strong>Verbesserungsvorschläge</strong> — konkrete Textänderungen im Redline-Format, die den GWÖ-Score erhöhen würden</li>
</ul>
<p>
Ziel ist <strong>Transparenz</strong>: Bürger:innen können nachvollziehen, welche
Anträge dem Gemeinwohl dienen — und welche dagegen arbeiten. Die Bewertungen sind
öffentlich, maschinenlesbar (JSON/CSV/Atom-Feed) und unter CC BY 4.0 lizenziert.
</p>
</div>
<h2>Die GWÖ-Matrix 2.0 für Gemeinden</h2>
<div class="card">
<p><strong>5 Berührungsgruppen</strong> (Zeilen) × <strong>5 Werte</strong> (Spalten) = 25 Bewertungsfelder.
Jedes Feld wird von <strong>5</strong> (fundamental widersprechend) bis <strong>+5</strong>
(stark fördernd) bewertet. Der GWÖ-Score (010) ist ein gewichteter Durchschnitt.</p>
</div>
<div class="card">
<h3>Die fünf Werte (Spalten)</h3>
<table>
<tr><th style="width:30%;">Wert</th><th>Leitfrage</th></tr>
<tr><td><strong>1. Menschenwürde</strong></td><td>Werden Grundrechte geschützt? Rechtliche Gleichstellung? Schutz vor Diskriminierung?</td></tr>
<tr><td><strong>2. Solidarität</strong></td><td>Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft? Kooperation statt Konkurrenz?</td></tr>
<tr><td><strong>3. Ökologische Nachhaltigkeit</strong></td><td>Klimaschutz? Ressourcenschonung? Biodiversität? Kreislaufwirtschaft?</td></tr>
<tr><td><strong>4. Soziale Gerechtigkeit</strong></td><td>Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung? Chancengleichheit?</td></tr>
<tr><td><strong>5. Transparenz & Demokratie</strong></td><td>Bürgerbeteiligung? Offenlegung? Demokratische Prozesse? Rechenschaftspflicht?</td></tr>
</table>
</div>
<div class="card">
<h3>Die fünf Berührungsgruppen (Zeilen)</h3>
<table>
<tr><th style="width:30%;">Gruppe</th><th>Wer ist gemeint?</th></tr>
<tr><td><strong>A · Lieferant:innen</strong></td><td>Externe Beschaffung, Lieferketten, Dienstleister:innen — unter welchen Bedingungen kauft die öffentliche Hand ein?</td></tr>
<tr><td><strong>B · Finanzen</strong></td><td>Umgang mit öffentlichen Mitteln, Haushalt, Steuerzahler:innen — wohin fließt das Geld?</td></tr>
<tr><td><strong>C · Verwaltung</strong></td><td>Mandatsträger:innen, Mitarbeitende, Ehrenamtliche — wie wird intern gearbeitet?</td></tr>
<tr><td><strong>D · Bürger:innen</strong></td><td>Wirkung innerhalb der Grenzen, Daseinsvorsorge — was haben die Menschen vor Ort davon?</td></tr>
<tr><td><strong>E · Gesellschaft & Natur</strong></td><td>Wirkung über die Grenzen hinaus, Zukunft — welche Spuren hinterlassen wir?</td></tr>
</table>
</div>
<div class="card">
<h3>Alle 25 Felder im Detail</h3>
<p style="margin-bottom:1rem;color:var(--color-muted);font-size:0.9rem;">Klicke auf ein Feld für die ausführliche Erklärung.</p>
<div id="field-explain" style="display:none;background:#f0f8f0;border-left:3px solid var(--color-green);padding:1rem 1.25rem;margin-bottom:1rem;border-radius:4px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong id="field-explain-title" style="font-size:1.05rem;"></strong>
<button onclick="document.getElementById('field-explain').style.display='none'" style="background:none;border:none;font-size:1.1rem;cursor:pointer;color:#888;"></button>
</div>
<div id="field-explain-text" style="margin-top:0.5rem;line-height:1.7;"></div>
</div>
<script>
const fieldInfo = {
"A1": {
label: "Grundrechtsschutz in der Lieferkette",
praxis: "Wenn die öffentliche Hand Büromöbel, Dienstkleidung oder IT-Geräte beschafft: Unter welchen Bedingungen wurden diese hergestellt? Werden Lieferanten verpflichtet, Arbeitsschutzstandards und Menschenrechte einzuhalten? Gibt es Ausschlusskriterien für Produkte aus Kinderarbeit oder Zwangsarbeit? In der Praxis bedeutet das z.\u00a0B. die Aufnahme von ILO-Kernarbeitsnormen in Vergabekriterien oder die Bevorzugung fair zertifizierter Anbieter.",
theorie: "Die GWÖ versteht Menschenwürde als unteilbar und kettenübergreifend: Wer öffentlich einkauft, trägt Mitverantwortung für die Bedingungen am Anfang der Wertschöpfungskette. Das Feld A1 operationalisiert den Zusammenhang zwischen kommunaler Beschaffung und globalem Menschenrechtsschutz — analog zum Lieferkettensorgfaltspflichtengesetz, aber mit weicherem, wertebasiertem Maßstab."
},
"A2": {
label: "Nutzen für die Gemeinde",
praxis: "Beauftragt die Kommune den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleiben Steuergelder in der Region und schaffen Arbeitsplätze vor Ort? In der Praxis geht es um regionale Vergabestrategien, Losaufteilung zugunsten kleinerer Betriebe und die Berücksichtigung von Gemeinwohlkriterien neben dem Preis.",
theorie: "Solidarität beginnt in der Nachbarschaft. Die GWÖ-Matrix misst hier, ob die Beschaffungspolitik einer Kommune aktiv zur regionalen Wertschöpfung beiträgt. Das entspricht dem Subsidiaritätsprinzip: Aufgaben sollen möglichst nah an den Betroffenen erledigt werden, wirtschaftliche Kreisläufe möglichst lokal geschlossen werden."
},
"A3": {
label: "Ökologische Verantwortung in der Lieferkette",
praxis: "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa transportiert? Sind Recyclingquoten Teil der Ausschreibung? Konkret: Gibt es Vorgaben zu CO₂-Fußabdruck, Verpackungsvermeidung oder biologischem Anbau in den Leistungsbeschreibungen?",
theorie: "Die ökologische Säule der GWÖ fordert, dass Umweltkosten nicht externalisiert werden. Jede Beschaffungsentscheidung hat einen ökologischen Fußabdruck — A3 macht diesen sichtbar und bewertet, ob die Kommune ihre Marktmacht für Nachhaltigkeitsziele einsetzt."
},
"A4": {
label: "Soziale Verantwortung in der Lieferkette",
praxis: "Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Haben Subunternehmer die gleichen Arbeitsbedingungen wie Festangestellte? Wird bei der Vergabe geprüft, ob Mindestlöhne eingehalten werden, ob Zeitarbeit missbraucht wird, ob Saisonarbeiter:innen angemessen untergebracht sind?",
theorie: "Soziale Gerechtigkeit endet nicht am Werkstor. Die GWÖ bewertet die gesamte Wertschöpfungskette nach dem Prinzip: Wer von öffentlichen Aufträgen profitiert, muss auch soziale Mindeststandards garantieren. A4 prüft, ob diese Standards vertraglich verankert und kontrolliert werden."
},
"A5": {
label: "Rechenschaft und Mitsprache bei Beschaffung",
praxis: "Können Bürger:innen nachschauen, welche Firma den Auftrag für den Straßenbau bekommen hat — und warum? Gibt es ein öffentliches Vergaberegister? Werden Vergabeentscheidungen im Rat diskutiert oder hinter verschlossenen Türen getroffen? Können Betroffene (z.\u00a0B. Anwohner:innen) Einspruch erheben?",
theorie: "Transparenz ist das Immunsystem der Demokratie. Die GWÖ fordert Offenlegung als Regelfall, nicht als Ausnahme. A5 misst, ob Beschaffungsprozesse für die Öffentlichkeit nachvollziehbar und mitgestaltbar sind — ein demokratisches Grundprinzip, das in der kommunalen Praxis oft zu kurz kommt."
},
"B1": {
label: "Ethisches Finanzgebaren",
praxis: "Liegt das Geld Ihrer Stadt bei einer Bank, die auch Waffengeschäfte oder fossile Großprojekte finanziert? Oder bei einer ethischen Bank, die in soziale und ökologische Projekte investiert? Werden Kassenkredite bei der erstbesten Großbank aufgenommen — oder gibt es ethische Anlagerichtlinien?",
theorie: "Die GWÖ betrachtet Geld als Mittel zum Zweck, nicht als Selbstzweck. B1 bewertet, ob kommunale Finanzentscheidungen ethischen Kriterien folgen. Das schließt die Wahl der Hausbank, Anlagestrategien für Rücklagen und die Konditionen von Kassenkrediten ein. Vorbild sind Kommunen, die explizite Negativlisten für Rüstung, fossile Energien und Steueroasen in ihren Anlagerichtlinien verankert haben."
},
"B2": {
label: "Gemeinnutz im Finanzgebaren",
praxis: "Fließen Steuergelder in einen neuen Radweg für alle — oder in eine Umgehungsstraße, die nur dem Gewerbegebiet nützt? Werden Subventionen nach Gemeinwohlkriterien vergeben oder nach Lobby-Stärke? Profitiert die Allgemeinheit oder eine kleine Gruppe?",
theorie: "Solidarität in der Finanzpolitik heißt: Öffentliches Geld dient öffentlichen Zwecken. B2 misst, ob Haushaltsentscheidungen dem Gemeinwohl dienen. Die GWÖ unterscheidet zwischen Investitionen, die allen zugutekommen (Bibliotheken, ÖPNV, Grünflächen), und solchen, die nur partikulare Interessen bedienen."
},
"B3": {
label: "Ökologische Verantwortung der Finanzpolitik",
praxis: "Investiert die Kommune in Solaranlagen auf Schuldächern? Oder wird das Geld in klimaschädliche Infrastruktur gesteckt? Gibt es einen kommunalen Klimafonds? Werden Folgekosten des Klimawandels (Hochwasserschutz, Hitzeanpassung) im Haushalt berücksichtigt?",
theorie: "Ökologische Nachhaltigkeit muss sich im Haushalt widerspiegeln. B3 bewertet, ob die Kommune ihr Geld so einsetzt, dass ökologische Ziele unterstützt werden. Die GWÖ fordert hier die Internalisierung externer Kosten: Wer klimaschädlich investiert, muss die Folgekosten mitrechnen."
},
"B4": {
label: "Soziale Verantwortung der Finanzpolitik",
praxis: "Bekommen ärmere Stadtteile genauso viel Geld für Spielplätze und Schulen wie reiche Viertel? Gibt es eine bewusste Umverteilung zugunsten benachteiligter Quartiere? Werden soziale Folgekosten von Sparmaßnahmen berücksichtigt?",
theorie: "Soziale Gerechtigkeit erfordert bewusste Verteilungsentscheidungen. B4 misst, ob der Haushalt Ungleichheit verringert oder verstärkt. Die GWÖ orientiert sich am Rawlsschen Differenzprinzip: Eine Ungleichverteilung ist nur dann gerechtfertigt, wenn sie den am schlechtesten Gestellten zugutekommt."
},
"B5": {
label: "Partizipation in der Finanzpolitik",
praxis: "Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können, ob das Geld in die Bibliothek oder den Sportplatz fließt? Werden Haushaltsentwürfe verständlich aufbereitet? Können Vereine und Initiativen Projektmittel beantragen?",
theorie: "Demokratie braucht finanzielle Transparenz. B5 bewertet, ob Bürger:innen Einblick in und Einfluss auf die Verwendung ihrer Steuergelder haben. Das reicht vom lesbaren Haushaltsbericht bis zum deliberativen Bürgerhaushalt nach dem Vorbild von Porto Alegre."
},
"C1": {
label: "Individuelle Rechts- und Gleichstellung",
praxis: "Werden in der Stadtverwaltung Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen auf eine Stelle? Gibt es Schutz vor Mobbing und Diskriminierung? Werden Führungspositionen quotiert besetzt? Gibt es anonymisierte Bewerbungsverfahren?",
theorie: "Die Menschenwürde der Mitarbeitenden ist die Grundlage jeder guten Verwaltung. C1 bewertet, ob die Kommune als Arbeitgeberin Gleichstellung aktiv fördert — nicht nur gesetzliche Mindeststandards einhält, sondern darüber hinausgeht. Die GWÖ misst hier die Differenz zwischen formaler Gleichberechtigung und gelebter Gleichstellung."
},
"C2": {
label: "Gemeinsame Zielvereinbarung für das Gemeinwohl",
praxis: "Hat die Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen? Gibt es ein Leitbild, das über Wahlperioden hinaus Bestand hat? Werden Ziele messbar formuliert und regelmäßig überprüft? Oder kocht jedes Amt sein eigenes Süppchen?",
theorie: "Solidarität innerhalb der Verwaltung bedeutet: Alle ziehen am selben Strang. C2 misst den Grad der internen Kohärenz — ob Gemeinwohlziele als Querschnittsaufgabe verstanden werden oder in Ressortdenken versanden. Die GWÖ orientiert sich hier am Konzept der lernenden Organisation."
},
"C3": {
label: "Förderung ökologischen Verhaltens intern",
praxis: "Fahren die Mitarbeiter:innen des Rathauses mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine? Wird Papier eingespart, doppelseitig gedruckt, auf Ökostrom umgestellt? Werden Dienstreisen klimakompensiert?",
theorie: "Die Kommune hat eine Vorbildfunktion. C3 bewertet, ob ökologisches Verhalten intern gefördert und belohnt wird. Die GWÖ argumentiert: Wer von Bürger:innen nachhaltiges Handeln erwartet, muss selbst vorangehen. Das umfasst sowohl strukturelle Maßnahmen (Gebäudesanierung, Fuhrpark-Umstellung) als auch Alltagsverhalten."
},
"C4": {
label: "Gerechte Verteilung von Arbeit",
praxis: "Können Eltern in der Verwaltung Teilzeit arbeiten, ohne Karrierenachteile? Gibt es flexible Arbeitszeiten für pflegende Angehörige? Werden prekäre Beschäftigungsverhältnisse (Befristungen, Minijobs) in der Kommune minimiert?",
theorie: "Soziale Gerechtigkeit beginnt beim eigenen Personal. C4 misst, ob die Kommune als Arbeitgeberin faire Arbeitsbedingungen schafft — insbesondere für die Vereinbarkeit von Beruf und Privatleben, für Menschen mit Betreuungspflichten und für diejenigen in den unteren Lohngruppen."
},
"C5": {
label: "Transparente Kommunikation und demokratische Prozesse intern",
praxis: "Können Sie die Sitzungsprotokolle des Stadtrats online lesen? Verstehen Sie, warum Entscheidungen so und nicht anders gefallen sind? Gibt es eine Fehlerkultur in der Verwaltung? Werden Beschwerden ernst genommen?",
theorie: "Transparenz nach innen und außen ist die Voraussetzung für Vertrauen. C5 bewertet, ob Entscheidungsprozesse nachvollziehbar, Informationsflüsse offen und Feedback-Kanäle funktional sind. Die GWÖ versteht Verwaltung nicht als Apparat, sondern als demokratische Dienstleisterin."
},
"D1": {
label: "Schutz des Individuums, Rechtsgleichheit",
praxis: "Werden Sie auf dem Amt fair behandelt — egal ob Sie einen deutschen oder ausländischen Namen haben? Schützt die Polizei alle gleich? Gibt es barrierefreie Zugänge, mehrsprachige Formulare, kultursensible Angebote? Wird Racial Profiling systematisch verhindert?",
theorie: "Menschenwürde bedeutet: Jeder Mensch hat den gleichen Wert, unabhängig von Herkunft, Geschlecht, Religion oder sozialem Status. D1 misst, ob die Kommune diesen Grundsatz in der täglichen Interaktion mit ihren Bürger:innen verwirklicht — nicht nur rechtlich, sondern auch in der gelebten Verwaltungskultur."
},
"D2": {
label: "Gesamtwohl in der Gemeinde",
praxis: "Profitiert die ganze Stadt von dem Antrag — oder nur ein Stadtteil, eine Altersgruppe, eine Einkommensschicht? Werden Interessen abgewogen? Gibt es Folgenabschätzungen, die alle Bevölkerungsgruppen berücksichtigen?",
theorie: "Solidarität auf kommunaler Ebene heißt: Das Gesamtwohl geht vor Partikularinteressen. D2 bewertet, ob politische Entscheidungen dem Nutzen aller dienen. Die GWÖ warnt vor der 'Tyrannei der Mehrheit' ebenso wie vor der Dominanz organisierter Minderheiten — der Maßstab ist das inklusive Gemeinwohl."
},
"D3": {
label: "Ökologische Gestaltung der öffentlichen Leistung",
praxis: "Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren? Wird das Regenwasser im Park versickert statt in die Kanalisation geleitet? Sind öffentliche Gebäude energetisch saniert? Gibt es Trinkwasserbrunnen statt Einwegflaschen in den Ämtern?",
theorie: "Jede kommunale Dienstleistung hat einen ökologischen Fußabdruck. D3 bewertet, ob die Daseinsvorsorge nachhaltig gestaltet ist. Die GWÖ argumentiert: Öffentliche Leistungen erreichen alle — deshalb ist ihr ökologischer Hebel besonders groß."
},
"D4": {
label: "Soziale Gestaltung der öffentlichen Leistung",
praxis: "Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner noch einen Arzttermin in der Nähe? Findet die Familie mit drei Kindern eine bezahlbare Wohnung? Sind Bibliotheken, Schwimmbäder, Kulturangebote für alle zugänglich — oder nur für die, die es sich leisten können?",
theorie: "Soziale Gerechtigkeit in der Daseinsvorsorge ist der Kern kommunaler Politik. D4 misst, ob grundlegende öffentliche Leistungen — Bildung, Gesundheit, Wohnen, Mobilität, Kultur — für alle Einkommensgruppen zugänglich sind. Die GWÖ orientiert sich hier am Konzept der 'Capabilities' (Amartya Sen): Was nützt ein Recht, wenn man es sich nicht leisten kann?"
},
"D5": {
label: "Transparente Kommunikation und demokratische Einbindung",
praxis: "Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird? Gibt es Bürgerversammlungen, Online-Beteiligung, Jugendparlamente? Werden Planungsprozesse offen kommuniziert? Können Betroffene Einspruch erheben — und wird der ernst genommen?",
theorie: "Demokratie ist mehr als Wahlen alle vier Jahre. D5 bewertet die Qualität der alltäglichen demokratischen Beteiligung. Die GWÖ fordert deliberative Demokratie auf kommunaler Ebene: Bürger:innen sollen nicht nur informiert, sondern aktiv in Entscheidungsprozesse eingebunden werden."
},
"E1": {
label: "Menschenwürdiges Leben für zukünftige Generationen",
praxis: "Hinterlassen wir unseren Enkeln einen Schuldenberg und versiegelte Flächen? Oder investieren wir heute so, dass auch 2050 noch gute Lebensbedingungen herrschen? Gibt es eine Generationenbilanz im Haushalt? Werden langfristige Folgekosten mitgedacht?",
theorie: "Die Menschenwürde hat eine zeitliche Dimension. E1 bewertet, ob die Kommune die Interessen zukünftiger Generationen systematisch berücksichtigt. Die GWÖ bezieht sich hier auf Hans Jonas' 'Prinzip Verantwortung': Handle so, dass die Wirkungen deiner Handlung verträglich sind mit der Permanenz echten menschlichen Lebens auf Erden."
},
"E2": {
label: "Beitrag zum Gesamtwohl über die Gemeindegrenzen hinaus",
praxis: "Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden? Gibt es regionale Kooperationen, interkommunale Zusammenarbeit, gemeinsame Projekte? Werden Spillover-Effekte (positiv und negativ) auf die Umgebung berücksichtigt?",
theorie: "Solidarität endet nicht an der Gemeindegrenze. E2 misst, ob eine Kommune auch das Wohl der Region, des Landes und darüber hinaus mitdenkt. Die GWÖ warnt vor kommunalem Egoismus — wenn z.\u00a0B. ein Gewerbegebiet Steuereinnahmen generiert, aber den Nachbarn den Verkehr aufbürdet."
},
"E3": {
label: "Verantwortung für ökologische Auswirkungen jenseits der Gemeinde",
praxis: "Denkt Ihre Kommune beim Einkauf an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten? Werden globale Umweltwirkungen lokaler Entscheidungen sichtbar gemacht?",
theorie: "Die ökologische Krise ist global, aber die Verursachung ist lokal. E3 bewertet, ob eine Kommune ihre ökologische Verantwortung über die eigenen Grenzen hinaus wahrnimmt. Die GWÖ argumentiert: Wer billig einkauft und die Umweltkosten in andere Länder exportiert, lebt auf Kosten anderer — auch wenn die Bilanz vor Ort grün aussieht."
},
"E4": {
label: "Beitrag zum sozialen Ausgleich",
praxis: "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden? Werden faire Handelsprodukte bevorzugt? Engagiert sich die Kommune in der Entwicklungszusammenarbeit?",
theorie: "Soziale Gerechtigkeit im globalen Maßstab ist die anspruchsvollste Dimension der GWÖ. E4 bewertet, ob eine Kommune über den eigenen Tellerrand schaut und zum Abbau globaler Ungleichheit beiträgt. Das kann über Fairtrade-Beschaffung, Städtepartnerschaften oder solidarische Finanzierungsmodelle geschehen."
},
"E5": {
label: "Transparente und demokratische Mitbestimmung auf übergeordneter Ebene",
praxis: "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen (Pariser Klimaabkommen, SDGs) aktiv unterstützt? Gibt es kommunale Resolutionen zu überregionalen Themen? Engagiert sich die Stadt in kommunalen Netzwerken?",
theorie: "Demokratie braucht Fürsprecher auf allen Ebenen. E5 bewertet, ob eine Kommune ihre demokratische Stimme auch jenseits der eigenen Zuständigkeit erhebt. Die GWÖ versteht Kommunen als demokratische Akteure im Mehrebenensystem — nicht als passive Vollstrecker übergeordneter Politik."
}
};
function showFieldInfo(code) {
const info = fieldInfo[code];
if (!info) return;
const el = document.getElementById('field-explain');
document.getElementById('field-explain-title').textContent = code + ': ' + info.label;
document.getElementById('field-explain-text').innerHTML =
'<p style="margin-bottom:0.75rem;"><strong>Praktische Bedeutung:</strong> ' + info.praxis + '</p>' +
'<p><strong>Theoretischer Hintergrund:</strong> ' + info.theorie + '</p>';
el.style.display = 'block';
el.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
</script>
<div class="matrix-grid"> <div class="matrix-grid">
<div class="cell"></div> <div class="cell"></div>
<div class="header-cell">Menschen&shy;würde</div> <div class="header-cell">1. Menschen&shy;würde</div>
<div class="header-cell">Solidarität</div> <div class="header-cell">2. Solidarität</div>
<div class="header-cell">Ökologische Nachhaltig&shy;keit</div> <div class="header-cell">3. Ökol. Nachh.</div>
<div class="header-cell">Soziale Gerechtig&shy;keit</div> <div class="header-cell">4. Soz. Gerecht.</div>
<div class="header-cell">Transparenz & Demokratie</div> <div class="header-cell">5. Transparenz</div>
<div class="row-header">A · Lieferanten</div> <div class="row-header">A · Lieferant:innen</div>
<div class="cell">A1</div><div class="cell">A2</div><div class="cell">A3</div><div class="cell">A4</div><div class="cell">A5</div> <div class="cell" style="cursor:pointer;" title="Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt?" onclick="showFieldInfo('A1')"><strong>A1</strong><br><small>Grundrechtsschutz in der Lieferkette</small></div>
<div class="cell" style="cursor:pointer;" title="Beauftragt die Stadt den Betrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland?" onclick="showFieldInfo('A2')"><strong>A2</strong><br><small>Nutzen für die Gemeinde</small></div>
<div class="cell" style="cursor:pointer;" title="Werden bei Aufträgen Klimastandards verlangt? Kommt das Schulessen regional?" onclick="showFieldInfo('A3')"><strong>A3</strong><br><small>Ökologische Verantwortung</small></div>
<div class="cell" style="cursor:pointer;" title="Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn?" onclick="showFieldInfo('A4')"><strong>A4</strong><br><small>Soziale Verantwortung</small></div>
<div class="cell" style="cursor:pointer;" title="Können Sie nachschauen, welche Firma den Auftrag bekam — und warum?" onclick="showFieldInfo('A5')"><strong>A5</strong><br><small>Rechenschaft & Mitsprache</small></div>
<div class="row-header">B · Finanzen</div> <div class="row-header">B · Finanzen</div>
<div class="cell">B1</div><div class="cell">B2</div><div class="cell">B3</div><div class="cell">B4</div><div class="cell">B5</div> <div class="cell" style="cursor:pointer;" title="Liegt das Geld Ihrer Stadt bei einer ethischen Bank — oder bei einer, die Waffengeschäfte finanziert?" onclick="showFieldInfo('B1')"><strong>B1</strong><br><small>Ethisches Finanzgebaren</small></div>
<div class="cell" style="cursor:pointer;" title="Fließen Steuergelder in Radwege für alle — oder in eine Umgehungsstraße nur fürs Gewerbegebiet?" onclick="showFieldInfo('B2')"><strong>B2</strong><br><small>Gemeinnutz im Haushalt</small></div>
<div class="cell" style="cursor:pointer;" title="Investiert die Kommune in Solaranlagen auf Schuldächern?" onclick="showFieldInfo('B3')"><strong>B3</strong><br><small>Ökologische Finanzpolitik</small></div>
<div class="cell" style="cursor:pointer;" title="Bekommen ärmere Stadtteile genauso viel für Spielplätze wie reiche?" onclick="showFieldInfo('B4')"><strong>B4</strong><br><small>Soziale Finanzpolitik</small></div>
<div class="cell" style="cursor:pointer;" title="Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können?" onclick="showFieldInfo('B5')"><strong>B5</strong><br><small>Partizipation im Haushalt</small></div>
<div class="row-header">C · Verwaltung</div> <div class="row-header">C · Verwaltung</div>
<div class="cell">C1</div><div class="cell">C2</div><div class="cell">C3</div><div class="cell">C4</div><div class="cell">C5</div> <div class="cell" style="cursor:pointer;" title="Werden Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen?" onclick="showFieldInfo('C1')"><strong>C1</strong><br><small>Gleichstellung</small></div>
<div class="cell" style="cursor:pointer;" title="Hat die Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen?" onclick="showFieldInfo('C2')"><strong>C2</strong><br><small>Gemeinsame Ziele</small></div>
<div class="cell" style="cursor:pointer;" title="Fahren Mitarbeitende mit dem Dienstrad oder dem SUV? Vegetarisches Kantinenessen?" onclick="showFieldInfo('C3')"><strong>C3</strong><br><small>Ökologisches Verhalten</small></div>
<div class="cell" style="cursor:pointer;" title="Können Eltern in der Verwaltung Teilzeit arbeiten ohne Karrierenachteile?" onclick="showFieldInfo('C4')"><strong>C4</strong><br><small>Gerechte Arbeitsteilung</small></div>
<div class="cell" style="cursor:pointer;" title="Können Sie die Sitzungsprotokolle online lesen? Verstehen Sie die Entscheidungen?" onclick="showFieldInfo('C5')"><strong>C5</strong><br><small>Transparenz & Demokratie</small></div>
<div class="row-header">D · Bürger</div> <div class="row-header">D · Bürger:innen</div>
<div class="cell">D1</div><div class="cell">D2</div><div class="cell">D3</div><div class="cell">D4</div><div class="cell">D5</div> <div class="cell" style="cursor:pointer;" title="Werden Sie auf dem Amt fair behandelt — egal welchen Namen Sie tragen?" onclick="showFieldInfo('D1')"><strong>D1</strong><br><small>Rechtsgleichheit</small></div>
<div class="cell" style="cursor:pointer;" title="Profitiert die ganze Stadt — oder nur ein Stadtteil, eine Altersgruppe?" onclick="showFieldInfo('D2')"><strong>D2</strong><br><small>Gesamtwohl</small></div>
<div class="cell" style="cursor:pointer;" title="Kommt der Strom aus Erneuerbaren? Wird Regenwasser versickert statt in die Kanalisation geleitet?" onclick="showFieldInfo('D3')"><strong>D3</strong><br><small>Ökol. öffentliche Leistung</small></div>
<div class="cell" style="cursor:pointer;" title="Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner einen Arzttermin?" onclick="showFieldInfo('D4')"><strong>D4</strong><br><small>Soziale öffentliche Leistung</small></div>
<div class="cell" style="cursor:pointer;" title="Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird?" onclick="showFieldInfo('D5')"><strong>D5</strong><br><small>Demokratische Einbindung</small></div>
<div class="row-header">E · Gesellschaft</div> <div class="row-header">E · Gesellschaft</div>
<div class="cell">E1</div><div class="cell">E2</div><div class="cell">E3</div><div class="cell">E4</div><div class="cell">E5</div> <div class="cell" style="cursor:pointer;" title="Hinterlassen wir unseren Enkeln einen Schuldenberg — oder investieren wir für 2050?" onclick="showFieldInfo('E1')"><strong>E1</strong><br><small>Zukünftige Generationen</small></div>
<div class="cell" style="cursor:pointer;" title="Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden?" onclick="showFieldInfo('E2')"><strong>E2</strong><br><small>Beitrag zum Gesamtwohl</small></div>
<div class="cell" style="cursor:pointer;" title="Denkt die Kommune an den CO₂-Fußabdruck, an Regenwälder, an Wasserverbrauch in Dürregebieten?" onclick="showFieldInfo('E3')"><strong>E3</strong><br><small>Ökologische Auswirkungen</small></div>
<div class="cell" style="cursor:pointer;" title="Unterstützt die Stadt strukturschwache Regionen? Partnerschaften im globalen Süden?" onclick="showFieldInfo('E4')"><strong>E4</strong><br><small>Sozialer Ausgleich</small></div>
<div class="cell" style="cursor:pointer;" title="Setzt sich die Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene?" onclick="showFieldInfo('E5')"><strong>E5</strong><br><small>Demokratische Mitbestimmung</small></div>
</div> </div>
<p>Jedes Feld wird auf einer Skala von <strong>-5</strong> (fundamental widersprechend)
bis <strong>+5</strong> (stark fördernd) bewertet. Der Gesamtscore (0-10) gewichtet
die Matrix-Bewertungen und berücksichtigt Ausschlusskriterien:</p>
<table>
<tr><th>Symbol</th><th>Rating</th><th>Bedeutung</th></tr>
<tr><td>++</td><td>+4 bis +5</td><td>Stark fördernd, vorbildlich</td></tr>
<tr><td>+</td><td>+1 bis +3</td><td>Fördernd</td></tr>
<tr><td></td><td>0</td><td>Neutral / nicht berührt</td></tr>
<tr><td></td><td>-1 bis -3</td><td>Widersprechend</td></tr>
<tr><td></td><td>-4 bis -5</td><td>Stark widersprechend</td></tr>
</table>
<details> <details>
<summary>Mehr zur GWÖ-Matrix</summary> <summary>Bewertungsskala</summary>
<p style="margin-top: 0.5rem;"> <table style="margin-top:0.5rem;">
Die Matrix basiert auf dem <tr><th>Symbol</th><th>Rating</th><th>Bedeutung</th></tr>
<a href="https://econgood.org" target="_blank">Arbeitsbuch der Gemeinwohl-Ökonomie</a>. <tr><td>++</td><td>+4 bis +5</td><td>Stark fördernd, vorbildlich</td></tr>
Die Adaption für Gemeinden fokussiert auf kommunale Handlungsfelder: <tr><td>+</td><td>+1 bis +3</td><td>Fördernd</td></tr>
Beschaffung, Haushalt, Verwaltung, Daseinsvorsorge und überregionale Wirkung. <tr><td></td><td>0</td><td>Neutral / nicht berührt</td></tr>
</p> <tr><td></td><td>1 bis 3</td><td>Widersprechend</td></tr>
<tr><td></td><td>4 bis 5</td><td>Stark widersprechend</td></tr>
</table>
</details> </details>
</div> </div>
<h2>Analyse-Pipeline</h2> <h2>Analyse-Pipeline</h2>
<div class="card"> <div class="card">
<p>Jede Bewertung durchläuft fünf Schritte:</p>
<div class="pipeline-step"> <div class="pipeline-step">
<div class="step-num">1</div> <div class="step-num">1</div>
<div> <div>
<strong>Antrags-Text herunterladen</strong><br> <strong>Antragstext laden</strong><br>
Der Volltext wird automatisch aus dem jeweiligen Landtags-Portal geholt Der PDF-Volltext wird aus dem Landtags-Portal geholt
({{ adapter_count }} Parlamente angebunden). Der PDF-Text wird via PyMuPDF extrahiert. ({{ adapter_count }} Parlamente angebunden). Nur echte Anträge und
Gesetzentwürfe werden analysiert — Kleine Anfragen, Berichte und
Beschlussempfehlungen werden automatisch erkannt und übersprungen.
</div> </div>
</div> </div>
<div class="pipeline-step"> <div class="pipeline-step">
<div class="step-num">2</div> <div class="step-num">2</div>
<div> <div>
<strong>Relevante Wahlprogramm-Passagen suchen</strong><br> <strong>Wahlprogramm-Passagen suchen</strong><br>
Für <strong>alle Fraktionen der Wahlperiode</strong> werden per Embedding-Suche Per semantischer Suche ({{ embedding_model }}, 1024 Dimensionen) werden für
(Qwen text-embedding-v3) die thematisch relevantesten Passagen aus Wahl- und <strong>jede Fraktion</strong> die thematisch relevantesten Passagen aus
Grundsatzprogrammen gesucht (Top-5 pro Partei, Cosinus-Ähnlichkeit ≥ 0.45). Wahl- und Grundsatzprogrammen gefunden. Aktuell {{ programme_count }} Programme
mit {{ chunk_count }} Textabschnitten indexiert.
</div> </div>
</div> </div>
@ -222,9 +445,10 @@
<div class="step-num">3</div> <div class="step-num">3</div>
<div> <div>
<strong>KI-Bewertung</strong><br> <strong>KI-Bewertung</strong><br>
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der GWÖ-Matrix Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der
und vergleicht ihn mit den gefundenen Programm-Passagen. Der Prompt enthält GWÖ-Matrix und vergleicht ihn mit den gefundenen Programmpassagen.
strikte Regeln für die Quellenangabe (nur wörtliche Zitate aus den vorgelegten Passagen). Der Prompt erzwingt die Verwendung wörtlicher Zitate — das Modell darf
keine Quellenangaben frei erfinden.
</div> </div>
</div> </div>
@ -232,180 +456,81 @@
<div class="step-num">4</div> <div class="step-num">4</div>
<div> <div>
<strong>Zitat-Verifikation</strong><br> <strong>Zitat-Verifikation</strong><br>
Jedes vom Modell genannte Zitat wird <strong>server-seitig verifiziert</strong>: Jedes Zitat wird <strong>server-seitig verifiziert</strong>: der Text muss
Der zitierte Text muss als Substring (oder 5-Wort-Sequenz) in einem der als Substring im Original-PDF auffindbar sein. Quellenangabe und Seitenzahl
vorgelegten Chunks auffindbar sein. Nicht-verifizierbare Zitate werden werden aus dem echten Treffer rekonstruiert — die Modell-Ausgabe wird für diese
verworfen — Quellenangabe und Seitenzahl werden aus dem echten Treffer Felder verworfen. Klick auf ein Zitat öffnet das PDF mit markierter Fundstelle.
rekonstruiert, nicht aus der Modell-Ausgabe übernommen.
</div>
</div>
<div class="pipeline-step">
<div class="step-num">5</div>
<div>
<strong>Persistierung & Darstellung</strong><br>
Die verifizierte Bewertung wird gespeichert. Klick auf ein Zitat öffnet
das Original-Wahlprogramm-PDF mit <strong>gelb markierter Fundstelle</strong>.
</div> </div>
</div> </div>
<details> <details>
<summary>Technische Details zum Sprachmodell</summary> <summary>Technische Details</summary>
<div style="margin-top: 0.5rem;"> <table style="margin-top:0.5rem;">
<table> <tr><th>Eigenschaft</th><th>Wert</th></tr>
<tr><th>Eigenschaft</th><th>Wert</th></tr> <tr><td>Sprachmodell</td><td>{{ model_name }} (DashScope / Alibaba Cloud)</td></tr>
<tr><td>Modell</td><td>{{ model_name }}</td></tr> <tr><td>Embedding-Modell</td><td>{{ embedding_model }} (1024 Dimensionen)</td></tr>
<tr><td>Anbieter</td><td>DashScope (Alibaba Cloud)</td></tr> <tr><td>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr>
<tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr> <tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr>
<tr><td>Embedding-Modell</td><td>text-embedding-v3 (1024 Dimensionen)</td></tr> <tr><td>Zitat-Verifikation</td><td>Substring- oder 5-Wort-Anker-Match gegen Original-PDF</td></tr>
<tr><td>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr> </table>
</table>
</div>
</details> </details>
</div> </div>
<h2>Beispiel einer Bewertung</h2>
<div class="card">
<p>Am Beispiel eines fiktiven Antrags "Kostenfreies Schulessen in allen Grundschulen":</p>
<div class="pipeline-step">
<div class="step-num">1</div>
<div>
<strong>Antragstext wird geladen</strong><br>
"Der Landtag wolle beschließen: Die Landesregierung wird aufgefordert,
ein Programm für kostenfreies Mittagessen in allen Grundschulen …"
</div>
</div>
<div class="pipeline-step">
<div class="step-num">2</div>
<div>
<strong>Embedding-Suche findet relevante Passagen</strong><br>
Für jede Fraktion (z.B. SPD, CDU, GRÜNE, FDP, AfD) werden die thematisch
nächsten Abschnitte aus den Wahlprogrammen gesucht. Beispiel:
<em>"Wir setzen uns für gesunde Ernährung in Kitas und Schulen ein"</em>
(GRÜNE Wahlprogramm, S. 47).
</div>
</div>
<div class="pipeline-step">
<div class="step-num">3</div>
<div>
<strong>KI bewertet den Antrag</strong><br>
GWÖ-Score: <strong>7/10</strong> — berührt D4 (Soziale öffentliche Leistung, ++),
E3 (Ökologische Auswirkungen, +), B2 (Gemeinnutz im Finanzgebaren, +).
Wahlprogramm-Passung GRÜNE: 9/10 mit Zitat aus S. 47.
</div>
</div>
<div class="pipeline-step">
<div class="step-num">4</div>
<div>
<strong>Zitat wird verifiziert</strong><br>
Der Server prüft: steht "gesunde Ernährung in Kitas und Schulen"
wirklich auf S. 47 des GRÜNE-Wahlprogramms? ✓ Ja → Zitat wird übernommen.
Quellenangabe wird aus dem echten Treffer konstruiert.
</div>
</div>
</div>
<h2>Wahlprogramm-Vergleich</h2>
<div class="card">
<p>
Für jede Fraktion der aktuellen Wahlperiode wird die <strong>Passung</strong>
des Antrags zu zwei Programmen bewertet:
</p>
<ul style="margin: 0.5rem 0 0.5rem 1.5rem;">
<li><strong>Wahlprogramm</strong> — das Landtags-Wahlprogramm der jeweiligen Legislaturperiode</li>
<li><strong>Grundsatzprogramm</strong> — das aktuelle Bundespartei-Grundsatzprogramm</li>
</ul>
<p>
Aktuell sind <strong>{{ programme_count }} Programme</strong> indexiert
({{ chunk_count }} Textabschnitte). Die vollständige Liste ist auf der
<a href="/quellen">Quellen-Seite</a> einsehbar.
</p>
<div class="note">
<strong>Wichtig:</strong> Wenn für eine Fraktion kein Programm im Index vorhanden ist,
wird kein Score vergeben — stattdessen erscheint ein Hinweis. Bewertungen
basieren ausschließlich auf verifizierbaren Quellen, nicht auf dem Trainingswissen
des Sprachmodells.
</div>
</div>
<h2>Qualitätssicherung</h2> <h2>Qualitätssicherung</h2>
<div class="card"> <div class="card">
<h3>Zitat-Verifikation (Sub-D)</h3> <ul>
<p> <li><strong>Automatische Zitat-Verifikation</strong> — jedes Zitat wird gegen das
Ein automatisierter Property-Test prüft für jedes in der Datenbank gespeicherte Original-PDF geprüft. Nicht-verifizierbare Zitate werden verworfen. Dieses
Zitat, ob der zitierte Text tatsächlich auf der angegebenen Seite des System hat im April 2026 drei LLM-halluzinierte Zitate aufgedeckt.</li>
Wahlprogramm-PDFs vorkommt (Substring- oder 5-Wort-Anker-Match). Dieses <li><strong>Typ-Filterung</strong> — nur abstimmbare Drucksachen (Anträge,
Verfahren hat im April 2026 drei halluzinierte Zitate aufgedeckt und zur Gesetzentwürfe) werden bewertet. Kleine Anfragen, Berichte und andere
Implementierung der server-seitigen Verifikation geführt. nicht-abstimmbare Dokumente werden automatisch erkannt und ausgeschlossen.</li>
</p> <li><strong>Automatische Neu-Analyse</strong> — wenn ein Zitat im PDF nicht
auffindbar ist, wird der Antrag mit der aktuellen Pipeline neu analysiert.</li>
<h3>Server-seitige Quellen-Rekonstruktion</h3> <li><strong>Open Data</strong> — alle Bewertungen sind als JSON und CSV exportierbar
<p> (CC BY 4.0). Der Atom-Feed ermöglicht automatische Benachrichtigung bei neuen
Das Sprachmodell darf keine Quellenangaben (Programmname, Seitenzahl) frei Bewertungen.</li>
erfinden. Nach jeder Analyse wird jedes Zitat gegen die tatsächlich vorgelegten </ul>
Textabschnitte abgeglichen. Quellenangabe und URL werden aus dem gefundenen
Treffer <strong>server-seitig konstruiert</strong> — die Modell-Ausgabe für
diese Felder wird verworfen.
</p>
<h3>Automatische Neu-Analyse</h3>
<p>
Wenn ein Nutzer auf ein Zitat klickt und die Textstelle im PDF nicht auffindbar
ist (z.B. bei älteren Bewertungen vor der Verifikations-Einführung), wird der
Antrag automatisch mit der aktuellen Pipeline neu analysiert.
</p>
</div> </div>
<h2>Einschränkungen</h2> <h2>Einschränkungen</h2>
<div class="card"> <div class="card">
<ul style="margin-left: 1.5rem;"> <ul>
<li><strong>Keine juristische Bewertung</strong> — die GWÖ-Analyse ist eine <li><strong>Wertebasierte Einordnung, keine Rechtsprüfung</strong> — die Analyse
wertebasierte Einordnung, keine Rechtsprüfung.</li> bewertet nach GWÖ-Kriterien, nicht nach juristischer Zulässigkeit.</li>
<li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen <li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen
aufweisen. Die Bewertungen sollten als Orientierung verstanden werden, aufweisen. Bewertungen sind Orientierung, nicht objektive Wahrheit.</li>
nicht als objektive Wahrheit.</li> <li><strong>Programmabhängig</strong> — Fraktionen ohne hinterlegtes Wahlprogramm
<li><strong>Nur indexierte Programme</strong> — Parteien ohne hinterlegtes erhalten keinen Programm-Vergleich.</li>
Programm können nicht zuverlässig bewertet werden.</li> <li><strong>Antragstext, nicht Umsetzung</strong> — bewertet wird was im Antrag
<li><strong>Keine Analyse des Abstimmungsverhaltens</strong> — bewertet wird steht, nicht ob oder wie es umgesetzt wird.</li>
der Antragstext, nicht ob oder wie darüber abgestimmt wurde.</li>
<li><strong>Aktualität</strong> — Wahlprogramme werden einmalig zur Wahl
indexiert und nicht automatisch aktualisiert.</li>
</ul> </ul>
</div> </div>
<h2>Datenquellen</h2> <h2>Datenquellen</h2>
<div class="card"> <div class="card">
<p><strong>{{ adapter_count }} Parlamente</strong> sind angebunden:</p> <p><strong>{{ adapter_count }} Parlamente</strong> angebunden:</p>
<table> <table>
<tr><th>Parlament</th><th>Doku-System</th></tr> <tr><th>Parlament</th><th>System</th></tr>
{% for bl in bundeslaender %} {% for bl in bundeslaender %}
<tr> <tr><td>{{ bl.name }} ({{ bl.code }})</td><td>{{ bl.doku_system }}</td></tr>
<td>{{ bl.name }} ({{ bl.code }})</td>
<td>{{ bl.doku_system }}</td>
</tr>
{% endfor %} {% endfor %}
</table> </table>
<p style="margin-top: 1rem;"> <p style="margin-top: 1rem;">
<a href="/quellen">Vollständige Programm-Liste</a> · <a href="/quellen">Programme & Quellen</a> ·
<a href="https://docs.toppyr.de/gwoe-antragspruefer/reference/adapter-capabilities/" target="_blank">Technische Adapter-Vergleichsmatrix</a> · <a href="/api/auswertungen/export.json" download>Open Data (JSON)</a> ·
<a href="https://docs.toppyr.de/gwoe-antragspruefer/adr/" target="_blank">Architektur-Entscheidungen (ADRs)</a> <a href="/api/feed.xml">Atom-Feed</a> ·
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
</p> </p>
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
{{ app_name }} · <a href="https://econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> · {{ app_name }} · <a href="https://germany.econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a> <a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
</div> </div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GWÖ-Monitor {{ scan_date }}</title>
</head>
<body style="font-family:Helvetica,Arial,sans-serif;max-width:640px;margin:0 auto;padding:20px;color:#333">
<h2 style="color:#007a80;margin-bottom:4px">GWÖ-Antragsprüfer — Monitoring {{ scan_date }}</h2>
<p style="color:#666;margin-top:4px;font-size:0.9em">Täglicher Scan aller aktiven Bundesländer</p>
{% if new_total == 0 %}
<div style="background:#f0fafa;border-left:3px solid #009da5;padding:10px 14px;margin:12px 0;font-size:0.95em;color:#444">
<b style="color:#007a80">Heute keine Änderungen.</b> Alle {{ total_seen }} in den Landtags-Portalen sichtbaren Drucksachen sind bereits seit dem letzten Scan bekannt. Das heißt: die Portale haben seit gestern keine neuen Anträge publiziert — nicht: der Scan war erfolglos.
</div>
{% endif %}
<!-- Kennzahlen-Block -->
<table style="width:100%;border-collapse:collapse;margin:16px 0">
<tr style="background:#f0fafa">
<td style="padding:10px 14px;border:1px solid #c8e6e6;font-weight:bold">Neue Drucksachen seit letztem Scan</td>
<td style="padding:10px 14px;border:1px solid #c8e6e6;font-size:1.4em;color:#007a80;font-weight:bold">{{ new_total }}</td>
</tr>
<tr>
<td style="padding:10px 14px;border:1px solid #ddd">Im Portal aktuell sichtbar (inkl. bekannter)</td>
<td style="padding:10px 14px;border:1px solid #ddd">{{ total_seen }}</td>
</tr>
<tr style="background:#fffbf0">
<td style="padding:10px 14px;border:1px solid #ddd">Kosten-Schätzung (alle analysieren)</td>
<td style="padding:10px 14px;border:1px solid #ddd">
<b>{{ "%.4f"|format(estimated_cost_eur) }} EUR</b>
<span style="font-size:0.8em;color:#888">&nbsp;(Qwen Plus, Näherung)</span>
</td>
</tr>
{% if errors %}
<tr style="background:#fff3f3">
<td style="padding:10px 14px;border:1px solid #f5c0c0;color:#c00">Adapter-Fehler</td>
<td style="padding:10px 14px;border:1px solid #f5c0c0;color:#c00;font-weight:bold">{{ errors|length }}</td>
</tr>
{% endif %}
</table>
<!-- Fehler-Details -->
{% if errors %}
<div style="background:#fff3f3;border-left:3px solid #c00;padding:10px 14px;margin:12px 0">
<b style="color:#c00">Fehler-Details:</b>
<ul style="margin:6px 0 0;padding-left:18px;font-size:0.9em">
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Bundesland-Übersicht -->
<h3 style="color:#007a80;border-bottom:1px solid #c8e6e6;padding-bottom:6px">Bundesland-Übersicht</h3>
<table style="width:100%;border-collapse:collapse;font-size:0.9em">
<thead>
<tr style="background:#e6f4f4">
<th style="padding:7px 10px;border:1px solid #c8e6e6;text-align:left">BL</th>
<th style="padding:7px 10px;border:1px solid #c8e6e6;text-align:right">Gesehen</th>
<th style="padding:7px 10px;border:1px solid #c8e6e6;text-align:right">Neu</th>
<th style="padding:7px 10px;border:1px solid #c8e6e6;text-align:left">Status</th>
</tr>
</thead>
<tbody>
{% for r in results %}
<tr style="{% if r.error %}background:#fff8f8{% elif r.new_count > 0 %}background:#f8fff8{% endif %}">
<td style="padding:6px 10px;border:1px solid #ddd;font-weight:bold">{{ r.bundesland }}</td>
<td style="padding:6px 10px;border:1px solid #ddd;text-align:right">{{ r.total_seen }}</td>
<td style="padding:6px 10px;border:1px solid #ddd;text-align:right;color:{% if r.new_count > 0 %}#007a80{% else %}#999{% endif %}">
{{ r.new_count }}
</td>
<td style="padding:6px 10px;border:1px solid #ddd;font-size:0.85em">
{% if r.error %}
<span style="color:#c00">✗ {{ r.error[:100] }}</span>
{% elif r.new_count > 0 %}
<span style="color:#2a7a2a">✓ {{ r.new_count }} neue</span>
{% else %}
<span style="color:#999">keine Änderung</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Neue Drucksachen -->
{% if new_docs %}
<h3 style="color:#007a80;border-bottom:1px solid #c8e6e6;padding-bottom:6px;margin-top:24px">
Neue Drucksachen ({{ new_docs|length }})
</h3>
{% for doc in new_docs[:30] %}
<div style="border-left:3px solid #007a80;padding:6px 12px;margin:8px 0;background:#f9f9f9;font-size:0.9em">
<span style="color:#007a80;font-weight:bold">{{ doc.bundesland }}</span>
<span style="color:#555;margin-left:8px">{{ doc.drucksache }}</span>
{% if doc.datum %}
<span style="color:#888;font-size:0.85em;margin-left:8px">{{ doc.datum }}</span>
{% endif %}
<br>
<span style="color:#333">{{ (doc.title or doc.drucksache or '')[:120] }}</span>
{% if doc.fraktionen %}
<br><span style="color:#777;font-size:0.85em">
{% if doc.fraktionen is iterable and doc.fraktionen is not string %}
{{ doc.fraktionen | join(', ') }}
{% else %}
{{ doc.fraktionen }}
{% endif %}
</span>
{% endif %}
</div>
{% endfor %}
{% if new_docs|length > 30 %}
<p style="color:#666;font-size:0.9em">… und {{ new_docs|length - 30 }} weitere neue Drucksachen.</p>
{% endif %}
{% else %}
<p style="color:#888;font-style:italic">Keine neuen Drucksachen heute.</p>
{% endif %}
<!-- Footer -->
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0">
<p style="font-size:0.8em;color:#aaa">
GWÖ-Antragsprüfer Monitoring &middot; Kosten-Schätzung basiert auf Qwen-Plus-Preisen (DashScope, April 2026) &middot;
Nur Metadaten — kein LLM-Call im Scan
</p>
</body>
</html>

View File

@ -179,10 +179,8 @@
</style> </style>
</head> </head>
<body> <body>
<header class="header"> {% set page_title = 'Quellen' %}
<h1><a href="/">{{ app_name }}</a></h1> {% include "_header.html" %}
<span>→ Quellen</span>
</header>
<div class="container"> <div class="container">
<a href="/" class="back-link">← Zurück zur Übersicht</a> <a href="/" class="back-link">← Zurück zur Übersicht</a>

282
app/templates/v2/base.html Normal file
View File

@ -0,0 +1,282 @@
{% from "v2/components/icon.html" import icon %}
<!DOCTYPE html>
<html lang="de" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GWÖ-Antragsprüfer{% endblock %}</title>
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
{# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
<link rel="stylesheet" href="/static/v2/tokens.css">
<link rel="stylesheet" href="/static/v2/fonts.css">
<link rel="stylesheet" href="/static/v2/v2.css">
{% block head_extra %}{% endblock %}
</head>
<body class="v2">
{% block body %}
{# AppShell inline, damit {% block main %} aus Screen-Templates rendert.
include propagiert Blocks nicht (Jinja2-Limitierung), darum direkt hier. #}
<div id="v2-overlay" class="v2-overlay"></div>
<div class="v2-shell">
<aside id="v2-sidebar" class="v2-sidebar">
<div class="v2-brand">
GWÖ-<span class="grn">ANTRAGS</span><span class="blu">PRÜFER</span>
</div>
<div class="v2-brand-sub">Matrix 2.0 · Gemeinden</div>
<nav aria-label="Hauptnavigation">
<div class="v2-nav-group">
<div class="v2-nav-label">— Lesen</div>
<a href="/" class="v2-nav-item {% if v2_active_nav == 'durchsuchen' %}active{% endif %}"
aria-current="{% if v2_active_nav == 'durchsuchen' %}page{% endif %}">
{{ icon("magnifying-glass", 14) }} Durchsuchen
{% if assessment_count is defined and assessment_count %}
<span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %}
</a>
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Prüfen</div>
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
</div>
{% if is_admin is defined and is_admin %}
<div class="v2-nav-group">
<div class="v2-nav-label">— Administration</div>
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
<a href="/v2/admin/queue" class="v2-nav-item">{{ icon("list-checks", 14) }} Queue</a>
<a href="/v2/admin/abos" class="v2-nav-item">{{ icon("envelope-simple", 14) }} Abos</a>
</div>
{% endif %}
</nav>
</aside>
<header class="v2-topbar">
<button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">&#9776;</button>
<span class="v2-topbar-spacer"></span>
<a href="/classic" class="v2-back-link">{{ icon("arrow-square-out", 13) }} Klassische Ansicht</a>
<a href="/methodik">{{ icon("info", 13) }} Methodik</a>
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
<div id="v2-auth-control" style="display:inline-flex;align-items:center;">
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
</div>
<button id="v2-theme-toggle"
onclick="window.__v2CycleTheme && window.__v2CycleTheme()"
aria-label="Farbschema wechseln"
style="background:none;border:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);letter-spacing:0.06em;text-transform:uppercase;opacity:0.75;padding:0;display:inline-flex;align-items:center;gap:4px;">
<span id="v2-theme-icon">{{ icon("circle-half", 14) }}</span><span id="v2-theme-label">Auto</span>
</button>
</header>
<main class="v2-main" id="v2-main">
{% block main %}{% endblock %}
</main>
<footer class="v2-footer">
<span>GWÖ-Antragsprüfer · Matrix 2.0 · CC BY 4.0</span>
<a href="/methodik">Methodik</a>
<a href="/quellen">Quellen</a>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer">Quellcode</a>
<span class="v2-topbar-spacer"></span>
<a href="/classic" style="color:var(--ecg-green);opacity:1;">Zurück zur klassischen Ansicht</a>
</footer>
</div>
{% endblock %}
<script>
/* ── Dark-Mode Toggle ────────────────────────────────────────────── */
(function () {
const STORAGE_KEY = 'gwoe.theme';
const PREF_KEY = 'gwoe.ui';
const root = document.documentElement;
function applyTheme(theme) {
root.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}
function cycleTheme() {
const current = localStorage.getItem(STORAGE_KEY) || 'auto';
const next = { auto: 'light', light: 'dark', dark: 'auto' }[current] || 'auto';
applyTheme(next);
updateToggleLabel();
}
function updateToggleLabel() {
const labelEl = document.getElementById('v2-theme-label');
const iconEl = document.getElementById('v2-theme-icon');
if (!labelEl) return;
const current = localStorage.getItem(STORAGE_KEY) || 'auto';
const labels = { auto: 'Auto', light: 'Hell', dark: 'Dunkel' };
const icons = {
auto: 'circle-half',
light: 'sun',
dark: 'moon'
};
labelEl.textContent = labels[current] || 'Auto';
if (iconEl) {
const iconName = icons[current] || 'circle-half';
iconEl.dataset.icon = iconName;
// Replace icon SVG dynamically via fetch (icons are static files)
fetch('/static/v2/icons/phosphor/' + iconName + '.svg')
.then(r => r.text())
.then(svg => { iconEl.innerHTML = svg; })
.catch(() => {});
}
}
// Restore stored theme
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) applyTheme(stored);
window.__v2CycleTheme = cycleTheme;
document.addEventListener('DOMContentLoaded', updateToggleLabel);
})();
/* ── UI Preference (v2 = Default) ───────────────────────────────── */
/* AUTO-REDIRECT DEAKTIVIERT — er verursacht Loop mit aggressivem classic-localStorage-Set.
Umschalter via Topbar-Link „Klassische Ansicht" reicht als Opt-Out. */
(function () {
// Alte Preference-Spuren löschen, damit niemand festklebt
localStorage.removeItem('gwoe.ui');
return;
// dead code weiter unten, bewusst belassen für Nachvollziehbarkeit
// Wenn Nutzer:in zuvor explizit "classic" gewählt hat, zu /classic weiterleiten.
// gwoe.ui='classic' wird von index.html gesetzt, wenn man /classic besucht.
// Nach Rückkehr via "Zum neuen Design"-Link überschreibt v2 das localStorage,
// damit die Präferenz für den nächsten Tab-Start korrekt ist.
var pref = localStorage.getItem('gwoe.ui');
if (pref === 'classic') {
// Einmal weiterleiten; danach bleibt Nutzer:in auf v2 (kein Loop)
localStorage.removeItem('gwoe.ui');
window.location.replace('/classic');
} else {
localStorage.setItem('gwoe.ui', 'v2');
}
})();
/* ── Mobile Sidebar Toggle ───────────────────────────────────────── */
(function () {
document.addEventListener('DOMContentLoaded', function () {
const toggle = document.getElementById('v2-menu-toggle');
const sidebar = document.getElementById('v2-sidebar');
const overlay = document.getElementById('v2-overlay');
if (!toggle || !sidebar || !overlay) return;
toggle.addEventListener('click', function () {
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
});
overlay.addEventListener('click', function () {
sidebar.classList.remove('open');
overlay.classList.remove('open');
});
});
})();
</script>
{% block body_scripts %}{% endblock %}
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
{% include "v2/components/auth_modal.html" %}
<script>
/* ── v2 Auth-State — Topbar-Control ─────────────────────────────────── */
(function () {
var TOPBAR_CONTROL_ID = 'v2-auth-control';
var BTN_BASE = [
'background:none',
'border:none',
'cursor:pointer',
'font-family:var(--font-sans)',
'font-size:11px',
'color:var(--ecg-dark)',
'letter-spacing:0.06em',
'text-transform:uppercase',
'opacity:0.75',
'padding:0',
'display:inline-flex',
'align-items:center',
'gap:4px'
].join(';');
function renderUnauthenticated(container) {
container.innerHTML =
'<button style="' + BTN_BASE + '" onclick="v2AuthModalOpen()" aria-label="Anmelden">' +
'{{ icon("key", 13) | replace("\"", "\'") }} Anmelden' +
'</button>';
}
function renderAuthenticated(container, user) {
var name = user.preferred_username || user.name || user.sub || 'Konto';
container.innerHTML =
'<span style="' + BTN_BASE + ';cursor:default;gap:4px;">' +
'{{ icon("user", 13) | replace("\"", "\'") }} ' +
'<span style="max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + name + '">' + name + '</span>' +
'</span>' +
'&nbsp;' +
'<button style="' + BTN_BASE + '" onclick="v2AuthLogout()" aria-label="Abmelden">' +
'{{ icon("sign-out", 13) | replace("\"", "\'") }} Abmelden' +
'</button>';
}
async function initV2Auth() {
var container = document.getElementById(TOPBAR_CONTROL_ID);
if (!container) return;
try {
var resp = await fetch('/api/auth/me');
var data = await resp.json();
if (data && data.authenticated) {
renderAuthenticated(container, data);
} else {
renderUnauthenticated(container);
}
} catch (_) {
renderUnauthenticated(container);
}
}
async function logout() {
// Server-seitig Cookies löschen (HttpOnly → client-side nicht möglich)
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (e) { /* ignore, reload trotzdem */ }
location.reload();
}
window.v2AuthLogout = logout;
document.addEventListener('DOMContentLoaded', initV2Auth);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,133 @@
{#
appshell.html — AppShell-Macro für GWÖ-Antragsprüfer v2
Rendert die zweispaltige Shell mit Sidebar (230 px Desktop) und Drawer
(< 900 px). Wird per {% include %} aus base.html eingebettet;
der eigentliche Seiteninhalt kommt über den Jinja2-Block "main".
Navigation-Gruppen: LESEN / PRÜFEN / DATEN / ADMIN (laut Brief §04).
Aktiver Eintrag: v2_active_nav wird vom Screen-Template gesetzt.
#}
{# Overlay für mobilen Drawer #}
<div id="v2-overlay" class="v2-overlay"></div>
<div class="v2-shell">
{# ── Sidebar ──────────────────────────────────────────────────── #}
<aside id="v2-sidebar" class="v2-sidebar">
<div class="v2-brand">
GWÖ-<span class="grn">ANTRAGS</span><span class="blu">PRÜFER</span>
</div>
<div class="v2-brand-sub">Matrix 2.0 · Gemeinden</div>
<nav aria-label="Hauptnavigation">
<div class="v2-nav-group">
<div class="v2-nav-label">— Lesen</div>
<a href="/"
class="v2-nav-item {% if v2_active_nav == 'durchsuchen' %}active{% endif %}"
aria-current="{% if v2_active_nav == 'durchsuchen' %}page{% endif %}">
Durchsuchen
{% if assessment_count is defined and assessment_count %}
<span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %}
</a>
<a href="/v2/merkliste"
class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">
Merkliste
</a>
<a href="/v2/tags"
class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">
Tags
</a>
<a href="/v2/cluster"
class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">
Cluster
</a>
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Prüfen</div>
<a href="/v2/neu"
class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">
Neuer Antrag
</a>
<a href="/v2/batch"
class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">
Batch-Analyse
</a>
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen"
class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">
Auswertungen
</a>
<a href="/api/auswertungen/export.csv"
class="v2-nav-item {% if v2_active_nav == 'export' %}active{% endif %}">
Export · API
</a>
<a href="/api/feed.xml"
class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">
Atom-Feed
</a>
</div>
{% if is_admin is defined and is_admin %}
<div class="v2-nav-group">
<div class="v2-nav-label">— Administration</div>
<a href="/v2/admin/freischaltungen"
class="v2-nav-item {% if v2_active_nav == 'freischaltungen' %}active{% endif %}">
Freischaltungen
</a>
<a href="/v2/admin/queue"
class="v2-nav-item {% if v2_active_nav == 'queue' %}active{% endif %}">
Queue
</a>
<a href="/v2/admin/abos"
class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">
Abos
</a>
</div>
{% endif %}
</nav>
</aside>
{# ── Topbar ───────────────────────────────────────────────────── #}
<header class="v2-topbar">
<button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">
&#9776;
</button>
<span class="v2-topbar-spacer"></span>
<a href="/classic" class="v2-back-link">Klassische Ansicht</a>
<a href="/methodik">Methodik</a>
<a href="/quellen">Quellen</a>
<button id="v2-theme-toggle"
aria-label="Farbschema wechseln (Hell / Dunkel / Auto)"
onclick="window.__v2CycleTheme && window.__v2CycleTheme()"
style="background:none;border:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);letter-spacing:0.06em;text-transform:uppercase;opacity:0.75;padding:0;">
Auto
</button>
</header>
{# ── Main Content ─────────────────────────────────────────────── #}
<main class="v2-main" id="v2-main">
{% block main %}{% endblock %}
</main>
{# ── Footer ───────────────────────────────────────────────────── #}
<footer class="v2-footer">
<span>GWÖ-Antragsprüfer · Matrix 2.0 · CC BY 4.0</span>
<a href="/methodik">Methodik</a>
<a href="/quellen">Quellen</a>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer">Quellcode</a>
<span class="v2-topbar-spacer"></span>
<a href="/classic" style="color:var(--ecg-green);opacity:1;">Zurück zur klassischen Ansicht</a>
</footer>
</div>{# .v2-shell #}

View File

@ -0,0 +1,181 @@
{#
auth_modal.html — Login- und Registrierungs-Modal für v2
Einbinden via: {% include "v2/components/auth_modal.html" %}
Öffnen via: document.getElementById('v2-auth-modal').style.display = 'flex'
#}
{% from "v2/components/icon.html" import icon %}
<!-- ── v2 Auth Modal ──────────────────────────────────────────────────── -->
<div id="v2-auth-modal"
role="dialog" aria-modal="true" aria-labelledby="v2-auth-modal-title"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:400;justify-content:center;align-items:center;"
onclick="if(event.target===this)v2AuthModalClose()">
<div style="background:var(--paper);border-radius:8px;padding:var(--space-6);max-width:440px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:var(--font-sans);">
<!-- Header -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-5);">
<h3 id="v2-auth-modal-title"
style="margin:0;font-family:var(--font-sans);font-size:1.1rem;font-weight:700;color:var(--ecg-blue);letter-spacing:0.03em;">
Anmelden
</h3>
<button onclick="v2AuthModalClose()"
aria-label="Modal schließen"
style="background:none;border:none;cursor:pointer;color:var(--ecg-dark);opacity:0.6;font-size:1.2rem;line-height:1;padding:0;">✕</button>
</div>
<!-- Tabs -->
<div style="display:flex;border-bottom:2px solid var(--hairline);margin-bottom:var(--space-5);">
<button id="v2-auth-tab-login"
onclick="v2AuthSwitchTab('login')"
style="flex:1;padding:var(--space-2) var(--space-3);border:none;background:none;cursor:pointer;font-family:var(--font-sans);font-size:0.9rem;font-weight:700;color:var(--ecg-blue);border-bottom:2px solid var(--ecg-blue);margin-bottom:-2px;">
Anmelden
</button>
<button id="v2-auth-tab-register"
onclick="v2AuthSwitchTab('register')"
style="flex:1;padding:var(--space-2) var(--space-3);border:none;background:none;cursor:pointer;font-family:var(--font-sans);font-size:0.9rem;font-weight:400;color:var(--ecg-light);margin-bottom:-2px;">
Registrieren
</button>
</div>
<!-- Login Form -->
<form id="v2-auth-login-form" onsubmit="v2AuthSubmitLogin(event)"
style="display:flex;flex-direction:column;gap:var(--space-3);">
<input name="username" placeholder="Benutzername" required autocomplete="username"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<input name="password" type="password" placeholder="Passwort" required autocomplete="current-password"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<button type="submit"
style="padding:var(--space-3);background:var(--ecg-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
Anmelden
</button>
</form>
<!-- Register Form -->
<form id="v2-auth-register-form" onsubmit="v2AuthSubmitRegister(event)"
style="display:none;flex-direction:column;gap:var(--space-3);">
<input name="firstName" placeholder="Vorname" required autocomplete="given-name"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<input name="lastName" placeholder="Nachname" required autocomplete="family-name"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<input name="email" type="email" placeholder="E-Mail-Adresse" required autocomplete="email"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<input name="username" placeholder="Benutzername (frei wählbar)" required autocomplete="username"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
<button type="submit"
style="padding:var(--space-3);background:var(--ecg-green);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
Freischaltung beantragen
</button>
<p style="margin:0;font-size:0.78rem;color:var(--ecg-light);">
Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen.
</p>
</form>
<!-- Status-Anzeige (Fehler / Erfolg) -->
<div id="v2-auth-status" style="margin-top:var(--space-3);font-size:0.875rem;min-height:1.2em;"></div>
</div>
</div>
<script>
/* ── v2 Auth Modal ───────────────────────────────────────────────────── */
(function () {
function open() {
const modal = document.getElementById('v2-auth-modal');
if (modal) modal.style.display = 'flex';
// Status leeren bei jedem Öffnen
const status = document.getElementById('v2-auth-status');
if (status) status.innerHTML = '';
}
function close() {
const modal = document.getElementById('v2-auth-modal');
if (modal) modal.style.display = 'none';
}
function switchTab(tab) {
const loginForm = document.getElementById('v2-auth-login-form');
const registerForm = document.getElementById('v2-auth-register-form');
const tabLogin = document.getElementById('v2-auth-tab-login');
const tabRegister = document.getElementById('v2-auth-tab-register');
const title = document.getElementById('v2-auth-modal-title');
const status = document.getElementById('v2-auth-status');
if (!loginForm || !registerForm) return;
const isLogin = tab === 'login';
loginForm.style.display = isLogin ? 'flex' : 'none';
registerForm.style.display = isLogin ? 'none' : 'flex';
// Tab-Stile
const activeStyle = 'color:var(--ecg-blue);font-weight:700;border-bottom:2px solid var(--ecg-blue);margin-bottom:-2px;';
const inactiveStyle = 'color:var(--ecg-light);font-weight:400;margin-bottom:-2px;';
tabLogin.style.cssText += isLogin ? activeStyle : inactiveStyle;
tabRegister.style.cssText += isLogin ? inactiveStyle : activeStyle;
if (title) title.textContent = isLogin ? 'Anmelden' : 'Registrieren';
if (status) status.innerHTML = '';
}
async function submitLogin(e) {
e.preventDefault();
const form = e.target;
const status = document.getElementById('v2-auth-status');
status.innerHTML = '<span style="color:var(--ecg-blue);">Anmeldung läuft\u2026</span>';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(form)).toString()
});
const data = await resp.json();
if (resp.ok && data.authenticated) {
status.innerHTML = '<span style="color:var(--ecg-green);">Angemeldet.</span>';
close();
location.reload();
} else {
status.innerHTML = '<span style="color:#c33;">' + (data.detail || 'Anmeldung fehlgeschlagen') + '</span>';
}
} catch (err) {
status.innerHTML = '<span style="color:#c33;">' + err.message + '</span>';
}
}
async function submitRegister(e) {
e.preventDefault();
const form = e.target;
const status = document.getElementById('v2-auth-status');
status.innerHTML = '<span style="color:var(--ecg-blue);">Wird registriert\u2026</span>';
try {
const resp = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(form)).toString()
});
const data = await resp.json();
if (resp.ok) {
status.innerHTML = '<span style="color:var(--ecg-green);">Freischaltung beantragt. Sie erhalten eine E-Mail, sobald Ihr Konto aktiviert ist.</span>';
form.reset();
} else {
status.innerHTML = '<span style="color:#c33;">' + (data.detail || 'Registrierung fehlgeschlagen') + '</span>';
}
} catch (err) {
status.innerHTML = '<span style="color:#c33;">' + err.message + '</span>';
}
}
// ESC schließt Modal
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
// Globale API für Topbar-Button und externe Aufrufer
window.v2AuthModalOpen = open;
window.v2AuthModalClose = close;
window.v2AuthSwitchTab = switchTab;
window.v2AuthSubmitLogin = submitLogin;
window.v2AuthSubmitRegister = submitRegister;
})();
</script>

View File

@ -0,0 +1,32 @@
{#
chip.html — Filter/Tag-Chip
Props:
label : Anzeigetext
active : bool — ob der Chip selektiert ist (default False)
variant : "default" | "green" | "dark"
href : Optionaler Link. Ohne href wird ein <button> gerendert.
attrs : Optionaler Dict mit zusätzlichen HTML-Attributen (z.B. data-*)
Verwendung:
{% from "v2/components/chip.html" import chip %}
{{ chip("Bundesweit", active=True) }}
{{ chip("BW", href="/v2?bl=BW") }}
{{ chip("Score 810", active=True, variant="green") }}
#}
{% macro chip(label, active=False, variant="default", href="", attrs={}) %}
{% set classes = "v2-chip" %}
{% if variant != "default" %}{% set classes = classes ~ " " ~ variant %}{% endif %}
{% if active %}{% set classes = classes ~ " active" %}{% endif %}
{% if href %}
<a class="{{ classes }}" href="{{ href }}"
{% for k, v in attrs.items() %}{{ k }}="{{ v }}" {% endfor %}
>{{ label }}</a>
{% else %}
<button class="{{ classes }}" type="button"
{% for k, v in attrs.items() %}{{ k }}="{{ v }}" {% endfor %}
>{{ label }}</button>
{% endif %}
{% endmacro %}

View File

@ -0,0 +1 @@
{% macro icon(name, size=16, cls="") %}<span class="v2-icon {{ cls }}" style="display:inline-flex;width:{{ size }}px;height:{{ size }}px;flex-shrink:0;vertical-align:middle;" aria-hidden="true">{% include "v2/icons/phosphor/" ~ name ~ ".svg" %}</span>{% endmacro %}

View File

@ -0,0 +1,30 @@
{#
kasten.html — ECOnGOOD-Kasten (4 Varianten, Manual Seite 13)
Props:
variant : "solid-green" | "solid-blue" | "outline-green" | "outline-blue"
title : Optionaler Kasten-Titel (h4, Avenir Black, color:inherit)
body : Fließtext oder HTML-String
caller : Optionaler Jinja2-Caller-Block für komplexen Body-Inhalt
Verwendung:
{% from "v2/components/kasten.html" import kasten %}
{{ kasten("solid-green", "Hinweis", "Body-Text.") }}
Mit Caller-Block:
{% call kasten("outline-blue", "Titel") %}
<p>Komplexer Inhalt mit <strong>Markup</strong>.</p>
{% endcall %}
#}
{% macro kasten(variant="outline-green", title="", body="") %}
<div class="v2-kasten {{ variant }}">
{% if title %}
<h4>{{ title }}</h4>
{% endif %}
{% if body %}
<p>{{ body }}</p>
{% endif %}
{{ caller() if caller is defined else "" }}
</div>
{% endmacro %}

View File

@ -0,0 +1,69 @@
{#
matrix_mini.html — GWÖ-Matrix 5×5 Minidarstellung
Props:
matrix : Dict mit Schlüsseln A1E5, je Wert ein Dict:
{ "rating": int (-2 bis 2), "symbol": str ("++"|"+"|"○"|""|"") }
Fehlende Felder werden als neutral (○) dargestellt.
Farbstufen-Klassen (CSS in v2.css):
m-pp : rating 2 (++ stark fördernd) — ECG-Grün auf Weiß
m-p : rating 1 (+ fördernd) — Grün-Tint
m-0 : rating 0 (○ neutral) — Weiß
m-n : rating -1 ( widersprechend) — Rot-Tint
m-nn : rating -2 ( stark widerspr.)— Dunkelrot
Verwendung:
{% from "v2/components/matrix_mini.html" import matrix_mini %}
{{ matrix_mini(assessment.matrix) }}
#}
{% macro matrix_mini(matrix) %}
{% set rows = ["A", "B", "C", "D", "E"] %}
{% set cols = ["1", "2", "3", "4", "5"] %}
{% set row_labels = {"A": "A · Liefer.", "B": "B · Finanzen", "C": "C · Verwalt.", "D": "D · Bürger", "E": "E · Gesell."} %}
{% set col_labels = {"1": "Würde", "2": "Solid.", "3": "Ökol.", "4": "Soz.", "5": "Trans."} %}
{% macro rating_class(r) %}
{% if r == 2 %}m-pp
{% elif r == 1 %}m-p
{% elif r == -1 %}m-n
{% elif r == -2 %}m-nn
{% else %}m-0{% endif %}
{% endmacro %}
<div class="v2-matrix-mini" role="table" aria-label="GWÖ-Matrix 5×5">
{# Header-Zeile #}
<div class="hdr" role="columnheader"></div>
{% for c in cols %}
<div class="hdr" role="columnheader">{{ col_labels[c] }}</div>
{% endfor %}
{# Daten-Zeilen #}
{% for r in rows %}
<div class="rhdr" role="rowheader">{{ row_labels[r] }}</div>
{% for c in cols %}
{% set key = r ~ c %}
{% set cell = matrix[key] if matrix is defined and key in matrix else {} %}
{% set rating = cell.rating | default(0) | int %}
{% set symbol = cell.symbol | default("○") %}
<div class="{{ rating_class(rating) | trim }}"
role="cell"
title="{{ key }}: {{ symbol }}"
aria-label="{{ key }}, {{ symbol }}, {% if rating == 2 %}stark fördernd{% elif rating == 1 %}fördernd{% elif rating == 0 %}neutral{% elif rating == -1 %}widersprechend{% else %}stark widersprechend{% endif %}"
onclick="if(typeof v2ShowMatrixFieldInfo==='function')v2ShowMatrixFieldInfo('{{ key }}')"
style="cursor:pointer;">
{{ symbol }}
</div>
{% endfor %}
{% endfor %}
</div>
<div class="v2-matrix-legend" aria-hidden="true">
<span>++ stark fördernd</span>
<span>+ fördernd</span>
<span>○ neutral</span>
<span> widersprechend</span>
<span> stark widerspr.</span>
</div>
{% endmacro %}

View File

@ -0,0 +1,34 @@
{#
quote_card.html — Zitat-Karte mit Verifikations-Siegel
Props:
text : str — Zitattext (wird kursiv gesetzt)
source : str — Quellenangabe (z.B. "Wahlprogramm 2026 · S. 84")
verified : bool — Zeigt ✓ verifiziert-Siegel (default True)
contra : bool — Widerspruch-Variante (rote Border, default False)
pdf_href : str — Optionaler Link zu PDF-Viewer mit Seiten-Anker
Farbcodierung:
contra=False: border-left var(--ecg-blue), Siegel Grün
contra=True: border-left var(--redline-contra), Siegel Rot
Verwendung:
{% from "v2/components/quote_card.html" import quote_card %}
{{ quote_card("Wir verpflichten...", "Wahlprogramm 2026 · S. 84") }}
{{ quote_card("Konkurrenz abzulehnen...", "Grundsatzprogramm · S. 42", contra=True) }}
#}
{% macro quote_card(text, source="", verified=True, contra=False, pdf_href="") %}
<div class="v2-quote {% if contra %}contra{% endif %}">
<div class="q-body">„{{ text }}"</div>
<cite>
{% if verified %}
<span class="verified">{% if contra %}&#10007;{% else %}&#10003;{% endif %} {% if contra %}Programm-Widerspruch{% else %}verifiziert{% endif %}</span>
{% endif %}
{{ source }}
{% if pdf_href %}
· <a href="{{ pdf_href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
{% endif %}
</cite>
</div>
{% endmacro %}

View File

@ -0,0 +1,44 @@
{#
redline.html — Diff-Renderer für Redline-Vorschläge
Rendert die vom Backend gelieferten {del, ins}-Segmente als Mono-Block.
Keine neue Diff-Logik im Frontend — der LLM-Output muss bereits
formatierte Segmente enthalten (via v5-Prompt-Format).
Props:
original : str — Original-Textauszug aus dem Antrag (für Kontext)
vorschlag : str — Verbesserter Text; darf **fett** (ins) und
~~durchgestrichen~~ (del) als Markdown-Marker enthalten,
die zu <span class="ins"> / <span class="del"> gerendert werden.
Backend kann alternativ bereits HTML liefern.
segments : list[dict] optional — vorberechnete Segmente:
[{"type": "del"|"ins"|"ctx", "text": "..."}]
Wenn gesetzt, wird original/vorschlag ignoriert.
Verwendung:
{% from "v2/components/redline.html" import redline %}
{{ redline("§ 3 Abs. 2 auf Antrag", "§ 3 Abs. 2 **verpflichtend**") }}
{{ redline(segments=[{"type":"ctx","text":"§ 3 Abs. 2 "},{"type":"del","text":"auf Antrag"},{"type":"ins","text":"verpflichtend"}]) }}
#}
{% macro redline(original="", vorschlag="", segments=none) %}
<div class="v2-redline" role="region" aria-label="Redline-Vorschlag">
{% if segments %}
{# Segment-basiertes Rendering (bevorzugt) #}
{% for seg in segments %}
{% if seg.type == "del" %}<span class="del">{{ seg.text }}</span>
{% elif seg.type == "ins" %}<span class="ins">{{ seg.text }}</span>
{% else %}{{ seg.text }}
{% endif %}
{% endfor %}
{% else %}
{# Markdown-Marker-Rendering: **text** → ins, ~~text~~ → del #}
{# Jinja2 hat kein eingebautes Regex-Replace, daher nutzen wir einen #}
{# Inline-Namespace-Hack + einfaches Zeichen-für-Zeichen-Parsing. #}
{# Für komplexere Fälle sollten Segmente vom Backend geliefert werden. #}
{{ vorschlag | replace("**", "§INS§") | replace("~~", "§DEL§") }}
{# Hinweis: Für Phase 2 sollte der Screen das Backend auffordern, #}
{# segments direkt zu liefern. Dieser Fallback ist ein Stub. #}
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,66 @@
{#
result_row.html — Ergebnislisten-Zeile
Props (über assessment-Dict):
assessment.score : float (010)
assessment.title : str — Antragstitel (Avenir Black, 14.5 px)
assessment.drucksache : str — Drucksache-ID
assessment.bundesland : str — Bundesland-Kürzel
assessment.parteien : list[str] — Liste der einreichenden Fraktionen
assessment.tags : list[str] — Themen-Tags (optional)
assessment.datum : str — Datum (YYYY-MM-DD oder lesbar)
assessment.href : str — Link zur Detailseite
Score-Band-Klassen:
s-high : Score >= 8 (Grün-Tint)
s-mid : Score 57 (Grau)
s-low : Score < 5 (Rot-Tint)
Verwendung:
{% from "v2/components/result_row.html" import result_row %}
{% for a in assessments %}
{{ result_row(a) }}
{% endfor %}
#}
{% macro result_row(assessment) %}
{% set score = assessment.score | float %}
{% if score >= 8 %}
{% set band = "s-high" %}
{% elif score >= 5 %}
{% set band = "s-mid" %}
{% else %}
{% set band = "s-low" %}
{% endif %}
<a class="v2-result-row"
href="{{ assessment.href | default('/v2/antrag/' ~ assessment.drucksache) }}"
aria-label="{{ assessment.title }} — Score {{ '%.1f'|format(score) }}">
<div class="v2-score-cell {{ band }}" aria-label="Score {{ '%.1f'|format(score) }}">
{{ "%.1f" | format(score) }}
<small>Score</small>
</div>
<div>
<div class="v2-r-title">{{ assessment.title }}</div>
<div class="v2-r-sub">
{% for p in (assessment.parteien | default([])) %}
<span class="v2-party-chip">{{ p }}</span>
{% endfor %}
· Drucksache {{ assessment.drucksache }}
{% if assessment.tags is defined and assessment.tags %}
· {{ assessment.tags | join(", ") }}
{% endif %}
</div>
</div>
<div class="v2-r-state">
{{ assessment.bundesland | default("") }}
{% if assessment.parlament is defined %} · {{ assessment.parlament }}{% endif %}
</div>
<div class="v2-r-date">{{ assessment.datum | default("") }}</div>
</a>
{% endmacro %}

View File

@ -0,0 +1,32 @@
{#
score_hero.html — Großer Score-Block für die Detailseite
Props:
score : float (010) — der GWÖ-Score
verdict_title : str — kurzes Urteil (z.B. "Vorbildlich"), UPPERCASE
verdict_body : str — ein bis zwei Sätze Urteilsbeschreibung
Verhalten:
- score >= 8: var(--ecg-green) als Akzentfarbe
- score < 5: var(--redline-contra) als Akzentfarbe (CSS-Klasse "low")
- 57: Neutral (var(--ecg-dark))
Verwendung:
{% from "v2/components/score_hero.html" import score_hero %}
{{ score_hero(9.1, "Vorbildlich", "Starker Beitrag zur ökologischen Nachhaltigkeit.") }}
#}
{% macro score_hero(score, verdict_title="", verdict_body="") %}
{% set s = score | float %}
{% if s < 5 %}{% set modifier = "low" %}{% else %}{% set modifier = "" %}{% endif %}
<div class="v2-score-hero {{ modifier }}" role="region" aria-label="GWÖ-Score {{ '%.1f'|format(s) }} von 10">
<div class="big-num" aria-hidden="true">
{{ "%.1f" | format(s) }}<span class="slash">/10</span>
</div>
<div class="verdict">
{% if verdict_title %}<b>{{ verdict_title }}</b>{% endif %}
{{ verdict_body }}
</div>
</div>
{% endmacro %}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,16.37a86.4,86.4,0,0,1,16,3V212.67a86.4,86.4,0,0,1-16,3Zm32,9.26a87.81,87.81,0,0,1,16,10.54V195.83a87.81,87.81,0,0,1-16,10.54ZM40,128a88.11,88.11,0,0,1,80-87.63V215.63A88.11,88.11,0,0,1,40,128Zm160,50.54V77.46a87.82,87.82,0,0,1,0,101.08Z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M48,180c0,11,7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,85.78,206.4,30.06,30.06,0,0,1,64,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,64,160C55.18,160,48,169,48,180Zm79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84-1.25-.81-1.23-1-1.12-1.9a4.57,4.57,0,0,1,2-3.67c4.6-3.12,15.34-1.73,19.82-.56A8,8,0,0,0,142,145.86c-2.12-.55-21-5.22-32.84,2.76a20.58,20.58,0,0,0-9,14.95c-2,15.88,13.65,20.41,23,23.11,12.06,3.49,13.12,4.92,12.78,7.59-.31,2.41-1.26,3.34-2.14,3.93-4.6,3.06-15.17,1.56-19.55.36A8,8,0,0,0,109.94,214a61.34,61.34,0,0,0,15.19,2c5.82,0,12.3-1,17.49-4.46a20.82,20.82,0,0,0,9.19-15.23C154,179,137.49,174.17,127.6,171.31Zm83.09-26.84a8,8,0,0,0-10.23,4.84L188,184.21l-12.47-34.9a8,8,0,0,0-15.07,5.38l20,56a8,8,0,0,0,15.07,0l20-56A8,8,0,0,0,210.69,144.47ZM216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-27.31-8L160,51.31V80Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-40-64a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,152Z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z"/></svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></svg>

After

Width:  |  Height:  |  Size: 640 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,112a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Zm77.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88.11,88.11,0,1,1,11.31-11.31l50.07,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/></svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M106.91,149.09A71.53,71.53,0,0,1,128,200a8,8,0,0,1-16,0,56,56,0,0,0-56-56,8,8,0,0,1,0-16A71.53,71.53,0,0,1,106.91,149.09ZM56,80a8,8,0,0,0,0,16A104,104,0,0,1,160,200a8,8,0,0,0,16,0A120,120,0,0,0,56,80Zm118.79,1.21A166.9,166.9,0,0,0,56,32a8,8,0,0,0,0,16A151,151,0,0,1,163.48,92.52,151,151,0,0,1,208,200a8,8,0,0,0,16,0A166.9,166.9,0,0,0,174.79,81.21ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M144,157.68a68,68,0,1,0-71.9,0c-20.65,6.76-39.23,19.39-54.17,37.17a8,8,0,0,0,12.25,10.3C50.25,181.19,77.91,168,108,168s57.75,13.19,77.87,37.15a8,8,0,0,0,12.25-10.3C183.18,177.07,164.6,164.44,144,157.68ZM56,100a52,52,0,1,1,52,52A52.06,52.06,0,0,1,56,100Zm197.66,33.66-32,32a8,8,0,0,1-11.32,0l-16-16a8,8,0,0,1,11.32-11.32L216,148.69l26.34-26.35a8,8,0,0,1,11.32,11.32Z"/></svg>

After

Width:  |  Height:  |  Size: 465 B

Some files were not shown because too many files have changed in this diff Show More