Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block, decisive-Outcome-Selection) in app/marker.py extrahiert. Template ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik testbar ohne Render-Kontext. Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown- Renderers in aktuelle-themen.html — fuer Tests und potenzielle Server-side-Render-Optionen (z.B. PM-Mail). Tests: - tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome, consistency_state inkl. Multi-Vote, ambivalente Empfehlung, Edge-Cases. - tests/test_pm_render.py (21 Cases): Bold, Italic, Listen, HTML-Escape, Paragraph-Splitting, snake_case-Schutz. Refs: ADR 0010, ADR 0011 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.7 KiB
Python
104 lines
3.7 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 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
|