From c7d6ac7f5f16fc311e3030a59c718df17aeb1246 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 29 Apr 2026 00:37:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(#150):=20BE-Parser=20produktiv=20=E2=80=94?= =?UTF-8?q?=20Berliner=20Abgeordnetenhaus-Plenarprotokolle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app/protokoll_parsers/__init__.py | 2 + app/protokoll_parsers/be.py | 218 +++++++++++++++++++++----- scripts/auto-ingest-protocols.sh | 1 + tests/test_protokoll_parsers_be.py | 146 +++++++++++++++++ tests/test_protokoll_parsers_stubs.py | 7 +- 5 files changed, 336 insertions(+), 38 deletions(-) create mode 100644 tests/test_protokoll_parsers_be.py diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index 3709c12..e3decc4 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -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, } diff --git a/app/protokoll_parsers/be.py b/app/protokoll_parsers/be.py index 0524ffa..4ac28c8 100644 --- a/app/protokoll_parsers/be.py +++ b/app/protokoll_parsers/be.py @@ -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"(?PAntrag|Änderungsantrag|Gesetzesvorlage|Gesetzentwurf|" + r"Entschließungsantrag|Beschlussempfehlung)" + r"[^.]{0,200}?(?Pangenommen|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[^?]+?)(?=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[^?]+?)(?=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[^?.]+?)(?=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 diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index 44f6360..d672156 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -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) ===" diff --git a/tests/test_protokoll_parsers_be.py b/tests/test_protokoll_parsers_be.py new file mode 100644 index 0000000..eb1fe19 --- /dev/null +++ b/tests/test_protokoll_parsers_be.py @@ -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" diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index 69a0a6c..6dddc4e 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -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." )