diff --git a/app/main.py b/app/main.py
index 43dcbf5..b700c55 100644
--- a/app/main.py
+++ b/app/main.py
@@ -145,6 +145,12 @@ templates_dir.mkdir(exist_ok=True)
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
+templates.env.globals["heuchelei_score"] = heuchelei_score
+templates.env.globals["decisive_outcome"] = decisive_outcome
+templates.env.globals["consistency_state"] = consistency_state
+
# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ─────────────────
diff --git a/app/marker.py b/app/marker.py
new file mode 100644
index 0000000..a47b7dc
--- /dev/null
+++ b/app/marker.py
@@ -0,0 +1,103 @@
+"""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
+
+
+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
diff --git a/app/pm_render.py b/app/pm_render.py
new file mode 100644
index 0000000..5a92f3b
--- /dev/null
+++ b/app/pm_render.py
@@ -0,0 +1,81 @@
+"""Mini-Markdown-Renderer für PM-Bodies — Python-Spiegelbild der
+JS-Funktion ``renderPmBody`` in ``v2/screens/aktuelle-themen.html``.
+
+Der Renderer interpretiert eine kleine Untermenge von Markdown:
+
+- ``**bold**`` und ``__bold__`` → ``...``
+- ``*italic*`` und ``_italic_`` → ``...`` (vorsichtig — nur
+ wenn nicht zwischen Ziffern oder Word-Charakters)
+- Zeilen, die mit ``- `` oder ``* `` beginnen → ``
``
+- Doppel-Newlines trennen Absätze → ``...
``
+- Einzelne Newlines innerhalb eines Absatzes → ``
``
+- HTML-Special-Chars (``&``, ``<``, ``>``) werden escaped.
+
+Die Python-Variante existiert primär zum Testen. Im Produktiv-Frontend
+wird die JS-Variante benutzt (kein Round-Trip zum Server).
+"""
+from __future__ import annotations
+
+import re
+
+# 1) HTML-Escape (analog zur JS-Variante, in dieser Reihenfolge)
+def _html_escape(s: str) -> str:
+ return s.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+# 2) Bold-Marker: **...** und __...__
+_RE_BOLD_STAR = re.compile(r"\*\*([^*\n]+?)\*\*")
+_RE_BOLD_UNDER = re.compile(r"__([^_\n]+?)__")
+
+# 3) Italic-Marker: *…* und _…_, jeweils nicht zw. Word-Chars
+_RE_ITALIC_STAR = re.compile(r"(? str:
+ """Render Mini-Markdown zu HTML. Leere/None-Eingabe → leerer String."""
+ if not body:
+ return ""
+ s = _html_escape(body)
+ s = _RE_BOLD_STAR.sub(r"\1", s)
+ s = _RE_BOLD_UNDER.sub(r"\1", s)
+ s = _RE_ITALIC_STAR.sub(r"\1", s)
+ s = _RE_ITALIC_UNDER.sub(r"\1", s)
+
+ # Listen: konsekutive "- "/"* "-Zeilen zu einem zusammenfassen
+ out_lines = []
+ in_list = False
+ for line in s.split("\n"):
+ if _RE_BULLET.match(line):
+ if not in_list:
+ out_lines.append('')
+ in_list = True
+ out_lines.append("- " + _RE_BULLET.sub("", line) + "
")
+ else:
+ if in_list:
+ out_lines.append("
")
+ in_list = False
+ out_lines.append(line)
+ if in_list:
+ out_lines.append("
")
+ s = "\n".join(out_lines)
+
+ # Paragraphen-Split bei doppelten Newlines, einzelne →
+ paras = re.split(r"\n\s*\n", s)
+ rendered = []
+ for p in paras:
+ trimmed = p.strip()
+ if not trimmed:
+ continue
+ if trimmed.startswith("<"):
+ rendered.append(trimmed)
+ else:
+ rendered.append(
+ ''
+ + trimmed.replace("\n", "
")
+ + "
"
+ )
+ return "\n".join(rendered)
diff --git a/app/templates/v2/screens/antrag_detail.html b/app/templates/v2/screens/antrag_detail.html
index 42e8d68..a94e799 100644
--- a/app/templates/v2/screens/antrag_detail.html
+++ b/app/templates/v2/screens/antrag_detail.html
@@ -276,27 +276,15 @@
"sammel": "#0969da",
} %}
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
- Bei mehreren Votes (Überweisung → Endabstimmung) erste mit
- definitivem Outcome bevorzugen. #}
- {% set verdict_text = (antrag.verdict_title or '') | lower %}
- {% set decisive = namespace(ergebnis=None) %}
- {% for v in antrag.plenum_votes %}
- {% if not decisive.ergebnis and (v.ergebnis or '') | lower in ['angenommen', 'abgelehnt', 'bestätigt'] %}
- {% set decisive.ergebnis = (v.ergebnis or '') | lower %}
- {% endif %}
- {% endfor %}
- {% set rec_supports = ('unterstützen' in verdict_text) or ('befürworten' in verdict_text) %}
- {% set rec_rejects = 'ablehnen' in verdict_text %}
- {% set out_passed = decisive.ergebnis in ['angenommen', 'bestätigt'] %}
- {% set out_failed = decisive.ergebnis == 'abgelehnt' %}
- {% set conflict = (rec_supports and out_failed) or (rec_rejects and out_passed) %}
- {% set aligned = (rec_supports and out_passed) or (rec_rejects and out_failed) %}
- {% if conflict or aligned %}
+ Logik in app/marker.py — siehe ADR 0010. #}
+ {% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %}
+ {% set _decisive = decisive_outcome(antrag.plenum_votes) %}
+ {% if _state %}
- {% if conflict %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}
- — Empfohlen: {{ antrag.verdict_title }}; Beschluss: {{ decisive.ergebnis | capitalize }}.
+ background:{% if _state == 'conflict' %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %};
+ border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};">
+ {% if _state == 'conflict' %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}
+ — Empfohlen: {{ antrag.verdict_title }}; Beschluss: {{ _decisive | capitalize }}.
{% endif %}
{% for v in antrag.plenum_votes %}
@@ -319,9 +307,8 @@
{% if v.fraktionen_nein %}
Nein:
{% for f in v.fraktionen_nein %}
- {% set fs_match = (antrag.fraktions_scores or []) | selectattr('fraktion', 'equalto', f) | list %}
- {% set wp_score_match = fs_match[0].wahlprogramm.score | float if fs_match else 0 %}
- {{ f }}{% if wp_score_match >= 7 %}⚠{% endif %}
+ {% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %}
+ {{ f }}{% if _wp_match is not none %}⚠{% endif %}
{% endfor %}
{% endif %}
diff --git a/tests/test_marker.py b/tests/test_marker.py
new file mode 100644
index 0000000..5210521
--- /dev/null
+++ b/tests/test_marker.py
@@ -0,0 +1,192 @@
+"""Unit-Tests für app.marker — Heuchelei + Konsistenz."""
+import pytest
+
+try:
+ from app.marker import (
+ heuchelei_score,
+ decisive_outcome,
+ consistency_state,
+ DECISIVE_OUTCOMES,
+ )
+ _HAS_MARKER = True
+except ImportError:
+ _HAS_MARKER = False
+
+pytestmark = pytest.mark.skipif(not _HAS_MARKER, reason="app.marker nicht importierbar")
+
+
+# ─── heuchelei_score ────────────────────────────────────────────────────────
+
+class TestHeucheleiScore:
+ """heuchelei_score liefert WP-Score wenn Fraktion NEIN-stimmt aber WP≥7."""
+
+ def test_score_high_returns_score(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}]
+ assert heuchelei_score("CDU", fs) == 7.0
+
+ def test_score_at_threshold_returns_score(self):
+ # 7.0 ist genau Schwelle und sollte greifen
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}]
+ assert heuchelei_score("CDU", fs, threshold=7.0) == 7.0
+
+ def test_score_below_returns_none(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 6.9}}]
+ assert heuchelei_score("CDU", fs) is None
+
+ def test_score_above_default_returns_score(self):
+ fs = [{"fraktion": "FDP", "wahlprogramm": {"score": 8.0}}]
+ assert heuchelei_score("FDP", fs) == 8.0
+
+ def test_unknown_fraktion_returns_none(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 9.0}}]
+ assert heuchelei_score("AfD", fs) is None
+
+ def test_empty_list_returns_none(self):
+ assert heuchelei_score("CDU", []) is None
+
+ def test_none_input_returns_none(self):
+ assert heuchelei_score("CDU", None) is None
+
+ def test_missing_score_field_returns_none(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {}}]
+ assert heuchelei_score("CDU", fs) is None
+
+ def test_score_as_int_handled(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7}}]
+ assert heuchelei_score("CDU", fs) == 7.0
+
+ def test_invalid_score_returns_none(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": "not-a-number"}}]
+ assert heuchelei_score("CDU", fs) is None
+
+ def test_custom_threshold(self):
+ fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 5.0}}]
+ # bei threshold=4 greift's
+ assert heuchelei_score("CDU", fs, threshold=4.0) == 5.0
+ # bei threshold=6 nicht
+ assert heuchelei_score("CDU", fs, threshold=6.0) is None
+
+ def test_finds_first_match(self):
+ """Wenn dieselbe Fraktion mehrfach in der Liste ist, wird der erste Treffer genommen."""
+ fs = [
+ {"fraktion": "CDU", "wahlprogramm": {"score": 8.0}},
+ {"fraktion": "CDU", "wahlprogramm": {"score": 3.0}},
+ ]
+ assert heuchelei_score("CDU", fs) == 8.0
+
+
+# ─── decisive_outcome ───────────────────────────────────────────────────────
+
+class TestDecisiveOutcome:
+ """decisive_outcome wählt erstes Vote mit angenommen|abgelehnt|bestätigt."""
+
+ def test_single_decisive(self):
+ votes = [{"ergebnis": "angenommen"}]
+ assert decisive_outcome(votes) == "angenommen"
+
+ def test_single_abgelehnt(self):
+ votes = [{"ergebnis": "abgelehnt"}]
+ assert decisive_outcome(votes) == "abgelehnt"
+
+ def test_single_bestaetigt(self):
+ votes = [{"ergebnis": "bestätigt"}]
+ assert decisive_outcome(votes) == "bestätigt"
+
+ def test_skip_ueberwiesen_to_decisive(self):
+ """Bei Überweisung → angenommen sollte angenommen gewählt werden."""
+ votes = [
+ {"ergebnis": "überwiesen"},
+ {"ergebnis": "angenommen"},
+ ]
+ assert decisive_outcome(votes) == "angenommen"
+
+ def test_only_ueberwiesen_returns_none(self):
+ votes = [{"ergebnis": "überwiesen"}]
+ assert decisive_outcome(votes) is None
+
+ def test_only_zurueckgezogen_returns_none(self):
+ votes = [{"ergebnis": "zurückgezogen"}]
+ assert decisive_outcome(votes) is None
+
+ def test_empty_list_returns_none(self):
+ assert decisive_outcome([]) is None
+
+ def test_none_input_returns_none(self):
+ assert decisive_outcome(None) is None
+
+ def test_first_decisive_wins(self):
+ """Wenn beide Votes definitiv sind, wird das erste gewählt."""
+ votes = [
+ {"ergebnis": "angenommen"},
+ {"ergebnis": "abgelehnt"},
+ ]
+ assert decisive_outcome(votes) == "angenommen"
+
+ def test_case_insensitive(self):
+ votes = [{"ergebnis": "Angenommen"}]
+ assert decisive_outcome(votes) == "angenommen"
+
+ def test_decisive_outcomes_constant(self):
+ """Konstante DECISIVE_OUTCOMES enthält genau die drei Werte."""
+ assert DECISIVE_OUTCOMES == frozenset({"angenommen", "abgelehnt", "bestätigt"})
+
+
+# ─── consistency_state ──────────────────────────────────────────────────────
+
+class TestConsistencyState:
+ """consistency_state vergleicht GWÖ-Empfehlung × Beschluss."""
+
+ def test_supports_passed_aligned(self):
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned"
+
+ def test_supports_failed_conflict(self):
+ votes = [{"ergebnis": "abgelehnt"}]
+ assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict"
+
+ def test_rejects_failed_aligned(self):
+ votes = [{"ergebnis": "abgelehnt"}]
+ assert consistency_state("Ablehnen", votes) == "aligned"
+
+ def test_rejects_passed_conflict(self):
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("Ablehnen", votes) == "conflict"
+
+ def test_supports_with_changes_passed_aligned(self):
+ """„Unterstützen mit Änderungen" enthält 'unterstützen' → rec_supports."""
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("Unterstützen mit Änderungen", votes) == "aligned"
+
+ def test_befuerworten_synonym(self):
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("Befürworten", votes) == "aligned"
+
+ def test_ambivalent_returns_none(self):
+ """„Mit Vorbehalt" enthält weder 'unterstützen' noch 'ablehnen'."""
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("Mit Vorbehalt", votes) is None
+
+ def test_ueberwiesen_returns_none(self):
+ votes = [{"ergebnis": "überwiesen"}]
+ assert consistency_state("Uneingeschränkt unterstützen", votes) is None
+
+ def test_no_votes_returns_none(self):
+ assert consistency_state("Uneingeschränkt unterstützen", []) is None
+
+ def test_no_verdict_returns_none(self):
+ votes = [{"ergebnis": "angenommen"}]
+ assert consistency_state("", votes) is None
+ assert consistency_state(None, votes) is None
+
+ def test_multivote_picks_decisive(self):
+ """Bei Überweisung → Endabstimmung wird die Endabstimmung gewertet."""
+ votes = [
+ {"ergebnis": "überwiesen"},
+ {"ergebnis": "abgelehnt"},
+ ]
+ assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict"
+
+ def test_bestaetigt_treated_as_passed(self):
+ votes = [{"ergebnis": "bestätigt"}]
+ assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned"
+ assert consistency_state("Ablehnen", votes) == "conflict"
diff --git a/tests/test_pm_render.py b/tests/test_pm_render.py
new file mode 100644
index 0000000..800a5b2
--- /dev/null
+++ b/tests/test_pm_render.py
@@ -0,0 +1,137 @@
+"""Tests für app.pm_render — Python-Spiegelbild des JS-Mini-Markdown-Renderers."""
+import pytest
+
+try:
+ from app.pm_render import render_pm_body
+ _HAS_RENDER = True
+except ImportError:
+ _HAS_RENDER = False
+
+pytestmark = pytest.mark.skipif(not _HAS_RENDER, reason="app.pm_render nicht importierbar")
+
+
+class TestEmptyAndNone:
+ def test_empty_returns_empty(self):
+ assert render_pm_body("") == ""
+
+ def test_none_returns_empty(self):
+ assert render_pm_body(None) == ""
+
+
+class TestPlainParagraph:
+ def test_single_paragraph(self):
+ out = render_pm_body("Ein einzelner Satz.")
+ assert out == 'Ein einzelner Satz.
'
+
+ def test_two_paragraphs(self):
+ out = render_pm_body("Erster Satz.\n\nZweiter Satz.")
+ assert "Erster Satz" in out
+ assert "Zweiter Satz" in out
+ assert out.count("Zeile B" in out
+
+
+class TestHtmlEscape:
+ def test_lt_gt_escaped(self):
+ out = render_pm_body("Vergleich a < b > c.")
+ assert "<" in out and ">" in out
+ assert "" not in out # nichts wird zu echten Tags
+
+ def test_amp_escaped(self):
+ out = render_pm_body("Tom & Jerry")
+ assert "&" in out
+
+
+class TestBold:
+ def test_double_star_to_strong(self):
+ out = render_pm_body("Wert: **30 %**")
+ assert "30 %" in out
+
+ def test_double_underscore_to_strong(self):
+ out = render_pm_body("Wert: __wichtig__")
+ assert "wichtig" in out
+
+ def test_no_bold_across_newline(self):
+ """**...** mit Newline dazwischen wird nicht gemarkt."""
+ out = render_pm_body("**erste\nZeile**")
+ assert "" not in out
+
+
+class TestItalic:
+ def test_single_star_to_em(self):
+ out = render_pm_body("Das ist *betont*.")
+ assert "betont" in out
+
+ def test_single_underscore_to_em(self):
+ out = render_pm_body("Das ist _wichtig_.")
+ assert "wichtig" in out
+
+ def test_underscore_in_word_not_em(self):
+ """snake_case darf nicht als em gerendert werden."""
+ out = render_pm_body("Variable my_var hier.")
+ assert "" not in out
+
+ def test_star_between_words_not_em(self):
+ """Ein * zwischen Wort-Charaktern ist kein em-Marker."""
+ out = render_pm_body("Datei: foo*bar.txt")
+ assert "" not in out
+
+
+class TestBoldAndItalicCombined:
+ def test_bold_first_then_italic(self):
+ out = render_pm_body("**fett** und *kursiv*.")
+ assert "fett" in out
+ assert "kursiv" in out
+
+
+class TestLists:
+ def test_simple_dash_list(self):
+ out = render_pm_body("- Item 1\n- Item 2")
+ assert "" in out
+ # Keine rohen ** mehr
+ assert "**" not in out
+
+ def test_no_bold_when_unmatched(self):
+ body = "Ein *einfacher Stern."
+ out = render_pm_body(body)
+ # Unmatched * darf nicht als em gerendert werden
+ assert "" not in out