diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index c38452e..a4d080c 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -35,6 +35,7 @@ from .hh import parse_protocol as _parse_hh from .th import parse_protocol as _parse_th from .he import parse_protocol as _parse_he from .sh import parse_protocol as _parse_sh +from .hb import parse_protocol as _parse_hb # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -47,6 +48,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "TH": _parse_th, "HE": _parse_he, "SH": _parse_sh, + "HB": _parse_hb, } diff --git a/app/protokoll_parsers/hb.py b/app/protokoll_parsers/hb.py index 878ac06..bb084af 100644 --- a/app/protokoll_parsers/hb.py +++ b/app/protokoll_parsers/hb.py @@ -1,47 +1,147 @@ -"""Bremen (HB) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Bremen (HB) — Beschlussprotokoll-Parser (#106 / #153, 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 HB solange. +**Limitierung:** Bremen publiziert wie Hessen nur kompakte +Beschlussprotokolle (TOPs + Status-Saetze), KEINE Wortprotokolle mit +Vote-Block. Daher liefert dieser Parser pro Drucksache: +- Ergebnis-Status (angenommen/abgelehnt/info) +- KEINE Fraktions-Vote-Detail (Vote-Listen bleiben leer) -## Recherche +URL-Pattern (verifiziert WP21 Sitzungen 33+): +``https://www.bremische-buergerschaft.de/dokumente/wp{wp:02}/{land|stadt}/protokoll/b{wp:02}{l|s}{n:04}.pdf`` -| Feld | Wert | -|---|---| -| **Doku-System** | PARiS | -| **Base-URL** | https://paris.bremische-buergerschaft.de | -| **Familie** | StarWeb-Familie | -| **Format** | PDF (oder HTML) | +WP21 Land-Sitzungen: ``b21l0001.pdf``, ``b21l0002.pdf``, ... +WP21 Stadt-Sitzungen: ``b21s0001.pdf``, ``b21s0002.pdf``, ... -## URL-Discovery +Auto-Ingest-Cron: pure URL-Probing per Sitzungs-Index funktioniert. -URL-Pattern unbekannt — PARiS-Skin-Search-API noetig +## Status-Saetze (verifiziert WP21 Sitzung 33) -## Bezug +``` +Die Bürgerschaft (Landtag) lehnt den Antrag ab. +Die Bürgerschaft (Landtag) lehnt den Änderungsantrag (21/1688) ab. +Die Bürgerschaft (Landtag) stimmt dem Änderungsantrag (21/1764) zu. +Die Bürgerschaft (Landtag) beschließt das Gesetz in erster Lesung. +Die Bürgerschaft (Landtag) nimmt von der Mitteilung Kenntnis. ← skip (kein Vote) +``` -- 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/153 (Titel: "protokoll-parser: HB (Bremen)") +## Status-Mapping -## Aufwand +- ``lehnt ... ab`` → ergebnis="abgelehnt" +- ``stimmt ... zu`` → ergebnis="angenommen" +- ``beschließt ...`` → ergebnis="angenommen" +- ``verabschiedet`` → ergebnis="angenommen" +- ``nimmt ... Kenntnis`` → ergebnis="kenntnis" (skip) -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 +## Drucksachen-Lookup + +- Inline-Form: ``(21/1234)`` direkt im Status-Satz +- Block-Form: ``(Drucksache 21/N)`` rueckwaerts vom Anchor (TOP-Block) """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "HB-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/hb.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) +try: + import fitz +except ImportError: + fitz = None + + +ALLE_FRAKTIONEN_HB = ["SPD", "GRÜNE", "CDU", "DIE LINKE", "FDP", "BIW"] + + +# Anchor: "Die Bürgerschaft (Landtag|Stadtbürgerschaft) ... " +ANCHOR_RE = re.compile( + r"Die\s+Bürgerschaft\s+\((?:Landtag|Stadtbürgerschaft)\)\s+" + r"(?Plehnt|stimmt|beschließt|nimmt|verabschiedet|verweist|" + r"überweist|leitet)\s+" + r"(?P[^.]{0,500}?)" + r"(?P\sab\.|\szu\.|\sKenntnis\.|\.)", + re.DOTALL, +) + +DS_INLINE_RE = re.compile(r"\((\d{1,2}/\d{2,5})\)") +DS_BLOCK_RE = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5})") + + +def _classify_status(verb: str, rest: str, terminator: str) -> str: + """Map HB-Beschluss-Vokabular auf einheitliche ergebnis-Codes.""" + verb_l = verb.lower() + rest_l = rest.lower() + term_l = terminator.lower().strip() + if "kenntnis" in rest_l or "kenntnis" in term_l: + return "kenntnis" + if verb_l == "lehnt" and "ab" in term_l: + return "abgelehnt" + if verb_l == "stimmt" and "zu" in term_l: + return "angenommen" + if verb_l in ("beschließt", "verabschiedet"): + return "angenommen" + if verb_l in ("verweist", "überweist", "leitet"): + return "überwiesen" + return "unbekannt" + + +def _resolve_drucksache_hb(text: str, anchor_start: int, rest: str) -> Optional[str]: + """Drucksachen-Aufloesung: erst Inline-Form aus rest, dann Block-Form.""" + inline = DS_INLINE_RE.search(rest) + if inline: + return inline.group(1) + window_start = max(0, anchor_start - 1500) + window = text[window_start:anchor_start] + matches = list(DS_BLOCK_RE.finditer(window)) + if matches: + return 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]: + """Parst HB-Beschlussprotokoll, liefert Status pro Drucksache. + + Vote-Listen bleiben leer (HB publiziert nur Status, keine Fraktions-Detail). + """ + if fitz is None: + raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den HB-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 ANCHOR_RE.finditer(full): + verb = m.group("verb") + rest = m.group("rest") + terminator = m.group("terminator") + ergebnis = _classify_status(verb, rest, terminator) + if ergebnis in ("kenntnis", "unbekannt"): + continue + + ds = _resolve_drucksache_hb(full, m.start(), rest) + if not ds: + continue + + results.append({ + "drucksache": ds, + "ergebnis": ergebnis, + "einstimmig": False, # Beschlussprotokoll → keine Vote-Detail + "kind": "direct", + "votes": {"ja": [], "nein": [], "enthaltung": []}, + "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 43b4b66..5617dbc 100644 --- a/docs/protokoll-parser-roadmap.md +++ b/docs/protokoll-parser-roadmap.md @@ -22,7 +22,7 @@ Body und der Eintrag wird in `PROTOKOLL_PARSERS` ergaenzt. | BB | `bb.py` | [#149](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/149) | 📋 Stub | | 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) | 📋 Stub | +| **HB** | `hb.py` | [#153](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/153) | ✅ produktiv (Status-Only, Beschlussprotokoll WP21, URL-Pattern direkt) | | **HE** | `he.py` | [#154](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/154) | ✅ produktiv (Status-Only, Beschlussprotokoll WP21, Cron via Index-Scrape) | | LSA | `lsa.py` | [#156](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/156) | 📋 Stub | | MV | `mv.py` | [#157](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/157) | 📋 Stub | diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index 658822f..5a32ae2 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -24,6 +24,7 @@ GAP_TOLERANCE=3 # 3 aufeinanderfolgende 404 → fertig fuer dieses BL # URL-Pattern unterstuetzt zwei Platzhalter: # {n} — Sitzungs-Nr unkpaddet (z.B. NRW: MMP18-1.pdf) # {n3} — Sitzungs-Nr 3-stellig zero-gepadded (z.B. BUND: 20001.xml) +# {n4} — Sitzungs-Nr 4-stellig zero-gepadded (z.B. HB: b21l0033.pdf) 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" @@ -32,6 +33,7 @@ PROTO_TARGETS=( "BE|19|PlPr19-|https://www.parlament-berlin.de/ados/19/IIIPlen/protokoll/plen19-{n3}-pp.pdf" "BE|18|PlPr18-|https://www.parlament-berlin.de/ados/18/IIIPlen/protokoll/plen18-{n3}-pp.pdf" "TH|8|PlPr8-|https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf" + "HB|21|HB21l-|https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf" ) echo "=== auto-ingest-protocols $(date -Iseconds) ===" @@ -239,7 +241,9 @@ print(c.fetchone()[0]) consecutive_404=0 for n in $(seq $start_n $((last_n + 50))); do n3=$(printf "%03d" "$n") + n4=$(printf "%04d" "$n") url="${pattern//\{n3\}/$n3}" + url="${url//\{n4\}/$n4}" url="${url//\{n\}/$n}" http=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 15 "$url" || echo "000") if [ "$http" = "200" ]; then diff --git a/tests/test_protokoll_parsers_hb.py b/tests/test_protokoll_parsers_hb.py new file mode 100644 index 0000000..3415ab3 --- /dev/null +++ b/tests/test_protokoll_parsers_hb.py @@ -0,0 +1,107 @@ +"""Tests fuer app/protokoll_parsers/hb.py — HB Beschlussprotokoll-Parser (#153). + +HB ist (wie HE) ein Beschlussprotokoll-Parser: Status-only, keine Vote-Detail. +Stichprobe-getestet gegen WP21 Sitzung 33 (Bremen Landtag). +""" +from __future__ import annotations + +import pytest + +from app.protokoll_parsers.hb import ( + _classify_status, + _normalize_text, + _resolve_drucksache_hb, + ANCHOR_RE, + DS_INLINE_RE, + DS_BLOCK_RE, + ALLE_FRAKTIONEN_HB, +) + + +class TestClassifyStatus: + def test_lehnt_ab_to_abgelehnt(self): + assert _classify_status("lehnt", "den Antrag", " ab.") == "abgelehnt" + + def test_stimmt_zu_to_angenommen(self): + assert _classify_status("stimmt", "dem Antrag", " zu.") == "angenommen" + + def test_beschliesst_to_angenommen(self): + assert _classify_status("beschließt", "das Gesetz", ".") == "angenommen" + + def test_verabschiedet_to_angenommen(self): + assert _classify_status("verabschiedet", "den Haushalt", ".") == "angenommen" + + def test_nimmt_kenntnis_skipped(self): + assert _classify_status( + "nimmt", "von der Mitteilung Kenntnis", "." + ) == "kenntnis" + + def test_ueberweist_to_ueberwiesen(self): + assert _classify_status("überweist", "den Antrag", ".") == "überwiesen" + + +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("Bürger- schaft") == "Bürgerschaft" + + +class TestAnchorRegex: + def test_matches_landtag_lehnt(self): + text = "Die Bürgerschaft (Landtag) lehnt den Antrag ab." + m = ANCHOR_RE.search(text) + assert m and m.group("verb") == "lehnt" + + def test_matches_stadtbuergerschaft_stimmt(self): + text = "Die Bürgerschaft (Stadtbürgerschaft) stimmt dem Antrag zu." + m = ANCHOR_RE.search(text) + assert m and m.group("verb") == "stimmt" + + def test_matches_beschliesst_gesetz(self): + text = "Die Bürgerschaft (Landtag) beschließt das Gesetz." + m = ANCHOR_RE.search(text) + assert m and m.group("verb") == "beschließt" + + def test_matches_nimmt_kenntnis(self): + text = "Die Bürgerschaft (Landtag) nimmt von der Antwort Kenntnis." + m = ANCHOR_RE.search(text) + assert m and m.group("verb") == "nimmt" + + +class TestDrucksacheRegex: + def test_inline_form_with_parens(self): + m = DS_INLINE_RE.search("lehnt den Änderungsantrag (21/1688) ab") + assert m and m.group(1) == "21/1688" + + def test_block_form_with_drucksache_label(self): + m = DS_BLOCK_RE.search("Antrag der Fraktion (Drucksache 21/1234)") + assert m and m.group(1) == "21/1234" + + +class TestResolveDrucksacheHb: + def test_inline_takes_priority(self): + text = "Drucksache 21/1000 ... Die Bürgerschaft lehnt (21/2000) ab." + rest = "den Antrag (21/2000)" + anchor = text.index("Die Bürgerschaft") + assert _resolve_drucksache_hb(text, anchor, rest) == "21/2000" + + def test_block_form_used_when_no_inline(self): + text = "Drucksache 21/1500. Die Bürgerschaft lehnt den Antrag ab." + rest = "den Antrag" + anchor = text.index("Die Bürgerschaft") + assert _resolve_drucksache_hb(text, anchor, rest) == "21/1500" + + def test_returns_none_when_no_drucksache(self): + assert _resolve_drucksache_hb( + "Die Bürgerschaft lehnt den Antrag ab.", 0, "den Antrag" + ) is None + + +class TestConstants: + def test_all_fraktionen_set(self): + # WP21-HB Konstellation: SPD-GRÜNE-LINKE Koalition + CDU/FDP Opposition + BIW + assert "SPD" in ALLE_FRAKTIONEN_HB + assert "GRÜNE" in ALLE_FRAKTIONEN_HB + assert "CDU" in ALLE_FRAKTIONEN_HB diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index b7b2134..ee7e485 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 raus, weil seit 2026-04-28/29 produktive Parser - "BB", "BW", "BY", "HB", + # BUND/BE/HH/TH/HE/SH/HB raus, weil seit 2026-04-28/29 produktive Parser + "BB", "BW", "BY", "LSA", "MV", "NI", "RP", "SL", "SN", ] @@ -76,8 +76,8 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell: NRW + BUND + BE + HH + TH + HE + SH produktiv - assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH"}, ( + # Aktuell: NRW + BUND + BE + HH + TH + HE + SH + HB produktiv + assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH", "HB"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )