gwoe-antragspruefer/app/marker.py
Dotty Dotter 5823828fec feat: Opportunismus-Marker bei JA-Stimmen mit WP-Score < 3
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>
2026-05-06 15:48:06 +02:00

134 lines
4.6 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.

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