gwoe-antragspruefer/app/protokoll_parsers/hb.py
Dotty Dotter d9ae0b0db8 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>
2026-04-29 01:41:40 +02:00

148 lines
4.7 KiB
Python

"""Bremen (HB) — Beschlussprotokoll-Parser (#106 / #153, ADR 0009).
**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)
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``
WP21 Land-Sitzungen: ``b21l0001.pdf``, ``b21l0002.pdf``, ...
WP21 Stadt-Sitzungen: ``b21s0001.pdf``, ``b21s0002.pdf``, ...
Auto-Ingest-Cron: pure URL-Probing per Sitzungs-Index funktioniert.
## Status-Saetze (verifiziert WP21 Sitzung 33)
```
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)
```
## Status-Mapping
- ``lehnt ... ab`` → ergebnis="abgelehnt"
- ``stimmt ... zu`` → ergebnis="angenommen"
- ``beschließt ...`` → ergebnis="angenommen"
- ``verabschiedet`` → ergebnis="angenommen"
- ``nimmt ... Kenntnis`` → ergebnis="kenntnis" (skip)
## 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
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