feat(#106,#134): NRW-Protokoll-Parser v5 ins Repo migriert
Vorher als parser_v5_iteration15.py nur auf Prod-Server, nicht versionskontrolliert. Jetzt unter app/protokoll_parser_nrw.py mit klarem Naming-Schema (BL-Suffix, damit Folge-Adapter analog heissen koennen, vgl. ADR 0002). Aenderungen am Code: - from __future__ import annotations (Py3.9-kompatibel fuer 'str | None') - fitz-Import optional (try/except), damit pure-string-Funktionen auch im Stub-conftest funktionieren 30 Tests in test_protokoll_parser_nrw.py (#134 Phase 2): - normalize_fraktionen: F.D.P., GRÜNE-Aliase, Landesregierung - _is_empty_phrase: Niemand/Keine/nicht-Mustern - _parse_vote_block: ja/nein-Extraktion plus Negationen - find_results: angenommen/abgelehnt, einstimmig (nur ueber-Kind!), (neu)-Suffix in Drucksachen-Nrn, Sortierung, Dedup - resolve_drucksache_for_ueber: Backward-Search mit closest-match Refs #106 (Abstimmungsverhalten verknuepfen — Vorbereitung fuer DB-Schema) Refs #126 (BL-uebergreifender Parser — NRW als Referenz-Implementierung) Refs #134 (Test-Suite Audit — Phase 2)
This commit is contained in:
parent
3262f17458
commit
d640734641
348
app/protokoll_parser_nrw.py
Normal file
348
app/protokoll_parser_nrw.py
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
"""NRW-Plenarprotokoll Abstimmungs-Parser v5 (deterministisch, anchor-basiert).
|
||||||
|
|
||||||
|
Neue Architektur: Statt pro Drucksache zu suchen, findet der Parser zuerst
|
||||||
|
alle **Result-Anchors** im Volltext ("Damit ist ... angenommen/abgelehnt/...")
|
||||||
|
und extrahiert pro Anchor rückwärts:
|
||||||
|
1. die zugehörige Drucksache (nächste 18/XXXXX davor, innerhalb ~500 chars)
|
||||||
|
2. den Vote-Block (letztes "Wer stimmt ... zu?" vor dem Anchor)
|
||||||
|
|
||||||
|
Fixture-basierte Tests. Ziel: 18/19 (17824 ist bewusst nicht_gesondert).
|
||||||
|
|
||||||
|
Migriert nach app/ aus dem POC-Skript parser_v5_iteration15.py
|
||||||
|
(2026-04-28, #134/#106). Fitz-Import ist optional — pure-string-Funktionen
|
||||||
|
laufen ohne, parse_protocol() braucht das echte fitz.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try: # fitz ist optional — pure-string-Funktionen laufen ohne
|
||||||
|
import fitz
|
||||||
|
except ImportError:
|
||||||
|
fitz = None
|
||||||
|
|
||||||
|
FRAKTIONEN_MAP = [
|
||||||
|
("Bündnis 90/Die Grünen", "GRÜNE"),
|
||||||
|
("Bündnis 90", "GRÜNE"),
|
||||||
|
("Grünen", "GRÜNE"),
|
||||||
|
("GRÜNE", "GRÜNE"),
|
||||||
|
("F.D.P.", "FDP"),
|
||||||
|
("FDP", "FDP"),
|
||||||
|
("CDU", "CDU"),
|
||||||
|
("SPD", "SPD"),
|
||||||
|
("AfD", "AfD"),
|
||||||
|
("LINKE", "LINKE"),
|
||||||
|
("BSW", "BSW"),
|
||||||
|
("Landesregierung", "Landesregierung"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLE_FRAKTIONEN_NRW = ["CDU", "SPD", "GRÜNE", "FDP", "AfD"]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fraktionen(txt):
|
||||||
|
"""Extrahiere Fraktions-Tokens aus einem Text-Abschnitt."""
|
||||||
|
found = set()
|
||||||
|
# Reihenfolge: längere zuerst (damit "Bündnis 90/Die Grünen" vor "Grünen" matcht)
|
||||||
|
remaining = txt
|
||||||
|
for key, val in FRAKTIONEN_MAP:
|
||||||
|
if key in remaining:
|
||||||
|
found.add(val)
|
||||||
|
remaining = remaining.replace(key, "") # Doppel-Match vermeiden
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty_phrase(txt):
|
||||||
|
"""Prüft ob der Text eine Negation ausdrückt (niemand, nicht, keine)."""
|
||||||
|
neg = ["niemand", "Niemand", "Keine", "keine", "nicht der Fall",
|
||||||
|
"Auch nicht", "ist nicht", "ist auch nicht", "nicht vor"]
|
||||||
|
return any(n in txt for n in neg)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vote_block(block: str) -> dict:
|
||||||
|
"""Extrahiere ja/nein/enthaltung aus dem Text-Block vor einem Result-Anchor.
|
||||||
|
|
||||||
|
Vereinfachter Ansatz: matche bis zum nächsten '?' oder 200 chars.
|
||||||
|
"""
|
||||||
|
votes = {"ja": [], "nein": [], "enthaltung": []}
|
||||||
|
|
||||||
|
# JA — letztes Match gewinnt (bei Re-Votes)
|
||||||
|
ja_matches = list(re.finditer(
|
||||||
|
r"Wer stimmt(?! dagegen)[^?]{0,80}zu\?\s*[–-]?\s*([^?]{1,250})",
|
||||||
|
block
|
||||||
|
))
|
||||||
|
if ja_matches:
|
||||||
|
g = ja_matches[-1].group(1)
|
||||||
|
if not _is_empty_phrase(g):
|
||||||
|
votes["ja"] = normalize_fraktionen(g)
|
||||||
|
|
||||||
|
# NEIN
|
||||||
|
nein_patterns = [
|
||||||
|
r"Wer stimmt dagegen\?\s*[–-]?\s*([^?]{1,200})",
|
||||||
|
r"Wer lehnt[^?]{0,30}ab\?\s*[–-]?\s*([^?]{1,200})",
|
||||||
|
r"Stimmt jemand dagegen\?\s*[–-]?\s*([^?]{1,120})",
|
||||||
|
r"Ist jemand dagegen\?\s*[–-]?\s*([^?]{1,120})",
|
||||||
|
]
|
||||||
|
for pat in nein_patterns:
|
||||||
|
matches = list(re.finditer(pat, block))
|
||||||
|
if matches:
|
||||||
|
g = matches[-1].group(1)
|
||||||
|
votes["nein"] = [] if _is_empty_phrase(g) else normalize_fraktionen(g)
|
||||||
|
break
|
||||||
|
|
||||||
|
# ENTHALTUNG
|
||||||
|
enth_patterns = [
|
||||||
|
r"Wer enthält sich\?\s*[–-]?\s*([^?]{1,200})",
|
||||||
|
r"Gibt es Enthaltungen\?\s*[–-]?\s*([^?]{1,200})",
|
||||||
|
r"Enthält sich jemand\?\s*[–-]?\s*([^?]{1,120})",
|
||||||
|
r"Möchte sich jemand enthalten\?\s*[–-]?\s*([^?]{1,120})",
|
||||||
|
]
|
||||||
|
for pat in enth_patterns:
|
||||||
|
matches = list(re.finditer(pat, block))
|
||||||
|
if matches:
|
||||||
|
g = matches[-1].group(1)
|
||||||
|
votes["enthaltung"] = [] if _is_empty_phrase(g) else normalize_fraktionen(g)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Implizite leere Enthaltungen: "Enthaltungen gibt es damit nicht"
|
||||||
|
if not votes["enthaltung"] and re.search(r"Enthaltungen\s+gibt\s+es\s+damit\s+nicht", block):
|
||||||
|
votes["enthaltung"] = []
|
||||||
|
|
||||||
|
return votes
|
||||||
|
|
||||||
|
|
||||||
|
# Result-Anchors: Pattern → (ergebnis, is_ueberweisung)
|
||||||
|
# v6: Broad-Anchor-Matches für alle direkten Varianten.
|
||||||
|
# Type 'direct_broad': matcht "Damit/Somit ist der/dieser/die Antrag/Gesetzentwurf/...
|
||||||
|
# ... angenommen/abgelehnt/überwiesen/verabschiedet" — Drucksache wird
|
||||||
|
# separat aus dem Match-Span extrahiert (oder aus dem vorangehenden Segment).
|
||||||
|
RESULT_ANCHORS = [
|
||||||
|
# Broad direct-result pattern (deckt fast alle Varianten ab).
|
||||||
|
# "beschlossen" = bei direkter Abstimmung eines Antrags = angenommen
|
||||||
|
(r"(?:Damit|Somit) ist (?:der|dieser|die|diese) (?:Antrag|Gesetzentwurf|Änderungsantrag|Wahlvorschlag|Entschließungsantrag|Beschlussempfehlung)[^.]{0,200}?(angenommen|abgelehnt|überwiesen|zurückgezogen|verabschiedet|beschlossen)", "direct_broad"),
|
||||||
|
# Variante ohne führendes "Damit/Somit ist": "Dieser Antrag Drucksache X ist somit ... abgelehnt"
|
||||||
|
(r"Dieser (?:Antrag|Gesetzentwurf|Änderungsantrag|Wahlvorschlag)[^.]{0,200}?(angenommen|abgelehnt|überwiesen|zurückgezogen|verabschiedet|beschlossen)", "direct_broad"),
|
||||||
|
# Überweisungs-Anchor (Drucksache muss rückwärts gesucht werden)
|
||||||
|
(r"(?:Damit|Somit) ist (?:diese|die)\s+Überweisungsempfehlung\s+(einstimmig\s+|ebenso\s+)?(angenommen)", "ueber"),
|
||||||
|
(r"Somit ist das so beschlossen()()", "ueber"),
|
||||||
|
(r"Damit ist das so beschlossen()()", "ueber"),
|
||||||
|
# "Damit schließt sich der Landtag der Empfehlung des Rechtsausschusses an" — Empfehlung-Beitritt
|
||||||
|
(r"Damit schließt sich der Landtag der Empfehlung[^.]{0,100}?an()()", "ueber"),
|
||||||
|
# Petitionsausschuss-Sammel-Abstimmung
|
||||||
|
(r"Damit sind die Beschlüsse des Petitionsausschusses[^.]{0,100}?bestätigt()()", "petition"),
|
||||||
|
# Übersicht-Bestätigung (§ 82 Abs. 2 GO)
|
||||||
|
(r"Damit sind die in Drucksache (\d+/\d+(?:\(neu\))?) enthaltenen[^.]{0,150}?bestätigt()", "uebersicht"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_results(text: str) -> list[dict]:
|
||||||
|
"""Finde alle Result-Anchors im Text.
|
||||||
|
|
||||||
|
Returns: Liste von {drucksache, ergebnis, anchor_start, anchor_end, kind, einstimmig}.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for pat, kind in RESULT_ANCHORS:
|
||||||
|
for m in re.finditer(pat, text):
|
||||||
|
groups = m.groups()
|
||||||
|
ds = None
|
||||||
|
einstimmig = False
|
||||||
|
span_text = text[m.start():m.end()]
|
||||||
|
|
||||||
|
# Für "direct" kind: erste DS-artige Group ist die Drucksache
|
||||||
|
if kind == "direct":
|
||||||
|
for g in groups:
|
||||||
|
if g and re.match(r"^\d+/\d+(?:\(neu\))?$", g):
|
||||||
|
ds = g
|
||||||
|
break
|
||||||
|
# Für "direct_broad": Drucksache innerhalb des Match-Spans suchen
|
||||||
|
elif kind == "direct_broad":
|
||||||
|
ds_match = re.search(r"Drucksache\s+(\d+/\d+(?:\(neu\))?)", span_text)
|
||||||
|
if ds_match:
|
||||||
|
ds = ds_match.group(1)
|
||||||
|
# Ergebnis: suche bekanntes Wort in allen Groups
|
||||||
|
ergebnis = None
|
||||||
|
for g in groups:
|
||||||
|
if g and g.strip() == "einstimmig":
|
||||||
|
einstimmig = True
|
||||||
|
if g and g.strip() in ("angenommen", "abgelehnt", "überwiesen", "zurückgezogen", "verabschiedet", "beschlossen"):
|
||||||
|
ergebnis = g.strip()
|
||||||
|
# "verabschiedet" = angenommen und verabschiedet (Gesetzentwurf)
|
||||||
|
# "beschlossen" (bei direkter Abstimmung) = angenommen
|
||||||
|
if ergebnis in ("verabschiedet", "beschlossen"):
|
||||||
|
ergebnis = "angenommen"
|
||||||
|
if kind == "ueber":
|
||||||
|
ergebnis = "überwiesen"
|
||||||
|
if "einstimmig" in text[m.start():m.end() + 5]:
|
||||||
|
einstimmig = True
|
||||||
|
# "Damit ist das so beschlossen" / "Somit ist das so beschlossen" = implizit einstimmig
|
||||||
|
if "so beschlossen" in text[m.start():m.end() + 5]:
|
||||||
|
einstimmig = True
|
||||||
|
if kind == "petition":
|
||||||
|
ergebnis = "sammel"
|
||||||
|
einstimmig = True
|
||||||
|
if kind == "uebersicht":
|
||||||
|
ergebnis = "bestätigt"
|
||||||
|
einstimmig = True
|
||||||
|
# Drucksache ist in Group[0] des Patterns
|
||||||
|
for g in groups:
|
||||||
|
if g and re.match(r"^\d+/\d+(?:\(neu\))?$", g):
|
||||||
|
ds = g
|
||||||
|
break
|
||||||
|
if not ergebnis:
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"drucksache": ds,
|
||||||
|
"ergebnis": ergebnis,
|
||||||
|
"kind": kind,
|
||||||
|
"einstimmig": einstimmig,
|
||||||
|
"anchor_start": m.start(),
|
||||||
|
"anchor_end": m.end(),
|
||||||
|
})
|
||||||
|
results.sort(key=lambda r: r["anchor_start"])
|
||||||
|
dedup = []
|
||||||
|
seen_positions = set()
|
||||||
|
for r in results:
|
||||||
|
if r["anchor_start"] in seen_positions:
|
||||||
|
continue
|
||||||
|
seen_positions.add(r["anchor_start"])
|
||||||
|
dedup.append(r)
|
||||||
|
return dedup
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_drucksache_for_ueber(text: str, anchor_start: int) -> str | None:
|
||||||
|
"""Für Überweisungs-Anchors: rückwärts die nächste Drucksache-Nr suchen."""
|
||||||
|
# Schaue bis 2000 chars zurück
|
||||||
|
window_start = max(0, anchor_start - 2000)
|
||||||
|
window = text[window_start:anchor_start]
|
||||||
|
# Letzte Drucksache vor dem Anchor
|
||||||
|
matches = list(re.finditer(r"Drucksache\s+(\d+/\d+(?:\(neu\))?)", window))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return matches[-1].group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Normalisiere PDF-Text: Worttrennungen (-\n) auflösen, Zeilenumbrüche zu Spaces."""
|
||||||
|
# Worttrennung am Zeilenende: "Überweisungs-\nempfehlung" → "Überweisungsempfehlung"
|
||||||
|
text = re.sub(r"-\s*\n\s*", "", text)
|
||||||
|
# Alle restlichen Zeilenumbrüche zu Spaces
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_protocol(pdf_path: str) -> list[dict]:
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
full = "".join(page.get_text() for page in doc)
|
||||||
|
doc.close()
|
||||||
|
full = normalize_text(full)
|
||||||
|
|
||||||
|
anchors = find_results(full)
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
# Segment-Boundaries: jede Abstimmung beginnt mit einer dieser Phrasen
|
||||||
|
segment_starts = [m.start() for m in re.finditer(
|
||||||
|
r"(?:(?:Damit|Somit) kommen wir (?:zur|somit zur) Abstimmung|Wir kommen (?:somit )?zur Abstimmung|Wir stimmen(?!\s+zu\?)|(?:Somit|Damit) kommen wir (?:direkt )?zu den Abstimmungen|Wir stimmen zweitens|gehen (?:wir )?zur Abstimmung über|Somit kommen wir sofort zur Abstimmung)",
|
||||||
|
full
|
||||||
|
)]
|
||||||
|
|
||||||
|
def segment_start_for(anchor_pos: int) -> int:
|
||||||
|
"""Letzte Segment-Grenze vor dem Anchor."""
|
||||||
|
candidates = [s for s in segment_starts if s < anchor_pos]
|
||||||
|
return candidates[-1] if candidates else max(0, anchor_pos - 1500)
|
||||||
|
|
||||||
|
for a in anchors:
|
||||||
|
ds = a["drucksache"]
|
||||||
|
if not ds:
|
||||||
|
ds = resolve_drucksache_for_ueber(full, a["anchor_start"])
|
||||||
|
if not ds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Vote-Block: vom letzten Segment-Start bis zum Anchor
|
||||||
|
block_start = segment_start_for(a["anchor_start"])
|
||||||
|
block = full[block_start:a["anchor_end"]]
|
||||||
|
|
||||||
|
# Einstimmig: immer alle ja, unabhängig davon was das Fenster sagt
|
||||||
|
if a["einstimmig"]:
|
||||||
|
votes = {"ja": list(ALLE_FRAKTIONEN_NRW), "nein": [], "enthaltung": []}
|
||||||
|
else:
|
||||||
|
votes = _parse_vote_block(block)
|
||||||
|
# Fallback-Einstimmig: wenn ein Überweisungs-Anchor keinen eigenen
|
||||||
|
# "Wer stimmt ... zu?"-Block hat (stattdessen nur inverse Form
|
||||||
|
# "Wer stimmt gegen ...?"), ist das in der Praxis einstimmig.
|
||||||
|
if a["kind"] == "ueber" and not votes["ja"] and not votes["nein"] and not votes["enthaltung"]:
|
||||||
|
votes = {"ja": list(ALLE_FRAKTIONEN_NRW), "nein": [], "enthaltung": []}
|
||||||
|
|
||||||
|
parsed.append({
|
||||||
|
"drucksache": ds,
|
||||||
|
"ergebnis": a["ergebnis"],
|
||||||
|
"votes": votes,
|
||||||
|
"anchor_pos": a["anchor_start"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def compare_to_fixture(parsed: list[dict], fixture: dict) -> tuple[int, list]:
|
||||||
|
"""Vergleiche Parser-Output gegen Ground-Truth-Fixture."""
|
||||||
|
parsed_map = {}
|
||||||
|
for p in parsed:
|
||||||
|
parsed_map.setdefault(p["drucksache"], []).append(p)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
matches = 0
|
||||||
|
for gt in fixture["drucksachen"]:
|
||||||
|
ds = gt["drucksache"]
|
||||||
|
gt_erg = gt["ergebnis"]
|
||||||
|
if ds not in parsed_map:
|
||||||
|
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||||
|
# Korrekt NICHT gefunden
|
||||||
|
matches += 1
|
||||||
|
continue
|
||||||
|
errors.append(f"{ds}: NOT FOUND")
|
||||||
|
continue
|
||||||
|
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||||
|
errors.append(f"{ds}: expected nicht_gesondert, but parser found it")
|
||||||
|
continue
|
||||||
|
# Pick the one closest to expected — if multiple, take the first
|
||||||
|
candidates = parsed_map[ds]
|
||||||
|
p = candidates[0]
|
||||||
|
|
||||||
|
gt_erg = gt["ergebnis"]
|
||||||
|
if gt_erg == "nicht_gesondert_abgestimmt":
|
||||||
|
# Erwartetes Verhalten: Parser sollte es NICHT finden
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
if p["ergebnis"] != gt_erg:
|
||||||
|
errors.append(f"{ds}: ergebnis {p['ergebnis']} != {gt_erg}")
|
||||||
|
ok = False
|
||||||
|
if sorted(p["votes"]["ja"]) != sorted(gt["ja"]):
|
||||||
|
errors.append(f"{ds}: ja {p['votes']['ja']} != {gt['ja']}")
|
||||||
|
ok = False
|
||||||
|
if sorted(p["votes"]["nein"]) != sorted(gt["nein"]):
|
||||||
|
errors.append(f"{ds}: nein {p['votes']['nein']} != {gt['nein']}")
|
||||||
|
ok = False
|
||||||
|
if sorted(p["votes"]["enthaltung"]) != sorted(gt["enthaltung"]):
|
||||||
|
errors.append(f"{ds}: enth {p['votes']['enthaltung']} != {gt['enthaltung']}")
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
matches += 1
|
||||||
|
return matches, errors
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pdf = "/tmp/mmp18-119.pdf"
|
||||||
|
fixture_path = "/tmp/nrw_fixture.json"
|
||||||
|
fixture = json.load(open(fixture_path))
|
||||||
|
|
||||||
|
parsed = parse_protocol(pdf)
|
||||||
|
print(f"Parsed {len(parsed)} Abstimmungen gesamt")
|
||||||
|
|
||||||
|
matches, errors = compare_to_fixture(parsed, fixture)
|
||||||
|
print(f"Match gegen Fixture: {matches}/{len(fixture['drucksachen']) - 1} (ohne nicht_gesondert)")
|
||||||
|
print()
|
||||||
|
if errors:
|
||||||
|
print("Fehler:")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
205
tests/test_protokoll_parser_nrw.py
Normal file
205
tests/test_protokoll_parser_nrw.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""Tests fuer app/protokoll_parser_nrw.py — NRW-Plenarprotokoll-Parser v5.
|
||||||
|
|
||||||
|
Backfill aus #134. Der Parser ist deterministisch und anchor-basiert;
|
||||||
|
jede Aenderung an den RESULT_ANCHORS oder den Vote-Block-Regexes muss
|
||||||
|
sofort durch diese Tests fallen.
|
||||||
|
|
||||||
|
Die echte 19/19-Garantie auf MMP18-119 laeuft separat als Integration-Test
|
||||||
|
(braucht das PDF). Hier: pure-string-Tests fuer alle Reverse-Engineering-
|
||||||
|
Findings, die bei der iterativen Entwicklung 1-15 dokumentiert wurden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
# fitz ist via tests/conftest.py gestubbed — Pure-String-Funktionen kommen ohne aus.
|
||||||
|
|
||||||
|
from app.protokoll_parser_nrw import (
|
||||||
|
normalize_fraktionen,
|
||||||
|
find_results,
|
||||||
|
resolve_drucksache_for_ueber,
|
||||||
|
normalize_text,
|
||||||
|
_is_empty_phrase,
|
||||||
|
_parse_vote_block,
|
||||||
|
ALLE_FRAKTIONEN_NRW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeFraktionen:
|
||||||
|
def test_simple_cdu(self):
|
||||||
|
assert normalize_fraktionen("Wer stimmt zu? – CDU") == ["CDU"]
|
||||||
|
|
||||||
|
def test_multiple_fraktionen(self):
|
||||||
|
result = normalize_fraktionen("CDU, SPD und GRÜNE")
|
||||||
|
assert result == sorted(["CDU", "SPD", "GRÜNE"])
|
||||||
|
|
||||||
|
def test_buendnis_90_normalizes_to_gruene(self):
|
||||||
|
assert normalize_fraktionen("Bündnis 90/Die Grünen") == ["GRÜNE"]
|
||||||
|
|
||||||
|
def test_fdp_with_dots_normalizes(self):
|
||||||
|
"""F.D.P. (mit Punkten) muss als FDP erkannt werden."""
|
||||||
|
assert normalize_fraktionen("F.D.P.") == ["FDP"]
|
||||||
|
|
||||||
|
def test_no_double_match_for_overlapping_keys(self):
|
||||||
|
"""'GRÜNE' darf nicht zusaetzlich als 'Grünen' wieder gematcht werden."""
|
||||||
|
result = normalize_fraktionen("GRÜNE und Grünen")
|
||||||
|
# Beide Tokens sind dieselbe Fraktion → nur einmal in der Liste
|
||||||
|
assert result.count("GRÜNE") == 1
|
||||||
|
|
||||||
|
def test_landesregierung_recognized(self):
|
||||||
|
assert "Landesregierung" in normalize_fraktionen("Landesregierung")
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty(self):
|
||||||
|
assert normalize_fraktionen("") == []
|
||||||
|
|
||||||
|
def test_no_known_partei(self):
|
||||||
|
assert normalize_fraktionen("Some random text") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsEmptyPhrase:
|
||||||
|
def test_niemand_is_empty(self):
|
||||||
|
assert _is_empty_phrase("Stimmt jemand dagegen? – Niemand") is True
|
||||||
|
|
||||||
|
def test_keine_is_empty(self):
|
||||||
|
assert _is_empty_phrase("Enthaltungen? – Keine") is True
|
||||||
|
|
||||||
|
def test_nicht_der_fall(self):
|
||||||
|
assert _is_empty_phrase("Das ist nicht der Fall.") is True
|
||||||
|
|
||||||
|
def test_actual_fraktion_is_not_empty(self):
|
||||||
|
assert _is_empty_phrase("CDU und SPD") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseVoteBlock:
|
||||||
|
def test_simple_ja_extraction(self):
|
||||||
|
block = "Wer stimmt zu? – CDU und SPD."
|
||||||
|
votes = _parse_vote_block(block)
|
||||||
|
assert "CDU" in votes["ja"] and "SPD" in votes["ja"]
|
||||||
|
|
||||||
|
def test_ja_with_negation_returns_empty(self):
|
||||||
|
"""'Wer stimmt zu? – Niemand.' → ja-Liste muss leer sein."""
|
||||||
|
block = "Wer stimmt zu? – Niemand."
|
||||||
|
votes = _parse_vote_block(block)
|
||||||
|
assert votes["ja"] == []
|
||||||
|
|
||||||
|
def test_nein_extraction(self):
|
||||||
|
block = "Wer stimmt dagegen? – AfD."
|
||||||
|
votes = _parse_vote_block(block)
|
||||||
|
assert "AfD" in votes["nein"]
|
||||||
|
|
||||||
|
def test_dagegen_negation(self):
|
||||||
|
block = "Wer stimmt dagegen? – Das ist nicht der Fall."
|
||||||
|
votes = _parse_vote_block(block)
|
||||||
|
assert votes["nein"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindResults:
|
||||||
|
def test_direct_angenommen(self):
|
||||||
|
text = (
|
||||||
|
"Damit ist der Antrag Drucksache 18/123 mit den Stimmen "
|
||||||
|
"der CDU und der SPD angenommen."
|
||||||
|
)
|
||||||
|
results = find_results(text)
|
||||||
|
assert len(results) == 1
|
||||||
|
r = results[0]
|
||||||
|
assert r["drucksache"] == "18/123"
|
||||||
|
assert r["ergebnis"] == "angenommen"
|
||||||
|
|
||||||
|
def test_direct_abgelehnt(self):
|
||||||
|
text = (
|
||||||
|
"Damit ist der Antrag Drucksache 18/9999 mit den Stimmen "
|
||||||
|
"der CDU gegen die Stimmen der SPD abgelehnt."
|
||||||
|
)
|
||||||
|
results = find_results(text)
|
||||||
|
assert any(r["drucksache"] == "18/9999" and r["ergebnis"] == "abgelehnt" for r in results)
|
||||||
|
|
||||||
|
def test_einstimmig_flag_only_for_ueber_kind(self):
|
||||||
|
"""v5-Verhalten dokumentiert: 'einstimmig' wird in direct-kind-Anchors
|
||||||
|
NICHT gesetzt, nur in ueber/petition/uebersicht. Dieser Test pinnt
|
||||||
|
das aktuelle Verhalten — wenn v6 einstimmig auch fuer direct erkennt,
|
||||||
|
muss der Test angepasst werden."""
|
||||||
|
text = "Damit ist der Antrag Drucksache 18/100 einstimmig angenommen."
|
||||||
|
results = find_results(text)
|
||||||
|
assert results[0]["kind"] == "direct_broad"
|
||||||
|
# einstimmig wird hier (noch) nicht gesetzt — Reverse-Engineering-Befund
|
||||||
|
assert results[0]["einstimmig"] is False
|
||||||
|
|
||||||
|
def test_einstimmig_flag_for_ueberweisung(self):
|
||||||
|
"""Bei Ueberweisungs-Anchors mit 'einstimmig' im naechsten Token-Bereich
|
||||||
|
wird das Flag gesetzt."""
|
||||||
|
text = "Drucksache 18/100 ... Damit ist diese Überweisungsempfehlung einstimmig angenommen."
|
||||||
|
results = find_results(text)
|
||||||
|
ueber_results = [r for r in results if r["kind"] == "ueber"]
|
||||||
|
assert ueber_results, "kein ueber-Result im Test-Text gefunden"
|
||||||
|
assert ueber_results[0]["einstimmig"] is True
|
||||||
|
|
||||||
|
def test_ueberweisung_so_beschlossen_implies_einstimmig(self):
|
||||||
|
"""'Damit ist das so beschlossen' = implizit einstimmige Ueberweisung."""
|
||||||
|
text = "Drucksache 18/200 ... Damit ist das so beschlossen."
|
||||||
|
results = find_results(text)
|
||||||
|
assert any(r["kind"] == "ueber" and r["einstimmig"] for r in results)
|
||||||
|
|
||||||
|
def test_neu_suffix_in_drucksachenummer(self):
|
||||||
|
"""Drucksache-Nummern mit (neu)-Suffix muessen matchen."""
|
||||||
|
text = "Damit ist der Antrag Drucksache 18/4567(neu) angenommen."
|
||||||
|
results = find_results(text)
|
||||||
|
# Match irgendwo in den Results
|
||||||
|
assert any(r["drucksache"] == "18/4567(neu)" for r in results)
|
||||||
|
|
||||||
|
def test_results_sorted_by_position(self):
|
||||||
|
"""Mehrere Anchors muessen nach anchor_start aufsteigend sortiert sein."""
|
||||||
|
text = (
|
||||||
|
"Damit ist der Antrag Drucksache 18/100 angenommen. "
|
||||||
|
"Spaeter im Text. Damit ist der Antrag Drucksache 18/200 abgelehnt."
|
||||||
|
)
|
||||||
|
results = find_results(text)
|
||||||
|
positions = [r["anchor_start"] for r in results]
|
||||||
|
assert positions == sorted(positions)
|
||||||
|
|
||||||
|
def test_dedup_same_position(self):
|
||||||
|
"""Wenn zwei Patterns am selben anchor_start matchen, nur einer im Output."""
|
||||||
|
text = "Damit ist der Antrag Drucksache 18/300 angenommen."
|
||||||
|
results = find_results(text)
|
||||||
|
positions = [r["anchor_start"] for r in results]
|
||||||
|
assert len(positions) == len(set(positions))
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDrucksacheForUeber:
|
||||||
|
def test_finds_nearest_ds_before_anchor(self):
|
||||||
|
text = "Drucksache 18/100 ... irgendein Text ... Damit ist das so beschlossen."
|
||||||
|
anchor_start = text.find("Damit")
|
||||||
|
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||||
|
assert ds == "18/100"
|
||||||
|
|
||||||
|
def test_picks_closest_when_multiple(self):
|
||||||
|
"""Bei mehreren DS-Nrn vor dem Anchor wird die naechste gewaehlt."""
|
||||||
|
text = "Drucksache 18/100 ... Drucksache 18/200 ... Damit ist das so beschlossen."
|
||||||
|
anchor_start = text.find("Damit")
|
||||||
|
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||||
|
assert ds == "18/200"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_ds_before(self):
|
||||||
|
text = "Damit ist das so beschlossen. Drucksache 18/100 spaeter."
|
||||||
|
anchor_start = 0
|
||||||
|
ds = resolve_drucksache_for_ueber(text, anchor_start)
|
||||||
|
assert ds is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeText:
|
||||||
|
def test_collapses_whitespace(self):
|
||||||
|
"""Mehrfach-Whitespace wird zu einzelnem Leerzeichen kollabiert."""
|
||||||
|
result = normalize_text("Damit ist\nder\tAntrag")
|
||||||
|
assert " " not in result
|
||||||
|
|
||||||
|
def test_preserves_drucksache_format(self):
|
||||||
|
"""Drucksache-Schreibweise mit Slash muss erhalten bleiben."""
|
||||||
|
result = normalize_text("Drucksache 18/123")
|
||||||
|
assert "18/123" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnownFraktionsList:
|
||||||
|
def test_alle_fraktionen_nrw_complete(self):
|
||||||
|
"""ALLE_FRAKTIONEN_NRW deckt die WP18-Fraktionen ab (CDU, SPD, GRÜNE, FDP, AfD)."""
|
||||||
|
for f in ("CDU", "SPD", "GRÜNE", "FDP", "AfD"):
|
||||||
|
assert f in ALLE_FRAKTIONEN_NRW
|
||||||
Loading…
Reference in New Issue
Block a user