"""Generate the new programme.PROGRAMME literal from the lazy-migrated state. Reads the in-memory PROGRAMME dict (after _migrate_from_legacy ran), enriches with titel from WAHLPROGRAMME (where available) and seiten from PyMuPDF, then emits a Python literal sorted by (typ, bundesland, gueltig_ab). """ import sys sys.path.insert(0, "/app") from pathlib import Path # Trigger migration from app import programme as programme_mod programme_mod._ensure_initialized() PROGRAMME_NEU = programme_mod.PROGRAMME # Quelle für titel from app.wahlprogramme import WAHLPROGRAMME # Override: titel-Mapping aus _ARCHIVED_SKELETONS (programme.py). # Diese sind über partei+bundesland+typ identifiziert, weil die Skeleton-IDs # Jahres-Suffixe haben, die embeddings.PROGRAMME-IDs nicht. TITEL_OVERRIDE_BY_PARTEI_BL_TYP = { ("CSU", "BY", "grundsatzprogramm-land"): "Für ein neues Miteinander — Grundsatzprogramm der CSU", ("CDU", "NRW", "grundsatzprogramm-land"): "Aufstieg, Sicherheit, Perspektive — Das Nordrhein-Westfalen-Programm", ("CDU", "SN", "grundsatzprogramm-land"): "Zukunftsplan für Sachsen", ("CDU", "LSA", "grundsatzprogramm-land"): "Sachsen-Anhalt. Unsere Verantwortung. Unsere Zukunft.", ("SSW", "SH", "grundsatzprogramm-land"): "SSW Rahmenprogramm", } import fitz REFERENZEN = Path("/app/app/static/referenzen") def get_titel(prog: dict) -> str | None: """titel aus WAHLPROGRAMME[bl][partei] holen, falls vorhanden.""" bl = prog.get("bundesland") partei = prog.get("partei") if not bl or not partei: return None wp_entry = WAHLPROGRAMME.get(bl, {}).get(partei) if not wp_entry: return None # Nur wenn das pdf zueinander passt if wp_entry.get("file") != prog.get("pdf"): return None return wp_entry.get("titel") or None def get_seiten(pdf: str) -> int | None: """PDF-Seitenzahl via PyMuPDF.""" p = REFERENZEN / pdf if not p.exists(): return None try: doc = fitz.open(p) n = doc.page_count doc.close() return n except Exception as e: print(f"# fitz-Fehler bei {pdf}: {e}", file=sys.stderr) return None # Schlüssel für Python-Literal — diktiert die Reihenfolge im Output-Dict KEYS_ORDER = [ "id", "typ", "partei", "bundesland", "wp", "gueltig_ab", "gueltig_bis", "name", "titel", "pdf", "seiten", # Verbleibende, die Tests heute lesen — im Schema-Refactor entfernt: # "beschluss", "wahl", "hinweis", "ist_grundsatz" — übergehen. ] def repr_value(v): if v is None: return "None" if isinstance(v, bool): return "True" if v else "False" if isinstance(v, (int, float)): return repr(v) if isinstance(v, str): # double-quote-strings, mit Escape von eingebetteten "-Zeichen escaped = v.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' return repr(v) def fmt_entry(prog: dict) -> str: out = [] for k in KEYS_ORDER: v = prog.get(k) if v is None and k in ("titel", "wp", "seiten", "bundesland", "gueltig_bis"): # Optional-Felder: explizit None auch raus, wenn Feld nicht gesetzt out.append(f'"{k}": None') elif v is None: continue # Pflichtfeld nicht gesetzt → überspringen, fällt sonst auf None else: out.append(f'"{k}": {repr_value(v)}') return "{" + ", ".join(out) + "}" def gruppe(prog: dict): """Sortier-Schlüssel.""" typ = prog.get("typ", "zzz") bl = prog.get("bundesland") or "" ga = prog.get("gueltig_ab") or "0000" partei = prog.get("partei", "") return (typ, bl, ga, partei) # Anreichern + ausgeben results = [] for pid, prog in PROGRAMME_NEU.items(): enriched = dict(prog) enriched.setdefault("id", pid) # Migration setzt titel = name als Default — das ist kein echter Slogan, # nur Doppelung. titel ECHT nur dann tragen, wenn entweder explizit # in WAHLPROGRAMME ("Machen, worauf es ankommt") oder vom Skeleton # in programme.py (Land-Grundsatzprogramme). if enriched.get("titel") == enriched.get("name"): enriched.pop("titel", None) if not enriched.get("titel"): t = get_titel(prog) if t and t != enriched.get("name"): enriched["titel"] = t # Override für Land-Grundsatzprogramme (Slogans aus alten Skeletons) if not enriched.get("titel"): key = ( prog.get("partei"), prog.get("bundesland"), prog.get("typ"), ) override = TITEL_OVERRIDE_BY_PARTEI_BL_TYP.get(key) if override: enriched["titel"] = override if not enriched.get("seiten"): s = get_seiten(prog.get("pdf", "")) if s: enriched["seiten"] = s results.append(enriched) results.sort(key=gruppe) # Statistik n_titel = sum(1 for p in results if p.get("titel")) n_seiten = sum(1 for p in results if p.get("seiten")) print(f"# {len(results)} Einträge insgesamt", file=sys.stderr) print(f"# {n_titel} mit titel ({len(results) - n_titel} ohne)", file=sys.stderr) print(f"# {n_seiten} mit seiten ({len(results) - n_seiten} ohne)", file=sys.stderr) # Output: vollständige neue programme.py header = '''"""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) - ``analyzer.py`` (sucht das zum Antrag passende Wahlprogramm) - UI (zeigt Geltungszeitraum + zugeordnete Regierung pro Programm) Siehe ``app/legislaturen.py`` für Wahlperioden + Regierungen — Programm- Daten beschreiben das Dokument selbst, nicht die Regierung, die aus ihm hervorging. Die Verbindung läuft über ``legislaturen.regierung_zum_zeitpunkt(bundesland, datum)``. """ from __future__ import annotations from pathlib import Path from typing import Literal, Optional, TypedDict # ───────────────────────────────────────────────────────────────────────────── # Schema # ───────────────────────────────────────────────────────────────────────────── 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, typ, partei, gueltig_ab, name, pdf. Optional: bundesland, wp, gueltig_bis, titel, seiten. Was hier bewusst NICHT drin ist: - ``regierungsbildung`` / ``regierungsende`` — gehört zu ``legislaturen.REGIERUNGEN``. Verbindung Programm→Regierung läuft über ``legislaturen.regierung_zum_zeitpunkt(bl, antrag_datum)``. - ``partei`` Langform ("CDU NRW") — ableitbar via partei + bundesland. - ``jahr`` — ``int(gueltig_ab[:4])`` reicht. """ id: str # eindeutiger Schlüssel, z.B. "cdu-nrw-2022" typ: ProgrammTyp partei: str # kanonisch (CDU, BiW, BÜNDNIS 90/DIE GRÜNEN, …) bundesland: Optional[str] # BL-Code; None nur bei Bundesgrundsatzprogrammen wp: Optional[int] # Legislatur-Nummer; nur typ=wahlprogramm gueltig_ab: str # ISO YYYY-MM-DD; bei wahl: Wahltag gueltig_bis: Optional[str] # ISO; None = aktuell gültig name: str # voll-qualifiziert für Citation, z.B. "CDU NRW Wahlprogramm 2022" titel: Optional[str] # Slogan ("Machen, worauf es ankommt"); None wenn nicht erfasst pdf: str # Dateiname in static/referenzen/ seiten: Optional[int] # PDF-Seitenzahl REFERENZEN_PATH = Path(__file__).parent / "static" / "referenzen" KONTEXT_PATH = Path(__file__).parent / "kontext" # ───────────────────────────────────────────────────────────────────────────── # Daten — alle 287 Programme (historisch + aktuell), sortiert nach # (typ, bundesland, gueltig_ab, partei). Auto-generiert aus der bisherigen # embeddings.PROGRAMME + WAHLPROGRAMME-Lazy-Migration via fitz für seiten. # Generator: tools/build_programme_literal.py (siehe Commit-Historie). # ───────────────────────────────────────────────────────────────────────────── ''' print(header) # Literal print("PROGRAMME: dict[str, Programm] = {") last_gruppe = None for prog in results: typ = prog.get("typ", "?") bl = prog.get("bundesland") or "BUND" cur = (typ, bl) if cur != last_gruppe: print(f"\n # ─── {typ} · {bl} ───") last_gruppe = cur print(f' "{prog["id"]}": {fmt_entry(prog)},') print("}") # Helper-API helpers = ''' # ───────────────────────────────────────────────────────────────────────────── # 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 get_programm_by_pdf(pdf: str) -> Optional[Programm]: """Reverse-Lookup über pdf-Dateinamen.""" for prog in PROGRAMME.values(): if prog.get("pdf") == pdf: return prog return None 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. """ 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. """ if bundesland is not None: 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 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 def all_programme() -> list[Programm]: """Alle eingetragenen Programme.""" return list(PROGRAMME.values()) ''' print(helpers)