"""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 "" USER_PROMPT_TEMPLATE = """Analysiere den folgenden Antrag: {bundesland_context} {quotes_context} {text} **PFLICHT-FRAKTIONEN:** Du MUSST ALLE folgenden Fraktionen der aktuellen Wahlperiode in `wahlprogrammScores` bewerten — keine auslassen: {pflicht_fraktionen} 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.""" def get_user_prompt_template() -> str: """Public Template-String fuer Transparenz-Seite (#145). Enthaelt die Platzhalter ``{bundesland_context}``, ``{quotes_context}``, ``{text}`` und ``{pflicht_fraktionen}`` — gerendert wird in ``analyze_text`` direkt via ``.format(...)``. """ return USER_PROMPT_TEMPLATE 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 = USER_PROMPT_TEMPLATE.format( bundesland_context=bundesland_context, quotes_context=quotes_context if quotes_context else "Keine relevanten Zitate gefunden.", text=text, pflicht_fraktionen=", ".join(BUNDESLAENDER[bundesland].landtagsfraktionen), ) # 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