gwoe-antragspruefer/app/wahlprogramme.py
Dotty Dotter 8992cffc64 Add MV+BE Wahlprogramme zur jeweils laufenden Legislatur (#4, #10)
11 PDFs in app/static/referenzen/ + Einträge in WAHLPROGRAMME
und embeddings.PROGRAMME für die beiden bisher nur per
föderalem Grundsatzprogramm-Fallback abgedeckten Landtage:

- **MV** (WP 8, seit 26.10.2021): CDU, SPD, GRÜNE, FDP, AfD, LINKE
  Wahlprogramme zur LTW 26.09.2021. Issue #4.

- **BE** (WP 19, konstituiert nach Wiederholungswahl 12.02.2023):
  CDU, SPD, GRÜNE, LINKE, AfD Programme zur AGH-Wahl 26.09.2021.
  Die Wiederholungswahl 2023 nutzte dieselben Programme wie die
  Originalwahl, daher die "be-2023.pdf"-Benennung mit Programm-
  jahr 2021. Issue #10.

Quellen: abgeordnetenwatch.de Mirror für 9 PDFs, library.fes.de
für SPD MV, cdu-mv.de direkt für CDU MV, fdp-mv.de direkt für
FDP MV. Alle PDFs verifiziert via pdftotext gegen das im Programm
genannte Wahldatum, um zu vermeiden, dass aktuellere
Wahlkampf-Entwürfe (z.B. das CDU "Berlin-Plan 2026") als
Legislatur-Programm fehlinterpretiert werden.

Indexierung in die embeddings-DB ist NICHT Teil dieses Commits —
sie muss separat im prod-Container ausgeführt werden:

  docker exec gwoe-antragspruefer python -c "
  from app.embeddings import index_programm
  from pathlib import Path
  d = Path('/app/static/referenzen')
  for pid in ['cdu-mv-2021','spd-mv-2021','gruene-mv-2021',
              'fdp-mv-2021','afd-mv-2021','linke-mv-2021',
              'cdu-be-2023','spd-be-2023','gruene-be-2023',
              'linke-be-2023','afd-be-2023']:
      index_programm(pid, d)
  "

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:24:33 +02:00

392 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
},
},
# Mecklenburg-Vorpommern — Wahlprogramme zur LTW 26.09.2021. Die
# aktuelle 8. WP (seit 26.10.2021) wird mit diesen Programmen
# analysiert. Issue #4.
"MV": {
"CDU": {
"file": "cdu-mv-2021.pdf",
"titel": "Zusammen. Den Blick nach vorn. Gemeinsam die Zukunft meistern",
"partei": "CDU Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 56,
},
"SPD": {
"file": "spd-mv-2021.pdf",
"titel": "Verantwortung für heute und morgen — Regierungsprogramm 20212026",
"partei": "SPD Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 95,
},
"GRÜNE": {
"file": "gruene-mv-2021.pdf",
"titel": "Für Klima, Land und ein besseres Miteinander",
"partei": "BÜNDNIS 90/DIE GRÜNEN Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 88,
},
"FDP": {
"file": "fdp-mv-2021.pdf",
"titel": "Wahlprogramm der Freien Demokraten Mecklenburg-Vorpommern zur Landtagswahl 2021",
"partei": "FDP Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 120,
},
"AfD": {
"file": "afd-mv-2021.pdf",
"titel": "Landeswahlprogramm der AfD Mecklenburg-Vorpommern 2021",
"partei": "AfD Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 84,
},
"LINKE": {
"file": "linke-mv-2021.pdf",
"titel": "Das ist links! — Zukunftsprogramm für Mecklenburg-Vorpommern",
"partei": "DIE LINKE Mecklenburg-Vorpommern",
"jahr": 2021,
"seiten": 82,
},
},
# Berlin — Wahlprogramme zur Abgeordnetenhauswahl 2021 (am 26.09.2021,
# wiederholt am 12.02.2023). Die laufende 19. WP (seit 27.04.2023) wird
# mit den 2021er Programmen analysiert, weil die Parteien zur
# Wiederholungswahl mit denselben Programmen angetreten sind. Issue #10.
"BE": {
"CDU": {
"file": "cdu-be-2023.pdf",
"titel": "Unser Berlin. Mehr geht nur gemeinsam. — Berlin-Plan der CDU Berlin 20212026",
"partei": "CDU Berlin",
"jahr": 2021,
"seiten": 135,
},
"SPD": {
"file": "spd-be-2023.pdf",
"titel": "Ganz sicher Berlin — Wahlprogramm der SPD Berlin zur Abgeordnetenhauswahl 2021",
"partei": "SPD Berlin",
"jahr": 2021,
"seiten": 86,
},
"GRÜNE": {
"file": "gruene-be-2023.pdf",
"titel": "Unser Plan für Berlin — Landeswahlprogramm BÜNDNIS 90/DIE GRÜNEN Berlin 2021",
"partei": "BÜNDNIS 90/DIE GRÜNEN Berlin",
"jahr": 2021,
"seiten": 280,
},
"LINKE": {
"file": "linke-be-2023.pdf",
"titel": "rot. radikal. realistisch. — Unser Programm für die soziale Stadt",
"partei": "DIE LINKE Berlin",
"jahr": 2021,
"seiten": 130,
},
"AfD": {
"file": "afd-be-2023.pdf",
"titel": "Wahlprogramm der AfD Berlin für die Wahl des Abgeordnetenhauses am 26. September 2021",
"partei": "AfD Berlin",
"jahr": 2021,
"seiten": 166,
},
},
}
# 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)