feat: Stimmverhalten × Gemeinwohl-Orientierung in /auswertungen

Neue Auswertungs-Sicht: Welche Fraktionen stimmen häufiger gemeinwohl-
orientierten Anträgen zu? Verschneidet GWÖ-Bewertung pro Antrag mit
dem tatsächlichen Plenum-Stimmverhalten der Fraktionen.

Vier Aussagen, alle hinter dem neuen Tab "Stimmverhalten":

1. **Gemeinwohl-Stimm-Index** pro Fraktion: Ø-GWÖ-Score der JA-Anträge
   minus Ø-GWÖ-Score der NEIN-Anträge. Domain −10..+10. Positiv = stimmt
   eher Gemeinwohl-affinen Anträgen zu.

2. **Heuchelei-Quote** pro Fraktion: Anteil der Anträge mit
   wahlprogramm_score ≥ 7 (passt zum eigenen Wahlprogramm), bei denen
   die Fraktion trotzdem NEIN gestimmt hat.

3. **Stimm-Index pro GWÖ-Wert** als Heatmap: 5 Spalten (Würde,
   Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie) aus den
   gwoe_matrix-Suffix-Spalten. Domain −5..+5 pro Zelle.

4. **Cross-BL-Vergleich** als Grouped Bar: gleiche Fraktion in
   mehreren Ländern. Nur Fraktionen in ≥2 BL mit ausreichender
   Datenbasis.

Querschnitt:
- `exclude_antragsteller=True` per Default (Toggle-Checkbox in UI),
  weil Antragsteller-Fraktionen quasi immer JA stimmen → würde Index
  verzerren. Toggle macht den Effekt sichtbar.
- `min_n=5` pro Fraktion fuer Stimm-Index, n=3 fuer Heatmaps.
  Fraktionen unter dem Cutoff werden als "Nicht aussagekräftig" separat
  gelistet.
- Caveat-Banner mit `n_assessments_matched` über jedem Chart.

Implementation:
- `app/auswertungen.py`: `_load_assessments_with_votes()` JOIN-Helper
  + 4 Aggregat-Funktionen analog zu `aggregate_matrix`-Pattern.
  Reuse: `normalize_partei` für Aliasing (BÜNDNIS 90/DIE GRÜNEN →
  GRÜNE), `wahlperiode_for` für WP-Filter.
- `app/main.py`: 4 neue read-only GET-Endpoints unter
  `/api/auswertungen/stimm-index|heuchelei|stimm-index-pro-wert|
  stimm-index-cross-bl`.
- `app/templates/v2/screens/auswertungen.html`: 4. Tab "Stimmverhalten"
  mit 4 Sub-Sektionen, Chart.js Bars + HTML-Heatmap-Tabelle.
- `tests/test_auswertungen_stimmverhalten.py`: 18 neue Tests
  (Fixture-DB mit 13 Assessments + 13 Vote-Results, Edge-Cases:
  GRÜNE-positiver-Index, AfD-negativer-Index, exclude_antragsteller-
  Effekt, min_n-Cutoff, leere DB).

Sparse-Data-Realität: aktuell 35 Assessments im prod, dünne Datenbasis
fuer einige Fraktionen. Feature wächst mit Issue #44 Batch-Bewertung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-29 15:30:02 +02:00
parent 33bb564ed1
commit 5eabe0d9b3
4 changed files with 1241 additions and 3 deletions

View File

