gwoe-antragspruefer/app/programme.py
Dotty Dotter 991d1eb903 feat: Programme + Legislaturen-Registry mit historisch korrekter Geltung
Neue Module:
- app/programme.py: zentrale Programm-Registry (alle Wahl- und Grundsatz-
  programme in einem Index), mit Geltungsdaten gueltig_ab/gueltig_bis und
  Helpern wahlprogramm_zum_zeitpunkt(), grundsatzprogramm_zum_zeitpunkt(),
  alle_versionen(). Skelett fuer 6 zusaetzliche Eintraege (CSU 2023,
  CDU NRW 2015, CDU SN 2023, CDU LSA 2023, SSW SH 2016, FREIE WAEHLER)
  vorbereitet — PDFs folgen.
- app/legislaturen.py: 56 Legislaturen + 70 Regierungen fuer 16 BL + Bund.
  Helper legislatur_zum_zeitpunkt(), regierung_zum_zeitpunkt(),
  regierungen_einer_wp() fuer historisch korrekte Antrags-Bewertung
  (z.B. Kemmerich-28-Tage-Kabinett, RP-Uebergang Dreyer III -> Schweitzer I,
  BUND Scholz-Ampel -> geschaeftsfuehrend -> Merz I).
- tests/test_legislaturen.py: 20 Tests zu Konsistenz + Historie.

Datenbasis: 8 BTW-2025-Wahlprogramme (CDU, CSU, SPD, GRUENE, FDP, AfD,
LINKE, BSW) als PDFs hinzugefuegt. SHA-256-Pinning in
app/wahlprogramm-shas.lock.json (separat).

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

488 lines
20 KiB
Python

