gwoe-antragspruefer/app/redline_utils.py
Dotty Dotter ad1db2a924 feat: 16 BL-Adapter, Drucksache-Typen, Mail-Digest, Clustering, Redline-Parser
- 16 aktive BL-Adapter + BUND (parlamente.py 3397 LOC)
- drucksache_typen.py: BL-spezifische Typ-Normalisierung (#127)
- mail.py: SMTP + Daily-Digest (#124)
- clustering.py: Embedding-Naehe-Graph + Bubble-Chart (#105)
- redline_utils.py: §INS§/§DEL§-Parser + PDF-Cite-URL-Builder
- embeddings v3->v4 Migration (#123, ADR 0006)
- chart.js + d3.v7 als statische Assets fuer Auswertungen-Cluster

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:54:50 +02:00

89 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 WAHLPROGRAMME-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 WAHLPROGRAMME-Registry ermitteln: Dateiname ohne .pdf
from .wahlprogramme import WAHLPROGRAMME
pid = ""
for bl_data in WAHLPROGRAMME.values():
for partei_data in bl_data.values():
titel = partei_data.get("titel", "")
partei_name = partei_data.get("partei", "")
file_name = partei_data.get("file", "")
if titel and (titel in quelle or partei_name in quelle):
pid = file_name.replace(".pdf", "")
break
if pid:
break
if not pid:
return ""
text = zitat.get("text", "")
q = " ".join(text.split()[:5])
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"