Initial commit: GWÖ-Antragsprüfer v1.0
Features: - GWÖ-Matrix 2.0 Analyse für NRW-Landtagsanträge - Verbesserungsvorschläge im Redline-Format (Original/Vorschlag/Begründung) - Wahlprogramm- und Parteiprogrammtreue-Bewertung - Landtag-Suche via OPAL-API - Tag-Wolke mit Multi-Select Filter - Partei-Filter mit Durchschnittswerten - PDF-Report-Generierung - Security Headers (CSP, X-Frame-Options, etc.) - Persistente SQLite-DB via Docker Volumes Tech Stack: - FastAPI + Jinja2 - Qwen LLM via DashScope API - SQLite + aiosqlite - WeasyPrint für PDF - Docker Compose mit Traefik
This commit is contained in:
commit
63de3ca20d
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
data/
|
||||||
|
reports/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# DashScope API (Alibaba Qwen)
|
||||||
|
DASHSCOPE_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# Optional: Keycloak SSO
|
||||||
|
KEYCLOAK_URL=https://sso.example.com
|
||||||
|
KEYCLOAK_REALM=collaboration
|
||||||
|
KEYCLOAK_CLIENT_ID=gwoe-antragspruefer
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Data (persistent on server, not in repo)
|
||||||
|
data/
|
||||||
|
reports/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
7
.tarignore
Normal file
7
.tarignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
data/
|
||||||
|
reports/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install system dependencies for WeasyPrint
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
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
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Tobias Rödel
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
148
README.md
Normal file
148
README.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# GWÖ-Antragsprüfer
|
||||||
|
|
||||||
|
**Automatische Gemeinwohl-Bilanzierung von Parlamentsanträgen nach der GWÖ-Matrix 2.0 für Gemeinden**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 🎯 Was ist das?
|
||||||
|
|
||||||
|
Der GWÖ-Antragsprüfer analysiert Anträge aus Landesparlamenten (aktuell NRW) und bewertet sie nach den Kriterien der **Gemeinwohl-Ökonomie (GWÖ)**:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🔍 **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
|
||||||
|
|
||||||
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- DashScope API-Key (Qwen LLM)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App läuft 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tests ausführen
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
ruff check app/
|
||||||
|
|
||||||
|
# Type-Checking
|
||||||
|
mypy app/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Lizenz
|
||||||
|
|
||||||
|
MIT License - siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
|
## 🙏 Credits
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Entwickelt von Tobias Rödel** · [tobiasroedel.de](https://tobiasroedel.de)
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
273
app/analyzer.py
Normal file
273
app/analyzer.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"""LLM-based analysis of parliamentary motions against GWÖ matrix."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models import Assessment
|
||||||
|
from .wahlprogramme import find_relevant_quotes, format_quote_for_prompt, WAHLPROGRAMME
|
||||||
|
from .embeddings import get_relevant_quotes_for_antrag, format_quotes_for_prompt, EMBEDDINGS_DB
|
||||||
|
|
||||||
|
# Load context files
|
||||||
|
KONTEXT_DIR = Path(__file__).parent / "kontext"
|
||||||
|
|
||||||
|
|
||||||
|
def load_context_file(name: str) -> str:
|
||||||
|
"""Load a context file from the kontext directory."""
|
||||||
|
path = KONTEXT_DIR / name
|
||||||
|
if path.exists():
|
||||||
|
return path.read_text()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt() -> str:
|
||||||
|
"""Build the system prompt with GWÖ matrix context."""
|
||||||
|
return """Du bist ein Experte für Gemeinwohl-Ökonomie (GWÖ) und parlamentarische Analyse. Du bewertest Anträge aus Landesparlamenten systematisch nach drei Dimensionen:
|
||||||
|
|
||||||
|
1. **GWÖ-Treue** (0-10): Übereinstimmung mit der GWÖ-Matrix 2.0 für Gemeinden
|
||||||
|
2. **Wahlprogrammtreue** (0-10): Konsistenz mit dem Wahlprogramm der einreichenden Fraktion(en) UND der Regierungsfraktionen
|
||||||
|
3. **Parteiprogrammtreue** (0-10): Konsistenz mit dem Grundsatzprogramm der einreichenden Fraktion(en) UND der Regierungsfraktionen
|
||||||
|
|
||||||
|
## GWÖ-Matrix 2.0 für Gemeinden
|
||||||
|
|
||||||
|
Die Matrix besteht aus 5 Berührungsgruppen × 5 Werte = 25 Themenfelder.
|
||||||
|
|
||||||
|
### Die fünf Werte (Spalten) mit Staatsprinzipien
|
||||||
|
|
||||||
|
| Nr | Wert | Staatsprinzip | Kernfragen |
|
||||||
|
|----|------|---------------|------------|
|
||||||
|
| 1 | **Menschenwürde** | Rechtsstaatsprinzip | Werden Grundrechte geschützt? Rechtliche Gleichstellung? |
|
||||||
|
| 2 | **Solidarität** | Gemeinnutz | Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft? |
|
||||||
|
| 3 | **Ökologische Nachhaltigkeit** | Umwelt-Verantwortung | Klimaschutz? Ressourcenschonung? Biodiversität? |
|
||||||
|
| 4 | **Soziale Gerechtigkeit** | Sozialstaatsprinzip | Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung? |
|
||||||
|
| 5 | **Transparenz & Mitbestimmung** | Demokratie | Bürgerbeteiligung? Offenlegung? Demokratische Prozesse? |
|
||||||
|
|
||||||
|
### Die fünf Berührungsgruppen (Zeilen)
|
||||||
|
|
||||||
|
| Code | Gruppe | Beschreibung |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| **A** | Ausgelagerte Betriebe, Lieferant:innen, Dienstleister:innen | Externe Beschaffung, Lieferketten |
|
||||||
|
| **B** | Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen | Umgang mit öffentlichen Mitteln, Haushalt |
|
||||||
|
| **C** | Politische Führung, Verwaltung, Ehrenamtliche | Mandatsträger:innen, Mitarbeitende |
|
||||||
|
| **D** | Bürger:innen und Wirtschaft | Wirkung innerhalb der Grenzen, Daseinsvorsorge |
|
||||||
|
| **E** | Staat, Gesellschaft und Natur | Wirkung über die Grenzen hinaus, Zukunft |
|
||||||
|
|
||||||
|
### Matrix-Feldwertung (Skala -5 bis +5)
|
||||||
|
|
||||||
|
| Symbol | Rating | Bedeutung |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| `++` | +4 bis +5 | Stark fördernd, vorbildlich |
|
||||||
|
| `+` | +1 bis +3 | Fördernd |
|
||||||
|
| `○` | 0 | Neutral/nicht berührt |
|
||||||
|
| `−` | -1 bis -3 | Widersprechend |
|
||||||
|
| `−−` | -4 bis -5 | Stark widersprechend, fundamentaler Widerspruch |
|
||||||
|
|
||||||
|
**Skala-Logik:**
|
||||||
|
- **0** = Antrag berührt dieses Feld nicht
|
||||||
|
- **+1 bis +5** = Stärke der Übereinstimmung mit GWÖ-Werten
|
||||||
|
- **-1 bis -5** = Stärke des Widerspruchs zu GWÖ-Werten
|
||||||
|
|
||||||
|
### Empfehlungs-Kategorien
|
||||||
|
|
||||||
|
| Empfehlung | Kriterium |
|
||||||
|
|------------|-----------|
|
||||||
|
| **Uneingeschränkt unterstützen** | GWÖ 8-10, keine gravierenden Schwächen |
|
||||||
|
| **Unterstützen mit Änderungen** | GWÖ 5-7, Verbesserungspotenzial vorhanden |
|
||||||
|
| **Überarbeiten** | GWÖ 3-4, grundlegende Probleme |
|
||||||
|
| **Ablehnen** | GWÖ 0-2, fundamentaler Widerspruch zu GWÖ-Werten |
|
||||||
|
|
||||||
|
## Ausgabeformat
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt im folgenden Format (keine Markdown-Codeblöcke):
|
||||||
|
|
||||||
|
{
|
||||||
|
"drucksache": "Drucksachennummer falls bekannt, sonst 'unbekannt'",
|
||||||
|
"title": "Titel des Antrags",
|
||||||
|
"fraktionen": ["Fraktion1"],
|
||||||
|
"datum": "YYYY-MM-DD oder unbekannt",
|
||||||
|
"link": null,
|
||||||
|
"gwoeScore": 0-10,
|
||||||
|
"gwoeBegründung": "3-4 Sätze mit Bezug zu konkreten Themenfeldern",
|
||||||
|
"gwoeMatrix": [
|
||||||
|
{ "field": "D4", "label": "Soziale öffentliche Leistung", "aspect": "Konkreter Bezug", "rating": 2, "symbol": "+" }
|
||||||
|
],
|
||||||
|
"gwoeSchwerpunkt": ["D4", "D1"],
|
||||||
|
"wahlprogrammScores": [
|
||||||
|
{
|
||||||
|
"fraktion": "SPD",
|
||||||
|
"istAntragsteller": true,
|
||||||
|
"wahlprogramm": {
|
||||||
|
"score": 9,
|
||||||
|
"begründung": "...",
|
||||||
|
"zitate": [
|
||||||
|
{
|
||||||
|
"text": "Exaktes Zitat aus Wahlprogramm",
|
||||||
|
"quelle": "SPD NRW Wahlprogramm 2022, S. 47",
|
||||||
|
"url": "/static/referenzen/spd-nrw-2022.pdf#page=47"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"parteiprogramm": { "score": 8, "begründung": "..." }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verbesserungen": [
|
||||||
|
{
|
||||||
|
"original": "Originaltext aus dem Antrag",
|
||||||
|
"vorschlag": "Verbesserter Text mit **Ergänzungen** und ~~Streichungen~~",
|
||||||
|
"begruendung": "Bezug zu GWÖ-Themenfeld"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stärken": ["Punkt 1", "Punkt 2"],
|
||||||
|
"schwächen": ["Punkt 1"],
|
||||||
|
"empfehlung": "Ablehnen | Überarbeiten | Unterstützen mit Änderungen | Uneingeschränkt unterstützen",
|
||||||
|
"empfehlungSymbol": "[X] | [!] | [+] | [++]",
|
||||||
|
"verbesserungspotenzial": "gering | mittel | hoch | fundamental",
|
||||||
|
"themen": ["Bildung", "Soziales"],
|
||||||
|
"antragZusammenfassung": "1-2 Sätze Kernaussage",
|
||||||
|
"antragKernpunkte": ["Punkt 1", "Punkt 2", "Punkt 3"]
|
||||||
|
}
|
||||||
|
|
||||||
|
## Wichtige Regeln
|
||||||
|
|
||||||
|
- **Verbesserungsvorschläge**: Maximal 3! Fokussiere auf die wirkungsvollsten Änderungen, die den GWÖ-Score am meisten verbessern würden.
|
||||||
|
- **Zitate**: Nur echte Textstellen aus den Wahlprogrammen verwenden (werden als Kontext mitgeliefert).
|
||||||
|
- **Matrix-Bewertung**: Bewerte nur Felder, die der Antrag tatsächlich berührt. Nicht jeder Antrag betrifft alle 25 Felder.
|
||||||
|
- **Gesamtscore-Berechnung**: Der gwoeScore (0-10) berücksichtigt die Matrix-Bewertungen:
|
||||||
|
- Wenn EIN Feld -4 oder -5 hat → Gesamtscore maximal 3/10
|
||||||
|
- 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: −− / − / ○ / + / ++)"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_bundesland_context(bundesland: str) -> str:
|
||||||
|
"""Get context for a specific state."""
|
||||||
|
contexts = {
|
||||||
|
"NRW": {
|
||||||
|
"wahlprogramme": "wahlprogramme-nrw-2022.md",
|
||||||
|
"parteiprogramme": "parteiprogramme.md",
|
||||||
|
"regierungsfraktionen": ["CDU", "GRÜNE"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = contexts.get(bundesland, contexts["NRW"])
|
||||||
|
|
||||||
|
wahlprogramme = load_context_file(ctx["wahlprogramme"])
|
||||||
|
parteiprogramme = load_context_file(ctx["parteiprogramme"])
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
## Wahlprogramme {bundesland} 2022
|
||||||
|
|
||||||
|
{wahlprogramme}
|
||||||
|
|
||||||
|
## Grundsatzprogramme der Parteien
|
||||||
|
|
||||||
|
{parteiprogramme}
|
||||||
|
|
||||||
|
## Regierungsfraktionen in {bundesland}
|
||||||
|
|
||||||
|
{', '.join(ctx['regierungsfraktionen'])}
|
||||||
|
|
||||||
|
Bei Oppositionsanträgen: Bewerte zusätzlich, ob die Regierungsfraktionen zustimmen würden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = get_system_prompt()
|
||||||
|
bundesland_context = get_bundesland_context(bundesland)
|
||||||
|
|
||||||
|
# Extrahiere Fraktionen aus Text (einfache Heuristik)
|
||||||
|
fraktionen = []
|
||||||
|
for partei in WAHLPROGRAMME.keys():
|
||||||
|
if partei in text or partei.lower() in text.lower():
|
||||||
|
fraktionen.append(partei)
|
||||||
|
|
||||||
|
# Suche relevante Zitate via semantische Suche (Embeddings)
|
||||||
|
quotes_context = ""
|
||||||
|
if EMBEDDINGS_DB.exists():
|
||||||
|
try:
|
||||||
|
semantic_quotes = get_relevant_quotes_for_antrag(text, fraktionen, top_k_per_partei=2)
|
||||||
|
quotes_context = format_quotes_for_prompt(semantic_quotes)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Semantic search failed: {e}, falling back to keyword search")
|
||||||
|
quotes = find_relevant_quotes(text, fraktionen)
|
||||||
|
quotes_context = format_quote_for_prompt(quotes)
|
||||||
|
else:
|
||||||
|
# Fallback to keyword search
|
||||||
|
quotes = find_relevant_quotes(text, fraktionen)
|
||||||
|
quotes_context = format_quote_for_prompt(quotes)
|
||||||
|
|
||||||
|
user_prompt = f"""Analysiere den folgenden Antrag:
|
||||||
|
|
||||||
|
<kontext>
|
||||||
|
{bundesland_context}
|
||||||
|
</kontext>
|
||||||
|
|
||||||
|
<wahlprogramm_zitate>
|
||||||
|
{quotes_context if quotes_context else "Keine relevanten Zitate gefunden."}
|
||||||
|
</wahlprogramm_zitate>
|
||||||
|
|
||||||
|
<antrag>
|
||||||
|
{text}
|
||||||
|
</antrag>
|
||||||
|
|
||||||
|
Bewerte nach GWÖ-Matrix 2.0 für Gemeinden:
|
||||||
|
1. GWÖ-Treue (0-10) mit Matrix-Zuordnung und Symbolen (++/+/○/−/−−)
|
||||||
|
2. Wahlprogrammtreue der einreichenden Fraktion(en) UND Regierungsfraktionen (0-10)
|
||||||
|
3. Parteiprogrammtreue der einreichenden Fraktion(en) UND Regierungsfraktionen (0-10)
|
||||||
|
4. Bis zu 3 Verbesserungsvorschläge in Redline-Syntax
|
||||||
|
5. Themen-Tags für Kategorisierung
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Convert to Assessment model
|
||||||
|
return Assessment.model_validate(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
last_error = e
|
||||||
|
print(f"JSON parse error on attempt {attempt + 1}/{max_retries}: {e}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
print(f"Retrying with higher temperature...")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Log the problematic content for debugging
|
||||||
|
print(f"Failed JSON content (first 500 chars): {content[:500]}")
|
||||||
|
raise
|
||||||
35
app/config.py
Normal file
35
app/config.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = "GWÖ-Antragsprüfer"
|
||||||
|
app_version: str = "1.0.0"
|
||||||
|
prompt_version: str = "v4.1"
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
base_dir: Path = Path(__file__).resolve().parent.parent
|
||||||
|
data_dir: Path = base_dir / "data"
|
||||||
|
reports_dir: Path = base_dir / "reports"
|
||||||
|
kontext_dir: Path = Path(__file__).resolve().parent / "kontext"
|
||||||
|
db_path: Path = data_dir / "gwoe-antraege.db"
|
||||||
|
|
||||||
|
# LLM
|
||||||
|
dashscope_api_key: str = ""
|
||||||
|
dashscope_base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||||
|
llm_model_default: str = "qwen-plus-latest"
|
||||||
|
llm_model_premium: str = "qwen-max"
|
||||||
|
|
||||||
|
# Keycloak (TODO)
|
||||||
|
keycloak_url: str = ""
|
||||||
|
keycloak_realm: str = ""
|
||||||
|
keycloak_client_id: str = ""
|
||||||
|
|
||||||
|
# Server
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
332
app/database.py
Normal file
332
app/database.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
"""SQLite database for job tracking."""
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Initialize database with tables."""
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
input_preview TEXT,
|
||||||
|
bundesland TEXT DEFAULT 'NRW',
|
||||||
|
model TEXT DEFAULT 'qwen-plus',
|
||||||
|
result TEXT,
|
||||||
|
html_path TEXT,
|
||||||
|
pdf_path TEXT,
|
||||||
|
error TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Assessments table for pre-computed + new analyses
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS assessments (
|
||||||
|
drucksache TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
fraktionen TEXT, -- JSON array
|
||||||
|
datum TEXT,
|
||||||
|
link TEXT,
|
||||||
|
bundesland TEXT DEFAULT 'NRW',
|
||||||
|
|
||||||
|
gwoe_score REAL,
|
||||||
|
gwoe_begruendung TEXT,
|
||||||
|
gwoe_matrix TEXT, -- JSON array
|
||||||
|
gwoe_schwerpunkt TEXT, -- JSON array
|
||||||
|
|
||||||
|
wahlprogramm_scores TEXT, -- JSON array
|
||||||
|
|
||||||
|
verbesserungen TEXT, -- JSON array
|
||||||
|
staerken TEXT, -- JSON array
|
||||||
|
schwaechen TEXT, -- JSON array
|
||||||
|
|
||||||
|
empfehlung TEXT,
|
||||||
|
empfehlung_symbol TEXT,
|
||||||
|
verbesserungspotenzial TEXT,
|
||||||
|
|
||||||
|
themen TEXT, -- JSON array
|
||||||
|
antrag_zusammenfassung TEXT,
|
||||||
|
antrag_kernpunkte TEXT, -- JSON array
|
||||||
|
|
||||||
|
source TEXT DEFAULT 'batch', -- 'batch' or 'webapp'
|
||||||
|
model TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job(
|
||||||
|
job_id: str,
|
||||||
|
input_preview: str,
|
||||||
|
bundesland: str = "NRW",
|
||||||
|
model: str = "qwen-plus",
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new analysis job."""
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs (id, input_preview, bundesland, model, user_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(job_id, input_preview, bundesland, model, user_id, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": job_id, "status": "queued", "created_at": now}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job(job_id: str) -> Optional[dict]:
|
||||||
|
"""Get job by ID."""
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_job(job_id: str, **kwargs) -> bool:
|
||||||
|
"""Update job fields."""
|
||||||
|
if not kwargs:
|
||||||
|
return False
|
||||||
|
|
||||||
|
kwargs["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
fields = ", ".join(f"{k} = ?" for k in kwargs.keys())
|
||||||
|
values = list(kwargs.values()) + [job_id]
|
||||||
|
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
await db.execute(f"UPDATE jobs SET {fields} WHERE id = ?", values)
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_jobs(user_id: str, limit: int = 50) -> list[dict]:
|
||||||
|
"""Get jobs for a user (for history page)."""
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(user_id, limit),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_assessment(data: dict) -> bool:
|
||||||
|
"""Insert or update an assessment."""
|
||||||
|
import json
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO assessments (
|
||||||
|
drucksache, title, fraktionen, datum, link, bundesland,
|
||||||
|
gwoe_score, gwoe_begruendung, gwoe_matrix, gwoe_schwerpunkt,
|
||||||
|
wahlprogramm_scores, verbesserungen, staerken, schwaechen,
|
||||||
|
empfehlung, empfehlung_symbol, verbesserungspotenzial,
|
||||||
|
themen, antrag_zusammenfassung, antrag_kernpunkte,
|
||||||
|
source, model, 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,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""", (
|
||||||
|
data.get("drucksache"),
|
||||||
|
data.get("title"),
|
||||||
|
json.dumps(data.get("fraktionen", [])),
|
||||||
|
data.get("datum"),
|
||||||
|
data.get("link"),
|
||||||
|
data.get("bundesland", "NRW"),
|
||||||
|
data.get("gwoeScore"),
|
||||||
|
data.get("gwoeBegründung"),
|
||||||
|
json.dumps(data.get("gwoeMatrix", [])),
|
||||||
|
json.dumps(data.get("gwoeSchwerpunkt", [])),
|
||||||
|
json.dumps(data.get("wahlprogrammScores", [])),
|
||||||
|
json.dumps(data.get("verbesserungen", [])),
|
||||||
|
json.dumps(data.get("stärken", [])),
|
||||||
|
json.dumps(data.get("schwächen", [])),
|
||||||
|
data.get("empfehlung"),
|
||||||
|
data.get("empfehlungSymbol"),
|
||||||
|
data.get("verbesserungspotenzial"),
|
||||||
|
json.dumps(data.get("themen", [])),
|
||||||
|
data.get("antragZusammenfassung"),
|
||||||
|
json.dumps(data.get("antragKernpunkte", [])),
|
||||||
|
data.get("source", "webapp"),
|
||||||
|
data.get("model"),
|
||||||
|
now, now
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_assessment(drucksache: str) -> Optional[dict]:
|
||||||
|
"""Get assessment by drucksache ID."""
|
||||||
|
import json
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM assessments WHERE drucksache = ?", (drucksache,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
d = dict(row)
|
||||||
|
# Parse JSON fields
|
||||||
|
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||||
|
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||||
|
"schwaechen", "themen", "antrag_kernpunkte"]:
|
||||||
|
if d.get(field):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(d[field])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_assessments() -> list[dict]:
|
||||||
|
"""Get all assessments from database."""
|
||||||
|
import json
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT * FROM assessments ORDER BY gwoe_score DESC"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
results = []
|
||||||
|
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"]:
|
||||||
|
if d.get(field):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(d[field])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
results.append(d)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def import_json_assessments(assessments_dir):
|
||||||
|
"""Import assessments from JSON files into database."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
dir_path = Path(assessments_dir)
|
||||||
|
if not dir_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for f in dir_path.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
data["source"] = "batch"
|
||||||
|
await upsert_assessment(data)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error importing {f}: {e}")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_search_query(query: str) -> tuple[list[str], bool]:
|
||||||
|
"""
|
||||||
|
Parse search query for AND logic and exact phrases.
|
||||||
|
Returns: (terms, is_exact)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- 'Klimaschutz Energie' -> (['klimaschutz', 'energie'], False)
|
||||||
|
- '"Grüner Stahl"' -> (['grüner stahl'], True)
|
||||||
|
"""
|
||||||
|
query = query.strip()
|
||||||
|
|
||||||
|
# Check for exact phrase (entire query in quotes)
|
||||||
|
if query.startswith('"') and query.endswith('"') and query.count('"') == 2:
|
||||||
|
exact = query[1:-1].strip()
|
||||||
|
return ([exact.lower()], True)
|
||||||
|
|
||||||
|
# Extract quoted phrases and regular terms
|
||||||
|
import shlex
|
||||||
|
try:
|
||||||
|
parts = shlex.split(query)
|
||||||
|
except ValueError:
|
||||||
|
parts = query.split()
|
||||||
|
|
||||||
|
return ([p.lower() for p in parts], False)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_assessments(query: str, bundesland: str = None, limit: int = 50) -> list[dict]:
|
||||||
|
"""Search assessments by title, drucksache, or themen. Supports AND logic."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
terms, is_exact = _parse_search_query(query)
|
||||||
|
|
||||||
|
# Build SQL for first term (to narrow down results)
|
||||||
|
first_term = terms[0] if terms else query.lower()
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT * FROM assessments
|
||||||
|
WHERE (
|
||||||
|
LOWER(drucksache) LIKE ?
|
||||||
|
OR LOWER(title) LIKE ?
|
||||||
|
OR LOWER(themen) LIKE ?
|
||||||
|
OR LOWER(fraktionen) LIKE ?
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = [f"%{first_term}%"] * 4
|
||||||
|
|
||||||
|
if bundesland:
|
||||||
|
sql += " AND bundesland = ?"
|
||||||
|
params.append(bundesland)
|
||||||
|
|
||||||
|
sql += " ORDER BY gwoe_score DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(settings.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(sql, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
d = dict(row)
|
||||||
|
for field in ["fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||||
|
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||||
|
"schwaechen", "themen", "antrag_kernpunkte"]:
|
||||||
|
if d.get(field):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(d[field])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply AND filter for multiple terms
|
||||||
|
if len(terms) > 1 or is_exact:
|
||||||
|
searchable = f"{d.get('title', '')} {d.get('drucksache', '')} {' '.join(d.get('fraktionen', []))} {' '.join(d.get('themen', []))}".lower()
|
||||||
|
if is_exact:
|
||||||
|
if terms[0] not in searchable:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if not all(term in searchable for term in terms):
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(d)
|
||||||
|
|
||||||
|
return results
|
||||||
387
app/embeddings.py
Normal file
387
app/embeddings.py
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
"""Semantic search for Wahlprogramme and Parteiprogramme using Qwen embeddings."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
# Embedding model
|
||||||
|
EMBEDDING_MODEL = "text-embedding-v3"
|
||||||
|
EMBEDDING_DIMENSIONS = 1024
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
EMBEDDINGS_DB = settings.data_dir / "embeddings.db"
|
||||||
|
|
||||||
|
# Programme definitions
|
||||||
|
PROGRAMME = {
|
||||||
|
# Wahlprogramme NRW 2022
|
||||||
|
"spd-nrw-2022": {
|
||||||
|
"name": "SPD NRW Wahlprogramm 2022",
|
||||||
|
"typ": "wahlprogramm",
|
||||||
|
"partei": "SPD",
|
||||||
|
"bundesland": "NRW",
|
||||||
|
"pdf": "spd-nrw-2022.pdf",
|
||||||
|
},
|
||||||
|
"cdu-nrw-2022": {
|
||||||
|
"name": "CDU NRW Wahlprogramm 2022",
|
||||||
|
"typ": "wahlprogramm",
|
||||||
|
"partei": "CDU",
|
||||||
|
"bundesland": "NRW",
|
||||||
|
"pdf": "cdu-nrw-2022.pdf",
|
||||||
|
},
|
||||||
|
"gruene-nrw-2022": {
|
||||||
|
"name": "Grüne NRW Wahlprogramm 2022",
|
||||||
|
"typ": "wahlprogramm",
|
||||||
|
"partei": "GRÜNE",
|
||||||
|
"bundesland": "NRW",
|
||||||
|
"pdf": "gruene-nrw-2022.pdf",
|
||||||
|
},
|
||||||
|
"fdp-nrw-2022": {
|
||||||
|
"name": "FDP NRW Wahlprogramm 2022",
|
||||||
|
"typ": "wahlprogramm",
|
||||||
|
"partei": "FDP",
|
||||||
|
"bundesland": "NRW",
|
||||||
|
"pdf": "fdp-nrw-2022.pdf",
|
||||||
|
},
|
||||||
|
"afd-nrw-2022": {
|
||||||
|
"name": "AfD NRW Wahlprogramm 2022",
|
||||||
|
"typ": "wahlprogramm",
|
||||||
|
"partei": "AfD",
|
||||||
|
"bundesland": "NRW",
|
||||||
|
"pdf": "afd-nrw-2022.pdf",
|
||||||
|
},
|
||||||
|
# Grundsatzprogramme (Bund)
|
||||||
|
"spd-grundsatz": {
|
||||||
|
"name": "SPD Grundsatzprogramm 2007",
|
||||||
|
"typ": "parteiprogramm",
|
||||||
|
"partei": "SPD",
|
||||||
|
"pdf": "spd-grundsatzprogramm.pdf",
|
||||||
|
},
|
||||||
|
"cdu-grundsatz": {
|
||||||
|
"name": "CDU Grundsatzprogramm 2007",
|
||||||
|
"typ": "parteiprogramm",
|
||||||
|
"partei": "CDU",
|
||||||
|
"pdf": "cdu-grundsatzprogramm.pdf",
|
||||||
|
},
|
||||||
|
"gruene-grundsatz": {
|
||||||
|
"name": "Grüne Grundsatzprogramm 2020",
|
||||||
|
"typ": "parteiprogramm",
|
||||||
|
"partei": "GRÜNE",
|
||||||
|
"pdf": "gruene-grundsatzprogramm.pdf",
|
||||||
|
},
|
||||||
|
"fdp-grundsatz": {
|
||||||
|
"name": "FDP Grundsatzprogramm 2012",
|
||||||
|
"typ": "parteiprogramm",
|
||||||
|
"partei": "FDP",
|
||||||
|
"pdf": "fdp-grundsatzprogramm.pdf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_embeddings_db():
|
||||||
|
"""Initialize the embeddings database."""
|
||||||
|
conn = sqlite3.connect(EMBEDDINGS_DB)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
programm_id TEXT NOT NULL,
|
||||||
|
partei TEXT NOT NULL,
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
seite INTEGER,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
embedding BLOB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_partei ON chunks(partei)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_typ ON chunks(typ)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> OpenAI:
|
||||||
|
"""Get DashScope client."""
|
||||||
|
return OpenAI(
|
||||||
|
api_key=settings.dashscope_api_key,
|
||||||
|
base_url=settings.dashscope_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_embedding(text: str) -> list[float]:
|
||||||
|
"""Create embedding for text using Qwen."""
|
||||||
|
client = get_client()
|
||||||
|
response = client.embeddings.create(
|
||||||
|
model=EMBEDDING_MODEL,
|
||||||
|
input=text,
|
||||||
|
dimensions=EMBEDDING_DIMENSIONS,
|
||||||
|
)
|
||||||
|
return response.data[0].embedding
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
|
||||||
|
"""Split text into overlapping chunks by words."""
|
||||||
|
words = text.split()
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(words):
|
||||||
|
chunk_words = words[i:i + chunk_size]
|
||||||
|
chunk = " ".join(chunk_words)
|
||||||
|
if chunk.strip():
|
||||||
|
chunks.append(chunk)
|
||||||
|
i += chunk_size - overlap
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_with_pages(pdf_path: Path) -> list[tuple[int, str]]:
|
||||||
|
"""Extract text from PDF with page numbers."""
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
if text.strip():
|
||||||
|
pages.append((page_num + 1, text))
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def index_programm(programm_id: str, pdf_dir: Path) -> int:
|
||||||
|
"""Index a single program PDF into embeddings database."""
|
||||||
|
if programm_id not in PROGRAMME:
|
||||||
|
raise ValueError(f"Unknown program: {programm_id}")
|
||||||
|
|
||||||
|
info = PROGRAMME[programm_id]
|
||||||
|
pdf_path = pdf_dir / info["pdf"]
|
||||||
|
|
||||||
|
if not pdf_path.exists():
|
||||||
|
print(f"PDF not found: {pdf_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn = sqlite3.connect(EMBEDDINGS_DB)
|
||||||
|
|
||||||
|
# Remove existing chunks for this program
|
||||||
|
conn.execute("DELETE FROM chunks WHERE programm_id = ?", (programm_id,))
|
||||||
|
|
||||||
|
# Extract and chunk
|
||||||
|
pages = extract_text_with_pages(pdf_path)
|
||||||
|
total_chunks = 0
|
||||||
|
|
||||||
|
for page_num, page_text in pages:
|
||||||
|
chunks = chunk_text(page_text, chunk_size=400, overlap=50)
|
||||||
|
|
||||||
|
for chunk_text_content in chunks:
|
||||||
|
if len(chunk_text_content.split()) < 20: # Skip tiny chunks
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
embedding = create_embedding(chunk_text_content)
|
||||||
|
embedding_blob = json.dumps(embedding).encode()
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO chunks (programm_id, partei, typ, seite, text, embedding)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
programm_id,
|
||||||
|
info["partei"],
|
||||||
|
info["typ"],
|
||||||
|
page_num,
|
||||||
|
chunk_text_content,
|
||||||
|
embedding_blob,
|
||||||
|
))
|
||||||
|
total_chunks += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error embedding chunk: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Indexed {total_chunks} chunks from {programm_id}")
|
||||||
|
return total_chunks
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
norm_a = sum(x * x for x in a) ** 0.5
|
||||||
|
norm_b = sum(x * x for x in b) ** 0.5
|
||||||
|
if norm_a == 0 or norm_b == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (norm_a * norm_b)
|
||||||
|
|
||||||
|
|
||||||
|
def find_relevant_chunks(
|
||||||
|
query: str,
|
||||||
|
parteien: list[str] = None,
|
||||||
|
typ: str = None,
|
||||||
|
top_k: int = 3,
|
||||||
|
min_similarity: float = 0.5,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Find most relevant chunks for a query."""
|
||||||
|
|
||||||
|
query_embedding = create_embedding(query)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(EMBEDDINGS_DB)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
sql = "SELECT * FROM chunks WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if parteien:
|
||||||
|
placeholders = ",".join("?" * len(parteien))
|
||||||
|
sql += f" AND partei IN ({placeholders})"
|
||||||
|
params.extend(parteien)
|
||||||
|
|
||||||
|
if typ:
|
||||||
|
sql += " AND typ = ?"
|
||||||
|
params.append(typ)
|
||||||
|
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Calculate similarities
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
chunk_embedding = json.loads(row["embedding"])
|
||||||
|
similarity = cosine_similarity(query_embedding, chunk_embedding)
|
||||||
|
|
||||||
|
if similarity >= min_similarity:
|
||||||
|
results.append({
|
||||||
|
"programm_id": row["programm_id"],
|
||||||
|
"partei": row["partei"],
|
||||||
|
"typ": row["typ"],
|
||||||
|
"seite": row["seite"],
|
||||||
|
"text": row["text"],
|
||||||
|
"similarity": similarity,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by similarity and return top_k
|
||||||
|
results.sort(key=lambda x: x["similarity"], reverse=True)
|
||||||
|
return results[:top_k]
|
||||||
|
|
||||||
|
|
||||||
|
def get_relevant_quotes_for_antrag(
|
||||||
|
antrag_text: str,
|
||||||
|
fraktionen: list[str],
|
||||||
|
top_k_per_partei: int = 2,
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
"""Get relevant quotes from Wahl- and Parteiprogramme for an Antrag."""
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for partei in fraktionen + ["CDU", "GRÜNE"]: # Include Regierungsfraktionen
|
||||||
|
partei_upper = partei.upper() if partei != "GRÜNE" else "GRÜNE"
|
||||||
|
|
||||||
|
# Wahlprogramm
|
||||||
|
wahl_chunks = find_relevant_chunks(
|
||||||
|
antrag_text,
|
||||||
|
parteien=[partei_upper],
|
||||||
|
typ="wahlprogramm",
|
||||||
|
top_k=top_k_per_partei,
|
||||||
|
min_similarity=0.45,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parteiprogramm
|
||||||
|
partei_chunks = find_relevant_chunks(
|
||||||
|
antrag_text,
|
||||||
|
parteien=[partei_upper],
|
||||||
|
typ="parteiprogramm",
|
||||||
|
top_k=top_k_per_partei,
|
||||||
|
min_similarity=0.45,
|
||||||
|
)
|
||||||
|
|
||||||
|
if wahl_chunks or partei_chunks:
|
||||||
|
results[partei_upper] = {
|
||||||
|
"wahlprogramm": wahl_chunks,
|
||||||
|
"parteiprogramm": partei_chunks,
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def format_quotes_for_prompt(quotes: dict) -> str:
|
||||||
|
"""Format quotes for inclusion in LLM prompt."""
|
||||||
|
if not quotes:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["\n## Relevante Passagen aus Wahl- und Parteiprogrammen\n"]
|
||||||
|
|
||||||
|
for partei, data in quotes.items():
|
||||||
|
lines.append(f"\n### {partei}\n")
|
||||||
|
|
||||||
|
if data.get("wahlprogramm"):
|
||||||
|
lines.append("**Wahlprogramm NRW 2022:**")
|
||||||
|
for chunk in data["wahlprogramm"]:
|
||||||
|
text = chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"]
|
||||||
|
lines.append(f'- S. {chunk["seite"]}: "{text}"')
|
||||||
|
|
||||||
|
if data.get("parteiprogramm"):
|
||||||
|
lines.append("\n**Grundsatzprogramm:**")
|
||||||
|
for chunk in data["parteiprogramm"]:
|
||||||
|
text = chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"]
|
||||||
|
lines.append(f'- S. {chunk["seite"]}: "{text}"')
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_programme_info() -> list[dict]:
|
||||||
|
"""Get list of all indexed programmes with metadata."""
|
||||||
|
info_list = []
|
||||||
|
|
||||||
|
for prog_id, info in PROGRAMME.items():
|
||||||
|
info_list.append({
|
||||||
|
"id": prog_id,
|
||||||
|
"name": info["name"],
|
||||||
|
"typ": info["typ"],
|
||||||
|
"partei": info["partei"],
|
||||||
|
"bundesland": info.get("bundesland"),
|
||||||
|
"pdf": info["pdf"],
|
||||||
|
"pdf_url": f"/static/referenzen/{info['pdf']}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return info_list
|
||||||
|
|
||||||
|
|
||||||
|
def get_indexing_status() -> dict:
|
||||||
|
"""Get status of indexed programmes."""
|
||||||
|
if not EMBEDDINGS_DB.exists():
|
||||||
|
return {"indexed": 0, "programmes": []}
|
||||||
|
|
||||||
|
conn = sqlite3.connect(EMBEDDINGS_DB)
|
||||||
|
|
||||||
|
# Count chunks per program
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT programm_id, COUNT(*) as chunks
|
||||||
|
FROM chunks
|
||||||
|
GROUP BY programm_id
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
indexed = {row[0]: row[1] for row in rows}
|
||||||
|
|
||||||
|
programmes = []
|
||||||
|
for prog_id, info in PROGRAMME.items():
|
||||||
|
programmes.append({
|
||||||
|
"id": prog_id,
|
||||||
|
"name": info["name"],
|
||||||
|
"partei": info["partei"],
|
||||||
|
"chunks": indexed.get(prog_id, 0),
|
||||||
|
"indexed": prog_id in indexed,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"indexed": len(indexed),
|
||||||
|
"total": len(PROGRAMME),
|
||||||
|
"programmes": programmes,
|
||||||
|
}
|
||||||
2418
app/kontext/afd-nrw-2022-paged.txt
Normal file
2418
app/kontext/afd-nrw-2022-paged.txt
Normal file
File diff suppressed because it is too large
Load Diff
4671
app/kontext/cdu-nrw-2022-paged.txt
Normal file
4671
app/kontext/cdu-nrw-2022-paged.txt
Normal file
File diff suppressed because it is too large
Load Diff
5180
app/kontext/fdp-nrw-2022-paged.txt
Normal file
5180
app/kontext/fdp-nrw-2022-paged.txt
Normal file
File diff suppressed because it is too large
Load Diff
5074
app/kontext/gruene-nrw-2022-paged.txt
Normal file
5074
app/kontext/gruene-nrw-2022-paged.txt
Normal file
File diff suppressed because it is too large
Load Diff
139
app/kontext/gwoe-matrix-2.0.md
Normal file
139
app/kontext/gwoe-matrix-2.0.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# GWÖ-Matrix V2.0 — Gemeinden
|
||||||
|
|
||||||
|
*Quelle: [Matrix-Gemeinwohl-Bilanzierung-Gemeinden-V2.0.pdf](https://germany.econgood.org/wp-content/uploads/sites/8/2024/04/Matrix-Gemeinwohl-Bilanzierung-Gemeinden-V2.0.pdf)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Die Matrix 2.0 für Gemeinden bietet einen etwas weiteren Blick als die später erschienene Matrix 2.1.A für die öffentliche Hand. Sie wurde speziell für kommunale Gebietskörperschaften entwickelt und eignet sich gut für die Bewertung parlamentarischer Anträge auf Landes- und Kommunalebene.
|
||||||
|
|
||||||
|
**Struktur:** 5 Berührungsgruppen × 5 Werte = 25 Themenfelder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die 5 Werte (Spalten)
|
||||||
|
|
||||||
|
| Nr | Wert | Staatsprinzip |
|
||||||
|
|----|------|---------------|
|
||||||
|
| 1 | **Menschenwürde** | Rechtsstaatsprinzip |
|
||||||
|
| 2 | **Solidarität** | Gemeinnutz |
|
||||||
|
| 3 | **Ökologische Nachhaltigkeit** | Umwelt-Verantwortung |
|
||||||
|
| 4 | **Soziale Gerechtigkeit** | Sozialstaatsprinzip |
|
||||||
|
| 5 | **Transparenz & Demokratische Mitbestimmung** | Demokratie |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die 5 Berührungsgruppen (Zeilen)
|
||||||
|
|
||||||
|
| Code | Gruppe | Beschreibung |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| **A** | Ausgelagerte/selbständige Betriebe, Lieferant:innen, Dienstleister:innen | Externe Beschaffung, Lieferketten, ausgelagerte Aufgaben |
|
||||||
|
| **B** | Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen | Umgang mit öffentlichen Mitteln, Haushalt, Finanzpolitik |
|
||||||
|
| **C** | Politische Führung, Verwaltung und koordinierte Ehrenamtliche | Mandatsträger:innen, Mitarbeitende, ehrenamtlich Engagierte |
|
||||||
|
| **D** | Bürger:innen und Wirtschaft | Wirkung auf Bevölkerung und lokale Wirtschaft, Daseinsvorsorge |
|
||||||
|
| **E** | Staat, Gesellschaft und Natur | Überregionale/langfristige Wirkung, zukünftige Generationen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die 25 Themenfelder
|
||||||
|
|
||||||
|
### Zeile A: Lieferant:innen & Dienstleister:innen
|
||||||
|
|
||||||
|
| Feld | Titel | Kernfragen |
|
||||||
|
|------|-------|------------|
|
||||||
|
| **A1** | Grundrechtsschutz und Menschenwürde in der Lieferkette | Werden bei Beschaffung Menschenrechte beachtet? Sorgfaltspflichten? |
|
||||||
|
| **A2** | Nutzen für die Gemeinde | Bringt die Beschaffung Mehrwert für die Gemeinde? Regionale Wertschöpfung? |
|
||||||
|
| **A3** | Ökologische Verantwortung für die Lieferkette | Umweltkriterien bei Vergabe? Nachhaltige Lieferketten? |
|
||||||
|
| **A4** | Soziale Verantwortung für die Lieferkette | Faire Arbeitsbedingungen bei Lieferanten? Tariftreue? |
|
||||||
|
| **A5** | Öffentliche Rechenschaft und Mitsprache | Transparenz bei Auftragsvergabe? Einbindung von Stakeholdern? |
|
||||||
|
|
||||||
|
### Zeile B: Finanzpartner:innen & Steuerzahler:innen
|
||||||
|
|
||||||
|
| Feld | Titel | Kernfragen |
|
||||||
|
|------|-------|------------|
|
||||||
|
| **B1** | Ethisches Finanzgebaren / Geld und Mensch | Ethische Geldanlage? Keine spekulativen Investments? |
|
||||||
|
| **B2** | Gemeinnutz im Finanzgebaren | Dienen Finanzen dem Gemeinwohl? Gerechte Mittelverteilung? |
|
||||||
|
| **B3** | Ökologische Verantwortung der Finanzpolitik | Klimaschutz im Haushalt? Grüne Investitionen? |
|
||||||
|
| **B4** | Soziale Verantwortung der Finanzpolitik | Sozial gerechte Haushaltsplanung? Unterstützung Bedürftiger? |
|
||||||
|
| **B5** | Rechenschaft und Partizipation in der Finanzpolitik | Transparenter Haushalt? Bürgerbeteiligung bei Finanzen? |
|
||||||
|
|
||||||
|
### Zeile C: Politische Führung & Verwaltung
|
||||||
|
|
||||||
|
| Feld | Titel | Kernfragen |
|
||||||
|
|------|-------|------------|
|
||||||
|
| **C1** | Individuelle Rechts- und Gleichstellung | Gleichstellung der Mitarbeitenden? Anti-Diskriminierung? |
|
||||||
|
| **C2** | Gemeinsame Zielvereinbarung für das Gemeinwohl | Gemeinsame Vision? Gemeinwohlorientierte Verwaltungskultur? |
|
||||||
|
| **C3** | Förderung ökologischen Verhaltens | Umweltbildung? Nachhaltige Verwaltung? |
|
||||||
|
| **C4** | Gerechte Verteilung von Arbeit | Work-Life-Balance? Faire Arbeitsbedingungen? |
|
||||||
|
| **C5** | Transparente Kommunikation und demokratische Prozesse | Offene Verwaltung? Beteiligung der Mitarbeitenden? |
|
||||||
|
|
||||||
|
### Zeile D: Bürger:innen und Wirtschaft
|
||||||
|
|
||||||
|
| Feld | Titel | Kernfragen |
|
||||||
|
|------|-------|------------|
|
||||||
|
| **D1** | Schutz des Individuums, Rechtsgleichheit | Bürgerrechte geschützt? Gleicher Zugang zu Leistungen? |
|
||||||
|
| **D2** | Gesamtwohl in der Gemeinde | Fördert die Maßnahme das lokale Gemeinwohl? |
|
||||||
|
| **D3** | Ökologische Gestaltung der öffentlichen Leistung | Umweltfreundliche öffentliche Dienste? Klimaschutz? |
|
||||||
|
| **D4** | Soziale Gestaltung der öffentlichen Leistung | Sozial gerechte Daseinsvorsorge? Inklusion? |
|
||||||
|
| **D5** | Transparente Kommunikation und demokratische Einbindung | Bürgerbeteiligung? Transparenz der Entscheidungen? |
|
||||||
|
|
||||||
|
### Zeile E: Staat, Gesellschaft und Natur
|
||||||
|
|
||||||
|
| Feld | Titel | Kernfragen |
|
||||||
|
|------|-------|------------|
|
||||||
|
| **E1** | Gestaltung der Bedingungen für ein menschenwürdiges Leben – zukünftige Generationen | Generationengerechtigkeit? Langfristige Lebensqualität? |
|
||||||
|
| **E2** | Beitrag zum Gesamtwohl | Überregionaler Nutzen? Solidarität mit anderen? |
|
||||||
|
| **E3** | Verantwortung für ökologische Auswirkungen | Klimawirkung über die Region hinaus? Biodiversität? |
|
||||||
|
| **E4** | Beitrag zum sozialen Ausgleich | Strukturpolitik? Ausgleich zwischen Regionen? |
|
||||||
|
| **E5** | Transparente und demokratische Mitbestimmung | Partizipation auf höherer Ebene? Demokratieförderung? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevanz für Landesanträge
|
||||||
|
|
||||||
|
Die meisten parlamentarischen Anträge betreffen:
|
||||||
|
|
||||||
|
- **D-Zeile (primär):** Wirkung auf Bürger:innen und Wirtschaft im Land
|
||||||
|
- **E-Zeile (sekundär):** Überregionale oder langfristige Auswirkungen
|
||||||
|
- **C-Zeile:** Wenn es um Verwaltungsreformen geht
|
||||||
|
- **B-Zeile:** Bei Haushalts- und Finanzthemen
|
||||||
|
|
||||||
|
**Prinzip:** D (intern/lokal) hat Vorrang vor E (extern/überregional). Themen mit hauptsächlich interner Wirkung gehören zu D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewertungsskala
|
||||||
|
|
||||||
|
| Punkte | Stufe | Beschreibung |
|
||||||
|
|--------|-------|--------------|
|
||||||
|
| 7-10 | **Vorbildlich** | Innovative Maßnahmen, weitreichende Verbesserungen |
|
||||||
|
| 4-6 | **Erfahren** | Erkennbare Verbesserungen, gute Ergebnisse |
|
||||||
|
| 2-3 | **Fortgeschritten** | Erste Maßnahmen, erste Erfolge |
|
||||||
|
| 1 | **Erste Schritte** | Erstes Engagement |
|
||||||
|
| 0 | **Basislinie** | Nur gesetzliche Anforderungen |
|
||||||
|
| negativ | **Widerspruch** | Aktiver Widerspruch zu GWÖ-Werten |
|
||||||
|
|
||||||
|
### Feldwertung
|
||||||
|
|
||||||
|
- `++` (+2/+3): Stark fördernd
|
||||||
|
- `+` (+1): Fördernd
|
||||||
|
- `○` (0): Neutral
|
||||||
|
- `−` (-1): Widersprechend
|
||||||
|
- `−−` (-2/-3): Stark widersprechend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unterschied zu Matrix 2.1.A
|
||||||
|
|
||||||
|
| Aspekt | Matrix 2.0 (Gemeinden) | Matrix 2.1.A (Öffentliche Hand) |
|
||||||
|
|--------|------------------------|--------------------------------|
|
||||||
|
| **Fokus** | Kommunale Ebene | Alle öffentlichen Gebietskörperschaften |
|
||||||
|
| **Zeile A** | "Ausgelagerte Betriebe" | "Lieferant:innen" |
|
||||||
|
| **Zeile D** | "Bürger:innen und Wirtschaft" | "Bevölkerung und Wirtschaft" |
|
||||||
|
| **Detailgrad** | Kompakter | Ausführlicher |
|
||||||
|
| **Ideal für** | Kommunalpolitik, konkrete Projekte | Landespolitik, übergeordnete Themen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stand: März 2026*
|
||||||
148
app/kontext/parteiprogramme.md
Normal file
148
app/kontext/parteiprogramme.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Parteiprogramme — Kurzreferenz
|
||||||
|
|
||||||
|
*Für die Bewertung von Wahlprogrammtreue UND Grundsatzprogrammtreue*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersicht der Programme
|
||||||
|
|
||||||
|
| Partei | Wahlprogramm NRW 2022 | Grundsatzprogramm |
|
||||||
|
|--------|----------------------|-------------------|
|
||||||
|
| **CDU** | Landtagswahl 2022 | "In Freiheit leben" (2024) |
|
||||||
|
| **SPD** | Landtagswahl 2022 | Hamburger Programm (2007) |
|
||||||
|
| **GRÜNE** | Landtagswahl 2022 | "...zu achten und zu schützen..." (2020) |
|
||||||
|
| **FDP** | Landtagswahl 2022 | "Verantwortung für die Freiheit" (2012) |
|
||||||
|
| **AfD** | Landtagswahl 2022 | "Programm für Deutschland" (2016) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CDU
|
||||||
|
|
||||||
|
### Wahlprogramm NRW 2022 — Kernpositionen
|
||||||
|
|
||||||
|
- **Sicherheit:** Mehr Polizei, härtere Strafen, Null-Toleranz
|
||||||
|
- **Bildung:** 10.000 neue Lehrkräfte, Digitalisierung, Talentschulen
|
||||||
|
- **Klimaschutz:** Klimaneutralität 2045, Technologieoffenheit
|
||||||
|
- **Wirtschaft:** Bürokratieabbau, Mittelstandsförderung
|
||||||
|
- **Infrastruktur:** Straßenbau UND ÖPNV-Ausbau
|
||||||
|
|
||||||
|
### Grundsatzprogramm 2024 — Leitideen
|
||||||
|
|
||||||
|
- **Menschenbild:** Christlich-demokratisches Menschenbild, Würde, Freiheit, Verantwortung
|
||||||
|
- **Staat:** Subsidiäre Ordnung, Föderalismus, starker aber begrenzter Staat
|
||||||
|
- **Wirtschaft:** Soziale Marktwirtschaft, Eigentum, Leistungsprinzip
|
||||||
|
- **Umwelt:** Schöpfungsverantwortung, Technologieoffenheit, Marktwirtschaftlicher Umweltschutz
|
||||||
|
- **Europa:** Europäische Einigung, transatlantische Partnerschaft
|
||||||
|
- **Familie:** Ehe und Familie als Fundament, Wahlfreiheit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPD
|
||||||
|
|
||||||
|
### Wahlprogramm NRW 2022 — Kernpositionen
|
||||||
|
|
||||||
|
- **Bildung:** Gebührenfreie Kitas, Ganztagsschule, Abschaffung Schulform-Segregation
|
||||||
|
- **Wohnen:** 100.000 neue Wohnungen, Mietendeckel-Prüfung
|
||||||
|
- **Arbeit:** Tariftreue bei Vergaben, 13€ Landesmindestlohn
|
||||||
|
- **Klimaschutz:** Klimaneutralität 2040, Kohleausstieg beschleunigen
|
||||||
|
- **Soziales:** Soziale Gerechtigkeit, Chancengleichheit
|
||||||
|
|
||||||
|
### Grundsatzprogramm (Hamburger Programm) 2007 — Leitideen
|
||||||
|
|
||||||
|
- **Grundwerte:** Freiheit, Gerechtigkeit, Solidarität
|
||||||
|
- **Demokratischer Sozialismus:** Nicht Endzustand, sondern andauernde Aufgabe
|
||||||
|
- **Arbeit:** Recht auf Arbeit, gerechte Verteilung, starke Gewerkschaften
|
||||||
|
- **Sozialstaat:** Vorsorgender Sozialstaat, Bildung als Schlüssel
|
||||||
|
- **Nachhaltigkeit:** Ökologische Verantwortung als Teil der Grundwerte
|
||||||
|
- **Globalisierung:** Internationale Solidarität, gerechte Weltwirtschaft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BÜNDNIS 90/DIE GRÜNEN
|
||||||
|
|
||||||
|
### Wahlprogramm NRW 2022 — Kernpositionen
|
||||||
|
|
||||||
|
- **Klimaschutz:** Klimaneutralität deutlich vor 2040, Kohleausstieg 2030
|
||||||
|
- **Energie:** 100% Erneuerbare, Solarpflicht, Windkraftausbau
|
||||||
|
- **Mobilität:** Verkehrswende, 365€-Ticket, Fahrradland NRW
|
||||||
|
- **Demokratie:** Bürger:innenräte, Absenkung Wahlalter
|
||||||
|
- **Wirtschaft:** Gemeinwohlorientierung, regionale Wertschöpfung
|
||||||
|
- **Naturschutz:** 30% Naturschutzfläche
|
||||||
|
|
||||||
|
### Grundsatzprogramm 2020 — Leitideen
|
||||||
|
|
||||||
|
- **Ökologie:** Klimaschutz als Menschheitsaufgabe, planetare Grenzen
|
||||||
|
- **Demokratie:** Lebendige Demokratie, Partizipation, Bürger:innenbeteiligung
|
||||||
|
- **Gerechtigkeit:** Sozial-ökologische Transformation, Teilhabe für alle
|
||||||
|
- **Selbstbestimmung:** Individuelle Freiheit, Vielfalt, Emanzipation
|
||||||
|
- **Frieden:** Gewaltfreiheit, internationale Verantwortung
|
||||||
|
- **Europäische Einigung:** Föderales Europa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FDP
|
||||||
|
|
||||||
|
### Wahlprogramm NRW 2022 — Kernpositionen
|
||||||
|
|
||||||
|
- **Digitalisierung:** Digitales Musterland, E-Government
|
||||||
|
- **Bildung:** Weltbeste Bildung, MINT-Förderung, digitale Schulen
|
||||||
|
- **Wirtschaft:** Bürokratieabbau, Startup-Förderung, Entlastung
|
||||||
|
- **Klimaschutz:** Technologieoffenheit, Emissionshandel, kein Verbote
|
||||||
|
- **Mobilität:** Technologieoffenheit, Infrastrukturausbau
|
||||||
|
|
||||||
|
### Grundsatzprogramm 2012 — Leitideen
|
||||||
|
|
||||||
|
- **Freiheit:** Individuelle Freiheit als höchster Wert
|
||||||
|
- **Verantwortung:** Eigenverantwortung vor Staatsverantwortung
|
||||||
|
- **Chancen:** Chancengerechtigkeit, Aufstieg durch Leistung
|
||||||
|
- **Marktwirtschaft:** Freie Marktwirtschaft, Wettbewerb, Eigentum
|
||||||
|
- **Rechtsstaat:** Bürgerrechte, Datenschutz, schlanker Staat
|
||||||
|
- **Bildung:** Bildung als Bürgerrecht, Vielfalt der Bildungswege
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AfD
|
||||||
|
|
||||||
|
### Wahlprogramm NRW 2022 — Kernpositionen
|
||||||
|
|
||||||
|
- **Migration:** Strikte Begrenzung, Abschiebungen, "Remigration"
|
||||||
|
- **Energie:** Kernkraft, Kohle behalten, gegen Windkraft
|
||||||
|
- **Sicherheit:** Mehr Polizei, härtere Strafen
|
||||||
|
- **Corona:** Gegen Maßnahmen, keine Impfpflicht
|
||||||
|
- **Bildung:** Leistungsprinzip, gegen "Gendersprache"
|
||||||
|
|
||||||
|
### Grundsatzprogramm 2016 — Leitideen
|
||||||
|
|
||||||
|
- **Demokratie:** Direkte Demokratie, Volksabstimmungen
|
||||||
|
- **Nation:** Nationale Souveränität, EU-Kritik, Euro-Ausstieg
|
||||||
|
- **Familie:** Traditionelles Familienbild, gegen "Gender-Ideologie"
|
||||||
|
- **Einwanderung:** Strikte Kontrolle, kulturelle Integration
|
||||||
|
- **Wirtschaft:** Soziale Marktwirtschaft, gegen Subventionen
|
||||||
|
- **Energie:** Gegen Energiewende, für Kernkraft und Kohle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewertungsskala für Programmtreue
|
||||||
|
|
||||||
|
| Score | Bedeutung |
|
||||||
|
|-------|-----------|
|
||||||
|
| **9-10** | Vollständige Übereinstimmung, könnte aus dem Programm stammen |
|
||||||
|
| **7-8** | Hohe Übereinstimmung, unterstützt Kernziele |
|
||||||
|
| **5-6** | Partielle Übereinstimmung, keine Widersprüche |
|
||||||
|
| **3-4** | Geringe Übereinstimmung, marginaler Bezug |
|
||||||
|
| **1-2** | Widerspricht Teilaspekten des Programms |
|
||||||
|
| **0** | Vollständiger Widerspruch zu Kernpositionen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateien im Kontext-Ordner
|
||||||
|
|
||||||
|
- `cdu-grundsatzprogramm-2024.pdf` — CDU "In Freiheit leben"
|
||||||
|
- `spd-hamburger-programm-2007.pdf` — SPD Hamburger Programm
|
||||||
|
- `gruene-grundsatzprogramm-2020.pdf` — Grüne "...zu achten und zu schützen..."
|
||||||
|
- `fdp-grundsatzprogramm-2012.pdf` — FDP "Verantwortung für die Freiheit"
|
||||||
|
- `afd-grundsatzprogramm-2016.pdf` — AfD "Programm für Deutschland"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stand: März 2026*
|
||||||
5274
app/kontext/spd-nrw-2022-paged.txt
Normal file
5274
app/kontext/spd-nrw-2022-paged.txt
Normal file
File diff suppressed because it is too large
Load Diff
266
app/kontext/wahlprogramme-nrw-2022.md
Normal file
266
app/kontext/wahlprogramme-nrw-2022.md
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
# Wahlprogramme NRW 2022 — Detailreferenz
|
||||||
|
|
||||||
|
*Für präzise Bewertung der Wahlprogrammtreue bei NRW-Landtagsanträgen*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CDU NRW 2022: "NRW. Gemeinsam. Gestalten."
|
||||||
|
|
||||||
|
### Wirtschaft & Arbeit
|
||||||
|
- **Bürokratieabbau:** "Bürokratiebremse" – für jede neue Regelung eine streichen
|
||||||
|
- **Mittelstand:** Förderprogramme, Fachkräftesicherung, Ausbildungsoffensive
|
||||||
|
- **Digitalisierung:** Gigabit-Ausbau, 5G flächendeckend, digitale Verwaltung
|
||||||
|
- **Gründer:** Startup-Land NRW, Risikokapital, Gründerstipendien
|
||||||
|
- **Tariftreue:** Keine gesetzliche Tariftreuepflicht (Vertragsfreiheit)
|
||||||
|
|
||||||
|
### Bildung & Wissenschaft
|
||||||
|
- **Lehrkräfte:** 10.000 neue Stellen, Quereinsteiger erleichtern
|
||||||
|
- **Digitale Schule:** Tablets für alle, IT-Ausstattung, Informatik Pflichtfach
|
||||||
|
- **Talentschulen:** Mehr Förderung in Brennpunktvierteln
|
||||||
|
- **Kitas:** Qualitätsoffensive, flexible Betreuungszeiten
|
||||||
|
- **Hochschulen:** Exzellenzstrategie, Forschungsförderung
|
||||||
|
|
||||||
|
### Innere Sicherheit
|
||||||
|
- **Polizei:** 3.000 neue Stellen, bessere Ausstattung, Bodycams
|
||||||
|
- **Null-Toleranz:** Konsequente Strafverfolgung, schnelle Verfahren
|
||||||
|
- **Clankriminalität:** Spezielle Ermittlungsgruppen, Vermögensabschöpfung
|
||||||
|
- **Justiz:** Mehr Richter, digitale Gerichte
|
||||||
|
|
||||||
|
### Umwelt & Klima
|
||||||
|
- **Klimaneutralität:** 2045 (nicht früher), Technologieoffenheit
|
||||||
|
- **Wasserstoff:** NRW als Wasserstoffland, Infrastrukturausbau
|
||||||
|
- **Wald:** 10.000 ha Aufforstung, klimaresistenter Wald
|
||||||
|
- **Energieeffizienz:** Gebäudesanierung fördern, nicht erzwingen
|
||||||
|
- **Windkraft:** Ja, aber mit Abstandsregelungen (1000m)
|
||||||
|
|
||||||
|
### Mobilität & Infrastruktur
|
||||||
|
- **Straßen:** Sanierung Landesstraßen, Engpassbeseitigung Autobahnen
|
||||||
|
- **ÖPNV:** Ausbau RRX, mehr Taktung, barrierefreie Bahnhöfe
|
||||||
|
- **Radwege:** Radschnellwege fördern, aber kein Vorrang vor Straße
|
||||||
|
- **E-Mobilität:** Ladeinfrastruktur, Förderung E-Autos
|
||||||
|
|
||||||
|
### Soziales & Gesundheit
|
||||||
|
- **Pflege:** Landespflegekammer, Fachkräfteoffensive
|
||||||
|
- **Krankenhäuser:** Standortsicherung, Investitionsprogramm
|
||||||
|
- **Familie:** Familienzentren, Betreuungsgeld
|
||||||
|
- **Ehrenamt:** Ehrenamtskarte, Bürokratieabbau für Vereine
|
||||||
|
|
||||||
|
### Besondere Positionen
|
||||||
|
- **Eigentumsschutz:** Gegen Enteignungen, für Investitionsanreize
|
||||||
|
- **Leistungsprinzip:** Bildungsaufstieg durch Leistung, gegen Gleichmacherei
|
||||||
|
- **Sicherheit vor Freiheitseinschränkung:** Balance, aber Sicherheit Priorität
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPD NRW 2022: "NRW. Für euch. Für Dich."
|
||||||
|
|
||||||
|
### Wirtschaft & Arbeit
|
||||||
|
- **Tariftreue:** Gesetzliche Tariftreuepflicht bei öffentlicher Vergabe
|
||||||
|
- **Landesmindestlohn:** 13€ für Landesbeschäftigte und Auftragnehmer
|
||||||
|
- **Transformation:** Aktive Industriepolitik, Kohleausstieg sozial gestalten
|
||||||
|
- **Gute Arbeit:** Befristungen eindämmen, Werkverträge regulieren
|
||||||
|
- **Mitbestimmung:** Stärkung Betriebsräte, Tarifbindung erhöhen
|
||||||
|
|
||||||
|
### Bildung & Wissenschaft
|
||||||
|
- **Gebührenfrei:** Komplette Bildungskette gebührenfrei (Kita bis Master)
|
||||||
|
- **Ganztagsschule:** Rechtsanspruch, rhythmisierter Ganztag
|
||||||
|
- **Chancengleichheit:** Längeres gemeinsames Lernen, keine Schulformempfehlung
|
||||||
|
- **Schulsozialarbeit:** Deutlicher Ausbau, feste Stellen
|
||||||
|
- **Inklusion:** Konsequent umsetzen, Ressourcen bereitstellen
|
||||||
|
|
||||||
|
### Innere Sicherheit
|
||||||
|
- **Polizei:** Mehr Stellen, bessere Ausbildung, interkulturelle Kompetenz
|
||||||
|
- **Prävention:** Mehr Jugendarbeit, Ausstiegsprogramme
|
||||||
|
- **Rechtsextremismus:** Schwerpunkt Bekämpfung, Verfassungsschutz reformieren
|
||||||
|
- **Kennzeichnungspflicht:** Individuelle Kennung für Polizeibeamte
|
||||||
|
|
||||||
|
### Umwelt & Klima
|
||||||
|
- **Klimaneutralität:** 2040 (schneller als CDU)
|
||||||
|
- **Kohleausstieg:** Beschleunigen, aber sozial absichern
|
||||||
|
- **Erneuerbare:** Massive Beschleunigung Windkraft, Photovoltaikpflicht
|
||||||
|
- **Naturschutz:** Mehr Schutzgebiete, Biotopvernetzung
|
||||||
|
- **ÖPNV:** 365€-Ticket, kostenloser ÖPNV für Schüler/Azubis
|
||||||
|
|
||||||
|
### Wohnen
|
||||||
|
- **Neubau:** 100.000 neue Wohnungen, öffentlicher Wohnungsbau
|
||||||
|
- **Mieten:** Mietpreisbremse verschärfen, Mietendeckel prüfen
|
||||||
|
- **Sozialwohnungen:** Bindungsfristen verlängern, mehr Förderung
|
||||||
|
- **Bodenrecht:** Kommunales Vorkaufsrecht stärken
|
||||||
|
|
||||||
|
### Soziales & Gesundheit
|
||||||
|
- **Pflege:** Flächentarifvertrag, mehr Personal, bessere Bezahlung
|
||||||
|
- **Krankenhäuser:** Keine Privatisierung, Daseinsvorsorge
|
||||||
|
- **Gesundheitszentren:** Medizinische Versorgungszentren im ländlichen Raum
|
||||||
|
- **Kinderarmut:** Bekämpfung als Schwerpunkt, Kindergrundsicherung
|
||||||
|
|
||||||
|
### Besondere Positionen
|
||||||
|
- **Vermögenssteuer:** Auf Bundesebene einsetzen
|
||||||
|
- **Umverteilung:** Reichere stärker belasten, Entlastung für Normalverdiener
|
||||||
|
- **Öffentlicher Dienst:** Stärken, nicht auslagern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BÜNDNIS 90/DIE GRÜNEN NRW 2022: "Wirtschaft und Klima zusammen denken"
|
||||||
|
|
||||||
|
### Wirtschaft & Arbeit
|
||||||
|
- **Transformation:** Grüne Industriepolitik, klimaneutrale Wirtschaft
|
||||||
|
- **Gemeinwohl:** Gemeinwohlökonomie fördern, alternative Wirtschaftsmodelle
|
||||||
|
- **Gute Arbeit:** Tariftreue, faire Löhne, Arbeitszeitverkürzung ermöglichen
|
||||||
|
- **Startups:** Grüne Gründungen, Social Entrepreneurship
|
||||||
|
- **Regionale Wirtschaft:** Lokale Wertschöpfungsketten stärken
|
||||||
|
|
||||||
|
### Bildung & Wissenschaft
|
||||||
|
- **Gebührenfrei:** Bildung darf nichts kosten
|
||||||
|
- **Inklusion:** Inklusive Schulen, multiprofessionelle Teams
|
||||||
|
- **Digitalisierung:** Datenschutzkonform, medienkompetent
|
||||||
|
- **Lehrkräfte:** Bessere Arbeitsbedingungen, kleinere Klassen
|
||||||
|
- **Demokratiebildung:** Schule als demokratischer Lernort
|
||||||
|
|
||||||
|
### Umwelt & Klima (KERNTHEMA)
|
||||||
|
- **Klimaneutralität:** Deutlich vor 2040, Sektorziele
|
||||||
|
- **Kohleausstieg:** 2030, nicht später
|
||||||
|
- **Erneuerbare:** 100%, Solarpflicht auf Dächern, 2% Landesfläche Wind
|
||||||
|
- **Naturschutz:** 30% Landesfläche unter Schutz
|
||||||
|
- **Biodiversität:** Artenvielfalt sichern, Pestizidreduktion
|
||||||
|
- **Kreislaufwirtschaft:** Ressourcenschonung, Mehrweg, Reparatur
|
||||||
|
|
||||||
|
### Mobilität (KERNTHEMA)
|
||||||
|
- **Verkehrswende:** Vorrang für Fuß, Rad, ÖPNV
|
||||||
|
- **365€-Ticket:** Bezahlbarer ÖPNV für alle
|
||||||
|
- **Fahrradland NRW:** Radschnellwege, sichere Radinfrastruktur
|
||||||
|
- **Autoverkehr:** Reduzieren, Tempo 30 innerorts als Regel
|
||||||
|
- **E-Mobilität:** Fördern, aber Fokus auf Verkehrsvermeidung
|
||||||
|
|
||||||
|
### Demokratie & Partizipation
|
||||||
|
- **Bürger:innenräte:** Institutionalisieren, losbasiert
|
||||||
|
- **Wahlalter:** Absenken auf 16 (Landtag) und 14 (Kommune)
|
||||||
|
- **Transparenz:** Open Government, Lobbyregister
|
||||||
|
- **Vielfalt:** Antidiskriminierung, Diversity in Verwaltung
|
||||||
|
|
||||||
|
### Soziales
|
||||||
|
- **Kinderrechte:** In Landesverfassung verankern
|
||||||
|
- **Pflege:** Gute Arbeitsbedingungen, Fachkräfteoffensive
|
||||||
|
- **Inklusion:** Barrierefreiheit konsequent umsetzen
|
||||||
|
- **Geschlechtergerechtigkeit:** Parität, Equal Pay
|
||||||
|
|
||||||
|
### Besondere Positionen
|
||||||
|
- **Postwachstum:** Wirtschaftswachstum nicht als Selbstzweck
|
||||||
|
- **Suffizi:** Weniger Verbrauch, bewusster Konsum
|
||||||
|
- **Bürger:innenenergie:** Dezentrale, demokratische Energieversorgung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FDP NRW 2022: "Nie gab es mehr zu tun"
|
||||||
|
|
||||||
|
### Wirtschaft & Arbeit
|
||||||
|
- **Entlastung:** Steuern senken, Abgaben reduzieren
|
||||||
|
- **Bürokratieabbau:** Radikal entrümpeln, Genehmigungen beschleunigen
|
||||||
|
- **Digitalisierung:** Digitales Musterland, volldigitale Verwaltung
|
||||||
|
- **Gründer:** Beste Bedingungen für Startups, Risikokapital
|
||||||
|
- **Technologieoffenheit:** Keine Verbote, Markt entscheiden lassen
|
||||||
|
|
||||||
|
### Bildung (KERNTHEMA)
|
||||||
|
- **Weltbeste Bildung:** Anspruch auf internationale Spitze
|
||||||
|
- **MINT:** Massive Förderung, Informatik als Pflichtfach
|
||||||
|
- **Individuelle Förderung:** Begabtenförderung, kein Einheitsbrei
|
||||||
|
- **Schulvielfalt:** Differenziertes Schulsystem erhalten
|
||||||
|
- **Digitale Bildung:** Tablets, Cloud, modernste Ausstattung
|
||||||
|
|
||||||
|
### Umwelt & Klima
|
||||||
|
- **Emissionshandel:** Marktbasierter Klimaschutz, CO2-Preis
|
||||||
|
- **Technologieoffenheit:** Alle Technologien, auch Kernkraft prüfen
|
||||||
|
- **Innovation:** Klimaschutz durch Fortschritt, nicht Verzicht
|
||||||
|
- **Gegen Verbote:** Keine Fahrverbote, keine Heizungsvorschriften
|
||||||
|
- **Wasserstoff:** Schlüsseltechnologie, Infrastruktur aufbauen
|
||||||
|
|
||||||
|
### Mobilität
|
||||||
|
- **Technologieoffenheit:** E-Fuels, Wasserstoff, keine Verbrenner-Verbote
|
||||||
|
- **Infrastruktur:** Straßen und Schiene ausbauen
|
||||||
|
- **ÖPNV:** Attraktiver machen, aber nicht zu Lasten Individualverkehr
|
||||||
|
- **Flugtaxis:** Urban Air Mobility fördern
|
||||||
|
|
||||||
|
### Demokratie & Bürgerrechte
|
||||||
|
- **Bürgerrechte:** Datenschutz, Privatsphäre, gegen Überwachung
|
||||||
|
- **Eigenverantwortung:** Weniger Staat, mehr individuelle Freiheit
|
||||||
|
- **Gegen Bevormundung:** Keine Gendersprache-Vorschriften, keine Verbote
|
||||||
|
|
||||||
|
### Besondere Positionen
|
||||||
|
- **Eigentumsschutz:** Gegen Enteignungen, für Investitionsanreize
|
||||||
|
- **Leistungsprinzip:** Aufstieg durch Leistung, gegen Gleichmacherei
|
||||||
|
- **Privat vor Staat:** Privatwirtschaft effizienter als öffentliche Hand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AfD NRW 2022
|
||||||
|
|
||||||
|
### Wirtschaft
|
||||||
|
- **Subventionen:** Gegen staatliche Eingriffe in Wirtschaft
|
||||||
|
- **Mittelstand:** Fördern durch Entlastung, nicht Subventionen
|
||||||
|
- **Arbeitsmarkt:** Regulierung reduzieren
|
||||||
|
- **Globalisierung:** Kritisch, nationale Wirtschaft stärken
|
||||||
|
|
||||||
|
### Bildung
|
||||||
|
- **Leistungsprinzip:** Differenziertes Schulsystem, keine Einheitsschule
|
||||||
|
- **Gegen "Ideologie":** Keine "Gender-Sprache", keine "Klimaideologie"
|
||||||
|
- **Disziplin:** Ordnung in Schulen, Respekt vor Lehrern
|
||||||
|
- **Deutsche Kultur:** Kulturelle Bildung, deutsche Geschichte
|
||||||
|
|
||||||
|
### Energie (KERNTHEMA)
|
||||||
|
- **Kernkraft:** Laufzeitverlängerung, neue Kraftwerke
|
||||||
|
- **Kohle:** Braunkohle erhalten, Versorgungssicherheit
|
||||||
|
- **Gegen Energiewende:** "Planwirtschaft", unwirtschaftlich
|
||||||
|
- **Gegen Windkraft:** Landschaftszerstörung, Wertverlust
|
||||||
|
|
||||||
|
### Migration (KERNTHEMA)
|
||||||
|
- **Begrenzung:** Massive Reduzierung Zuwanderung
|
||||||
|
- **Abschiebungen:** Konsequent durchsetzen
|
||||||
|
- **Integration:** Assimilation fordern, deutsche Leitkultur
|
||||||
|
- **"Remigration":** Rückführung abgelehnter Asylbewerber
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- **Mehr Polizei:** Deutlich aufstocken
|
||||||
|
- **Härtere Strafen:** Strafverschärfungen, schnelle Verfahren
|
||||||
|
- **Grenzkontrollen:** An allen Grenzen
|
||||||
|
|
||||||
|
### Besondere Positionen
|
||||||
|
- **EU-Kritik:** "Brüsseler Bürokratie", nationale Souveränität
|
||||||
|
- **Direkte Demokratie:** Volksabstimmungen auf Landesebene
|
||||||
|
- **Gegen Corona-Maßnahmen:** Keine Impfpflicht, Maßnahmen beenden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnell-Referenz: Kernkonflikte
|
||||||
|
|
||||||
|
| Thema | CDU/FDP | SPD/GRÜNE | AfD |
|
||||||
|
|-------|---------|-----------|-----|
|
||||||
|
| **Klima-Tempo** | 2045, technologieoffen | vor 2040, Erneuerbare | gegen Energiewende |
|
||||||
|
| **Kohleausstieg** | schrittweise | 2030 | gar nicht |
|
||||||
|
| **Tariftreue** | freiwillig | gesetzlich | - |
|
||||||
|
| **Vermögensteuer** | nein | ja | nein |
|
||||||
|
| **ÖPNV** | Ausbau | Vorrang vor Auto | kein Vorrang |
|
||||||
|
| **Migration** | gesteuert | humanitär | stark begrenzt |
|
||||||
|
| **Bildung** | differenziert | inklusiv | "leistungsorientiert" |
|
||||||
|
| **Windkraft** | mit Abstand | Beschleunigung | dagegen |
|
||||||
|
| **Bürgerräte** | skeptisch | dafür | Volksabstimmungen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regierungskoalition 2022-2027: CDU + GRÜNE
|
||||||
|
|
||||||
|
**Koalitionsvertrag "Zukunftsvertrag"** — wichtige Kompromisse:
|
||||||
|
|
||||||
|
- Klimaneutralität: 2045 (CDU) mit ambitioniertem Pfad (Grüne)
|
||||||
|
- Kohle: Kein festes Datum, aber "so früh wie möglich"
|
||||||
|
- Windkraft: Ausbau beschleunigen, aber Akzeptanz sichern
|
||||||
|
- Mobilität: ÖPNV stärken UND Straßen erhalten
|
||||||
|
- Bildung: Qualitätsoffensive, keine Strukturreform
|
||||||
|
- Sicherheit: Mehr Polizei, aber auch Prävention
|
||||||
|
|
||||||
|
**Bei Oppositionsanträgen prüfen:**
|
||||||
|
- Würde CDU zustimmen? (Wirtschaft, Sicherheit, Pragmatismus)
|
||||||
|
- Würden Grüne zustimmen? (Klima, Soziales, Demokratie)
|
||||||
|
- Koalitions-Kompromisslinie beachten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stand: März 2026*
|
||||||
575
app/main.py
Normal file
575
app/main.py
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
"""GWÖ-Antragsprüfer — FastAPI Webapp."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, UploadFile, Request, BackgroundTasks, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .database import (
|
||||||
|
init_db, get_job, create_job, update_job,
|
||||||
|
get_all_assessments, get_assessment, upsert_assessment, import_json_assessments,
|
||||||
|
search_assessments
|
||||||
|
)
|
||||||
|
from .parlamente import get_adapter, ADAPTERS
|
||||||
|
from .analyzer import analyze_antrag
|
||||||
|
from .report import generate_html_report, generate_pdf_report
|
||||||
|
from .embeddings import (
|
||||||
|
init_embeddings_db, get_programme_info, get_indexing_status,
|
||||||
|
index_programm, PROGRAMME
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version=settings.app_version,
|
||||||
|
docs_url=None, # Disable /docs in production
|
||||||
|
redoc_url=None, # Disable /redoc in production
|
||||||
|
openapi_url=None, # Disable /openapi.json in production
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Security Headers Middleware
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||||
|
# CSP: Allow self, inline styles (for templates), and PDF viewer
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"frame-ancestors 'none';"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
# Setup directories
|
||||||
|
settings.data_dir.mkdir(exist_ok=True)
|
||||||
|
settings.reports_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Static files and templates
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
templates_dir = Path(__file__).parent / "templates"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
templates_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
templates = Jinja2Templates(directory=str(templates_dir))
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
await init_db()
|
||||||
|
init_embeddings_db()
|
||||||
|
# JSON import disabled - all assessments now live in SQLite DB only
|
||||||
|
# Legacy import would overwrite new v5 assessments with old format
|
||||||
|
# count = await import_json_assessments(settings.data_dir / "assessments")
|
||||||
|
# if count > 0:
|
||||||
|
# print(f"Imported {count} assessments from JSON files")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request):
|
||||||
|
"""Landing page with upload form."""
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"bundeslaender": [
|
||||||
|
{"code": "NRW", "name": "Nordrhein-Westfalen", "active": True},
|
||||||
|
{"code": "BY", "name": "Bayern", "active": False},
|
||||||
|
{"code": "BW", "name": "Baden-Württemberg", "active": False},
|
||||||
|
{"code": "HE", "name": "Hessen", "active": False},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/analyze")
|
||||||
|
async def start_analysis(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
text: Optional[str] = Form(None),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
bundesland: str = Form("NRW"),
|
||||||
|
model: str = Form("qwen-plus"),
|
||||||
|
):
|
||||||
|
"""Start analysis job."""
|
||||||
|
if not text and not file:
|
||||||
|
raise HTTPException(status_code=400, detail="Entweder Text oder PDF-Datei erforderlich")
|
||||||
|
|
||||||
|
# Extract text from PDF if uploaded
|
||||||
|
if file and file.filename:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
text = ""
|
||||||
|
for page in doc:
|
||||||
|
text += page.get_text()
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
# Create job
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
await create_job(job_id, text[:500], bundesland, model)
|
||||||
|
|
||||||
|
# Start background analysis
|
||||||
|
background_tasks.add_task(run_analysis, job_id, text, bundesland, model)
|
||||||
|
|
||||||
|
return JSONResponse({"job_id": job_id, "status": "queued"})
|
||||||
|
|
||||||
|
|
||||||
|
async def run_analysis(job_id: str, text: str, bundesland: str, model: str):
|
||||||
|
"""Background task for analysis."""
|
||||||
|
try:
|
||||||
|
await update_job(job_id, status="processing")
|
||||||
|
|
||||||
|
# Run LLM analysis
|
||||||
|
assessment = await analyze_antrag(text, bundesland, model)
|
||||||
|
|
||||||
|
# Generate reports
|
||||||
|
html_path = settings.reports_dir / f"{job_id}.html"
|
||||||
|
pdf_path = settings.reports_dir / f"{job_id}.pdf"
|
||||||
|
|
||||||
|
await generate_html_report(assessment, html_path)
|
||||||
|
await generate_pdf_report(assessment, pdf_path)
|
||||||
|
|
||||||
|
await update_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
result=assessment.model_dump_json(),
|
||||||
|
html_path=str(html_path),
|
||||||
|
pdf_path=str(pdf_path),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await update_job(job_id, status="failed", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status/{job_id}")
|
||||||
|
async def get_status(job_id: str):
|
||||||
|
"""Get job status."""
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
return JSONResponse({
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": job["status"],
|
||||||
|
"created_at": job["created_at"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/result/{job_id}", response_class=HTMLResponse)
|
||||||
|
async def get_result(request: Request, job_id: str):
|
||||||
|
"""Get analysis result as HTML."""
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
if job["status"] != "completed":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
|
||||||
|
|
||||||
|
html_path = Path(job["html_path"])
|
||||||
|
if html_path.exists():
|
||||||
|
return HTMLResponse(html_path.read_text())
|
||||||
|
|
||||||
|
raise HTTPException(status_code=500, detail="Report nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/result/{job_id}/pdf")
|
||||||
|
async def get_pdf(job_id: str):
|
||||||
|
"""Download PDF report."""
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
if job["status"] != "completed":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
|
||||||
|
|
||||||
|
pdf_path = Path(job["pdf_path"])
|
||||||
|
if pdf_path.exists():
|
||||||
|
return FileResponse(
|
||||||
|
pdf_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=f"gwoe-bericht-{job_id[:8]}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=500, detail="PDF nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
# API: Load assessments from database
|
||||||
|
@app.get("/api/assessments")
|
||||||
|
async def list_assessments():
|
||||||
|
"""Return all assessments from database."""
|
||||||
|
rows = await get_all_assessments()
|
||||||
|
|
||||||
|
# Convert DB format to frontend format
|
||||||
|
assessments = []
|
||||||
|
for row in rows:
|
||||||
|
assessments.append({
|
||||||
|
"drucksache": row.get("drucksache"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"fraktionen": row.get("fraktionen", []),
|
||||||
|
"datum": row.get("datum"),
|
||||||
|
"link": row.get("link"),
|
||||||
|
"gwoeScore": row.get("gwoe_score"),
|
||||||
|
"gwoeBegründung": row.get("gwoe_begruendung"),
|
||||||
|
"gwoeMatrix": row.get("gwoe_matrix", []),
|
||||||
|
"gwoeSchwerpunkt": row.get("gwoe_schwerpunkt", []),
|
||||||
|
"wahlprogrammScores": row.get("wahlprogramm_scores", []),
|
||||||
|
"verbesserungen": row.get("verbesserungen", []),
|
||||||
|
"stärken": row.get("staerken", []),
|
||||||
|
"schwächen": row.get("schwaechen", []),
|
||||||
|
"empfehlung": row.get("empfehlung"),
|
||||||
|
"empfehlungSymbol": row.get("empfehlung_symbol"),
|
||||||
|
"verbesserungspotenzial": row.get("verbesserungspotenzial"),
|
||||||
|
"themen": row.get("themen", []),
|
||||||
|
"antragZusammenfassung": row.get("antrag_zusammenfassung"),
|
||||||
|
"antragKernpunkte": row.get("antrag_kernpunkte", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return assessments
|
||||||
|
|
||||||
|
|
||||||
|
# API: Get single assessment (use query param for drucksache with /)
|
||||||
|
@app.get("/api/assessment")
|
||||||
|
async def get_single_assessment(drucksache: str):
|
||||||
|
"""Get a single assessment by drucksache ID."""
|
||||||
|
row = await get_assessment(drucksache)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Assessment nicht gefunden")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"drucksache": row.get("drucksache"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"fraktionen": row.get("fraktionen", []),
|
||||||
|
"datum": row.get("datum"),
|
||||||
|
"link": row.get("link"),
|
||||||
|
"gwoeScore": row.get("gwoe_score"),
|
||||||
|
"gwoeBegründung": row.get("gwoe_begruendung"),
|
||||||
|
"gwoeMatrix": row.get("gwoe_matrix", []),
|
||||||
|
"gwoeSchwerpunkt": row.get("gwoe_schwerpunkt", []),
|
||||||
|
"wahlprogrammScores": row.get("wahlprogramm_scores", []),
|
||||||
|
"verbesserungen": row.get("verbesserungen", []),
|
||||||
|
"stärken": row.get("staerken", []),
|
||||||
|
"schwächen": row.get("schwaechen", []),
|
||||||
|
"empfehlung": row.get("empfehlung"),
|
||||||
|
"empfehlungSymbol": row.get("empfehlung_symbol"),
|
||||||
|
"verbesserungspotenzial": row.get("verbesserungspotenzial"),
|
||||||
|
"themen": row.get("themen", []),
|
||||||
|
"antragZusammenfassung": row.get("antrag_zusammenfassung"),
|
||||||
|
"antragKernpunkte": row.get("antrag_kernpunkte", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# API: Generate PDF on demand for an assessment
|
||||||
|
@app.get("/api/assessment/pdf")
|
||||||
|
async def download_assessment_pdf(drucksache: str):
|
||||||
|
"""Generate and download PDF for an assessment."""
|
||||||
|
from .models import Assessment
|
||||||
|
|
||||||
|
row = await get_assessment(drucksache)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Assessment nicht gefunden")
|
||||||
|
|
||||||
|
# Check if PDF already exists
|
||||||
|
safe_name = drucksache.replace("/", "-")
|
||||||
|
pdf_path = settings.reports_dir / f"{safe_name}.pdf"
|
||||||
|
|
||||||
|
if not pdf_path.exists():
|
||||||
|
# Convert DB row to Assessment model for report generation
|
||||||
|
assessment_data = {
|
||||||
|
"drucksache": row.get("drucksache"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"fraktionen": row.get("fraktionen", []),
|
||||||
|
"datum": row.get("datum"),
|
||||||
|
"link": row.get("link"),
|
||||||
|
"gwoe_score": row.get("gwoe_score") or 0,
|
||||||
|
"gwoe_begruendung": row.get("gwoe_begruendung") or "",
|
||||||
|
"gwoe_matrix": row.get("gwoe_matrix", []),
|
||||||
|
"gwoe_schwerpunkt": row.get("gwoe_schwerpunkt", []),
|
||||||
|
"wahlprogramm_scores": row.get("wahlprogramm_scores", []),
|
||||||
|
"verbesserungen": row.get("verbesserungen", []),
|
||||||
|
"staerken": row.get("staerken", []),
|
||||||
|
"schwaechen": row.get("schwaechen", []),
|
||||||
|
"empfehlung": row.get("empfehlung") or "",
|
||||||
|
"empfehlung_symbol": row.get("empfehlung_symbol") or "",
|
||||||
|
"verbesserungspotenzial": row.get("verbesserungspotenzial") or "",
|
||||||
|
"themen": row.get("themen", []),
|
||||||
|
"antrag_zusammenfassung": row.get("antrag_zusammenfassung") or "",
|
||||||
|
"antrag_kernpunkte": row.get("antrag_kernpunkte", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
assessment = Assessment(**assessment_data)
|
||||||
|
await generate_pdf_report(assessment, pdf_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
pdf_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=f"gwoe-{safe_name}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# API: Search internal DB only
|
||||||
|
@app.get("/api/search")
|
||||||
|
async def search_internal(
|
||||||
|
q: str,
|
||||||
|
bundesland: str = "NRW",
|
||||||
|
limit: int = 50
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search internal assessments database only.
|
||||||
|
"""
|
||||||
|
db_results = await search_assessments(q, bundesland, limit)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in db_results:
|
||||||
|
results.append({
|
||||||
|
"drucksache": row.get("drucksache"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"fraktionen": row.get("fraktionen", []),
|
||||||
|
"datum": row.get("datum"),
|
||||||
|
"link": row.get("link"),
|
||||||
|
"bundesland": bundesland,
|
||||||
|
"gwoeScore": row.get("gwoe_score"),
|
||||||
|
"themen": row.get("themen", []),
|
||||||
|
"status": "checked",
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# API: Search external parliament portal (Landtag)
|
||||||
|
@app.get("/api/search-landtag")
|
||||||
|
async def search_landtag(
|
||||||
|
q: str,
|
||||||
|
bundesland: str = "NRW",
|
||||||
|
limit: int = 20
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search external parliament portal (e.g., NRW OPAL).
|
||||||
|
Returns results that can be analyzed with "Jetzt prüfen".
|
||||||
|
"""
|
||||||
|
adapter = get_adapter(bundesland)
|
||||||
|
if not adapter:
|
||||||
|
return {"error": f"Bundesland {bundesland} noch nicht unterstützt"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
external = await adapter.search(q, limit)
|
||||||
|
results = []
|
||||||
|
for doc in external:
|
||||||
|
results.append({
|
||||||
|
"drucksache": doc.drucksache,
|
||||||
|
"title": doc.title,
|
||||||
|
"fraktionen": doc.fraktionen,
|
||||||
|
"datum": doc.datum,
|
||||||
|
"link": doc.link,
|
||||||
|
"bundesland": bundesland,
|
||||||
|
"typ": doc.typ,
|
||||||
|
"gwoeScore": None,
|
||||||
|
"status": "unchecked",
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Landtag search error: {e}")
|
||||||
|
return {"error": f"Suchfehler: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
# API: Analyze a document from parliament portal
|
||||||
|
@app.post("/api/analyze-drucksache")
|
||||||
|
async def analyze_drucksache(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
drucksache: str = Form(...),
|
||||||
|
bundesland: str = Form("NRW"),
|
||||||
|
model: str = Form("qwen-plus")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download a document from parliament portal and analyze it.
|
||||||
|
"""
|
||||||
|
# Check if already analyzed
|
||||||
|
existing = await get_assessment(drucksache)
|
||||||
|
if existing:
|
||||||
|
return {"status": "already_checked", "drucksache": drucksache}
|
||||||
|
|
||||||
|
# Get adapter and download
|
||||||
|
adapter = get_adapter(bundesland)
|
||||||
|
if not adapter:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bundesland {bundesland} nicht unterstützt")
|
||||||
|
|
||||||
|
# Download text
|
||||||
|
text = await adapter.download_text(drucksache)
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Dokument {drucksache} nicht gefunden")
|
||||||
|
|
||||||
|
# Get document metadata
|
||||||
|
doc = await adapter.get_document(drucksache)
|
||||||
|
|
||||||
|
# Create job
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
await create_job(job_id, text[:500], bundesland, model)
|
||||||
|
|
||||||
|
# Start background analysis
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_drucksache_analysis,
|
||||||
|
job_id, drucksache, text, bundesland, model, doc
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "queued", "job_id": job_id, "drucksache": drucksache}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_drucksache_analysis(
|
||||||
|
job_id: str,
|
||||||
|
drucksache: str,
|
||||||
|
text: str,
|
||||||
|
bundesland: str,
|
||||||
|
model: str,
|
||||||
|
doc
|
||||||
|
):
|
||||||
|
"""Background task for drucksache analysis."""
|
||||||
|
try:
|
||||||
|
await update_job(job_id, status="processing")
|
||||||
|
|
||||||
|
# Run LLM analysis
|
||||||
|
assessment = await analyze_antrag(text, bundesland, model)
|
||||||
|
|
||||||
|
# Prepare data for DB
|
||||||
|
assessment_data = {
|
||||||
|
"drucksache": drucksache,
|
||||||
|
"title": assessment.title or (doc.title if doc else f"Drucksache {drucksache}"),
|
||||||
|
"fraktionen": assessment.fraktionen,
|
||||||
|
"datum": assessment.datum or (doc.datum if doc else ""),
|
||||||
|
"link": doc.link if doc else "",
|
||||||
|
"bundesland": bundesland,
|
||||||
|
"gwoeScore": assessment.gwoe_score,
|
||||||
|
"gwoeBegründung": assessment.gwoe_begruendung,
|
||||||
|
"gwoeMatrix": [m.model_dump() for m in assessment.gwoe_matrix],
|
||||||
|
"gwoeSchwerpunkt": assessment.gwoe_schwerpunkt,
|
||||||
|
"wahlprogrammScores": [w.model_dump() for w in assessment.wahlprogramm_scores],
|
||||||
|
"verbesserungen": [v.model_dump() for v in assessment.verbesserungen],
|
||||||
|
"stärken": assessment.staerken,
|
||||||
|
"schwächen": assessment.schwaechen,
|
||||||
|
"empfehlung": assessment.empfehlung,
|
||||||
|
"empfehlungSymbol": assessment.empfehlung_symbol,
|
||||||
|
"verbesserungspotenzial": assessment.verbesserungspotenzial,
|
||||||
|
"themen": assessment.themen,
|
||||||
|
"antragZusammenfassung": assessment.antrag_zusammenfassung,
|
||||||
|
"antragKernpunkte": assessment.antrag_kernpunkte,
|
||||||
|
"source": "webapp",
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to DB
|
||||||
|
await upsert_assessment(assessment_data)
|
||||||
|
|
||||||
|
# Generate reports
|
||||||
|
html_path = settings.reports_dir / f"{job_id}.html"
|
||||||
|
pdf_path = settings.reports_dir / f"{job_id}.pdf"
|
||||||
|
|
||||||
|
await generate_html_report(assessment, html_path)
|
||||||
|
await generate_pdf_report(assessment, pdf_path)
|
||||||
|
|
||||||
|
await update_job(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
result=assessment.model_dump_json(),
|
||||||
|
html_path=str(html_path),
|
||||||
|
pdf_path=str(pdf_path),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"ERROR in run_drucksache_analysis for {drucksache}: {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
await update_job(job_id, status="failed", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# API: List available Bundesländer
|
||||||
|
@app.get("/api/bundeslaender")
|
||||||
|
async def list_bundeslaender():
|
||||||
|
"""List available bundesländer with their status."""
|
||||||
|
return [
|
||||||
|
{"code": "NRW", "name": "Nordrhein-Westfalen", "active": True},
|
||||||
|
{"code": "BY", "name": "Bayern", "active": False},
|
||||||
|
{"code": "BW", "name": "Baden-Württemberg", "active": False},
|
||||||
|
{"code": "HE", "name": "Hessen", "active": False},
|
||||||
|
{"code": "NI", "name": "Niedersachsen", "active": False},
|
||||||
|
{"code": "RP", "name": "Rheinland-Pfalz", "active": False},
|
||||||
|
{"code": "SH", "name": "Schleswig-Holstein", "active": False},
|
||||||
|
{"code": "SL", "name": "Saarland", "active": False},
|
||||||
|
{"code": "SN", "name": "Sachsen", "active": False},
|
||||||
|
{"code": "ST", "name": "Sachsen-Anhalt", "active": False},
|
||||||
|
{"code": "TH", "name": "Thüringen", "active": False},
|
||||||
|
{"code": "BB", "name": "Brandenburg", "active": False},
|
||||||
|
{"code": "MV", "name": "Mecklenburg-Vorpommern", "active": False},
|
||||||
|
{"code": "HH", "name": "Hamburg", "active": False},
|
||||||
|
{"code": "HB", "name": "Bremen", "active": False},
|
||||||
|
{"code": "BE", "name": "Berlin", "active": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# === Quellen / Programme ===
|
||||||
|
|
||||||
|
@app.get("/quellen", response_class=HTMLResponse)
|
||||||
|
async def quellen_page(request: Request):
|
||||||
|
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen."""
|
||||||
|
programmes = get_programme_info()
|
||||||
|
status = get_indexing_status()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("quellen.html", {
|
||||||
|
"request": request,
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"programmes": programmes,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/programme")
|
||||||
|
async def list_programme():
|
||||||
|
"""List all available programmes."""
|
||||||
|
return get_programme_info()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/programme/status")
|
||||||
|
async def programme_status():
|
||||||
|
"""Get indexing status of all programmes."""
|
||||||
|
return get_indexing_status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/programme/index")
|
||||||
|
async def index_programme(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
programm_id: str = Form(None),
|
||||||
|
all_programmes: bool = Form(False),
|
||||||
|
):
|
||||||
|
"""Index programme(s) for semantic search."""
|
||||||
|
pdf_dir = static_dir / "referenzen"
|
||||||
|
|
||||||
|
if all_programmes:
|
||||||
|
# Index sequentially to avoid DB locks
|
||||||
|
async def index_all_sequential():
|
||||||
|
for prog_id in PROGRAMME.keys():
|
||||||
|
try:
|
||||||
|
index_programm(prog_id, pdf_dir)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error indexing {prog_id}: {e}")
|
||||||
|
background_tasks.add_task(index_all_sequential)
|
||||||
|
return {"status": "indexing", "programmes": list(PROGRAMME.keys())}
|
||||||
|
|
||||||
|
if programm_id and programm_id in PROGRAMME:
|
||||||
|
background_tasks.add_task(index_programm, programm_id, pdf_dir)
|
||||||
|
return {"status": "indexing", "programm_id": programm_id}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiges Programm")
|
||||||
|
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": settings.app_version}
|
||||||
167
app/models.py
Normal file
167
app/models.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""Python types ported from TypeScript types.ts — GWÖ-Matrix 2.0 für Gemeinden."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# --- Enums ---
|
||||||
|
|
||||||
|
class Empfehlung(str, Enum):
|
||||||
|
ABLEHNEN = "Ablehnen"
|
||||||
|
UEBERARBEITEN = "Überarbeiten"
|
||||||
|
UNTERSTUETZEN_MIT = "Unterstützen mit Änderungen"
|
||||||
|
UNEINGESCHRAENKT = "Uneingeschränkt unterstützen"
|
||||||
|
|
||||||
|
|
||||||
|
class EmpfehlungSymbol(str, Enum):
|
||||||
|
X = "[X]"
|
||||||
|
BANG = "[!]"
|
||||||
|
PLUS = "[+]"
|
||||||
|
DPLUS = "[++]"
|
||||||
|
|
||||||
|
|
||||||
|
class Verbesserungspotenzial(str, Enum):
|
||||||
|
GERING = "gering"
|
||||||
|
MITTEL = "mittel"
|
||||||
|
HOCH = "hoch"
|
||||||
|
FUNDAMENTAL = "fundamental"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sub-models ---
|
||||||
|
|
||||||
|
class MatrixEntry(BaseModel):
|
||||||
|
field: str = Field(..., pattern=r"^[A-E][1-5]$")
|
||||||
|
label: str
|
||||||
|
aspect: str
|
||||||
|
rating: int = Field(..., ge=-5, le=5) # Neue Skala: -5 bis +5
|
||||||
|
symbol: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Zitat(BaseModel):
|
||||||
|
text: str
|
||||||
|
quelle: str
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgrammScore(BaseModel):
|
||||||
|
score: float = Field(..., ge=0, le=10)
|
||||||
|
begruendung: str = Field(..., alias="begründung")
|
||||||
|
zitate: list[Zitat] = Field(default_factory=list)
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FraktionScores(BaseModel):
|
||||||
|
fraktion: str
|
||||||
|
ist_antragsteller: Optional[bool] = Field(None, alias="istAntragsteller")
|
||||||
|
ist_regierung: Optional[bool] = Field(None, alias="istRegierung")
|
||||||
|
wahlprogramm: ProgrammScore
|
||||||
|
parteiprogramm: ProgrammScore
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class Verbesserung(BaseModel):
|
||||||
|
original: str
|
||||||
|
vorschlag: str
|
||||||
|
begruendung: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- Main Assessment ---
|
||||||
|
|
||||||
|
class Assessment(BaseModel):
|
||||||
|
drucksache: str
|
||||||
|
title: str
|
||||||
|
fraktionen: list[str]
|
||||||
|
datum: str
|
||||||
|
link: Optional[str] = None
|
||||||
|
|
||||||
|
gwoe_score: float = Field(..., ge=0, le=10, alias="gwoeScore")
|
||||||
|
gwoe_begruendung: str = Field(..., alias="gwoeBegründung")
|
||||||
|
gwoe_matrix: list[MatrixEntry] = Field(..., alias="gwoeMatrix")
|
||||||
|
gwoe_schwerpunkt: list[str] = Field(..., alias="gwoeSchwerpunkt")
|
||||||
|
|
||||||
|
wahlprogramm_scores: list[FraktionScores] = Field(..., alias="wahlprogrammScores")
|
||||||
|
|
||||||
|
verbesserungen: list[Verbesserung] = []
|
||||||
|
|
||||||
|
staerken: list[str] = Field(default_factory=list, alias="stärken")
|
||||||
|
schwaechen: list[str] = Field(default_factory=list, alias="schwächen")
|
||||||
|
empfehlung: Empfehlung
|
||||||
|
empfehlung_symbol: Optional[str] = Field(None, alias="empfehlungSymbol")
|
||||||
|
verbesserungspotenzial: Verbesserungspotenzial
|
||||||
|
|
||||||
|
themen: list[str] = []
|
||||||
|
antrag_zusammenfassung: Optional[str] = Field(None, alias="antragZusammenfassung")
|
||||||
|
antrag_kernpunkte: Optional[list[str]] = Field(None, alias="antragKernpunkte")
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Matrix constants ---
|
||||||
|
|
||||||
|
MATRIX_LABELS: dict[str, str] = {
|
||||||
|
"A1": "Grundrechtsschutz und Menschenwürde in der Lieferkette",
|
||||||
|
"A2": "Nutzen für die Gemeinde",
|
||||||
|
"A3": "Ökologische Verantwortung für die Lieferkette",
|
||||||
|
"A4": "Soziale Verantwortung für die Lieferkette",
|
||||||
|
"A5": "Öffentliche Rechenschaft und Mitsprache",
|
||||||
|
"B1": "Ethisches Finanzgebaren / Geld und Mensch",
|
||||||
|
"B2": "Gemeinnutz im Finanzgebaren",
|
||||||
|
"B3": "Ökologische Verantwortung der Finanzpolitik",
|
||||||
|
"B4": "Soziale Verantwortung der Finanzpolitik",
|
||||||
|
"B5": "Rechenschaft und Partizipation in der Finanzpolitik",
|
||||||
|
"C1": "Individuelle Rechts- und Gleichstellung",
|
||||||
|
"C2": "Gemeinsame Zielvereinbarung für das Gemeinwohl",
|
||||||
|
"C3": "Förderung ökologischen Verhaltens",
|
||||||
|
"C4": "Gerechte Verteilung von Arbeit",
|
||||||
|
"C5": "Transparente Kommunikation und demokratische Prozesse",
|
||||||
|
"D1": "Schutz des Individuums, Rechtsgleichheit",
|
||||||
|
"D2": "Gesamtwohl in der Gemeinde",
|
||||||
|
"D3": "Ökologische Gestaltung der öffentlichen Leistung",
|
||||||
|
"D4": "Soziale Gestaltung der öffentlichen Leistung",
|
||||||
|
"D5": "Transparente Kommunikation und demokratische Einbindung",
|
||||||
|
"E1": "Gestaltung der Bedingungen für ein menschenwürdiges Leben – zukünftige Generationen",
|
||||||
|
"E2": "Beitrag zum Gesamtwohl",
|
||||||
|
"E3": "Verantwortung für ökologische Auswirkungen",
|
||||||
|
"E4": "Beitrag zum sozialen Ausgleich",
|
||||||
|
"E5": "Transparente und demokratische Mitbestimmung",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROW_LABELS: dict[str, str] = {
|
||||||
|
"A": "Ausgelagerte Betriebe, Lieferant:innen, Dienstleister:innen",
|
||||||
|
"B": "Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen",
|
||||||
|
"C": "Politische Führung, Verwaltung, Ehrenamtliche",
|
||||||
|
"D": "Bürger:innen und Wirtschaft",
|
||||||
|
"E": "Staat, Gesellschaft und Natur",
|
||||||
|
}
|
||||||
|
|
||||||
|
COL_LABELS = [
|
||||||
|
"Menschenwürde",
|
||||||
|
"Solidarität",
|
||||||
|
"Ökologische Nachhaltigkeit",
|
||||||
|
"Soziale Gerechtigkeit",
|
||||||
|
"Transparenz & Demokratie",
|
||||||
|
]
|
||||||
|
|
||||||
|
COL_STAATSPRINZIPIEN = [
|
||||||
|
"Rechtsstaatsprinzip",
|
||||||
|
"Gemeinnutz",
|
||||||
|
"Umwelt-Verantwortung",
|
||||||
|
"Sozialstaatsprinzip",
|
||||||
|
"Demokratie",
|
||||||
|
]
|
||||||
|
|
||||||
|
MATRIX_VERSION = "2.0"
|
||||||
|
MATRIX_TITLE = "Matrix 2.0 für Gemeinden"
|
||||||
|
|
||||||
|
EMPFEHLUNG_CONFIG: dict[str, dict] = {
|
||||||
|
"Ablehnen": {"symbol": "[X]", "color": "#d00000", "css_class": "empf-ablehnen"},
|
||||||
|
"Überarbeiten": {"symbol": "[!]", "color": "#F7941D", "css_class": "empf-ueberarbeiten"},
|
||||||
|
"Unterstützen mit Änderungen": {"symbol": "[+]", "color": "#009da5", "css_class": "empf-unterstuetzen"},
|
||||||
|
"Uneingeschränkt unterstützen": {"symbol": "[++]", "color": "#889e33", "css_class": "empf-voll"},
|
||||||
|
}
|
||||||
363
app/parlamente.py
Normal file
363
app/parlamente.py
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
"""Parliament search adapters for different German states."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Drucksache:
|
||||||
|
"""A parliamentary document."""
|
||||||
|
drucksache: str # e.g. "18/8125"
|
||||||
|
title: str
|
||||||
|
fraktionen: list[str]
|
||||||
|
datum: str # ISO date
|
||||||
|
link: str # PDF URL
|
||||||
|
bundesland: str
|
||||||
|
typ: str = "Antrag" # Antrag, Anfrage, Beschlussempfehlung, etc.
|
||||||
|
|
||||||
|
|
||||||
|
class ParlamentAdapter(ABC):
|
||||||
|
"""Base adapter for searching parliament documents."""
|
||||||
|
|
||||||
|
bundesland: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||||
|
"""Search for documents matching query."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
|
||||||
|
"""Get a specific document by ID."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def download_text(self, drucksache: str) -> Optional[str]:
|
||||||
|
"""Download and extract text from a document."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NRWAdapter(ParlamentAdapter):
|
||||||
|
"""Adapter for NRW Landtag (opal.landtag.nrw.de)."""
|
||||||
|
|
||||||
|
bundesland = "NRW"
|
||||||
|
name = "Landtag Nordrhein-Westfalen"
|
||||||
|
base_url = "https://opal.landtag.nrw.de"
|
||||||
|
search_url = "https://opal.landtag.nrw.de/home/dokumente/dokumentensuche/parlamentsdokumente/aktuelle-dokumente.html"
|
||||||
|
|
||||||
|
def _parse_query(self, query: str) -> tuple[str, list[str], bool]:
|
||||||
|
"""
|
||||||
|
Parse search query for AND logic and exact phrases.
|
||||||
|
Returns: (search_term_for_api, filter_terms, is_exact)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- 'Klimaschutz Energie' -> ('Klimaschutz', ['klimaschutz', 'energie'], False)
|
||||||
|
- '"Grüner Stahl"' -> ('Grüner Stahl', ['grüner stahl'], True)
|
||||||
|
- 'Klimaschutz "erneuerbare Energie"' -> ('Klimaschutz', ['klimaschutz', 'erneuerbare energie'], False)
|
||||||
|
"""
|
||||||
|
query = query.strip()
|
||||||
|
|
||||||
|
# Check for exact phrase (entire query in quotes)
|
||||||
|
if query.startswith('"') and query.endswith('"') and query.count('"') == 2:
|
||||||
|
exact = query[1:-1].strip()
|
||||||
|
return (exact, [exact.lower()], True)
|
||||||
|
|
||||||
|
# Extract quoted phrases and regular terms
|
||||||
|
import shlex
|
||||||
|
try:
|
||||||
|
parts = shlex.split(query)
|
||||||
|
except ValueError:
|
||||||
|
# Fallback for unbalanced quotes
|
||||||
|
parts = query.split()
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return (query, [query.lower()], False)
|
||||||
|
|
||||||
|
# Use first term for API search, all terms for filtering
|
||||||
|
filter_terms = [p.lower() for p in parts]
|
||||||
|
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)."""
|
||||||
|
searchable = f"{doc.title} {doc.drucksache} {' '.join(doc.fraktionen)} {doc.typ}".lower()
|
||||||
|
|
||||||
|
if is_exact:
|
||||||
|
# Exact phrase must appear
|
||||||
|
return terms[0] in searchable
|
||||||
|
else:
|
||||||
|
# All terms must appear (AND)
|
||||||
|
return all(term in searchable for term in terms)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
# First, get the page to establish session
|
||||||
|
initial = await client.get(self.search_url)
|
||||||
|
if initial.status_code != 200:
|
||||||
|
print(f"NRW search initial request failed: {initial.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse for webflow token from pagination links
|
||||||
|
soup = BeautifulSoup(initial.text, 'html.parser')
|
||||||
|
|
||||||
|
# Find a pagination link to extract the webflow token
|
||||||
|
pagination_link = soup.select_one('a[href*="webflowexecution"]')
|
||||||
|
webflow_token = ""
|
||||||
|
webflow_execution = ""
|
||||||
|
|
||||||
|
if pagination_link:
|
||||||
|
href = pagination_link.get('href', '')
|
||||||
|
# Extract webflowToken and webflowexecution from URL
|
||||||
|
token_match = re.search(r'webflowToken=([^&]*)', href)
|
||||||
|
exec_match = re.search(r'(webflowexecution[^=]+)=([^&]+)', href)
|
||||||
|
if token_match:
|
||||||
|
webflow_token = token_match.group(1)
|
||||||
|
if exec_match:
|
||||||
|
webflow_execution = f"{exec_match.group(1)}={exec_match.group(2)}"
|
||||||
|
|
||||||
|
# Now perform the search with POST
|
||||||
|
# Find the form action URL with webflow token
|
||||||
|
form = soup.select_one('form#docSearchByItem')
|
||||||
|
form_action = self.search_url
|
||||||
|
if form and form.get('action'):
|
||||||
|
action = form.get('action')
|
||||||
|
if action.startswith('/'):
|
||||||
|
form_action = f"{self.base_url}{action}"
|
||||||
|
elif action.startswith('http'):
|
||||||
|
form_action = action
|
||||||
|
else:
|
||||||
|
form_action = f"{self.search_url}?{action}"
|
||||||
|
|
||||||
|
# Build form data for "Einfache Suche" (searchByItem form)
|
||||||
|
form_data = {
|
||||||
|
'_eventId_sendform': '1',
|
||||||
|
'dokNum': api_query, # This is the text search field
|
||||||
|
'formId': 'searchByItem',
|
||||||
|
'dokTyp': '', # All types
|
||||||
|
'wp': '18', # Wahlperiode 18
|
||||||
|
}
|
||||||
|
|
||||||
|
# POST request with form data to the form action URL
|
||||||
|
search_resp = await client.post(
|
||||||
|
form_action,
|
||||||
|
data=form_data,
|
||||||
|
cookies=initial.cookies,
|
||||||
|
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||||
|
)
|
||||||
|
|
||||||
|
if search_resp.status_code != 200:
|
||||||
|
print(f"NRW search request failed: {search_resp.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
soup = BeautifulSoup(search_resp.text, 'html.parser')
|
||||||
|
|
||||||
|
# Find all document result items (li elements containing articles)
|
||||||
|
items = soup.select('li:has(article)')
|
||||||
|
|
||||||
|
for item in items[:limit]:
|
||||||
|
try:
|
||||||
|
# Extract drucksache number from first link
|
||||||
|
num_link = item.select_one('a[href*="MMD"]')
|
||||||
|
if not num_link:
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = num_link.get('href', '')
|
||||||
|
# Extract number: MMD18-12345.pdf -> 18/12345
|
||||||
|
match = re.search(r'MMD(\d+)-(\d+)\.pdf', href)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
legislatur, nummer = match.groups()
|
||||||
|
drucksache = f"{legislatur}/{nummer}"
|
||||||
|
pdf_url = f"https://www.landtag.nrw.de{href}" if href.startswith('/') else href
|
||||||
|
|
||||||
|
# Extract title from the title link (class e-document-result-item__title)
|
||||||
|
title_elem = item.select_one('a.e-document-result-item__title')
|
||||||
|
if title_elem:
|
||||||
|
# Get text content, clean it up
|
||||||
|
title = title_elem.get_text(strip=True)
|
||||||
|
# Remove SVG icon text and clean
|
||||||
|
title = re.sub(r'\s*<svg.*', '', title)
|
||||||
|
title = re.sub(r'\s+', ' ', title).strip()
|
||||||
|
else:
|
||||||
|
# Fallback: try to find any longer text
|
||||||
|
title = f"Drucksache {drucksache}"
|
||||||
|
|
||||||
|
# Clean up common artifacts
|
||||||
|
title = re.sub(r'\s*\(\s*externer Link.*?\)', '', title).strip()
|
||||||
|
|
||||||
|
# Extract type (Antrag, Kleine Anfrage, etc.)
|
||||||
|
typ_elem = item.select_one('.e-document-result-item__category')
|
||||||
|
typ = typ_elem.get_text(strip=True) if typ_elem else "Drucksache"
|
||||||
|
|
||||||
|
# Extract date
|
||||||
|
time_elem = item.select_one('time')
|
||||||
|
datum = ""
|
||||||
|
if time_elem:
|
||||||
|
datum_text = time_elem.get_text(strip=True)
|
||||||
|
# Convert DD.MM.YYYY to YYYY-MM-DD
|
||||||
|
date_match = re.match(r'(\d{2})\.(\d{2})\.(\d{4})', datum_text)
|
||||||
|
if date_match:
|
||||||
|
d, m, y = date_match.groups()
|
||||||
|
datum = f"{y}-{m}-{d}"
|
||||||
|
|
||||||
|
# Extract Urheber (fraktionen) - look for paragraph containing "Urheber:"
|
||||||
|
urheber_text = ""
|
||||||
|
for p in item.select('p'):
|
||||||
|
if 'Urheber:' in p.get_text():
|
||||||
|
urheber_text = p.get_text()
|
||||||
|
break
|
||||||
|
|
||||||
|
fraktionen = []
|
||||||
|
if urheber_text:
|
||||||
|
# Extract party names (SPD, CDU, GRÜNE, FDP, AfD)
|
||||||
|
for party in ['SPD', 'CDU', 'GRÜNE', 'Grüne', 'FDP', 'AfD']:
|
||||||
|
if party in urheber_text:
|
||||||
|
fraktionen.append(party.upper() if party.lower() != 'grüne' else 'GRÜNE')
|
||||||
|
|
||||||
|
doc = Drucksache(
|
||||||
|
drucksache=drucksache,
|
||||||
|
title=title,
|
||||||
|
fraktionen=fraktionen,
|
||||||
|
datum=datum,
|
||||||
|
link=pdf_url,
|
||||||
|
bundesland="NRW",
|
||||||
|
typ=typ,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply AND filter (all terms must match)
|
||||||
|
if self._matches_all_terms(doc, filter_terms, is_exact):
|
||||||
|
results.append(doc)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing item: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"NRW search error: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
|
||||||
|
"""Get document metadata by drucksache ID (e.g. '18/8125')."""
|
||||||
|
# Parse legislatur and number
|
||||||
|
match = re.match(r"(\d+)/(\d+)", drucksache)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
legislatur, nummer = match.groups()
|
||||||
|
pdf_url = f"https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMD{legislatur}-{nummer}.pdf"
|
||||||
|
|
||||||
|
# Try to fetch and extract basic info
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.head(pdf_url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return Drucksache(
|
||||||
|
drucksache=drucksache,
|
||||||
|
title=f"Drucksache {drucksache}",
|
||||||
|
fraktionen=[],
|
||||||
|
datum="",
|
||||||
|
link=pdf_url,
|
||||||
|
bundesland="NRW",
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_text(self, drucksache: str) -> Optional[str]:
|
||||||
|
"""Download PDF and extract text."""
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
doc = await self.get_document(drucksache)
|
||||||
|
if not doc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(doc.link)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract text with PyMuPDF
|
||||||
|
pdf = fitz.open(stream=resp.content, filetype="pdf")
|
||||||
|
text = ""
|
||||||
|
for page in pdf:
|
||||||
|
text += page.get_text()
|
||||||
|
pdf.close()
|
||||||
|
|
||||||
|
return text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading {drucksache}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BayernAdapter(ParlamentAdapter):
|
||||||
|
"""Adapter for Bayerischer Landtag."""
|
||||||
|
|
||||||
|
bundesland = "BY"
|
||||||
|
name = "Bayerischer Landtag"
|
||||||
|
base_url = "https://www.bayern.landtag.de"
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||||
|
# TODO: Implement Bayern search
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
|
||||||
|
# TODO: Implement
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_text(self, drucksache: str) -> Optional[str]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BWAdapter(ParlamentAdapter):
|
||||||
|
"""Adapter for Baden-Württemberg Landtag."""
|
||||||
|
|
||||||
|
bundesland = "BW"
|
||||||
|
name = "Landtag Baden-Württemberg"
|
||||||
|
base_url = "https://www.landtag-bw.de"
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||||
|
# TODO: Implement BW search
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_text(self, drucksache: str) -> Optional[str]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of adapters
|
||||||
|
ADAPTERS = {
|
||||||
|
"NRW": NRWAdapter(),
|
||||||
|
"BY": BayernAdapter(),
|
||||||
|
"BW": BWAdapter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_adapter(bundesland: str) -> Optional[ParlamentAdapter]:
|
||||||
|
"""Get adapter for a bundesland."""
|
||||||
|
return ADAPTERS.get(bundesland)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_all(query: str, bundesland: str = "NRW", limit: int = 20) -> list[Drucksache]:
|
||||||
|
"""Search parliament documents in a specific state."""
|
||||||
|
adapter = get_adapter(bundesland)
|
||||||
|
if not adapter:
|
||||||
|
return []
|
||||||
|
return await adapter.search(query, limit)
|
||||||
427
app/report.py
Normal file
427
app/report.py
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
"""Report generation for HTML and PDF output."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from .models import Assessment, MATRIX_LABELS, EMPFEHLUNG_CONFIG
|
||||||
|
|
||||||
|
# ECOnGOOD Colors
|
||||||
|
COLORS = {
|
||||||
|
"darkgray": "#5a5a5a",
|
||||||
|
"green": "#889e33",
|
||||||
|
"blue": "#009da5",
|
||||||
|
"lightgray": "#bfbfbf",
|
||||||
|
"orange": "#F7941D",
|
||||||
|
"red": "#d00000",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_score_color(score: float) -> str:
|
||||||
|
"""Get color for a score value."""
|
||||||
|
if score >= 7:
|
||||||
|
return COLORS["blue"]
|
||||||
|
if score >= 4:
|
||||||
|
return COLORS["green"]
|
||||||
|
if score >= 2:
|
||||||
|
return "#FFC20E"
|
||||||
|
if score >= 1:
|
||||||
|
return COLORS["orange"]
|
||||||
|
return COLORS["red"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_rating_symbol(rating: int) -> str:
|
||||||
|
"""Convert numeric rating to symbol."""
|
||||||
|
if rating >= 2:
|
||||||
|
return "++"
|
||||||
|
if rating == 1:
|
||||||
|
return "+"
|
||||||
|
if rating == 0:
|
||||||
|
return "○"
|
||||||
|
if rating == -1:
|
||||||
|
return "−"
|
||||||
|
return "−−"
|
||||||
|
|
||||||
|
|
||||||
|
def format_redline_html(text: str) -> str:
|
||||||
|
"""Convert redline markup to HTML."""
|
||||||
|
import re
|
||||||
|
# **text** → green bold (inserted)
|
||||||
|
text = re.sub(r'\*\*([^*]+)\*\*', r'<span class="inserted">\1</span>', text)
|
||||||
|
# ~~text~~ → red strikethrough (deleted)
|
||||||
|
text = re.sub(r'~~([^~]+)~~', r'<span class="deleted">\1</span>', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def build_matrix_html(assessment: Assessment) -> str:
|
||||||
|
"""Build HTML matrix table."""
|
||||||
|
rating_map = {e.field: e for e in assessment.gwoe_matrix}
|
||||||
|
|
||||||
|
rows = ["A", "B", "C", "D", "E"]
|
||||||
|
row_labels = {
|
||||||
|
"A": "Lieferant:innen",
|
||||||
|
"B": "Finanzen",
|
||||||
|
"C": "Führung/Verwaltung",
|
||||||
|
"D": "Bürger:innen",
|
||||||
|
"E": "Gesellschaft/Natur",
|
||||||
|
}
|
||||||
|
|
||||||
|
html = ['<table class="matrix-table">']
|
||||||
|
html.append('<thead><tr>')
|
||||||
|
html.append('<th></th>')
|
||||||
|
for col in range(1, 6):
|
||||||
|
html.append(f'<th>{col}</th>')
|
||||||
|
html.append('</tr></thead>')
|
||||||
|
html.append('<tbody>')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
html.append(f'<tr><th>{row}: {row_labels[row]}</th>')
|
||||||
|
for col in range(1, 6):
|
||||||
|
field = f"{row}{col}"
|
||||||
|
entry = rating_map.get(field)
|
||||||
|
if entry:
|
||||||
|
symbol = get_rating_symbol(entry.rating)
|
||||||
|
css_class = "positive" if entry.rating > 0 else ("negative" if entry.rating < 0 else "neutral")
|
||||||
|
html.append(f'<td class="{css_class}" title="{entry.aspect}">{symbol}</td>')
|
||||||
|
else:
|
||||||
|
html.append('<td></td>')
|
||||||
|
html.append('</tr>')
|
||||||
|
|
||||||
|
html.append('</tbody></table>')
|
||||||
|
return '\n'.join(html)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_html_report(assessment: Assessment, output_path: Path) -> None:
|
||||||
|
"""Generate HTML report."""
|
||||||
|
|
||||||
|
empf_config = EMPFEHLUNG_CONFIG.get(assessment.empfehlung.value, {})
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GWÖ-Antragsprüfung: {assessment.title}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--color-darkgray: {COLORS['darkgray']};
|
||||||
|
--color-green: {COLORS['green']};
|
||||||
|
--color-blue: {COLORS['blue']};
|
||||||
|
--color-lightgray: {COLORS['lightgray']};
|
||||||
|
--color-orange: {COLORS['orange']};
|
||||||
|
--color-red: {COLORS['red']};
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
font-family: 'Avenir', Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
color: var(--color-darkgray);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 10pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid var(--color-blue);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header img {{
|
||||||
|
max-width: 150px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header-label {{
|
||||||
|
font-size: 8pt;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-blue);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h1 {{
|
||||||
|
color: var(--color-darkgray);
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h2 {{
|
||||||
|
color: var(--color-blue);
|
||||||
|
font-size: 11pt;
|
||||||
|
border-bottom: 1px solid var(--color-lightgray);
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h3 {{
|
||||||
|
color: var(--color-green);
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.meta-box {{
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 9pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.empfehlung-box {{
|
||||||
|
background: {empf_config.get('color', COLORS['blue'])}15;
|
||||||
|
border: 1px solid {empf_config.get('color', COLORS['blue'])};
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.empfehlung-box .symbol {{
|
||||||
|
font-size: 12pt;
|
||||||
|
color: {empf_config.get('color', COLORS['blue'])};
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.empfehlung-box .text {{
|
||||||
|
font-size: 10pt;
|
||||||
|
display: inline;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.score-bar {{
|
||||||
|
background: var(--color-lightgray);
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.score-bar-fill {{
|
||||||
|
height: 100%;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 8pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table th, .matrix-table td {{
|
||||||
|
border: 1px solid var(--color-lightgray);
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table thead th {{
|
||||||
|
background: var(--color-blue);
|
||||||
|
color: white;
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: normal;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table tbody th {{
|
||||||
|
background: #f5f5f5;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 8pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table .positive {{
|
||||||
|
background: var(--color-green);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table .negative {{
|
||||||
|
background: var(--color-red);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.matrix-table .neutral {{
|
||||||
|
background: #f0f0f0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.verbesserung {{
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-lightgray);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.verbesserung .original {{
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 0.4rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.verbesserung .vorschlag {{
|
||||||
|
background: rgba(136, 158, 51, 0.1);
|
||||||
|
border-left: 2px solid var(--color-green);
|
||||||
|
padding: 0.4rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.inserted {{
|
||||||
|
color: var(--color-green);
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.deleted {{
|
||||||
|
color: var(--color-red);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.two-columns {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.staerken {{
|
||||||
|
border-left: 2px solid var(--color-green);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.schwaechen {{
|
||||||
|
border-left: 2px solid var(--color-orange);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
ul {{
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
li {{
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
p {{
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.footer {{
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-lightgray);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-lightgray);
|
||||||
|
font-size: 7pt;
|
||||||
|
}}
|
||||||
|
|
||||||
|
@media print {{
|
||||||
|
body {{ max-width: none; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-label">GEMEINWOHL-ÖKONOMIE | ANTRAGSBEWERTUNG</div>
|
||||||
|
<h1>{assessment.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-box">
|
||||||
|
<strong>Drucksache:</strong> {assessment.drucksache} |
|
||||||
|
<strong>Datum:</strong> {assessment.datum} |
|
||||||
|
<strong>Fraktion(en):</strong> {', '.join(assessment.fraktionen)} |
|
||||||
|
<strong>GWÖ-Score:</strong> <span style="color: {get_score_color(assessment.gwoe_score)}; font-weight: bold;">{assessment.gwoe_score}/10</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empfehlung-box">
|
||||||
|
<span class="symbol">{empf_config.get('symbol', '[?]')}</span>
|
||||||
|
<span class="text"><strong>Empfehlung:</strong> {assessment.empfehlung.value}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Der Antrag im Überblick</h2>
|
||||||
|
<p>{assessment.antrag_zusammenfassung or 'Keine Zusammenfassung verfügbar.'}</p>
|
||||||
|
|
||||||
|
{('<ul>' + ''.join(f'<li>{k}</li>' for k in assessment.antrag_kernpunkte) + '</ul>') if assessment.antrag_kernpunkte else ''}
|
||||||
|
|
||||||
|
<h2>GWÖ-Treue</h2>
|
||||||
|
<p style="font-size: 9pt;"><strong>Score:</strong> <span style="color: {get_score_color(assessment.gwoe_score)};">{assessment.gwoe_score}/10</span></p>
|
||||||
|
|
||||||
|
<div class="score-bar">
|
||||||
|
<div class="score-bar-fill" style="width: {assessment.gwoe_score * 10}%; background: {get_score_color(assessment.gwoe_score)};"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Begründung:</strong> {assessment.gwoe_begruendung}</p>
|
||||||
|
<p><strong>Schwerpunkte:</strong> {', '.join(assessment.gwoe_schwerpunkt)}</p>
|
||||||
|
|
||||||
|
<h2>Matrix-Zuordnung (Matrix 2.0 für Gemeinden)</h2>
|
||||||
|
|
||||||
|
{build_matrix_html(assessment)}
|
||||||
|
|
||||||
|
<p style="font-size: 7pt; color: #999;">
|
||||||
|
<strong>Legende:</strong> ++ stark fördernd, + fördernd, ○ neutral, − widersprechend, −− stark widersprechend
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Berührte Themenfelder</h3>
|
||||||
|
<ul>
|
||||||
|
{''.join(f'<li><strong>{e.field}:</strong> {e.aspect} [{get_rating_symbol(e.rating)}]</li>' for e in assessment.gwoe_matrix)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Programmtreue</h2>
|
||||||
|
|
||||||
|
{''.join(f'''
|
||||||
|
<h3>{s.fraktion} {' (Antragsteller)' if s.ist_antragsteller else ''}{' (Regierung)' if s.ist_regierung else ''}</h3>
|
||||||
|
<p><strong>Wahlprogramm:</strong> {s.wahlprogramm.score}/10 — {s.wahlprogramm.begruendung}</p>
|
||||||
|
<p><strong>Parteiprogramm:</strong> {s.parteiprogramm.score}/10 — {s.parteiprogramm.begruendung}</p>
|
||||||
|
''' for s in assessment.wahlprogramm_scores)}
|
||||||
|
|
||||||
|
<h2>Verbesserungsvorschläge</h2>
|
||||||
|
|
||||||
|
{''.join(f'''
|
||||||
|
<div class="verbesserung">
|
||||||
|
<div class="original"><strong>Original:</strong><br>{v.original}</div>
|
||||||
|
<div class="vorschlag"><strong>Vorschlag:</strong><br>{format_redline_html(v.vorschlag)}</div>
|
||||||
|
<div style="font-style: italic; margin-top: 0.5rem;">{v.begruendung}</div>
|
||||||
|
</div>
|
||||||
|
''' for v in assessment.verbesserungen) or '<p>Keine Verbesserungsvorschläge.</p>'}
|
||||||
|
|
||||||
|
<h2>Zusammenfassung</h2>
|
||||||
|
|
||||||
|
<div class="two-columns">
|
||||||
|
<div class="staerken">
|
||||||
|
<h3 style="color: var(--color-green);">Stärken</h3>
|
||||||
|
<ul>
|
||||||
|
{''.join(f'<li>{s}</li>' for s in assessment.staerken) or '<li>(keine)</li>'}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="schwaechen">
|
||||||
|
<h3 style="color: var(--color-orange);">Schwächen</h3>
|
||||||
|
<ul>
|
||||||
|
{''.join(f'<li>{s}</li>' for s in assessment.schwaechen) or '<li>(keine)</li>'}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Erstellt mit GWÖ-Antragsprüfer v4.1 | Matrix 2.0 für Gemeinden</p>
|
||||||
|
<p style="color: var(--color-blue);">germany.econgood.org</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
output_path.write_text(html)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_pdf_report(assessment: Assessment, output_path: Path) -> None:
|
||||||
|
"""Generate PDF report using WeasyPrint."""
|
||||||
|
# First generate HTML
|
||||||
|
html_path = output_path.with_suffix('.tmp.html')
|
||||||
|
await generate_html_report(assessment, html_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
HTML(filename=str(html_path)).write_pdf(str(output_path))
|
||||||
|
finally:
|
||||||
|
html_path.unlink(missing_ok=True)
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
BIN
app/static/referenzen/afd-nrw-2022.pdf
Normal file
BIN
app/static/referenzen/afd-nrw-2022.pdf
Normal file
Binary file not shown.
4583
app/static/referenzen/cdu-grundsatzprogramm.pdf
Normal file
4583
app/static/referenzen/cdu-grundsatzprogramm.pdf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/static/referenzen/cdu-nrw-2022.pdf
Normal file
BIN
app/static/referenzen/cdu-nrw-2022.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/fdp-grundsatzprogramm.pdf
Normal file
BIN
app/static/referenzen/fdp-grundsatzprogramm.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/fdp-nrw-2022.pdf
Normal file
BIN
app/static/referenzen/fdp-nrw-2022.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/gruene-grundsatzprogramm.pdf
Normal file
BIN
app/static/referenzen/gruene-grundsatzprogramm.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/gruene-nrw-2022.pdf
Normal file
BIN
app/static/referenzen/gruene-nrw-2022.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/spd-grundsatzprogramm.pdf
Normal file
BIN
app/static/referenzen/spd-grundsatzprogramm.pdf
Normal file
Binary file not shown.
BIN
app/static/referenzen/spd-nrw-2022.pdf
Normal file
BIN
app/static/referenzen/spd-nrw-2022.pdf
Normal file
Binary file not shown.
1489
app/templates/index.html
Normal file
1489
app/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
309
app/templates/quellen.html
Normal file
309
app/templates/quellen.html
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quellen — {{ app_name }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-darkgray: #5a5a5a;
|
||||||
|
--color-green: #889e33;
|
||||||
|
--color-blue: #009da5;
|
||||||
|
--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: 1200px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--color-blue);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.programme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.programme-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.programme-card h3 {
|
||||||
|
color: var(--color-darkgray);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.programme-meta {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.programme-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-spd { background: #e3000f; color: white; }
|
||||||
|
.badge-cdu { background: #000000; color: white; }
|
||||||
|
.badge-gruene { background: #46962b; color: white; }
|
||||||
|
.badge-fdp { background: #ffed00; color: black; }
|
||||||
|
.badge-afd { background: #009ee0; color: white; }
|
||||||
|
|
||||||
|
.badge-wahlprogramm { background: var(--color-blue); color: white; }
|
||||||
|
.badge-parteiprogramm { background: var(--color-green); color: white; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #007b82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--color-lightgray);
|
||||||
|
color: var(--color-darkgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-box {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indexed { color: var(--color-green); }
|
||||||
|
.not-indexed { color: #888; }
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<h1><a href="/">{{ app_name }}</a></h1>
|
||||||
|
<span>→ Quellen</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||||
|
|
||||||
|
<div class="intro">
|
||||||
|
<h2>Quellen & Referenzdokumente</h2>
|
||||||
|
<p>
|
||||||
|
Der GWÖ-Antragsprüfer vergleicht parlamentarische Anträge mit den Wahl- und Grundsatzprogrammen
|
||||||
|
der Parteien. Hier finden Sie alle verwendeten Originaldokumente zum Download.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 0.5rem; color: #888;">
|
||||||
|
Die Programme werden semantisch indexiert, um relevante Passagen für jeden Antrag zu finden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-box">
|
||||||
|
<h3>Indexierungsstatus</h3>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-value">{{ status.indexed }}</div>
|
||||||
|
<div class="status-label">Indexiert</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-value">{{ status.total }}</div>
|
||||||
|
<div class="status-label">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<button class="btn btn-primary" onclick="indexAll()">
|
||||||
|
🔄 Alle Programme indexieren
|
||||||
|
</button>
|
||||||
|
<span id="index-status" style="margin-left: 1rem; color: #888;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Wahlprogramme NRW 2022</h2>
|
||||||
|
<div class="programme-grid">
|
||||||
|
{% for prog in programmes if prog.typ == 'wahlprogramm' %}
|
||||||
|
<div class="programme-card">
|
||||||
|
<h3>
|
||||||
|
<span class="programme-badge badge-{{ prog.partei|lower }}">{{ prog.partei }}</span>
|
||||||
|
{{ prog.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="programme-meta">
|
||||||
|
<span class="programme-badge badge-wahlprogramm">Wahlprogramm</span>
|
||||||
|
{% if prog.bundesland %}{{ prog.bundesland }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<a href="{{ prog.pdf_url }}" target="_blank" class="btn btn-primary">
|
||||||
|
📄 PDF herunterladen
|
||||||
|
</a>
|
||||||
|
{% for s in status.programmes if s.id == prog.id %}
|
||||||
|
<span style="margin-left: 0.5rem; font-size: 0.85rem;" class="{% if s.indexed %}indexed{% else %}not-indexed{% endif %}">
|
||||||
|
{% if s.indexed %}✓ {{ s.chunks }} Chunks{% else %}○ Nicht indexiert{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Grundsatzprogramme</h2>
|
||||||
|
<div class="programme-grid">
|
||||||
|
{% for prog in programmes if prog.typ == 'parteiprogramm' %}
|
||||||
|
<div class="programme-card">
|
||||||
|
<h3>
|
||||||
|
<span class="programme-badge badge-{{ prog.partei|lower }}">{{ prog.partei }}</span>
|
||||||
|
{{ prog.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="programme-meta">
|
||||||
|
<span class="programme-badge badge-parteiprogramm">Grundsatzprogramm</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<a href="{{ prog.pdf_url }}" target="_blank" class="btn btn-primary">
|
||||||
|
📄 PDF herunterladen
|
||||||
|
</a>
|
||||||
|
{% for s in status.programmes if s.id == prog.id %}
|
||||||
|
<span style="margin-left: 0.5rem; font-size: 0.85rem;" class="{% if s.indexed %}indexed{% else %}not-indexed{% endif %}">
|
||||||
|
{% if s.indexed %}✓ {{ s.chunks }} Chunks{% else %}○ Nicht indexiert{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="intro" style="margin-top: 2rem;">
|
||||||
|
<h3>Hinweise zur Methodik</h3>
|
||||||
|
<ul style="margin-left: 1.5rem; margin-top: 0.5rem;">
|
||||||
|
<li>Die Programme werden in semantische Chunks aufgeteilt (~400 Wörter)</li>
|
||||||
|
<li>Jeder Chunk wird mit einem Embedding-Modell (Qwen) vektorisiert</li>
|
||||||
|
<li>Bei der Analyse wird der Antrag ebenfalls vektorisiert</li>
|
||||||
|
<li>Die ähnlichsten Passagen werden als Kontext an das LLM übergeben</li>
|
||||||
|
<li>Das LLM zitiert nur, wenn eine Passage wirklich zur Argumentation passt</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function indexAll() {
|
||||||
|
const statusEl = document.getElementById('index-status');
|
||||||
|
statusEl.textContent = '⏳ Indexierung gestartet...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('all_programmes', 'true');
|
||||||
|
|
||||||
|
const resp = await fetch('/api/programme/index', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
statusEl.textContent = `✓ Indexierung läuft für ${data.programmes.length} Programme`;
|
||||||
|
|
||||||
|
// Reload after a delay
|
||||||
|
setTimeout(() => location.reload(), 30000);
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = `✗ Fehler: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
214
app/wahlprogramme.py
Normal file
214
app/wahlprogramme.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"""Wahlprogramm-Referenzsystem mit Zitaten und Seitenreferenzen."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Wahlprogramm-Metadaten
|
||||||
|
WAHLPROGRAMME = {
|
||||||
|
"CDU": {
|
||||||
|
"file": "cdu-nrw-2022.pdf",
|
||||||
|
"titel": "Machen, worauf es ankommt",
|
||||||
|
"partei": "CDU NRW",
|
||||||
|
"jahr": 2022,
|
||||||
|
"seiten": 109,
|
||||||
|
},
|
||||||
|
"SPD": {
|
||||||
|
"file": "spd-nrw-2022.pdf",
|
||||||
|
"titel": "Unser Land von morgen",
|
||||||
|
"partei": "SPD NRW",
|
||||||
|
"jahr": 2022,
|
||||||
|
"seiten": 116,
|
||||||
|
},
|
||||||
|
"GRÜNE": {
|
||||||
|
"file": "gruene-nrw-2022.pdf",
|
||||||
|
"titel": "Von hier an Zukunft",
|
||||||
|
"partei": "BÜNDNIS 90/DIE GRÜNEN NRW",
|
||||||
|
"jahr": 2022,
|
||||||
|
"seiten": 100,
|
||||||
|
},
|
||||||
|
"FDP": {
|
||||||
|
"file": "fdp-nrw-2022.pdf",
|
||||||
|
"titel": "Nie gab es mehr zu tun",
|
||||||
|
"partei": "FDP NRW",
|
||||||
|
"jahr": 2022,
|
||||||
|
"seiten": 96,
|
||||||
|
},
|
||||||
|
"AfD": {
|
||||||
|
"file": "afd-nrw-2022.pdf",
|
||||||
|
"titel": "Wer sonst.",
|
||||||
|
"partei": "AfD NRW",
|
||||||
|
"jahr": 2022,
|
||||||
|
"seiten": 68,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Basis-Pfad für Referenzdokumente
|
||||||
|
REFERENZEN_PATH = Path(__file__).parent / "static" / "referenzen"
|
||||||
|
KONTEXT_PATH = Path(__file__).parent / "kontext"
|
||||||
|
|
||||||
|
|
||||||
|
def load_wahlprogramm_text(partei: str) -> dict[int, str]:
|
||||||
|
"""Lädt Wahlprogramm-Text mit Seitenzuordnung.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mit Seitennummer -> Text
|
||||||
|
"""
|
||||||
|
if partei not in WAHLPROGRAMME:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Versuche paged-Textdatei zu laden
|
||||||
|
paged_file = KONTEXT_PATH / f"{WAHLPROGRAMME[partei]['file'].replace('.pdf', '-paged.txt')}"
|
||||||
|
if not paged_file.exists():
|
||||||
|
# Fallback: Normale Textdatei
|
||||||
|
txt_file = KONTEXT_PATH / f"{WAHLPROGRAMME[partei]['file'].replace('.pdf', '.txt')}"
|
||||||
|
if txt_file.exists():
|
||||||
|
return {1: txt_file.read_text()}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
text = paged_file.read_text()
|
||||||
|
pages = {}
|
||||||
|
current_page = 1
|
||||||
|
current_text = []
|
||||||
|
|
||||||
|
for line in text.split('\n'):
|
||||||
|
if line.startswith('--- PAGE '):
|
||||||
|
# Speichere vorherige Seite
|
||||||
|
if current_text:
|
||||||
|
pages[current_page] = '\n'.join(current_text)
|
||||||
|
# Extrahiere neue Seitenzahl
|
||||||
|
match = re.search(r'PAGE (\d+)', line)
|
||||||
|
if match:
|
||||||
|
current_page = int(match.group(1))
|
||||||
|
current_text = []
|
||||||
|
else:
|
||||||
|
current_text.append(line)
|
||||||
|
|
||||||
|
# Letzte Seite speichern
|
||||||
|
if current_text:
|
||||||
|
pages[current_page] = '\n'.join(current_text)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def search_wahlprogramm(partei: str, keywords: list[str], max_results: int = 3) -> list[dict]:
|
||||||
|
"""Sucht relevante Passagen in einem Wahlprogramm.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
partei: Partei-Kürzel (CDU, SPD, GRÜNE, FDP, AfD)
|
||||||
|
keywords: Suchbegriffe
|
||||||
|
max_results: Maximale Anzahl Ergebnisse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von {seite, text, score, url}
|
||||||
|
"""
|
||||||
|
pages = load_wahlprogramm_text(partei)
|
||||||
|
if not pages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
keywords_lower = [k.lower() for k in keywords]
|
||||||
|
|
||||||
|
for page_num, text in pages.items():
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Zähle Keyword-Treffer
|
||||||
|
score = sum(1 for kw in keywords_lower if kw in text_lower)
|
||||||
|
|
||||||
|
if score > 0:
|
||||||
|
# Finde relevante Absätze (mit Keyword)
|
||||||
|
paragraphs = text.split('\n\n')
|
||||||
|
relevant_paragraphs = []
|
||||||
|
|
||||||
|
for para in paragraphs:
|
||||||
|
para_clean = para.strip()
|
||||||
|
if len(para_clean) < 50:
|
||||||
|
continue
|
||||||
|
para_lower = para_clean.lower()
|
||||||
|
if any(kw in para_lower for kw in keywords_lower):
|
||||||
|
relevant_paragraphs.append(para_clean)
|
||||||
|
|
||||||
|
if relevant_paragraphs:
|
||||||
|
# Nimm den relevantesten Absatz (mit meisten Keywords)
|
||||||
|
best_para = max(relevant_paragraphs,
|
||||||
|
key=lambda p: sum(1 for kw in keywords_lower if kw in p.lower()))
|
||||||
|
|
||||||
|
# Kürze auf ~300 Zeichen
|
||||||
|
if len(best_para) > 300:
|
||||||
|
best_para = best_para[:297] + "..."
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"partei": partei,
|
||||||
|
"seite": page_num,
|
||||||
|
"text": best_para,
|
||||||
|
"score": score,
|
||||||
|
"url": f"/static/referenzen/{WAHLPROGRAMME[partei]['file']}#page={page_num}",
|
||||||
|
"quelle": f"{WAHLPROGRAMME[partei]['partei']} Wahlprogramm {WAHLPROGRAMME[partei]['jahr']}, S. {page_num}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sortiere nach Score, nimm Top-Ergebnisse
|
||||||
|
results.sort(key=lambda x: x['score'], reverse=True)
|
||||||
|
return results[:max_results]
|
||||||
|
|
||||||
|
|
||||||
|
def find_relevant_quotes(antrag_text: str, fraktionen: list[str]) -> dict[str, list[dict]]:
|
||||||
|
"""Findet relevante Zitate aus Wahlprogrammen für einen Antrag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antrag_text: Volltext des Antrags
|
||||||
|
fraktionen: Liste der Fraktionen (Antragsteller + Regierung)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mit Partei -> Liste von Zitaten
|
||||||
|
"""
|
||||||
|
# Extrahiere Keywords aus Antrag (einfache Heuristik)
|
||||||
|
# Entferne Stoppwörter und kurze Wörter
|
||||||
|
stopwords = {'der', 'die', 'das', 'und', 'oder', 'für', 'mit', 'von', 'zu', 'auf',
|
||||||
|
'ist', 'sind', 'wird', 'werden', 'hat', 'haben', 'ein', 'eine', 'einer',
|
||||||
|
'den', 'dem', 'des', 'im', 'in', 'an', 'bei', 'nach', 'über', 'unter',
|
||||||
|
'durch', 'als', 'auch', 'nur', 'noch', 'aber', 'wenn', 'dass', 'sich',
|
||||||
|
'nicht', 'wie', 'so', 'aus', 'zum', 'zur', 'vom', 'beim', 'seit', 'bis'}
|
||||||
|
|
||||||
|
words = re.findall(r'\b[A-Za-zäöüÄÖÜß]{4,}\b', antrag_text)
|
||||||
|
keywords = [w for w in words if w.lower() not in stopwords]
|
||||||
|
|
||||||
|
# Zähle Worthäufigkeit
|
||||||
|
word_freq = {}
|
||||||
|
for w in keywords:
|
||||||
|
w_lower = w.lower()
|
||||||
|
word_freq[w_lower] = word_freq.get(w_lower, 0) + 1
|
||||||
|
|
||||||
|
# Top-Keywords (häufigste)
|
||||||
|
top_keywords = sorted(word_freq.keys(), key=lambda x: word_freq[x], reverse=True)[:15]
|
||||||
|
|
||||||
|
# Suche in relevanten Wahlprogrammen
|
||||||
|
quotes = {}
|
||||||
|
|
||||||
|
# Immer Regierungsfraktionen einbeziehen
|
||||||
|
parteien_to_search = set(fraktionen) | {"CDU", "GRÜNE"}
|
||||||
|
|
||||||
|
for partei in parteien_to_search:
|
||||||
|
if partei in WAHLPROGRAMME:
|
||||||
|
found = search_wahlprogramm(partei, top_keywords, max_results=2)
|
||||||
|
if found:
|
||||||
|
quotes[partei] = found
|
||||||
|
|
||||||
|
return quotes
|
||||||
|
|
||||||
|
|
||||||
|
def format_quote_for_prompt(quotes: dict[str, list[dict]]) -> str:
|
||||||
|
"""Formatiert Zitate für den LLM-Prompt."""
|
||||||
|
if not quotes:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["\n## Relevante Passagen aus Wahlprogrammen\n"]
|
||||||
|
lines.append("Nutze diese Originalzitate als Belege in deiner Bewertung:\n")
|
||||||
|
|
||||||
|
for partei, zitate in quotes.items():
|
||||||
|
for z in zitate:
|
||||||
|
lines.append(f"### {z['quelle']}")
|
||||||
|
lines.append(f'> "{z["text"]}"')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
gwoe-antragspruefer:
|
||||||
|
build: .
|
||||||
|
container_name: gwoe-antragspruefer
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
|
||||||
|
- KEYCLOAK_URL=https://sso.toppyr.de
|
||||||
|
- KEYCLOAK_REALM=collaboration
|
||||||
|
- KEYCLOAK_CLIENT_ID=gwoe-antragspruefer
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./reports:/app/reports
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.gwoe.rule=Host(`gwoe.toppyr.de`)"
|
||||||
|
- "traefik.http.routers.gwoe.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.gwoe.tls=true"
|
||||||
|
- "traefik.http.routers.gwoe.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.gwoe.loadbalancer.server.port=8000"
|
||||||
|
networks:
|
||||||
|
- collaboration_collaboration
|
||||||
|
|
||||||
|
networks:
|
||||||
|
collaboration_collaboration:
|
||||||
|
external: true
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
fastapi>=0.115.0,<0.116.0
|
||||||
|
starlette>=0.38.0,<0.42.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
jinja2>=3.1.0,<3.1.6
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
pymupdf>=1.24.0
|
||||||
|
openai>=1.50.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
weasyprint>=62.0
|
||||||
|
pydantic>=2.9.0
|
||||||
|
pydantic-settings>=2.5.0
|
||||||
Loading…
Reference in New Issue
Block a user