feat(#148): BUND-Parser produktiv — Bundestags-XML-Plenarprotokolle

Erster vollwertiger Plenarprotokoll-Parser nach NRW. Quelle:
https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml

Anchor-Sprache (verifiziert WP20 Sitzungen 30 + 100):
  'Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen
   gegen die Stimmen der CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion
   angenommen.'

Pattern:
- Subjekt: Beschlussempfehlung | Ueberweisungsvorschlag | Antrag | Gesetzentwurf
- Vote-Block: 'mit den Stimmen X / gegen die Stimmen Y / bei Enthaltung Z'
- Ergebnis: 'angenommen' oder 'abgelehnt'
- Drucksache rueckwaerts vom Anchor (1500 chars Window)
- Kind 'ueberweisung' invertiert ergebnis zu 'ueberwiesen'

Fraktions-Mapping (WP20 = Ampel):
- 'Koalitionsfraktionen' → SPD + GRÜNE + FDP
- 'Oppositionsfraktionen' → CDU/CSU + AfD + LINKE
- 'CDU/CSU-Fraktion', 'Fraktion Bündnis 90/Die Grünen', etc.

WP21 (ab 2025) braucht eigenes Mapping-Update.

26 Tests in test_protokoll_parsers_bund.py (Vote-Block-Parsing, Anchor-
Erkennung, Drucksachen-Lookup, End-to-End mit Mock-XML).

Cron + Ingest-CLI:
- PROTO_TARGETS-Format erweitert um PROTOKOLL_ID_PREFIX und {n3}-
  Placeholder fuer 3-stellig zero-gepaddetes BT-Schema (BTP20-N)
- ingest_votes URL-Suffix dynamisch (PDF vs XML) statt hardcoded .pdf
- Eintrag in PROTOKOLL_PARSERS (NRW + BUND)
- Stub-Test angepasst: BUND raus aus STUB_BL_CODES

889 Tests gruen (787 → 889, +102 fuer Phase-2 Stubs+Tests+BUND).
This commit is contained in:
Dotty Dotter 2026-04-28 23:21:39 +02:00
parent 7cfbd9f210
commit 22a2b63c35
6 changed files with 445 additions and 100 deletions

View File

@ -129,8 +129,13 @@ def _cli() -> None:
parser.error("--pdf oder --url ist erforderlich") parser.error("--pdf oder --url ist erforderlich")
if args.url: if args.url:
# Download in tmp und nach dem Run wieder loeschen # Tmpfile-Suffix aus der URL ableiten (PDF, XML, ...) — der BUND-Parser
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: # nutzt XML, der NRW-Parser nutzt PDF. Suffix beeinflusst nur den
# Dateinamen; Parser lesen Inhalt nach Format.
url_suffix = "." + args.url.rsplit(".", 1)[-1].split("?")[0]
if url_suffix not in (".pdf", ".xml", ".html"):
url_suffix = ".pdf"
with tempfile.NamedTemporaryFile(suffix=url_suffix, delete=False) as tmp:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
print(f"Lade {args.url}{tmp_path}") print(f"Lade {args.url}{tmp_path}")

View File

@ -29,12 +29,14 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from .nrw import parse_protocol as _parse_nrw from .nrw import parse_protocol as _parse_nrw
from .bund import parse_protocol as _parse_bund
# 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]]
PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
"NRW": _parse_nrw, "NRW": _parse_nrw,
"BUND": _parse_bund,
} }

View File

