Atomic refactor of the three modules that previously hardcoded NRW
behaviour. After this commit, every analysis path consults the central
BUNDESLAENDER registry for governing fractions, parliament name, and
state metadata.
wahlprogramme.py
- WAHLPROGRAMME is now nested {bundesland: {partei: meta}}; NRW data
hoisted unchanged under the "NRW" key.
- New WAHLPROGRAMM_KONTEXT_FILES dict maps a state to its overview
markdown file (currently only NRW).
- find_relevant_quotes(text, fraktionen, bundesland) — bundesland is
now a required positional. Governing fractions for the requested
state are merged with the submitting fractions before lookup.
- Helpers get_wahlprogramm() and parteien_mit_wahlprogramm() expose
the new shape to other modules.
- ValueError on unknown bundesland (no silent fallback).
embeddings.py
- Schema migration in init_embeddings_db: adds a `bundesland` column
to the chunks table when missing, plus an index, and backfills
existing rows from the PROGRAMME registry. Grundsatzprogramme
(federal level) keep bundesland NULL by design.
- find_relevant_chunks accepts a bundesland filter that matches state
rows OR NULL — so federal Grundsatzprogramme remain visible to every
analysis.
- get_relevant_quotes_for_antrag(text, fraktionen, bundesland, …) —
bundesland required, governing fractions read from BUNDESLAENDER
instead of hardcoded ["CDU","GRÜNE"]. Order-preserving dedup
replaces the previous set-based merge.
- index_programm now writes the bundesland column on insert.
- Dropped the hardcoded "Wahlprogramm NRW 2022" label in
format_quotes_for_prompt — bundesland context is implicit in the
surrounding prompt block.
analyzer.py
- get_bundesland_context reads parlament_name, regierungsfraktionen,
landtagsfraktionen and the optional WAHLPROGRAMM_KONTEXT_FILES entry
from the central registry. Throws ValueError on unknown OR inactive
bundesland — kills the silent NRW fallback that previously masked
configuration gaps.
- The Antragsteller-detection heuristic now iterates
BUNDESLAENDER[bundesland].landtagsfraktionen instead of
WAHLPROGRAMME.keys(), so we recognise parties for which we don't
yet have a Wahlprogramm PDF.
- Both quote lookups (semantic + keyword fallback) now receive the
bundesland.
Resolves issue #5. Foundation for #2 (LSA), #3 (Berlin), #4 (MV).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
258 lines
8.3 KiB
Python
258 lines
8.3 KiB
Python
"""Wahlprogramm-Referenzsystem mit Zitaten und Seitenreferenzen.
|
|
|
|
Bundesland-bewusst seit Issue #5: ``WAHLPROGRAMME[bundesland][partei]`` statt
|
|
flach. Konsumiert ``BUNDESLAENDER`` aus ``bundeslaender.py`` für die
|
|
Regierungsfraktionen-Lookup und für Plausibilitätsprüfungen.
|
|
|
|
Verantwortlich für die schlüsselwortbasierte Fallback-Suche in den
|
|
paged-Textversionen der Wahlprogramme. Die semantische Suche lebt in
|
|
``embeddings.py``.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from .bundeslaender import BUNDESLAENDER
|
|
|
|
|
|
# WAHLPROGRAMME[bundesland][partei] -> Metadaten
|
|
# Beim Hinzufügen eines neuen Bundeslands: Eintrag hier UND parallel
|
|
# in WAHLPROGRAMM_KONTEXT_FILES.
|
|
WAHLPROGRAMME: dict[str, dict[str, dict]] = {
|
|
"NRW": {
|
|
"CDU": {
|
|
"file": "cdu-nrw-2022.pdf",
|
|
"titel": "Machen, worauf es ankommt",
|
|
"partei": "CDU NRW",
|
|
"jahr": 2022,
|
|
"seiten": 109,
|
|
},
|
|
"SPD": {
|
|
"file": "spd-nrw-2022.pdf",
|
|
"titel": "Unser Land von morgen",
|
|
"partei": "SPD NRW",
|
|
"jahr": 2022,
|
|
"seiten": 116,
|
|
},
|
|
"GRÜNE": {
|
|
"file": "gruene-nrw-2022.pdf",
|
|
"titel": "Von hier an Zukunft",
|
|
"partei": "BÜNDNIS 90/DIE GRÜNEN NRW",
|
|
"jahr": 2022,
|
|
"seiten": 100,
|
|
},
|
|
"FDP": {
|
|
"file": "fdp-nrw-2022.pdf",
|
|
"titel": "Nie gab es mehr zu tun",
|
|
"partei": "FDP NRW",
|
|
"jahr": 2022,
|
|
"seiten": 96,
|
|
},
|
|
"AfD": {
|
|
"file": "afd-nrw-2022.pdf",
|
|
"titel": "Wer sonst.",
|
|
"partei": "AfD NRW",
|
|
"jahr": 2022,
|
|
"seiten": 68,
|
|
},
|
|
},
|
|
}
|
|
|
|
# Pro Bundesland: Markdown-Übersichtsdatei mit Wahlprogramm-Zusammenfassungen,
|
|
# wird als Kontext in den LLM-Prompt geladen (nicht für die Suche).
|
|
WAHLPROGRAMM_KONTEXT_FILES: dict[str, str] = {
|
|
"NRW": "wahlprogramme-nrw-2022.md",
|
|
}
|
|
|
|
REFERENZEN_PATH = Path(__file__).parent / "static" / "referenzen"
|
|
KONTEXT_PATH = Path(__file__).parent / "kontext"
|
|
|
|
|
|
def get_wahlprogramm(bundesland: str, partei: str) -> Optional[dict]:
|
|
"""Liefert die Wahlprogramm-Metadaten oder None, wenn keins vorliegt."""
|
|
return WAHLPROGRAMME.get(bundesland, {}).get(partei)
|
|
|
|
|
|
def parteien_mit_wahlprogramm(bundesland: str) -> list[str]:
|
|
"""Liste der Parteien, für die im gegebenen Bundesland ein Wahlprogramm vorliegt."""
|
|
return list(WAHLPROGRAMME.get(bundesland, {}).keys())
|
|
|
|
|
|
def load_wahlprogramm_text(bundesland: str, partei: str) -> dict[int, str]:
|
|
"""Lädt Wahlprogramm-Text mit Seitenzuordnung.
|
|
|
|
Returns:
|
|
Dict mit Seitennummer -> Text. Leer, wenn kein Wahlprogramm hinterlegt
|
|
oder die paged-Textdatei fehlt.
|
|
"""
|
|
info = get_wahlprogramm(bundesland, partei)
|
|
if not info:
|
|
return {}
|
|
|
|
# Versuche paged-Textdatei zu laden
|
|
paged_file = KONTEXT_PATH / info['file'].replace('.pdf', '-paged.txt')
|
|
if not paged_file.exists():
|
|
# Fallback: Normale Textdatei
|
|
txt_file = KONTEXT_PATH / info['file'].replace('.pdf', '.txt')
|
|
if txt_file.exists():
|
|
return {1: txt_file.read_text()}
|
|
return {}
|
|
|
|
text = paged_file.read_text()
|
|
pages = {}
|
|
current_page = 1
|
|
current_text = []
|
|
|
|
for line in text.split('\n'):
|
|
if line.startswith('--- PAGE '):
|
|
if current_text:
|
|
pages[current_page] = '\n'.join(current_text)
|
|
match = re.search(r'PAGE (\d+)', line)
|
|
if match:
|
|
current_page = int(match.group(1))
|
|
current_text = []
|
|
else:
|
|
current_text.append(line)
|
|
|
|
if current_text:
|
|
pages[current_page] = '\n'.join(current_text)
|
|
|
|
return pages
|
|
|
|
|
|
def search_wahlprogramm(
|
|
bundesland: str,
|
|
partei: str,
|
|
keywords: list[str],
|
|
max_results: int = 3,
|
|
) -> list[dict]:
|
|
"""Sucht relevante Passagen in einem Wahlprogramm.
|
|
|
|
Args:
|
|
bundesland: Bundesland-Code (NRW, LSA, …)
|
|
partei: Partei-Kürzel (CDU, SPD, GRÜNE, FDP, AfD, …)
|
|
keywords: Suchbegriffe
|
|
max_results: Maximale Anzahl Ergebnisse
|
|
|
|
Returns:
|
|
Liste von {bundesland, partei, seite, text, score, url, quelle}
|
|
"""
|
|
info = get_wahlprogramm(bundesland, partei)
|
|
if not info:
|
|
return []
|
|
|
|
pages = load_wahlprogramm_text(bundesland, partei)
|
|
if not pages:
|
|
return []
|
|
|
|
results = []
|
|
keywords_lower = [k.lower() for k in keywords]
|
|
|
|
for page_num, text in pages.items():
|
|
text_lower = text.lower()
|
|
score = sum(1 for kw in keywords_lower if kw in text_lower)
|
|
|
|
if score > 0:
|
|
paragraphs = text.split('\n\n')
|
|
relevant_paragraphs = []
|
|
|
|
for para in paragraphs:
|
|
para_clean = para.strip()
|
|
if len(para_clean) < 50:
|
|
continue
|
|
para_lower = para_clean.lower()
|
|
if any(kw in para_lower for kw in keywords_lower):
|
|
relevant_paragraphs.append(para_clean)
|
|
|
|
if relevant_paragraphs:
|
|
best_para = max(
|
|
relevant_paragraphs,
|
|
key=lambda p: sum(1 for kw in keywords_lower if kw in p.lower()),
|
|
)
|
|
if len(best_para) > 300:
|
|
best_para = best_para[:297] + "..."
|
|
|
|
results.append({
|
|
"partei": partei,
|
|
"bundesland": bundesland,
|
|
"seite": page_num,
|
|
"text": best_para,
|
|
"score": score,
|
|
"url": f"/static/referenzen/{info['file']}#page={page_num}",
|
|
"quelle": f"{info['partei']} Wahlprogramm {info['jahr']}, S. {page_num}",
|
|
})
|
|
|
|
results.sort(key=lambda x: x['score'], reverse=True)
|
|
return results[:max_results]
|
|
|
|
|
|
def find_relevant_quotes(
|
|
antrag_text: str,
|
|
fraktionen: list[str],
|
|
bundesland: str,
|
|
) -> dict[str, list[dict]]:
|
|
"""Findet relevante Zitate aus Wahlprogrammen für einen Antrag.
|
|
|
|
Args:
|
|
antrag_text: Volltext des Antrags
|
|
fraktionen: Liste der einreichenden Fraktionen
|
|
bundesland: Bundesland-Code (Pflichtparameter; bestimmt, welche
|
|
Wahlprogramme durchsucht werden und welche Regierungsfraktionen
|
|
zusätzlich einbezogen werden).
|
|
|
|
Returns:
|
|
Dict mit Partei -> Liste von Zitaten
|
|
"""
|
|
if bundesland not in BUNDESLAENDER:
|
|
raise ValueError(f"Unbekanntes Bundesland: {bundesland}")
|
|
|
|
# Extrahiere Keywords aus Antrag (einfache Heuristik)
|
|
stopwords = {
|
|
'der', 'die', 'das', 'und', 'oder', 'für', 'mit', 'von', 'zu', 'auf',
|
|
'ist', 'sind', 'wird', 'werden', 'hat', 'haben', 'ein', 'eine', 'einer',
|
|
'den', 'dem', 'des', 'im', 'in', 'an', 'bei', 'nach', 'über', 'unter',
|
|
'durch', 'als', 'auch', 'nur', 'noch', 'aber', 'wenn', 'dass', 'sich',
|
|
'nicht', 'wie', 'so', 'aus', 'zum', 'zur', 'vom', 'beim', 'seit', 'bis',
|
|
}
|
|
|
|
words = re.findall(r'\b[A-Za-zäöüÄÖÜß]{4,}\b', antrag_text)
|
|
keywords = [w for w in words if w.lower() not in stopwords]
|
|
|
|
word_freq: dict[str, int] = {}
|
|
for w in keywords:
|
|
w_lower = w.lower()
|
|
word_freq[w_lower] = word_freq.get(w_lower, 0) + 1
|
|
|
|
top_keywords = sorted(word_freq.keys(), key=lambda x: word_freq[x], reverse=True)[:15]
|
|
|
|
# Antragsteller + Regierungsfraktionen des Bundeslands
|
|
regierungsfraktionen = BUNDESLAENDER[bundesland].regierungsfraktionen
|
|
parteien_to_search = set(fraktionen) | set(regierungsfraktionen)
|
|
|
|
quotes: dict[str, list[dict]] = {}
|
|
for partei in parteien_to_search:
|
|
if get_wahlprogramm(bundesland, partei):
|
|
found = search_wahlprogramm(bundesland, partei, top_keywords, max_results=2)
|
|
if found:
|
|
quotes[partei] = found
|
|
|
|
return quotes
|
|
|
|
|
|
def format_quote_for_prompt(quotes: dict[str, list[dict]]) -> str:
|
|
"""Formatiert Zitate für den LLM-Prompt."""
|
|
if not quotes:
|
|
return ""
|
|
|
|
lines = ["\n## Relevante Passagen aus Wahlprogrammen\n"]
|
|
lines.append("Nutze diese Originalzitate als Belege in deiner Bewertung:\n")
|
|
|
|
for partei, zitate in quotes.items():
|
|
for z in zitate:
|
|
lines.append(f"### {z['quelle']}")
|
|
lines.append(f'> "{z["text"]}"')
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|