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:
parent
473637a842
commit
c7d6ac7f5f
@ -30,6 +30,7 @@ from typing import Callable
|
||||
|
||||
from .nrw import parse_protocol as _parse_nrw
|
||||
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.
|
||||
ProtokollParser = Callable[[str], list[dict]]
|
||||
@ -37,6 +38,7 @@ ProtokollParser = Callable[[str], list[dict]]
|
||||
PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
|
||||
"NRW": _parse_nrw,
|
||||
"BUND": _parse_bund,
|
||||
"BE": _parse_be,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
Recherche-Findings vom 2026-04-28, sodass die Implementer-Session
|
||||
direkt produktiv loslegen kann. Der Stub wird **nicht** in
|
||||
``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert — der
|
||||
Auto-Ingest-Cron ueberspringt BE solange.
|
||||
PDF-basierter Parser fuer Berliner Abgeordnetenhaus-Plenarprotokolle.
|
||||
Quelle: ``https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf``
|
||||
|
||||
## Recherche
|
||||
## Anchor-Sprache (verifiziert WP19 Sitzung 50)
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| **Doku-System** | PARDOK |
|
||||
| **Base-URL** | https://pardok.parlament-berlin.de |
|
||||
| **Familie** | LSA-Familie |
|
||||
| **Format** | PDF erwartet |
|
||||
Berliner Abstimmungs-Sprache ist NRW-aehnlich, mit eigenem
|
||||
Sprach-Stil:
|
||||
|
||||
## 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)
|
||||
- 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)")
|
||||
## Fraktions-Mapping WP19
|
||||
|
||||
## Aufwand
|
||||
|
||||
Geschaetzt 1-3 Tage konzentrierte Arbeit:
|
||||
- 2-4h URL-Discovery + Format-Inspektion (Sample-Protokoll inhaltlich anschauen)
|
||||
- 4-8h Anchor-Phrasen-Reverse-Engineering + Parser-Implementierung
|
||||
- 4h Tests mit Fixture-Pinning
|
||||
- 1h Eintrag in PROTOKOLL_PARSERS + auto-ingest-protocols.sh
|
||||
- ``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
|
||||
|
||||
def parse_protocol(path: str) -> list[dict]:
|
||||
"""STUB — siehe Modul-Docstring."""
|
||||
raise NotImplementedError(
|
||||
"BE-Plenarprotokoll-Parser ist noch nicht implementiert. "
|
||||
"Siehe app/protokoll_parsers/be.py-Docstring fuer Recherche-Findings "
|
||||
"und docs/protokoll-parser-roadmap.md."
|
||||
)
|
||||
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
|
||||
|
||||
@ -28,6 +28,7 @@ PROTO_TARGETS=(
|
||||
"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"
|
||||
"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) ==="
|
||||
|
||||
146
tests/test_protokoll_parsers_be.py
Normal file
146
tests/test_protokoll_parsers_be.py
Normal 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"
|
||||
@ -21,7 +21,8 @@ from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender
|
||||
|
||||
STUB_BL_CODES = [
|
||||
# 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",
|
||||
]
|
||||
|
||||
@ -76,8 +77,8 @@ class TestRegistryDiscipline:
|
||||
|
||||
def test_stubs_not_in_registry(self):
|
||||
registered = set(supported_bundeslaender())
|
||||
# Aktuell: NRW + BUND produktiv
|
||||
assert registered == {"NRW", "BUND"}, (
|
||||
# Aktuell: NRW + BUND + BE produktiv
|
||||
assert registered == {"NRW", "BUND", "BE"}, (
|
||||
"Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, "
|
||||
"diesen Test anpassen UND den Stub durch echten Parser ersetzen."
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user