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