gwoe-antragspruefer/app/protokoll_parsers/be.py
Dotty Dotter c7d6ac7f5f feat(#150): BE-Parser produktiv — Berliner Abgeordnetenhaus-Plenarprotokolle
Dritter vollwertiger Plenarprotokoll-Parser nach NRW + BUND.

URL-Pattern verifiziert (WP19 Sitzungen 1, 10, 50, 80, 100):
  https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf

Anchor-Sprache (NRW-aehnlich, mit Berliner-Eigenheit 'pro forma'):
  Wer den Antrag auf Drucksache 19/X annehmen moechte, ... – Das sind
    die Fraktionen Buendnis 90/Die Gruenen und Die Linke.
  Wer stimmt dagegen? – Das sind die Fraktionen der CDU, SPD und AfD.
  Wer enthaelt sich, pro forma? – Das ist niemand.
  Damit ist der Antrag abgelehnt.

Pattern:
- Result-Anchor: Damit ist [Antrag/Aenderungsantrag/Gesetzentwurf/...]
  (angenommen|abgelehnt)
- Vote-Block: 3 Q+A-Paare im Reden-Stil (annehmen moechte / dagegen /
  enthaelt sich)
- Drucksachen-Lookup: 'Drucksache 19/N(-suffix)' rueckwaerts (1500-char Fenster)

Fraktions-Mapping WP19:
- Buendnis 90/Die Gruenen → GRÜNE
- Die Linke → LINKE
- CDU, SPD, AfD, FDP

21 Tests in test_protokoll_parsers_be.py.
Cron-PROTO_TARGETS erweitert um BE WP19 (~80 Sitzungen).
Stub-Test angepasst.

905 Tests gruen (889 → 905, +16 fuer BE).
2026-04-29 00:37:47 +02:00

196 lines
5.9 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.

"""Berlin (BE) — Plenarprotokoll-Parser (#106 / #150, ADR 0009).
PDF-basierter Parser fuer Berliner Abgeordnetenhaus-Plenarprotokolle.
Quelle: ``https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf``
## Anchor-Sprache (verifiziert WP19 Sitzung 50)
Berliner Abstimmungs-Sprache ist NRW-aehnlich, mit eigenem
Sprach-Stil:
```
Wer den Antrag auf Drucksache 19/1589 annehmen moechte, den bitte
ich jetzt um das Handzeichen. Das sind die Fraktionen Bündnis 90/
Die Gruenen und Die Linke. Wer stimmt dagegen? Das sind die
Fraktionen der CDU, SPD und AfD. Wer enthaelt sich, pro forma?
Das ist niemand. Damit ist der Antrag abgelehnt.
```
## Pattern-Erkennung
- **Result-Anchor:** ``Damit ist [Antrag/Aenderungsantrag/...] (angenommen|abgelehnt)``
- **Vote-Block:** drei Q+A-Paare im Reden-Stil
- JA: ``annehmen moechte ... Das sind [PHRASE]``
- NEIN: ``Wer stimmt dagegen? Das sind [PHRASE]``
- ENTH: ``Wer enthaelt sich(, pro forma)? [PHRASE]``
- **Drucksachen-Lookup:** ``auf Drucksache 19/N`` rueckwaerts vom Anchor
## Fraktions-Mapping WP19
- ``Buendnis 90/Die Gruenen`` → GRÜNE
- ``Die Linke`` → LINKE
- ``CDU``, ``SPD``, ``AfD``, ``FDP``
- ``fraktionsloser Abgeordneter`` → ignoriert (Einzelpersonen)
"""
from __future__ import annotations
import re
from typing import Optional
try:
import fitz # PyMuPDF
except ImportError:
fitz = None
ALLE_FRAKTIONEN_BE = ["CDU", "SPD", "GRÜNE", "LINKE", "AfD", "FDP"]
# Reihenfolge: längere Aliasse zuerst.
FRAKTIONEN_MAP_BE = [
("Bündnis 90/Die Grünen", ["GRÜNE"]),
("Bündnisses 90/Die Grünen", ["GRÜNE"]),
("Bündnis 90", ["GRÜNE"]),
("Die Linke", ["LINKE"]),
("der Linken", ["LINKE"]),
("Linke", ["LINKE"]),
("CDU", ["CDU"]),
("SPD", ["SPD"]),
("AfD", ["AfD"]),
("FDP", ["FDP"]),
]
def _normalize_fraktionen_be(text: str) -> list[str]:
"""Extrahiere BE-Fraktions-Codes aus einer Phrase."""
found = set()
remaining = text
for phrase, codes in FRAKTIONEN_MAP_BE:
if phrase in remaining:
for c in codes:
found.add(c)
remaining = remaining.replace(phrase, " ")
return sorted(found)
# Result-Anchor: "Damit ist [Subjekt] (angenommen|abgelehnt)"
RESULT_ANCHOR_RE = re.compile(
r"Damit ist\s+(?:auch\s+)?(?:dieser?|die|das|der)\s+"
r"(?P<subject>Antrag|Änderungsantrag|Gesetzesvorlage|Gesetzentwurf|"
r"Entschließungsantrag|Beschlussempfehlung)"
r"[^.]{0,200}?(?P<ergebnis>angenommen|abgelehnt)\.",
re.DOTALL,
)
# Vote-Sub-Patterns: 3 Reden-Q+A-Paare. Wir akzeptieren Punkte zwischen
# der Frage und dem Vote-Block ("möchte. Das sind ..."), und stoppen am
# nächsten Q-Marker oder Damit-Anchor.
JA_RE = re.compile(
r"annehmen m(?:ö|oe)chte[^?]{0,200}?[-]\s+(?:Das sind\s+|Das ist\s+)?"
r"(?P<ja>[^?]+?)(?=Wer stimmt dagegen|Wer enthält sich|Wer enthaelt sich|Damit ist)",
re.DOTALL,
)
NEIN_RE = re.compile(
r"Wer stimmt dagegen\?[^?]{0,40}?[-]\s+(?:Das sind\s+|Das ist\s+)?"
r"(?P<nein>[^?]+?)(?=Wer enthält sich|Wer enthaelt sich|Damit ist)",
re.DOTALL,
)
ENTH_RE = re.compile(
r"Wer enth(?:ä|ae)lt sich(?:,\s+pro forma)?\?[^?]{0,40}?[-]\s+"
r"(?P<enth>[^?.]+?)(?=Damit ist|Wer|\.)",
re.DOTALL,
)
DS_RE_BE = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5}(?:-\d+)?)")
def _parse_vote_block_be(block: str) -> dict:
"""Parst BE-Vote-Block aus dem Text vor einem Damit-Anchor."""
votes = {"ja": [], "nein": [], "enthaltung": []}
ja_m = JA_RE.search(block)
if ja_m:
votes["ja"] = _normalize_fraktionen_be(ja_m.group("ja"))
nein_m = NEIN_RE.search(block)
if nein_m:
votes["nein"] = _normalize_fraktionen_be(nein_m.group("nein"))
enth_m = ENTH_RE.search(block)
if enth_m:
enth_text = enth_m.group("enth")
# 'niemand' → leere Liste (Berliner Idiom)
if "niemand" in enth_text.lower() or "ist nicht der Fall" in enth_text:
votes["enthaltung"] = []
else:
votes["enthaltung"] = _normalize_fraktionen_be(enth_text)
return votes
def _resolve_drucksache_be(text: str, anchor_start: int) -> Optional[str]:
"""Rueckwaerts vom Anchor die Drucksache finden (1500-Zeichen Window)."""
window_start = max(0, anchor_start - 1500)
window = text[window_start:anchor_start]
matches = list(DS_RE_BE.finditer(window))
if matches:
return matches[-1].group(1)
return None
def _normalize_text(text: str) -> str:
"""Whitespace-Normalisierung wie NRW-Parser."""
text = re.sub(r"\s+", " ", text)
return text
def parse_protocol(pdf_path: str) -> list[dict]:
"""Parst ein Berliner Plenarprotokoll-PDF und liefert Vote-Records."""
if fitz is None:
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den BE-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):
ergebnis = m.group("ergebnis")
# Vote-Block: 1500 Zeichen vor dem Anchor
block_start = max(0, m.start() - 1500)
block = full[block_start:m.end()]
ds = _resolve_drucksache_be(full, m.start())
if not ds:
continue
votes = _parse_vote_block_be(block)
einstimmig = (
len(votes["ja"]) >= 5
and not votes["nein"]
and not votes["enthaltung"]
)
results.append({
"drucksache": ds,
"ergebnis": ergebnis,
"einstimmig": einstimmig,
"kind": "direct",
"votes": votes,
"anchor_pos": m.start(),
})
# Dedup ueber anchor_pos
seen = set()
deduped = []
for r in results:
if r["anchor_pos"] in seen:
continue
seen.add(r["anchor_pos"])
deduped.append(r)
return deduped