"""Detail-Marker fuer Stimmverhalten × GWÖ. Zwei Funktionen, die im Antrag-Detail-Template aus Jinja heraus aufgerufen werden: - ``heuchelei_score``: liefert den Wahlprogramm-Score einer Fraktion, wenn er ≥ ``threshold`` ist und damit den ⚠-Indikator triggert. - ``decisive_outcome``: bestimmt das erste „definitive" Outcome aus einer Liste von Plenum-Votes (Überweisung/zurückgezogen/sammel übersprungen, ``angenommen|abgelehnt|bestätigt`` priorisiert). - ``consistency_state``: vergleicht GWÖ-Empfehlung mit dem definitiven Outcome und liefert ``"conflict"``, ``"aligned"`` oder ``None``. Die Logik soll testbar sein, ohne Jinja zu rendern. Daher reine Funktionen ohne Seiteneffekte. Siehe ADR 0010 (Stimmverhalten × GWÖ-Aggregat) für den Kontext. """ from __future__ import annotations from typing import Iterable, Optional DECISIVE_OUTCOMES = frozenset({"angenommen", "abgelehnt", "bestätigt"}) def heuchelei_score( fraktion: str, fraktions_scores: Optional[Iterable[dict]], threshold: float = 7.0, ) -> Optional[float]: """Wahlprogramm-Score einer Fraktion, falls ≥ threshold; sonst None. ``fraktions_scores`` ist die Liste, die ``_row_to_detail`` ans Template hängt — jeder Eintrag hat ``fraktion`` (str) und ``wahlprogramm.score`` (float|int). Wenn die Fraktion nicht gefunden wird oder der Score unter dem Schwellwert liegt, wird ``None`` zurückgegeben — das Template rendert dann keinen ⚠-Marker. """ if not fraktions_scores: return None for fs in fraktions_scores: if fs.get("fraktion") == fraktion: wp = fs.get("wahlprogramm") or {} score = wp.get("score") if score is None: return None try: score_f = float(score) except (TypeError, ValueError): return None return score_f if score_f >= threshold else None return None def decisive_outcome(plenum_votes: Optional[Iterable[dict]]) -> Optional[str]: """Erstes definitives Outcome aus einer Liste von Plenum-Votes. Bei mehreren Votes (Überweisung → Endabstimmung) wird das erste Vote zurückgegeben, dessen ``ergebnis`` (case-insensitiv) in ``DECISIVE_OUTCOMES`` liegt. ``None`` wenn keines vorhanden. """ if not plenum_votes: return None for v in plenum_votes: ergebnis = (v.get("ergebnis") or "").lower() if ergebnis in DECISIVE_OUTCOMES: return ergebnis return None def consistency_state( verdict_title: Optional[str], plenum_votes: Optional[Iterable[dict]], ) -> Optional[str]: """Vergleich GWÖ-Empfehlung × tatsächlicher Beschluss. Liefert: - ``"conflict"`` — Empfehlung „unterstützen/befürworten" und Beschluss „abgelehnt", ODER Empfehlung „ablehnen" und Beschluss „angenommen/bestätigt". - ``"aligned"`` — Empfehlung und Beschluss decken sich. - ``None`` — keine eindeutige Aussage möglich (Beschluss „überwiesen", oder Empfehlung ambivalent wie „Mit Vorbehalt", oder fehlende Daten). """ if not verdict_title or not plenum_votes: return None outcome = decisive_outcome(plenum_votes) if outcome is None: return None text = verdict_title.lower() rec_supports = ("unterstützen" in text) or ("befürworten" in text) rec_rejects = "ablehnen" in text out_passed = outcome in ("angenommen", "bestätigt") out_failed = outcome == "abgelehnt" if (rec_supports and out_failed) or (rec_rejects and out_passed): return "conflict" if (rec_supports and out_passed) or (rec_rejects and out_failed): return "aligned" return None