gwoe-antragspruefer/app/protokoll_parsers/sh.py
Dotty Dotter 7ebdc78331 feat(#160): SH-Parser produktiv — Schleswig-Holsteiner Plenarprotokolle
Verifiziert auf WP20 Sitzungen 115 + 116. Format ist TH-aehnlich:

Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen|so beschlossen)"
Vote-Block (Q+A im Reden-Stil):
  - JA: "Wer dem zustimmen will ... Das sind die Fraktionen von X"
  - NEIN: "Wer stimmt dagegen? ... Das sind die Fraktionen von Y"
  - ENTH: "Wer enthaelt sich? ... Z"
Drucksachen-Lookup: rueckwaerts vom Anchor

Besonderheiten:
- SSW (5%-Huerden-befreit) als feste Fraktion
- "Damit ist die Ausschussueberweisung einstimmig so beschlossen" → ergebnis="ueberwiesen"
- "Das sind alle anderen Fraktionen" → NEIN als Komplement von JA inferiert
- Soft-Hyphen-Reparatur (PDF-Zeilenumbruch "zustim- men" → "zustimmen")
- _last_match-Helper, weil 1500-char-Window mehrere Vote-Bloecke enthalten kann
  (TH-Limitierung gefixed)

URL-Pattern (verifiziert):
https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf

Datum-Anteile (YYYY-Pfad + MM-YY-Suffix) machen URL-Vorhersage unmoeglich
→ Auto-Ingest-Cron via Index-Scrape (analog HH/HE):
https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/

Tests: 23 SH-Tests + Stub-Registry-Test angepasst.
Stand: 7 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:29:06 +02:00

