feat(#166): Berührungsgruppen-Aufschlüsselung im Stimmverhalten-Tab

Stimm-Index pro Beruehrungsgruppe (Matrix-Zeilen A-E) zusaetzlich zur
bestehenden Werte-Aufschluesselung (Spalten 1-5). Toggle-Buttons in der
3. Sub-Section schalten zwischen Werte/Gruppen.

- `aggregate_stimm_index_pro_gruppe()` analog zu `_pro_wert`, aber
  gruppiert nach `field[0]` (A-E) statt `field[-1]` (1-5).
- `_gruppen_score_for_assessment()` Helper.
- `GET /api/auswertungen/stimm-index-pro-gruppe`.
- UI-Toggle "Pro GWÖ-Wert" / "Pro Berührungsgruppe" mit `setMatrixAxis()`.
- 6 neue Tests, Suite jetzt 995 grün.

Beruehrungsgruppen-Labels (aus app/models.py:MATRIX_LABELS gekuerzt):
- A: Ausgelagerte Betriebe / Lieferant:innen
- B: Finanzpartner:innen / Steuerzahler:innen
- C: Politische Führung / Verwaltung / Ehrenamt
- D: Bürger:innen und Wirtschaft
- E: Staat, Gesellschaft und Natur

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-29 23:00:35 +02:00
parent d81753c4fb
commit 79003d6056
4 changed files with 272 additions and 7 deletions

View File

@ -271,6 +271,16 @@ GWOE_WERTE = {
"5": "Transparenz & Demokratie",
}
# Zeilen der GWOe-Matrix: {field-prefix → Beruehrungsgruppe-Label}. Aus
# app/models.py:MATRIX_LABELS uebernommen, gekuerzt fuer UI-Spalten.
GWOE_BERUEHRUNGSGRUPPEN = {
"A": "Ausgelagerte Betriebe / Lieferant:innen",
"B": "Finanzpartner:innen / Steuerzahler:innen",
"C": "Politische Führung / Verwaltung / Ehrenamt",
"D": "Bürger:innen und Wirtschaft",
"E": "Staat, Gesellschaft und Natur",
}
def _load_assessments_with_votes(
filter_bl: Optional[str] = None,
@ -518,6 +528,22 @@ def _wert_score_for_assessment(matrix: list[dict]) -> dict[str, float]:
return {col: sum(vals) / len(vals) for col, vals in by_col.items()}
def _gruppen_score_for_assessment(matrix: list[dict]) -> dict[str, float]:
"""Mittelwert pro Beruehrungsgruppe-Zeile (A..E). Field-Prefix ist die
Zeile (`field[0]`), z.B. A1..A5 alle in Gruppe A."""
by_row: defaultdict[str, list[float]] = defaultdict(list)
for entry in matrix or []:
if not isinstance(entry, dict):
continue
field = entry.get("field") or ""
rating = entry.get("rating")
if not field or rating is None:
continue
if len(field) >= 2 and field[0] in GWOE_BERUEHRUNGSGRUPPEN:
by_row[field[0]].append(float(rating))
return {row: sum(vals) / len(vals) for row, vals in by_row.items()}
def aggregate_stimm_index_pro_wert(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
@ -588,6 +614,74 @@ def aggregate_stimm_index_pro_wert(
}
def aggregate_stimm_index_pro_gruppe(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
db_path: Optional[Path] = None,
) -> dict:
"""Pro (Fraktion, Beruehrungsgruppe A-E) ein Stimm-Index analog zu
pro_wert, aber gegen den Gruppen-Score statt den Wert-Score (#166).
Gruppen-Score eines Antrags = Ø(rating der gwoe_matrix-Felder mit
dem entsprechenden Zeilen-Prefix). Domain: -5..+5 pro Gruppe.
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
gruppen_namen = list(GWOE_BERUEHRUNGSGRUPPEN.values())
gruppen_keys = list(GWOE_BERUEHRUNGSGRUPPEN.keys())
ja: defaultdict[tuple[str, str], list[float]] = defaultdict(list)
nein: defaultdict[tuple[str, str], list[float]] = defaultdict(list)
parteien_seen: set[str] = set()
for row in rows:
gruppen_scores = _gruppen_score_for_assessment(row["gwoe_matrix"])
if not gruppen_scores:
continue
skip = row["antragsteller"] if exclude_antragsteller else set()
for f in row["ja"] - skip:
parteien_seen.add(f)
for row_key, sc in gruppen_scores.items():
ja[(f, row_key)].append(sc)
for f in row["nein"] - skip:
parteien_seen.add(f)
for row_key, sc in gruppen_scores.items():
nein[(f, row_key)].append(sc)
parteien = sorted(parteien_seen)
cells: dict[str, dict[str, dict]] = {}
for p in parteien:
cells[p] = {}
for row_key, gruppen_name in zip(gruppen_keys, gruppen_namen):
n_ja = len(ja[(p, row_key)])
n_nein = len(nein[(p, row_key)])
avg_ja = _avg(ja[(p, row_key)])
avg_nein = _avg(nein[(p, row_key)])
idx = (round(avg_ja - avg_nein, 2)
if avg_ja is not None and avg_nein is not None else None)
cells[p][gruppen_name] = {
"stimm_index": idx,
"n_ja": n_ja,
"n_nein": n_nein,
"ausreichend": n_ja >= min_n and n_nein >= min_n,
}
return {
"fraktionen": parteien,
"gruppen": gruppen_namen,
"cells": cells,
"n_assessments_matched": len({r["drucksache"] for r in rows}),
"filter": {
"bundesland": filter_bl,
"wahlperiode": filter_wp,
"exclude_antragsteller": exclude_antragsteller,
"min_n": min_n,
},
}
def aggregate_empfehlungs_konsistenz(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,

View File

@ -2370,6 +2370,23 @@ async def auswertungen_stimm_index_cross_bl(
)
@app.get("/api/auswertungen/stimm-index-pro-gruppe")
async def auswertungen_stimm_index_pro_gruppe(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
):
"""Stimm-Index pro Beruehrungsgruppe (A-E der GWÖ-Matrix-Zeilen) (#166)."""
from .auswertungen import aggregate_stimm_index_pro_gruppe
return aggregate_stimm_index_pro_gruppe(
filter_bl=bundesland,
filter_wp=wahlperiode,
exclude_antragsteller=exclude_antragsteller,
min_n=min_n,
)
@app.get("/api/auswertungen/empfehlungs-konsistenz")
async def auswertungen_empfehlungs_konsistenz(
bundesland: Optional[str] = None,

View File

@ -262,15 +262,33 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
</div>
<div id="sv-heuchelei-meta" class="meta-line"></div>
<!-- Sub 3: Pro GWÖ-Wert Heatmap -->
<!-- Sub 3: Pro GWÖ-Wert / Pro Berührungsgruppe (Toggle) -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">3. Stimm-Index pro GWÖ-Wert</h3>
margin:1.5rem 0 0.5rem;">3. Stimm-Index pro Matrix-Achse</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Aufschlüsselung nach den fünf GWÖ-Werten (Würde, Solidarität,
Nachhaltigkeit, Gerechtigkeit, Demokratie). Pro Zelle: Stimm-Index analog
Aussage 1, aber gegen den Wert-Score statt den Gesamt-Score. Domain
pro Zelle: 5..+5.
Aufschlüsselung der Stimm-Indizes pro <strong>GWÖ-Wert</strong>
(Spalten: Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit,
Demokratie) oder pro <strong>Berührungsgruppe</strong> (Zeilen AE:
Lieferant:innen, Finanzpartner, Politik, Bürger, Staat). Pro Zelle:
Stimm-Index analog Aussage 1, aber gegen den Achsen-Score statt den
Gesamt-Score. Domain pro Zelle: 5..+5.
</p>
<div style="display:inline-flex;gap:4px;margin-bottom:0.5rem;">
<button type="button" id="sv-axis-werte" class="sv-axis-btn active"
onclick="setMatrixAxis('werte')"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;
border:1px solid var(--ecg-border);border-radius:3px;
cursor:pointer;background:var(--ecg-teal);color:#fff;">
Pro GWÖ-Wert
</button>
<button type="button" id="sv-axis-gruppen" class="sv-axis-btn"
onclick="setMatrixAxis('gruppen')"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;
border:1px solid var(--ecg-border);border-radius:3px;
cursor:pointer;background:var(--ecg-card-bg);color:var(--ecg-dark);">
Pro Berührungsgruppe
</button>
</div>
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
<!-- Sub 4: Empfehlungs-Konsistenz Bar Chart -->
@ -334,6 +352,36 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<script>
let _tabLoaded = { 'bl-partei': false, 'themen': false, 'stimmverhalten': false };
let _svCharts = { index: null, heuchelei: null, empfehlung: null, crossBl: null };
let _svMatrixAxis = 'werte'; // 'werte' or 'gruppen'
function setMatrixAxis(axis) {
_svMatrixAxis = axis;
const werteBtn = document.getElementById('sv-axis-werte');
const gruppenBtn = document.getElementById('sv-axis-gruppen');
if (axis === 'werte') {
werteBtn.style.background = 'var(--ecg-teal)';
werteBtn.style.color = '#fff';
gruppenBtn.style.background = 'var(--ecg-card-bg)';
gruppenBtn.style.color = 'var(--ecg-dark)';
} else {
gruppenBtn.style.background = 'var(--ecg-teal)';
gruppenBtn.style.color = '#fff';
werteBtn.style.background = 'var(--ecg-card-bg)';
werteBtn.style.color = 'var(--ecg-dark)';
}
loadMatrixHeatmap();
}
function loadMatrixHeatmap() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
if (_svMatrixAxis === 'gruppen') {
loadStimmIndexProGruppe(bl, exclude);
} else {
loadStimmIndexProWert(bl, exclude);
}
}
function switchTab(id, btn) {
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
@ -539,7 +587,7 @@ async function loadStimmverhalten() {
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadStimmIndexProWert(bl, exclude);
loadMatrixHeatmap();
loadEmpfehlungsKonsistenz(bl);
loadStimmIndexCrossBl(exclude);
}
@ -739,6 +787,51 @@ async function loadStimmIndexProWert(bl, exclude) {
}
}
async function loadStimmIndexProGruppe(bl, exclude) {
const wrap = document.getElementById('sv-wert-heatmap');
let url = `/api/auswertungen/stimm-index-pro-gruppe?exclude_antragsteller=${exclude}&min_n=3`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Heatmap …</div>';
try {
const r = await fetch(url);
const data = await r.json();
if (!data.fraktionen.length) {
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Daten für diesen Filter.</div>';
return;
}
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Fraktion</th>';
for (const g of data.gruppen) html += `<th style="font-size:10px;max-width:120px;white-space:normal;">${g}</th>`;
html += '</tr></thead><tbody>';
for (const partei of data.fraktionen) {
html += `<tr><th class="row-h">${partei}</th>`;
for (const gruppe of data.gruppen) {
const cell = (data.cells[partei] || {})[gruppe];
if (cell && cell.stimm_index != null && cell.ausreichend) {
const color = wertHeatColor(cell.stimm_index);
const sign = cell.stimm_index >= 0 ? '+' : '';
html += `<td style="background:${color};" title="${partei} × ${gruppe}: Index ${sign}${cell.stimm_index} (n_ja=${cell.n_ja}, n_nein=${cell.n_nein})">
${sign}${cell.stimm_index.toFixed(1)}
</td>`;
} else {
html += '<td class="empty"></td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
html += `<div class="meta-line">Datenbasis: ${data.n_assessments_matched} Anträge.
Mindest-N pro Zelle: 3. Skala 5..+5 (Gruppen-Score-Differenz Ja minus Nein).</div>`;
wrap.innerHTML = html;
} catch (e) {
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
}
}
async function loadEmpfehlungsKonsistenz(bl) {
const meta = document.getElementById('sv-empfehlung-meta');

View File

@ -17,8 +17,10 @@ from app.auswertungen import (
aggregate_heuchelei,
aggregate_stimm_index,
aggregate_stimm_index_cross_bl,
aggregate_stimm_index_pro_gruppe,
aggregate_stimm_index_pro_wert,
export_stimmverhalten_csv,
_gruppen_score_for_assessment,
_wert_score_for_assessment,
)
@ -377,6 +379,65 @@ class TestAggregateProWert:
assert "ausreichend" in cell
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_pro_gruppe (#166)
# ─────────────────────────────────────────────────────────────────────────────
class TestGruppenScoreHelper:
def test_extracts_per_row(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "A3", "rating": 5},
{"field": "B2", "rating": 4},
]
result = _gruppen_score_for_assessment(matrix)
# Zeile A: A1=3 + A3=5 → Ø=4
assert result["A"] == 4.0
# Zeile B: B2=4 → Ø=4
assert result["B"] == 4.0
def test_empty_matrix(self):
assert _gruppen_score_for_assessment([]) == {}
def test_invalid_entries_skipped(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "X9", "rating": 2}, # invalid row prefix
{"field": "", "rating": 5},
]
result = _gruppen_score_for_assessment(matrix)
assert result == {"A": 3.0}
class TestAggregateProGruppe:
def test_structure(self, sample_db):
out = aggregate_stimm_index_pro_gruppe(db_path=sample_db, min_n=1)
# 5 Berührungsgruppen-Labels
assert len(out["gruppen"]) == 5
assert "cells" in out
def test_cell_format(self, sample_db):
out = aggregate_stimm_index_pro_gruppe(db_path=sample_db, min_n=1)
if not out["fraktionen"]:
pytest.skip("no parteien — DB empty")
first_partei = out["fraktionen"][0]
# Pick first gruppe
first_gruppe = out["gruppen"][0]
cell = out["cells"][first_partei][first_gruppe]
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell
assert "ausreichend" in cell
def test_independent_from_pro_wert(self, sample_db):
"""Pro-Gruppe-Aggregation soll andere Achse als Pro-Wert haben."""
wert_out = aggregate_stimm_index_pro_wert(db_path=sample_db, min_n=1)
gruppe_out = aggregate_stimm_index_pro_gruppe(db_path=sample_db, min_n=1)
# Werte und Gruppen-Labels müssen disjunkt sein
assert set(wert_out["werte"]).isdisjoint(set(gruppe_out["gruppen"]))
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_cross_bl
# ─────────────────────────────────────────────────────────────────────────────