@ -242,3 +242,416 @@ def export_long_format(db_path: Optional[Path] = None) -> str:
f"{r['gwoe_score']:.2f}",
])
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────────────
# 4. Stimmverhalten × Gemeinwohl-Orientierung (#106 + #145 Folge)
#
# JOIN ueber assessments.gwoe_score × plenum_vote_results.fraktionen_ja|_nein|
# _enthaltung. Vier Sichten:
# - aggregate_stimm_index: pro Fraktion JA-Mean minus NEIN-Mean von gwoe_score
# - aggregate_heuchelei: pro Fraktion % der Antraege mit
# wahlprogramm_score>=7 wo Vote=NEIN
# - aggregate_stimm_index_pro_wert: stimm_index pro (Fraktion, GWOe-Wert)
# aus gwoe_matrix-Spalten
# - aggregate_stimm_index_cross_bl: stimm_index pro (Fraktion, BL)
#
# Sparse-Data-Realitaet: 35 Assessments × 7000 Votes → meiste Anträge mit Vote
# haben kein Assessment. min_n-Cutoff filtert Fraktionen mit zu wenig Daten.
# ─────────────────────────────────────────────────────────────────────────────
# Spalten der GWOe-Matrix: {field-suffix → Wert-Name}. Die Field-IDs in der DB
# sind ``A1..E5`` mit Reihe = Beruehrungsgruppe (A-E), Spalte = Wert (1-5).
GWOE_WERTE = {
"1": "Menschenwürde",
"2": "Solidarität",
"3": "Ökologische Nachhaltigkeit",
"4": "Soziale Gerechtigkeit",
"5": "Transparenz & Demokratie",
}
def _load_assessments_with_votes(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
db_path: Optional[Path] = None,
) -> list[dict]:
"""JOIN assessments × plenum_vote_results auf (bundesland, drucksache).
Liefert pro Match-Zeile alle relevanten Felder fuer die Stimmverhalten-
Aggregationen nur Assessments mit gwoe_score und Vote-Result.
Mehrfach-Votes pro Drucksache (Compound-PK ueber quelle_protokoll)
erzeugen entsprechend mehrere Zeilen.
"""
path = db_path or settings.db_path
if not Path(path).exists():
return []
conn = sqlite3.connect(str(path))
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT a.drucksache, a.bundesland, a.datum,
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
FROM assessments a
INNER JOIN plenum_vote_results p
ON a.bundesland = p.bundesland
AND a.drucksache = p.drucksache
WHERE a.gwoe_score IS NOT NULL
"""
).fetchall()
finally:
conn.close()
out: list[dict] = []
for r in rows:
bl = r["bundesland"] or ""
if filter_bl is not None and bl != filter_bl:
continue
if filter_wp is not None:
wp = wahlperiode_for(r["datum"], bl)
if wp != filter_wp:
continue
def _parse_json_list(raw):
try:
return json.loads(raw) if raw else []
except (json.JSONDecodeError, TypeError):
return []
def _norm_set(raw_list):
"""Normalisiere eine Fraktionsliste auf kanonische Namen."""
return {
normalize_partei(p, bundesland=bl) or p
for p in raw_list if p
}
antragsteller = _norm_set(_parse_json_list(r["fraktionen"]))
ja = _norm_set(_parse_json_list(r["fraktionen_ja"]))
nein = _norm_set(_parse_json_list(r["fraktionen_nein"]))
enth = _norm_set(_parse_json_list(r["fraktionen_enthaltung"]))
out.append({
"drucksache": r["drucksache"],
"bundesland": bl,
"datum": r["datum"] or "",
"gwoe_score": r["gwoe_score"],
"gwoe_matrix": _parse_json_list(r["gwoe_matrix"]),
"gwoe_schwerpunkt": _parse_json_list(r["gwoe_schwerpunkt"]),
"wahlprogramm_scores": _parse_json_list(r["wahlprogramm_scores"]),
"antragsteller": antragsteller,
"ja": ja,
"nein": nein,
"enthaltung": enth,
"quelle_protokoll": r["quelle_protokoll"],
})
return out
def _avg(values: list[float]) -> Optional[float]:
return round(sum(values) / len(values), 2) if values else None
def aggregate_stimm_index(
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: Ø-GWÖ-Score der JA-Antraege minus Ø-GWÖ-Score der
NEIN-Antraege. Antragsteller-Bias optional rausgerechnet.
``stimm_index`` ist None wenn n_ja oder n_nein = 0; ``ausreichend``
ist True wenn n_ja >= min_n und n_nein >= min_n (Domain-Heuristik:
Aussage erst belastbar bei beidseitigem Mindest-N).
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
ja_scores: defaultdict[str, list[float]] = defaultdict(list)
nein_scores: defaultdict[str, list[float]] = defaultdict(list)
enth_scores: defaultdict[str, list[float]] = defaultdict(list)
for row in rows:
score = row["gwoe_score"]
skip = row["antragsteller"] if exclude_antragsteller else set()
for f in row["ja"] - skip:
ja_scores[f].append(score)
for f in row["nein"] - skip:
nein_scores[f].append(score)
for f in row["enthaltung"] - skip:
enth_scores[f].append(score)
parteien = sorted(set(ja_scores) | set(nein_scores) | set(enth_scores))
fraktionen_out = []
for p in parteien:
n_ja = len(ja_scores[p])
n_nein = len(nein_scores[p])
n_enth = len(enth_scores[p])
avg_ja = _avg(ja_scores[p])
avg_nein = _avg(nein_scores[p])
idx = (round(avg_ja - avg_nein, 2)
if avg_ja is not None and avg_nein is not None else None)
fraktionen_out.append({
"partei": p,
"n_ja": n_ja,
"n_nein": n_nein,
"n_enth": n_enth,
"avg_gwoe_ja": avg_ja,
"avg_gwoe_nein": avg_nein,
"avg_gwoe_enth": _avg(enth_scores[p]),
"stimm_index": idx,
"ausreichend": n_ja >= min_n and n_nein >= min_n,
})
fraktionen_out.sort(
key=lambda f: (f["stimm_index"] if f["stimm_index"] is not None else -999),
reverse=True,
)
return {
"fraktionen": fraktionen_out,
"n_assessments_matched": len({r["drucksache"] for r in rows}),
"n_votes_matched": len(rows),
"filter": {
"bundesland": filter_bl,
"wahlperiode": filter_wp,
"exclude_antragsteller": exclude_antragsteller,
"min_n": min_n,
},
}
def aggregate_heuchelei(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
score_threshold: float = 7.0,
min_n: int = 5,
db_path: Optional[Path] = None,
) -> dict:
"""Pro Fraktion: Anteil der Antraege mit wahlprogramm_score >= 7
(Antrag passt zum Wahlprogramm), bei denen die Fraktion trotzdem
NEIN gestimmt hat.
Misst Inkonsistenz zwischen Wahlversprechen und Stimmverhalten.
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
n_passt: defaultdict[str, int] = defaultdict(int)
n_passt_nein: defaultdict[str, int] = defaultdict(int)
n_passt_ja: defaultdict[str, int] = defaultdict(int)
n_passt_enth: defaultdict[str, int] = defaultdict(int)
for row in rows:
wp_scores = row["wahlprogramm_scores"] or []
for entry in wp_scores:
if not isinstance(entry, dict):
continue
raw_partei = entry.get("fraktion") or ""
partei = normalize_partei(raw_partei, bundesland=row["bundesland"]) or raw_partei
if not partei:
continue
wp_block = entry.get("wahlprogramm") or {}
score = wp_block.get("score")
if score is None or score < score_threshold:
continue
n_passt[partei] += 1
if partei in row["nein"]:
n_passt_nein[partei] += 1
elif partei in row["ja"]:
n_passt_ja[partei] += 1
elif partei in row["enthaltung"]:
n_passt_enth[partei] += 1
fraktionen_out = []
for partei in sorted(n_passt):
total = n_passt[partei]
nein = n_passt_nein[partei]
ja = n_passt_ja[partei]
enth = n_passt_enth[partei]
quote = round(nein / total, 3) if total else None
fraktionen_out.append({
"partei": partei,
"n_im_programm": total,
"n_nein_trotz_programm": nein,
"n_ja_passt": ja,
"n_enth_passt": enth,
"heuchelei_quote": quote,
"ausreichend": total >= min_n,
})
fraktionen_out.sort(
key=lambda f: (f["heuchelei_quote"] or 0), reverse=True,
)
return {
"fraktionen": fraktionen_out,
"n_assessments_matched": len({r["drucksache"] for r in rows}),
"filter": {
"bundesland": filter_bl,
"wahlperiode": filter_wp,
"score_threshold": score_threshold,
"min_n": min_n,
},
}
def _wert_score_for_assessment(matrix: list[dict]) -> dict[str, float]:
"""Aus der gwoe_matrix-Liste den Mittelwert pro Wert-Spalte (1..5)
berechnen.
matrix-Eintraege haben ``field`` wie ``A1, B3, E5`` und ``rating`` -5..+5.
Pro Spalten-Suffix wird Ø-rating berechnet fehlende Felder werden
ignoriert (LLM lässt nicht-relevante Zellen oft weg).
"""
by_col: 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[-1] in GWOE_WERTE:
by_col[field[-1]].append(float(rating))
return {col: sum(vals) / len(vals) for col, vals in by_col.items()}
def aggregate_stimm_index_pro_wert(
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, GWÖ-Wert) ein Stimm-Index-Analog: Ø-Wert-Score der
JA-Antraege minus Ø-Wert-Score der NEIN-Antraege.
Wert-Score eines Antrags = Ø(rating der gwoe_matrix-Felder mit dem
entsprechenden Spalten-Suffix). Domain: -5..+5 pro Wert.
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
werte_namen = list(GWOE_WERTE.values()) # in Reihenfolge 1..5
werte_keys = list(GWOE_WERTE.keys())
# Pro (partei, wert_key) → list[wert_score] fuer JA / NEIN
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:
wert_scores = _wert_score_for_assessment(row["gwoe_matrix"])
if not wert_scores:
continue
skip = row["antragsteller"] if exclude_antragsteller else set()
for f in row["ja"] - skip:
parteien_seen.add(f)
for col, sc in wert_scores.items():
ja[(f, col)].append(sc)
for f in row["nein"] - skip:
parteien_seen.add(f)
for col, sc in wert_scores.items():
nein[(f, col)].append(sc)
parteien = sorted(parteien_seen)
cells: dict[str, dict[str, dict]] = {}
for p in parteien:
cells[p] = {}
for col, wert_name in zip(werte_keys, werte_namen):
n_ja = len(ja[(p, col)])
n_nein = len(nein[(p, col)])
avg_ja = _avg(ja[(p, col)])
avg_nein = _avg(nein[(p, col)])
idx = (round(avg_ja - avg_nein, 2)
if avg_ja is not None and avg_nein is not None else None)
cells[p][wert_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,
"werte": werte_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_stimm_index_cross_bl(
filter_wp: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
db_path: Optional[Path] = None,
) -> dict:
"""Stimm-Index pro (Fraktion, Bundesland) — macht regionale Drift
sichtbar fuer bundesweit aktive Fraktionen (CDU, SPD, GRÜNE, AfD,
FDP, LINKE, BSW)."""
rows = _load_assessments_with_votes(None, filter_wp, db_path)
ja: defaultdict[tuple[str, str], list[float]] = defaultdict(list)
nein: defaultdict[tuple[str, str], list[float]] = defaultdict(list)
bl_seen: set[str] = set()
parteien_seen: set[str] = set()
for row in rows:
bl = row["bundesland"]
if not bl:
continue
bl_seen.add(bl)
score = row["gwoe_score"]
skip = row["antragsteller"] if exclude_antragsteller else set()
for f in row["ja"] - skip:
parteien_seen.add(f)
ja[(f, bl)].append(score)
for f in row["nein"] - skip:
parteien_seen.add(f)
nein[(f, bl)].append(score)
bundeslaender = sorted(bl_seen)
parteien = sorted(parteien_seen)
cells: dict[str, dict[str, dict]] = {}
for p in parteien:
cells[p] = {}
for bl in bundeslaender:
n_ja = len(ja[(p, bl)])
n_nein = len(nein[(p, bl)])
avg_ja = _avg(ja[(p, bl)])
avg_nein = _avg(nein[(p, bl)])
idx = (round(avg_ja - avg_nein, 2)
if avg_ja is not None and avg_nein is not None else None)
cells[p][bl] = {
"stimm_index": idx,
"n_ja": n_ja,
"n_nein": n_nein,
"ausreichend": n_ja >= min_n and n_nein >= min_n,
}
# Filter Fraktionen mit nur 1 BL → in Phase 1 nicht aufschlussreich
parteien_multi_bl = [
p for p in parteien
if sum(1 for bl in bundeslaender if cells[p][bl]["ausreichend"]) >= 2
]
return {
"fraktionen": parteien_multi_bl,
"fraktionen_alle": parteien,
"bundeslaender": bundeslaender,
"cells": cells,
"n_assessments_matched": len({r["drucksache"] for r in rows}),
"filter": {
"wahlperiode": filter_wp,
"exclude_antragsteller": exclude_antragsteller,
"min_n": min_n,
},
}

View File

@ -2292,6 +2292,84 @@ async def auswertungen_export_csv():
)
# ─── Stimmverhalten × Gemeinwohl-Orientierung (Issue: Auswertungen-Erweiterung) ─
@app.get("/api/auswertungen/stimm-index")
async def auswertungen_stimm_index(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
):
"""Pro Fraktion Ø-GWÖ der JA-Anträge MINUS Ø-GWÖ der NEIN-Anträge.
Antwort auf "Welche Fraktionen stimmen häufiger gemeinwohl-
orientierten Anträgen zu?". Antragsteller-Bias per Default raus.
"""
from .auswertungen import aggregate_stimm_index
return aggregate_stimm_index(
filter_bl=bundesland,
filter_wp=wahlperiode,
exclude_antragsteller=exclude_antragsteller,
min_n=min_n,
)
@app.get("/api/auswertungen/heuchelei")
async def auswertungen_heuchelei(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
score_threshold: float = 7.0,
min_n: int = 5,
):
"""Pro Fraktion: Anteil der Anträge mit wahlprogramm_score>=threshold,
bei denen die Fraktion trotzdem NEIN gestimmt hat.
Macht sichtbar, wer gegen die eigenen Wahlversprechen stimmt.
"""
from .auswertungen import aggregate_heuchelei
return aggregate_heuchelei(
filter_bl=bundesland,
filter_wp=wahlperiode,
score_threshold=score_threshold,
min_n=min_n,
)
@app.get("/api/auswertungen/stimm-index-pro-wert")
async def auswertungen_stimm_index_pro_wert(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
):
"""Stimm-Index aufgeschlüsselt pro GWÖ-Wert (5 Spalten:
Würde/Solidarität/Nachhaltigkeit/Gerechtigkeit/Demokratie)."""
from .auswertungen import aggregate_stimm_index_pro_wert
return aggregate_stimm_index_pro_wert(
filter_bl=bundesland,
filter_wp=wahlperiode,
exclude_antragsteller=exclude_antragsteller,
min_n=min_n,
)
@app.get("/api/auswertungen/stimm-index-cross-bl")
async def auswertungen_stimm_index_cross_bl(
wahlperiode: Optional[str] = None,
exclude_antragsteller: bool = True,
min_n: int = 5,
):
"""Stimm-Index pro (Fraktion, Bundesland) — regionale Drift sichtbar
fuer bundesweit aktive Fraktionen."""
from .auswertungen import aggregate_stimm_index_cross_bl
return aggregate_stimm_index_cross_bl(
filter_wp=wahlperiode,
exclude_antragsteller=exclude_antragsteller,
min_n=min_n,
)
# ─── v2 Frontend (#139 Phase 2 + Phase 3) ───────────────────────────────────
# / ist jetzt Default-v2. /v2 leitet auf / weiter; /v2/antrag/* auf /antrag/*.
# /classic ist die alte Ansicht (index.html unverändert).

