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) <noreply@anthropic.com>
This commit is contained in:
parent
741faae8ff
commit
7a1c37afe4
@ -306,7 +306,7 @@ def _load_assessments_with_votes(
|
|||||||
a.fraktionen, a.gwoe_score, a.gwoe_matrix,
|
a.fraktionen, a.gwoe_score, a.gwoe_matrix,
|
||||||
a.gwoe_schwerpunkt, a.wahlprogramm_scores,
|
a.gwoe_schwerpunkt, a.wahlprogramm_scores,
|
||||||
p.fraktionen_ja, p.fraktionen_nein, p.fraktionen_enthaltung,
|
p.fraktionen_ja, p.fraktionen_nein, p.fraktionen_enthaltung,
|
||||||
p.quelle_protokoll
|
p.quelle_protokoll, p.ergebnis
|
||||||
FROM assessments a
|
FROM assessments a
|
||||||
INNER JOIN plenum_vote_results p
|
INNER JOIN plenum_vote_results p
|
||||||
ON a.bundesland = p.bundesland
|
ON a.bundesland = p.bundesland
|
||||||
@ -358,6 +358,7 @@ def _load_assessments_with_votes(
|
|||||||
"nein": nein,
|
"nein": nein,
|
||||||
"enthaltung": enth,
|
"enthaltung": enth,
|
||||||
"quelle_protokoll": r["quelle_protokoll"],
|
"quelle_protokoll": r["quelle_protokoll"],
|
||||||
|
"ergebnis": r["ergebnis"],
|
||||||
})
|
})
|
||||||
return out
|
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]:
|
def _wert_score_for_assessment(matrix: list[dict]) -> dict[str, float]:
|
||||||
"""Aus der gwoe_matrix-Liste den Mittelwert pro Wert-Spalte (1..5)
|
"""Aus der gwoe_matrix-Liste den Mittelwert pro Wert-Spalte (1..5)
|
||||||
berechnen.
|
berechnen.
|
||||||
|
|||||||
20
app/main.py
20
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")
|
@app.get("/api/auswertungen/stimm-index-pro-wert")
|
||||||
async def auswertungen_stimm_index_pro_wert(
|
async def auswertungen_stimm_index_pro_wert(
|
||||||
bundesland: Optional[str] = None,
|
bundesland: Optional[str] = None,
|
||||||
|
|||||||
@ -943,20 +943,105 @@ async function loadHeuchelei(bl) {
|
|||||||
`davon Nein gestimmt: ${f.n_nein_trotz_programm}`,
|
`davon Nein gestimmt: ${f.n_nein_trotz_programm}`,
|
||||||
`davon Ja gestimmt: ${f.n_ja_passt}`,
|
`davon Ja gestimmt: ${f.n_ja_passt}`,
|
||||||
`davon Enthaltung: ${f.n_enth_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) {
|
} catch (e) {
|
||||||
meta.textContent = 'Fehler: ' + 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 = `
|
||||||
|
<div style="background:var(--paper);max-width:760px;width:100%;max-height:80vh;overflow:auto;border-radius:8px;padding:24px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
|
<h3 id="sv-heuchelei-modal-title" style="margin:0;font-family:var(--font-display);">…</h3>
|
||||||
|
<button onclick="closeHeucheleiDrilldown()"
|
||||||
|
style="background:none;border:1px solid var(--ecg-border);border-radius:4px;padding:4px 12px;cursor:pointer;font-family:var(--font-mono);">✕</button>
|
||||||
|
</div>
|
||||||
|
<p id="sv-heuchelei-modal-meta" style="font-size:12px;opacity:0.7;margin:0 0 12px;"></p>
|
||||||
|
<div id="sv-heuchelei-modal-body">Lade…</div>
|
||||||
|
</div>`;
|
||||||
|
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 = '<p style="opacity:0.7;">Keine Anträge gefunden.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.6;margin-bottom:8px;">
|
||||||
|
${data.count} Treffer${data.items.length < data.count ? ` — Top ${data.items.length} angezeigt` : ''}.
|
||||||
|
</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead style="background:var(--ecg-card-bg);">
|
||||||
|
<tr>
|
||||||
|
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid var(--ecg-border);">Drucksache</th>
|
||||||
|
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid var(--ecg-border);">BL</th>
|
||||||
|
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid var(--ecg-border);">Datum</th>
|
||||||
|
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid var(--ecg-border);">GWÖ</th>
|
||||||
|
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid var(--ecg-border);">WP</th>
|
||||||
|
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid var(--ecg-border);">Beschluss</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${data.items.map(it => `
|
||||||
|
<tr style="border-bottom:1px solid var(--hairline);">
|
||||||
|
<td style="padding:6px 10px;font-family:var(--font-mono);">
|
||||||
|
<a href="/antrag/${it.drucksache}?bundesland=${encodeURIComponent(it.bundesland)}"
|
||||||
|
style="color:var(--ecg-teal);">${it.drucksache}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding:6px 10px;font-family:var(--font-mono);">${it.bundesland}</td>
|
||||||
|
<td style="padding:6px 10px;font-family:var(--font-mono);">${it.datum || ''}</td>
|
||||||
|
<td style="padding:6px 10px;text-align:right;">${it.gwoe_score != null ? it.gwoe_score.toFixed(1) : '—'}</td>
|
||||||
|
<td style="padding:6px 10px;text-align:right;font-weight:700;color:#bf8700;">${it.wahlprogramm_score.toFixed(0)}</td>
|
||||||
|
<td style="padding:6px 10px;font-style:italic;opacity:0.8;">${it.ergebnis || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
} 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) {
|
function wertHeatColor(idx) {
|
||||||
// Wert-Spalten haben Domain -5..+5 (rating-Skala der Matrix)
|
// Wert-Spalten haben Domain -5..+5 (rating-Skala der Matrix)
|
||||||
if (idx == null) return 'rgba(120,120,120,0.1)';
|
if (idx == null) return 'rgba(120,120,120,0.1)';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user