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:
parent
a8f85bf3ee
commit
5823828fec
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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]],
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user