gwoe-antragspruefer/tools/build_programme_literal.py

362 lines
14 KiB
Python
Raw Normal View History

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
"""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 ProgrammRegierung 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)