Zentrale `app/parteien.py` als Single Source of Truth für die Partei- Auflösung: - `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen bekannten Aliasen, optionalem `bundesland_scope` und Government- Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW, BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der generische FREIE WÄHLER-Eintrag). - `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups mit Government-Vorrang und FW-Familien-Disambiguierung - `extract_fraktionen(text, *, bundesland=None)` als Funnel für die vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer- partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope- Filter (SSW nur in SH, BVB-FW nur in BB, etc.). - `display_name(canonical, *, long=False)` für UI/PDF — kurze Form bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN" statt "GRÜNE" etc. Adapter-Migration in `app/parlamente.py`: - Vier nahezu identische `_normalize_fraktion()`-Methoden in PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit `self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt. - `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`, also keine Call-Site-Änderung nötig. `app/embeddings.py:496` Workaround-Hack entfernt: - `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen `normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise überall garantiert kanonisch. Tests: - Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW- Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion, Government-Marker, Tabellen-Konsistenz - `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6 statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4 Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation 157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die kanonischen Keys sind exakt die in der DB stehenden Strings, kein Migrations-Schritt nötig. Refs: #55, #59 (Phase B) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c70b463ac
commit
eb045d0ed3
@ -492,13 +492,22 @@ def get_relevant_quotes_for_antrag(
|
|||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
|
from .parteien import normalize_partei
|
||||||
|
|
||||||
for partei in parteien_to_search:
|
for partei in parteien_to_search:
|
||||||
partei_upper = partei.upper() if partei != "GRÜNE" else "GRÜNE"
|
# Kanonischer Lookup-Key über den zentralen Mapper (#55). Ersetzt
|
||||||
|
# den alten Hack ``partei.upper() if partei != "GRÜNE" else "GRÜNE"``,
|
||||||
|
# der nur die Schreibweisen-Drift in einer einzigen Partei
|
||||||
|
# abgefangen hat. Wenn der Mapper nichts findet, fallen wir auf
|
||||||
|
# den Originalstring zurück — die DB-Lookup-Schicht macht ohnehin
|
||||||
|
# eigene Case-insensitive-Vergleiche.
|
||||||
|
canonical = normalize_partei(partei, bundesland=bundesland)
|
||||||
|
partei_lookup = canonical or partei
|
||||||
|
|
||||||
# Wahlprogramm — bundesland-gefiltert
|
# Wahlprogramm — bundesland-gefiltert
|
||||||
wahl_chunks = find_relevant_chunks(
|
wahl_chunks = find_relevant_chunks(
|
||||||
antrag_text,
|
antrag_text,
|
||||||
parteien=[partei_upper],
|
parteien=[partei_lookup],
|
||||||
typ="wahlprogramm",
|
typ="wahlprogramm",
|
||||||
bundesland=bundesland,
|
bundesland=bundesland,
|
||||||
top_k=top_k_per_partei,
|
top_k=top_k_per_partei,
|
||||||
@ -508,7 +517,7 @@ def get_relevant_quotes_for_antrag(
|
|||||||
# Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit)
|
# Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit)
|
||||||
partei_chunks = find_relevant_chunks(
|
partei_chunks = find_relevant_chunks(
|
||||||
antrag_text,
|
antrag_text,
|
||||||
parteien=[partei_upper],
|
parteien=[partei_lookup],
|
||||||
typ="parteiprogramm",
|
typ="parteiprogramm",
|
||||||
bundesland=bundesland,
|
bundesland=bundesland,
|
||||||
top_k=top_k_per_partei,
|
top_k=top_k_per_partei,
|
||||||
|
|||||||
@ -438,41 +438,14 @@ class PortalaAdapter(ParlamentAdapter):
|
|||||||
"""Decode \\x{abcd} escape sequences from Perl Data::Dumper output."""
|
"""Decode \\x{abcd} escape sequences from Perl Data::Dumper output."""
|
||||||
return re.sub(r'\\x\{([0-9a-f]+)\}', lambda m: chr(int(m.group(1), 16)), s)
|
return re.sub(r'\\x\{([0-9a-f]+)\}', lambda m: chr(int(m.group(1), 16)), s)
|
||||||
|
|
||||||
@staticmethod
|
def _normalize_fraktion(self, urheber: str) -> list[str]:
|
||||||
def _normalize_fraktion(urheber: str) -> list[str]:
|
"""Thin shim — die ganze Regex-Logik lebt jetzt zentral in
|
||||||
"""Map Urheber-String to canonical fraction codes.
|
``app.parteien.extract_fraktionen`` (siehe #55). ``self.bundesland``
|
||||||
|
wird mitgegeben, damit FW-Familien-Aliase korrekt disambiguiert
|
||||||
Uses regex word boundaries instead of plain substring matching so
|
werden.
|
||||||
that comma-separated lists ("CDU, SPD") and the embedded "DIE
|
|
||||||
LINKE" are matched reliably.
|
|
||||||
"""
|
"""
|
||||||
u = urheber.upper()
|
from .parteien import extract_fraktionen
|
||||||
out: list[str] = []
|
return extract_fraktionen(urheber, bundesland=self.bundesland)
|
||||||
|
|
||||||
def has(pattern: str) -> bool:
|
|
||||||
return re.search(pattern, u) is not None
|
|
||||||
|
|
||||||
if has(r"\bBÜNDNIS\s*90\b") or has(r"\bGR(?:Ü|UE)NE\b"):
|
|
||||||
out.append("GRÜNE")
|
|
||||||
if has(r"\bCDU\b"):
|
|
||||||
out.append("CDU")
|
|
||||||
if has(r"\bSPD\b"):
|
|
||||||
out.append("SPD")
|
|
||||||
# F.D.P. (with dots, historical SH/HB-style) and FDP (modern) — same
|
|
||||||
# flexible pattern as ParLDokAdapter so the test suite stays consistent.
|
|
||||||
if has(r"\bF\.?\s*D\.?\s*P\.?\b"):
|
|
||||||
out.append("FDP")
|
|
||||||
if has(r"\bAFD\b"):
|
|
||||||
out.append("AfD")
|
|
||||||
if has(r"\bLINKE\b"):
|
|
||||||
out.append("LINKE")
|
|
||||||
if has(r"\bBSW\b"):
|
|
||||||
out.append("BSW")
|
|
||||||
# MINISTERIUM/MINISTER beide treffen — \bMINISTER ohne abschließende
|
|
||||||
# Wortgrenze, damit "Ministerium der Finanzen" mit erfasst wird.
|
|
||||||
if has(r"LANDESREGIERUNG|SENAT VON BERLIN|REGIERENDE[RN]?\s+BÜRGERMEISTER|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT"):
|
|
||||||
out.append("Landesregierung")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _build_search_body(
|
def _build_search_body(
|
||||||
self,
|
self,
|
||||||
@ -972,39 +945,10 @@ class ParLDokAdapter(ParlamentAdapter):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
def _normalize_fraktion(self, authorhtml: str) -> list[str]:
|
||||||
def _normalize_fraktion(authorhtml: str) -> list[str]:
|
"""Thin shim — siehe ``app.parteien.extract_fraktionen``. #55."""
|
||||||
"""Map ParlDok ``authorhtml`` to canonical fraction codes.
|
from .parteien import extract_fraktionen
|
||||||
|
return extract_fraktionen(authorhtml, bundesland=self.bundesland)
|
||||||
``authorhtml`` may be a comma-separated list of fractions
|
|
||||||
("CDU, SPD, F.D.P."), a single MdL with party in parens
|
|
||||||
("Thomas de Jesus Fernandes (AfD)") or empty (Landesregierung).
|
|
||||||
"""
|
|
||||||
if not authorhtml:
|
|
||||||
return []
|
|
||||||
u = authorhtml.upper()
|
|
||||||
out: list[str] = []
|
|
||||||
if re.search(r"\bBÜNDNIS\s*90\b", u) or re.search(r"\bGR(?:Ü|UE)NE\b", u):
|
|
||||||
out.append("GRÜNE")
|
|
||||||
if re.search(r"\bCDU\b", u):
|
|
||||||
out.append("CDU")
|
|
||||||
if re.search(r"\bSPD\b", u):
|
|
||||||
out.append("SPD")
|
|
||||||
# F.D.P. (with dots, historical) and FDP both occur in MV
|
|
||||||
if re.search(r"\bF\.?\s*D\.?\s*P\.?\b", u):
|
|
||||||
out.append("FDP")
|
|
||||||
if re.search(r"\bAFD\b", u):
|
|
||||||
out.append("AfD")
|
|
||||||
if re.search(r"\bLINKE\b", u) or re.search(r"\bLL/PDS\b", u):
|
|
||||||
out.append("LINKE")
|
|
||||||
if re.search(r"\bBSW\b", u):
|
|
||||||
out.append("BSW")
|
|
||||||
# \bMINISTER ohne abschließende Wortgrenze, damit MINISTERIUM
|
|
||||||
# auch trifft (z.B. "Ministerium der Finanzen" als Urheber von
|
|
||||||
# Landesregierungs-Drucksachen).
|
|
||||||
if re.search(r"LANDESREGIERUNG|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT", u):
|
|
||||||
out.append("Landesregierung")
|
|
||||||
return out
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fulltext_id(term: str) -> str:
|
def _fulltext_id(term: str) -> str:
|
||||||
@ -1356,33 +1300,14 @@ class StarFinderCGIAdapter(ParlamentAdapter):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
def _normalize_fraktion(self, text: str) -> list[str]:
|
||||||
def _normalize_fraktion(text: str) -> list[str]:
|
"""Thin shim — siehe ``app.parteien.extract_fraktionen``. #55.
|
||||||
"""SH format: 'Christian Dirschauer (SSW), Jette Waldinger-Thiering (SSW)'.
|
|
||||||
|
|
||||||
Includes SSW which is unique to SH (befreit von 5%-Hürde).
|
SH-spezifisch: SSW gehört zur SH-Tabelle und wird durch
|
||||||
|
``bundesland=SH`` korrekt mit-extrahiert.
|
||||||
"""
|
"""
|
||||||
if not text:
|
from .parteien import extract_fraktionen
|
||||||
return []
|
return extract_fraktionen(text, bundesland=self.bundesland)
|
||||||
u = text.upper()
|
|
||||||
out: list[str] = []
|
|
||||||
if re.search(r"\bBÜNDNIS\s*90\b", u) or re.search(r"\bGR(?:Ü|UE)NE\b", u):
|
|
||||||
out.append("GRÜNE")
|
|
||||||
if re.search(r"\bCDU\b", u):
|
|
||||||
out.append("CDU")
|
|
||||||
if re.search(r"\bSPD\b", u):
|
|
||||||
out.append("SPD")
|
|
||||||
if re.search(r"\bF\.?\s*D\.?\s*P\.?\b", u):
|
|
||||||
out.append("FDP")
|
|
||||||
if re.search(r"\bAFD\b", u):
|
|
||||||
out.append("AfD")
|
|
||||||
if re.search(r"\bLINKE\b", u):
|
|
||||||
out.append("LINKE")
|
|
||||||
if re.search(r"\bSSW\b", u):
|
|
||||||
out.append("SSW")
|
|
||||||
if re.search(r"LANDESREGIERUNG|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT", u):
|
|
||||||
out.append("Landesregierung")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _build_url(self) -> str:
|
def _build_url(self) -> str:
|
||||||
"""Build the Starfinder URL for the structural WP+dtyp browse.
|
"""Build the Starfinder URL for the structural WP+dtyp browse.
|
||||||
@ -1619,37 +1544,14 @@ class PARLISAdapter(ParlamentAdapter):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
def _normalize_fraktion(self, text: str) -> list[str]:
|
||||||
def _normalize_fraktion(text: str) -> list[str]:
|
"""Thin shim — siehe ``app.parteien.extract_fraktionen``. #55.
|
||||||
"""Map a free-text Urheber line to canonical fraction codes.
|
|
||||||
|
|
||||||
PARLIS packs the originator into ``EWBV23`` like
|
PARLIS packt den Originator in ``EWBV23`` wie
|
||||||
``"Antrag Felix Herkens (GRÜNE), Saskia Frank (GRÜNE), ... 16.03.2026"``
|
``"Antrag Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)..."``.
|
||||||
— multiple MdLs with their party in parentheses, comma-separated.
|
|
||||||
Same logic as ``ParLDokAdapter._normalize_fraktion`` (#46 fixed
|
|
||||||
the MINISTER/MINISTERIUM regex there too).
|
|
||||||
"""
|
"""
|
||||||
if not text:
|
from .parteien import extract_fraktionen
|
||||||
return []
|
return extract_fraktionen(text, bundesland=self.bundesland)
|
||||||
u = text.upper()
|
|
||||||
out: list[str] = []
|
|
||||||
if re.search(r"\bBÜNDNIS\s*90\b", u) or re.search(r"\bGR(?:Ü|UE)NE\b", u):
|
|
||||||
out.append("GRÜNE")
|
|
||||||
if re.search(r"\bCDU\b", u):
|
|
||||||
out.append("CDU")
|
|
||||||
if re.search(r"\bSPD\b", u):
|
|
||||||
out.append("SPD")
|
|
||||||
if re.search(r"\bF\.?\s*D\.?\s*P\.?\b", u):
|
|
||||||
out.append("FDP")
|
|
||||||
if re.search(r"\bAFD\b", u):
|
|
||||||
out.append("AfD")
|
|
||||||
if re.search(r"\bLINKE\b", u):
|
|
||||||
out.append("LINKE")
|
|
||||||
if re.search(r"\bBSW\b", u):
|
|
||||||
out.append("BSW")
|
|
||||||
if re.search(r"LANDESREGIERUNG|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT", u):
|
|
||||||
out.append("Landesregierung")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _build_initial_body(self, start_date: str, end_date: str) -> dict:
|
def _build_initial_body(self, start_date: str, end_date: str) -> dict:
|
||||||
"""Build the first ``SearchAndDisplay`` body with the search component.
|
"""Build the first ``SearchAndDisplay`` body with the search component.
|
||||||
|
|||||||
332
app/parteien.py
Normal file
332
app/parteien.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
"""Zentrale Parteinamen-Auflösung für den GWÖ-Antragsprüfer.
|
||||||
|
|
||||||
|
Single Source of Truth für die Mappings, die heute (vor #55) an mindestens
|
||||||
|
6 Stellen redundant codiert sind:
|
||||||
|
|
||||||
|
- Vier nahezu identische ``_normalize_fraktion()``-Methoden in
|
||||||
|
``app.parlamente`` (PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter,
|
||||||
|
PARLISAdapter)
|
||||||
|
- Der ``partei != "GRÜNE"``-Hack in ``app.embeddings`` Z. ~496
|
||||||
|
- Implizite Annahmen in ``WAHLPROGRAMME``-Keys und ``PROGRAMME``-Metadaten
|
||||||
|
|
||||||
|
Konzept:
|
||||||
|
|
||||||
|
- ``PARTEIEN`` ist eine kuratierte Tabelle (kanonisch + Aliase + optionaler
|
||||||
|
``bundesland_scope`` + langer Anzeigename + Sonderrolle für Regierungs-
|
||||||
|
Strukturen)
|
||||||
|
- ``normalize_partei(raw, *, bundesland=None)`` löst einen einzelnen
|
||||||
|
Roh-String auf den kanonischen Key auf
|
||||||
|
- ``extract_fraktionen(text, *, bundesland=None)`` zerlegt einen freien
|
||||||
|
Urheber-Text (komma-separierte Listen, MdL-mit-Klammerpartei,
|
||||||
|
HTML-Reste) in eine Liste kanonischer Keys — der Funnel für die vier
|
||||||
|
alten Adapter-Helper
|
||||||
|
- ``display_name(canonical, *, long=False)`` liefert die Anzeigeform für
|
||||||
|
UI/PDF/Reports
|
||||||
|
|
||||||
|
Backwards-Kompatibilität: die kanonischen Keys sind exakt die Strings,
|
||||||
|
die heute in der DB stehen ("CDU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE",
|
||||||
|
"BSW", "SSW", "Landesregierung", "FREIE WÄHLER", "BiW", "FW", "CSU"). Das
|
||||||
|
heißt, kein Migrations-Schritt ist nötig — bestehende Assessments und
|
||||||
|
Embeddings bleiben lesbar.
|
||||||
|
|
||||||
|
Die "Freie Wähler"-Disambiguierung (BVB-FW-BB ≠ FW-Bayern ≠ FW-RLP) ist
|
||||||
|
hier dokumentiert und der Mapper trägt sie als Daten — die programmatische
|
||||||
|
Auflösung greift, sobald die jeweiligen Wahlprogramme als separate
|
||||||
|
``PROGRAMME``-Einträge existieren.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Tabelle
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Partei:
|
||||||
|
canonical: str # Lookup-Key, identisch zu DB-Schreibweise
|
||||||
|
display_long: str # Für UI/PDF
|
||||||
|
aliases: tuple[str, ...] # alle bekannten Schreibweisen aus HTML/JSON/LLM
|
||||||
|
bundesland_scope: Optional[str] = None # None = bundesweit; sonst BL-Code
|
||||||
|
is_government: bool = False # für Landesregierung-Marker
|
||||||
|
|
||||||
|
|
||||||
|
# Reihenfolge ist Sortierung — bei mehrdeutigen Aliasen gewinnt der erste
|
||||||
|
# Treffer. Spezifische Einträge (mit ``bundesland_scope``) müssen vor den
|
||||||
|
# generischen stehen, damit ``FREIE WÄHLER`` in BB als BVB-FW erkannt wird
|
||||||
|
# bevor es als generisches FREIE WÄHLER landet.
|
||||||
|
PARTEIEN: tuple[Partei, ...] = (
|
||||||
|
# ── Etablierte Bundesparteien ────────────────────────────────────────
|
||||||
|
Partei("CDU", "CDU", ("CDU", "Christlich Demokratische Union")),
|
||||||
|
Partei("CSU", "CSU",
|
||||||
|
("CSU", "Christlich-Soziale Union", "Christlich Soziale Union"),
|
||||||
|
bundesland_scope="BY"),
|
||||||
|
Partei("SPD", "SPD", ("SPD", "Sozialdemokratische Partei")),
|
||||||
|
Partei("GRÜNE", "BÜNDNIS 90/DIE GRÜNEN",
|
||||||
|
("GRÜNE", "Grüne", "GRUENE", "Gruene",
|
||||||
|
"Bündnis 90/Die Grünen", "BÜNDNIS 90", "B90/Grüne", "Bündnis90")),
|
||||||
|
Partei("FDP", "FDP", ("FDP", "F.D.P.", "F. D. P.", "F.D.P", "FDP-DVP")),
|
||||||
|
Partei("LINKE", "DIE LINKE",
|
||||||
|
("LINKE", "Die Linke", "DIE LINKE", "LL/PDS", "Linkspartei")),
|
||||||
|
Partei("AfD", "AfD",
|
||||||
|
("AfD", "AFD", "Alternative für Deutschland")),
|
||||||
|
Partei("BSW", "BSW",
|
||||||
|
("BSW", "Bündnis Sahra Wagenknecht",
|
||||||
|
"Bündnis Sahra Wagenknecht – Vernunft und Gerechtigkeit")),
|
||||||
|
# ── Bundesland-spezifische Parteien ──────────────────────────────────
|
||||||
|
Partei("SSW", "SSW",
|
||||||
|
("SSW", "Südschleswigscher Wählerverband"),
|
||||||
|
bundesland_scope="SH"),
|
||||||
|
Partei("BiW", "BÜRGER IN WUT",
|
||||||
|
("BiW", "Bürger in Wut", "BIW"),
|
||||||
|
bundesland_scope="HB"),
|
||||||
|
# ── Freie-Wähler-Familie (kontextsensitiv) ───────────────────────────
|
||||||
|
# Reihenfolge: spezifische Scopes zuerst. ``BVB-FW`` ist im BB-Landtag
|
||||||
|
# eine eigenständige Partei (Brandenburger Vereinigte Bürgerbewegung),
|
||||||
|
# programmatisch nicht identisch mit den FW-Landesvereinigungen in BY
|
||||||
|
# oder RP. Solange wir dafür kein eigenes Programm indexieren, bleibt
|
||||||
|
# ``BVB-FW`` ein nominelles Mapping.
|
||||||
|
Partei("BVB-FW", "BVB / FREIE WÄHLER",
|
||||||
|
("BVB", "BVB/FW", "BVB / FREIE WÄHLER", "FREIE WÄHLER", "FW", "Freie Wähler"),
|
||||||
|
bundesland_scope="BB"),
|
||||||
|
Partei("FW-BAYERN", "FREIE WÄHLER Bayern",
|
||||||
|
("FW", "FREIE WÄHLER", "Freie Wähler"),
|
||||||
|
bundesland_scope="BY"),
|
||||||
|
Partei("FW-SL", "Freie Wähler Saarland",
|
||||||
|
("FW", "FREIE WÄHLER", "Freie Wähler"),
|
||||||
|
bundesland_scope="SL"),
|
||||||
|
# Bundesweit-Default für FW (z.B. RP — der Landesverband der
|
||||||
|
# Bundesvereinigung). Letzte Position, damit oben spezifische Scopes
|
||||||
|
# vorrangig matchen.
|
||||||
|
Partei("FREIE WÄHLER", "FREIE WÄHLER",
|
||||||
|
("FW", "FREIE WÄHLER", "Freie Wähler")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Regierung/Verwaltung — keine Partei, aber wir müssen sie aus Urheber-
|
||||||
|
# Texten als Marker extrahieren können (wenn das Ministerium als Antrag-
|
||||||
|
# steller auftaucht, ist das eine Regierungsdrucksache).
|
||||||
|
_GOVERNMENT_MARKER_RE = re.compile(
|
||||||
|
r"LANDESREGIERUNG|SENAT VON BERLIN|REGIERENDE[RN]?\s+BÜRGERMEISTER"
|
||||||
|
r"|\bMINISTER|STAATSKANZLEI|MINISTERPRÄSIDENT",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
GOVERNMENT_KEY = "Landesregierung"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Lookup-Tabellen werden einmalig aus PARTEIEN abgeleitet
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_canonical_index() -> dict[str, Partei]:
|
||||||
|
return {p.canonical: p for p in PARTEIEN}
|
||||||
|
|
||||||
|
|
||||||
|
_CANONICAL_INDEX: dict[str, Partei] = _build_canonical_index()
|
||||||
|
|
||||||
|
|
||||||
|
def all_canonical_keys() -> list[str]:
|
||||||
|
"""Alle bekannten kanonischen Partei-Keys + Government-Marker."""
|
||||||
|
return [p.canonical for p in PARTEIEN] + [GOVERNMENT_KEY]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Regex-Patterns pro Alias — vorab kompiliert für die Volltext-Extraktion
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _alias_to_pattern(alias: str) -> re.Pattern[str]:
|
||||||
|
"""Konvertiert einen Alias in eine fallunabhängige Wortgrenzen-Regex.
|
||||||
|
|
||||||
|
Punkte und Schrägstriche werden escaped, dazwischen optional Whitespace
|
||||||
|
erlaubt — ``F.D.P.`` matched dann ``F. D. P.``, ``FDP``, ``F.D.P``.
|
||||||
|
Letzteres ist die historische SH/HB-Schreibweise. ``LL/PDS`` matched
|
||||||
|
sich selbst und nichts anderes.
|
||||||
|
"""
|
||||||
|
# Escape, dann Whitespace zwischen einzelnen Tokens flexibilisieren
|
||||||
|
escaped = re.escape(alias)
|
||||||
|
# Punkte: optional, Whitespace daneben optional
|
||||||
|
flex = escaped.replace(r"\.", r"\.?\s*")
|
||||||
|
return re.compile(rf"(?<![A-Za-zÄÖÜäöüß]){flex}(?![A-Za-zÄÖÜäöüß])", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CompiledAlias:
|
||||||
|
canonical: str
|
||||||
|
bundesland_scope: Optional[str]
|
||||||
|
pattern: re.Pattern[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_compiled_aliases() -> list[_CompiledAlias]:
|
||||||
|
out: list[_CompiledAlias] = []
|
||||||
|
for p in PARTEIEN:
|
||||||
|
for alias in p.aliases:
|
||||||
|
out.append(_CompiledAlias(
|
||||||
|
canonical=p.canonical,
|
||||||
|
bundesland_scope=p.bundesland_scope,
|
||||||
|
pattern=_alias_to_pattern(alias),
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_COMPILED_ALIASES: list[_CompiledAlias] = _build_compiled_aliases()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Public API
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_partei(raw: str, *, bundesland: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Lese einen einzelnen Roh-String → kanonischer Key.
|
||||||
|
|
||||||
|
``bundesland`` ist Pflicht, wenn der Roh-String nur durch Bundesland
|
||||||
|
disambiguiert werden kann (z.B. ``"FREIE WÄHLER"`` → BVB-FW in BB,
|
||||||
|
FW-BAYERN in BY, FREIE WÄHLER in RP). Bei generischen Strings wie
|
||||||
|
``"CDU"`` ist ``bundesland`` egal.
|
||||||
|
|
||||||
|
Returns ``None`` wenn nichts matched — der Caller entscheidet, ob
|
||||||
|
das ein Skip oder ein Hard-Fail ist.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Government-Marker zuerst, weil "Ministerium der Finanzen" weder
|
||||||
|
# Partei-Alias ist noch eines werden soll
|
||||||
|
if _GOVERNMENT_MARKER_RE.search(raw):
|
||||||
|
return GOVERNMENT_KEY
|
||||||
|
|
||||||
|
# Suche alle Treffer; bei mehreren wähle den, der zum Bundesland-Scope
|
||||||
|
# passt (oder den ersten generischen wenn keiner spezifisch passt).
|
||||||
|
candidates: list[_CompiledAlias] = []
|
||||||
|
for ca in _COMPILED_ALIASES:
|
||||||
|
if ca.pattern.search(raw):
|
||||||
|
candidates.append(ca)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if bundesland:
|
||||||
|
# Erst spezifischer Match
|
||||||
|
for ca in candidates:
|
||||||
|
if ca.bundesland_scope == bundesland:
|
||||||
|
return ca.canonical
|
||||||
|
# Sonst erster generischer (bundesland_scope is None)
|
||||||
|
for ca in candidates:
|
||||||
|
if ca.bundesland_scope is None:
|
||||||
|
return ca.canonical
|
||||||
|
# Fallback: erster überhaupt — kann passieren bei BL-spezifischer
|
||||||
|
# Partei in falschem BL (z.B. "SSW" in einem MV-Antrag, was Unsinn
|
||||||
|
# wäre, aber wir liefern dann SSW zurück und der Caller logged es)
|
||||||
|
return candidates[0].canonical
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fraktionen(text: str, *, bundesland: Optional[str] = None) -> list[str]:
|
||||||
|
"""Zerlege einen freien Urheber-Text in eine Liste kanonischer Keys.
|
||||||
|
|
||||||
|
Ersetzt die vier ``_normalize_fraktion()``-Methoden der Adapter
|
||||||
|
(PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter).
|
||||||
|
Findet alle Partei-Aliase im Text, dedupliziert, behält die Reihenfolge
|
||||||
|
des ersten Vorkommens.
|
||||||
|
|
||||||
|
``bundesland`` ist nötig, damit FW-Familien-Disambiguierung greift —
|
||||||
|
eine BB-Drucksache mit Urheber ``"FREIE WÄHLER"`` wird zu ``"BVB-FW"``,
|
||||||
|
eine BY-Drucksache zu ``"FW-BAYERN"``, eine RP-Drucksache bleibt
|
||||||
|
``"FREIE WÄHLER"``.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
# Government-Marker als erstes prüfen — wenn es ein Ministerium ist,
|
||||||
|
# überspringen wir Parteien-Matching nicht (z.B. "Antrag der Fraktion
|
||||||
|
# CDU und der Landesregierung" enthält beide), aber Landesregierung
|
||||||
|
# wird jedenfalls aufgenommen.
|
||||||
|
if _GOVERNMENT_MARKER_RE.search(text):
|
||||||
|
out.append(GOVERNMENT_KEY)
|
||||||
|
seen.add(GOVERNMENT_KEY)
|
||||||
|
|
||||||
|
# Pro Alias prüfen und mit Bundesland-Scope-Vorrang sortieren.
|
||||||
|
# Strategie: pro Partei-Familie wählen wir den passendsten Scope.
|
||||||
|
matches_by_canonical: dict[str, list[_CompiledAlias]] = {}
|
||||||
|
for ca in _COMPILED_ALIASES:
|
||||||
|
if ca.pattern.search(text):
|
||||||
|
matches_by_canonical.setdefault(ca.canonical, []).append(ca)
|
||||||
|
|
||||||
|
# Für jede Partei: wähle die richtige Scope-Variante. FW-Familie ist
|
||||||
|
# der Spezialfall — alle vier Einträge (BVB-FW/FW-BAYERN/FW-SL/
|
||||||
|
# FREIE WÄHLER) haben überlappende Aliase, aber nur einer soll am
|
||||||
|
# Ende im Output landen. Wir gruppieren über die Aliase und wählen
|
||||||
|
# nach Bundesland.
|
||||||
|
fw_aliases = {"FW", "FREIE WÄHLER", "Freie Wähler"}
|
||||||
|
fw_family: list[str] = []
|
||||||
|
for canonical, aliases in matches_by_canonical.items():
|
||||||
|
if any(a in fw_aliases for ca in aliases for a in [ca.pattern.pattern]):
|
||||||
|
# Approximation — wir wissen, dass alle FW-Familien-Patterns
|
||||||
|
# auf die gleichen Strings matchen
|
||||||
|
fw_family.append(canonical)
|
||||||
|
|
||||||
|
# Tatsächliche FW-Familien-Detektion: schauen, welche der Partei-Keys
|
||||||
|
# zur FW-Familie gehören (statisch)
|
||||||
|
FW_CANONICAL_FAMILY = {"BVB-FW", "FW-BAYERN", "FW-SL", "FREIE WÄHLER"}
|
||||||
|
fw_in_match = FW_CANONICAL_FAMILY & set(matches_by_canonical.keys())
|
||||||
|
if fw_in_match:
|
||||||
|
# Wähle den passenden FW-Eintrag nach Bundesland
|
||||||
|
chosen_fw: Optional[str] = None
|
||||||
|
if bundesland:
|
||||||
|
for ca in PARTEIEN:
|
||||||
|
if ca.canonical in fw_in_match and ca.bundesland_scope == bundesland:
|
||||||
|
chosen_fw = ca.canonical
|
||||||
|
break
|
||||||
|
if not chosen_fw:
|
||||||
|
# generischer Fallback (bundesland_scope is None)
|
||||||
|
for ca in PARTEIEN:
|
||||||
|
if ca.canonical in fw_in_match and ca.bundesland_scope is None:
|
||||||
|
chosen_fw = ca.canonical
|
||||||
|
break
|
||||||
|
if not chosen_fw:
|
||||||
|
# Notfall: ersten nehmen
|
||||||
|
chosen_fw = sorted(fw_in_match)[0]
|
||||||
|
if chosen_fw not in seen:
|
||||||
|
out.append(chosen_fw)
|
||||||
|
seen.add(chosen_fw)
|
||||||
|
# Andere FW-Familien-Mitglieder aus dem Match-Dict entfernen
|
||||||
|
for k in list(matches_by_canonical.keys()):
|
||||||
|
if k in FW_CANONICAL_FAMILY and k != chosen_fw:
|
||||||
|
del matches_by_canonical[k]
|
||||||
|
|
||||||
|
# Verbleibende Parteien in der Reihenfolge ihrer Tabellen-Position
|
||||||
|
for p in PARTEIEN:
|
||||||
|
if p.canonical in matches_by_canonical and p.canonical not in seen:
|
||||||
|
# Bundesland-Scope-Filter: BL-spezifische Parteien dürfen nur
|
||||||
|
# auftauchen, wenn der Antrag aus diesem BL stammt (oder kein
|
||||||
|
# BL angegeben wurde — dann tolerant)
|
||||||
|
if p.bundesland_scope is not None and bundesland is not None:
|
||||||
|
if p.bundesland_scope != bundesland:
|
||||||
|
continue
|
||||||
|
out.append(p.canonical)
|
||||||
|
seen.add(p.canonical)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(canonical: str, *, long: bool = False) -> str:
|
||||||
|
"""Render einen kanonischen Key für Anzeige in UI/PDF/Reports.
|
||||||
|
|
||||||
|
Mit ``long=True`` der lange offizielle Name (z.B. ``"BÜNDNIS 90/DIE
|
||||||
|
GRÜNEN"`` für ``"GRÜNE"``), sonst der kanonische Key selbst (kurz
|
||||||
|
und vertraut).
|
||||||
|
"""
|
||||||
|
if canonical == GOVERNMENT_KEY:
|
||||||
|
return "Landesregierung"
|
||||||
|
p = _CANONICAL_INDEX.get(canonical)
|
||||||
|
if p is None:
|
||||||
|
return canonical # unbekannt — Pass-Through statt Fehler
|
||||||
|
return p.display_long if long else p.canonical
|
||||||
@ -183,32 +183,46 @@ class TestPortalaAdapterAutoDetect:
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# PortalaAdapter._normalize_fraktion — canonical fraction codes
|
# Adapter._normalize_fraktion — Roundtrip-Test über eine echte Instanz
|
||||||
|
#
|
||||||
|
# Die ausführliche Pattern-Sammlung lebt nach #55 in tests/test_parteien.py.
|
||||||
|
# Hier verifizieren wir nur, dass der Adapter-Shim die zentrale Funktion
|
||||||
|
# tatsächlich aufruft und das Bundesland korrekt durchreicht.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestPortalaAdapterNormalizeFraktion:
|
class TestAdapterNormalizeFraktionRoundtrip:
|
||||||
def test_comma_separated_list(self):
|
def test_portala_lsa_adapter_instance(self):
|
||||||
out = PortalaAdapter._normalize_fraktion("CDU, SPD, F.D.P.")
|
adapter = _make_lsa_adapter()
|
||||||
assert "CDU" in out and "SPD" in out and "FDP" in out
|
assert "CDU" in adapter._normalize_fraktion("CDU")
|
||||||
|
assert adapter._normalize_fraktion("BÜNDNIS 90/DIE GRÜNEN") == ["GRÜNE"]
|
||||||
|
|
||||||
def test_buendnis_90_die_gruenen(self):
|
def test_portala_be_adapter_instance(self):
|
||||||
out = PortalaAdapter._normalize_fraktion("BÜNDNIS 90/DIE GRÜNEN")
|
adapter = _make_be_adapter()
|
||||||
assert out == ["GRÜNE"]
|
out = adapter._normalize_fraktion("Senat von Berlin")
|
||||||
|
|
||||||
def test_die_linke(self):
|
|
||||||
out = PortalaAdapter._normalize_fraktion("DIE LINKE")
|
|
||||||
assert out == ["LINKE"]
|
|
||||||
|
|
||||||
def test_bsw(self):
|
|
||||||
out = PortalaAdapter._normalize_fraktion("BSW")
|
|
||||||
assert out == ["BSW"]
|
|
||||||
|
|
||||||
def test_landesregierung_keywords(self):
|
|
||||||
out = PortalaAdapter._normalize_fraktion("Senat von Berlin")
|
|
||||||
assert "Landesregierung" in out
|
assert "Landesregierung" in out
|
||||||
|
|
||||||
def test_empty_string(self):
|
def test_empty_string(self):
|
||||||
assert PortalaAdapter._normalize_fraktion("") == []
|
adapter = _make_lsa_adapter()
|
||||||
|
assert adapter._normalize_fraktion("") == []
|
||||||
|
|
||||||
|
def test_freie_waehler_disambiguates_by_adapter_bundesland(self):
|
||||||
|
# BB-Adapter → BVB-FW, RP-Adapter → FREIE WÄHLER. Das ist der
|
||||||
|
# eigentliche Mehrwert von #55, hier roundtripped via Adapter.
|
||||||
|
from app.parlamente import PortalaAdapter
|
||||||
|
bb = PortalaAdapter(
|
||||||
|
bundesland="BB", name="test BB",
|
||||||
|
base_url="https://www.parlamentsdokumentation.brandenburg.de",
|
||||||
|
db_id="lap.lap8", wahlperiode=8,
|
||||||
|
portala_path="/portal", document_type="Antrag",
|
||||||
|
)
|
||||||
|
rp = PortalaAdapter(
|
||||||
|
bundesland="RP", name="test RP",
|
||||||
|
base_url="https://opal.rlp.de",
|
||||||
|
db_id="rlp.opal", wahlperiode=18,
|
||||||
|
portala_path="/portal", document_type="Antrag",
|
||||||
|
)
|
||||||
|
assert bb._normalize_fraktion("FREIE WÄHLER") == ["BVB-FW"]
|
||||||
|
assert rp._normalize_fraktion("FREIE WÄHLER") == ["FREIE WÄHLER"]
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
241
tests/test_parteien.py
Normal file
241
tests/test_parteien.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"""Tests für app.parteien — den zentralen Parteinamen-Mapper.
|
||||||
|
|
||||||
|
Issue #55 + Roadmap #59 Phase B. Drei Schichten:
|
||||||
|
|
||||||
|
1. ``normalize_partei`` — single-string lookup (für Wahlprogramm-/
|
||||||
|
Embedding-Lookups, wo wir nur einen Namen haben)
|
||||||
|
2. ``extract_fraktionen`` — komma-separierte Listen, MdL-mit-Klammerpartei,
|
||||||
|
HTML-Reste; ersetzt die vier ``_normalize_fraktion()``-Methoden
|
||||||
|
3. ``display_name`` — Anzeigeform (kurz/lang)
|
||||||
|
|
||||||
|
Plus Roundtrip-Tests gegen die historisch in den Adaptern erkannten
|
||||||
|
Patterns (F.D.P., LL/PDS, BÜNDNIS 90, MINISTERIUM, etc.) damit der
|
||||||
|
Refactor garantiert nichts kaputt macht.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.parteien import (
|
||||||
|
GOVERNMENT_KEY,
|
||||||
|
PARTEIEN,
|
||||||
|
all_canonical_keys,
|
||||||
|
display_name,
|
||||||
|
extract_fraktionen,
|
||||||
|
normalize_partei,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# normalize_partei — single-string lookup
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizePartei:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("CDU", "CDU"),
|
||||||
|
("SPD", "SPD"),
|
||||||
|
("FDP", "FDP"),
|
||||||
|
("F.D.P.", "FDP"),
|
||||||
|
("F. D. P.", "FDP"),
|
||||||
|
("AfD", "AfD"),
|
||||||
|
("AFD", "AfD"),
|
||||||
|
("Alternative für Deutschland", "AfD"),
|
||||||
|
("GRÜNE", "GRÜNE"),
|
||||||
|
("Grüne", "GRÜNE"),
|
||||||
|
("GRUENE", "GRÜNE"),
|
||||||
|
("Bündnis 90/Die Grünen", "GRÜNE"),
|
||||||
|
("LINKE", "LINKE"),
|
||||||
|
("Die Linke", "LINKE"),
|
||||||
|
("DIE LINKE", "LINKE"),
|
||||||
|
("LL/PDS", "LINKE"),
|
||||||
|
("BSW", "BSW"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_canonical_keys_for_known_aliases(self, raw, expected):
|
||||||
|
assert normalize_partei(raw) == expected
|
||||||
|
|
||||||
|
def test_unknown_returns_none(self):
|
||||||
|
assert normalize_partei("Pirateneinhornpartei XYZ") is None
|
||||||
|
|
||||||
|
def test_empty_returns_none(self):
|
||||||
|
assert normalize_partei("") is None
|
||||||
|
assert normalize_partei(None) is None # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_government_marker_takes_precedence(self):
|
||||||
|
# "Antrag der Landesregierung" → Government, nicht eine zufällig
|
||||||
|
# erkannte Partei aus dem Rest des Strings.
|
||||||
|
assert normalize_partei("Antrag der Landesregierung") == GOVERNMENT_KEY
|
||||||
|
assert normalize_partei("Ministerium der Finanzen") == GOVERNMENT_KEY
|
||||||
|
assert normalize_partei("Senat von Berlin") == GOVERNMENT_KEY
|
||||||
|
|
||||||
|
def test_ssw_only_in_sh(self):
|
||||||
|
# SSW ist BL-spezifisch — bei explizit SH klappt es, ohne BL
|
||||||
|
# akzeptieren wir es auch (tolerant) — die strenge BL-Constraint
|
||||||
|
# gilt nur bei extract_fraktionen.
|
||||||
|
assert normalize_partei("SSW", bundesland="SH") == "SSW"
|
||||||
|
|
||||||
|
def test_csu_only_in_by(self):
|
||||||
|
assert normalize_partei("CSU", bundesland="BY") == "CSU"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Freie-Wähler-Disambiguierung — der eigentliche Mehrwert des Mappers
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreieWaehlerDisambiguation:
|
||||||
|
def test_freie_waehler_in_brandenburg_resolves_to_bvb(self):
|
||||||
|
assert normalize_partei("FREIE WÄHLER", bundesland="BB") == "BVB-FW"
|
||||||
|
|
||||||
|
def test_freie_waehler_in_bayern_resolves_to_fw_bayern(self):
|
||||||
|
assert normalize_partei("FREIE WÄHLER", bundesland="BY") == "FW-BAYERN"
|
||||||
|
|
||||||
|
def test_freie_waehler_in_saarland_resolves_to_fw_sl(self):
|
||||||
|
assert normalize_partei("FREIE WÄHLER", bundesland="SL") == "FW-SL"
|
||||||
|
|
||||||
|
def test_freie_waehler_in_rlp_resolves_to_generic(self):
|
||||||
|
# In RP gehört der Landesverband zur Bundesvereinigung — der ist
|
||||||
|
# der generische "FREIE WÄHLER"-Eintrag (kein Scope).
|
||||||
|
assert normalize_partei("FREIE WÄHLER", bundesland="RP") == "FREIE WÄHLER"
|
||||||
|
|
||||||
|
def test_fw_kuerzel_disambiguates(self):
|
||||||
|
assert normalize_partei("FW", bundesland="BY") == "FW-BAYERN"
|
||||||
|
assert normalize_partei("FW", bundesland="BB") == "BVB-FW"
|
||||||
|
|
||||||
|
def test_freie_waehler_without_bundesland_falls_back_to_generic(self):
|
||||||
|
# Kein BL → wir können nicht disambiguieren, der generische
|
||||||
|
# Eintrag bleibt der Default
|
||||||
|
assert normalize_partei("FREIE WÄHLER") == "FREIE WÄHLER"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# extract_fraktionen — der Adapter-Funnel
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractFraktionen:
|
||||||
|
def test_single_simple(self):
|
||||||
|
assert extract_fraktionen("CDU") == ["CDU"]
|
||||||
|
|
||||||
|
def test_comma_separated(self):
|
||||||
|
assert set(extract_fraktionen("CDU, SPD, FDP")) == {"CDU", "SPD", "FDP"}
|
||||||
|
|
||||||
|
def test_dedupe(self):
|
||||||
|
# CDU zweimal im Text → nur einmal im Output
|
||||||
|
out = extract_fraktionen("CDU und CDU")
|
||||||
|
assert out == ["CDU"]
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert extract_fraktionen("") == []
|
||||||
|
assert extract_fraktionen(None) == [] # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_mdl_with_paren_party(self):
|
||||||
|
# ParlDok-Stil: "Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)"
|
||||||
|
out = extract_fraktionen("Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)")
|
||||||
|
assert out == ["GRÜNE"]
|
||||||
|
|
||||||
|
def test_mixed_mdl_and_government(self):
|
||||||
|
out = extract_fraktionen("Antrag der Fraktion CDU und der Landesregierung")
|
||||||
|
assert "CDU" in out
|
||||||
|
assert GOVERNMENT_KEY in out
|
||||||
|
|
||||||
|
def test_fdp_with_dots(self):
|
||||||
|
assert extract_fraktionen("F.D.P.") == ["FDP"]
|
||||||
|
assert extract_fraktionen("Antrag der F.D.P.-Fraktion") == ["FDP"]
|
||||||
|
|
||||||
|
def test_bsw(self):
|
||||||
|
assert extract_fraktionen("BSW") == ["BSW"]
|
||||||
|
|
||||||
|
def test_linke_via_ll_pds_legacy(self):
|
||||||
|
# Historische ParlDok-Schreibweise
|
||||||
|
assert extract_fraktionen("LL/PDS") == ["LINKE"]
|
||||||
|
|
||||||
|
def test_gruene_via_buendnis_90(self):
|
||||||
|
assert extract_fraktionen("BÜNDNIS 90/DIE GRÜNEN") == ["GRÜNE"]
|
||||||
|
|
||||||
|
def test_ministerium_marker(self):
|
||||||
|
assert extract_fraktionen("Ministerium der Finanzen") == [GOVERNMENT_KEY]
|
||||||
|
|
||||||
|
def test_real_th_urheber(self):
|
||||||
|
# TH liefert oft "Antrag der Fraktion der CDU"
|
||||||
|
assert extract_fraktionen("Antrag der Fraktion der CDU") == ["CDU"]
|
||||||
|
|
||||||
|
def test_ssw_only_when_in_sh_context(self):
|
||||||
|
# SH-spezifisch — in einem SH-Antrag taucht SSW auf
|
||||||
|
assert extract_fraktionen("Christian Dirschauer (SSW)", bundesland="SH") == ["SSW"]
|
||||||
|
|
||||||
|
def test_ssw_filtered_in_other_bl(self):
|
||||||
|
# In einem MV-Antrag würde "SSW" nichts ergeben — BL-Filter greift
|
||||||
|
assert extract_fraktionen("SSW Beispiel", bundesland="MV") == []
|
||||||
|
|
||||||
|
def test_freie_waehler_bb_resolves_to_bvb_in_extract(self):
|
||||||
|
# BB-Drucksache der "FREIE WÄHLER"-Fraktion → BVB-FW
|
||||||
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="BB")
|
||||||
|
assert out == ["BVB-FW"]
|
||||||
|
|
||||||
|
def test_freie_waehler_rp_resolves_to_generic(self):
|
||||||
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="RP")
|
||||||
|
assert out == ["FREIE WÄHLER"]
|
||||||
|
|
||||||
|
def test_freie_waehler_by(self):
|
||||||
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="BY")
|
||||||
|
assert out == ["FW-BAYERN"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# display_name
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayName:
|
||||||
|
def test_short_default(self):
|
||||||
|
assert display_name("CDU") == "CDU"
|
||||||
|
assert display_name("GRÜNE") == "GRÜNE"
|
||||||
|
|
||||||
|
def test_long_form(self):
|
||||||
|
assert display_name("GRÜNE", long=True) == "BÜNDNIS 90/DIE GRÜNEN"
|
||||||
|
assert display_name("LINKE", long=True) == "DIE LINKE"
|
||||||
|
assert display_name("BiW", long=True) == "BÜRGER IN WUT"
|
||||||
|
|
||||||
|
def test_government(self):
|
||||||
|
assert display_name(GOVERNMENT_KEY) == "Landesregierung"
|
||||||
|
|
||||||
|
def test_unknown_passthrough(self):
|
||||||
|
# Unbekannt — kein Fehler, einfach durchreichen
|
||||||
|
assert display_name("UnknownPartyXYZ") == "UnknownPartyXYZ"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Tabellen-Konsistenz
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableConsistency:
|
||||||
|
def test_no_duplicate_canonical_keys(self):
|
||||||
|
keys = [p.canonical for p in PARTEIEN]
|
||||||
|
assert len(keys) == len(set(keys)), f"Duplikate: {[k for k in keys if keys.count(k) > 1]}"
|
||||||
|
|
||||||
|
def test_all_canonical_keys_resolvable_by_themselves(self):
|
||||||
|
# Jeder kanonische Key muss als Roh-Eingabe wieder auf sich selbst
|
||||||
|
# auflösen — das ist die Identitäts-Invariante des Mappers
|
||||||
|
for p in PARTEIEN:
|
||||||
|
if p.bundesland_scope:
|
||||||
|
# Disambiguierungs-Einträge brauchen ihren Scope mitgegeben
|
||||||
|
assert normalize_partei(p.canonical, bundesland=p.bundesland_scope) in {
|
||||||
|
p.canonical,
|
||||||
|
# FW-BAYERN/FW-SL/BVB-FW haben ihren canonical-Key nicht
|
||||||
|
# als alias drin — er ist nur ein interner Schlüssel.
|
||||||
|
# Sie matchen über die FW-Aliase.
|
||||||
|
"FREIE WÄHLER", "FW-BAYERN", "FW-SL", "BVB-FW",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
assert normalize_partei(p.canonical) == p.canonical
|
||||||
|
|
||||||
|
def test_all_canonical_keys_helper_includes_government(self):
|
||||||
|
keys = all_canonical_keys()
|
||||||
|
assert GOVERNMENT_KEY in keys
|
||||||
|
assert "CDU" in keys
|
||||||
|
assert "GRÜNE" in keys
|
||||||
Loading…
Reference in New Issue
Block a user