"""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 — Reserve für Programme, die NICHT aus # embeddings.PROGRAMME oder WAHLPROGRAMME migriert werden können (z.B. # zukünftig: historische Vorgänger-Wahlprogramme aus früheren Legislaturen, # falls deren PDFs eingebunden werden ohne dass embeddings.PROGRAMME sie # kennt). # ───────────────────────────────────────────────────────────────────────────── _ADDITIONAL_PROGRAMME: list[Programm] = [] # Historische Skelett-Einträge (PDFs jetzt in embeddings.PROGRAMME) — # wurden in einer früheren Iteration manuell als pdf-pending markiert, # bevor die Beschaffung lief. Aktuelle Daten leben in embeddings.PROGRAMME # und werden über _migrate_from_legacy() automatisch übernommen. _ARCHIVED_SKELETONS: 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 from .legislaturen import aktuelle_legislatur # Schritt 1: Wahlprogramme aus WAHLPROGRAMME. # Geltungsbeginn = Wahltag (siehe ADR 0013 Folge-Entscheidung B1+B2): # Programme werden zur Wahl beschlossen; Opposition muss nicht auf eine # Regierungsbildung warten, an der sie nicht beteiligt ist. Der # ``regierungsbildung``-Wert aus WAHLPROGRAMME bleibt erhalten und wird # vom Bewertungs-Kontext-Block separat angezeigt ("Regierung zur # Antragszeit"), aber die Programm-Geltung selbst startet am Wahltag. for bundesland, parteien in WAHLPROGRAMME.items(): # Wahltag aus legislaturen lookup (aktuelle WP des BL). leg = aktuelle_legislatur(bundesland) wahltag = leg.get("wahltermin") if leg else None 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 # Geltungsbeginn: bevorzugt expliziter ``wahltag``-Eintrag # (für historische Programme aus Block 2 nötig), Fallback # auf aktuellen Wahltag der Legislatur, letzter Fallback auf # ``regierungsbildung`` (rückwärts-kompatibel). gueltig_ab = ( info.get("wahltag") or wahltag or info.get("regierungsbildung") or "1900-01-01" ) 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": gueltig_ab, "wp": leg.get("wp") if leg else None, "gueltig_ab": gueltig_ab, "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. # typ=parteiprogramm: ohne ``bundesland`` → Bundes-Grundsatz, mit # ``bundesland`` → Landes-Grundsatzprogramm. Beide funktional gleich, # nur unterscheidet sich der Geltungsbereich. for pid, info in _EMB_PROGRAMME.items(): if info.get("typ") != "parteiprogramm": continue # Wahlprogramme schon in Schritt 1 if pid in PROGRAMME: continue bl = info.get("bundesland") prog2: Programm = { "id": pid, "titel": info.get("name", ""), "name": info.get("name", ""), "typ": "grundsatzprogramm-land" if bl else "grundsatzprogramm-bund", "partei": info.get("partei", ""), "bundesland": bl, "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())