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).
87 lines
3.2 KiB
Python
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}"
|