gwoe-wahlpruefsteine/ANLEITUNG-NACHBAU.md
Dotty Dotter f2a12f1238 Initial: GWÖ-Wahlprüfsteine Auswertung Bayern 2026
- Scraper: HTML-Extraktion von ECOnGOOD-Webseite
- Analyzer: LLM-Bewertung (Qwen) nach GWÖ-Matrix 2.0
- Aggregator: Partei-Auswertung + Kandidat:innen-Ranking
- CLI: Reproduzierbarer Workflow (scrape → analyze → aggregate)
- Output: 7 Dokumente inkl. Pressemitteilung und Methodik
- 27 Kandidat:innen, 162 Einzelbewertungen
2026-03-30 23:37:11 +02:00

691 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GWÖ-Wahlprüfsteine Auswertung — Nachbau-Anleitung
Diese Anleitung erklärt, wie du das Tool für eigene Wahlprüfstein-Auswertungen nachbauen oder anpassen kannst.
---
## Was macht dieses Tool?
1. **Scrapen:** Lädt Wahlprüfstein-Antworten von einer Webseite
2. **Analysieren:** Bewertet jede Antwort automatisch nach GWÖ-Kriterien (mit KI)
3. **Aggregieren:** Fasst Ergebnisse nach Parteien zusammen
4. **Berichten:** Erstellt Markdown-Reports mit Zitaten und Empfehlungen
**Ergebnis:** Objektive, reproduzierbare Auswertung von Wahlprüfsteinen aus GWÖ-Perspektive.
---
## Voraussetzungen
### Software
- **Python 3.11+** (macOS/Linux: vorinstalliert, Windows: python.org)
- **Git** (optional, für Versionierung)
### API-Zugang
Du brauchst einen LLM-API-Zugang. Optionen:
| Anbieter | Modell | Kosten | Anmerkung |
|----------|--------|--------|-----------|
| **Alibaba DashScope** | qwen-plus | ~$0.001/Call | Empfohlen, günstig |
| **OpenAI** | gpt-4o-mini | ~$0.003/Call | Teurer, aber bekannt |
| **Anthropic** | claude-3-haiku | ~$0.002/Call | Gute Alternative |
**Für DashScope (empfohlen):**
1. Account erstellen: https://dashscope.console.aliyun.com
2. API-Key generieren
3. Key sicher speichern (siehe unten)
---
## Installation
### 1. Projektordner erstellen
```bash
mkdir -p ~/projekte/wahlpruefsteine-auswertung
cd ~/projekte/wahlpruefsteine-auswertung
```
### 2. Python Virtual Environment
```bash
python3 -m venv .venv
source .venv/bin/activate # Linux/macOS
# oder: .venv\Scripts\activate # Windows
```
### 3. Abhängigkeiten installieren
```bash
pip install beautifulsoup4 requests openai
```
### 4. API-Key konfigurieren
**Option A: Umgebungsvariable (einfach)**
```bash
export DASHSCOPE_API_KEY="sk-xxx..."
```
**Option B: macOS Keychain (sicher)**
```bash
security add-generic-password -s qwen-api -a $USER -w "sk-xxx..."
```
**Option C: .env-Datei**
```bash
echo "DASHSCOPE_API_KEY=sk-xxx..." > .env
pip install python-dotenv
# Dann in Python: from dotenv import load_dotenv; load_dotenv()
```
---
## Dateien erstellen
Erstelle diese 4 Dateien im Projektordner:
### 1. `schema.sql` — Datenbankstruktur
```sql
-- Kandidat:innen
CREATE TABLE IF NOT EXISTS kandidaten (
id INTEGER PRIMARY KEY,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
plz TEXT,
kommune TEXT NOT NULL,
landkreis TEXT,
partei_raw TEXT NOT NULL,
partei_normalisiert TEXT NOT NULL,
ist_waehlergemeinschaft BOOLEAN DEFAULT FALSE,
pdf_url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Die 6 Fragen (anpassen an deine Wahlprüfsteine!)
CREATE TABLE IF NOT EXISTS fragen (
id INTEGER PRIMARY KEY,
nummer INTEGER UNIQUE NOT NULL,
kurztext TEXT NOT NULL,
volltext TEXT NOT NULL
);
-- Rohantworten
CREATE TABLE IF NOT EXISTS antworten_raw (
id INTEGER PRIMARY KEY,
kandidat_id INTEGER REFERENCES kandidaten(id) ON DELETE CASCADE,
frage_id INTEGER REFERENCES fragen(id) ON DELETE CASCADE,
antwort_kurz TEXT,
antwort_erlaeuterung TEXT,
UNIQUE(kandidat_id, frage_id)
);
-- LLM-Bewertungen
CREATE TABLE IF NOT EXISTS bewertungen (
id INTEGER PRIMARY KEY,
antwort_id INTEGER REFERENCES antworten_raw(id) ON DELETE CASCADE UNIQUE,
substanz_score INTEGER CHECK(substanz_score >= 0 AND substanz_score <= 3),
umfang TEXT CHECK(umfang IN ('keine', 'kurz', 'mittel', 'ausführlich')),
wortanzahl INTEGER,
gwoe_score REAL CHECK(gwoe_score >= 0 AND gwoe_score <= 10),
gwoe_begruendung TEXT,
matrix_felder TEXT, -- JSON-Array
staerken TEXT, -- JSON-Array
schwaechen TEXT, -- JSON-Array
bewertet_am DATETIME DEFAULT CURRENT_TIMESTAMP,
model TEXT DEFAULT 'qwen-plus'
);
-- Stammdaten: Fragen (ANPASSEN!)
INSERT OR IGNORE INTO fragen (nummer, kurztext, volltext) VALUES
(1, 'Leitlinien', 'Werden Sie sich für Maßnahmen einsetzen, welche die Werte und Themen der GWÖ in Leitlinien und Strategien Ihrer Kommune integrieren?'),
(2, 'Anreize', 'Werden Sie sich für Anreize einsetzen, um Unternehmen zu unterstützen, gemeinwohl-orientierter zu wirtschaften?'),
(3, 'Vergabe', 'Werden Sie sich dafür einsetzen, dass öffentliche Aufträge bevorzugt an Unternehmen mit Gemeinwohl-Bilanz vergeben werden?'),
(4, 'Information', 'Möchten Sie Bürger:innen regelmäßig über die Gemeinwohl-Auswirkungen kommunaler Entwicklungen informieren?'),
(5, 'Mitentscheidung', 'Möchten Sie Bürger:innen stärker in kommunale Entscheidungsprozesse einbinden?'),
(6, 'Bekanntheit', 'Möchten Sie die GWÖ in Ihrer Kommune und auf höheren Ebenen bekannter machen?');
```
### 2. `scraper.py` — Daten laden
```python
#!/usr/bin/env python3
"""
Wahlprüfsteine Scraper — ANPASSEN AN DEINE DATENQUELLE!
"""
import re
import sqlite3
from pathlib import Path
from bs4 import BeautifulSoup
import requests
# Partei-Normalisierung (ANPASSEN für dein Bundesland!)
PARTEI_MAPPING = {
r'bündnis\s*90\s*/?\s*die\s*grünen?': 'Grüne',
r'grüne': 'Grüne',
r'freie\s*wähler': 'Freie Wähler',
r'^csu$': 'CSU',
r'^cdu$': 'CDU',
r'^spd$': 'SPD',
r'^fdp$': 'FDP',
r'^ödp': 'ÖDP',
r'die\s*linke': 'Linke',
r'^afd$': 'AfD',
}
def normalize_partei(raw: str) -> tuple[str, bool]:
"""Normalisiert Parteinamen. Returns: (partei, ist_wählergemeinschaft)"""
raw_lower = raw.lower().strip()
for pattern, normalized in PARTEI_MAPPING.items():
if re.search(pattern, raw_lower, re.IGNORECASE):
return normalized, False
return 'Wählergemeinschaft', True
def parse_html(html_content: str) -> list[dict]:
"""
Parst HTML und extrahiert Kandidaten + Antworten.
WICHTIG: Diese Funktion musst du an deine HTML-Struktur anpassen!
"""
soup = BeautifulSoup(html_content, 'html.parser')
kandidaten = []
# BEISPIEL: Tabelle mit Kandidaten parsen
# ANPASSEN an deine HTML-Struktur!
for table in soup.find_all('table'):
rows = table.find_all('tr')
for row in rows[1:]: # Header überspringen
cells = row.find_all('td')
if len(cells) < 7:
continue
try:
kandidaten.append({
'plz': cells[0].get_text(strip=True),
'kommune': cells[1].get_text(strip=True),
'landkreis': cells[2].get_text(strip=True),
'vorname': cells[3].get_text(strip=True),
'nachname': cells[4].get_text(strip=True),
'partei_raw': cells[5].get_text(strip=True),
'pdf_url': cells[6].find('a')['href'] if cells[6].find('a') else None,
'antworten': {} # Frage-Nr -> (ja_nein, erläuterung)
})
# Antworten extrahieren (6 Fragen × 2 Spalten)
for i in range(6):
ja_nein = cells[7 + i*2].get_text(strip=True) if 7 + i*2 < len(cells) else None
erlaeuterung = cells[8 + i*2].get_text(strip=True) if 8 + i*2 < len(cells) else None
kandidaten[-1]['antworten'][i+1] = (ja_nein, erlaeuterung)
except (IndexError, KeyError):
continue
return kandidaten
def init_db(db_path: Path) -> sqlite3.Connection:
"""Initialisiert die Datenbank."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
schema_path = Path(__file__).parent / 'schema.sql'
with open(schema_path) as f:
conn.executescript(f.read())
conn.commit()
return conn
def save_to_db(conn: sqlite3.Connection, kandidaten: list[dict]) -> int:
"""Speichert Kandidaten in der Datenbank."""
cursor = conn.cursor()
for k in kandidaten:
partei_norm, ist_wg = normalize_partei(k['partei_raw'])
cursor.execute("""
INSERT OR REPLACE INTO kandidaten
(vorname, nachname, plz, kommune, landkreis, partei_raw,
partei_normalisiert, ist_waehlergemeinschaft, pdf_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (k['vorname'], k['nachname'], k['plz'], k['kommune'],
k['landkreis'], k['partei_raw'], partei_norm, ist_wg, k['pdf_url']))
kandidat_id = cursor.lastrowid
for frage_nr, (ja_nein, erlaeuterung) in k['antworten'].items():
cursor.execute("""
INSERT OR REPLACE INTO antworten_raw
(kandidat_id, frage_id, antwort_kurz, antwort_erlaeuterung)
VALUES (?, ?, ?, ?)
""", (kandidat_id, frage_nr, ja_nein, erlaeuterung))
conn.commit()
return len(kandidaten)
def main():
import argparse
parser = argparse.ArgumentParser(description='Wahlprüfsteine Scraper')
parser.add_argument('--url', required=True, help='URL der Wahlprüfsteine-Seite')
parser.add_argument('--db', default='wahlpruefsteine.db', help='Datenbankpfad')
args = parser.parse_args()
print(f"Lade: {args.url}")
response = requests.get(args.url, timeout=30)
kandidaten = parse_html(response.text)
print(f"Gefunden: {len(kandidaten)} Kandidat:innen")
conn = init_db(Path(args.db))
count = save_to_db(conn, kandidaten)
conn.close()
print(f"Gespeichert: {count} in {args.db}")
if __name__ == '__main__':
main()
```
### 3. `analyzer.py` — KI-Bewertung
```python
#!/usr/bin/env python3
"""
GWÖ-Bewertung mit LLM
"""
import sqlite3
import json
import os
import time
from pathlib import Path
from openai import OpenAI
# GWÖ-Kontext für das LLM
GWOE_CONTEXT = """
## GWÖ-Matrix 2.0 für Gemeinden
### Die 5 Werte
1. **Menschenwürde** — Grundrechte, Rechtsstaatlichkeit
2. **Solidarität** — Gemeinnutz, Mehrwert für Gemeinschaft
3. **Ökologische Nachhaltigkeit** — Klimaschutz, Ressourcenschonung
4. **Soziale Gerechtigkeit** — Daseinsvorsorge, gerechte Verteilung
5. **Transparenz & Mitbestimmung** — Bürgerbeteiligung, Demokratie
### Berührungsgruppen
- A: Lieferant:innen (Beschaffung)
- B: Finanzpartner:innen (Haushalt)
- C: Verwaltung (Mitarbeitende)
- D: Bürger:innen (Daseinsvorsorge) — WICHTIGSTE für Kommunalpolitik!
- E: Gesellschaft (überregional)
### Bewertungsskala
- 7-10: Vorbildlich (innovative Maßnahmen)
- 4-6: Erfahren (erkennbare Verbesserungen)
- 2-3: Fortgeschritten (erste Maßnahmen)
- 0-1: Basislinie
"""
SYSTEM_PROMPT = f"""Du bist ein GWÖ-Experte und analysierst Wahlprüfstein-Antworten.
{GWOE_CONTEXT}
Bewerte die Antwort und gib NUR valides JSON zurück:
{{
"substanz_score": 0-3, // 0=keine Antwort, 1=ausweichend, 2=substanziell, 3=umfassend
"umfang": "keine|kurz|mittel|ausführlich",
"gwoe_score": 0.0-10.0,
"gwoe_begruendung": "2-3 Sätze",
"matrix_felder": ["D5", "C2"], // Berührte Felder
"staerken": ["Punkt 1"],
"schwaechen": ["Punkt 1"]
}}"""
def get_api_client():
"""Erstellt API-Client (ANPASSEN für deinen Anbieter!)"""
# Option 1: DashScope (Qwen)
api_key = os.environ.get('DASHSCOPE_API_KEY')
if api_key:
return OpenAI(
api_key=api_key,
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
), "qwen-plus"
# Option 2: OpenAI
api_key = os.environ.get('OPENAI_API_KEY')
if api_key:
return OpenAI(api_key=api_key), "gpt-4o-mini"
raise ValueError("Kein API-Key gefunden! Setze DASHSCOPE_API_KEY oder OPENAI_API_KEY")
def analyze_answer(client, model: str, kandidat: str, frage: str, antwort: str) -> dict:
"""Analysiert eine einzelne Antwort."""
user_prompt = f"""## Frage
{frage}
## Antwort von {kandidat}
{antwort if antwort else "(keine Antwort)"}
Bewerte nach GWÖ-Kriterien."""
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
],
temperature=0.3,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
def main():
import argparse
parser = argparse.ArgumentParser(description='GWÖ-Analyzer')
parser.add_argument('--db', default='wahlpruefsteine.db')
parser.add_argument('--limit', type=int, help='Max. Anzahl Bewertungen')
args = parser.parse_args()
client, model = get_api_client()
print(f"Verwende Modell: {model}")
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
# Unbewertete Antworten holen
query = """
SELECT ar.id, k.vorname || ' ' || k.nachname as kandidat,
f.volltext as frage, ar.antwort_erlaeuterung as antwort
FROM antworten_raw ar
JOIN kandidaten k ON ar.kandidat_id = k.id
JOIN fragen f ON ar.frage_id = f.id
LEFT JOIN bewertungen b ON ar.id = b.antwort_id
WHERE b.id IS NULL
"""
if args.limit:
query += f" LIMIT {args.limit}"
antworten = conn.execute(query).fetchall()
print(f"Zu bewerten: {len(antworten)}")
for i, row in enumerate(antworten, 1):
print(f"[{i}/{len(antworten)}] {row['kandidat']}...", end=" ", flush=True)
try:
result = analyze_answer(client, model, row['kandidat'], row['frage'], row['antwort'])
conn.execute("""
INSERT INTO bewertungen
(antwort_id, substanz_score, umfang, gwoe_score, gwoe_begruendung,
matrix_felder, staerken, schwaechen, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
row['id'],
result.get('substanz_score'),
result.get('umfang'),
result.get('gwoe_score'),
result.get('gwoe_begruendung'),
json.dumps(result.get('matrix_felder', [])),
json.dumps(result.get('staerken', [])),
json.dumps(result.get('schwaechen', [])),
model
))
conn.commit()
print(f"GWÖ: {result.get('gwoe_score', '?')}")
except Exception as e:
print(f"FEHLER: {e}")
time.sleep(0.5) # Rate Limiting
conn.close()
print("Fertig!")
if __name__ == '__main__':
main()
```
### 4. `aggregator.py` — Reports erstellen
```python
#!/usr/bin/env python3
"""
Aggregation und Report-Generierung
"""
import sqlite3
import json
from pathlib import Path
from datetime import datetime
def get_partei_stats(conn) -> list[dict]:
"""Statistiken pro Partei."""
cursor = conn.cursor()
cursor.execute("""
SELECT
k.partei_normalisiert AS partei,
COUNT(DISTINCT k.id) AS kandidaten,
COUNT(b.id) AS antworten,
ROUND(AVG(b.substanz_score), 2) AS avg_substanz,
ROUND(AVG(b.gwoe_score), 2) AS avg_gwoe
FROM kandidaten k
LEFT JOIN antworten_raw ar ON k.id = ar.kandidat_id
LEFT JOIN bewertungen b ON ar.id = b.antwort_id
WHERE b.id IS NOT NULL
GROUP BY k.partei_normalisiert
ORDER BY avg_gwoe DESC
""")
return [dict(row) for row in cursor.fetchall()]
def get_top_quotes(conn, partei: str, limit: int = 3) -> list[dict]:
"""Beste Zitate einer Partei."""
cursor = conn.cursor()
cursor.execute("""
SELECT
k.vorname || ' ' || k.nachname AS name,
k.kommune,
f.kurztext AS frage,
ar.antwort_erlaeuterung AS zitat,
b.gwoe_score
FROM bewertungen b
JOIN antworten_raw ar ON b.antwort_id = ar.id
JOIN kandidaten k ON ar.kandidat_id = k.id
JOIN fragen f ON ar.frage_id = f.id
WHERE k.partei_normalisiert = ?
AND ar.antwort_erlaeuterung IS NOT NULL
AND LENGTH(ar.antwort_erlaeuterung) > 50
ORDER BY b.gwoe_score DESC
LIMIT ?
""", (partei, limit))
return [dict(row) for row in cursor.fetchall()]
def generate_report(stats: list[dict], conn) -> str:
"""Generiert Markdown-Report."""
lines = [
"# GWÖ-Wahlprüfsteine Auswertung",
"",
f"*Stand: {datetime.now().strftime('%d.%m.%Y %H:%M')}*",
"",
"## Zusammenfassung",
"",
"| Partei | Kandidat:innen | Ø GWÖ | Ø Substanz |",
"|--------|---------------|-------|------------|",
]
for s in stats:
lines.append(f"| **{s['partei']}** | {s['kandidaten']} | {s['avg_gwoe']:.1f} | {s['avg_substanz']:.1f}/3 |")
lines.extend(["", "---", "", "## Details", ""])
for s in stats:
lines.append(f"### {s['partei']}{s['avg_gwoe']:.1f})")
lines.append("")
quotes = get_top_quotes(conn, s['partei'])
if quotes:
lines.append("**Beste Antworten:**")
for q in quotes:
zitat = q['zitat'][:200] + "..." if len(q['zitat']) > 200 else q['zitat']
lines.append(f"> *\"{zitat}\"*")
lines.append(f"> — {q['name']} ({q['kommune']}), GWÖ: {q['gwoe_score']:.1f}")
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--db', default='wahlpruefsteine.db')
parser.add_argument('--output', default='report.md')
args = parser.parse_args()
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
stats = get_partei_stats(conn)
report = generate_report(stats, conn)
Path(args.output).write_text(report)
print(f"Report erstellt: {args.output}")
conn.close()
if __name__ == '__main__':
main()
```
---
## Ausführung
### Vollständiger Workflow
```bash
# 1. Daten laden
python3 scraper.py --url "https://example.org/wahlpruefsteine"
# 2. KI-Bewertung (dauert je nach Anzahl)
python3 analyzer.py
# 3. Report generieren
python3 aggregator.py --output auswertung.md
```
### Status prüfen
```bash
sqlite3 wahlpruefsteine.db "
SELECT
(SELECT COUNT(*) FROM kandidaten) as kandidaten,
(SELECT COUNT(*) FROM antworten_raw) as antworten,
(SELECT COUNT(*) FROM bewertungen) as bewertet;
"
```
---
## Anpassungen für andere Datenquellen
### Andere HTML-Struktur
Passe `parse_html()` in `scraper.py` an:
```python
def parse_html(html_content: str) -> list[dict]:
soup = BeautifulSoup(html_content, 'html.parser')
# DEINE LOGIK HIER
# z.B. für andere Tabellenstruktur, div-basierte Layouts, etc.
return kandidaten
```
### Andere Fragen
Passe die `INSERT`-Statements in `schema.sql` an:
```sql
INSERT OR IGNORE INTO fragen (nummer, kurztext, volltext) VALUES
(1, 'Deine Frage 1', 'Vollständiger Text...'),
(2, 'Deine Frage 2', 'Vollständiger Text...'),
-- usw.
```
### Andere Parteien/Region
Passe `PARTEI_MAPPING` in `scraper.py` an:
```python
PARTEI_MAPPING = {
r'^cdu$': 'CDU', # statt CSU für andere Bundesländer
# deine Parteien...
}
```
### Andere LLM-Anbieter
Passe `get_api_client()` in `analyzer.py` an:
```python
# Für Anthropic Claude:
from anthropic import Anthropic
client = Anthropic(api_key=os.environ['ANTHROPIC_API_KEY'])
# Für lokales Ollama:
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
```
---
## Kosten-Übersicht
| Anbieter | Modell | ~Kosten/100 Antworten |
|----------|--------|----------------------|
| DashScope | qwen-plus | $0.06 |
| DashScope | qwen-turbo | $0.02 |
| OpenAI | gpt-4o-mini | $0.30 |
| Anthropic | claude-3-haiku | $0.20 |
| Ollama | lokal | $0.00 |
---
## Tipps
1. **Erst testen:** Starte mit `--limit 5` beim Analyzer
2. **HTML speichern:** `curl URL > data.html` für Offline-Entwicklung
3. **Logs prüfen:** Bei Fehlern einzelne Antworten manuell checken
4. **Schwellen anpassen:** Die GWÖ-Schwellen (5 für "empfohlen") ggf. senken
---
## Lizenz
MIT — Frei verwendbar, auch kommerziell.
---
*Anleitung erstellt: 29.03.2026*
*Fragen? → GWÖ-Community oder ECOnGOOD*