"""LLM-based analysis of parliamentary motions against GWÖ matrix.
Seit ADR 0008: Die reinen LLM-Calls laufen über den ``LlmBewerter``-Port
(``app/ports/llm_bewerter.py``); der Default-Adapter ist ``QwenBewerter``
(``app/adapters/qwen_bewerter.py``). Citation-Binding, Missing-Programme-
Check und Pydantic-Validierung bleiben hier in der Application-Schicht.
"""
import hashlib
import json
import logging
import re
from pathlib import Path
from typing import Optional
from .config import settings
from .models import Assessment
from .bundeslaender import BUNDESLAENDER
from .wahlprogramm_check import check_missing_programmes
from .ports.llm_bewerter import LlmBewerter, LlmRequest
from .wahlprogramme import (
find_relevant_quotes,
format_quote_for_prompt,
WAHLPROGRAMM_KONTEXT_FILES,
)
from .embeddings import (
get_relevant_quotes_for_antrag,
format_quotes_for_prompt,
reconstruct_zitate,
EMBEDDINGS_DB,
)
logger = logging.getLogger(__name__)
def _content_fingerprint(content: str) -> str:
"""Cheap, log-safe identifier for an LLM response: length + first 8 chars
of SHA-1. Lets us correlate retries without ever leaking the LLM's
actual output (which may contain sensitive Antrags-Inhalte). Issue
#57 Befund #4.
Wird nach ADR 0008 nur noch für post-LLM-Diagnostik (Pydantic-Validation)
gebraucht; der LLM-Retry-Loop selbst loggt in ``QwenBewerter``.
"""
if not content:
return "len=0"
h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8]
return f"len={len(content)} sha1={h}"
def get_default_bewerter() -> LlmBewerter:
"""Lazy-Instanziierung des Default-Adapters.
Der Adapter-Import ist lazy, damit Tests ohne ``openai``-Paket und ohne
DashScope-Credentials laufen (das Stubbing in ``conftest.py`` reicht,
solange niemand Top-Level importiert).
"""
from .adapters.qwen_bewerter import QwenBewerter
return QwenBewerter()
# 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"],
"konfidenz": "hoch | mittel | niedrig",
"shareThreads": "Schlagkräftiger Post für Threads/Instagram (max 500 Zeichen). Emoji, Engagement, CTA, konkret auf den Antrag bezogen. Hashtags: #Gemeinwohl #GWÖ + 2-3 thematische.",
"shareTwitter": "Prägnanter Tweet für X/Twitter (max 280 Zeichen). Knackig, pointiert, mit Emoji und 2 Hashtags.",
"shareMastodon": "Sachlicher aber ansprechender Post für Mastodon (max 500 Zeichen). Informativ, quellenbasiert, mit Kontext."
}
## Wichtige Regeln
- **Verbesserungsvorschläge**: Maximal 3! Fokussiere auf die wirkungsvollsten Änderungen, die den GWÖ-Score am meisten verbessern würden.
- **Zitate**: Jedes Zitat MUSS auf einen `[Qn]`-Chunk aus dem mitgelieferten Kontext verweisen und den `text`-String **wörtlich** (mind. 5 zusammenhängende Wörter) aus genau diesem Chunk übernehmen. Kein Paraphrasieren, kein Cross-Referencing aus dem Trainingswissen. Wenn kein Chunk passt: lass `zitate` leer — lieber 0 Zitate als ein erfundenes. Die ausführliche ZITATEREGEL steht im wahlprogramm_zitate-Block.
- **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: −− / − / ○ / + / ++)
- **Konfidenz**: Selbsteinschätzung der Bewertungssicherheit:
- "hoch": Antrag ist eindeutig, GWÖ-Bezug klar, genügend Kontext
- "mittel": Antrag ist mehrdeutig oder berührt Nischenthemen
- "niedrig": Antrag ist sehr kurz, unklar oder fachfremd — Bewertung unsicher"""
def get_bundesland_context(bundesland: str) -> str:
"""Build the LLM context block for a specific state.
Liest Regierungsfraktionen und Parlamentsname aus ``BUNDESLAENDER`` und
die optionale Wahlprogramm-Übersichtsdatei aus ``WAHLPROGRAMM_KONTEXT_FILES``.
Federal-level Grundsatzprogramme (parteiprogramme.md) sind bundesländer-
übergreifend.
Raises:
ValueError: bei unbekanntem oder inaktivem Bundesland. Pre-#5
existierte hier ein silent fallback auf NRW — bewusst entfernt,
damit Konfigurationslücken früh sichtbar werden.
"""
bl = BUNDESLAENDER.get(bundesland)
if bl is None:
raise ValueError(f"Unbekanntes Bundesland: {bundesland}")
if not bl.aktiv:
raise ValueError(
f"Bundesland {bundesland} ist nicht aktiv (siehe bundeslaender.py)"
)
wahlprogramm_kontext_file = WAHLPROGRAMM_KONTEXT_FILES.get(bundesland)
wahlprogramme_text = (
load_context_file(wahlprogramm_kontext_file) if wahlprogramm_kontext_file else ""
)
parteiprogramme_text = load_context_file("parteiprogramme.md")
return f"""
## Parlament
{bl.parlament_name} (Wahlperiode {bl.wahlperiode}, seit {bl.wahlperiode_start})
## Wahlprogramme {bl.name}
{wahlprogramme_text or '(keine Übersichtsdatei hinterlegt)'}
## Grundsatzprogramme der Parteien
{parteiprogramme_text}
## Regierungsfraktionen in {bl.name}
{', '.join(bl.regierungsfraktionen)}
## Im Landtag vertretene Fraktionen
{', '.join(bl.landtagsfraktionen)}
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",
bewerter: Optional[LlmBewerter] = None,
) -> Assessment:
"""Analyze a parliamentary motion using the LLM.
Args:
text: Antrag-Volltext (plain).
bundesland: BL-Code aus ``bundeslaender.py``.
model: LLM-Modell (wird vom Default-Adapter ``QwenBewerter``
akzeptiert; andere Adapter können eigene Modell-Namen nutzen).
bewerter: ``LlmBewerter``-Implementierung. Default: ``QwenBewerter``
(DashScope/Qwen). Tests reichen hier ``FakeLlmBewerter``.
Nach ADR 0008: der HTTP-Call samt Retry-Loop lebt im Adapter; hier
bleibt nur noch die Application-Logik (Prompt-Komposition, Semantic-
Search, Citation-Binding, Missing-Programme-Check, Pydantic-Validation
und Domain-Invarianten-Warnings).
"""
if bewerter is None:
bewerter = get_default_bewerter()
system_prompt = get_system_prompt()
bundesland_context = get_bundesland_context(bundesland)
# Extrahiere Fraktionen aus Text (einfache Heuristik): Welche der im
# Landtag vertretenen Parteien werden im Antrag genannt? Quelle ist
# BUNDESLAENDER.landtagsfraktionen — nicht WAHLPROGRAMME, weil wir
# auch Fraktionen erkennen wollen, für die wir (noch) kein Wahlprogramm
# hinterlegt haben.
landtagsfraktionen = BUNDESLAENDER[bundesland].landtagsfraktionen
text_lower = text.lower()
fraktionen = [
partei for partei in landtagsfraktionen
if partei in text or partei.lower() in text_lower
]
# Suche relevante Zitate via semantische Suche (Embeddings)
quotes_context = ""
semantic_quotes: dict = {}
if EMBEDDINGS_DB.exists():
try:
semantic_quotes = get_relevant_quotes_for_antrag(
text, fraktionen, bundesland=bundesland, top_k_per_partei=5,
)
quotes_context = format_quotes_for_prompt(
semantic_quotes, searched_parties=fraktionen,
)
except (NameError, AttributeError, TypeError, KeyError):
# Programmierfehler (z.B. der partei_upper-Refactor-Rest aus
# #55/eb045d0, der zu Issue #60 führte) sollen hart fehlschlagen
# statt still auf den schwächeren Keyword-Pfad zurückzufallen.
raise
except Exception:
logger.exception("Semantic search failed, falling back to keyword search")
quotes = find_relevant_quotes(text, fraktionen, bundesland=bundesland)
quotes_context = format_quote_for_prompt(quotes)
else:
# Fallback to keyword search
quotes = find_relevant_quotes(text, fraktionen, bundesland=bundesland)
quotes_context = format_quote_for_prompt(quotes)
user_prompt = f"""Analysiere den folgenden Antrag:
{bundesland_context}
{quotes_context if quotes_context else "Keine relevanten Zitate gefunden."}
{text}
**PFLICHT-FRAKTIONEN:** Du MUSST ALLE folgenden Fraktionen der aktuellen Wahlperiode in `wahlprogrammScores` bewerten — keine auslassen:
{', '.join(BUNDESLAENDER[bundesland].landtagsfraktionen)}
Bewerte nach GWÖ-Matrix 2.0 für Gemeinden:
1. GWÖ-Treue (0-10) mit Matrix-Zuordnung und Symbolen (++/+/○/−/−−)
2. Wahlprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
3. Parteiprogrammtreue JEDER der oben genannten Pflicht-Fraktionen (0-10)
4. Bis zu 3 Verbesserungsvorschläge in Redline-Syntax
5. Themen-Tags für Kategorisierung
**ZITATEREGEL — STRIKT:** In jedem ``wahlprogrammScores[].wahlprogramm.zitate[].quelle``
und ``parteiprogrammScores[].parteiprogramm.zitate[].quelle`` musst du **wortgleich**
einen der oben in ```` aufgelisteten Quellen-Labels (Programm-Name +
Seite) übernehmen — z.B. ``"CDU Mecklenburg-Vorpommern Wahlprogramm 2021, S. 33"``.
Erfinde keine Quellen aus deinem Trainingswissen. Nimm keine Quelle aus einem anderen
Bundesland (z.B. NRW 2022) als die hier aufgelisteten — selbst wenn dir die dortigen
Programme bekannter sind. Findest du oben für eine Partei keinen passenden Chunk, lass
``zitate`` leer (``[]``) und vermerke das in der ``begruendung``.
Ausgabe als reines JSON ohne Markdown-Codeblöcke."""
# LLM-Call über den Port. Retry-Loop + Markdown-Stripping wohnen im
# Adapter (``QwenBewerter``). Bei exhausted retries wirft er
# json.JSONDecodeError — wir lassen das durchpropagieren wie vor der
# Migration.
request = LlmRequest(
system_prompt=system_prompt,
user_prompt=user_prompt,
model=model,
)
data = await bewerter.bewerte(request)
# Issue #60 Option B — server-side reconstruction of citation quelle/url
# from the actually retrieved chunks, before Pydantic validation. Der LLM
# ist nicht mehr Quelle für die Quellen-Labels; wir ersetzen sie durch
# das kanonische _chunk_source_label und droppen Zitate ohne Chunk-Match.
if semantic_quotes:
data = reconstruct_zitate(data, semantic_quotes)
# #128: Fehlende Wahlprogramme server-seitig erkennen und eintragen. Der
# LLM bekommt diese Information nicht — sie basiert auf der lokalen
# Registry, nicht auf dem LLM-Wissen.
missing = check_missing_programmes(bundesland, landtagsfraktionen)
if missing:
logger.warning(
"Fehlende Wahlprogramme für %s in %s: %s",
landtagsfraktionen, bundesland, missing,
)
data["fehlendeProgramme"] = missing
# Pydantic-Validation: harter Check auf Schema-Drift.
assessment = Assessment.model_validate(data)
# Tag-4-Invarianten-Warnings (ADR 0008): Verstöße gegen das Score-Cap
# werden geloggt, aber nicht geworfen — das LLM soll lernen, nicht der
# Produktivbetrieb brechen.
if assessment.verletzt_score_cap():
logger.warning(
"Assessment %s verletzt Score-Cap: gwoe_score=%.1f bei "
"fundamental-kritischem Matrix-Feld (rating≤-4)",
assessment.drucksache, assessment.gwoe_score,
)
return assessment