gwoe-antragspruefer/app/protokoll_parsers/hb.py

148 lines
4.7 KiB
Python
Raw Normal View History

"""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