From 7a1c37afe437839ac34c97a859eee85a907f6d04 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 6 May 2026 15:54:51 +0200 Subject: [PATCH] feat(#170 Phase 2.2): Drilldown-Modal von Heuchelei-Bar zu Antragsliste Klick auf eine Heuchelei-Bar oeffnet ein Modal mit der konkreten Liste der Antraege wo die Fraktion mit Nein gestimmt hat, obwohl der Antrag inhaltlich zum eigenen Wahlprogramm passt. - Backend: app.auswertungen.get_heuchelei_cases() + Endpoint GET /api/auswertungen/heuchelei-cases?partei=X[&bundesland=Y]. - Backend: _load_assessments_with_votes liefert jetzt zusaetzlich das ergebnis-Feld (additiv im SELECT). - Frontend: onClick-Handler im Heuchelei-Bar-Chart, Modal-Markup wird lazy injiziert, Tabellen-Drilldown mit Drucksachen-Link. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/auswertungen.py | 57 +++++++++++++- app/main.py | 20 +++++ app/templates/v2/screens/auswertungen.html | 87 +++++++++++++++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/app/auswertungen.py b/app/auswertungen.py index 51ba4a2..49c2f45 100644 --- a/app/auswertungen.py +++ b/app/auswertungen.py @@ -306,7 +306,7 @@ def _load_assessments_with_votes( a.fraktionen, a.gwoe_score, a.gwoe_matrix, a.gwoe_schwerpunkt, a.wahlprogramm_scores, p.fraktionen_ja, p.fraktionen_nein, p.fraktionen_enthaltung, - p.quelle_protokoll + p.quelle_protokoll, p.ergebnis FROM assessments a INNER JOIN plenum_vote_results p ON a.bundesland = p.bundesland @@ -358,6 +358,7 @@ def _load_assessments_with_votes( "nein": nein, "enthaltung": enth, "quelle_protokoll": r["quelle_protokoll"], + "ergebnis": r["ergebnis"], }) return out @@ -579,6 +580,60 @@ def aggregate_heuchelei( } +def get_heuchelei_cases( + partei: str, + filter_bl: Optional[str] = None, + filter_wp: Optional[str] = None, + score_threshold: float = 7.0, + limit: int = 50, + db_path: Optional[Path] = None, +) -> dict: + """Liste der Antraege wo ``partei`` mit NEIN gestimmt hat, + obwohl der Antrag inhaltlich zum eigenen Wahlprogramm passt + (`wahlprogramm.score >= score_threshold`). + + Drilldown-Quelle fuer #170 (Klick auf Heuchelei-Bar). + """ + rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path) + cases = [] + for row in rows: + if partei not in row["nein"]: + continue + wp_scores = row["wahlprogramm_scores"] or [] + for entry in wp_scores: + if not isinstance(entry, dict): + continue + raw_partei = entry.get("fraktion") or "" + normalized = normalize_partei(raw_partei, bundesland=row["bundesland"]) or raw_partei + if normalized != partei: + continue + wp_block = entry.get("wahlprogramm") or {} + score = wp_block.get("score") + if score is None or score < score_threshold: + continue + cases.append({ + "drucksache": row["drucksache"], + "bundesland": row["bundesland"], + "datum": row.get("datum"), + "gwoe_score": row.get("gwoe_score"), + "wahlprogramm_score": float(score), + "ergebnis": row.get("ergebnis"), + }) + break # je Antrag genau einmal zaehlen + + cases.sort(key=lambda c: (c["wahlprogramm_score"] or 0), reverse=True) + return { + "partei": partei, + "count": len(cases), + "items": cases[:limit], + "filter": { + "bundesland": filter_bl, + "wahlperiode": filter_wp, + "score_threshold": score_threshold, + }, + } + + def _wert_score_for_assessment(matrix: list[dict]) -> dict[str, float]: """Aus der gwoe_matrix-Liste den Mittelwert pro Wert-Spalte (1..5) berechnen. diff --git a/app/main.py b/app/main.py index df99c84..2a51cca 100644 --- a/app/main.py +++ b/app/main.py @@ -2630,6 +2630,26 @@ async def auswertungen_heuchelei( ) +@app.get("/api/auswertungen/heuchelei-cases") +async def auswertungen_heuchelei_cases( + partei: str, + bundesland: Optional[str] = None, + wahlperiode: Optional[str] = None, + score_threshold: float = 7.0, + limit: int = 50, +): + """Drilldown-Liste: konkrete Anträge wo `partei` mit NEIN gestimmt hat, + obwohl der Antrag inhaltlich zum eigenen Wahlprogramm passt.""" + from .auswertungen import get_heuchelei_cases + return get_heuchelei_cases( + partei=partei, + filter_bl=bundesland, + filter_wp=wahlperiode, + score_threshold=score_threshold, + limit=limit, + ) + + @app.get("/api/auswertungen/stimm-index-pro-wert") async def auswertungen_stimm_index_pro_wert( bundesland: Optional[str] = None, diff --git a/app/templates/v2/screens/auswertungen.html b/app/templates/v2/screens/auswertungen.html index 8952d9c..bf4b9d7 100644 --- a/app/templates/v2/screens/auswertungen.html +++ b/app/templates/v2/screens/auswertungen.html @@ -943,20 +943,105 @@ async function loadHeuchelei(bl) { `davon Nein gestimmt: ${f.n_nein_trotz_programm}`, `davon Ja gestimmt: ${f.n_ja_passt}`, `davon Enthaltung: ${f.n_enth_passt}`, + '', + '↻ Klick öffnet Antragsliste', ]; } } } + }, + onClick: (evt, elements) => { + if (!elements || !elements.length) return; + const idx = elements[0].index; + const partei = filtered[idx].partei; + openHeucheleiDrilldown(partei, bl); } } }); - meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — Threshold: Wahlprogramm-Treue ≥ 7/10.`; + meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — Threshold: Wahlprogramm-Treue ≥ 7/10. Klick auf eine Bar zeigt die konkreten Anträge.`; } catch (e) { meta.textContent = 'Fehler: ' + e; } } +async function openHeucheleiDrilldown(partei, bl) { + // Lazy-Modal-Markup + let modal = document.getElementById('sv-heuchelei-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sv-heuchelei-modal'; + modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px;'; + modal.innerHTML = ` +
+
+

+ +
+

+
Lade…
+
`; + document.body.appendChild(modal); + modal.addEventListener('click', (e) => { if (e.target === modal) closeHeucheleiDrilldown(); }); + } + modal.style.display = 'flex'; + document.getElementById('sv-heuchelei-modal-title').textContent = + `Heuchelei-Drilldown: ${partei}`; + document.getElementById('sv-heuchelei-modal-meta').textContent = + 'Anträge mit Wahlprogramm-Treue ≥ 7/10 wo die Fraktion mit Nein gestimmt hat.'; + + let url = `/api/auswertungen/heuchelei-cases?partei=${encodeURIComponent(partei)}&limit=50`; + if (bl) url += '&bundesland=' + encodeURIComponent(bl); + try { + const r = await fetch(url); + const data = await r.json(); + const body = document.getElementById('sv-heuchelei-modal-body'); + if (!data.items || !data.items.length) { + body.innerHTML = '

Keine Anträge gefunden.

'; + return; + } + body.innerHTML = ` +

+ ${data.count} Treffer${data.items.length < data.count ? ` — Top ${data.items.length} angezeigt` : ''}. +

+ + + + + + + + + + + + + ${data.items.map(it => ` + + + + + + + + + `).join('')} + +
DrucksacheBLDatumGWÖWPBeschluss
+ ${it.drucksache} + ${it.bundesland}${it.datum || ''}${it.gwoe_score != null ? it.gwoe_score.toFixed(1) : '—'}${it.wahlprogramm_score.toFixed(0)}${it.ergebnis || '—'}
`; + } catch (e) { + document.getElementById('sv-heuchelei-modal-body').textContent = 'Fehler: ' + e; + } +} + +function closeHeucheleiDrilldown() { + const modal = document.getElementById('sv-heuchelei-modal'); + if (modal) modal.style.display = 'none'; +} + function wertHeatColor(idx) { // Wert-Spalten haben Domain -5..+5 (rating-Skala der Matrix) if (idx == null) return 'rgba(120,120,120,0.1)';