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>
This commit is contained in:
Dotty Dotter 2026-05-06 15:48:06 +02:00
parent a8f85bf3ee
commit 5823828fec
4 changed files with 86 additions and 2 deletions

View File

@ -146,8 +146,14 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=str(templates_dir)) templates = Jinja2Templates(directory=str(templates_dir))
# Detail-Marker im Jinja-Environment registrieren (siehe ADR 0010) # Detail-Marker im Jinja-Environment registrieren (siehe ADR 0010)
from .marker import heuchelei_score, decisive_outcome, consistency_state # noqa: E402 from .marker import ( # noqa: E402
heuchelei_score,
opportunismus_score,
decisive_outcome,
consistency_state,
)
templates.env.globals["heuchelei_score"] = heuchelei_score templates.env.globals["heuchelei_score"] = heuchelei_score
templates.env.globals["opportunismus_score"] = opportunismus_score
templates.env.globals["decisive_outcome"] = decisive_outcome templates.env.globals["decisive_outcome"] = decisive_outcome
templates.env.globals["consistency_state"] = consistency_state templates.env.globals["consistency_state"] = consistency_state

View File

@ -70,6 +70,36 @@ def decisive_outcome(plenum_votes: Optional[Iterable[dict]]) -> Optional[str]:
return None 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( def consistency_state(
verdict_title: Optional[str], verdict_title: Optional[str],
plenum_votes: Optional[Iterable[dict]], plenum_votes: Optional[Iterable[dict]],

View File

@ -301,7 +301,10 @@
<div style="display:flex;flex-wrap:wrap;gap:12px;font-family:var(--font-mono);font-size:11px;"> <div style="display:flex;flex-wrap:wrap;gap:12px;font-family:var(--font-mono);font-size:11px;">
{% if v.fraktionen_ja %} {% if v.fraktionen_ja %}
<div><span style="color:#2da44e;font-weight:700;">Ja:</span> <div><span style="color:#2da44e;font-weight:700;">Ja:</span>
{% for f in v.fraktionen_ja %}<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#2da44e 15%,transparent);color:#1a7f37;border-radius:3px;margin-right:3px;">{{ f }}</span>{% endfor %} {% for f in v.fraktionen_ja %}
{% set _opp_match = opportunismus_score(f, antrag.fraktions_scores) %}
<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#2da44e 15%,transparent);color:#1a7f37;border-radius:3px;margin-right:3px;">{{ f }}{% if _opp_match is not none %}<span style="margin-left:4px;cursor:help;font-style:italic;color:#bf8700;" title="Diese Fraktion stimmte mit Ja, obwohl der Antrag nicht zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_opp_match) }}/10).">!</span>{% endif %}</span>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
{% if v.fraktionen_nein %} {% if v.fraktionen_nein %}

View File

@ -4,6 +4,7 @@ import pytest
try: try:
from app.marker import ( from app.marker import (
heuchelei_score, heuchelei_score,
opportunismus_score,
decisive_outcome, decisive_outcome,
consistency_state, consistency_state,
DECISIVE_OUTCOMES, DECISIVE_OUTCOMES,
@ -75,6 +76,50 @@ class TestHeucheleiScore:
assert heuchelei_score("CDU", fs) == 8.0 assert heuchelei_score("CDU", fs) == 8.0
# ─── opportunismus_score ────────────────────────────────────────────────────
class TestOpportunismusScore:
"""opportunismus_score liefert WP-Score wenn Fraktion JA-stimmt aber WP<3."""
def test_score_low_returns_score(self):
fs = [{"fraktion": "AfD", "wahlprogramm": {"score": 1.0}}]
assert opportunismus_score("AfD", fs) == 1.0
def test_score_zero_returns_score(self):
fs = [{"fraktion": "GRÜNE", "wahlprogramm": {"score": 0.0}}]
assert opportunismus_score("GRÜNE", fs) == 0.0
def test_score_at_threshold_returns_none(self):
# 3.0 ist genau Schwelle, sollte NICHT greifen (wir wollen < 3)
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 3.0}}]
assert opportunismus_score("CDU", fs) is None
def test_score_above_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 5.0}}]
assert opportunismus_score("CDU", fs) is None
def test_unknown_fraktion_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 1.0}}]
assert opportunismus_score("AfD", fs) is None
def test_empty_list_returns_none(self):
assert opportunismus_score("CDU", []) is None
def test_none_input_returns_none(self):
assert opportunismus_score("CDU", None) is None
def test_missing_score_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {}}]
assert opportunismus_score("CDU", fs) is None
def test_custom_threshold(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 4.0}}]
# threshold=5 → 4 < 5 → opportunism greift
assert opportunismus_score("CDU", fs, threshold=5.0) == 4.0
# threshold=3 → 4 ≥ 3 → kein opportunism
assert opportunismus_score("CDU", fs, threshold=3.0) is None
# ─── decisive_outcome ─────────────────────────────────────────────────────── # ─── decisive_outcome ───────────────────────────────────────────────────────
class TestDecisiveOutcome: class TestDecisiveOutcome: