"""LLM-based analysis of parliamentary motions against GWÖ matrix.""" import hashlib import json import logging import re from pathlib import Path from openai import AsyncOpenAI from .config import settings from .models import Assessment from .bundeslaender import BUNDESLAENDER 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.""" if not content: return "len=0" h = hashlib.sha1(content.encode("utf-8", errors="replace")).hexdigest()[:8] return f"len={len(content)} sha1={h}" # 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**: 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: −− / − / ○ / + / ++)""" 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") -> 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): 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 folgende Fraktionen in `wahlprogrammScores` bewerten: {', '.join(dict.fromkeys(fraktionen + BUNDESLAENDER[bundesland].regierungsfraktionen))} 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.""" # 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) # Issue #60 Option B — server-side reconstruction of citation # quelle/url from the actually retrieved chunks, before Pydantic # validation. The LLM is no longer trusted for the citation source # label; we replace it with the canonical _chunk_source_label of # the chunk whose text actually contains the cited snippet, and # drop any zitat that can't be located in any retrieved chunk. if semantic_quotes: data = reconstruct_zitate(data, semantic_quotes) # Convert to Assessment model return Assessment.model_validate(data) except json.JSONDecodeError as e: last_error = e logger.warning( "LLM JSON parse error attempt %d/%d (%s) — content %s", attempt + 1, max_retries, e, _content_fingerprint(content), ) if attempt < max_retries - 1: continue else: # Letzter Fehlversuch — Fingerprint reicht zur Forensik; # Volltext darf nicht ins Log, weil er Antrag-Inhalte enthält logger.error( "LLM JSON parsing exhausted retries, content %s", _content_fingerprint(content), ) raise