221 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser (#106 / #160, ADR 0009).
URL-Pattern (verifiziert WP20 Sitzungen 115, 116):
``https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf``
Datums-Anteil (YYYY-Pfad + MM-YY-Suffix) macht reine URL-Vorhersage
unmoeglich → Auto-Ingest scrapt Index-Seite
``https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/``.
## Anchor-Sprache (verifiziert WP20 Sitzung 116)
```
... Das sind die Fraktionen von SPD, FDP und SSW. Wer stimmt dagegen?
Das sind die Fraktionen von CDU und BÜNDNIS 90/DIE GRÜNEN. Damit
ist der Antrag abgelehnt.
```
Pattern (TH-aehnlich):
- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig|mit Mehrheit|trotzdem mit Mehrheit)? (angenommen|abgelehnt|überwiesen)``
- **Vote-Block:** Q+A im Reden-Stil
- JA: ``Wer dem zustimmt ... Das sind die Fraktionen von [PHRASE]`` /
``Das ist die [PHRASE]-Fraktion``
- NEIN: ``Wer stimmt dagegen? ... [PHRASE]`` / ``Gegenstimmen? ... [PHRASE]``
- ENTH: ``Wer enthaelt sich? ... [PHRASE]`` (oft fehlend)
- **Drucksachen-Lookup:** ``Drucksache 20/N`` rueckwaerts vom Anchor
## Fraktions-Mapping WP20 (ab 2022)
WP20 Konstellation: CDU + GRÜNE (Koalition), SPD + FDP + SSW (Opposition).
SSW ist 5%-Huerden-befreit, daher fester Bestandteil.
- ``CDU``, ``SPD``, ``FDP``, ``SSW``
- ``BÜNDNIS 90/DIE GRÜNEN`` / ``GRÜNEN`` / ``GRÜNE`` → GRÜNE
"""
from __future__ import annotations
import re
from typing import Optional
try:
import fitz
except ImportError:
fitz = None
ALLE_FRAKTIONEN_SH = ["CDU", "GRÜNE", "SPD", "FDP", "SSW"]
FRAKTIONEN_MAP_SH = [
("BÜNDNIS 90/DIE GRÜNEN", ["GRÜNE"]),
("BÜNDNIS 90", ["GRÜNE"]),
("DIE GRÜNEN", ["GRÜNE"]),
("GRÜNEN", ["GRÜNE"]),
("GRÜNE", ["GRÜNE"]),
("CDU", ["CDU"]),
("SPD", ["SPD"]),
("FDP", ["FDP"]),
("SSW", ["SSW"]),
]
def _normalize_fraktionen_sh(text: str) -> list[str]:
found = set()
remaining = text
for phrase, codes in FRAKTIONEN_MAP_SH:
if phrase in remaining:
for c in codes:
found.add(c)
remaining = remaining.replace(phrase, " ")
return sorted(found)
# Result-Anchor: "Damit ist/sind [Subjekt] (modus)? (ergebnis)"
# Erfasst auch "Damit ist die Ausschussueberweisung einstimmig so beschlossen"
# als ergebnis="überwiesen".
RESULT_ANCHOR_RE = re.compile(
r"Damit\s+(?:ist|sind)\s+(?:der|die|das|dieser?|dieses|beide|alle|auch)?\s*"
r"(?P<subject>Antrag|Alternativantrag|Änderungsantrag|Gesetzentwurf|"
r"Beschlussempfehlung|Tagesordnungspunkt|Anträge|Ausschussüberweisung|"
r"trotzdem)?"
r"[^.]{0,200}?(?P<modus>einstimmig|mehrheitlich|mit\s+(?:großer\s+)?Mehrheit|"
r"trotzdem mit Mehrheit)?\s*"
r"(?P<ergebnis>angenommen|abgelehnt|überwiesen|so\s+beschlossen)",
re.DOTALL,
)
# Vote-Sub-Patterns. SH-Form ist Q+A:
# Q: "Wer dem zustimmen will, den bitte ich um das Handzeichen."
# A: " Das sind die Fraktionen von SPD, FDP und SSW."
# Wir matchen Q komplett, dann ab dem ersten `` die Antwort.
JA_RE = re.compile(
r"Wer\s+(?:dem|dafür|so|der|den)?\s*"
r"(?:zustimmen|zustimmt|stimmen|verfahren|"
r"beschließen|so\s+beschließen|für\s+\S+|ist\s+dafür)"
r"\s*(?:will|möchte|kann|ist)?"
r"[^]{0,200}\s*"
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+|"
r"einstimmig)?)?"
r"(?P<ja>[^?.]+?)(?=\.\s|\?|Wer\s+(?:stimmt|enth|dagegen|ist\s+dagegen)|"
r"Gegenstimmen|Damit|Stimmenthaltungen?)",
re.DOTALL,
)
NEIN_RE = re.compile(
r"(?:Wer\s+(?:stimmt\s+dagegen|ist\s+dagegen)|Gegenstimmen)\??[^]{0,80}\s*"
r"(?:Bei\s+Gegenstimmen\s+(?:der\s+)?(?:Fraktionen\s+von\s+)?)?"
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?"
r"(?P<nein>[^?.]+?)(?=\.\s|\?|Wer\s+enth|Damit|Stimmenthaltung)",
re.DOTALL,
)
ENTH_RE = re.compile(
r"(?:Wer\s+enthält\s+sich|Stimmenthaltungen?)\??[^]{0,80}\s*"
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?"
r"(?P<enth>[^?.]+?)(?=\.\s|\?|Damit|Wer)",
re.DOTALL,
)
DS_RE_SH = re.compile(r"Drucksache\s+20/(\d{2,5})")
def _last_match(pattern: re.Pattern, text: str) -> Optional[re.Match]:
"""Return the LAST match — vote-blocks vor einem Anchor sollen den
naechstliegenden Q+A-Block treffen, nicht den ersten."""
last = None
for m in pattern.finditer(text):
last = m
return last
def _parse_vote_block_sh(block: str) -> dict:
votes = {"ja": [], "nein": [], "enthaltung": []}
ja_m = _last_match(JA_RE, block)
if ja_m:
votes["ja"] = _normalize_fraktionen_sh(ja_m.group("ja"))
nein_m = _last_match(NEIN_RE, block)
if nein_m:
votes["nein"] = _normalize_fraktionen_sh(nein_m.group("nein"))
enth_m = _last_match(ENTH_RE, block)
if enth_m:
enth_text = enth_m.group("enth")
low = enth_text.lower()
if "niemand" in low or "keine" in low:
votes["enthaltung"] = []
else:
votes["enthaltung"] = _normalize_fraktionen_sh(enth_text)
return votes
def _resolve_drucksache_sh(text: str, anchor_start: int) -> Optional[str]:
window_start = max(0, anchor_start - 1500)
window = text[window_start:anchor_start]
matches = list(DS_RE_SH.finditer(window))
if matches:
return f"20/{matches[-1].group(1)}"
return None
def _normalize_text(text: str) -> str:
# Soft-hyphenation reparieren: 'zustim- men' → 'zustimmen' (PDF-Zeilen-
# umbruch mit Trennstrich, deutsche Silbentrennung am Wortinneren).
text = re.sub(r"(?<=[a-zäöüß])-\s+(?=[a-zäöüß])", "", text)
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 SH-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") or "").lower()
ergebnis_raw = m.group("ergebnis").lower()
# "so beschlossen" tritt bei Ausschussueberweisung auf
if "beschlossen" in ergebnis_raw:
ergebnis = "überwiesen"
else:
ergebnis = ergebnis_raw
block_start = max(0, m.start() - 1500)
block = full[block_start:m.end()]
ds = _resolve_drucksache_sh(full, m.start())
if not ds:
continue
votes = _parse_vote_block_sh(block)
einstimmig = "einstimmig" in modus
if einstimmig and not votes["ja"]:
votes["ja"] = list(ALLE_FRAKTIONEN_SH)
# "Das sind alle anderen Fraktionen" → NEIN-Komplement von JA
if (votes["ja"] and not votes["nein"]
and "alle anderen" in block[-400:].lower()):
votes["nein"] = sorted(set(ALLE_FRAKTIONEN_SH) - set(votes["ja"]))
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