feat(#153): HB-Parser produktiv — Bremer Beschlussprotokolle (Status-Only)
Bremen publiziert wie Hessen nur Beschlussprotokolle (TOPs + Status-Saetze), KEINE Wortprotokolle mit Vote-Block. Daher minimaler Parser: - Drucksache + Status (angenommen/abgelehnt/ueberwiesen) - Vote-Listen bleiben leer (HB hat keine Fraktions-Detail) Anchor-Regex: "Die Buergerschaft (Landtag|Stadtbuergerschaft) <verb> <rest> <terminator>" Verb-Mapping: - "lehnt ... ab" → abgelehnt - "stimmt ... zu" → angenommen - "beschliesst ..." → angenommen - "verabschiedet ..." → angenommen - "verweist|ueberweist|leitet" → ueberwiesen - "nimmt ... Kenntnis" → uebersprungen (kein Vote) Drucksachen-Aufloesung: erst Inline-Form "(21/N)", dann Block-Form "Drucksache 21/N" rueckwaerts vom Anchor. URL-Pattern (verifiziert WP21 Sitzung 33 Land): https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf Cron unterstuetzt jetzt {n4}-Platzhalter (4-stellig). HB Land WP21 ingestiert via direktes URL-Probing (b21l0001.pdf … b21l9999.pdf). Stadtbuergerschaft (b21s*) als Folge-Issue. Tests: 21 HB-Tests, Verifikation S33 → 20 Beschluesse extrahiert. Stand: 8 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH, HB). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
329c6e25e5
commit
d9ae0b0db8
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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) <verb> ... <terminator>"
|
||||
ANCHOR_RE = re.compile(
|
||||
r"Die\s+Bürgerschaft\s+\((?:Landtag|Stadtbürgerschaft)\)\s+"
|
||||
r"(?P<verb>lehnt|stimmt|beschließt|nimmt|verabschiedet|verweist|"
|
||||
r"überweist|leitet)\s+"
|
||||
r"(?P<rest>[^.]{0,500}?)"
|
||||
r"(?P<terminator>\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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
107
tests/test_protokoll_parsers_hb.py
Normal file
107
tests/test_protokoll_parsers_hb.py
Normal file
@ -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
|
||||
@ -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."
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user