- 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
278 lines
8.8 KiB
Python
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())
|