Brings Sachsen-Anhalt online as the second supported Bundesland after
NRW. Closes the gap that issue #2 left open: with the PortalaAdapter
already in place from c7242f8, this commit adds the reference data and
flips the activation switch.
Wahlprogramme (LTW Sachsen-Anhalt 06.06.2021)
- Six PDFs added under app/static/referenzen/{cdu,spd,gruene,fdp,afd,
linke}-lsa-2021.pdf, plus paged plain-text extractions under
app/kontext/*.txt for the keyword fallback search.
- Sources verified by hand:
- CDU "Unsere Heimat. Unsere Verantwortung." (cdulsa.de, 82 pages)
- SPD "Zusammenhalt und neue Chancen" (FES library, 77 pages)
- GRÜNE "Verlässlich für Sachsen-Anhalt" (gruene-lsa.de, 164 pages)
- FDP "Wahlprogramm zur Landtagswahl 2021" (Naumann-Stiftung, 76 pages)
- AfD "Alles für unsere Heimat!" (klimawahlen.de mirror, 64 pages)
- LINKE "Wahlprogramm zur Landtagswahl 2021" (dielinke-sachsen-anhalt.de,
88 pages)
- The CDU PDF was the trickiest: KAS blocks bot downloads via
Cloudflare; the cdulsa.de copy was located by an autonomous web
search and verified to be byte-identical with the official document.
Embeddings indexed (in production container, OpenAI-compatible
DashScope embeddings via the existing index_programm pipeline):
- CDU 134, SPD 145, GRÜNE 183, FDP 100, AfD 64, LINKE 143 chunks
- Total LSA: 769 new chunks alongside the existing 775 NRW chunks
and 335 federal Grundsatzprogramm chunks.
wahlprogramme.py
- WAHLPROGRAMME["LSA"] populated with all six parties (canonical fraction
codes, original titles, page counts).
embeddings.py
- PROGRAMME extended with the six new "<partei>-lsa-2021" entries that
the indexer pipeline expects.
bundeslaender.py
- LSA flipped to aktiv=True. The frontend dropdown will now offer
Sachsen-Anhalt as a selectable bundesland and analyzer.get_bundesland_
context() will produce a real LSA prompt block (CDU/SPD/FDP as
governing fractions, all six landtagsfraktionen).
End-to-end smoke test (live in production container before commit)
- Adapter: PortalaAdapter.search() returned current Anträge of März 2026
(LINKE + GRÜNE) with correct titles and PDF URLs.
- Semantic search for an LSA "ÖPNV in der Altmark" sample antrag
matched LINKE S.53, SPD S.68, FDP S.52 — all three with similarity
> 0.6 and topical hits (Regionalisierungsmittel, ÖPNV-Förderprogramm,
Wasserstoffnetz).
Resolves issue #2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
9.9 KiB
Python
304 lines
9.9 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,
|
|
},
|
|
},
|
|
# Sachsen-Anhalt — Wahlprogramme zur LTW 06.06.2021. Die aktuelle 8. WP
|
|
# (seit 07/2021) wird mit diesen Programmen analysiert.
|
|
"LSA": {
|
|
"CDU": {
|
|
"file": "cdu-lsa-2021.pdf",
|
|
"titel": "Unsere Heimat. Unsere Verantwortung.",
|
|
"partei": "CDU Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 82,
|
|
},
|
|
"SPD": {
|
|
"file": "spd-lsa-2021.pdf",
|
|
"titel": "Zusammenhalt und neue Chancen. Politik fürs ganze Land",
|
|
"partei": "SPD Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 77,
|
|
},
|
|
"GRÜNE": {
|
|
"file": "gruene-lsa-2021.pdf",
|
|
"titel": "Verlässlich für Sachsen-Anhalt",
|
|
"partei": "BÜNDNIS 90/DIE GRÜNEN Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 164,
|
|
},
|
|
"FDP": {
|
|
"file": "fdp-lsa-2021.pdf",
|
|
"titel": "Wahlprogramm der FDP Sachsen-Anhalt zur Landtagswahl 2021",
|
|
"partei": "FDP Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 76,
|
|
},
|
|
"AfD": {
|
|
"file": "afd-lsa-2021.pdf",
|
|
"titel": "Alles für unsere Heimat! Programm der AfD Sachsen-Anhalt zur Landtagswahl 2021",
|
|
"partei": "AfD Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 64,
|
|
},
|
|
"LINKE": {
|
|
"file": "linke-lsa-2021.pdf",
|
|
"titel": "Wahlprogramm zur Landtagswahl 2021",
|
|
"partei": "DIE LINKE Sachsen-Anhalt",
|
|
"jahr": 2021,
|
|
"seiten": 88,
|
|
},
|
|
},
|
|
}
|
|
|
|
# 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)
|