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
|
|
|
|
"""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",
|
Phase J: SN EDAS-XML-Adapter (#26/#38) — Sachsen aktiv via XML-Export
Reaktiviert die in Phase J vertagte Adapter-Implementation: statt
ASP.NET-Postbacks zu simulieren (blockt durch __VIEWSTATE-Komplexität
plus robots.txt: Disallow: /), liest die neue ``SNEdasXmlAdapter``-
Klasse einen wöchentlich manuell aus EDAS exportierten XML-Dump.
Workflow:
1. User exportiert in der EDAS-Suchmaske mit Filter "Dokumententyp =
Antr" einen XML-Dump (bis zu 2500 Treffer/Export, sortiert
newest-first nach Datum)
2. Datei wird unter ``data/sn-edas-export.xml`` abgelegt (ins
persistent volume des prod-containers)
3. ``search()``/``get_document()`` lesen die XML-Datei lokal — keine
Server-Calls gegen edas.landtag.sachsen.de
4. ``download_text()`` resolved die echte PDF-URL on-demand über einen
einzelnen GET gegen ``viewer_navigation.aspx`` (single GET, kein
Postback) und holt dann das PDF von ``ws.landtag.sachsen.de/images``
XML-Schema (ISO-8859-1):
- ``<ID>`` interne EDAS-Doc-ID
- ``<Wahlperiode>``, ``<Dokumentenart>``, ``<Dokumentennummer>``
- ``<Fundstelle>`` z.B. ``"Antr CDU, BSW, SPD 01.10.2024 Drs 8/2"`` —
enthält Typ, Urheber und Datum, parsen via Regex
- ``<Titel>`` Volltext-Titel
PDF-URL-Schema (extrahiert aus dem viewer_navigation.aspx onLoad-
Handler): ``ws.landtag.sachsen.de/images/{wp}_Drs_{nr}_{...}.pdf``
mit variablen Suffix-Komponenten — wir machen die Resolution lazy.
Mapper-Erweiterung:
- ``parteien.PARTEIEN``-Tabelle um ``BÜNDNISGRÜNE``/``Bündnisgrüne``
ergänzt — der Sachsen-spezifische zusammengeschriebene Eigenname der
GRÜNEN-Fraktion (sonst wären 8/2100 etc. mit leerer Fraktionen-Liste
rausgekommen)
BL-Eintrag:
- ``SN.aktiv = True``
- ``doku_system="EDAS-XML-Export"`` (klare Klassifikation, dass es
KEIN normaler Webcrawler ist)
- Test ``test_sn_is_eigensystem_not_parldok`` umbenannt in
``test_sn_uses_xml_export_not_parldok``
Live-Probe lokal:
```
search('Klima', limit=5):
8/2100 2025-03-17 | [GRÜNE] | Fahrradoffensive Sachsen ...
7/192 2019-10-11 | [LINKE] | Erste Schritte zur Klimager...
7/2067 2020-03-19 | [CDU, SPD, GRÜNE] | Sächsische Waldbesitzer ...
```
176 Unit-Tests grün. Container braucht beim Deploy einen XML-Upload
ins data/-Volume — separater scp-Schritt.
Refs: #26, #38, #59 (Phase J revived)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:39:03 +02:00
|
|
|
|
"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")),
|
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
|
|
|
|
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
|