gwoe-antragspruefer/app/wahlprogramme.py
Dotty Dotter bd591b9246 refactor(programme): WAHLPROGRAMME → programme.PROGRAMME konsolidiert (#222)
Schließt #222. Entfernt die Doppelung zwischen ``wahlprogramme.WAHLPROGRAMME``
und ``programme.PROGRAMME``. Single source of truth ist jetzt
``programme.PROGRAMME`` als Literal mit allen 287 Programmen
(Wahlprogramme + Bundes- + Landes-Grundsatzprogramme, historisch + aktuell).

Schema schmaler — Felder ohne Konsumenten entfallen:
- ``regierungsbildung`` / ``regierungsende`` → gehören zu
  ``legislaturen.REGIERUNGEN``. Verbindung Programm→Regierung läuft jetzt
  über ``legislaturen.regierung_zum_zeitpunkt(bl, datum)``.
- ``partei`` (Langform "CDU NRW") → ableitbar aus partei + bundesland.
- ``jahr`` → ableitbar aus ``gueltig_ab[:4]``.
- ``beschluss`` / ``wahl`` / ``hinweis`` → keine App-Konsumenten.

Felder im neuen Schema: id, typ, partei, bundesland, wp, gueltig_ab,
gueltig_bis, name, titel (Slogan, optional), pdf, seiten.

Daten-Migration einmalig via ``tools/build_programme_literal.py``:
- Basis: bisherige embeddings.PROGRAMME (alle 287 IDs + gueltig_ab/bis)
- titel aus WAHLPROGRAMME für die ~80 aktuellen Wahlprogramme +
  Land-Grundsatzprogramm-Slogans (ehem. _ARCHIVED_SKELETONS)
- seiten via ``fitz.open(p).page_count`` für alle 287 PDFs

Aufrufer migriert:
- app/main.py:4055 — ``aktuelles_wahlprogramm(bl, partei).pdf``
- app/wahlprogramm_check.py — ``parteien_mit_wahlprogramm(bl)``
- app/redline_utils.py — Reverse-Lookup über ``all_programme()``
- app/wahlprogramm_fetch.py (3 Stellen) — ``aktuelles_wahlprogramm()``
- tests/test_redline_parser.py — Programm-Lookup statt WAHLPROGRAMME

``wahlprogramme.py`` schrumpft auf den Such-Code: Keyword-Fallback +
PDF-Text-Loader + ein dünner ``get_wahlprogramm``-Compat-Adapter zu
``programme.aktuelles_wahlprogramm``.

Drei Helper gelöscht (keine App-Konsumenten):
``regierungsbildung_for``, ``regierungsende_for``, ``regierung_aktuell``.
Wer das Datum der Regierungsbildung will, fragt
``legislaturen.aktuelle_regierung(bl).get('von')``.

Test-Suite: 1217 grün (vorher 1244, Differenz 27 = entfernte
regierungs-Helper-Tests + obsolete WAHLPROGRAMME-Strukturtests).
2026-05-09 00:37:35 +02:00

228 lines
7.6 KiB
Python

"""Wahlprogramm-Suche (Keyword-Fallback, wenn Embeddings nicht verfügbar).
Bis #222: hier lebte zusätzlich die ``WAHLPROGRAMME``-Datentabelle als
zweite Source-of-Truth. Sie wurde nach ``programme.PROGRAMME`` migriert
(siehe ADR 0013). Dieses Modul wurde dadurch reduziert auf:
- ``get_wahlprogramm(bl, partei)`` — Compat-Adapter auf
``programme.aktuelles_wahlprogramm`` (für Aufrufer, die das Programm
via (Bundesland, Partei) suchen — Hauptverwender ist die Keyword-Suche
hier in diesem Modul plus admin-Tools).
- ``load_wahlprogramm_text`` / ``search_wahlprogramm`` /
``find_relevant_quotes`` / ``format_quote_for_prompt`` — Keyword-
Fallback für die ``analyzer.py``-Pipeline, wenn die Embeddings-DB
nicht initialisiert ist.
Die semantische Suche lebt in ``embeddings.py``, die Programm-Stamm-
daten in ``programme.py``.
"""
import re
from pathlib import Path
from typing import Optional
from .bundeslaender import BUNDESLAENDER
from .programme import (
aktuelles_wahlprogramm,
parteien_mit_wahlprogramm,
)
# 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 das aktuell gültige Wahlprogramm dieser Partei in dem
Bundesland — als ``programme.Programm``-Dict (oder ``None``).
Compat-Adapter auf ``programme.aktuelles_wahlprogramm``. Aufrufer,
die früher ``info["file"]`` gelesen haben, müssen ``info["pdf"]``
lesen; ``info["partei"]`` (Langform) gibt es nicht mehr — Kurzform
+ ``info["bundesland"]`` reichen für die Anzeige.
"""
return aktuelles_wahlprogramm(bundesland, partei)
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 {}
pdf_name = info.get("pdf", "")
if not pdf_name:
return {}
paged_file = KONTEXT_PATH / pdf_name.replace('.pdf', '-paged.txt')
if not paged_file.exists():
# Fallback: Normale Textdatei
txt_file = KONTEXT_PATH / pdf_name.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 (Keyword-basiert,
Fallback wenn die Embeddings-DB nicht da ist).
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 []
pdf_name = info.get("pdf", "")
name = info.get("name") or partei
jahr = info.get("gueltig_ab", "")[:4] or "?"
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/{pdf_name}#page={page_num}",
"quelle": f"{name}, 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"]
for partei, partei_quotes in quotes.items():
if partei_quotes:
lines.append(f"\n### {partei}")
for q in partei_quotes:
lines.append(f"- S. {q['seite']}: \"{q['text']}\"")
return "\n".join(lines)