View File

@ -160,7 +160,7 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Auswertungen</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Bundesland × Partei · Thema × Fraktion · Cluster
Bundesland × Partei · Thema × Fraktion · Stimmverhalten · Cluster
</p>
</div>
@ -168,6 +168,7 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<div class="auswert-tabs" role="tablist">
<button class="auswert-tab active" role="tab" onclick="switchTab('bl-partei', this)">BL × Partei</button>
<button class="auswert-tab" role="tab" onclick="switchTab('themen', this)">Thema × Fraktion</button>
<button class="auswert-tab" role="tab" onclick="switchTab('stimmverhalten', this)">Stimmverhalten</button>
<button class="auswert-tab" role="tab" onclick="switchTab('cluster', this)">Cluster</button>
</div>
@ -198,7 +199,87 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
</div>
</div>
<!-- Panel 3: Cluster-Link -->
<!-- Panel 3: Stimmverhalten × Gemeinwohl -->
<div class="auswert-panel" id="panel-stimmverhalten">
<div class="v2-kasten outline-blue" style="margin-bottom:1rem;">
<h4 style="margin-top:0;">Stimmverhalten × Gemeinwohl-Orientierung</h4>
<p style="font-size:12px;line-height:1.5;">
Verschneidung von <strong>GWÖ-Bewertung pro Antrag</strong> mit dem
tatsächlichen <strong>Plenum-Stimmverhalten der Fraktionen</strong>.
Beantwortet die Frage: Welche Fraktion stimmt häufiger Anträgen mit
hoher Gemeinwohl-Bewertung zu, welche lehnt sie eher ab?
</p>
<p style="font-size:11px;line-height:1.5;opacity:0.8;">
<strong>Datenbasis:</strong> Anträge, die sowohl eine GWÖ-Bewertung
<em>als auch</em> einen Plenum-Vote haben. Heute noch dünn — wächst
mit der Anzahl Bewertungen.
<strong>Eigene Anträge sind per Default ausgeschlossen</strong>, weil
Antragsteller-Fraktionen quasi immer „ja" stimmen — das würde den Index
verzerren.
</p>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:11px;
font-family:var(--font-mono);margin-top:4px;cursor:pointer;">
<input type="checkbox" id="sv-exclude-antragsteller" checked
onchange="loadStimmverhalten()" />
Eigene Anträge ausschließen
</label>
</div>
<!-- Sub 1: Stimm-Index Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">1. Gemeinwohl-Stimm-Index pro Fraktion</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Ø-GWÖ-Score der <em>Ja</em>-Stimmen <strong>minus</strong> Ø-GWÖ-Score der
<em>Nein</em>-Stimmen. Positiv = stimmt eher Gemeinwohl-affinen Anträgen zu.
Domain: 10..+10.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-index-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-index-meta" class="meta-line"></div>
<div id="sv-index-insufficient" style="font-size:11px;font-family:var(--font-mono);
opacity:0.6;margin-top:6px;"></div>
<!-- Sub 2: Heuchelei-Score Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">2. Heuchelei-Quote pro Fraktion</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Anteil der Anträge mit <strong>Wahlprogramm-Treue ≥ 7/10</strong>
(passt inhaltlich zum Wahlprogramm der Fraktion), bei denen die Fraktion
trotzdem <em>Nein</em> gestimmt hat. Hohe Werte = häufige Inkonsistenz
zwischen Wahlversprechen und Stimmverhalten.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-heuchelei-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-heuchelei-meta" class="meta-line"></div>
<!-- Sub 3: Pro GWÖ-Wert Heatmap -->
<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>
<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.
</p>
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
<!-- Sub 4: Cross-BL Grouped Bar -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">4. Stimm-Index pro Bundesland (Cross-BL)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Gleiche Fraktion in mehreren Ländern. Macht regionale Drift sichtbar
— gleiche Partei, unterschiedlicher Stimm-Index? Nur Fraktionen in ≥2 BL
mit ausreichender Datenbasis.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-cross-bl-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-cross-bl-meta" class="meta-line"></div>
</div>
<!-- Panel 4: Cluster-Link -->
<div class="auswert-panel" id="panel-cluster">
<div class="v2-kasten outline-blue">
<h4>Cluster-Ansicht</h4>
@ -229,7 +310,8 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
{% block body_scripts %}
<script>
let _tabLoaded = { 'bl-partei': false, 'themen': false };
let _tabLoaded = { 'bl-partei': false, 'themen': false, 'stimmverhalten': false };
let _svCharts = { index: null, heuchelei: null, crossBl: null };
function switchTab(id, btn) {
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
@ -241,6 +323,10 @@ function switchTab(id, btn) {
loadThemenMatrix();
_tabLoaded.themen = true;
}
if (id === 'stimmverhalten' && !_tabLoaded.stimmverhalten) {
loadStimmverhalten();
_tabLoaded.stimmverhalten = true;
}
}
// Bei BL-Wechsel aktive Panels neu laden
@ -249,6 +335,7 @@ window.addEventListener('v2-bl-changed', function () {
if (!activePanel) return;
if (activePanel.id === 'panel-bl-partei') loadBlMatrix();
if (activePanel.id === 'panel-themen') loadThemenMatrix();
if (activePanel.id === 'panel-stimmverhalten') loadStimmverhalten();
});
document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); });
@ -421,6 +508,265 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('modal-backdrop').classList.remove('show');
});
// ─── Stimmverhalten × Gemeinwohl ────────────────────────────────────────────
async function loadStimmverhalten() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadStimmIndexProWert(bl, exclude);
loadStimmIndexCrossBl(exclude);
}
function svColor(idx) {
// Diverging color: positiv = teal/grün, negativ = rot, null = grau
if (idx == null) return 'rgba(120,120,120,0.4)';
if (idx >= 0) return `rgba(0,157,165,${Math.min(0.85, 0.3 + Math.abs(idx) / 10)})`;
return `rgba(200,30,30,${Math.min(0.85, 0.3 + Math.abs(idx) / 10)})`;
}
async function loadStimmIndex(bl, exclude) {
const meta = document.getElementById('sv-index-meta');
const insufficientEl = document.getElementById('sv-index-insufficient');
let url = `/api/auswertungen/stimm-index?exclude_antragsteller=${exclude}&min_n=5`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.index) _svCharts.index.destroy();
const ausreichend = data.fraktionen.filter(f => f.ausreichend && f.stimm_index != null);
const nicht = data.fraktionen.filter(f => !f.ausreichend);
if (!ausreichend.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — keine Fraktion erreicht das Mindest-N (5 Ja UND 5 Nein).`;
insufficientEl.textContent = '';
return;
}
const ctx = document.getElementById('sv-index-chart');
_svCharts.index = new Chart(ctx, {
type: 'bar',
data: {
labels: ausreichend.map(f => f.partei),
datasets: [{
label: 'Stimm-Index',
data: ausreichend.map(f => f.stimm_index),
backgroundColor: ausreichend.map(f => svColor(f.stimm_index)),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = ausreichend[ctx.dataIndex];
return [
`Ø GWÖ Ja: ${f.avg_gwoe_ja}/10 (n=${f.n_ja})`,
`Ø GWÖ Nein: ${f.avg_gwoe_nein}/10 (n=${f.n_nein})`,
`Enthaltung: n=${f.n_enth}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge mit GWÖ-Bewertung × Plenum-Vote.`;
if (nicht.length) {
insufficientEl.innerHTML = '<strong>Nicht aussagekräftig (N&lt;5):</strong> '
+ nicht.map(f => `${f.partei} (n_ja=${f.n_ja}, n_nein=${f.n_nein})`).join(' · ');
} else {
insufficientEl.textContent = '';
}
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadHeuchelei(bl) {
const meta = document.getElementById('sv-heuchelei-meta');
let url = '/api/auswertungen/heuchelei?score_threshold=7&min_n=5';
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.heuchelei) _svCharts.heuchelei.destroy();
const filtered = data.fraktionen.filter(f => f.ausreichend && f.heuchelei_quote != null);
if (!filtered.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — zu wenige Anträge mit Wahlprogramm-Treue ≥ 7.`;
return;
}
const ctx = document.getElementById('sv-heuchelei-chart');
_svCharts.heuchelei = new Chart(ctx, {
type: 'bar',
data: {
labels: filtered.map(f => f.partei),
datasets: [{
label: 'Heuchelei-Quote',
data: filtered.map(f => Math.round(f.heuchelei_quote * 1000) / 10),
backgroundColor: filtered.map(f => `rgba(200,30,30,${Math.min(0.85, 0.25 + f.heuchelei_quote)})`),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: 0, max: 100, title: { display: true, text: 'Heuchelei-Quote (%)' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = filtered[ctx.dataIndex];
return [
`Anträge passend zum Programm: ${f.n_im_programm}`,
`davon Nein gestimmt: ${f.n_nein_trotz_programm}`,
`davon Ja gestimmt: ${f.n_ja_passt}`,
`davon Enthaltung: ${f.n_enth_passt}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — Threshold: Wahlprogramm-Treue ≥ 7/10.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
function wertHeatColor(idx) {
// Wert-Spalten haben Domain -5..+5 (rating-Skala der Matrix)
if (idx == null) return 'rgba(120,120,120,0.1)';
if (idx >= 0) return `rgba(136,158,51,${Math.min(0.7, 0.15 + Math.abs(idx) / 5)})`;
return `rgba(200,0,0,${Math.min(0.7, 0.15 + Math.abs(idx) / 5)})`;
}
async function loadStimmIndexProWert(bl, exclude) {
const wrap = document.getElementById('sv-wert-heatmap');
let url = `/api/auswertungen/stimm-index-pro-wert?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 w of data.werte) html += `<th>${w}</th>`;
html += '</tr></thead><tbody>';
for (const partei of data.fraktionen) {
html += `<tr><th class="row-h">${partei}</th>`;
for (const wert of data.werte) {
const cell = (data.cells[partei] || {})[wert];
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} × ${wert}: 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 (Wert-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 loadStimmIndexCrossBl(exclude) {
const meta = document.getElementById('sv-cross-bl-meta');
let url = `/api/auswertungen/stimm-index-cross-bl?exclude_antragsteller=${exclude}&min_n=3`;
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.crossBl) _svCharts.crossBl.destroy();
if (!data.fraktionen.length) {
meta.textContent = `Keine Fraktion in ≥2 Bundesländern mit ausreichender Datenbasis.`;
return;
}
// Datasets: pro Bundesland eine Bar-Reihe
const datasets = data.bundeslaender.map((bl, i) => {
const colors = ['rgba(0,157,165,0.7)', 'rgba(247,148,29,0.7)', 'rgba(136,158,51,0.7)',
'rgba(200,30,30,0.7)', 'rgba(150,100,200,0.7)', 'rgba(80,80,80,0.7)',
'rgba(50,150,50,0.7)', 'rgba(220,180,20,0.7)'];
return {
label: bl,
data: data.fraktionen.map(p => {
const cell = (data.cells[p] || {})[bl];
return (cell && cell.ausreichend) ? cell.stimm_index : null;
}),
backgroundColor: colors[i % colors.length],
};
});
const ctx = document.getElementById('sv-cross-bl-chart');
_svCharts.crossBl = new Chart(ctx, {
type: 'bar',
data: { labels: data.fraktionen, datasets: datasets },
options: {
responsive: true,
scales: {
y: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } }
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const partei = data.fraktionen[ctx.dataIndex];
const bl = ctx.dataset.label;
const cell = (data.cells[partei] || {})[bl];
if (!cell) return '';
return `n_ja=${cell.n_ja}, n_nein=${cell.n_nein}`;
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge. ${data.fraktionen.length} Fraktion(en) in ≥2 BL mit n≥3 ja UND n≥3 nein.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
// Load BL-Matrix on init
loadBlMatrix();
</script>

View File

@ -0,0 +1,401 @@
"""Tests fuer app.auswertungen.aggregate_stimm_index/heuchelei/pro_wert/cross_bl.
Verifiziert die JOIN-Aggregations-Logik gegen eine in-memory SQLite-DB
mit kontrollierten Sample-Assessments und Sample-Vote-Results.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
import pytest
from app.auswertungen import (
aggregate_heuchelei,
aggregate_stimm_index,
aggregate_stimm_index_cross_bl,
aggregate_stimm_index_pro_wert,
_wert_score_for_assessment,
)
# ─────────────────────────────────────────────────────────────────────────────
# Fixture: assessments + plenum_vote_results
# ─────────────────────────────────────────────────────────────────────────────
def _matrix(*ratings: tuple[str, int]) -> str:
"""Build a gwoe_matrix JSON-Array. Args: (field, rating) tuples."""
return json.dumps([
{"field": f, "label": f, "aspect": f[-1], "rating": r,
"symbol": "++" if r >= 4 else ("+" if r >= 1 else "")}
for f, r in ratings
])
def _wp_scores(*items: tuple[str, int, bool]) -> str:
"""Build a wahlprogramm_scores JSON-Array. Args: (fraktion, score, is_antrag)."""
return json.dumps([
{
"fraktion": fr,
"ist_antragsteller": ist,
"wahlprogramm": {"score": sc, "begründung": "stub"},
}
for fr, sc, ist in items
])
@pytest.fixture
def sample_db(tmp_path: Path) -> Path:
"""Lege eine Mini-DB mit assessments + plenum_vote_results an, die typische
Faelle abdeckt:
- NRW Antrag mit hohem GWOe-Score, GRÜNE/SPD ja, CDU/AfD nein
- NRW Antrag mit niedrigem GWOe-Score, AfD ja, GRÜNE nein (Anti-Pattern)
- NRW Antrag mit hohem Score, CDU Antragsteller (sollte bei Default
excluded werden)
- MV Anträge fuer Cross-BL-Test
- Heuchelei-Sample: GRÜNE-Antrag mit GRÜNE-wahlprogramm_score=9 aber
GRÜNE stimmt NEIN (synthetisch konstruiert)
"""
db = tmp_path / "test_stimmverhalten.db"
conn = sqlite3.connect(str(db))
conn.execute("""
CREATE TABLE assessments (
drucksache TEXT PRIMARY KEY,
title TEXT,
fraktionen TEXT,
datum TEXT,
bundesland TEXT,
gwoe_score REAL,
link TEXT,
gwoe_begruendung TEXT,
gwoe_matrix TEXT,
gwoe_schwerpunkt TEXT,
wahlprogramm_scores TEXT,
verbesserungen TEXT,
staerken TEXT,
schwaechen TEXT,
empfehlung TEXT,
empfehlung_symbol TEXT,
verbesserungspotenzial TEXT,
themen TEXT,
antrag_zusammenfassung TEXT,
antrag_kernpunkte TEXT,
source TEXT,
model TEXT,
created_at TEXT,
updated_at TEXT
)
""")
conn.execute("""
CREATE TABLE plenum_vote_results (
bundesland TEXT NOT NULL,
drucksache TEXT NOT NULL,
ergebnis TEXT NOT NULL,
einstimmig INTEGER NOT NULL DEFAULT 0,
fraktionen_ja TEXT NOT NULL DEFAULT '[]',
fraktionen_nein TEXT NOT NULL DEFAULT '[]',
fraktionen_enthaltung TEXT NOT NULL DEFAULT '[]',
quelle_protokoll TEXT NOT NULL,
quelle_url TEXT,
parsed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (bundesland, drucksache, quelle_protokoll)
)
""")
# ─── Assessments ───
now = datetime.utcnow().isoformat()
assessments = [
# NRW WP18
("18/A", "NRW", "2024-01-15", '["GRÜNE"]', 8.5,
_matrix(("A1", 4), ("B2", 3), ("C3", 5), ("D4", 4), ("E5", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("18/B", "NRW", "2024-02-15", '["GRÜNE"]', 7.5,
_matrix(("A2", 3), ("C3", 4), ("D4", 3), ("E5", 4)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 2, False),
("SPD", 6, False), ("AfD", 1, False))),
("18/C", "NRW", "2024-03-15", '["GRÜNE"]', 9.0,
_matrix(("A3", 5), ("D4", 4), ("E5", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("18/D", "NRW", "2024-04-15", '["GRÜNE"]', 7.0,
_matrix(("B2", 3), ("C2", 4), ("D2", 3)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
("SPD", 7, False), ("AfD", 2, False))),
("18/E", "NRW", "2024-05-15", '["GRÜNE"]', 8.0,
_matrix(("A1", 4), ("B1", 3), ("E5", 4)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
# AfD-Antrag mit niedrigem Score (Anti-Pattern)
("18/F", "NRW", "2024-06-15", '["AfD"]', 2.0,
_matrix(("A1", -3), ("B2", -2), ("E5", -4)),
_wp_scores(("AfD", 8, True), ("GRÜNE", 1, False),
("CDU", 4, False), ("SPD", 2, False))),
("18/G", "NRW", "2024-07-15", '["AfD"]', 1.5,
_matrix(("A1", -4), ("E5", -5)),
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
("CDU", 3, False), ("SPD", 1, False))),
("18/H", "NRW", "2024-08-15", '["CDU"]', 5.0,
_matrix(("D4", 1), ("D3", 0)),
_wp_scores(("CDU", 7, True), ("GRÜNE", 4, False),
("SPD", 5, False), ("AfD", 3, False))),
("18/I", "NRW", "2024-09-15", '["SPD"]', 6.5,
_matrix(("B2", 3), ("D2", 2)),
_wp_scores(("SPD", 8, True), ("GRÜNE", 6, False),
("CDU", 4, False), ("AfD", 1, False))),
("18/J", "NRW", "2024-10-15", '["SPD"]', 4.0,
_matrix(("D4", 1), ("E5", 0)),
_wp_scores(("SPD", 5, True), ("GRÜNE", 3, False),
("CDU", 5, False), ("AfD", 2, False))),
# MV WP8 fuer Cross-BL
("8/A", "MV", "2024-04-01", '["GRÜNE"]', 7.0,
_matrix(("A1", 3), ("D4", 4)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
("SPD", 6, False), ("AfD", 2, False))),
("8/B", "MV", "2024-05-01", '["GRÜNE"]', 8.0,
_matrix(("B2", 4), ("D4", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("8/C", "MV", "2024-06-01", '["AfD"]', 2.0,
_matrix(("A1", -3), ("E5", -4)),
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
("CDU", 3, False), ("SPD", 2, False))),
]
for ds, bl, dat, fr, sc, mat, wps in assessments:
conn.execute(
"INSERT INTO assessments (drucksache, title, fraktionen, datum, "
"bundesland, gwoe_score, gwoe_matrix, wahlprogramm_scores, "
"source, model, created_at, updated_at) VALUES "
"(?, ?, ?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
(ds, f"Test {ds}", fr, dat, bl, sc, mat, wps, now, now),
)
# ─── Vote-Results ───
# Jeder NRW-Hoch-Antrag (A-E,I): GRÜNE+SPD ja, CDU+AfD nein
# AfD-Antrag F,G: AfD ja, GRÜNE+SPD+CDU nein
# CDU-Antrag H: CDU ja, GRÜNE+SPD+AfD nein
# SPD-Antrag J: SPD ja, GRÜNE+CDU+AfD nein
# MV-Hoch-Anträge: GRÜNE+SPD ja, CDU+AfD nein
# MV-AfD-Antrag: AfD ja, andere nein
votes = [
("NRW", "18/A", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-A"),
("NRW", "18/B", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-B"),
("NRW", "18/C", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-C"),
("NRW", "18/D", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-D"),
("NRW", "18/E", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-E"),
("NRW", "18/F", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "MMP18-F"),
("NRW", "18/G", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "MMP18-G"),
("NRW", "18/H", "angenommen", '["CDU","AfD"]', '["GRÜNE","SPD"]', "MMP18-H"),
("NRW", "18/I", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-I"),
("NRW", "18/J", "abgelehnt", '["SPD"]', '["GRÜNE","CDU","AfD"]', "MMP18-J"),
("MV", "8/A", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "PlPr8-A"),
("MV", "8/B", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "PlPr8-B"),
("MV", "8/C", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "PlPr8-C"),
]
for bl, ds, erg, ja, nein, prot in votes:
conn.execute(
"INSERT INTO plenum_vote_results "
"(bundesland, drucksache, ergebnis, fraktionen_ja, fraktionen_nein, "
" quelle_protokoll) VALUES (?, ?, ?, ?, ?, ?)",
(bl, ds, erg, ja, nein, prot),
)
conn.commit()
conn.close()
return db
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateStimmIndex:
def test_grüne_high_index(self, sample_db):
"""GRÜNE: stimmt JA bei hohen GWÖ-Anträgen, NEIN bei niedrigen → hoher Index."""
out = aggregate_stimm_index(db_path=sample_db, min_n=2)
gruene = next(f for f in out["fraktionen"] if f["partei"] == "GRÜNE")
# JA: 18/A,B,D,E,I + 8/A,B (NRW+MV high score). Antragsteller-exclude
# entfernt 18/A-E + 8/A,B fuer GRÜNE → bleibt 18/I als JA. NEIN: 18/H + 18/J + 18/F + 18/G + 8/C.
assert gruene["n_ja"] >= 1
assert gruene["n_nein"] >= 4
# Wenn beide N>0: stimm_index sollte positiv sein
if gruene["stimm_index"] is not None:
assert gruene["stimm_index"] > 0
def test_afd_low_index(self, sample_db):
"""AfD: stimmt NEIN bei hohen GWÖ-Anträgen, JA bei niedrigen → negativer Index."""
out = aggregate_stimm_index(db_path=sample_db, min_n=2)
afd = next(f for f in out["fraktionen"] if f["partei"] == "AfD")
# AfD ist Antragsteller bei 18/F,G,8/C → diese werden ausgeschlossen.
# Bleibt: NEIN bei 18/A-E, 18/I, 18/J, 8/A, 8/B. AfD JA bei 18/H (CDU-Antrag).
assert afd["n_nein"] >= 4
# Wenn JA und NEIN beide gefuellt: Index negativ
if afd["stimm_index"] is not None:
assert afd["stimm_index"] < 0
def test_exclude_antragsteller_default(self, sample_db):
"""Default schliesst eigene Antraege aus."""
with_excl = aggregate_stimm_index(
db_path=sample_db, exclude_antragsteller=True, min_n=2,
)
without_excl = aggregate_stimm_index(
db_path=sample_db, exclude_antragsteller=False, min_n=2,
)
# GRÜNE hat 7 eigene Anträge → ohne Exclude mehr n_ja
gr_with = next(f for f in with_excl["fraktionen"] if f["partei"] == "GRÜNE")
gr_without = next(f for f in without_excl["fraktionen"] if f["partei"] == "GRÜNE")
assert gr_without["n_ja"] > gr_with["n_ja"]
def test_filter_by_bundesland(self, sample_db):
nrw = aggregate_stimm_index(db_path=sample_db, filter_bl="NRW", min_n=2)
assert nrw["n_assessments_matched"] == 10
mv = aggregate_stimm_index(db_path=sample_db, filter_bl="MV", min_n=2)
assert mv["n_assessments_matched"] == 3
def test_min_n_threshold(self, sample_db):
"""Fraktion mit n_ja=2, min_n=5 → ausreichend=False."""
out = aggregate_stimm_index(db_path=sample_db, min_n=5)
for f in out["fraktionen"]:
if f["n_ja"] < 5 or f["n_nein"] < 5:
assert f["ausreichend"] is False
def test_empty_db(self, tmp_path):
out = aggregate_stimm_index(db_path=tmp_path / "missing.db")
assert out["n_assessments_matched"] == 0
assert out["fraktionen"] == []
def test_sorted_by_index_desc(self, sample_db):
"""Output sorted by stimm_index descending — None at end."""
out = aggregate_stimm_index(db_path=sample_db, min_n=1)
indices = [f["stimm_index"] for f in out["fraktionen"]]
# All not-None values should be in descending order
not_none = [v for v in indices if v is not None]
assert not_none == sorted(not_none, reverse=True)
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_heuchelei
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateHeuchelei:
def test_returns_structure(self, sample_db):
out = aggregate_heuchelei(db_path=sample_db, min_n=1)
assert "fraktionen" in out
assert "n_assessments_matched" in out
def test_heuchelei_quote_calculation(self, sample_db):
out = aggregate_heuchelei(
db_path=sample_db, score_threshold=7.0, min_n=1,
)
# SPD: wahlprogramm_score>=7 in 18/A-E (5x). SPD stimmt JA in allen
# → heuchelei_quote = 0/5 = 0.
spd = next(f for f in out["fraktionen"] if f["partei"] == "SPD")
assert spd["n_im_programm"] >= 5
assert spd["heuchelei_quote"] == 0 or spd["heuchelei_quote"] is None
def test_threshold_filter(self, sample_db):
"""Hoher Threshold reduziert n_im_programm."""
low = aggregate_heuchelei(db_path=sample_db, score_threshold=1.0, min_n=1)
high = aggregate_heuchelei(db_path=sample_db, score_threshold=9.5, min_n=1)
# Bei threshold=1 sind quasi alle Fraktionen drin, bei 9.5 sehr wenige
low_total = sum(f["n_im_programm"] for f in low["fraktionen"])
high_total = sum(f["n_im_programm"] for f in high["fraktionen"])
assert low_total > high_total
# ─────────────────────────────────────────────────────────────────────────────
# _wert_score_for_assessment helper
# ─────────────────────────────────────────────────────────────────────────────
class TestWertScoreHelper:
def test_extracts_per_column(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "B1", "rating": 5},
{"field": "C2", "rating": 4},
]
result = _wert_score_for_assessment(matrix)
# Spalte 1 (Würde): A1=3 + B1=5 → Ø=4
assert result["1"] == 4.0
# Spalte 2 (Solidarität): C2=4 → Ø=4
assert result["2"] == 4.0
def test_empty_matrix(self):
assert _wert_score_for_assessment([]) == {}
def test_invalid_entries_skipped(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "", "rating": 5}, # empty field skipped
{"field": "X9", "rating": 2}, # invalid column suffix
"not a dict", # type error
]
result = _wert_score_for_assessment(matrix)
assert result == {"1": 3.0}
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_pro_wert
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateProWert:
def test_structure(self, sample_db):
out = aggregate_stimm_index_pro_wert(db_path=sample_db, min_n=1)
assert out["werte"] == [
"Menschenwürde", "Solidarität", "Ökologische Nachhaltigkeit",
"Soziale Gerechtigkeit", "Transparenz & Demokratie",
]
assert "cells" in out
for partei in out["fraktionen"]:
assert set(out["cells"][partei].keys()) == set(out["werte"])
def test_cell_format(self, sample_db):
out = aggregate_stimm_index_pro_wert(db_path=sample_db, min_n=1)
if not out["fraktionen"]:
pytest.skip("no parteien — DB empty")
first_partei = out["fraktionen"][0]
cell = out["cells"][first_partei]["Menschenwürde"]
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell
assert "ausreichend" in cell
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_cross_bl
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateCrossBl:
def test_structure(self, sample_db):
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=1)
assert "NRW" in out["bundeslaender"]
assert "MV" in out["bundeslaender"]
assert "fraktionen" in out
assert "fraktionen_alle" in out
def test_only_multi_bl_in_fraktionen(self, sample_db):
"""Fraktionen-Liste enthält nur Parteien mit ≥2 BL ausreichend-erfüllt."""
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=2)
# Bei min_n=2 sind die meisten Kombinationen nicht ausreichend.
# Test prueft nur Struktur: fraktionen ⊆ fraktionen_alle.
assert set(out["fraktionen"]).issubset(set(out["fraktionen_alle"]))
def test_cell_format(self, sample_db):
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=1)
if out["fraktionen_alle"]:
partei = out["fraktionen_alle"][0]
for bl in out["bundeslaender"]:
cell = out["cells"][partei][bl]
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell