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:
parent
d81753c4fb
commit
79003d6056
@ -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,
|
||||
|
||||
17
app/main.py
17
app/main.py
@ -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,
|
||||
|
||||
@ -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 A–E:
|
||||
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');
|
||||
|
||||
|
||||
@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user