"""Zentrale Programm-Registry — alle politischen Programm-Dokumente
(Wahlprogramme, Bundes-Grundsatzprogramme, Landes-Grundsatzprogramme),
historisch und aktuell.
Single Source of Truth für:
- ``embeddings.py`` (Indexer liest die PDFs aus dieser Liste)
- ``wahlprogramme.py`` (Compat-Shim, leitet die alte BL/Partei-API hierher)
- ``analyzer.py`` (sucht das zum Antrag passende Wahlprogramm)
- UI (zeigt Geltungszeitraum + zugeordnete Regierung pro Programm)
Siehe ``app/legislaturen.py`` für Wahlperioden + Regierungen.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal, Optional, TypedDict
# ─────────────────────────────────────────────────────────────────────────────
# Typ
# ─────────────────────────────────────────────────────────────────────────────
ProgrammTyp = Literal[
"wahlprogramm", # zur Wahl beschlossen, gilt für 1 Legislatur
"grundsatzprogramm-bund", # bundesweites Grundsatzprogramm
"grundsatzprogramm-land", # landesspezifisches Grundsatzprogramm
]
class Programm(TypedDict, total=False):
"""Single source of truth für ein politisches Programm-Dokument.
Pflichtfelder: id, titel, name, typ, partei, gueltig_ab, pdf, seiten.
Optional: bundesland, beschluss, wahl, wp, gueltig_bis, hinweis.
"""
id: str # eindeutiger Schlüssel, z.B. "cdu-nrw-2022"
titel: str # offizieller Titel ("Machen, worauf es ankommt")
name: str # voll-qualifiziert für Citation (z.B. "CDU NRW Wahlprogramm 2022")
typ: ProgrammTyp
partei: str # normalisierter Schlüssel (CDU, GRÜNE, FREIE WÄHLER, BSW, …)
bundesland: Optional[str] # BL-Code; None nur bei reinen Bundesgrundsatzprogrammen
beschluss: Optional[str] # ISO YYYY-MM-DD; bei grundsatz: Parteitags-Beschluss
wahl: Optional[str] # ISO YYYY-MM-DD; nur typ=wahlprogramm: Wahltag
wp: Optional[int] # Legislatur-Nummer; nur typ=wahlprogramm
gueltig_ab: str # ISO; bei wahl: regierungsbildung; bei grundsatz: beschluss
gueltig_bis: Optional[str] # ISO; None = Programm ist aktuell gültig
pdf: str # Dateiname in static/referenzen/
seiten: int
hinweis: Optional[str] # freier Text, z.B. "BSW hat kein Grundsatzprogramm — Wahlprogramm dient als Hauptquelle"
REFERENZEN_PATH = Path(__file__).parent / "static" / "referenzen"
KONTEXT_PATH = Path(__file__).parent / "kontext"
# ─────────────────────────────────────────────────────────────────────────────
# Programm-Registry
# Die Daten werden aus der bisherigen ``embeddings.PROGRAMME`` und
# ``wahlprogramme.WAHLPROGRAMME`` migriert. Historische Wahlprogramme +
# Landesgrundsatzprogramme werden ergänzt sobald die Recherche fertig ist.
# ─────────────────────────────────────────────────────────────────────────────
# Aktuelle Programme — gefüllt durch ``_register_initial_data()`` weiter unten,
# damit der Migrations-Pfad an einer Stelle zu sehen ist.
PROGRAMME: dict[str, Programm] = {}
# ─────────────────────────────────────────────────────────────────────────────
# Helper-API
# ─────────────────────────────────────────────────────────────────────────────
def _date_in_range(datum: str, ab: str, bis: Optional[str]) -> bool:
"""Liefert True, wenn ``datum`` (ISO) in [ab, bis) liegt."""
if datum < ab:
return False
if bis is None:
return True
return datum < bis
def get_programm(programm_id: str) -> Optional[Programm]:
"""Lookup nach ID."""
return PROGRAMME.get(programm_id)
def aktuelles_wahlprogramm(bundesland: str, partei: str) -> Optional[Programm]:
"""Aktuell gültiges Wahlprogramm einer Partei in einem Bundesland.
Es kann nur eines aktuell sein (gueltig_bis=None und typ=wahlprogramm).
"""
for prog in PROGRAMME.values():
if (
prog.get("typ") == "wahlprogramm"
and prog.get("bundesland") == bundesland
and prog.get("partei") == partei
and prog.get("gueltig_bis") is None
):
return prog
return None
def wahlprogramm_zum_zeitpunkt(
bundesland: str, partei: str, datum: str,
) -> Optional[Programm]:
"""Welches Wahlprogramm dieser Partei galt im Bundesland am gegebenen Datum?
``datum`` ist ISO-Datum. Es wird das Programm zurückgegeben, dessen
Geltungszeitraum [gueltig_ab, gueltig_bis) das Datum enthält.
Rückgabe ``None``, wenn die Partei zu dem Zeitpunkt nicht im Schema
erfasst ist (oder das Bundesland nicht).
"""
for prog in PROGRAMME.values():
if (
prog.get("typ") == "wahlprogramm"
and prog.get("bundesland") == bundesland
and prog.get("partei") == partei
and _date_in_range(datum, prog["gueltig_ab"], prog.get("gueltig_bis"))
):
return prog
return None
def grundsatzprogramm_zum_zeitpunkt(
partei: str,
datum: str,
bundesland: Optional[str] = None,
) -> Optional[Programm]:
"""Welches Grundsatzprogramm der Partei galt am gegebenen Datum?
Wenn ``bundesland`` gesetzt ist, wird zuerst nach einem
Landes-Grundsatzprogramm gesucht; falls keines existiert, fällt die
Suche auf das Bundes-Grundsatzprogramm zurück.
Wenn ``bundesland`` ``None`` ist, wird nur nach Bundes-Grundsatz gesucht.
"""
if bundesland is not None:
# Erst Land suchen
for prog in PROGRAMME.values():
if (
prog.get("typ") == "grundsatzprogramm-land"
and prog.get("partei") == partei
and prog.get("bundesland") == bundesland
and _date_in_range(datum, prog["gueltig_ab"], prog.get("gueltig_bis"))
):
return prog
# Bund als Fallback / oder primär
for prog in PROGRAMME.values():
if (
prog.get("typ") == "grundsatzprogramm-bund"
and prog.get("partei") == partei
and _date_in_range(datum, prog["gueltig_ab"], prog.get("gueltig_bis"))
):
return prog
return None
def parteien_mit_wahlprogramm(bundesland: str) -> list[str]:
"""Parteien mit einem aktuell gültigen Wahlprogramm in dem Bundesland.
Reihenfolge: nach Eintrags-Reihenfolge in PROGRAMME (deterministic).
"""
seen: list[str] = []
for prog in PROGRAMME.values():
if (
prog.get("typ") == "wahlprogramm"
and prog.get("bundesland") == bundesland
and prog.get("gueltig_bis") is None
):
partei = prog["partei"]
if partei not in seen:
seen.append(partei)
return seen
def alle_versionen(bundesland: str, partei: str) -> list[Programm]:
"""Alle Wahlprogramm-Versionen dieser Partei im Bundesland, sortiert
nach ``gueltig_ab`` aufsteigend."""
versions = [
prog for prog in PROGRAMME.values()
if prog.get("typ") == "wahlprogramm"
and prog.get("bundesland") == bundesland
and prog.get("partei") == partei
]
versions.sort(key=lambda p: p["gueltig_ab"])
return versions
# ─────────────────────────────────────────────────────────────────────────────
# Initiale Daten — wird über separate Daten-Datei eingespielt.
# Während des Migrationsfensters wird ``embeddings.PROGRAMME`` und
# ``wahlprogramme.WAHLPROGRAMME`` automatisch in PROGRAMME überführt.
# ─────────────────────────────────────────────────────────────────────────────
def _register(prog: Programm) -> None:
"""Add a Programm to the registry, validating uniqueness of id."""
if prog["id"] in PROGRAMME:
raise ValueError(f"Programm-id collision: {prog['id']}")
PROGRAMME[prog["id"]] = prog
# ─────────────────────────────────────────────────────────────────────────────
# Zusätzliche Programme — historische Vorgänger und Landesgrundsatzprogramme,
# die nicht aus WAHLPROGRAMME/embeddings.PROGRAMME migriert werden können.
#
# PDFs werden separat beschafft und hier eingetragen sobald verfügbar.
# Einträge ohne existierende PDF-Datei können aktiv sein (z.B. für reine
# Metadaten-Anzeige), aber sie werden in der Embeddings-Indizierung
# übersprungen und ``test_every_registered_pdf_exists`` filtert sie via
# ``hinweis="pdf-pending"`` Marker aus.
# ─────────────────────────────────────────────────────────────────────────────
_ADDITIONAL_PROGRAMME: list[Programm] = [
# ─── CSU Bayern: aktualisiertes Grundsatzprogramm 2023 ───────────────
# "Die Ordnung 2016" (in embeddings.PROGRAMME als Bundes-Grundsatz)
# wird durch das 2023er ersetzt — wir tragen 2023 als aktuelles ein und
# setzen das 2016er auf gueltig_bis. Funktionell ist es ein Landes-
# grundsatzprogramm, weil CSU nur in Bayern existiert.
{
"id": "csu-grundsatz-2023",
"titel": "Für ein neues Miteinander — Grundsatzprogramm der CSU",
"name": "CSU Grundsatzprogramm 2023",
"typ": "grundsatzprogramm-land",
"partei": "CSU",
"bundesland": "BY",
"beschluss": "2023-05-06",
"wahl": None,
"wp": None,
"gueltig_ab": "2023-05-06",
"gueltig_bis": None,
"pdf": "csu-grundsatz-2023.pdf",
"seiten": 0, # nach Download via PyMuPDF setzen
"hinweis": "pdf-pending",
},
# ─── CDU NRW Landesgrundsatzprogramm 2015 ─────────────────────────────
{
"id": "cdu-grundsatz-nrw-2015",
"titel": "Aufstieg, Sicherheit, Perspektive — Das Nordrhein-Westfalen-Programm",
"name": "CDU NRW Grundsatzprogramm 2015",
"typ": "grundsatzprogramm-land",
"partei": "CDU",
"bundesland": "NRW",
"beschluss": "2015-06-13",
"wahl": None,
"wp": None,
"gueltig_ab": "2015-06-13",
"gueltig_bis": None,
"pdf": "cdu-grundsatz-nrw-2015.pdf",
"seiten": 0,
"hinweis": "pdf-pending",
},
# ─── CDU Sachsen Landesgrundsatzprogramm 2023 ─────────────────────────
{
"id": "cdu-grundsatz-sn-2023",
"titel": "Zukunftsplan für Sachsen",
"name": "CDU Sachsen Grundsatzprogramm 2023",
"typ": "grundsatzprogramm-land",
"partei": "CDU",
"bundesland": "SN",
"beschluss": "2023-11-20",
"wahl": None,
"wp": None,
"gueltig_ab": "2023-11-20",
"gueltig_bis": None,
"pdf": "cdu-grundsatz-sn-2023.pdf",
"seiten": 0,
"hinweis": "pdf-pending",
},
# ─── CDU Sachsen-Anhalt Landesgrundsatzprogramm 2023 ──────────────────
{
"id": "cdu-grundsatz-lsa-2023",
"titel": "Sachsen-Anhalt. Unsere Verantwortung. Unsere Zukunft.",
"name": "CDU Sachsen-Anhalt Grundsatzprogramm 2023",
"typ": "grundsatzprogramm-land",
"partei": "CDU",
"bundesland": "LSA",
"beschluss": "2023-09-30",
"wahl": None,
"wp": None,
"gueltig_ab": "2023-09-30",
"gueltig_bis": None,
"pdf": "cdu-grundsatz-lsa-2023.pdf",
"seiten": 0,
"hinweis": "pdf-pending",
},
# ─── SSW Schleswig-Holstein Rahmenprogramm 2016 ────────────────────────
# SSW ist ausschließlich in SH aktiv (Minderheitenpartei der dänisch-
# friesischen Volksgruppe). Das Rahmenprogramm tritt funktional an die
# Stelle eines Grundsatzprogramms.
{
"id": "ssw-grundsatz-sh-2016",
"titel": "SSW Rahmenprogramm",
"name": "SSW Rahmenprogramm 2016",
"typ": "grundsatzprogramm-land",
"partei": "SSW",
"bundesland": "SH",
"beschluss": "2016-04-16",
"wahl": None,
"wp": None,
"gueltig_ab": "2016-04-16",
"gueltig_bis": None,
"pdf": "ssw-grundsatz-sh-2016.pdf",
"seiten": 0,
"hinweis": "pdf-pending",
},
# ─── FREIE WÄHLER Bundesvereinigung Grundsatzprogramm ─────────────────
# FW sind nicht im Bundestag (BTW 2025 unter 5%), aber im Landtag in
# Bayern (Regierung) und Rheinland-Pfalz (Opposition). Bundesgrundsatz-
# programm gilt als bundesweite Referenz für alle Landesverbände.
{
"id": "fw-grundsatz-2012",
"titel": "FREIE WÄHLER Bundesgrundsatzprogramm",
"name": "FREIE WÄHLER Bundesgrundsatzprogramm",
"typ": "grundsatzprogramm-bund",
"partei": "FREIE WÄHLER",
"bundesland": None,
"beschluss": "2012-02-25", # erstes Bundesgrundsatzprogramm; mehrfach fortgeschrieben
"wahl": None,
"wp": None,
"gueltig_ab": "2012-02-25",
"gueltig_bis": None,
"pdf": "fw-grundsatz.pdf",
"seiten": 0,
"hinweis": "pdf-pending — FREIE WÄHLER sind nicht im Bundestag, "
"Programm gilt als bundesweite Referenz für FW Bayern + FW Rheinland-Pfalz",
},
]
def _migrate_from_legacy() -> None:
"""Migriere bestehende Daten aus ``wahlprogramme.WAHLPROGRAMME`` und
``embeddings.PROGRAMME`` in die neue Registry. Wird einmal beim
Modul-Import aufgerufen.
Reihenfolge:
1. Wahlprogramme aus WAHLPROGRAMME (autoritative Quelle für regierungs-
gebundene Geltungsdaten).
2. Grundsatzprogramme aus embeddings.PROGRAMME (typ=parteiprogramm).
3. _ADDITIONAL_PROGRAMME (neue Daten — Landesgrundsatz, ggf. Updates).
Update-Logik: Wenn ein _ADDITIONAL Programm denselben ``partei``,
``bundesland`` und Typ wie ein bestehendes hat und neueres
``gueltig_ab`` besitzt, wird das Vorgänger-``gueltig_bis`` rückwirkend
auf das ``gueltig_ab`` des neuen gesetzt.
"""
# Zirkuläre Imports vermeiden — lazy import beim Migrationszeitpunkt.
from .wahlprogramme import WAHLPROGRAMME
from .embeddings import PROGRAMME as _EMB_PROGRAMME
# Schritt 1: Wahlprogramme aus WAHLPROGRAMME
for bundesland, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
# ID ableiten aus PDF-Stem
pid = info["file"].rsplit(".", 1)[0]
if info.get("ist_grundsatz"):
# Bundes-Grundsatzprogramm; Eintrag erfolgt in Schritt 2.
continue
prog: Programm = {
"id": pid,
"titel": info.get("titel", ""),
"name": f"{info.get('partei', partei)} Wahlprogramm {info.get('jahr', '')}".strip(),
"typ": "wahlprogramm",
"partei": partei,
"bundesland": bundesland,
"beschluss": None,
"wahl": None,
"wp": None,
"gueltig_ab": info.get("regierungsbildung") or "1900-01-01",
"gueltig_bis": info.get("regierungsende"),
"pdf": info["file"],
"seiten": int(info.get("seiten", 0)),
"hinweis": None,
}
if pid not in PROGRAMME:
PROGRAMME[pid] = prog
# Schritt 2: Grundsatzprogramme aus embeddings.PROGRAMME
for pid, info in _EMB_PROGRAMME.items():
if info.get("typ") != "parteiprogramm":
continue # Wahlprogramme schon in Schritt 1 (oder werden dort gepflegt)
if pid in PROGRAMME:
continue
prog2: Programm = {
"id": pid,
"titel": info.get("name", ""),
"name": info.get("name", ""),
"typ": "grundsatzprogramm-bund",
"partei": info.get("partei", ""),
"bundesland": None,
"beschluss": info.get("gueltig_ab"),
"wahl": None,
"wp": None,
"gueltig_ab": info.get("gueltig_ab") or "1900-01-01",
"gueltig_bis": info.get("gueltig_bis"),
"pdf": info.get("pdf", ""),
"seiten": 0,
"hinweis": None,
}
PROGRAMME[pid] = prog2
# Schritt 3: _ADDITIONAL_PROGRAMME — mit Vorgänger-bis-Update
for prog3 in _ADDITIONAL_PROGRAMME:
# Setze gueltig_bis von Vorgänger-Programmen rückwirkend.
for existing in list(PROGRAMME.values()):
if (
existing.get("typ") in ("grundsatzprogramm-bund",
"grundsatzprogramm-land")
and existing.get("partei") == prog3.get("partei")
and existing.get("bundesland") == prog3.get("bundesland")
and existing.get("gueltig_bis") is None
and existing.get("gueltig_ab", "9999-99-99") < prog3["gueltig_ab"]
):
# Wir mutieren ein TypedDict in der Registry — das ist OK,
# weil _migrate_from_legacy() einmal beim Import läuft.
existing["gueltig_bis"] = prog3["gueltig_ab"] # type: ignore[typeddict-item]
if prog3["id"] not in PROGRAMME:
PROGRAMME[prog3["id"]] = prog3
# Lazy initialisierung — erst beim ersten echten Zugriff. Dadurch vermeidet
# das Modul Import-Reihenfolge-Probleme mit ``embeddings.py`` (das viel mehr
# Initialisierung braucht: openai, fitz, etc.).
_INITIALIZED = False
def _ensure_initialized() -> None:
global _INITIALIZED
if _INITIALIZED:
return
_migrate_from_legacy()
_INITIALIZED = True
# Patch helper-API to ensure init runs on first call.
_get_programm = get_programm
_aktuelles_wahlprogramm = aktuelles_wahlprogramm
_wahlprogramm_zum_zeitpunkt = wahlprogramm_zum_zeitpunkt
_grundsatzprogramm_zum_zeitpunkt = grundsatzprogramm_zum_zeitpunkt
_parteien_mit_wahlprogramm = parteien_mit_wahlprogramm
_alle_versionen = alle_versionen
def get_programm(programm_id: str) -> Optional[Programm]: # type: ignore[no-redef]
_ensure_initialized()
return _get_programm(programm_id)
def aktuelles_wahlprogramm(bundesland: str, partei: str) -> Optional[Programm]: # type: ignore[no-redef]
_ensure_initialized()
return _aktuelles_wahlprogramm(bundesland, partei)
def wahlprogramm_zum_zeitpunkt( # type: ignore[no-redef]
bundesland: str, partei: str, datum: str,
) -> Optional[Programm]:
_ensure_initialized()
return _wahlprogramm_zum_zeitpunkt(bundesland, partei, datum)
def grundsatzprogramm_zum_zeitpunkt( # type: ignore[no-redef]
partei: str, datum: str, bundesland: Optional[str] = None,
) -> Optional[Programm]:
_ensure_initialized()
return _grundsatzprogramm_zum_zeitpunkt(partei, datum, bundesland)
def parteien_mit_wahlprogramm(bundesland: str) -> list[str]: # type: ignore[no-redef]
_ensure_initialized()
return _parteien_mit_wahlprogramm(bundesland)
def alle_versionen(bundesland: str, partei: str) -> list[Programm]: # type: ignore[no-redef]
_ensure_initialized()
return _alle_versionen(bundesland, partei)
def all_programme() -> list[Programm]:
"""Alle eingetragenen Programme (initialisiert, falls nötig)."""
_ensure_initialized()
return list(PROGRAMME.values())