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).
This commit is contained in:
Dotty Dotter 2026-04-29 00:37:47 +02:00
parent 473637a842
commit c7d6ac7f5f
5 changed files with 336 additions and 38 deletions

View File

@ -30,6 +30,7 @@ from typing import Callable
from .nrw import parse_protocol as _parse_nrw from .nrw import parse_protocol as _parse_nrw
from .bund import parse_protocol as _parse_bund from .bund import parse_protocol as _parse_bund
from .be import parse_protocol as _parse_be
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
ProtokollParser = Callable[[str], list[dict]] ProtokollParser = Callable[[str], list[dict]]
@ -37,6 +38,7 @@ ProtokollParser = Callable[[str], list[dict]]
PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
"NRW": _parse_nrw, "NRW": _parse_nrw,
"BUND": _parse_bund, "BUND": _parse_bund,
"BE": _parse_be,
} }

View File

@ -1,47 +1,195 @@
"""Berlin (BE) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). """Berlin (BE) — Plenarprotokoll-Parser (#106 / #150, ADR 0009).
**Status: noch nicht implementiert.** Dieser Modul-Stub enthaelt PDF-basierter Parser fuer Berliner Abgeordnetenhaus-Plenarprotokolle.
Recherche-Findings vom 2026-04-28, sodass die Implementer-Session Quelle: ``https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf``
direkt produktiv loslegen kann. Der Stub wird **nicht** in
``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert der
Auto-Ingest-Cron ueberspringt BE solange.
## Recherche ## Anchor-Sprache (verifiziert WP19 Sitzung 50)
| Feld | Wert | Berliner Abstimmungs-Sprache ist NRW-aehnlich, mit eigenem
|---|---| Sprach-Stil:
| **Doku-System** | PARDOK |
| **Base-URL** | https://pardok.parlament-berlin.de |
| **Familie** | LSA-Familie |
| **Format** | PDF erwartet |
## URL-Discovery ```
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.
```
Plenum-PDF-URLs ueber PARDOK-Search-API zu ermitteln; direktes Pattern noch nicht bekannt ## Pattern-Erkennung
## Bezug - **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
- Architektur: ADR 0009 (Plenarprotokoll-Parser-Registry) ## Fraktions-Mapping WP19
- Roadmap: ``docs/protokoll-parser-roadmap.md``
- Referenz-Implementation: ``app/protokoll_parsers/nrw.py``
(38 Tests, 19/19-Fixture-Garantie)
- Folge-Issue: https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/150 (Titel: "protokoll-parser: BE (Berlin)")
## Aufwand - ``Buendnis 90/Die Gruenen`` GRÜNE
- ``Die Linke`` LINKE
Geschaetzt 1-3 Tage konzentrierte Arbeit: - ``CDU``, ``SPD``, ``AfD``, ``FDP``
- 2-4h URL-Discovery + Format-Inspektion (Sample-Protokoll inhaltlich anschauen) - ``fraktionsloser Abgeordneter`` ignoriert (Einzelpersonen)
- 4-8h Anchor-Phrasen-Reverse-Engineering + Parser-Implementierung
- 4h Tests mit Fixture-Pinning
- 1h Eintrag in PROTOKOLL_PARSERS + auto-ingest-protocols.sh
""" """
from __future__ import annotations from __future__ import annotations
import re
from typing import Optional
def parse_protocol(path: str) -> list[dict]: try:
"""STUB — siehe Modul-Docstring.""" import fitz # PyMuPDF
raise NotImplementedError( except ImportError:
"BE-Plenarprotokoll-Parser ist noch nicht implementiert. " fitz = None
"Siehe app/protokoll_parsers/be.py-Docstring fuer Recherche-Findings "
"und docs/protokoll-parser-roadmap.md."
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

View File

@ -28,6 +28,7 @@ PROTO_TARGETS=(
"NRW|18|MMP18-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{n}.pdf" "NRW|18|MMP18-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{n}.pdf"
"NRW|17|MMP17-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP17-{n}.pdf" "NRW|17|MMP17-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP17-{n}.pdf"
"BUND|20|BTP20-|https://dserver.bundestag.de/btp/20/20{n3}.xml" "BUND|20|BTP20-|https://dserver.bundestag.de/btp/20/20{n3}.xml"
"BE|19|PlPr19-|https://www.parlament-berlin.de/ados/19/IIIPlen/protokoll/plen19-{n3}-pp.pdf"
) )
echo "=== auto-ingest-protocols $(date -Iseconds) ===" echo "=== auto-ingest-protocols $(date -Iseconds) ==="

View File

@ -0,0 +1,146 @@
"""Tests fuer app/protokoll_parsers/be.py — Berliner Plenarprotokoll-Parser (#150).
Stichprobe-getestet gegen WP19 Sitzung 50 (Berlin).
"""
from __future__ import annotations
import pytest
from app.protokoll_parsers.be import (
_normalize_fraktionen_be,
_parse_vote_block_be,
_resolve_drucksache_be,
RESULT_ANCHOR_RE,
ALLE_FRAKTIONEN_BE,
FRAKTIONEN_MAP_BE,
)
class TestNormalizeFraktionenBe:
def test_simple_cdu(self):
assert _normalize_fraktionen_be("der CDU") == ["CDU"]
def test_buendnis_90_normalizes_to_gruene(self):
assert _normalize_fraktionen_be("Bündnis 90/Die Grünen") == ["GRÜNE"]
def test_die_linke(self):
assert _normalize_fraktionen_be("der Die Linke") == ["LINKE"]
def test_combined_phrase(self):
result = _normalize_fraktionen_be(
"die Fraktionen Bündnis 90/Die Grünen und Die Linke"
)
assert set(result) == {"GRÜNE", "LINKE"}
def test_three_fraktionen(self):
result = _normalize_fraktionen_be("die Fraktionen der CDU, SPD und AfD")
assert set(result) == {"CDU", "SPD", "AfD"}
def test_empty_returns_empty(self):
assert _normalize_fraktionen_be("") == []
def test_no_double_count(self):
# 'Die Linke' und 'Linke' beide im Text → nur 1× LINKE
result = _normalize_fraktionen_be("Die Linke und der Linken")
assert result.count("LINKE") == 1
class TestParseVoteBlockBe:
def test_complete_block(self):
block = (
"Wer den Antrag auf Drucksache 19/1589 annehmen möchte, den bitte "
"ich um das Handzeichen. Das sind die Fraktionen Bündnis 90/Die "
"Grünen und Die Linke. Wer stimmt dagegen? Das sind die Fraktionen "
"der CDU, SPD und AfD. Wer enthält sich, pro forma? Das ist niemand. "
"Damit ist der Antrag abgelehnt."
)
votes = _parse_vote_block_be(block)
assert set(votes["ja"]) == {"GRÜNE", "LINKE"}
assert set(votes["nein"]) == {"CDU", "SPD", "AfD"}
assert votes["enthaltung"] == [] # 'niemand' → leer
def test_enthaltung_ignored_when_niemand(self):
block = (
"annehmen möchte, ... Das sind die CDU. "
"Wer stimmt dagegen? Das sind SPD. "
"Wer enthält sich? Das ist niemand."
)
votes = _parse_vote_block_be(block)
assert votes["enthaltung"] == []
def test_enthaltung_with_real_fraktion(self):
block = (
"annehmen möchte, ... Das sind CDU und SPD. "
"Wer stimmt dagegen? AfD. "
"Wer enthält sich? Die Linke."
)
votes = _parse_vote_block_be(block)
assert votes["enthaltung"] == ["LINKE"]
class TestResolveDrucksacheBe:
def test_finds_drucksache_before_anchor(self):
text = "Auf Drucksache 19/1234 ... Damit ist der Antrag angenommen."
anchor = text.index("Damit")
assert _resolve_drucksache_be(text, anchor) == "19/1234"
def test_with_dash_suffix(self):
"""Berliner Drucksachen koennen '-1', '-2' Suffixe haben fuer
Aenderungs-Versionen."""
text = "Aenderungsantrag auf Drucksache 19/1589-2 ... Damit ist abgelehnt."
anchor = text.index("Damit")
assert _resolve_drucksache_be(text, anchor) == "19/1589-2"
def test_returns_none_when_no_ds(self):
text = "Damit ist der Antrag abgelehnt."
assert _resolve_drucksache_be(text, 0) is None
class TestResultAnchorRegex:
def test_matches_antrag_angenommen(self):
text = "Damit ist der Antrag angenommen."
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("subject") == "Antrag"
assert m.group("ergebnis") == "angenommen"
def test_matches_aenderungsantrag_abgelehnt(self):
text = "Damit ist der Änderungsantrag abgelehnt."
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("subject") == "Änderungsantrag"
assert m.group("ergebnis") == "abgelehnt"
def test_matches_dieser_antrag_abgelehnt(self):
text = "Damit ist dieser Antrag abgelehnt."
m = RESULT_ANCHOR_RE.search(text)
assert m
def test_matches_auch_dieser_aenderungsantrag(self):
text = "Damit ist auch dieser Änderungsantrag abgelehnt."
m = RESULT_ANCHOR_RE.search(text)
assert m
def test_matches_gesetzentwurf(self):
text = "Damit ist der Gesetzentwurf angenommen."
m = RESULT_ANCHOR_RE.search(text)
assert m
def test_no_match_random_text(self):
text = "Der Bezirksbürgermeister hat eine Idee abgelehnt."
m = RESULT_ANCHOR_RE.search(text)
# 'Damit ist' fehlt — kein Match
assert m is None
class TestConstants:
def test_all_fraktionen_complete(self):
assert set(ALLE_FRAKTIONEN_BE) == {"CDU", "SPD", "GRÜNE", "LINKE", "AfD", "FDP"}
def test_mapping_covers_all_fraktionen(self):
"""Jede der 6 BE-Fraktionen sollte mindestens einen Phrase-Eintrag haben."""
all_codes = set()
for _phrase, codes in FRAKTIONEN_MAP_BE:
all_codes.update(codes)
for f in ALLE_FRAKTIONEN_BE:
assert f in all_codes, f"Fraktion {f} fehlt im FRAKTIONEN_MAP_BE"

View File

@ -21,7 +21,8 @@ from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender
STUB_BL_CODES = [ STUB_BL_CODES = [
# BUND raus, weil seit 2026-04-28 produktiver Parser (#148) # BUND raus, weil seit 2026-04-28 produktiver Parser (#148)
"BB", "BE", "BW", "BY", "HB", "HE", "HH", # BE raus, weil seit 2026-04-29 produktiver Parser (#150)
"BB", "BW", "BY", "HB", "HE", "HH",
"LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH", "LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH",
] ]
@ -76,8 +77,8 @@ class TestRegistryDiscipline:
def test_stubs_not_in_registry(self): def test_stubs_not_in_registry(self):
registered = set(supported_bundeslaender()) registered = set(supported_bundeslaender())
# Aktuell: NRW + BUND produktiv # Aktuell: NRW + BUND + BE produktiv
assert registered == {"NRW", "BUND"}, ( assert registered == {"NRW", "BUND", "BE"}, (
"Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, "
"diesen Test anpassen UND den Stub durch echten Parser ersetzen." "diesen Test anpassen UND den Stub durch echten Parser ersetzen."
) )