From 5823828fec0fcec2482b9958d5c68146c91c5420 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 6 May 2026 15:48:06 +0200 Subject: [PATCH] feat: Opportunismus-Marker bei JA-Stimmen mit WP-Score < 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/main.py | 8 +++- app/marker.py | 30 ++++++++++++++ app/templates/v2/screens/antrag_detail.html | 5 ++- tests/test_marker.py | 45 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index b700c55..3439aac 100644 --- a/app/main.py +++ b/app/main.py @@ -146,8 +146,14 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static") templates = Jinja2Templates(directory=str(templates_dir)) # 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["opportunismus_score"] = opportunismus_score templates.env.globals["decisive_outcome"] = decisive_outcome templates.env.globals["consistency_state"] = consistency_state diff --git a/app/marker.py b/app/marker.py index a47b7dc..440da39 100644 --- a/app/marker.py +++ b/app/marker.py @@ -70,6 +70,36 @@ def decisive_outcome(plenum_votes: Optional[Iterable[dict]]) -> Optional[str]: 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]], diff --git a/app/templates/v2/screens/antrag_detail.html b/app/templates/v2/screens/antrag_detail.html index a94e799..8965ecc 100644 --- a/app/templates/v2/screens/antrag_detail.html +++ b/app/templates/v2/screens/antrag_detail.html @@ -301,7 +301,10 @@
{% if v.fraktionen_ja %}
Ja: - {% for f in v.fraktionen_ja %}{{ f }}{% endfor %} + {% for f in v.fraktionen_ja %} + {% set _opp_match = opportunismus_score(f, antrag.fraktions_scores) %} + {{ f }}{% if _opp_match is not none %}!{% endif %} + {% endfor %}
{% endif %} {% if v.fraktionen_nein %} diff --git a/tests/test_marker.py b/tests/test_marker.py index 5210521..659b01d 100644 --- a/tests/test_marker.py +++ b/tests/test_marker.py @@ -4,6 +4,7 @@ import pytest try: from app.marker import ( heuchelei_score, + opportunismus_score, decisive_outcome, consistency_state, DECISIVE_OUTCOMES, @@ -75,6 +76,50 @@ class TestHeucheleiScore: 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 ─────────────────────────────────────────────────────── class TestDecisiveOutcome: