Compare commits
10 Commits
4fbdc1522a
...
c38bca615d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38bca615d | ||
|
|
20b33c7560 | ||
|
|
b4fe3488e0 | ||
|
|
2dec009b5c | ||
|
|
2902164eff | ||
|
|
565849bd84 | ||
|
|
58731af83c | ||
|
|
8f0f6d6e32 | ||
|
|
2c0e94d29d | ||
|
|
ad1db2a924 |
1
.gitignore
vendored
@ -18,3 +18,4 @@ reports/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
site/
|
||||
.coverage
|
||||
|
||||
100
CHANGELOG.md
Normal 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
@ -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
|
||||
@ -18,8 +18,12 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code only (data/reports are mounted as volumes)
|
||||
COPY app/ ./app/
|
||||
|
||||
# Create directories for volumes
|
||||
RUN mkdir -p /app/data /app/reports
|
||||
# Create non-root user and directories (#119 Security)
|
||||
RUN adduser --disabled-password --gecos '' --uid 1000 appuser \
|
||||
&& mkdir -p /app/data /app/reports \
|
||||
&& chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Environment
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
233
README.md
@ -4,145 +4,144 @@
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🎯 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?
|
||||
- **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
|
||||
Der GWÖ-Antragsprüfer analysiert Anträge aus deutschen Landesparlamenten und dem Bundestag und bewertet sie nach den Kriterien der **Gemeinwohl-Ökonomie (GWÖ)**:
|
||||
|
||||
## ✨ Features
|
||||
- **GWÖ-Score (0–10)** — 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)
|
||||
- 📊 **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
|
||||
## Aktive Datenquellen (Stand Release 1.0)
|
||||
|
||||
## 🚀 Schnellstart
|
||||
**16 Bundesländer + Bundestag** — alle aktiven Adapter:
|
||||
|
||||
| BL | Wahlperiode | Quelle |
|
||||
|---|---|---|
|
||||
| BUND | 21 (2025–2029) | bundestag.de DIP |
|
||||
| BW | 17 (2021–2026) | PARLIS |
|
||||
| BY | 19 (2023–2028) | Bayern Landtag |
|
||||
| BE | 19 (2023–2026) | Berlin AGH |
|
||||
| BB | 8 (2024–2029) | StarWeb |
|
||||
| HB | 21 (2023–2027) | ParlDok |
|
||||
| HH | 23 (2025–2029) | ParlDok |
|
||||
| HE | 21 (2024–2029) | Hessen Landtag |
|
||||
| MV | 8 (2021–2026) | ParlDok |
|
||||
| NI | — | NILAS (login-protected, deferred) |
|
||||
| NRW | 18 (2022–2027) | OPAL |
|
||||
| RP | 18 (2021–2026) | StarWeb |
|
||||
| LSA | 8 (2021–2026) | StarWeb |
|
||||
| SL | 17 (2022–2027) | Umbraco |
|
||||
| SN | 8 (2024–2029) | XML-Export |
|
||||
| SH | 20 (2022–2027) | Schleswig-Holstein |
|
||||
| TH | 8 (2024–2029) | 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
|
||||
|
||||
- Python 3.12+
|
||||
- Docker & Docker Compose
|
||||
- DashScope API-Key (Qwen LLM)
|
||||
- Docker + Docker Compose
|
||||
- Python 3.12 (für lokale Tests)
|
||||
- DashScope API-Key (Qwen)
|
||||
- Keycloak (optional, für Login)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/tobiasroedel/gwoe-antragspruefer.git
|
||||
cd gwoe-antragspruefer
|
||||
|
||||
# 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
|
||||
git clone https://repo.toppyr.de/tobias/gwoe-antragspruefer
|
||||
cd gwoe-antragspruefer/webapp
|
||||
cp .env.example .env # API-Keys eintragen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Die App läuft auf http://localhost:8000
|
||||
App auf <http://localhost:8000>.
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
```
|
||||
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
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
# Tests ausführen
|
||||
pytest
|
||||
|
||||
# Linting
|
||||
ruff check app/
|
||||
|
||||
# Type-Checking
|
||||
mypy app/
|
||||
python3 -m pytest tests/ -q # Unit-Suite (574 Tests, < 2 s)
|
||||
python3 -m pytest tests/ -m integration # Live-Adapter-Tests (langsam)
|
||||
./scripts/smoke-test.sh # Gesamt-Funktionsprüfung gegen Live
|
||||
```
|
||||
|
||||
## 📝 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
|
||||
- [Alibaba DashScope](https://dashscope.aliyuncs.com) - Qwen LLM API
|
||||
- [Landtag NRW](https://www.landtag.nrw.de) - OPAL-Dokumentation
|
||||
Zwei getrennte Lizenzen:
|
||||
|
||||
---
|
||||
- **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
@ -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
@ -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"]
|
||||
111
app/adapters/qwen_bewerter.py
Normal 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
|
||||
172
app/analyzer.py
@ -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 json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from typing import Optional
|
||||
|
||||
from .config import settings
|
||||
from .models import Assessment
|
||||
from .bundeslaender import BUNDESLAENDER
|
||||
from .wahlprogramm_check import check_missing_programmes
|
||||
from .ports.llm_bewerter import LlmBewerter, LlmRequest
|
||||
from .wahlprogramme import (
|
||||
find_relevant_quotes,
|
||||
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
|
||||
of SHA-1. Lets us correlate retries without ever leaking the LLM's
|
||||
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:
|
||||
return "len=0"
|
||||
h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||
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
|
||||
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",
|
||||
"themen": ["Bildung", "Soziales"],
|
||||
"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
|
||||
@ -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
|
||||
- Bei "Ablehnen" → Score 0-2/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:
|
||||
@ -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:
|
||||
"""Analyze a parliamentary motion using the LLM."""
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=settings.dashscope_api_key,
|
||||
base_url=settings.dashscope_base_url,
|
||||
)
|
||||
|
||||
async def analyze_antrag(
|
||||
text: str,
|
||||
bundesland: str = "NRW",
|
||||
model: str = "qwen-plus",
|
||||
bewerter: Optional[LlmBewerter] = None,
|
||||
) -> 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()
|
||||
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."""
|
||||
|
||||
# Retry loop for JSON parsing errors
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.3 + (attempt * 0.1), # Slightly increase temp on retry
|
||||
max_tokens=4000,
|
||||
# LLM-Call über den Port. Retry-Loop + Markdown-Stripping wohnen im
|
||||
# Adapter (``QwenBewerter``). Bei exhausted retries wirft er
|
||||
# json.JSONDecodeError — wir lassen das durchpropagieren wie vor der
|
||||
# Migration.
|
||||
request = LlmRequest(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model=model,
|
||||
)
|
||||
data = await bewerter.bewerte(request)
|
||||
|
||||
# Issue #60 Option B — server-side reconstruction of citation quelle/url
|
||||
# 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,
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content.strip()
|
||||
|
||||
# Remove markdown code blocks if present
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[1]
|
||||
if content.endswith("```"):
|
||||
content = content.rsplit("```", 1)[0]
|
||||
if content.startswith("```json"):
|
||||
content = content[7:]
|
||||
content = content.strip()
|
||||
|
||||
try:
|
||||
# Parse JSON
|
||||
data = json.loads(content)
|
||||
# Issue #60 Option B — server-side reconstruction of citation
|
||||
# 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
|
||||
data["fehlendeProgramme"] = missing
|
||||
|
||||
# Pydantic-Validation: harter Check auf Schema-Drift.
|
||||
assessment = Assessment.model_validate(data)
|
||||
|
||||
# Tag-4-Invarianten-Warnings (ADR 0008): Verstöße gegen das Score-Cap
|
||||
# werden geloggt, aber nicht geworfen — das LLM soll lernen, nicht der
|
||||
# Produktivbetrieb brechen.
|
||||
if assessment.verletzt_score_cap():
|
||||
logger.warning(
|
||||
"Assessment %s verletzt Score-Cap: gwoe_score=%.1f bei "
|
||||
"fundamental-kritischem Matrix-Feld (rating≤-4)",
|
||||
assessment.drucksache, assessment.gwoe_score,
|
||||
)
|
||||
|
||||
return assessment
|
||||
|
||||
@ -77,6 +77,7 @@ def _load_assessments(db_path: Optional[Path] = None) -> list[dict]:
|
||||
|
||||
def aggregate_matrix(
|
||||
filter_wp: Optional[str] = None,
|
||||
filter_bl: Optional[str] = None,
|
||||
db_path: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Aggregate assessments to a 2D matrix.
|
||||
@ -89,12 +90,16 @@ def aggregate_matrix(
|
||||
"<bl>": {"<partei>": {"n": int, "avg": float}}
|
||||
},
|
||||
"filter_wp": <filter_wp> | None,
|
||||
"filter_bl": <filter_bl> | None,
|
||||
"total": int,
|
||||
}``
|
||||
|
||||
``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``;
|
||||
nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine
|
||||
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)
|
||||
|
||||
@ -108,6 +113,8 @@ def aggregate_matrix(
|
||||
bl = row["bundesland"]
|
||||
if not bl:
|
||||
continue
|
||||
if filter_bl is not None and bl != filter_bl:
|
||||
continue
|
||||
if filter_wp is not None:
|
||||
wp = wahlperiode_for(row["datum"], bl)
|
||||
if wp != filter_wp:
|
||||
@ -134,10 +141,28 @@ def aggregate_matrix(
|
||||
"parteien": sorted(parteien),
|
||||
"cells": cells,
|
||||
"filter_wp": filter_wp,
|
||||
"filter_bl": filter_bl,
|
||||
"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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
53
app/auth.py
@ -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:
|
||||
"""Baut die Keycloak-Login-URL für den Browser-Redirect."""
|
||||
if not _is_auth_enabled():
|
||||
@ -245,3 +269,32 @@ def keycloak_login_url(redirect_uri: str) -> str:
|
||||
f"&response_type=code"
|
||||
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
@ -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]
|
||||
]
|
||||
@ -20,15 +20,40 @@ class Settings(BaseSettings):
|
||||
llm_model_default: str = "qwen-plus-latest"
|
||||
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_realm: str = ""
|
||||
keycloak_client_id: str = ""
|
||||
keycloak_admin_user: str = ""
|
||||
keycloak_admin_password: str = ""
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
722
app/database.py
@ -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)")
|
||||
cols = {r[1] for r in await cursor.fetchall()}
|
||||
if "drucksache" not in cols:
|
||||
job_cols = {r[1] for r in await cursor.fetchall()}
|
||||
if "drucksache" not in job_cols:
|
||||
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)
|
||||
await db.execute("""
|
||||
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)
|
||||
await db.execute("""
|
||||
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)"
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
@ -134,6 +298,160 @@ async def get_bookmarks(user_id: str) -> list[str]:
|
||||
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) ────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ─── 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(
|
||||
job_id: 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:
|
||||
"""Insert or update an assessment."""
|
||||
"""Insert or update an assessment. Archives old version if exists (#110)."""
|
||||
import json
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
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("""
|
||||
INSERT INTO assessments (
|
||||
drucksache, title, fraktionen, datum, link, bundesland,
|
||||
@ -275,13 +689,24 @@ async def upsert_assessment(data: dict) -> bool:
|
||||
wahlprogramm_scores, verbesserungen, staerken, schwaechen,
|
||||
empfehlung, empfehlung_symbol, verbesserungspotenzial,
|
||||
themen, antrag_zusammenfassung, antrag_kernpunkte,
|
||||
source, model, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
source, model, konfidenz,
|
||||
summary_embedding, embedding_model,
|
||||
share_threads, share_twitter, share_mastodon,
|
||||
fehlende_programme,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drucksache) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
gwoe_score = excluded.gwoe_score,
|
||||
gwoe_begruendung = excluded.gwoe_begruendung,
|
||||
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
|
||||
""", (
|
||||
data.get("drucksache"),
|
||||
@ -306,6 +731,13 @@ async def upsert_assessment(data: dict) -> bool:
|
||||
json.dumps(data.get("antragKernpunkte", [])),
|
||||
data.get("source", "webapp"),
|
||||
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
|
||||
))
|
||||
await db.commit()
|
||||
@ -324,9 +756,10 @@ async def get_assessment(drucksache: str) -> Optional[dict]:
|
||||
if row:
|
||||
d = dict(row)
|
||||
# Parse JSON fields
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte"]:
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte",
|
||||
"fehlende_programme"]:
|
||||
if d.get(field):
|
||||
try:
|
||||
d[field] = json.loads(d[field])
|
||||
@ -370,9 +803,10 @@ async def get_all_assessments(bundesland: str = None) -> list[dict]:
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
# Parse JSON fields
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte"]:
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte",
|
||||
"fehlende_programme"]:
|
||||
if d.get(field):
|
||||
try:
|
||||
d[field] = json.loads(d[field])
|
||||
@ -471,9 +905,10 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte"]:
|
||||
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte",
|
||||
"fehlende_programme"]:
|
||||
if d.get(field):
|
||||
try:
|
||||
d[field] = json.loads(d[field])
|
||||
@ -491,5 +926,258 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50
|
||||
continue
|
||||
|
||||
results.append(d)
|
||||
|
||||
|
||||
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
@ -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))
|
||||
@ -15,9 +15,15 @@ from openai import OpenAI
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Embedding model
|
||||
EMBEDDING_MODEL = "text-embedding-v3"
|
||||
EMBEDDING_DIMENSIONS = 1024
|
||||
# Embedding-Modell (Issue #123 Migration v3 → v4):
|
||||
# WRITE = Modell für neue Embeddings (Reindex, neue Assessments, neue Queries)
|
||||
# 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
|
||||
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("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
|
||||
# nachtragen. Grundsatzprogramme bleiben NULL.
|
||||
for prog_id, info in PROGRAMME.items():
|
||||
@ -347,17 +361,50 @@ def get_client() -> OpenAI:
|
||||
)
|
||||
|
||||
|
||||
def create_embedding(text: str) -> list[float]:
|
||||
"""Create embedding for text using Qwen."""
|
||||
def create_embedding(text: str, model: Optional[str] = None) -> list[float]:
|
||||
"""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()
|
||||
response = client.embeddings.create(
|
||||
model=EMBEDDING_MODEL,
|
||||
model=model or EMBEDDING_MODEL,
|
||||
input=text,
|
||||
dimensions=EMBEDDING_DIMENSIONS,
|
||||
)
|
||||
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]:
|
||||
"""Split text into overlapping chunks by words."""
|
||||
words = text.split()
|
||||
@ -403,8 +450,13 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
|
||||
|
||||
conn = sqlite3.connect(EMBEDDINGS_DB)
|
||||
|
||||
# Remove existing chunks for this program
|
||||
conn.execute("DELETE FROM chunks WHERE programm_id = ?", (programm_id,))
|
||||
# Remove existing chunks for this program — nur für das aktuelle WRITE-
|
||||
# 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
|
||||
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()
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding, bundesland)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding, bundesland, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
programm_id,
|
||||
info["partei"],
|
||||
@ -432,6 +484,7 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
|
||||
chunk_text_content,
|
||||
embedding_blob,
|
||||
info.get("bundesland"), # NULL für Grundsatzprogramme
|
||||
EMBEDDING_MODEL,
|
||||
))
|
||||
total_chunks += 1
|
||||
except Exception as e:
|
||||
@ -445,6 +498,38 @@ def index_programm(programm_id: str, pdf_dir: Path) -> int:
|
||||
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:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
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.
|
||||
"""
|
||||
|
||||
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.row_factory = sqlite3.Row
|
||||
|
||||
# Build query
|
||||
sql = "SELECT * FROM chunks WHERE 1=1"
|
||||
params = []
|
||||
# Build query — filtert auf das aktive READ-Modell, damit v3- und
|
||||
# v4-Embeddings nicht gemischt werden (Cosine wäre Nonsens).
|
||||
sql = "SELECT * FROM chunks WHERE model = ?"
|
||||
params = [EMBEDDING_MODEL_READ]
|
||||
|
||||
if parteien:
|
||||
placeholders = ",".join("?" * len(parteien))
|
||||
|
||||
220
app/mail.py
Normal 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> ·
|
||||
<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}")
|
||||
1303
app/main.py
@ -40,6 +40,37 @@ class MatrixEntry(BaseModel):
|
||||
rating: int = Field(..., ge=-5, le=5) # Neue Skala: -5 bis +5
|
||||
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):
|
||||
text: str
|
||||
@ -99,9 +130,51 @@ class Assessment(BaseModel):
|
||||
themen: list[str] = []
|
||||
antrag_zusammenfassung: Optional[str] = Field(None, alias="antragZusammenfassung")
|
||||
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}
|
||||
|
||||
# ─── 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 ---
|
||||
|
||||
|
||||
332
app/monitoring.py
Normal 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 USD→EUR
|
||||
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
@ -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
|
||||
@ -21,19 +21,43 @@ class Drucksache:
|
||||
datum: str # ISO date
|
||||
link: str # PDF URL
|
||||
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):
|
||||
"""Base adapter for searching parliament documents."""
|
||||
|
||||
|
||||
bundesland: str
|
||||
name: str
|
||||
|
||||
filter_abstimmbar: bool = True # #127: nur abstimmbare Typen zurückgeben
|
||||
|
||||
@abstractmethod
|
||||
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||
"""Search for documents matching query."""
|
||||
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
|
||||
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
|
||||
@ -87,9 +111,16 @@ class NRWAdapter(ParlamentAdapter):
|
||||
return (parts[0], filter_terms, False)
|
||||
|
||||
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()
|
||||
|
||||
if is_exact:
|
||||
# Exact phrase must appear
|
||||
return terms[0] in searchable
|
||||
@ -100,9 +131,18 @@ class NRWAdapter(ParlamentAdapter):
|
||||
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||
"""Search NRW Landtag documents via OPAL portal."""
|
||||
results = []
|
||||
|
||||
|
||||
# Parse query for AND logic
|
||||
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:
|
||||
try:
|
||||
@ -3171,7 +3211,7 @@ class SaarlandAdapter(ParlamentAdapter):
|
||||
return data.get("FilteredResult", []) or []
|
||||
except Exception:
|
||||
logger.exception("SL search request error")
|
||||
return []
|
||||
raise
|
||||
|
||||
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||
"""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
|
||||
relevance-based vom Server — für die UI ist Relevanz zu einer
|
||||
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:
|
||||
# Take großzügig, weil der Antrag-Filter ~30-50% der Hits drosselt
|
||||
take = max(limit * 5, 30)
|
||||
# _post_search re-raises alle Netzwerkfehler (Fix #142)
|
||||
items = await self._post_search(client, query, skip=0, take=take)
|
||||
|
||||
results: list[Drucksache] = []
|
||||
|
||||
11
app/ports/__init__.py
Normal 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
@ -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: ...
|
||||
76
app/queue.py
@ -217,11 +217,14 @@ async def graceful_shutdown(timeout: int = 900):
|
||||
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.
|
||||
|
||||
Reads drucksache + bundesland from the jobs table and re-triggers
|
||||
the full analysis pipeline. This makes the queue crash-safe.
|
||||
Jobs WITH a drucksache column get re-enqueued automatically (if callback provided).
|
||||
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
|
||||
from .config import settings
|
||||
@ -229,35 +232,72 @@ async def re_enqueue_pending():
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
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"
|
||||
)
|
||||
pending = await rows.fetchall()
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
re_enqueued = 0
|
||||
marked_stale = 0
|
||||
for row in pending:
|
||||
job_id = row["id"]
|
||||
bundesland = row["bundesland"] or "NRW"
|
||||
drucksache = row["drucksache"]
|
||||
model = row["model"] or "qwen-plus"
|
||||
|
||||
# Drucksache aus input_preview extrahieren — das Feld enthält
|
||||
# die ersten 500 Zeichen des Antragstexts, aber wir brauchen
|
||||
# die Drucksache. Prüfe ob ein Assessment fehlt das diesen
|
||||
# Job betrifft. Wenn ja: die Drucksache steht nicht im Job.
|
||||
# Markiere als stale und der User kann manuell re-triggern.
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE jobs SET status='stale', updated_at=datetime('now') WHERE id=?",
|
||||
(job_id,),
|
||||
if not drucksache or not analysis_callback:
|
||||
# Legacy-Job ohne Drucksache oder kein Callback → stale markieren
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE jobs SET status='stale', updated_at=datetime('now') WHERE id=?",
|
||||
(job_id,),
|
||||
)
|
||||
await db.commit()
|
||||
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
@ -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
@ -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())
|
||||
44
app/repositories/__init__.py
Normal 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",
|
||||
]
|
||||
138
app/repositories/abonnement_repository.py
Normal 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
|
||||
135
app/repositories/antrag_repository.py
Normal 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
|
||||
64
app/repositories/bewertung_repository.py
Normal 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
2
app/static/d3.v7.min.js
vendored
Normal file
32
app/static/v2/fonts.css
Normal 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 300–900 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%;
|
||||
}
|
||||
BIN
app/static/v2/fonts/nunito-sans-italic-latin.woff2
Normal file
BIN
app/static/v2/fonts/nunito-sans-latin-variable.woff2
Normal file
1
app/static/v2/icons/phosphor/arrow-square-out.svg
Normal 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 |
1
app/static/v2/icons/phosphor/book-open.svg
Normal 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 |
1
app/static/v2/icons/phosphor/bookmark-simple.svg
Normal 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 |
1
app/static/v2/icons/phosphor/chart-bar.svg
Normal 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 |
1
app/static/v2/icons/phosphor/circle-half.svg
Normal 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 |
1
app/static/v2/icons/phosphor/envelope-simple.svg
Normal 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 |
1
app/static/v2/icons/phosphor/file-csv.svg
Normal 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 |
1
app/static/v2/icons/phosphor/file-plus.svg
Normal 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 |
1
app/static/v2/icons/phosphor/graph.svg
Normal 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 |
1
app/static/v2/icons/phosphor/info.svg
Normal 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 |
1
app/static/v2/icons/phosphor/key.svg
Normal 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 |
1
app/static/v2/icons/phosphor/list-checks.svg
Normal 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 |
1
app/static/v2/icons/phosphor/magnifying-glass-plus.svg
Normal 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 |
1
app/static/v2/icons/phosphor/magnifying-glass.svg
Normal 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 |
1
app/static/v2/icons/phosphor/moon.svg
Normal 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 |
1
app/static/v2/icons/phosphor/rss.svg
Normal 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 |
1
app/static/v2/icons/phosphor/sign-out.svg
Normal 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 |
1
app/static/v2/icons/phosphor/stack.svg
Normal 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 |
1
app/static/v2/icons/phosphor/sun.svg
Normal 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 |
1
app/static/v2/icons/phosphor/tag.svg
Normal 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 |
1
app/static/v2/icons/phosphor/user-check.svg
Normal 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 |
1
app/static/v2/icons/phosphor/user.svg
Normal 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
@ -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
157
app/sync_abgeordnetenwatch.py
Normal 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()
|
||||
19
app/templates/_header.html
Normal 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>
|
||||
@ -8,7 +8,7 @@
|
||||
:root {
|
||||
--color-darkgray: #5a5a5a;
|
||||
--color-green: #889e33;
|
||||
--color-blue: #009da5;
|
||||
--color-blue: #007a80;
|
||||
--color-lightgray: #bfbfbf;
|
||||
--color-bg: #f5f5f5;
|
||||
--color-orange: #F7941D;
|
||||
@ -160,12 +160,12 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Auswertungen — Bundesland × Partei × Wahlperiode</h1>
|
||||
<nav>
|
||||
<a href="/">← zurück zur Suche</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
</nav>
|
||||
{% set page_title = 'Auswertungen — Bundesland × Partei × Wahlperiode' %}
|
||||
{% set header_nav = '<a href="/quellen">Quellen</a>' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div style="background:#e8f4f8;border-left:4px solid #007a80;padding:0.6rem 1.2rem;font-size:0.9rem;color:#333;">
|
||||
Diese Seite ist auch direkt in der Haupt-App verfügbar: <a href="/?mode=auswertungen" style="color:#007a80;">zur integrierten Auswertungs-Ansicht →</a>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
@ -198,6 +198,11 @@
|
||||
</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>
|
||||
const wpFilter = document.getElementById('wp-filter');
|
||||
const reloadBtn = document.getElementById('reload');
|
||||
@ -274,12 +279,43 @@
|
||||
body.innerHTML = '<p style="color:#888;">Keine Daten für diese Kombination.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>';
|
||||
for (const row of z.wahlperioden) {
|
||||
html += `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`;
|
||||
// Chart + Tabelle
|
||||
body.innerHTML = '<canvas id="zeitreihe-chart" style="max-height:300px;margin-bottom:1rem;"></canvas>' +
|
||||
'<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) {
|
||||
body.innerHTML = `<p style="color:#d00;">Fehler: ${e}</p>`;
|
||||
}
|
||||
@ -298,6 +334,41 @@
|
||||
});
|
||||
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
600
app/templates/legal.html
Normal 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>
|
||||
@ -8,7 +8,7 @@
|
||||
:root {
|
||||
--color-darkgray: #5a5a5a;
|
||||
--color-green: #889e33;
|
||||
--color-blue: #009da5;
|
||||
--color-blue: #007a80;
|
||||
--color-lightgray: #bfbfbf;
|
||||
--color-bg: #f5f5f5;
|
||||
--color-amber: #ffc107;
|
||||
@ -45,6 +45,7 @@
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.card p + p { margin-top: 0.5rem; }
|
||||
.matrix-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(5, 1fr);
|
||||
@ -52,169 +53,391 @@
|
||||
font-size: 0.8rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.matrix-grid .cell {
|
||||
padding: 0.4rem;
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
.matrix-grid .cell { padding: 0.4rem; text-align: center; 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 summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
details summary { cursor: pointer; color: var(--color-blue); font-weight: 600; padding: 0.3rem 0; }
|
||||
details summary:hover { text-decoration: underline; }
|
||||
.pipeline-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: flex-start; gap: 1rem;
|
||||
margin: 0.75rem 0; padding: 0.75rem;
|
||||
background: #f8f9fa; border-radius: 6px;
|
||||
border-left: 3px solid var(--color-blue);
|
||||
}
|
||||
.step-num {
|
||||
background: var(--color-blue);
|
||||
color: white;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue); color: white;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: bold; font-size: 0.85rem; flex-shrink: 0;
|
||||
}
|
||||
.note {
|
||||
background: #fff3cd;
|
||||
border-left: 3px solid var(--color-amber);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: #fff3cd; border-left: 3px solid var(--color-amber);
|
||||
padding: 0.75rem 1rem; margin: 1rem 0; border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
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 { background: #f0f0f0; }
|
||||
a { color: var(--color-blue); }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ app_name }}</h1>
|
||||
<a href="/">Bewertungen</a>
|
||||
<a href="/auswertungen">Auswertungen</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
<strong>Methodik</strong>
|
||||
</div>
|
||||
{% set page_title = 'Methodik' %}
|
||||
{% set header_nav = '<a href="/">Startseite</a> <a href="/quellen">Quellen</a> <strong>Methodik</strong>' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="container">
|
||||
<h2>Wie funktioniert der GWÖ-Antragsprüfer?</h2>
|
||||
|
||||
<h2>Was ist die Gemeinwohl-Ökonomie?</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
Der GWÖ-Antragsprüfer bewertet Parlamentsanträge automatisch nach der
|
||||
<strong>Gemeinwohl-Ökonomie Matrix 2.0 für Gemeinden</strong>. Jede Bewertung
|
||||
analysiert drei Dimensionen: GWÖ-Treue, Übereinstimmung mit Wahlprogrammen
|
||||
und Übereinstimmung mit Grundsatzprogrammen der Parteien.
|
||||
Die <strong>Gemeinwohl-Ökonomie (GWÖ)</strong> ist ein Wirtschaftsmodell, das den
|
||||
Erfolg wirtschaftlichen Handelns nicht am Gewinn, sondern am <strong>Beitrag zum
|
||||
Gemeinwohl</strong> misst. Entwickelt von Christian Felber (2010), wird die GWÖ
|
||||
von einer internationalen Bewegung mit über 11.000 Unterstützern, 4.500
|
||||
Mitgliedern und 1.000 bilanzierten Organisationen getragen.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Alle Bewertungen werden durch ein KI-Sprachmodell erzeugt und anschließend
|
||||
<strong>automatisch verifiziert</strong> — Zitate werden gegen die Originaltexte
|
||||
der Wahlprogramme geprüft. Wörtliche Treffer werden als <em>verifiziert</em>
|
||||
markiert, paraphrasierte Stellen als <em>nicht wörtlich im Programm</em>
|
||||
gekennzeichnet.
|
||||
|
||||
<h3>Das Bewertungsmodell: die Gemeinwohl-Bilanz</h3>
|
||||
<p>
|
||||
Das Kernstück ist die <strong>Gemeinwohl-Bilanz</strong>: ein standardisiertes
|
||||
Bewertungsverfahren, das Organisationen anhand einer Matrix aus fünf Werten
|
||||
(Menschenwürde, Solidarität, ökologische Nachhaltigkeit, soziale Gerechtigkeit,
|
||||
Transparenz & 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>
|
||||
</div>
|
||||
|
||||
<h2>Die GWÖ-Matrix 2.0</h2>
|
||||
<h2>Was macht der GWÖ-Antragsprüfer?</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>Die Matrix besteht aus <strong>5 Berührungsgruppen</strong> (Zeilen) und
|
||||
<strong>5 Werten</strong> (Spalten) = 25 Themenfelder:</p>
|
||||
<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 (0–10)</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 (0–10) 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="cell"></div>
|
||||
<div class="header-cell">Menschen­würde</div>
|
||||
<div class="header-cell">Solidarität</div>
|
||||
<div class="header-cell">Ökologische Nachhaltig­keit</div>
|
||||
<div class="header-cell">Soziale Gerechtig­keit</div>
|
||||
<div class="header-cell">Transparenz & Demokratie</div>
|
||||
<div class="header-cell">1. Menschen­würde</div>
|
||||
<div class="header-cell">2. Solidarität</div>
|
||||
<div class="header-cell">3. Ökol. Nachh.</div>
|
||||
<div class="header-cell">4. Soz. Gerecht.</div>
|
||||
<div class="header-cell">5. Transparenz</div>
|
||||
|
||||
<div class="row-header">A · Lieferanten</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="row-header">A · Lieferant:innen</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="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="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="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="row-header">D · Bürger:innen</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="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>
|
||||
|
||||
<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>
|
||||
<summary>Mehr zur GWÖ-Matrix</summary>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die Matrix basiert auf dem
|
||||
<a href="https://econgood.org" target="_blank">Arbeitsbuch der Gemeinwohl-Ökonomie</a>.
|
||||
Die Adaption für Gemeinden fokussiert auf kommunale Handlungsfelder:
|
||||
Beschaffung, Haushalt, Verwaltung, Daseinsvorsorge und überregionale Wirkung.
|
||||
</p>
|
||||
<summary>Bewertungsskala</summary>
|
||||
<table style="margin-top:0.5rem;">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h2>Analyse-Pipeline</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>Jede Bewertung durchläuft fünf Schritte:</p>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<strong>Antrags-Text herunterladen</strong><br>
|
||||
Der Volltext wird automatisch aus dem jeweiligen Landtags-Portal geholt
|
||||
({{ adapter_count }} Parlamente angebunden). Der PDF-Text wird via PyMuPDF extrahiert.
|
||||
<strong>Antragstext laden</strong><br>
|
||||
Der PDF-Volltext wird aus dem Landtags-Portal geholt
|
||||
({{ 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 class="pipeline-step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<strong>Relevante Wahlprogramm-Passagen suchen</strong><br>
|
||||
Für <strong>alle Fraktionen der Wahlperiode</strong> werden per Embedding-Suche
|
||||
(Qwen text-embedding-v3) die thematisch relevantesten Passagen aus Wahl- und
|
||||
Grundsatzprogrammen gesucht (Top-5 pro Partei, Cosinus-Ähnlichkeit ≥ 0.45).
|
||||
<strong>Wahlprogramm-Passagen suchen</strong><br>
|
||||
Per semantischer Suche ({{ embedding_model }}, 1024 Dimensionen) werden für
|
||||
<strong>jede Fraktion</strong> die thematisch relevantesten Passagen aus
|
||||
Wahl- und Grundsatzprogrammen gefunden. Aktuell {{ programme_count }} Programme
|
||||
mit {{ chunk_count }} Textabschnitten indexiert.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -222,9 +445,10 @@
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<strong>KI-Bewertung</strong><br>
|
||||
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der GWÖ-Matrix
|
||||
und vergleicht ihn mit den gefundenen Programm-Passagen. Der Prompt enthält
|
||||
strikte Regeln für die Quellenangabe (nur wörtliche Zitate aus den vorgelegten Passagen).
|
||||
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der
|
||||
GWÖ-Matrix und vergleicht ihn mit den gefundenen Programmpassagen.
|
||||
Der Prompt erzwingt die Verwendung wörtlicher Zitate — das Modell darf
|
||||
keine Quellenangaben frei erfinden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -232,180 +456,81 @@
|
||||
<div class="step-num">4</div>
|
||||
<div>
|
||||
<strong>Zitat-Verifikation</strong><br>
|
||||
Jedes vom Modell genannte Zitat wird <strong>server-seitig verifiziert</strong>:
|
||||
Der zitierte Text muss als Substring (oder 5-Wort-Sequenz) in einem der
|
||||
vorgelegten Chunks auffindbar sein. Nicht-verifizierbare Zitate werden
|
||||
verworfen — Quellenangabe und Seitenzahl werden aus dem echten Treffer
|
||||
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>.
|
||||
Jedes Zitat wird <strong>server-seitig verifiziert</strong>: der Text muss
|
||||
als Substring im Original-PDF auffindbar sein. Quellenangabe und Seitenzahl
|
||||
werden aus dem echten Treffer rekonstruiert — die Modell-Ausgabe wird für diese
|
||||
Felder verworfen. Klick auf ein Zitat öffnet das PDF mit markierter Fundstelle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Technische Details zum Sprachmodell</summary>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Modell</td><td>{{ model_name }}</td></tr>
|
||||
<tr><td>Anbieter</td><td>DashScope (Alibaba Cloud)</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>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<summary>Technische Details</summary>
|
||||
<table style="margin-top:0.5rem;">
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Sprachmodell</td><td>{{ model_name }} (DashScope / Alibaba Cloud)</td></tr>
|
||||
<tr><td>Embedding-Modell</td><td>{{ embedding_model }} (1024 Dimensionen)</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>Zitat-Verifikation</td><td>Substring- oder 5-Wort-Anker-Match gegen Original-PDF</td></tr>
|
||||
</table>
|
||||
</details>
|
||||
</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>
|
||||
|
||||
<div class="card">
|
||||
<h3>Zitat-Verifikation (Sub-D)</h3>
|
||||
<p>
|
||||
Ein automatisierter Property-Test prüft für jedes in der Datenbank gespeicherte
|
||||
Zitat, ob der zitierte Text tatsächlich auf der angegebenen Seite des
|
||||
Wahlprogramm-PDFs vorkommt (Substring- oder 5-Wort-Anker-Match). Dieses
|
||||
Verfahren hat im April 2026 drei halluzinierte Zitate aufgedeckt und zur
|
||||
Implementierung der server-seitigen Verifikation geführt.
|
||||
</p>
|
||||
|
||||
<h3>Server-seitige Quellen-Rekonstruktion</h3>
|
||||
<p>
|
||||
Das Sprachmodell darf keine Quellenangaben (Programmname, Seitenzahl) frei
|
||||
erfinden. Nach jeder Analyse wird jedes Zitat gegen die tatsächlich vorgelegten
|
||||
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>
|
||||
<ul>
|
||||
<li><strong>Automatische Zitat-Verifikation</strong> — jedes Zitat wird gegen das
|
||||
Original-PDF geprüft. Nicht-verifizierbare Zitate werden verworfen. Dieses
|
||||
System hat im April 2026 drei LLM-halluzinierte Zitate aufgedeckt.</li>
|
||||
<li><strong>Typ-Filterung</strong> — nur abstimmbare Drucksachen (Anträge,
|
||||
Gesetzentwürfe) werden bewertet. Kleine Anfragen, Berichte und andere
|
||||
nicht-abstimmbare Dokumente werden automatisch erkannt und ausgeschlossen.</li>
|
||||
<li><strong>Automatische Neu-Analyse</strong> — wenn ein Zitat im PDF nicht
|
||||
auffindbar ist, wird der Antrag mit der aktuellen Pipeline neu analysiert.</li>
|
||||
<li><strong>Open Data</strong> — alle Bewertungen sind als JSON und CSV exportierbar
|
||||
(CC BY 4.0). Der Atom-Feed ermöglicht automatische Benachrichtigung bei neuen
|
||||
Bewertungen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Einschränkungen</h2>
|
||||
|
||||
<div class="card">
|
||||
<ul style="margin-left: 1.5rem;">
|
||||
<li><strong>Keine juristische Bewertung</strong> — die GWÖ-Analyse ist eine
|
||||
wertebasierte Einordnung, keine Rechtsprüfung.</li>
|
||||
<ul>
|
||||
<li><strong>Wertebasierte Einordnung, keine Rechtsprüfung</strong> — die Analyse
|
||||
bewertet nach GWÖ-Kriterien, nicht nach juristischer Zulässigkeit.</li>
|
||||
<li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen
|
||||
aufweisen. Die Bewertungen sollten als Orientierung verstanden werden,
|
||||
nicht als objektive Wahrheit.</li>
|
||||
<li><strong>Nur indexierte Programme</strong> — Parteien ohne hinterlegtes
|
||||
Programm können nicht zuverlässig bewertet werden.</li>
|
||||
<li><strong>Keine Analyse des Abstimmungsverhaltens</strong> — bewertet wird
|
||||
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>
|
||||
aufweisen. Bewertungen sind Orientierung, nicht objektive Wahrheit.</li>
|
||||
<li><strong>Programmabhängig</strong> — Fraktionen ohne hinterlegtes Wahlprogramm
|
||||
erhalten keinen Programm-Vergleich.</li>
|
||||
<li><strong>Antragstext, nicht Umsetzung</strong> — bewertet wird was im Antrag
|
||||
steht, nicht ob oder wie es umgesetzt wird.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Datenquellen</h2>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>{{ adapter_count }} Parlamente</strong> sind angebunden:</p>
|
||||
<p><strong>{{ adapter_count }} Parlamente</strong> angebunden:</p>
|
||||
<table>
|
||||
<tr><th>Parlament</th><th>Doku-System</th></tr>
|
||||
<tr><th>Parlament</th><th>System</th></tr>
|
||||
{% for bl in bundeslaender %}
|
||||
<tr>
|
||||
<td>{{ bl.name }} ({{ bl.code }})</td>
|
||||
<td>{{ bl.doku_system }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ bl.name }} ({{ bl.code }})</td><td>{{ bl.doku_system }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p style="margin-top: 1rem;">
|
||||
<a href="/quellen">Vollständige Programm-Liste</a> ·
|
||||
<a href="https://docs.toppyr.de/gwoe-antragspruefer/reference/adapter-capabilities/" target="_blank">Technische Adapter-Vergleichsmatrix</a> ·
|
||||
<a href="https://docs.toppyr.de/gwoe-antragspruefer/adr/" target="_blank">Architektur-Entscheidungen (ADRs)</a>
|
||||
<a href="/quellen">Programme & Quellen</a> ·
|
||||
<a href="/api/auswertungen/export.json" download>Open Data (JSON)</a> ·
|
||||
<a href="/api/feed.xml">Atom-Feed</a> ·
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ app_name }} · <a href="https://econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||
{{ app_name }} · <a href="https://germany.econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
|
||||
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
128
app/templates/monitoring_digest.html
Normal 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"> (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 · Kosten-Schätzung basiert auf Qwen-Plus-Preisen (DashScope, April 2026) ·
|
||||
Nur Metadaten — kein LLM-Call im Scan
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@ -179,10 +179,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/">{{ app_name }}</a></h1>
|
||||
<span>→ Quellen</span>
|
||||
</header>
|
||||
{% set page_title = 'Quellen' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||
|
||||
282
app/templates/v2/base.html
Normal 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">☰</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>' +
|
||||
' ' +
|
||||
'<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>
|
||||
133
app/templates/v2/components/appshell.html
Normal 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">
|
||||
☰
|
||||
</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 #}
|
||||
181
app/templates/v2/components/auth_modal.html
Normal 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>
|
||||
32
app/templates/v2/components/chip.html
Normal 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 8–10", 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 %}
|
||||
1
app/templates/v2/components/icon.html
Normal 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 %}
|
||||
30
app/templates/v2/components/kasten.html
Normal 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 %}
|
||||
69
app/templates/v2/components/matrix_mini.html
Normal file
@ -0,0 +1,69 @@
|
||||
{#
|
||||
matrix_mini.html — GWÖ-Matrix 5×5 Minidarstellung
|
||||
|
||||
Props:
|
||||
matrix : Dict mit Schlüsseln A1–E5, 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 %}
|
||||
34
app/templates/v2/components/quote_card.html
Normal 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 %}✗{% else %}✓{% 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 %}
|
||||
44
app/templates/v2/components/redline.html
Normal 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 %}
|
||||
66
app/templates/v2/components/result_row.html
Normal file
@ -0,0 +1,66 @@
|
||||
{#
|
||||
result_row.html — Ergebnislisten-Zeile
|
||||
|
||||
Props (über assessment-Dict):
|
||||
assessment.score : float (0–10)
|
||||
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 5–7 (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 %}
|
||||
32
app/templates/v2/components/score_hero.html
Normal file
@ -0,0 +1,32 @@
|
||||
{#
|
||||
score_hero.html — Großer Score-Block für die Detailseite
|
||||
|
||||
Props:
|
||||
score : float (0–10) — 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")
|
||||
- 5–7: 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 %}
|
||||
1
app/templates/v2/icons/phosphor/arrow-square-out.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/book-open.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/bookmark-simple.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/chart-bar.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/circle-half.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/envelope-simple.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/file-csv.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/file-plus.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/graph.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/info.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/key.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/list-checks.svg
Normal 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 |
@ -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 |
1
app/templates/v2/icons/phosphor/magnifying-glass.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/moon.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/rss.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/sign-out.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/stack.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/sun.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/tag.svg
Normal 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 |
1
app/templates/v2/icons/phosphor/user-check.svg
Normal 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 |