feat(#163): TH-Parser produktiv — Thueringer Plenarprotokolle

Fuenfter produktiver Parser nach NRW + BUND + BE + HH.

URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42):
  https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf

Anchor-Sprache (BE-aehnlich):
  Wer dem zustimmt, ... Das sind die Stimmen aus den Fraktionen der
  CDU, BSW, SPD und Die Linke. Wer stimmt gegen ...? Das sind die
  Stimmen aus der Fraktion der AfD. Damit ist [...] mehrheitlich
  angenommen.

Pattern:
- Result-Anchor: Damit ist [Subjekt] (mehrheitlich|einstimmig)?
  (angenommen|abgelehnt)
- Vote-Block: Wer dem zustimmt / Wer stimmt gegen / Wer enthaelt sich
- Drucksachen-Lookup: 'Drucksache 8/N' rueckwaerts

Fraktions-Mapping WP8 (ab Mai 2024): CDU, AfD, BSW, Linke, SPD
(WP7-Faktionen GRUENE/FDP fuer Backfill ebenfalls im Mapping).

Cron-PROTO_TARGETS um TH-WP8 erweitert. Stub-Test angepasst.
This commit is contained in:
Dotty Dotter 2026-04-29 01:11:58 +02:00
parent c71f4bf9f8
commit 399dbc2639
4 changed files with 176 additions and 39 deletions

View File

@ -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,
}

View File

@ -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"(?P<subject>Antrag|Änderungsantrag|Gesetzentwurf|Beschlussempfehlung|"
r"Platzierungswunsch|Tagesordnungspunkt|Entschließungsantrag)"
r"[^.]{0,150}?(?P<modus>einstimmig|mehrheitlich)?\s*"
r"(?P<ergebnis>angenommen|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<ja>[^.?]+?)(?=\.|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<nein>[^.?]+?)(?=\.|Wer enth|Damit)",
re.DOTALL,
)
ENTH_RE = re.compile(
r"Wer enth(?:ä|ae)lt sich[^.?]{0,80}?\??\s*"
r"(?P<enth>[^.?]+?)(?=\.|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

View File

@ -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) ==="

View File

@ -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."
)