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
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 15:48:06 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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
|