feat(#167): Empfehlungs-Konsistenz + CSV-Export Stimmverhalten

Phase-2-Erweiterungen des Stimmverhalten-Tabs:

**1. Empfehlungs-Konsistenz (#167):**
Pro Fraktion: Anteil der Anträge mit GWÖ-Empfehlung
"Uneingeschränkt unterstützen" oder "Unterstützen mit Änderungen",
bei denen die Fraktion trotzdem NEIN gestimmt hat. Orthogonal zur
Heuchelei-Quote — prüft NICHT gegen Wahlprogramm-Treue, sondern gegen
die GWÖ-Empfehlung des Systems.

- `aggregate_empfehlungs_konsistenz()` in app/auswertungen.py
- `GET /api/auswertungen/empfehlungs-konsistenz`
- 5. Chart-Sub-Section im Stimmverhalten-Tab (rote Bar Chart, 0..100%)

**2. CSV-Export (Phase-1-Querschnitts-TODO):**
Long-Format-CSV mit Spalten: drucksache, bundesland, wahlperiode, datum,
gwoe_score, empfehlung, partei, vote, ist_antragsteller. Macht alle
Stimmverhalten-Aussagen wissenschaftlich auswertbar (R/pandas/Excel).

- `export_stimmverhalten_csv()` in app/auswertungen.py
- `GET /api/auswertungen/stimmverhalten.csv` mit
  Filter-Parametern bundesland/wahlperiode/exclude_antragsteller
- "CSV-Export"-Button im Stimmverhalten-Tab neben dem Toggle

**Tests:** 27 Stimmverhalten-Tests (war 18, +4 Empfehlungs-Konsistenz,
+5 CSV-Export). Fixture um `empfehlung`-Spalte erweitert.

Suite: 989 Tests grün (war 980).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-29 22:56:35 +02:00
parent 5eabe0d9b3
commit d81753c4fb
4 changed files with 400 additions and 28 deletions

View File

@ -588,6 +588,89 @@ def aggregate_stimm_index_pro_wert(
}
def aggregate_empfehlungs_konsistenz(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
min_n: int = 5,
db_path: Optional[Path] = None,
) -> dict:
"""Pro Fraktion: Anteil der Antraege mit GWÖ-Empfehlung "Uneingeschraenkt
unterstuetzen" oder "Unterstuetzen mit Aenderungen", bei denen die
Fraktion trotzdem NEIN gestimmt hat.
Orthogonal zu Heuchelei-Score: prueft NICHT gegen Wahlprogramm-Treue,
sondern gegen die GWÖ-Empfehlung des Systems. Misst Inkonsistenz
zwischen "GWÖ haelt Antrag fuer gut" und "Fraktion stimmt dagegen".
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
# Empfehlung ist im JOIN-Helper noch nicht — eigener Lookup pro Drucksache.
# Statt Helper umzubauen: zweite Query auf assessments fuer empfehlung.
path = db_path or settings.db_path
if not Path(path).exists():
return {"fraktionen": [], "n_assessments_matched": 0, "filter": {
"bundesland": filter_bl, "wahlperiode": filter_wp, "min_n": min_n,
}}
conn = sqlite3.connect(str(path))
try:
empfehlung_map = {
(r[0], r[1]): r[2] for r in conn.execute(
"SELECT bundesland, drucksache, empfehlung FROM assessments"
).fetchall()
}
finally:
conn.close()
POSITIV = {"Uneingeschränkt unterstützen", "Unterstützen mit Änderungen"}
n_empfohlen: defaultdict[str, int] = defaultdict(int)
n_nein: defaultdict[str, int] = defaultdict(int)
n_ja: defaultdict[str, int] = defaultdict(int)
n_enth: defaultdict[str, int] = defaultdict(int)
seen_drucksachen = set()
for row in rows:
empfehlung = empfehlung_map.get((row["bundesland"], row["drucksache"]))
if empfehlung not in POSITIV:
continue
seen_drucksachen.add((row["bundesland"], row["drucksache"]))
all_voters = row["ja"] | row["nein"] | row["enthaltung"]
for f in all_voters:
n_empfohlen[f] += 1
if f in row["nein"]:
n_nein[f] += 1
elif f in row["ja"]:
n_ja[f] += 1
elif f in row["enthaltung"]:
n_enth[f] += 1
fraktionen_out = []
for partei in sorted(n_empfohlen):
total = n_empfohlen[partei]
nein = n_nein[partei]
quote = round(nein / total, 3) if total else None
fraktionen_out.append({
"partei": partei,
"n_empfohlen": total,
"n_nein_trotz_empfehlung": nein,
"n_ja": n_ja[partei],
"n_enth": n_enth[partei],
"konsistenz_quote": quote,
"ausreichend": total >= min_n,
})
fraktionen_out.sort(
key=lambda f: (f["konsistenz_quote"] or 0), reverse=True,
)
return {
"fraktionen": fraktionen_out,
"n_assessments_matched": len(seen_drucksachen),
"filter": {
"bundesland": filter_bl,
"wahlperiode": filter_wp,
"min_n": min_n,
},
}
def aggregate_stimm_index_cross_bl(
filter_wp: Optional[str] = None,
exclude_antragsteller: bool = True,
@ -655,3 +738,70 @@ def aggregate_stimm_index_cross_bl(
"min_n": min_n,
},
}
# ─────────────────────────────────────────────────────────────────────────────
# 5. CSV-Export der Stimmverhalten-Aggregationen
#
# Long-Format-CSV pro Aggregation, analog zu export_long_format(). Macht die
# Aussagen wissenschaftlich auswertbar (R/pandas/Excel) ohne JSON-Parsing.
# ─────────────────────────────────────────────────────────────────────────────
def export_stimmverhalten_csv(
filter_bl: Optional[str] = None,
filter_wp: Optional[str] = None,
exclude_antragsteller: bool = True,
db_path: Optional[Path] = None,
) -> str:
"""Long-Format-CSV: Eine Zeile pro (drucksache, partei, vote).
Spalten: drucksache, bundesland, wahlperiode, datum, gwoe_score,
empfehlung, partei, vote (ja|nein|enthaltung), ist_antragsteller.
Eine Zeile pro Fraktion-Stimme wer also an N Anträgen mit Vote
teilgenommen hat, hat N Zeilen.
"""
rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path)
# Empfehlung-Map analog aggregate_empfehlungs_konsistenz
path = db_path or settings.db_path
empfehlung_map: dict[tuple[str, str], str] = {}
if Path(path).exists():
conn = sqlite3.connect(str(path))
try:
empfehlung_map = {
(r[0], r[1]): r[2] or "" for r in conn.execute(
"SELECT bundesland, drucksache, empfehlung FROM assessments"
).fetchall()
}
finally:
conn.close()
buf = io.StringIO()
writer = csv.writer(buf, dialect="excel")
writer.writerow([
"drucksache", "bundesland", "wahlperiode", "datum",
"gwoe_score", "empfehlung", "partei", "vote", "ist_antragsteller",
])
for row in rows:
bl = row["bundesland"]
wp = wahlperiode_for(row["datum"], bl) if bl else ""
empfehlung = empfehlung_map.get((bl, row["drucksache"]), "")
antragsteller = row["antragsteller"]
for vote_key, voters in [
("ja", row["ja"]),
("nein", row["nein"]),
("enthaltung", row["enthaltung"]),
]:
for partei in sorted(voters):
if exclude_antragsteller and partei in antragsteller:
continue
writer.writerow([
row["drucksache"], bl, wp or "", row["datum"],
f"{row['gwoe_score']:.2f}",
empfehlung,
partei, vote_key,
"1" if partei in antragsteller else "0",
])
return buf.getvalue()

View File

@ -2370,6 +2370,44 @@ async def auswertungen_stimm_index_cross_bl(
)
@app.get("/api/auswertungen/empfehlungs-konsistenz")
async def auswertungen_empfehlungs_konsistenz(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
min_n: int = 5,
):
"""Pro Fraktion: Anteil der Anträge mit GWÖ-Empfehlung "Uneingeschränkt
unterstützen"/"Unterstützen mit Änderungen", bei denen die Fraktion
trotzdem NEIN gestimmt hat (#167)."""
from .auswertungen import aggregate_empfehlungs_konsistenz
return aggregate_empfehlungs_konsistenz(
filter_bl=bundesland,
filter_wp=wahlperiode,
min_n=min_n,
)
@app.get("/api/auswertungen/stimmverhalten.csv")
async def auswertungen_stimmverhalten_csv(
bundesland: Optional[str] = None,
wahlperiode: Optional[str] = None,
exclude_antragsteller: bool = True,
):
"""Long-Format-CSV: eine Zeile pro (drucksache, partei, vote). Macht die
Stimmverhalten-Aussagen wissenschaftlich auswertbar (R/pandas/Excel)."""
from .auswertungen import export_stimmverhalten_csv
csv_text = export_stimmverhalten_csv(
filter_bl=bundesland,
filter_wp=wahlperiode,
exclude_antragsteller=exclude_antragsteller,
)
return Response(
content=csv_text,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="gwoe-stimmverhalten.csv"'},
)
# ─── 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

@ -217,12 +217,20 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
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 style="display:flex;align-items:center;gap:1rem;margin-top:6px;flex-wrap:wrap;">
<label style="display:inline-flex;align-items:center;gap:6px;font-size:11px;
font-family:var(--font-mono);cursor:pointer;">
<input type="checkbox" id="sv-exclude-antragsteller" checked
onchange="loadStimmverhalten()" />
Eigene Anträge ausschließen
</label>
<button type="button" onclick="downloadStimmverhaltenCsv()"
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;
border:1px solid var(--ecg-border);border-radius:3px;
background:var(--ecg-card-bg);cursor:pointer;">
CSV-Export (Long-Format)
</button>
</div>
</div>
<!-- Sub 1: Stimm-Index Bar Chart -->
@ -265,9 +273,23 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
</p>
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
<!-- Sub 4: Cross-BL Grouped Bar -->
<!-- Sub 4: Empfehlungs-Konsistenz Bar Chart -->
<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>
margin:1.5rem 0 0.5rem;">4. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Anteil der Anträge mit GWÖ-Empfehlung „Uneingeschränkt unterstützen" oder
„Unterstützen mit Änderungen", bei denen die Fraktion trotzdem
<em>Nein</em> gestimmt hat. Orthogonal zur Heuchelei-Quote — prüft NICHT
gegen Wahlprogramm-Treue, sondern gegen die GWÖ-Empfehlung des Systems.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-empfehlung-chart" style="max-height:380px;"></canvas>
</div>
<div id="sv-empfehlung-meta" class="meta-line"></div>
<!-- Sub 5: Cross-BL Grouped Bar -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">5. 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
@ -311,7 +333,7 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
{% block body_scripts %}
<script>
let _tabLoaded = { 'bl-partei': false, 'themen': false, 'stimmverhalten': false };
let _svCharts = { index: null, heuchelei: null, crossBl: null };
let _svCharts = { index: null, heuchelei: null, empfehlung: null, crossBl: null };
function switchTab(id, btn) {
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
@ -518,9 +540,19 @@ async function loadStimmverhalten() {
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadStimmIndexProWert(bl, exclude);
loadEmpfehlungsKonsistenz(bl);
loadStimmIndexCrossBl(exclude);
}
function downloadStimmverhaltenCsv() {
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
const exclude = document.getElementById('sv-exclude-antragsteller').checked ? '1' : '0';
let url = `/api/auswertungen/stimmverhalten.csv?exclude_antragsteller=${exclude}`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
window.location.href = url;
}
function svColor(idx) {
// Diverging color: positiv = teal/grün, negativ = rot, null = grau
if (idx == null) return 'rgba(120,120,120,0.4)';
@ -707,6 +739,65 @@ async function loadStimmIndexProWert(bl, exclude) {
}
}
async function loadEmpfehlungsKonsistenz(bl) {
const meta = document.getElementById('sv-empfehlung-meta');
let url = '/api/auswertungen/empfehlungs-konsistenz?min_n=3';
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.empfehlung) _svCharts.empfehlung.destroy();
const filtered = data.fraktionen.filter(f => f.ausreichend && f.konsistenz_quote != null);
if (!filtered.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} positiv-empfohlene Anträge — keine Fraktion erreicht das Mindest-N (3).`;
return;
}
const ctx = document.getElementById('sv-empfehlung-chart');
_svCharts.empfehlung = new Chart(ctx, {
type: 'bar',
data: {
labels: filtered.map(f => f.partei),
datasets: [{
label: 'Inkonsistenz-Quote',
data: filtered.map(f => Math.round(f.konsistenz_quote * 1000) / 10),
backgroundColor: filtered.map(f => `rgba(200,30,30,${Math.min(0.85, 0.25 + f.konsistenz_quote)})`),
}]
},
options: {
indexAxis: 'y',
responsive: true,
scales: {
x: { min: 0, max: 100, title: { display: true, text: 'Inkonsistenz-Quote (%) — NEIN trotz GWÖ-Empfehlung' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const f = filtered[ctx.dataIndex];
return [
`Anträge mit GWÖ-Empfehlung+: ${f.n_empfohlen}`,
`davon Nein gestimmt: ${f.n_nein_trotz_empfehlung}`,
`davon Ja gestimmt: ${f.n_ja}`,
`davon Enthaltung: ${f.n_enth}`,
];
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge mit GWÖ-Empfehlung „Uneingeschränkt unterstützen" oder „Unterstützen mit Änderungen".`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
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`;

View File

@ -13,10 +13,12 @@ from pathlib import Path
import pytest
from app.auswertungen import (
aggregate_empfehlungs_konsistenz,
aggregate_heuchelei,
aggregate_stimm_index,
aggregate_stimm_index_cross_bl,
aggregate_stimm_index_pro_wert,
export_stimmverhalten_csv,
_wert_score_for_assessment,
)
@ -107,71 +109,76 @@ def sample_db(tmp_path: Path) -> Path:
""")
# ─── Assessments ───
# Format: (ds, bl, datum, fraktionen, score, matrix, wp_scores, empfehlung)
now = datetime.utcnow().isoformat()
UNTER = "Uneingeschränkt unterstützen"
AENDR = "Unterstützen mit Änderungen"
UEBR = "Überarbeiten"
ABLEH = "Ablehnen"
assessments = [
# NRW WP18
# NRW WP18 — High-Score-Anträge mit GWÖ-Empfehlung positiv
("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))),
("SPD", 7, False), ("AfD", 1, False)), UNTER),
("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))),
("SPD", 6, False), ("AfD", 1, False)), AENDR),
("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))),
("SPD", 7, False), ("AfD", 1, False)), UNTER),
("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))),
("SPD", 7, False), ("AfD", 2, False)), AENDR),
("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)
("SPD", 7, False), ("AfD", 1, False)), UNTER),
# AfD-Antrag mit niedrigem Score → empfehlung=ablehnen
("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))),
("CDU", 4, False), ("SPD", 2, False)), ABLEH),
("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))),
("CDU", 3, False), ("SPD", 1, False)), ABLEH),
("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))),
("SPD", 5, False), ("AfD", 3, False)), UEBR),
("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))),
("CDU", 4, False), ("AfD", 1, False)), AENDR),
("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))),
("CDU", 5, False), ("AfD", 2, False)), UEBR),
# 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))),
("SPD", 6, False), ("AfD", 2, False)), AENDR),
("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))),
("SPD", 7, False), ("AfD", 1, False)), UNTER),
("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))),
("CDU", 3, False), ("SPD", 2, False)), ABLEH),
]
for ds, bl, dat, fr, sc, mat, wps in assessments:
for ds, bl, dat, fr, sc, mat, wps, emp 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),
"empfehlung, source, model, created_at, updated_at) VALUES "
"(?, ?, ?, ?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
(ds, f"Test {ds}", fr, dat, bl, sc, mat, wps, emp, now, now),
)
# ─── Vote-Results ───
@ -399,3 +406,89 @@ class TestAggregateCrossBl:
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_empfehlungs_konsistenz (#167)
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateEmpfehlungsKonsistenz:
def test_structure(self, sample_db):
out = aggregate_empfehlungs_konsistenz(db_path=sample_db, min_n=1)
assert "fraktionen" in out
assert "n_assessments_matched" in out
assert out["n_assessments_matched"] >= 1
def test_afd_high_konsistenz_quote(self, sample_db):
"""AfD stimmt NEIN bei allen GWÖ-positiv-empfohlenen Anträgen → Quote ~ 1.0."""
out = aggregate_empfehlungs_konsistenz(db_path=sample_db, min_n=1)
afd = next((f for f in out["fraktionen"] if f["partei"] == "AfD"), None)
assert afd is not None
# AfD wurde bei 18/A,B,C,D,E,8/A,8/B als NEIN eingetragen (alle UNTER/AENDR)
# und 18/I (AENDR) auch NEIN. → quote sollte hoch sein
assert afd["n_empfohlen"] >= 5
if afd["konsistenz_quote"] is not None:
assert afd["konsistenz_quote"] > 0.5
def test_grüne_low_konsistenz_quote(self, sample_db):
"""GRÜNE stimmt JA bei eigenen GWÖ-positiv Anträgen → Quote sehr niedrig."""
out = aggregate_empfehlungs_konsistenz(db_path=sample_db, min_n=1)
gr = next((f for f in out["fraktionen"] if f["partei"] == "GRÜNE"), None)
assert gr is not None
if gr["konsistenz_quote"] is not None:
assert gr["konsistenz_quote"] < 0.3
def test_only_positive_empfehlungen_count(self, sample_db):
"""Anträge mit empfehlung=Ablehnen/Überarbeiten dürfen NICHT zählen."""
out = aggregate_empfehlungs_konsistenz(db_path=sample_db, min_n=1)
# 7 Anträge mit empfehlung positiv (UNTER+AENDR): 18/A,B,C,D,E,I + 8/A,8/B
# = 8 positive. NICHT mitgezählt: 18/F,G,H,J + 8/C
assert out["n_assessments_matched"] == 8
# ─────────────────────────────────────────────────────────────────────────────
# export_stimmverhalten_csv
# ─────────────────────────────────────────────────────────────────────────────
class TestExportStimmverhaltenCsv:
def test_csv_header(self, sample_db):
csv_text = export_stimmverhalten_csv(db_path=sample_db)
first_line = csv_text.splitlines()[0]
assert "drucksache" in first_line
assert "partei" in first_line
assert "vote" in first_line
assert "empfehlung" in first_line
assert "ist_antragsteller" in first_line
def test_csv_has_rows(self, sample_db):
csv_text = export_stimmverhalten_csv(db_path=sample_db)
lines = csv_text.splitlines()
# Header + 13 Anträge × ~3 Voter pro Antrag (excl Antragsteller)
assert len(lines) > 20
def test_csv_excludes_antragsteller_by_default(self, sample_db):
csv_text = export_stimmverhalten_csv(db_path=sample_db)
# GRÜNE ist Antragsteller bei 18/A-E + 8/A,B → keine Zeilen
# mit ist_antragsteller=1 erlaubt im Default
for line in csv_text.splitlines()[1:]:
cols = line.split(",")
ist_antrag = cols[-1].strip()
assert ist_antrag == "0"
def test_csv_includes_antragsteller_when_disabled(self, sample_db):
csv_text = export_stimmverhalten_csv(
db_path=sample_db, exclude_antragsteller=False,
)
antragsteller_rows = [
line for line in csv_text.splitlines()[1:]
if line.strip().endswith(",1")
]
assert len(antragsteller_rows) > 0
def test_csv_filter_by_bundesland(self, sample_db):
csv_text = export_stimmverhalten_csv(db_path=sample_db, filter_bl="MV")
# nur MV-Drucksachen 8/A, 8/B, 8/C
for line in csv_text.splitlines()[1:]:
assert ",MV," in line