feat(#155): HH-Parser produktiv — Hamburg Beschlussprotokolle

Vierter produktiver Plenarprotokoll-Parser nach NRW + BUND + BE.

Hamburg publiziert kompakte Beschlussprotokolle (Tabellen-Form mit
Vote-Block pro Beschluss):
  ... mehrheitlich mit den Stimmen der SPD und GRUENEN gegen die
  Stimmen der CDU und AfD bei Enthaltung der Linken angenommen

Pattern:
- einstimmig (angenommen|abgelehnt) — alle Fraktionen
- mehrheitlich mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z
  (angenommen|abgelehnt)

Fraktions-Mapping WP23: SPD, GRUENE, CDU, AfD, Linke

URL-Discovery laeuft ueber die Protokoll-Liste der Buergerschaft
(Blob-IDs via Index-Page-Scrape). Cron-Eintrag erst sobald
URL-Discovery-Skript hier integriert ist.

Stub-Test angepasst (HH raus aus STUB_BL_CODES).
This commit is contained in:
Dotty Dotter 2026-04-29 00:57:58 +02:00
parent c7d6ac7f5f
commit 5f97ae9fc3
3 changed files with 153 additions and 40 deletions

View File

@ -31,6 +31,7 @@ from typing import Callable
from .nrw import parse_protocol as _parse_nrw
from .bund import parse_protocol as _parse_bund
from .be import parse_protocol as _parse_be
from .hh import parse_protocol as _parse_hh
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
ProtokollParser = Callable[[str], list[dict]]
@ -39,6 +40,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
"NRW": _parse_nrw,
"BUND": _parse_bund,
"BE": _parse_be,
"HH": _parse_hh,
}

View File

@ -1,47 +1,159 @@
"""Hamburg (HH) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009).
"""Hamburg (HH) — Plenarprotokoll-Parser (#106 / #155, 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 HH solange.
Hamburg publiziert kompakte **Beschlussprotokolle** (Tabellen-Form mit
Vote-Block pro Beschluss). PDF-URL-Discovery laeuft ueber die Index-Seite
``hamburgische-buergerschaft.de/recherche-info/protokolle`` (Blob-IDs
nicht direkt vorhersagbar).
## Recherche
## Anchor-Sprache (verifiziert WP23 Sitzung 22)
| Feld | Wert |
|---|---|
| **Doku-System** | ParlDok |
| **Base-URL** | https://www.buergerschaft-hh.de/parldok |
| **Familie** | MV/TH-Familie |
| **Format** | PDF via ParlDok-Search |
```
... Antrag der GRUENEN und SPD-Fraktion mehrheitlich mit den Stimmen
der SPD und GRUENEN gegen die Stimmen der CDU und AfD bei Enthaltung
der Linken angenommen
```
## URL-Discovery
Pattern:
- ``einstimmig (angenommen|abgelehnt)`` alle Fraktionen ja/nein
- ``mehrheitlich mit den Stimmen X gegen die Stimmen Y (bei Enthaltung Z)? (angenommen|abgelehnt)``
ParlDok 5.x oder 8.x Live-Format vor Implementierung verifizieren (curl -s buergerschaft-hh.de/parldok/ | grep ParlDok)
## Fraktions-Mapping WP23
## Bezug
- ``GRUENE``, ``SPD``, ``CDU``, ``AfD`` (rot-gruener Senat)
- ``Linke`` / ``Linken`` LINKE
- ``Abg. {Name}`` einzelne Abgeordnete (ignorieren)
- 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/155 (Titel: "protokoll-parser: HH (Hamburg)")
## Drucksachen-Format
## Aufwand
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
``Drucksache 23/N`` oder bare ``23/N``. Drucksachen aus laufender WP
und Vor-WP gemischt im Text. Lookup nimmt die naechste DS rueckwaerts
vom Anchor.
"""
from __future__ import annotations
import re
from typing import Optional
def parse_protocol(path: str) -> list[dict]:
"""STUB — siehe Modul-Docstring."""
raise NotImplementedError(
"HH-Plenarprotokoll-Parser ist noch nicht implementiert. "
"Siehe app/protokoll_parsers/hh.py-Docstring fuer Recherche-Findings "
"und docs/protokoll-parser-roadmap.md."
try:
import fitz
except ImportError:
fitz = None
ALLE_FRAKTIONEN_HH = ["SPD", "GRÜNE", "CDU", "AfD", "LINKE"]
FRAKTIONEN_MAP_HH = [
("GRÜNEN", ["GRÜNE"]),
("GRÜNE", ["GRÜNE"]),
("SPD", ["SPD"]),
("CDU", ["CDU"]),
("AfD", ["AfD"]),
("Linken", ["LINKE"]),
("Linke", ["LINKE"]),
]
def _normalize_fraktionen_hh(text: str) -> list[str]:
found = set()
remaining = text
for phrase, codes in FRAKTIONEN_MAP_HH:
if phrase in remaining:
for c in codes:
found.add(c)
remaining = remaining.replace(phrase, " ")
return sorted(found)
# Result-Anchor: einstimmig oder mehrheitlich + (Vote-Block) + (angenommen|abgelehnt)
RESULT_ANCHOR_RE = re.compile(
r"(?P<modus>einstimmig|mehrheitlich)"
r"(?P<vote_block>(?:\s+mit den Stimmen[^.]{0,400})?)"
r"\s+(?P<ergebnis>angenommen|abgelehnt)",
re.DOTALL,
)
DS_RE_HH = re.compile(r"(?:Drucksache\s+)?(\d{2}/\d{3,5})")
def _parse_vote_block_hh(vote_block: str) -> dict:
"""Parst HH-Vote-Block 'mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z'."""
votes = {"ja": [], "nein": [], "enthaltung": []}
if not vote_block.strip():
return votes
nein_idx = vote_block.find("gegen die Stimmen")
enth_idx = vote_block.find("bei Enthaltung")
end_ja = min(idx for idx in (nein_idx, enth_idx, len(vote_block)) if idx >= 0)
ja_text = vote_block[:end_ja]
votes["ja"] = _normalize_fraktionen_hh(ja_text)
if nein_idx >= 0:
end_nein = enth_idx if enth_idx > nein_idx else len(vote_block)
nein_text = vote_block[nein_idx + len("gegen die Stimmen"):end_nein]
votes["nein"] = _normalize_fraktionen_hh(nein_text)
if enth_idx >= 0:
enth_text = vote_block[enth_idx + len("bei Enthaltung"):]
votes["enthaltung"] = _normalize_fraktionen_hh(enth_text)
return votes
def _resolve_drucksache_hh(text: str, anchor_start: int) -> Optional[str]:
"""Rueckwaerts vom Anchor naechste Drucksache."""
window_start = max(0, anchor_start - 800)
window = text[window_start:anchor_start]
matches = list(DS_RE_HH.finditer(window))
if matches:
return matches[-1].group(1)
return None
def _normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", text)
def parse_protocol(pdf_path: str) -> list[dict]:
"""Parst ein Hamburger Beschlussprotokoll-PDF."""
if fitz is None:
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den HH-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")
vote_block = m.group("vote_block") or ""
ergebnis = m.group("ergebnis")
ds = _resolve_drucksache_hh(full, m.start())
if not ds:
continue
votes = _parse_vote_block_hh(vote_block)
einstimmig = modus == "einstimmig"
if einstimmig and not votes["ja"]:
votes["ja"] = list(ALLE_FRAKTIONEN_HH)
results.append({
"drucksache": ds,
"ergebnis": ergebnis,
"einstimmig": einstimmig,
"kind": "direct",
"votes": votes,
"anchor_pos": m.start(),
})
seen = set()
deduped = []
for r in results:
if r["anchor_pos"] in seen:
continue
seen.add(r["anchor_pos"])
deduped.append(r)
return deduped

View File

@ -20,9 +20,8 @@ import pytest
from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender
STUB_BL_CODES = [
# BUND raus, weil seit 2026-04-28 produktiver Parser (#148)
# BE raus, weil seit 2026-04-29 produktiver Parser (#150)
"BB", "BW", "BY", "HB", "HE", "HH",
# BUND/BE/HH raus, weil seit 2026-04-28/29 produktive Parser (#148, #150, #155)
"BB", "BW", "BY", "HB", "HE",
"LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH",
]
@ -77,8 +76,8 @@ class TestRegistryDiscipline:
def test_stubs_not_in_registry(self):
registered = set(supported_bundeslaender())
# Aktuell: NRW + BUND + BE produktiv
assert registered == {"NRW", "BUND", "BE"}, (
# Aktuell: NRW + BUND + BE + HH produktiv
assert registered == {"NRW", "BUND", "BE", "HH"}, (
"Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, "
"diesen Test anpassen UND den Stub durch echten Parser ersetzen."
)