gwoe-wahlpruefsteine/wahlpruefsteine/analyzer.py
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

278 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""
GWÖ-Wahlprüfsteine Analyzer
Bewertet Antworten mit Qwen LLM nach GWÖ-Kriterien.
"""
import sqlite3
import json
import os
import time
from pathlib import Path
from dataclasses import dataclass
from openai import OpenAI
# GWÖ-Matrix 2.0 Kontext (gekürzt für Prompt)
GWOE_MATRIX_CONTEXT = """
## GWÖ-Matrix 2.0 für Gemeinden
### Die 5 Werte (Spalten)
1. **Menschenwürde** — Rechtsstaatsprinzip, Grundrechte
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
### Die 5 Berührungsgruppen (Zeilen)
- **A**: Lieferant:innen, Dienstleister:innen (Beschaffung, Lieferketten)
- **B**: Finanzpartner:innen, Steuerzahler:innen (Haushalt, Finanzpolitik)
- **C**: Politische Führung, Verwaltung (Mandatsträger:innen, Mitarbeitende)
- **D**: Bürger:innen und Wirtschaft (Daseinsvorsorge, lokale Wirkung)
- **E**: Staat, Gesellschaft, Natur (überregional, Zukunft)
### Relevante Felder für Kommunalpolitik
- **D5**: Demokratische Einbindung der Bürger:innen
- **D2**: Gesamtwohl der Gemeinde
- **C2**: Gemeinwohlorientierte Zielvereinbarung
- **A3/A4**: Nachhaltige/soziale Beschaffung
- **B5**: Partizipation in Finanzpolitik
### Bewertungsskala
- 7-10: Vorbildlich (innovative Maßnahmen)
- 4-6: Erfahren (erkennbare Verbesserungen)
- 2-3: Fortgeschritten (erste Maßnahmen)
- 1: Erste Schritte
- 0: Basislinie
"""
SYSTEM_PROMPT = """Du bist ein Experte für Gemeinwohl-Ökonomie (GWÖ) und analysierst Antworten auf Wahlprüfsteine.
{matrix_context}
## Deine Aufgabe
Bewerte die Antwort eines Bürgermeister-Kandidaten auf eine Wahlprüfstein-Frage.
## Ausgabeformat (JSON)
{{
"substanz_score": 0-3,
"substanz_begruendung": "Kurze Begründung",
"umfang": "keine|kurz|mittel|ausführlich",
"gwoe_score": 0.0-10.0,
"gwoe_begruendung": "2-3 Sätze mit Matrix-Bezug",
"matrix_felder": ["D5", "C2"],
"staerken": ["Punkt 1", "Punkt 2"],
"schwaechen": ["Punkt 1"]
}}
### Substanz-Score
- 0: Keine Antwort oder nur Ja/Nein ohne Erläuterung
- 1: Ausweichend, Floskeln, keine konkreten Maßnahmen
- 2: Substanziell, erkennbare Haltung, erste Ideen
- 3: Umfassend mit konkreten Maßnahmen und Zeitrahmen
### Umfang
- keine: Leer oder nur "/"
- kurz: 1-2 Sätze
- mittel: 3-5 Sätze
- ausführlich: >5 Sätze mit Details
### GWÖ-Score
Bewerte nach Matrix 2.0. Berücksichtige:
- Konkrete GWÖ-Bezüge (Gemeinwohl-Bilanz, Matrix)
- Allgemeine Gemeinwohl-Orientierung
- Partizipation und Transparenz
- Ökologische und soziale Aspekte
- Umsetzbarkeit der genannten Maßnahmen
Antworte NUR mit validem JSON, keine Erklärungen davor oder danach.""".format(matrix_context=GWOE_MATRIX_CONTEXT)
@dataclass
class AntwortZuBewerten:
antwort_id: int
kandidat: str
kommune: str
partei: str
frage_nr: int
frage_text: str
antwort_kurz: str | None
antwort_erlaeuterung: str | None
def get_unbewertete_antworten(conn: sqlite3.Connection, limit: int = None) -> list[AntwortZuBewerten]:
"""Holt alle noch nicht bewerteten Antworten."""
query = """
SELECT
ar.id as antwort_id,
k.vorname || ' ' || k.nachname as kandidat,
k.kommune,
k.partei_normalisiert as partei,
f.nummer as frage_nr,
f.volltext as frage_text,
ar.antwort_kurz,
ar.antwort_erlaeuterung
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
ORDER BY k.id, f.nummer
"""
if limit:
query += f" LIMIT {limit}"
cursor = conn.execute(query)
return [AntwortZuBewerten(**dict(row)) for row in cursor.fetchall()]
def build_user_prompt(antwort: AntwortZuBewerten) -> str:
"""Baut den User-Prompt für eine Antwort."""
erlaeuterung = antwort.antwort_erlaeuterung or "(keine Erläuterung)"
kurz = antwort.antwort_kurz or "(keine Angabe)"
return f"""## Wahlprüfstein-Frage {antwort.frage_nr}
**Frage:** {antwort.frage_text}
## Antwort von {antwort.kandidat} ({antwort.partei}), Kandidat:in für {antwort.kommune}
**Grundsätzlich:** {kurz}
**Erläuterung:** {erlaeuterung}
---
Bewerte diese Antwort nach den GWÖ-Kriterien."""
def analyze_with_llm(client: OpenAI, antwort: AntwortZuBewerten, model: str = "qwen-plus") -> dict:
"""Analysiert eine Antwort mit dem LLM."""
user_prompt = build_user_prompt(antwort)
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"}
)
result = json.loads(response.choices[0].message.content)
# Wortanzahl berechnen
text = (antwort.antwort_erlaeuterung or "")
result['wortanzahl'] = len(text.split()) if text else 0
return result
def save_bewertung(conn: sqlite3.Connection, antwort_id: int, bewertung: dict, model: str):
"""Speichert eine Bewertung in der Datenbank."""
conn.execute("""
INSERT OR REPLACE INTO bewertungen
(antwort_id, substanz_score, umfang, wortanzahl, gwoe_score, gwoe_begruendung,
matrix_felder, staerken, schwaechen, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
antwort_id,
bewertung.get('substanz_score'),
bewertung.get('umfang'),
bewertung.get('wortanzahl'),
bewertung.get('gwoe_score'),
bewertung.get('gwoe_begruendung'),
json.dumps(bewertung.get('matrix_felder', []), ensure_ascii=False),
json.dumps(bewertung.get('staerken', []), ensure_ascii=False),
json.dumps(bewertung.get('schwaechen', []), ensure_ascii=False),
model
))
conn.commit()
def main():
import argparse
parser = argparse.ArgumentParser(description='GWÖ-Wahlprüfsteine Analyzer')
parser.add_argument('--db', type=Path, default=Path(__file__).parent / 'wahlpruefsteine.db',
help='Pfad zur SQLite-Datenbank')
parser.add_argument('--model', default='qwen-plus',
choices=['qwen-plus', 'qwen-max', 'qwen-turbo'],
help='Qwen-Modell')
parser.add_argument('--limit', type=int, help='Maximal zu bewertende Antworten')
parser.add_argument('--delay', type=float, default=0.5, help='Pause zwischen API-Calls (Sekunden)')
parser.add_argument('--dry-run', action='store_true', help='Nur anzeigen, nicht speichern')
parser.add_argument('--verbose', '-v', action='store_true', help='Ausführliche Ausgabe')
args = parser.parse_args()
# API-Key aus Keychain
api_key = os.popen("security find-generic-password -s qwen-api -w 2>/dev/null").read().strip()
if not api_key:
api_key = os.environ.get('DASHSCOPE_API_KEY')
if not api_key:
print("FEHLER: Kein API-Key gefunden (Keychain 'qwen-api' oder DASHSCOPE_API_KEY)")
return 1
# DashScope Client
client = OpenAI(
api_key=api_key,
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
)
# Datenbank öffnen
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
# Unbewertete Antworten holen
antworten = get_unbewertete_antworten(conn, args.limit)
print(f"Zu bewerten: {len(antworten)} Antworten")
if not antworten:
print("Alle Antworten bereits bewertet.")
return 0
# Bewerten
erfolg = 0
fehler = 0
for i, antwort in enumerate(antworten, 1):
print(f"[{i}/{len(antworten)}] {antwort.kandidat} - Frage {antwort.frage_nr}...", end=" ", flush=True)
try:
bewertung = analyze_with_llm(client, antwort, args.model)
if args.verbose:
print(f"\n GWÖ: {bewertung.get('gwoe_score')}, Substanz: {bewertung.get('substanz_score')}")
print(f" Begründung: {bewertung.get('gwoe_begruendung', '')[:100]}...")
if not args.dry_run:
save_bewertung(conn, antwort.antwort_id, bewertung, args.model)
print(f"OK (GWÖ: {bewertung.get('gwoe_score', '?')})")
erfolg += 1
except Exception as e:
print(f"FEHLER: {e}")
fehler += 1
if i < len(antworten):
time.sleep(args.delay)
conn.close()
print(f"\nFertig: {erfolg} erfolgreich, {fehler} Fehler")
if args.dry_run:
print("(Dry-Run — nichts gespeichert)")
return 0 if fehler == 0 else 1
if __name__ == '__main__':
exit(main())