@ -1,104 +1,202 @@
"""Bundestag (BUND) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). """Bundestag (BUND) — Plenarprotokoll-Parser (#106 / #148, ADR 0009).
**Status: noch nicht implementiert.** Dieser Modul-Stub enthaelt XML-basierter Parser für Bundestags-Plenarprotokolle. Quelle:
Recherche-Findings, sodass die Implementer-Session direkt produktiv ``https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml`` (auch .pdf
loslegen kann. Der Stub wird **nicht** in verfuegbar; XML ist strukturierter, daher bevorzugt).
``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert der
Auto-Ingest-Cron ueberspringt BUND solange.
## Recherche 2026-04-28 ## Anchor-Sprache (verifiziert WP20 Sitzungen 30, 100)
### URL-Pattern Bundestag formuliert Beschluesse mit:
Plenum-Protokoll als XML (strukturiert):
```
https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml
```
Beispiel WP20 Sitzung 184: ``https://dserver.bundestag.de/btp/20/20184.xml``
Plenum-Protokoll als PDF (rendert dasselbe):
```
https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.pdf
```
### XML-Format
Top-Level: ``<dbtplenarprotokoll>`` mit Children:
- ``<vorspann>`` Sitzungsmetadaten
- ``<sitzungsverlauf>`` Reden + Tagesordnungspunkte
- ``<anlagen>`` Beschluss-Anlagen, namentliche Abstimmungen
- ``<rednerliste>``
Tags: ``<rede>``, ``<tagesordnungspunkt>``, ``<kommentar>`` (Regie-
Anweisungen wie "(Beifall bei der CDU/CSU)"), ``<redner>`` mit
``<fraktion>``-Untertag.
**Kein ``<abstimmung>``-Tag.** Vote-Daten muessen aus Reden + Kommentaren
extrahiert werden gleiche Architektur wie NRW (Anchor-basiert), aber
mit ANDEREN Anchor-Phrasen.
### Vote-Anchor-Phrasen (vom NRW-Pattern abweichend!)
**Verifiziert in WP20 Sitzung 30** (572k Zeichen XML, 5 angenommen-Anchors):
``` ```
Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen
und der Fraktion Die Linke gegen die Stimmen der CDU/CSU-Fraktion bei und der Fraktion Die Linke gegen die Stimmen der CDU/CSU-Fraktion
Enthaltung der AfD-Fraktion angenommen. bei Enthaltung der AfD-Fraktion angenommen.
``` ```
Pattern-Erkennung: Pattern:
- Anchor-Verb: ``angenommen`` oder ``abgelehnt`` am Satzende - Subjekt: "Die Beschlussempfehlung", "Der Überweisungsvorschlag",
- Vote-Block: ``mit den Stimmen [...] gegen die Stimmen [...] bei "Der Antrag", "Der Gesetzentwurf"
Enthaltung [...]`` - Vote-Block: "mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z"
- Fraktions-Phrasen: ``Fraktion X``, ``X-Fraktion``, ``Koalitionsfraktionen`` - Anchor-Verb: "angenommen" oder "abgelehnt"
- Drucksachen muessen **rueckwaerts** vom Anchor gesucht werden
(oft mehrere 100 Zeichen vorher)
**Wichtig:** BT-Anchor-Sprache ist viel laenger als NRW ## Fraktions-Mapping
(``Damit ist X angenommen``, 5-30 Zeichen) bei BT zwischen Stimm-
Block und ``angenommen`` koennen 200+ Zeichen liegen. Regex-Begrenzung
muss entsprechend grosszuegig sein.
WP20 Sitzung 184 = pure Aussprache, KEINE Beschluss-Anchors. Sample Koalitions-/Oppositions-Bezeichnungen aendern sich pro Wahlperiode.
fuer Tests: WP20-Sitzungen 30, 100, 150 (alle mit Beschluessen). Aktuell hardcoded fuer **WP20** (2021-2025, Ampel):
### Strukturierte Alternative — namentliche Abstimmungen - "Koalitionsfraktionen" SPD + GRÜNE + FDP
- "Oppositionsfraktionen" CDU/CSU + AfD + LINKE
Bundestag publiziert namentliche Abstimmungen separat als Excel/XML WP21 (ab 2025) wuerde anderes Mapping brauchen. Folge-Issue notwendig.
unter ``bundestag.de/parlament/plenum/abstimmung/abstimmung``. Pro
Abstimmung MP-level Vote-Records. Fraktions-Aggregate sind dort
extrahierbar OHNE PDF-Parsing.
**Empfehlung fuer Implementer:** statt PDF/XML-Parser bauen, lieber ## Drucksachen-Aufloesung
``app/abgeordnetenwatch.py`` (existiert) auf Fraktions-Aggregat-Form
runterrechnen das deckt namentliche Abstimmungen sauber ab. Nur fuer
Hammelsprung-und-Handzeichen-Abstimmungen (nicht-namentlich) muss man
das XML-Plenum parsen.
### Sample-Daten fuer Tests Vor dem Anchor wird rueckwaerts nach "Drucksache 20/N" oder
"auf Drucksache 20/N" gesucht. Der naechste Match in einem 1500-Zeichen-
- WP20 Sitzung 30: "{wp}{n:03}" btp/20/20030.xml enthaelt diverse Fenster gewinnt.
Gesetzentwurf-Beschluesse
- Sitzungen mit "namentliche Abstimmung" laut Tagesordnung sind
Goldstandard fuer Tests
### Aufwand
Geschaetzt 1-2 Tage:
- 4h Reverse-Engineering der Anchor-Sprache (mehrere Sample-Sitzungen)
- 4h Parser-Implementierung
- 4h Tests (Fixture-Pinning analog NRW 19/19-Garantie)
Folge-Issue: https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/148
""" """
from __future__ import annotations from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from typing import Optional
def parse_protocol(pdf_or_xml_path: str) -> list[dict]:
"""STUB — siehe Modul-Docstring.""" # WP20 (2021-2025) Koalition: SPD + GRÜNE + FDP. Opposition: CDU/CSU + AfD + LINKE.
raise NotImplementedError( # WP21 Implementierung erfordert separates Mapping pro WP — folgt sobald gebraucht.
"BUND-Plenarprotokoll-Parser ist noch nicht implementiert. " WP20_KOALITIONSFRAKTIONEN = ["SPD", "GRÜNE", "FDP"]
"Siehe app/protokoll_parsers/bund.py-Docstring fuer Recherche-Findings " WP20_OPPOSITIONSFRAKTIONEN = ["CDU/CSU", "AfD", "LINKE"]
"und docs/protokoll-parser-roadmap.md."
# Phrase → kanonische Fraktions-Codes. Reihenfolge: längere Aliasse zuerst.
FRAKTIONEN_MAP_BT = [
("Koalitionsfraktionen", WP20_KOALITIONSFRAKTIONEN),
("Koalitionsfraktion", WP20_KOALITIONSFRAKTIONEN),
("Oppositionsfraktionen", WP20_OPPOSITIONSFRAKTIONEN),
("Oppositionsfraktion", WP20_OPPOSITIONSFRAKTIONEN),
("Fraktion Bündnis 90/Die Grünen", ["GRÜNE"]),
("Bündnis 90/Die Grünen", ["GRÜNE"]),
("Fraktion Die Linke", ["LINKE"]),
("Die Linke", ["LINKE"]),
("CDU/CSU-Fraktion", ["CDU/CSU"]),
("Fraktion der CDU/CSU", ["CDU/CSU"]),
("CDU/CSU", ["CDU/CSU"]),
("SPD-Fraktion", ["SPD"]),
("Fraktion der SPD", ["SPD"]),
("SPD", ["SPD"]),
("FDP-Fraktion", ["FDP"]),
("Fraktion der FDP", ["FDP"]),
("FDP", ["FDP"]),
("AfD-Fraktion", ["AfD"]),
("Fraktion der AfD", ["AfD"]),
("AfD", ["AfD"]),
]
ALL_BT_FRAKTIONEN = ["CDU/CSU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE"]
def _normalize_fraktionen_bt(text: str) -> list[str]:
"""Extrahiere BT-Fraktions-Codes aus einer Phrase."""
found = set()
remaining = text
for phrase, codes in FRAKTIONEN_MAP_BT:
if phrase in remaining:
for c in codes:
found.add(c)
remaining = remaining.replace(phrase, " ")
return sorted(found)
# Result-Anchor: Subjekt + "ist mit den Stimmen [...] (angenommen|abgelehnt)"
# Großzügige 500-char-Begrenzung weil BT-Vote-Blocks lang werden koennen.
RESULT_ANCHOR_RE = re.compile(
r"(?P<subject>Die Beschlussempfehlung|Der Überweisungsvorschlag|Der Antrag"
r"|Der Gesetzentwurf|Diese Beschlussempfehlung)"
r"\s+ist\s+mit den Stimmen(?P<votes>[^.]{20,500}?)"
r"\s+(?P<ergebnis>angenommen|abgelehnt)\s*\.",
re.DOTALL,
)
def _parse_vote_block_bt(votes_text: str) -> dict:
"""Parst BT-Vote-Phrase: 'X gegen die Stimmen Y bei Enthaltung Z'."""
result = {"ja": [], "nein": [], "enthaltung": []}
# Aufsplit-Marker
nein_idx = votes_text.find("gegen die Stimmen")
enth_idx = votes_text.find("bei Enthaltung")
# Boundaries
end_ja = min(idx for idx in (nein_idx, enth_idx, len(votes_text)) if idx >= 0)
ja_text = votes_text[:end_ja]
result["ja"] = _normalize_fraktionen_bt(ja_text)
if nein_idx >= 0:
end_nein = enth_idx if enth_idx > nein_idx else len(votes_text)
nein_text = votes_text[nein_idx + len("gegen die Stimmen"):end_nein]
result["nein"] = _normalize_fraktionen_bt(nein_text)
if enth_idx >= 0:
enth_text = votes_text[enth_idx + len("bei Enthaltung"):]
result["enthaltung"] = _normalize_fraktionen_bt(enth_text)
return result
# Drucksache-Pattern fuer rueckwaerts-Lookup: "Drucksache 20/123" oder
# "auf Drucksache 20/123(neu)" — nehmen die letzten 1500 Zeichen vor dem
# Anchor.
DS_RE_BT = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5}(?:\(neu\))?)")
def _resolve_drucksache_bt(text: str, anchor_start: int) -> Optional[str]:
"""Rueckwaerts vom Anchor die letzte erwaehnte Drucksache finden."""
window_start = max(0, anchor_start - 1500)
window = text[window_start:anchor_start]
matches = list(DS_RE_BT.finditer(window))
if matches:
return matches[-1].group(1)
return None
def _extract_full_text(xml_path: str) -> str:
"""Extrahiere den Volltext aus einem BT-Plenarprotokoll-XML."""
tree = ET.parse(xml_path)
text = ET.tostring(tree.getroot(), encoding="unicode", method="text")
# Whitespace normalisieren: alles auf Single-Space, wie im NRW-Parser
text = re.sub(r"\s+", " ", text)
return text
def parse_protocol(xml_path: str) -> list[dict]:
"""Parst ein Bundestags-Plenarprotokoll-XML und liefert Vote-Records."""
text = _extract_full_text(xml_path)
results = []
for m in RESULT_ANCHOR_RE.finditer(text):
subject = m.group("subject")
ergebnis = m.group("ergebnis") # angenommen | abgelehnt
votes_text = m.group("votes")
ds = _resolve_drucksache_bt(text, m.start())
if not ds:
continue
votes = _parse_vote_block_bt(votes_text)
# einstimmig-Heuristik: alle 6 BT-Fraktionen in ja, nichts in nein/enth
einstimmig = (
len(votes["ja"]) >= 5 # mind. 5 von 6 → praktisch einstimmig
and not votes["nein"]
and not votes["enthaltung"]
) )
# Subjekt → kind-Klassifikation
if "Überweisungsvorschlag" in subject:
kind = "ueberweisung"
# Ueberweisungen sind typischerweise faktisch ergebnis="ueberwiesen"
ergebnis = "überwiesen" if ergebnis == "angenommen" else ergebnis
elif "Gesetzentwurf" in subject:
kind = "gesetzentwurf"
else:
kind = "direct"
results.append({
"drucksache": ds,
"ergebnis": ergebnis,
"einstimmig": einstimmig,
"kind": kind,
"votes": votes,
"anchor_pos": m.start(),
})
# Dedup ueber (drucksache, anchor_pos): falls ein Anchor mehrfach matched
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,21 +20,23 @@ GAP_TOLERANCE=3 # 3 aufeinanderfolgende 404 → fertig fuer dieses BL
# aktuell + Vorgaenger-WP, weil Plenum noch in der laufenden WP arbeitet # aktuell + Vorgaenger-WP, weil Plenum noch in der laufenden WP arbeitet
# und alte Sitzungen gelegentlich nachtraeglich digitalisiert werden. # und alte Sitzungen gelegentlich nachtraeglich digitalisiert werden.
# #
# Format: BL_CODE|WAHLPERIODE|URL_PATTERN_MIT_{n}_PLACEHOLDER # Format: BL_CODE|WAHLPERIODE|PROTOKOLL_ID_PREFIX|URL_PATTERN
# URL-Pattern unterstuetzt zwei Platzhalter:
# {n} — Sitzungs-Nr unkpaddet (z.B. NRW: MMP18-1.pdf)
# {n3} — Sitzungs-Nr 3-stellig zero-gepadded (z.B. BUND: 20001.xml)
PROTO_TARGETS=( PROTO_TARGETS=(
"NRW|18|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{n}.pdf" "NRW|18|MMP18-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{n}.pdf"
"NRW|17|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP17-{n}.pdf" "NRW|17|MMP17-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP17-{n}.pdf"
"BUND|20|BTP20-|https://dserver.bundestag.de/btp/20/20{n3}.xml"
) )
echo "=== auto-ingest-protocols $(date -Iseconds) ===" echo "=== auto-ingest-protocols $(date -Iseconds) ==="
for entry in "${PROTO_TARGETS[@]}"; do for entry in "${PROTO_TARGETS[@]}"; do
IFS='|' read -r bl wp pattern <<< "$entry" IFS='|' read -r bl wp prefix pattern <<< "$entry"
echo "--- ${bl} WP${wp} ---" echo "--- ${bl} WP${wp} (prefix=${prefix}) ---"
# Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/WP-Praefix # Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/Prefix
# python statt sqlite3 — Container hat kein CLI-sqlite3, aber das Python-Modul
prefix="MMP${wp}-"
last_n=$(docker exec "$CONTAINER" python -c " last_n=$(docker exec "$CONTAINER" python -c "
import sqlite3 import sqlite3
c = sqlite3.connect('/app/data/gwoe-antraege.db').cursor() c = sqlite3.connect('/app/data/gwoe-antraege.db').cursor()
@ -49,7 +51,9 @@ print(c.fetchone()[0])
consecutive_404=0 consecutive_404=0
for n in $(seq $start_n $((last_n + 50))); do for n in $(seq $start_n $((last_n + 50))); do
url="${pattern//\{n\}/$n}" n3=$(printf "%03d" "$n")
url="${pattern//\{n3\}/$n3}"
url="${url//\{n\}/$n}"
http=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 15 "$url" || echo "000") http=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 15 "$url" || echo "000")
if [ "$http" = "200" ]; then if [ "$http" = "200" ]; then
consecutive_404=0 consecutive_404=0

