diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index 86bb15e..a3f2fd5 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -32,6 +32,7 @@ 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 +from .th import parse_protocol as _parse_th # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -41,6 +42,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "BUND": _parse_bund, "BE": _parse_be, "HH": _parse_hh, + "TH": _parse_th, } diff --git a/app/protokoll_parsers/th.py b/app/protokoll_parsers/th.py index 82f9fb2..b9e3435 100644 --- a/app/protokoll_parsers/th.py +++ b/app/protokoll_parsers/th.py @@ -1,47 +1,181 @@ -"""Thüringen (TH) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Thueringen (TH) — Plenarprotokoll-Parser (#106 / #163, 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 TH solange. +URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42): +``https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf`` -## Recherche +(Arbeitsfassungen werden spaeter durch finale Versionen ersetzt — der +Endgueltige-Pfad ist via Parlamentsdokumentations-Portal zu finden.) -| Feld | Wert | -|---|---| -| **Doku-System** | ParlDok | -| **Base-URL** | https://parldok.thueringer-landtag.de | -| **Familie** | MV-Familie (Synergien hoch) | -| **Format** | PDF nach ID-Discovery | +## Anchor-Sprache (verifiziert WP8 Sitzung 40) -## URL-Discovery +``` +Wer dem zustimmt, den bitte ich um das Handzeichen. Das sind die +Stimmen aus den Fraktionen der CDU, BSW, SPD und Die Linke. Wer +stimmt gegen den Platzierungswunsch? Das sind die Stimmen aus der +Fraktion der AfD. Damit ist der Platzierungswunsch mehrheitlich +angenommen. +``` -ParlDok-Plattform analog MV — sobald MV-Parser steht, ist TH eine Iteration der MV-Logik mit anderem Base-URL-Praefix +Pattern (BE-aehnlich): +- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt)`` +- **Vote-Block:** Q+A im Reden-Stil + - JA: ``Wer dem zustimmt ... Das sind die Stimmen aus [PHRASE]`` + - NEIN: ``Wer stimmt gegen ... Das sind die Stimmen aus [PHRASE]`` + - ENTH: ``Wer enthaelt sich ... [PHRASE]`` (nicht in jedem Vote) +- **Drucksachen-Lookup:** ``Drucksache 8/N`` rueckwaerts vom Anchor -## Bezug +## Fraktions-Mapping WP8 (ab 2024) -- 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/163 (Titel: "protokoll-parser: TH (Thüringen)") +WP8 Konstellation (Mai-2024-Wahl): CDU, AfD, BSW, Die Linke, SPD. +Keine GRUENE oder FDP im aktuellen Landtag. -## 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 +- ``CDU``, ``SPD``, ``BSW``, ``AfD`` +- ``Die Linke`` / ``Linken`` → LINKE +- WP7 hatte zusaetzlich GRUENE und FDP — Mapping bei WP7-Backfill ergaenzen """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "TH-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/th.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) +try: + import fitz +except ImportError: + fitz = None + + +ALLE_FRAKTIONEN_TH = ["CDU", "SPD", "AfD", "LINKE", "BSW"] + +FRAKTIONEN_MAP_TH = [ + ("Die Linke", ["LINKE"]), + ("der Linken", ["LINKE"]), + ("Linke", ["LINKE"]), + ("CDU", ["CDU"]), + ("SPD", ["SPD"]), + ("AfD", ["AfD"]), + ("BSW", ["BSW"]), + ("GRÜNE", ["GRÜNE"]), # WP7-Kompatibilitaet + ("FDP", ["FDP"]), # WP7-Kompatibilitaet +] + + +def _normalize_fraktionen_th(text: str) -> list[str]: + found = set() + remaining = text + for phrase, codes in FRAKTIONEN_MAP_TH: + if phrase in remaining: + for c in codes: + found.add(c) + remaining = remaining.replace(phrase, " ") + return sorted(found) + + +# Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt)" +RESULT_ANCHOR_RE = re.compile( + r"Damit ist\s+(?:der|die|das|dieser?)?\s*" + r"(?PAntrag|Änderungsantrag|Gesetzentwurf|Beschlussempfehlung|" + r"Platzierungswunsch|Tagesordnungspunkt|Entschließungsantrag)" + r"[^.]{0,150}?(?Peinstimmig|mehrheitlich)?\s*" + r"(?Pangenommen|abgelehnt)\.", + re.DOTALL, +) + + +# Vote-Sub-Patterns +JA_RE = re.compile( + r"Wer dem zustimmt[^.]{0,80}?\.\s*" + r"(?:Das sind die Stimmen aus\s+)?(?P[^.?]+?)(?=\.|Wer (?:stimmt|enth))", + re.DOTALL, +) +NEIN_RE = re.compile( + r"Wer stimmt (?:dagegen|gegen)[^.?]{0,80}?\??\s*" + r"(?:Das sind die Stimmen aus\s+)?(?P[^.?]+?)(?=\.|Wer enth|Damit)", + re.DOTALL, +) +ENTH_RE = re.compile( + r"Wer enth(?:ä|ae)lt sich[^.?]{0,80}?\??\s*" + r"(?P[^.?]+?)(?=\.|Damit|Wer)", + re.DOTALL, +) + +DS_RE_TH = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5})") + + +def _parse_vote_block_th(block: str) -> dict: + votes = {"ja": [], "nein": [], "enthaltung": []} + + ja_m = JA_RE.search(block) + if ja_m: + votes["ja"] = _normalize_fraktionen_th(ja_m.group("ja")) + + nein_m = NEIN_RE.search(block) + if nein_m: + votes["nein"] = _normalize_fraktionen_th(nein_m.group("nein")) + + enth_m = ENTH_RE.search(block) + if enth_m: + enth_text = enth_m.group("enth") + if "niemand" in enth_text.lower() or "keine" in enth_text.lower(): + votes["enthaltung"] = [] + else: + votes["enthaltung"] = _normalize_fraktionen_th(enth_text) + + return votes + + +def _resolve_drucksache_th(text: str, anchor_start: int) -> Optional[str]: + window_start = max(0, anchor_start - 1500) + window = text[window_start:anchor_start] + matches = list(DS_RE_TH.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]: + if fitz is None: + raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den TH-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") + ergebnis = m.group("ergebnis") + block_start = max(0, m.start() - 1500) + block = full[block_start:m.end()] + + ds = _resolve_drucksache_th(full, m.start()) + if not ds: + continue + + votes = _parse_vote_block_th(block) + einstimmig = modus == "einstimmig" + + if einstimmig and not votes["ja"]: + votes["ja"] = list(ALLE_FRAKTIONEN_TH) + + 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/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index d5860c4..7d56e3e 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -31,6 +31,7 @@ PROTO_TARGETS=( "BUND|19|BTP19-|https://dserver.bundestag.de/btp/19/19{n3}.xml" "BE|19|PlPr19-|https://www.parlament-berlin.de/ados/19/IIIPlen/protokoll/plen19-{n3}-pp.pdf" "BE|18|PlPr18-|https://www.parlament-berlin.de/ados/18/IIIPlen/protokoll/plen18-{n3}-pp.pdf" + "TH|8|PlPr8-|https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf" ) echo "=== auto-ingest-protocols $(date -Iseconds) ===" diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index 127c292..0d4c3ae 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,9 +20,9 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender STUB_BL_CODES = [ - # BUND/BE/HH raus, weil seit 2026-04-28/29 produktive Parser (#148, #150, #155) + # BUND/BE/HH/TH raus, weil seit 2026-04-28/29 produktive Parser "BB", "BW", "BY", "HB", "HE", - "LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH", + "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 produktiv - assert registered == {"NRW", "BUND", "BE", "HH"}, ( + # Aktuell: NRW + BUND + BE + HH + TH produktiv + assert registered == {"NRW", "BUND", "BE", "HH", "TH"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )