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:
parent
7cfbd9f210
commit
22a2b63c35
@ -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} …")
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
235
tests/test_protokoll_parsers_bund.py
Normal file
235
tests/test_protokoll_parsers_bund.py
Normal 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"
|
||||||
|
}
|
||||||
@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user