gwoe-antragspruefer/app/protokoll_parsers/th.py

182 lines
5.3 KiB
Python
Raw Normal View History

"""Thueringen (TH) — Plenarprotokoll-Parser (#106 / #163, ADR 0009).
URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42):
``https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf``
(Arbeitsfassungen werden spaeter durch finale Versionen ersetzt der
Endgueltige-Pfad ist via Parlamentsdokumentations-Portal zu finden.)
## Anchor-Sprache (verifiziert WP8 Sitzung 40)
```
Wer dem zustimmt, den bitte ich um das Handzeichen. Das sind die
Stimmen aus den Fraktionen der CDU, BSW, SPD und Die Linke. Wer
stimmt gegen den Platzierungswunsch? Das sind die Stimmen aus der
Fraktion der AfD. Damit ist der Platzierungswunsch mehrheitlich
angenommen.
```
Pattern (BE-aehnlich):
- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt)``
- **Vote-Block:** Q+A im Reden-Stil
- JA: ``Wer dem zustimmt ... Das sind die Stimmen aus [PHRASE]``
- NEIN: ``Wer stimmt gegen ... Das sind die Stimmen aus [PHRASE]``
- ENTH: ``Wer enthaelt sich ... [PHRASE]`` (nicht in jedem Vote)
- **Drucksachen-Lookup:** ``Drucksache 8/N`` rueckwaerts vom Anchor
## Fraktions-Mapping WP8 (ab 2024)
WP8 Konstellation (Mai-2024-Wahl): CDU, AfD, BSW, Die Linke, SPD.
Keine GRUENE oder FDP im aktuellen Landtag.
- ``CDU``, ``SPD``, ``BSW``, ``AfD``
- ``Die Linke`` / ``Linken`` LINKE
- WP7 hatte zusaetzlich GRUENE und FDP Mapping bei WP7-Backfill ergaenzen
"""
from __future__ import annotations
import re
from typing import Optional
try:
import fitz
except ImportError:
fitz = None
ALLE_FRAKTIONEN_TH = ["CDU", "SPD", "AfD", "LINKE", "BSW"]
FRAKTIONEN_MAP_TH = [
("Die Linke", ["LINKE"]),
("der Linken", ["LINKE"]),
("Linke", ["LINKE"]),
("CDU", ["CDU"]),
("SPD", ["SPD"]),
("AfD", ["AfD"]),
("BSW", ["BSW"]),
("GRÜNE", ["GRÜNE"]), # WP7-Kompatibilitaet
("FDP", ["FDP"]), # WP7-Kompatibilitaet
]
def _normalize_fraktionen_th(text: str) -> list[str]:
found = set()
remaining = text
for phrase, codes in FRAKTIONEN_MAP_TH:
if phrase in remaining:
for c in codes:
found.add(c)
remaining = remaining.replace(phrase, " ")
return sorted(found)
# Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt)"
RESULT_ANCHOR_RE = re.compile(
r"Damit ist\s+(?:der|die|das|dieser?)?\s*"
r"(?P<subject>Antrag|Änderungsantrag|Gesetzentwurf|Beschlussempfehlung|"
r"Platzierungswunsch|Tagesordnungspunkt|Entschließungsantrag)"
r"[^.]{0,150}?(?P<modus>einstimmig|mehrheitlich)?\s*"
r"(?P<ergebnis>angenommen|abgelehnt)\.",
re.DOTALL,
)
# Vote-Sub-Patterns
JA_RE = re.compile(
r"Wer dem zustimmt[^.]{0,80}?\.\s*"
r"(?:Das sind die Stimmen aus\s+)?(?P<ja>[^.?]+?)(?=\.|Wer (?:stimmt|enth))",
re.DOTALL,
)
NEIN_RE = re.compile(
r"Wer stimmt (?:dagegen|gegen)[^.?]{0,80}?\??\s*"
r"(?:Das sind die Stimmen aus\s+)?(?P<nein>[^.?]+?)(?=\.|Wer enth|Damit)",
re.DOTALL,
)
ENTH_RE = re.compile(
r"Wer enth(?:ä|ae)lt sich[^.?]{0,80}?\??\s*"
r"(?P<enth>[^.?]+?)(?=\.|Damit|Wer)",
re.DOTALL,
)
DS_RE_TH = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5})")
def _parse_vote_block_th(block: str) -> dict:
votes = {"ja": [], "nein": [], "enthaltung": []}
ja_m = JA_RE.search(block)
if ja_m:
votes["ja"] = _normalize_fraktionen_th(ja_m.group("ja"))
nein_m = NEIN_RE.search(block)
if nein_m:
votes["nein"] = _normalize_fraktionen_th(nein_m.group("nein"))
enth_m = ENTH_RE.search(block)
if enth_m:
enth_text = enth_m.group("enth")
if "niemand" in enth_text.lower() or "keine" in enth_text.lower():
votes["enthaltung"] = []
else:
votes["enthaltung"] = _normalize_fraktionen_th(enth_text)
return votes
def _resolve_drucksache_th(text: str, anchor_start: int) -> Optional[str]:
window_start = max(0, anchor_start - 1500)
window = text[window_start:anchor_start]
matches = list(DS_RE_TH.finditer(window))
if matches:
return matches[-1].group(1)
return None
def _normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", text)
def parse_protocol(pdf_path: str) -> list[dict]:
if fitz is None:
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den TH-Parser")
doc = fitz.open(pdf_path)
full = "".join(p.get_text() for p in doc)
doc.close()
full = _normalize_text(full)
results = []
for m in RESULT_ANCHOR_RE.finditer(full):
modus = m.group("modus")
ergebnis = m.group("ergebnis")
block_start = max(0, m.start() - 1500)
block = full[block_start:m.end()]
ds = _resolve_drucksache_th(full, m.start())
if not ds:
continue
votes = _parse_vote_block_th(block)
einstimmig = modus == "einstimmig"
if einstimmig and not votes["ja"]:
votes["ja"] = list(ALLE_FRAKTIONEN_TH)
results.append({
"drucksache": ds,
"ergebnis": ergebnis,
"einstimmig": einstimmig,
"kind": "direct",
"votes": votes,
"anchor_pos": m.start(),
})
seen = set()
deduped = []
for r in results:
if r["anchor_pos"] in seen:
continue
seen.add(r["anchor_pos"])
deduped.append(r)
return deduped