gwoe-antragspruefer/app/marker.py
Dotty Dotter 9498ca4b97 refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
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>
2026-05-06 15:44:12 +02:00

104 lines
3.7 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 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