Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum." Fix C — Force-Honesty im Prompt: - format_quotes_for_prompt akzeptiert neuen Parameter searched_parties. Parteien, für die kein Chunk retrievt wurde, werden explizit als "KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0, zitate: [], Begründung: keine Quellen im Index". - Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0." Das ist die strukturelle Lösung — das LLM darf nicht mehr raten. - analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als searched_parties durchgereicht. Fix B — UI-Transparenz: - index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0: "Keine belegbaren Quellen im Index gefunden — Score basiert auf LLM-Einschätzung, nicht auf verifizierten Programm-Stellen." - Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet), keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten idealerweise Score=0 haben, aber die Warning ist ein Fallback für den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt. Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit — sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck. Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template). Refs: #63, ADR 0001
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""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:
|
||
|
||
<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
|
||
|
||
**ZITATEREGEL — STRIKT:** In jedem ``wahlprogrammScores[].wahlprogramm.zitate[].quelle``
|
||
und ``parteiprogrammScores[].parteiprogramm.zitate[].quelle`` musst du **wortgleich**
|
||
einen der oben in ``<wahlprogramm_zitate>`` 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
|