gwoe-antragspruefer/app/protokoll_parsers/he.py
Dotty Dotter 8125dbb731 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>
2026-04-29 01:19:02 +02:00

129 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Hessen (HE) — Beschlussprotokoll-Parser (#106 / #154, ADR 0009).
**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)
URL-Pattern (verifiziert WP21 Sitzungen 61, 62, 63):
``http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf``
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).
## Format-Beispiel (verifiziert WP21 Sitzung 62)
```
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
```
Pattern:
- ``Drucks. {wp}/N`` (mit Punkt nach Drucks)
- Naechstes Status-Wort: ``angenommen``, ``Abgelehnt``, ``Beschlussempfehlung angenommen``
- Vorangehende Lesungs-Phrase: ``In erster/zweiter/dritter Lesung``
## Ergebnis-Mapping
- ``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
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<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