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).
236 lines
9.0 KiB
Python
236 lines
9.0 KiB
Python
"""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"
|
|
}
|