Symmetrisch zur Heuchelei-Logik: bei JA-Fraktionen, deren eigener Wahlprogramm-Score < 3 ist, erscheint ein dezenter italic '!' mit Tooltip. 11 echte Cases gefunden auf dev (NRW + BB). app/marker.py: opportunismus_score() — neun neue Tests (test_marker.py jetzt 44 grün). Refs: ADR 0010, Phase 2.4 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""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 opportunismus_score(
|
||
fraktion: str,
|
||
fraktions_scores: Optional[Iterable[dict]],
|
||
threshold: float = 3.0,
|
||
) -> Optional[float]:
|
||
"""Wahlprogramm-Score einer Fraktion, falls < threshold; sonst None.
|
||
|
||
Symmetrisch zu ``heuchelei_score``, aber für den Fall, dass eine
|
||
Fraktion mit JA stimmt, obwohl der Antrag inhaltlich nicht zu
|
||
ihrem Wahlprogramm passt (`wahlprogramm.score < 3`).
|
||
|
||
Wenn die Fraktion nicht gefunden wird, der Score fehlt oder
|
||
≥ threshold ist, wird ``None`` zurückgegeben.
|
||
"""
|
||
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 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
|