diff --git a/app/embeddings.py b/app/embeddings.py index d59088d..c8cfd26 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -492,13 +492,22 @@ def get_relevant_quotes_for_antrag( results = {} + from .parteien import normalize_partei + 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 wahl_chunks = find_relevant_chunks( antrag_text, - parteien=[partei_upper], + parteien=[partei_lookup], typ="wahlprogramm", bundesland=bundesland, top_k=top_k_per_partei, @@ -508,7 +517,7 @@ def get_relevant_quotes_for_antrag( # Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit) partei_chunks = find_relevant_chunks( antrag_text, - parteien=[partei_upper], + parteien=[partei_lookup], typ="parteiprogramm", bundesland=bundesland, top_k=top_k_per_partei, diff --git a/app/parlamente.py b/app/parlamente.py index ab9651d..063b764 100644 --- a/app/parlamente.py +++ b/app/parlamente.py @@ -438,41 +438,14 @@ class PortalaAdapter(ParlamentAdapter): """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) - @staticmethod - def _normalize_fraktion(urheber: str) -> list[str]: - """Map Urheber-String to canonical fraction codes. - - Uses regex word boundaries instead of plain substring matching so - that comma-separated lists ("CDU, SPD") and the embedded "DIE - LINKE" are matched reliably. + def _normalize_fraktion(self, urheber: str) -> list[str]: + """Thin shim — die ganze Regex-Logik lebt jetzt zentral in + ``app.parteien.extract_fraktionen`` (siehe #55). ``self.bundesland`` + wird mitgegeben, damit FW-Familien-Aliase korrekt disambiguiert + werden. """ - u = urheber.upper() - out: list[str] = [] - - 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 + from .parteien import extract_fraktionen + return extract_fraktionen(urheber, bundesland=self.bundesland) def _build_search_body( self, @@ -972,39 +945,10 @@ class ParLDokAdapter(ParlamentAdapter): except ValueError: return "" - @staticmethod - def _normalize_fraktion(authorhtml: str) -> list[str]: - """Map ParlDok ``authorhtml`` to canonical fraction codes. - - ``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 + def _normalize_fraktion(self, authorhtml: str) -> list[str]: + """Thin shim — siehe ``app.parteien.extract_fraktionen``. #55.""" + from .parteien import extract_fraktionen + return extract_fraktionen(authorhtml, bundesland=self.bundesland) @staticmethod def _fulltext_id(term: str) -> str: @@ -1356,33 +1300,14 @@ class StarFinderCGIAdapter(ParlamentAdapter): except ValueError: return "" - @staticmethod - def _normalize_fraktion(text: str) -> list[str]: - """SH format: 'Christian Dirschauer (SSW), Jette Waldinger-Thiering (SSW)'. + def _normalize_fraktion(self, text: str) -> list[str]: + """Thin shim — siehe ``app.parteien.extract_fraktionen``. #55. - 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: - return [] - 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 + from .parteien import extract_fraktionen + return extract_fraktionen(text, bundesland=self.bundesland) def _build_url(self) -> str: """Build the Starfinder URL for the structural WP+dtyp browse. @@ -1619,37 +1544,14 @@ class PARLISAdapter(ParlamentAdapter): except ValueError: return "" - @staticmethod - def _normalize_fraktion(text: str) -> list[str]: - """Map a free-text Urheber line to canonical fraction codes. + def _normalize_fraktion(self, text: str) -> list[str]: + """Thin shim — siehe ``app.parteien.extract_fraktionen``. #55. - PARLIS packs the originator into ``EWBV23`` like - ``"Antrag Felix Herkens (GRÜNE), Saskia Frank (GRÜNE), ... 16.03.2026"`` - — multiple MdLs with their party in parentheses, comma-separated. - Same logic as ``ParLDokAdapter._normalize_fraktion`` (#46 fixed - the MINISTER/MINISTERIUM regex there too). + PARLIS packt den Originator in ``EWBV23`` wie + ``"Antrag Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)..."``. """ - if not text: - return [] - 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 + from .parteien import extract_fraktionen + return extract_fraktionen(text, bundesland=self.bundesland) def _build_initial_body(self, start_date: str, end_date: str) -> dict: """Build the first ``SearchAndDisplay`` body with the search component. diff --git a/app/parteien.py b/app/parteien.py new file mode 100644 index 0000000..0ea077b --- /dev/null +++ b/app/parteien.py @@ -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"(? 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 diff --git a/tests/test_parlamente.py b/tests/test_parlamente.py index 75c0ad6..c54beb9 100644 --- a/tests/test_parlamente.py +++ b/tests/test_parlamente.py @@ -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: - def test_comma_separated_list(self): - out = PortalaAdapter._normalize_fraktion("CDU, SPD, F.D.P.") - assert "CDU" in out and "SPD" in out and "FDP" in out +class TestAdapterNormalizeFraktionRoundtrip: + def test_portala_lsa_adapter_instance(self): + adapter = _make_lsa_adapter() + 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): - out = PortalaAdapter._normalize_fraktion("BÜNDNIS 90/DIE GRÜNEN") - assert out == ["GRÜNE"] - - 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") + def test_portala_be_adapter_instance(self): + adapter = _make_be_adapter() + out = adapter._normalize_fraktion("Senat von Berlin") assert "Landesregierung" in out 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"] # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_parteien.py b/tests/test_parteien.py new file mode 100644 index 0000000..47c80bc --- /dev/null +++ b/tests/test_parteien.py @@ -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