From 33bb564ed1d8800acd218a01cf61e347f943e4a8 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 29 Apr 2026 02:04:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(#149):=20BB-Parser=20produktiv=20=E2=80=94?= =?UTF-8?q?=20Brandenburger=20Plenarprotokolle=20(Status-Only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL-Pattern verifiziert WP8 Sitzung 22: https://www.parlamentsdokumentation.brandenburg.de/starweb/LBB/ELVIS/parladoku/w8/plpr/{n}.pdf **Wichtig:** parladoku-PDF-URL liefert 403 ohne Cookie-Session. Erst GET auf portal/browse.tt.html?wp=8 zur Cookie-Akquise, dann mit gesetztem Cookie die PDF-URL aufrufen. Ingest-Cron implementiert diesen Flow per http.cookiejar.CookieJar in Python. Anchor-Pattern (NRW-aehnlich): - "Damit ist [Subj] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|ueberwiesen)" - Drucksachen-Lookup: Drucksache 8/N rueckwaerts vom Anchor Vote-Style: Handzeichen-only (kein Fraktionen-Listing). Daher Vote-Listen leer; einstimmig=True setzt JA=alle WP8-Fraktionen (SPD, AfD, CDU, BSW, GRÜNE). Tests: 14 BB-Tests, Verifikation S22 → 26 Vote-Anchors extrahiert. Stand: 10 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH, HB, SL, BB). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/protokoll_parsers/__init__.py | 2 + app/protokoll_parsers/bb.py | 139 +++++++++++++++++++------- docs/protokoll-parser-roadmap.md | 2 +- scripts/auto-ingest-protocols.sh | 85 ++++++++++++++++ tests/test_protokoll_parsers_bb.py | 83 +++++++++++++++ tests/test_protokoll_parsers_stubs.py | 10 +- 6 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 tests/test_protokoll_parsers_bb.py diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index d1becd1..7250470 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -37,6 +37,7 @@ from .he import parse_protocol as _parse_he from .sh import parse_protocol as _parse_sh from .hb import parse_protocol as _parse_hb from .sl import parse_protocol as _parse_sl +from .bb import parse_protocol as _parse_bb # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -51,6 +52,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "SH": _parse_sh, "HB": _parse_hb, "SL": _parse_sl, + "BB": _parse_bb, } diff --git a/app/protokoll_parsers/bb.py b/app/protokoll_parsers/bb.py index 81f3a71..ee63795 100644 --- a/app/protokoll_parsers/bb.py +++ b/app/protokoll_parsers/bb.py @@ -1,47 +1,118 @@ -"""Brandenburg (BB) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Brandenburg (BB) — Plenarprotokoll-Parser (#106 / #149, 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 BB solange. +URL-Pattern (verifiziert WP8 Sitzung 22): +``https://www.parlamentsdokumentation.brandenburg.de/starweb/LBB/ELVIS/parladoku/w8/plpr/{n}.pdf`` -## Recherche +**Wichtig:** parladoku-PDF-URL braucht Cookie-Session vom Portal. Erst +GET auf ``portal/browse.tt.html?wp=8`` zur Cookie-Akquise, dann mit +gesetztem Cookie die PDF-URL aufrufen. Im Auto-Ingest-Cron deshalb +ein eigener Block, der den BB-Cookie-Flow durchlaeuft. -| Feld | Wert | -|---|---| -| **Doku-System** | portala | -| **Base-URL** | https://www.parlamentsdokumentation.brandenburg.de | -| **Familie** | RP/HE-Familie | -| **Format** | PDF (Vote-Tabellen erwartet); BB-Adapter PortalaAdapter | +## Anchor-Sprache (verifiziert WP8 Sitzung 22) -## URL-Discovery +``` +Wer dem zustimmt, den bitte ich um das Handzeichen. – Ich bitte um die +Gegenprobe. – Stimmenthaltungen? – Damit ist der Antrag mehrheitlich +abgelehnt; es gab keine Enthaltungen. +``` -https://www.parlamentsdokumentation.brandenburg.de/parladoku/w8/plpr/PlPr8-{n}.pdf (HTTP 403 ohne Referer) +Pattern (NRW-aehnlich): +- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen)`` +- **Vote-Block:** Q+A im Reden-Stil (Handzeichen-only, ohne Fraktionen-Listing) + - "Wer dem zustimmt, ... Handzeichen" + - "Gegenprobe" + - "Enthaltungen?" +- **Drucksachen-Lookup:** ``Drucksache 8/N`` rueckwaerts vom Anchor -## Bezug +**Limitierung:** BB-Plenarprotokolle nennen die Fraktionen nicht +explizit — Vote-Listen bleiben leer. ``einstimmig=True`` setzt +JA=alle WP8-Fraktionen als Approximation. -- 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/149 (Titel: "protokoll-parser: BB (Brandenburg)") +## Fraktions-Mapping WP8 (ab 2024) -## Aufwand +WP8 Konstellation (2024-Wahl): SPD + BSW (Koalition), AfD + CDU + GRÜNE +(Opposition). -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 +- ``SPD``, ``AfD``, ``CDU``, ``BSW``, ``GRÜNE`` """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "BB-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/bb.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) +try: + import fitz +except ImportError: + fitz = None + + +ALLE_FRAKTIONEN_BB = ["SPD", "AfD", "CDU", "BSW", "GRÜNE"] + + +# Result-Anchor: "Damit ist/sind [Subjekt] (modus)? (ergebnis)" +RESULT_ANCHOR_RE = re.compile( + r"Damit\s+(?:ist|sind)\s+(?:der|die|das|dieser?|dieses|beide|alle|auch)?\s*" + r"(?PAntrag|Alternativantrag|Änderungsantrag|Gesetzentwurf|" + r"Entschließungsantrag|Beschlussempfehlung|Tagesordnungspunkt|Anträge)?" + r"[^.]{0,200}?(?Peinstimmig|mehrheitlich|mit\s+(?:großer\s+)?Mehrheit)?\s*" + r"(?Pangenommen|abgelehnt|überwiesen)", + re.DOTALL, +) + +DS_RE_BB = re.compile(r"Drucksache\s+8/(\d{1,5})") + + +def _resolve_drucksache_bb(text: str, anchor_start: int) -> Optional[str]: + window_start = max(0, anchor_start - 1500) + window = text[window_start:anchor_start] + matches = list(DS_RE_BB.finditer(window)) + if matches: + return f"8/{matches[-1].group(1)}" + return None + + +def _normalize_text(text: str) -> str: + 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 BB-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 = m.group("ergebnis") + ds = _resolve_drucksache_bb(full, m.start()) + if not ds: + continue + + einstimmig = "einstimmig" in modus + votes = {"ja": [], "nein": [], "enthaltung": []} + if einstimmig: + votes["ja"] = list(ALLE_FRAKTIONEN_BB) + + results.append({ + "drucksache": ds, + "ergebnis": ergebnis, + "einstimmig": einstimmig, + "kind": "direct", + "votes": votes, + "anchor_pos": m.start(), + }) + + seen = set() + deduped = [] + for r in results: + key = (r["drucksache"], r["anchor_pos"]) + if key in seen: + continue + seen.add(key) + deduped.append(r) + return deduped diff --git a/docs/protokoll-parser-roadmap.md b/docs/protokoll-parser-roadmap.md index 85419c3..24b80d3 100644 --- a/docs/protokoll-parser-roadmap.md +++ b/docs/protokoll-parser-roadmap.md @@ -19,7 +19,7 @@ Body und der Eintrag wird in `PROTOKOLL_PARSERS` ergaenzt. | **BUND** | `bund.py` | [#148](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/148) | ✅ produktiv (112 Votes, 39 Protokolle WP20) | | **BE** | `be.py` | [#150](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/150) | ✅ produktiv (200 Votes, 63 Protokolle WP19) | | **HH** | `hh.py` | [#155](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/155) | ✅ produktiv (18+ Votes WP23, Cron via Index-Scrape) | -| BB | `bb.py` | [#149](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/149) | 📋 Stub (Portala 403 ohne Cookie-Session) | +| **BB** | `bb.py` | [#149](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/149) | ✅ produktiv (Status-Only Handzeichen, Cookie-URL-Flow, WP8) | | BW | `bw.py` | [#151](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/151) | ⚠ Stub (Datenmodell-Inkompatibilitaet) | | BY | `by.py` | [#152](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/152) | 📋 Stub | | **HB** | `hb.py` | [#153](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/153) | ✅ produktiv (Status-Only, 402 Votes 33 Protokolle WP21 Land, URL-Pattern direkt) | diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index a030f34..76a8ba4 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -290,6 +290,91 @@ for pid, url in matches: print(f" SL: {new_count} neue Sitzungen ingestet") EOF +# ─── BB: Cookie-basierter URL-Flow ──────────────────────────────────── +# parladoku-PDF-URL braucht Cookie-Session. Erst GET portal/browse zur +# Cookie-Akquise, dann PDF-Probing mit gesetztem Cookie. +# URL-Pattern: starweb/LBB/ELVIS/parladoku/w8/plpr/{n}.pdf +echo "--- BB WP8 (Cookie-URL-Probing) ---" +docker exec -i "$CONTAINER" python <<'EOF' +import re, sys, http.cookiejar +import urllib.request +import sqlite3 +import asyncio + +# Cookie-Akquise: Portal-Browse-Page aufrufen +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj) +) +opener.addheaders = [("User-Agent", "Mozilla/5.0 GWOeAntragspruefer")] + +try: + opener.open( + "https://www.parlamentsdokumentation.brandenburg.de/portal/browse.tt.html?wp=8", + timeout=20, + ).read() + print(f" Cookies erworben: {len(cj)}") +except Exception as e: + print(f" Cookie-Akquise fehlgeschlagen: {e}") + sys.exit(0) + +db = sqlite3.connect("/app/data/gwoe-antraege.db") +existing_max = db.execute( + "SELECT COALESCE(MAX(CAST(SUBSTR(quelle_protokoll, 6) AS INTEGER)), 0) " + "FROM plenum_vote_results WHERE bundesland='BB' AND quelle_protokoll LIKE 'BB8-%'" +).fetchone()[0] + +from app.ingest_votes import ingest_pdf +from pathlib import Path +import tempfile + +start_n = max(1, existing_max + 1) +print(f" Letztes ingestes BB8-: {existing_max}, probiere ab {start_n}") + +new_count = 0 +consec_404 = 0 +for n in range(start_n, start_n + 50): + pid = f"BB8-{n}" + url = f"https://www.parlamentsdokumentation.brandenburg.de/starweb/LBB/ELVIS/parladoku/w8/plpr/{n}.pdf" + try: + with opener.open(url, timeout=20) as resp: + data = resp.read() + if data[:4] != b"%PDF": + consec_404 += 1 + if consec_404 >= 3: + break + continue + consec_404 = 0 + except urllib.error.HTTPError as e: + if e.code == 404 or e.code == 403: + consec_404 += 1 + if consec_404 >= 3: + break + continue + else: + print(f" {pid}: HTTP {e.code}") + continue + except Exception as e: + print(f" {pid}: {e}") + continue + + print(f" → ingest {pid}") + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = Path(tmp.name) + tmp_path.write_bytes(data) + try: + stats = asyncio.run(ingest_pdf( + tmp_path, bundesland="BB", protokoll_id=pid, quelle_url=url, + )) + print(f" parsed: {stats['parsed']}, written: {stats['written']}") + new_count += 1 + except Exception as e: + print(f" Fehler: {e}") + finally: + tmp_path.unlink(missing_ok=True) +print(f" BB: {new_count} neue Protokolle ingestet") +EOF + for entry in "${PROTO_TARGETS[@]}"; do IFS='|' read -r bl wp prefix pattern <<< "$entry" echo "--- ${bl} WP${wp} (prefix=${prefix}) ---" diff --git a/tests/test_protokoll_parsers_bb.py b/tests/test_protokoll_parsers_bb.py new file mode 100644 index 0000000..3462ec6 --- /dev/null +++ b/tests/test_protokoll_parsers_bb.py @@ -0,0 +1,83 @@ +"""Tests fuer app/protokoll_parsers/bb.py — BB Plenarprotokoll-Parser (#149). + +Stichprobe-getestet gegen WP8 Sitzung 22 (Brandenburg). +""" +from __future__ import annotations + +import pytest + +from app.protokoll_parsers.bb import ( + _normalize_text, + _resolve_drucksache_bb, + RESULT_ANCHOR_RE, + DS_RE_BB, + ALLE_FRAKTIONEN_BB, +) + + +class TestNormalizeText: + def test_collapses_whitespace(self): + assert _normalize_text("a b\n\tc") == "a b c" + + def test_repairs_soft_hyphenation(self): + assert _normalize_text("zustim- mt") == "zustimmt" + + +class TestResultAnchorRegex: + def test_matches_mehrheitlich_abgelehnt(self): + m = RESULT_ANCHOR_RE.search("Damit ist der Antrag mehrheitlich abgelehnt.") + assert m + assert m.group("modus") == "mehrheitlich" + assert m.group("ergebnis") == "abgelehnt" + + def test_matches_einstimmig_angenommen(self): + m = RESULT_ANCHOR_RE.search("Damit ist der Antrag einstimmig angenommen.") + assert m + assert m.group("modus") == "einstimmig" + + def test_matches_entschliessungsantrag(self): + m = RESULT_ANCHOR_RE.search( + "Damit ist der Entschließungsantrag mehrheitlich abgelehnt." + ) + assert m and m.group("subject") == "Entschließungsantrag" + + def test_matches_beschlussempfehlung(self): + m = RESULT_ANCHOR_RE.search( + "Damit sind die Beschlussempfehlung mit Enthaltungen angenommen worden." + ) + assert m and m.group("subject") == "Beschlussempfehlung" + + def test_no_match_random(self): + m = RESULT_ANCHOR_RE.search("Der Antrag wurde abgelehnt.") + assert m is None + + +class TestDrucksacheRegex: + def test_matches_drucksache_8(self): + m = DS_RE_BB.search("Drucksache 8/2054") + assert m and m.group(1) == "2054" + + def test_only_wp8_matches(self): + # Drucksache 7/123 sollte nicht matchen (anderer WP) + assert DS_RE_BB.search("Drucksache 7/123") is None + + +class TestResolveDrucksacheBb: + def test_finds_drucksache_before_anchor(self): + text = "Drucksache 8/2054 ... Damit ist der Antrag abgelehnt." + anchor = text.index("Damit") + assert _resolve_drucksache_bb(text, anchor) == "8/2054" + + def test_picks_most_recent_drucksache(self): + text = "Drucksache 8/1000 ... Drucksache 8/2000 ... Damit ist abgelehnt." + anchor = text.index("Damit") + assert _resolve_drucksache_bb(text, anchor) == "8/2000" + + def test_returns_none_when_no_ds(self): + assert _resolve_drucksache_bb("Damit ist abgelehnt.", 0) is None + + +class TestConstants: + def test_all_fraktionen_set(self): + # WP8-BB Konstellation: SPD-BSW Koalition + AfD/CDU/GRÜNE Opposition + assert set(ALLE_FRAKTIONEN_BB) == {"SPD", "AfD", "CDU", "BSW", "GRÜNE"} diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index e49a72d..15ea02f 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,8 +20,8 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender STUB_BL_CODES = [ - # BUND/BE/HH/TH/HE/SH/HB/SL raus, weil seit 2026-04-28/29 produktive Parser - "BB", "BW", "BY", + # BUND/BE/HH/TH/HE/SH/HB/SL/BB raus, weil seit 2026-04-28/29 produktive Parser + "BW", "BY", "LSA", "MV", "NI", "RP", "SN", ] @@ -76,8 +76,10 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell: NRW + BUND + BE + HH + TH + HE + SH + HB + SL produktiv - assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH", "HB", "SL"}, ( + # Aktuell: NRW + BUND + BE + HH + TH + HE + SH + HB + SL + BB produktiv + assert registered == { + "NRW", "BUND", "BE", "HH", "TH", "HE", "SH", "HB", "SL", "BB", + }, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )