274 lines
10 KiB
Python
274 lines
10 KiB
Python
|
|
"""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
|