diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index e3decc4..86bb15e 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -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, } diff --git a/app/protokoll_parsers/hh.py b/app/protokoll_parsers/hh.py index 310b8a9..42b55a1 100644 --- a/app/protokoll_parsers/hh.py +++ b/app/protokoll_parsers/hh.py @@ -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"(?Peinstimmig|mehrheitlich)" + r"(?P(?:\s+mit den Stimmen[^.]{0,400})?)" + r"\s+(?Pangenommen|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 diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index 6dddc4e..127c292 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -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." )