diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index a3f2fd5..e02a2fb 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -33,6 +33,7 @@ from .bund import parse_protocol as _parse_bund from .be import parse_protocol as _parse_be from .hh import parse_protocol as _parse_hh from .th import parse_protocol as _parse_th +from .he import parse_protocol as _parse_he # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -43,6 +44,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "BE": _parse_be, "HH": _parse_hh, "TH": _parse_th, + "HE": _parse_he, } diff --git a/app/protokoll_parsers/he.py b/app/protokoll_parsers/he.py index 140d7c1..571c011 100644 --- a/app/protokoll_parsers/he.py +++ b/app/protokoll_parsers/he.py @@ -1,47 +1,128 @@ -"""Hessen (HE) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Hessen (HE) — Beschlussprotokoll-Parser (#106 / #154, 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 HE solange. +**Limitierung:** Hessen publiziert nur kompakte Beschlussprotokolle +(Tagesordnung + Status), KEINE Wortprotokolle mit Vote-Block. Daher +liefert dieser Parser pro Drucksache: +- Ergebnis-Status (angenommen/abgelehnt/ueberwiesen/...) +- KEINE Fraktions-Vote-Detail (Vote-Listen bleiben leer) -## Recherche +URL-Pattern (verifiziert WP21 Sitzungen 61, 62, 63): +``http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf`` -| Feld | Wert | -|---|---| -| **Doku-System** | portala | -| **Base-URL** | https://starweb.hessen.de/portal | -| **Familie** | BB/RP-Familie | -| **Format** | HTML bevorzugt; ggf. PDF als Fallback | +Datum-Teil ``DD-MM-YYYY`` macht direkte URL-Vorhersage unmoeglich. Der +Auto-Ingest-Cron muss die Index-Seite scrapen: +``https://starweb.hessen.de/starweb/LIS/Pd_Eingang.htm`` (analog HH). -## URL-Discovery +## Format-Beispiel (verifiziert WP21 Sitzung 62) -Plenum-Protokolle wahrscheinlich als HTML mit semantischen Tags pro Beschluss — wenn HTML zugaenglich, EINFACHER als PDF-Parser +``` +13. Dritte Lesung Gesetzentwurf Landesregierung +Gesetz ueber die Feststellung des Haushaltsplans... +– Drucks. 21/4047 zu Drucks. 21/3503 zu Drucks. 21/2971 – +In dritter Lesung angenommen: Gesetz beschlossen +``` -## Bezug +Pattern: +- ``Drucks. {wp}/N`` (mit Punkt nach Drucks) +- Naechstes Status-Wort: ``angenommen``, ``Abgelehnt``, ``Beschlussempfehlung angenommen`` +- Vorangehende Lesungs-Phrase: ``In erster/zweiter/dritter Lesung`` -- 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/154 (Titel: "protokoll-parser: HE (Hessen)") +## Ergebnis-Mapping -## 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 +- ``angenommen`` → ergebnis="angenommen" +- ``Abgelehnt`` → ergebnis="abgelehnt" +- ``Nach (Aussprache|erster Lesung|zweiter Lesung) an [Ausschuss]`` → ergebnis="überwiesen" +- ``Entgegengenommen``, ``Abgehalten`` → kind="info" (kein Vote-Beschluss) """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "HE-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/he.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) +try: + import fitz +except ImportError: + fitz = None + + +ALLE_FRAKTIONEN_HE = ["CDU", "SPD", "GRÜNE", "AfD", "FDP", "LINKE"] + + +# Anchor-Pattern: Drucks. {wp}/N + Status (mit beliebigem Text dazwischen) +ENTRY_RE = re.compile( + r"Drucks\.\s+(?P\d{1,2}/\d{2,5})" + r"(?P[^–]{0,800}?)" + r"–\s*(?P" + r"(?:In (?:erster|zweiter|dritter) Lesung\s+)?angenommen" + r"|(?:In (?:erster|zweiter|dritter) Lesung\s+)?Abgelehnt" + r"|Beschlussempfehlung angenommen" + r"|Beschlussempfehlung abgelehnt" + r"|Nach (?:Aussprache|erster Lesung|zweiter Lesung|dritter Lesung) an [A-Z]+(?:\s*,\s*[A-Z]+)*" + r"|Entgegengenommen(?:\s+und\s+besprochen)?" + r"|Abgehalten" + r"|Zur Kenntnis genommen" + r")", + re.DOTALL | re.IGNORECASE, +) + + +def _classify_status(status: str) -> str: + """Map HE-Status auf einheitliche ergebnis-Codes.""" + s = status.lower() + if "abgelehnt" in s: + return "abgelehnt" + if "nach" in s and "an " in s: + return "überwiesen" + if "angenommen" in s: + return "angenommen" + if "abgehalten" in s or "entgegengenommen" in s or "zur kenntnis genommen" in s: + return "kenntnis" # eigene Kategorie ohne Vote-Bedeutung + return "unbekannt" + + +def _normalize_text(text: str) -> str: + return re.sub(r"\s+", " ", text) + + +def parse_protocol(pdf_path: str) -> list[dict]: + """Parst HE-Beschlussprotokoll und liefert Status pro Drucksache. + + Vote-Listen bleiben leer (HE publiziert nur Status, kein Vote-Detail). + """ + if fitz is None: + raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den HE-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 ENTRY_RE.finditer(full): + ds = m.group("ds") + status = m.group("status").strip() + ergebnis = _classify_status(status) + if ergebnis in ("kenntnis", "unbekannt"): + # Kein Vote-Beschluss — uebersprungen, sonst muellt es die DB voll + continue + + results.append({ + "drucksache": ds, + "ergebnis": ergebnis, + "einstimmig": False, # Beschlussprotokoll hat keine Vote-Detail + "kind": "direct", + "votes": {"ja": [], "nein": [], "enthaltung": []}, + "anchor_pos": m.start(), + "_he_raw_status": status, # debug-info, nicht persistiert + }) + + # Dedup ueber (drucksache, anchor_pos) + 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 diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index 0d4c3ae..41e3567 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,8 +20,8 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender STUB_BL_CODES = [ - # BUND/BE/HH/TH raus, weil seit 2026-04-28/29 produktive Parser - "BB", "BW", "BY", "HB", "HE", + # BUND/BE/HH/TH/HE raus, weil seit 2026-04-28/29 produktive Parser + "BB", "BW", "BY", "HB", "LSA", "MV", "NI", "RP", "SH", "SL", "SN", ] @@ -76,8 +76,8 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell: NRW + BUND + BE + HH + TH produktiv - assert registered == {"NRW", "BUND", "BE", "HH", "TH"}, ( + # Aktuell: NRW + BUND + BE + HH + TH + HE produktiv + assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )