gwoe-antragspruefer/app/parteien.py
Dotty Dotter 19e5fe4691 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

335 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",
# 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"(?<![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