gwoe-antragspruefer/app/redline_utils.py
Dotty Dotter bd591b9246 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

87 lines
3.2 KiB
Python

"""Redline-Parser-Hilfsfunktionen — keine FastAPI-Abhängigkeiten.
Wird von app.main._row_to_detail() und von Tests direkt importiert.
"""
from __future__ import annotations
import re
from urllib.parse import quote_plus
def parse_redline_segments(vorschlag: str | None) -> list[dict]:
"""Parst §INS§text§INS§/§DEL§text§DEL§-Marker sowie **text**- und
~~text~~-Markdown in eine Liste von {type, text}-Segmenten (ctx/ins/del).
Toleriert beide Formate gleichzeitig. Unausgewogene Marker bleiben als ctx.
Leerer oder None-Input liefert [].
Beispiel:
>>> parse_redline_segments("§ 3 §DEL§alt§DEL§ §INS§neu§INS§ Ende")
[{'type': 'ctx', 'text': '§ 3 '}, {'type': 'del', 'text': 'alt'},
{'type': 'ctx', 'text': ' '}, {'type': 'ins', 'text': 'neu'},
{'type': 'ctx', 'text': ' Ende'}]
"""
if not vorschlag:
return []
# Normalisierung: §INS§...§INS§ und §DEL§...§DEL§ → interne Tags
text = vorschlag
text = re.sub(r"§INS§(.*?)§INS§", r"<INS>\1</INS>", text, flags=re.DOTALL)
text = re.sub(r"§DEL§(.*?)§DEL§", r"<DEL>\1</DEL>", text, flags=re.DOTALL)
# Markdown-Konvention: **...** → ins, ~~...~~ → del
text = re.sub(r"\*\*(.*?)\*\*", r"<INS>\1</INS>", text, flags=re.DOTALL)
text = re.sub(r"~~(.*?)~~", r"<DEL>\1</DEL>", text, flags=re.DOTALL)
# Splitten an Tags, Typen zuordnen
segments: list[dict] = []
parts = re.split(r"(<INS>.*?</INS>|<DEL>.*?</DEL>)", text, flags=re.DOTALL)
for part in parts:
if not part:
continue
ins_m = re.fullmatch(r"<INS>(.*)</INS>", part, re.DOTALL)
del_m = re.fullmatch(r"<DEL>(.*)</DEL>", part, re.DOTALL)
if ins_m:
segments.append({"type": "ins", "text": ins_m.group(1)})
elif del_m:
segments.append({"type": "del", "text": del_m.group(1)})
else:
segments.append({"type": "ctx", "text": part})
return segments
def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
"""Gibt den pdf_href für ein Zitat zurück.
Bevorzugt das bereits gepflegte url-Feld. Falls leer, rekonstruiert
die URL aus dem quelle-Feld (Format: 'Titel · S. N' oder 'Titel, S. N')
über die ``programme``-Registry.
"""
url = zitat.get("url", "")
if url:
return url
quelle = zitat.get("quelle", "")
seite_m = re.search(r"[·,]?\s*S\.\s*(\d+)", quelle)
if not seite_m:
return ""
seite = seite_m.group(1)
# pid aus programme-Registry ermitteln: über titel oder name match.
from .programme import all_programme
pid = ""
for prog in all_programme():
titel = prog.get("titel") or ""
name = prog.get("name") or ""
if (titel and titel in quelle) or (name and name in quelle):
pid = prog.get("id", "")
break
if not pid:
return ""
text = zitat.get("text", "")
q = " ".join(text.split()[:5])
# #page=N als URL-Hash, damit der Browser-PDF-Viewer direkt zur Seite
# springt — OpenAction im PDF wird von Chrome/Firefox ignoriert.
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}#page={seite}"