362 lines
14 KiB
Python
362 lines
14 KiB
Python
|
|
"""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)
|