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: