gwoe-antragspruefer/app/parteien.py
Dotty Dotter eb045d0ed3 Phase B: Parteinamen-Mapper #55 (Roadmap #59)
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>
2026-04-09 11:22:13 +02:00

333 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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