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

20 KiB
Raw Permalink Blame History

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

mkdir -p ~/projekte/wahlpruefsteine-auswertung
cd ~/projekte/wahlpruefsteine-auswertung

2. Python Virtual Environment

python3 -m venv .venv
source .venv/bin/activate  # Linux/macOS
# oder: .venv\Scripts\activate  # Windows

3. Abhängigkeiten installieren

pip install beautifulsoup4 requests openai

4. API-Key konfigurieren

Option A: Umgebungsvariable (einfach)

export DASHSCOPE_API_KEY="sk-xxx..."

Option B: macOS Keychain (sicher)

security add-generic-password -s qwen-api -a $USER -w "sk-xxx..."

Option C: .env-Datei

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

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

#!/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

#!/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

#!/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

# 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

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:

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:

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:

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:

# 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