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:
Dotty Dotter 2026-03-28 22:30:24 +01:00
commit 63de3ca20d
39 changed files with 32604 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
data/
reports/
__pycache__/
*.pyc
.env
venv/
.git/

7
.env.example Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
data/
reports/
__pycache__/
*.pyc
.env
venv/
.git/

30
Dockerfile Normal file
View 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
View 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
View File

@ -0,0 +1,148 @@
# GWÖ-Antragsprüfer
**Automatische Gemeinwohl-Bilanzierung von Parlamentsanträgen nach der GWÖ-Matrix 2.0 für Gemeinden**
![Python](https://img.shields.io/badge/Python-3.12-blue)
![FastAPI](https://img.shields.io/badge/FastAPI-0.109-green)
![License](https://img.shields.io/badge/License-MIT-yellow)
## 🎯 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
View File

273
app/analyzer.py Normal file
View 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
View 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
View 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
View 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,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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*

View 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*

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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} &nbsp;|&nbsp;
<strong>Datum:</strong> {assessment.datum} &nbsp;|&nbsp;
<strong>Fraktion(en):</strong> {', '.join(assessment.fraktionen)} &nbsp;|&nbsp;
<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
View File

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1489
app/templates/index.html Normal file

File diff suppressed because it is too large Load Diff

309
app/templates/quellen.html Normal file
View 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
View 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
View 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
View 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