feat(pdf): Heuchelei-/Opportunismus-Marker im Vote-Block (#175)

Web-Detail zeigt diese Marker bereits — pro NEIN-Fraktion einen ⚠ wenn
der eigene Wahlprogramm-Score ≥ 7/10 ist (Heuchelei: stimmt gegen die
eigenen Versprechen), pro JA-Fraktion einen ! wenn der Wahlprogramm-
Score < 3/10 (Opportunismus: stimmt zu obwohl Antrag inhaltlich nicht
zum eigenen Programm passt). Im PDF fehlten sie bisher.

Daten-Pfad: report.py rechnet die Marker einmal vor (heuchelei_score /
opportunismus_score aus app/marker.py, gefüttert mit umgemappten
fraktions_scores aus assessment.wahlprogramm_scores) und reicht zwei
Maps fraktion → score ans Template. Template macht nur noch Lookup:
``opportunismus_by_fraktion.get(f)`` neben jeder JA-Fraktion,
``heuchelei_by_fraktion.get(f)`` neben jeder NEIN-Fraktion. Plus
kompakte Legende unter dem Vote-Block, falls überhaupt Marker
vorkommen.

Stimmverhalten und Programm-Treue-Begründungen sind im PDF schon da
(verifiziert bei der Code-Inspektion). Damit ist die "PDF auf Augenhöhe
mit Web-Detail"-Liste aus #175 bis auf News-Match abgehakt; News-Match
explizit out-of-scope nach User-Entscheidung.
This commit is contained in:
Dotty Dotter 2026-05-09 02:21:12 +02:00
parent ad73c824d3
commit c7eab5a695
2 changed files with 58 additions and 2 deletions

View File

@ -590,6 +590,39 @@ async def generate_html_report_v3(
assessment.drucksache, exc,
)
# Pre-compute Heuchelei-/Opportunismus-Marker pro Fraktion.
# Jinja-Globals sind im Web ``heuchelei_score`` und ``opportunismus_score``;
# sie erwarten Dict-Listen (fraktions_scores aus _row_to_detail). Das
# Pydantic-Assessment.wahlprogramm_scores hat dieselben Daten, aber als
# Pydantic-Objekte. Wir mappen einmal um und rechnen die Marker für
# alle abstimmenden Fraktionen, damit das Template nur noch Lookup
# statt Logik macht.
fraktions_scores_dict = []
for fs in (assessment.wahlprogramm_scores or []):
fraktions_scores_dict.append({
"fraktion": fs.fraktion,
"wahlprogramm": {"score": fs.wahlprogramm.score if fs.wahlprogramm else None},
})
from .marker import heuchelei_score as _h, opportunismus_score as _o
heuchelei_by_fraktion: dict[str, float] = {}
opportunismus_by_fraktion: dict[str, float] = {}
if plenum_votes and fraktions_scores_dict:
seen: set[str] = set()
for v in plenum_votes:
for f in (v.get("fraktionen_nein") or []):
if f in seen:
continue
hv = _h(f, fraktions_scores_dict)
if hv is not None:
heuchelei_by_fraktion[f] = hv
for f in (v.get("fraktionen_ja") or []):
if f in seen:
continue
ov = _o(f, fraktions_scores_dict)
if ov is not None:
opportunismus_by_fraktion[f] = ov
template = _pdf_jinja.get_template("v3/pdf/antrag_pdf.html")
html = template.render(
assessment=assessment,
@ -602,6 +635,8 @@ async def generate_html_report_v3(
plenum_votes=plenum_votes,
konsistenz_state=konsistenz_state,
konsistenz_decisive=konsistenz_decisive,
heuchelei_by_fraktion=heuchelei_by_fraktion,
opportunismus_by_fraktion=opportunismus_by_fraktion,
)
output_path.write_text(html)

View File

@ -504,6 +504,20 @@
background: rgba(45, 164, 78, 0.07);
border-left-color: #2da44e;
}
.pdf-marker {
font-weight: 700;
font-size: 9pt;
margin-left: 2pt;
}
.pdf-marker.heuchelei { color: #cf222e; }
.pdf-marker.opportunismus { color: #bf6c10; font-style: italic; }
.pdf-marker-legend {
font-size: 7.5pt;
color: #57606a;
margin-top: 4pt;
padding-top: 3pt;
border-top: 1pt dashed #d0d7de;
}
</style>
</head>
<body>
@ -754,12 +768,12 @@
<div class="pdf-vote-pills">
{% if v.fraktionen_ja %}
<div><span class="pdf-vote-side" style="color:#1a7f37;">Ja:</span>
{% for f in v.fraktionen_ja %}<span class="pdf-vote-pill ja">{{ f }}</span>{% endfor %}
{% for f in v.fraktionen_ja %}<span class="pdf-vote-pill ja">{{ f }}{% if opportunismus_by_fraktion.get(f) is not none %}<span class="pdf-marker opportunismus" title="Opportunismus: {{ f }} stimmt zu trotz Wahlprogramm-Score {{ '%.0f' | format(opportunismus_by_fraktion[f]) }}/10">!</span>{% endif %}</span>{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_nein %}
<div><span class="pdf-vote-side" style="color:#a40e26;">Nein:</span>
{% for f in v.fraktionen_nein %}<span class="pdf-vote-pill nein">{{ f }}</span>{% endfor %}
{% for f in v.fraktionen_nein %}<span class="pdf-vote-pill nein">{{ f }}{% if heuchelei_by_fraktion.get(f) is not none %}<span class="pdf-marker heuchelei" title="Heuchelei: {{ f }} stimmt mit Nein trotz Wahlprogramm-Score {{ '%.0f' | format(heuchelei_by_fraktion[f]) }}/10"></span>{% endif %}</span>{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_enthaltung %}
@ -768,6 +782,13 @@
</div>
{% endif %}
</div>
{% if heuchelei_by_fraktion or opportunismus_by_fraktion %}
<div class="pdf-marker-legend">
{% if heuchelei_by_fraktion %}<span class="pdf-marker heuchelei"></span> Heuchelei (Nein trotz Wahlprogramm-Match ≥7/10){% endif %}
{% if heuchelei_by_fraktion and opportunismus_by_fraktion %} · {% endif %}
{% if opportunismus_by_fraktion %}<span class="pdf-marker opportunismus">!</span> Opportunismus (Ja trotz Wahlprogramm-Mismatch &lt;3/10){% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>