diff --git a/app/ingest_votes.py b/app/ingest_votes.py index cdb585b..395e7de 100644 --- a/app/ingest_votes.py +++ b/app/ingest_votes.py @@ -129,8 +129,13 @@ def _cli() -> None: parser.error("--pdf oder --url ist erforderlich") if args.url: - # Download in tmp und nach dem Run wieder loeschen - with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + # Tmpfile-Suffix aus der URL ableiten (PDF, XML, ...) — der BUND-Parser + # 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) try: print(f"Lade {args.url} → {tmp_path} …") diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index f3f8230..3709c12 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -29,12 +29,14 @@ from __future__ import annotations from typing import Callable 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. ProtokollParser = Callable[[str], list[dict]] PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "NRW": _parse_nrw, + "BUND": _parse_bund, } diff --git a/app/protokoll_parsers/bund.py b/app/protokoll_parsers/bund.py index adbb09c..6071403 100644 --- a/app/protokoll_parsers/bund.py +++ b/app/protokoll_parsers/bund.py @@ -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 -Recherche-Findings, sodass die Implementer-Session direkt produktiv -loslegen kann. Der Stub wird **nicht** in -``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert — der -Auto-Ingest-Cron ueberspringt BUND solange. +XML-basierter Parser für Bundestags-Plenarprotokolle. Quelle: +``https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml`` (auch .pdf +verfuegbar; XML ist strukturierter, daher bevorzugt). -## Recherche 2026-04-28 +## Anchor-Sprache (verifiziert WP20 Sitzungen 30, 100) -### URL-Pattern - -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: ```` mit Children: -- ```` — Sitzungsmetadaten -- ```` — Reden + Tagesordnungspunkte -- ```` — Beschluss-Anlagen, namentliche Abstimmungen -- ```` - -Tags: ````, ````, ```` (Regie- -Anweisungen wie "(Beifall bei der CDU/CSU)"), ```` mit -````-Untertag. - -**Kein ````-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): +Bundestag formuliert Beschluesse mit: ``` Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen -und der Fraktion Die Linke gegen die Stimmen der CDU/CSU-Fraktion bei -Enthaltung der AfD-Fraktion angenommen. +und der Fraktion Die Linke gegen die Stimmen der CDU/CSU-Fraktion +bei Enthaltung der AfD-Fraktion angenommen. ``` -Pattern-Erkennung: -- Anchor-Verb: ``angenommen`` oder ``abgelehnt`` am Satzende -- Vote-Block: ``mit den Stimmen [...] gegen die Stimmen [...] bei - Enthaltung [...]`` -- Fraktions-Phrasen: ``Fraktion X``, ``X-Fraktion``, ``Koalitionsfraktionen`` -- Drucksachen muessen **rueckwaerts** vom Anchor gesucht werden - (oft mehrere 100 Zeichen vorher) +Pattern: +- Subjekt: "Die Beschlussempfehlung", "Der Überweisungsvorschlag", + "Der Antrag", "Der Gesetzentwurf" +- Vote-Block: "mit den Stimmen X gegen die Stimmen Y bei Enthaltung Z" +- Anchor-Verb: "angenommen" oder "abgelehnt" -**Wichtig:** BT-Anchor-Sprache ist viel laenger als NRW -(``Damit ist X angenommen``, 5-30 Zeichen) — bei BT zwischen Stimm- -Block und ``angenommen`` koennen 200+ Zeichen liegen. Regex-Begrenzung -muss entsprechend grosszuegig sein. +## Fraktions-Mapping -WP20 Sitzung 184 = pure Aussprache, KEINE Beschluss-Anchors. Sample -fuer Tests: WP20-Sitzungen 30, 100, 150 (alle mit Beschluessen). +Koalitions-/Oppositions-Bezeichnungen aendern sich pro Wahlperiode. +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 -unter ``bundestag.de/parlament/plenum/abstimmung/abstimmung``. Pro -Abstimmung MP-level Vote-Records. Fraktions-Aggregate sind dort -extrahierbar OHNE PDF-Parsing. +WP21 (ab 2025) wuerde anderes Mapping brauchen. Folge-Issue notwendig. -**Empfehlung fuer Implementer:** statt PDF/XML-Parser bauen, lieber -``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. +## Drucksachen-Aufloesung -### Sample-Daten fuer Tests - -- WP20 Sitzung 30: "{wp}{n:03}" → btp/20/20030.xml — enthaelt diverse - 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 +Vor dem Anchor wird rueckwaerts nach "Drucksache 20/N" oder +"auf Drucksache 20/N" gesucht. Der naechste Match in einem 1500-Zeichen- +Fenster gewinnt. """ 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.""" - raise NotImplementedError( - "BUND-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/bund.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) + +# WP20 (2021-2025) Koalition: SPD + GRÜNE + FDP. Opposition: CDU/CSU + AfD + LINKE. +# WP21 Implementierung erfordert separates Mapping pro WP — folgt sobald gebraucht. +WP20_KOALITIONSFRAKTIONEN = ["SPD", "GRÜNE", "FDP"] +WP20_OPPOSITIONSFRAKTIONEN = ["CDU/CSU", "AfD", "LINKE"] + +# 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"(?PDie Beschlussempfehlung|Der Überweisungsvorschlag|Der Antrag" + r"|Der Gesetzentwurf|Diese Beschlussempfehlung)" + r"\s+ist\s+mit den Stimmen(?P[^.]{20,500}?)" + r"\s+(?Pangenommen|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 diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index b2e89c9..44f6360 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -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 # 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=( - "NRW|18|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|18|MMP18-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{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) ===" for entry in "${PROTO_TARGETS[@]}"; do - IFS='|' read -r bl wp pattern <<< "$entry" - echo "--- ${bl} WP${wp} ---" + IFS='|' read -r bl wp prefix pattern <<< "$entry" + echo "--- ${bl} WP${wp} (prefix=${prefix}) ---" - # Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/WP-Praefix - # python statt sqlite3 — Container hat kein CLI-sqlite3, aber das Python-Modul - prefix="MMP${wp}-" + # Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/Prefix last_n=$(docker exec "$CONTAINER" python -c " import sqlite3 c = sqlite3.connect('/app/data/gwoe-antraege.db').cursor() @@ -49,7 +51,9 @@ print(c.fetchone()[0]) consecutive_404=0 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") if [ "$http" = "200" ]; then consecutive_404=0 diff --git a/tests/test_protokoll_parsers_bund.py b/tests/test_protokoll_parsers_bund.py new file mode 100644 index 0000000..59b5687 --- /dev/null +++ b/tests/test_protokoll_parsers_bund.py @@ -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

+ xml_path.write_text( + f'' + f'

{body_text}

' + f'
', + 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" + } diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index cdb0f6b..69a0a6c 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,7 +20,8 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender 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", ] @@ -75,8 +76,8 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell muss nur NRW in der Registry sein - assert registered == {"NRW"}, ( + # Aktuell: NRW + BUND produktiv + assert registered == {"NRW", "BUND"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )