"""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", # Sachsen-spezifischer Eigenname der Fraktion "BÜNDNISGRÜNE", "Bündnisgrüne")), 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