View File

@ -0,0 +1,235 @@
"""Tests fuer app/protokoll_parsers/bund.py — Bundestags-Plenarprotokoll-Parser (#148).
Stichprobe-getestet gegen WP20 Sitzung 30 + 100 (XML aus dserver.bundestag.de).
Pure-string-Tests fuer Vote-Block-Parsing, Anchor-Detection, Fraktions-Mapping.
"""
from __future__ import annotations
import pytest
from app.protokoll_parsers.bund import (
_normalize_fraktionen_bt,
_parse_vote_block_bt,
_resolve_drucksache_bt,
RESULT_ANCHOR_RE,
parse_protocol,
WP20_KOALITIONSFRAKTIONEN,
WP20_OPPOSITIONSFRAKTIONEN,
ALL_BT_FRAKTIONEN,
)
class TestNormalizeFraktionenBt:
def test_simple_spd(self):
assert _normalize_fraktionen_bt("SPD-Fraktion") == ["SPD"]
def test_cdu_csu(self):
assert _normalize_fraktionen_bt("CDU/CSU-Fraktion") == ["CDU/CSU"]
def test_buendnis_90_normalizes_to_gruene(self):
result = _normalize_fraktionen_bt("Fraktion Bündnis 90/Die Grünen")
assert result == ["GRÜNE"]
def test_koalitionsfraktionen_expands_wp20(self):
"""In WP20: Koalition = SPD + GRÜNE + FDP."""
result = _normalize_fraktionen_bt("der Koalitionsfraktionen")
assert set(result) == set(WP20_KOALITIONSFRAKTIONEN)
def test_oppositionsfraktionen_expands_wp20(self):
result = _normalize_fraktionen_bt("der Oppositionsfraktionen")
assert set(result) == set(WP20_OPPOSITIONSFRAKTIONEN)
def test_combined_phrase(self):
"""'Koalitionsfraktionen und der Fraktion Die Linke' → SPD+GRÜNE+FDP+LINKE."""
result = _normalize_fraktionen_bt(
"der Koalitionsfraktionen und der Fraktion Die Linke"
)
assert set(result) == {"SPD", "GRÜNE", "FDP", "LINKE"}
def test_empty_returns_empty(self):
assert _normalize_fraktionen_bt("") == []
def test_no_double_count(self):
"""SPD darf in 'SPD-Fraktion' nicht zweimal gezaehlt werden."""
result = _normalize_fraktionen_bt("der SPD-Fraktion und der SPD")
assert result.count("SPD") == 1
class TestParseVoteBlockBt:
def test_full_block_with_all_three_kinds(self):
block = (
" der Koalitionsfraktionen und der Fraktion Die Linke "
"gegen die Stimmen der CDU/CSU-Fraktion "
"bei Enthaltung der AfD-Fraktion"
)
votes = _parse_vote_block_bt(block)
assert set(votes["ja"]) == {"SPD", "GRÜNE", "FDP", "LINKE"}
assert votes["nein"] == ["CDU/CSU"]
assert votes["enthaltung"] == ["AfD"]
def test_only_ja_and_nein(self):
block = (
" der SPD-Fraktion, der Fraktion Bündnis 90/Die Grünen, der FDP-Fraktion, "
"der CDU/CSU-Fraktion und der Fraktion Die Linke "
"gegen die Stimmen der AfD-Fraktion"
)
votes = _parse_vote_block_bt(block)
assert "AfD" not in votes["ja"]
assert votes["nein"] == ["AfD"]
assert votes["enthaltung"] == []
assert set(votes["ja"]) == {"SPD", "GRÜNE", "FDP", "CDU/CSU", "LINKE"}
def test_only_ja(self):
block = " der Koalitionsfraktionen"
votes = _parse_vote_block_bt(block)
assert set(votes["ja"]) == set(WP20_KOALITIONSFRAKTIONEN)
assert votes["nein"] == []
assert votes["enthaltung"] == []
class TestResolveDrucksacheBt:
def test_finds_nearest_ds_before(self):
text = (
"Drucksache 20/100 ... irgendwas ... "
"Die Beschlussempfehlung ist mit den Stimmen ..."
)
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/100"
def test_picks_closest_when_multiple(self):
"""Bei mehreren DS-Nrn vor dem Anchor wird die naechste gewaehlt."""
text = (
"Drucksache 20/100 ... Drucksache 20/200 ... "
"Die Beschlussempfehlung ..."
)
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/200"
def test_returns_none_when_no_ds(self):
text = "Die Beschlussempfehlung ist mit den Stimmen ..."
anchor = 0
assert _resolve_drucksache_bt(text, anchor) is None
def test_neu_suffix_supported(self):
text = "auf Drucksache 20/4567(neu) ... Die Beschlussempfehlung ..."
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/4567(neu)"
class TestResultAnchorRegex:
def test_matches_beschlussempfehlung_angenommen(self):
text = (
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der CDU/CSU-Fraktion angenommen."
)
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("subject") == "Die Beschlussempfehlung"
assert m.group("ergebnis") == "angenommen"
def test_matches_ueberweisungsvorschlag_abgelehnt(self):
text = (
"Der Überweisungsvorschlag ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der AfD-Fraktion abgelehnt."
)
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("ergebnis") == "abgelehnt"
def test_no_match_in_speech(self):
"""'angenommen' in einer Rede (ohne mit-den-Stimmen-Form) darf nicht matchen."""
text = "Wir haben das Angebot angenommen, weil das Geld gut angelegt ist."
assert RESULT_ANCHOR_RE.search(text) is None
class TestParseProtocolEndToEnd:
"""Integration-light: parsen ein Mock-XML mit BT-typischen Beschluessen."""
def _write_xml(self, tmp_path, body_text):
xml_path = tmp_path / "test.xml"
# Minimal-XML, alles in einem <p>
xml_path.write_text(
f'<?xml version="1.0"?>'
f'<dbtplenarprotokoll><sitzungsverlauf><p>{body_text}</p>'
f'</sitzungsverlauf></dbtplenarprotokoll>',
encoding="utf-8",
)
return xml_path
def test_single_beschluss(self, tmp_path):
body = (
"Beschlussempfehlung auf Drucksache 20/1234. "
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert len(result) == 1
r = result[0]
assert r["drucksache"] == "20/1234"
assert r["ergebnis"] == "angenommen"
assert set(r["votes"]["ja"]) == set(WP20_KOALITIONSFRAKTIONEN)
assert r["votes"]["nein"] == ["CDU/CSU"]
assert r["votes"]["enthaltung"] == ["AfD"]
assert r["einstimmig"] is False
def test_ueberweisungsvorschlag_kind(self, tmp_path):
body = (
"Drucksache 20/5000. "
"Der Überweisungsvorschlag ist mit den Stimmen "
"der Koalitionsfraktionen, der CDU/CSU-Fraktion, der Fraktion Die Linke "
"gegen die Stimmen der AfD-Fraktion angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert len(result) == 1
assert result[0]["kind"] == "ueberweisung"
# Ueberweisungs-Anchor → ergebnis 'überwiesen'
assert result[0]["ergebnis"] == "überwiesen"
def test_einstimmig_heuristic(self, tmp_path):
"""Wenn alle 6 Fraktionen ja stimmen, einstimmig=True."""
body = (
"Drucksache 20/9999. "
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen, "
"der CDU/CSU-Fraktion, der AfD-Fraktion und der Fraktion Die Linke "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert result[0]["einstimmig"] is True
def test_skip_anchor_without_drucksache(self, tmp_path):
"""Anchor ohne aufloesbare DS wird uebersprungen."""
body = (
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
assert parse_protocol(str(xml)) == []
def test_zero_results_for_pure_aussprache(self, tmp_path):
body = (
"Drucksache 20/100. Wir diskutieren den Antrag. "
"Die Linke hat das angenommen, dass die Politik gut ist."
)
xml = self._write_xml(tmp_path, body)
# Kein 'mit den Stimmen' → kein Treffer
assert parse_protocol(str(xml)) == []
class TestConstants:
def test_wp20_koalition_correct(self):
"""Sanity: WP20-Koalition = SPD + GRÜNE + FDP (Ampel)."""
assert set(WP20_KOALITIONSFRAKTIONEN) == {"SPD", "GRÜNE", "FDP"}
def test_wp20_opposition_correct(self):
"""WP20-Opposition = CDU/CSU + AfD + LINKE."""
assert set(WP20_OPPOSITIONSFRAKTIONEN) == {"CDU/CSU", "AfD", "LINKE"}
def test_all_bt_fraktionen_complete(self):
"""ALL_BT_FRAKTIONEN deckt alle 6 BT-Fraktionen der WP20 ab."""
assert set(ALL_BT_FRAKTIONEN) == {
"CDU/CSU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE"
}

View File

@ -20,7 +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", "BB", "BE", "BW", "BY", "HB", "HE", "HH", # BUND raus, weil seit 2026-04-28 produktiver Parser (#148)
"BB", "BE", "BW", "BY", "HB", "HE", "HH",
"LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH", "LSA", "MV", "NI", "RP", "SH", "SL", "SN", "TH",
] ]
@ -75,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 muss nur NRW in der Registry sein # Aktuell: NRW + BUND produktiv
assert registered == {"NRW"}, ( assert registered == {"NRW", "BUND"}, (
"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."
) )