feat(#154): HE-Parser produktiv — Hessen Beschlussprotokoll (Status-Only)

Hessen publiziert nur Beschlussprotokolle (Tagesordnung + Status), KEINE
Wortprotokolle mit Vote-Block. Daher minimaler Parser:
- Drucksache + Status (angenommen/abgelehnt/ueberwiesen)
- Vote-Listen bleiben leer (HE hat keine Fraktions-Detail)

URL-Pattern (verifiziert WP21 Sitzungen 61-63):
http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf

Datum-Teil DD-MM-YYYY → URL-Vorhersage unmoeglich, Auto-Ingest braucht
Index-Scrape via starweb.hessen.de/starweb/LIS/Pd_Eingang.htm (analog HH).

Status-Mapping:
- "angenommen" → ergebnis="angenommen"
- "Abgelehnt" → ergebnis="abgelehnt"
- "Nach (Aussprache|Lesung) an [Ausschuss]" → ergebnis="ueberwiesen"
- "Entgegengenommen", "Abgehalten", "Zur Kenntnis genommen" → uebersprungen

Tests: PROTOKOLL_PARSERS-Set jetzt {NRW, BUND, BE, HH, TH, HE}. STUB_BL_CODES
auf 11 BL reduziert (BB, BW, BY, HB, LSA, MV, NI, RP, SH, SL, SN bleiben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-29 01:19:02 +02:00
parent 399dbc2639
commit 8125dbb731
3 changed files with 122 additions and 39 deletions

View File

@ -33,6 +33,7 @@ from .bund import parse_protocol as _parse_bund
from .be import parse_protocol as _parse_be from .be import parse_protocol as _parse_be
from .hh import parse_protocol as _parse_hh from .hh import parse_protocol as _parse_hh
from .th import parse_protocol as _parse_th 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. # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
ProtokollParser = Callable[[str], list[dict]] ProtokollParser = Callable[[str], list[dict]]
@ -43,6 +44,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
"BE": _parse_be, "BE": _parse_be,
"HH": _parse_hh, "HH": _parse_hh,
"TH": _parse_th, "TH": _parse_th,
"HE": _parse_he,
} }

View File

@ -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 **Limitierung:** Hessen publiziert nur kompakte Beschlussprotokolle
Recherche-Findings vom 2026-04-28, sodass die Implementer-Session (Tagesordnung + Status), KEINE Wortprotokolle mit Vote-Block. Daher
direkt produktiv loslegen kann. Der Stub wird **nicht** in liefert dieser Parser pro Drucksache:
``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert der - Ergebnis-Status (angenommen/abgelehnt/ueberwiesen/...)
Auto-Ingest-Cron ueberspringt HE solange. - 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 | Datum-Teil ``DD-MM-YYYY`` macht direkte URL-Vorhersage unmoeglich. Der
|---|---| Auto-Ingest-Cron muss die Index-Seite scrapen:
| **Doku-System** | portala | ``https://starweb.hessen.de/starweb/LIS/Pd_Eingang.htm`` (analog HH).
| **Base-URL** | https://starweb.hessen.de/portal |
| **Familie** | BB/RP-Familie |
| **Format** | HTML bevorzugt; ggf. PDF als Fallback |
## 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) ## Ergebnis-Mapping
- 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)")
## Aufwand - ``angenommen`` ergebnis="angenommen"
- ``Abgelehnt`` ergebnis="abgelehnt"
Geschaetzt 1-3 Tage konzentrierte Arbeit: - ``Nach (Aussprache|erster Lesung|zweiter Lesung) an [Ausschuss]`` ergebnis="überwiesen"
- 2-4h URL-Discovery + Format-Inspektion (Sample-Protokoll inhaltlich anschauen) - ``Entgegengenommen``, ``Abgehalten`` kind="info" (kein Vote-Beschluss)
- 4-8h Anchor-Phrasen-Reverse-Engineering + Parser-Implementierung
- 4h Tests mit Fixture-Pinning
- 1h Eintrag in PROTOKOLL_PARSERS + auto-ingest-protocols.sh
""" """
from __future__ import annotations from __future__ import annotations
import re
from typing import Optional
def parse_protocol(path: str) -> list[dict]: try:
"""STUB — siehe Modul-Docstring.""" import fitz
raise NotImplementedError( except ImportError:
"HE-Plenarprotokoll-Parser ist noch nicht implementiert. " fitz = None
"Siehe app/protokoll_parsers/he.py-Docstring fuer Recherche-Findings "
"und docs/protokoll-parser-roadmap.md."
) 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<ds>\d{1,2}/\d{2,5})"
r"(?P<between>[^]{0,800}?)"
r"\s*(?P<status>"
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

View File

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