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:
parent
c7d6ac7f5f
commit
5f97ae9fc3
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user