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:
parent
5eabe0d9b3
commit
d81753c4fb
@ -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(
|
def aggregate_stimm_index_cross_bl(
|
||||||
filter_wp: Optional[str] = None,
|
filter_wp: Optional[str] = None,
|
||||||
exclude_antragsteller: bool = True,
|
exclude_antragsteller: bool = True,
|
||||||
@ -655,3 +738,70 @@ def aggregate_stimm_index_cross_bl(
|
|||||||
"min_n": min_n,
|
"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()
|
||||||
|
|||||||
38
app/main.py
38
app/main.py
@ -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) ───────────────────────────────────
|
# ─── v2 Frontend (#139 Phase 2 + Phase 3) ───────────────────────────────────
|
||||||
# / ist jetzt Default-v2. /v2 leitet auf / weiter; /v2/antrag/* auf /antrag/*.
|
# / ist jetzt Default-v2. /v2 leitet auf / weiter; /v2/antrag/* auf /antrag/*.
|
||||||
# /classic ist die alte Ansicht (index.html unverändert).
|
# /classic ist die alte Ansicht (index.html unverändert).
|
||||||
|
|||||||
@ -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
|
Antragsteller-Fraktionen quasi immer „ja" stimmen — das würde den Index
|
||||||
verzerren.
|
verzerren.
|
||||||
</p>
|
</p>
|
||||||
|
<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;
|
<label style="display:inline-flex;align-items:center;gap:6px;font-size:11px;
|
||||||
font-family:var(--font-mono);margin-top:4px;cursor:pointer;">
|
font-family:var(--font-mono);cursor:pointer;">
|
||||||
<input type="checkbox" id="sv-exclude-antragsteller" checked
|
<input type="checkbox" id="sv-exclude-antragsteller" checked
|
||||||
onchange="loadStimmverhalten()" />
|
onchange="loadStimmverhalten()" />
|
||||||
Eigene Anträge ausschließen
|
Eigene Anträge ausschließen
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Sub 1: Stimm-Index Bar Chart -->
|
<!-- Sub 1: Stimm-Index Bar Chart -->
|
||||||
@ -265,9 +273,23 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
|||||||
</p>
|
</p>
|
||||||
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
|
<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);
|
<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;">
|
<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 Fraktion in mehreren Ländern. Macht regionale Drift sichtbar
|
||||||
— gleiche Partei, unterschiedlicher Stimm-Index? Nur Fraktionen in ≥2 BL
|
— 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 %}
|
{% block body_scripts %}
|
||||||
<script>
|
<script>
|
||||||
let _tabLoaded = { 'bl-partei': false, 'themen': false, 'stimmverhalten': false };
|
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) {
|
function switchTab(id, btn) {
|
||||||
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
|
||||||
@ -518,9 +540,19 @@ async function loadStimmverhalten() {
|
|||||||
loadStimmIndex(bl, exclude);
|
loadStimmIndex(bl, exclude);
|
||||||
loadHeuchelei(bl);
|
loadHeuchelei(bl);
|
||||||
loadStimmIndexProWert(bl, exclude);
|
loadStimmIndexProWert(bl, exclude);
|
||||||
|
loadEmpfehlungsKonsistenz(bl);
|
||||||
loadStimmIndexCrossBl(exclude);
|
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) {
|
function svColor(idx) {
|
||||||
// Diverging color: positiv = teal/grün, negativ = rot, null = grau
|
// Diverging color: positiv = teal/grün, negativ = rot, null = grau
|
||||||
if (idx == null) return 'rgba(120,120,120,0.4)';
|
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) {
|
async function loadStimmIndexCrossBl(exclude) {
|
||||||
const meta = document.getElementById('sv-cross-bl-meta');
|
const meta = document.getElementById('sv-cross-bl-meta');
|
||||||
let url = `/api/auswertungen/stimm-index-cross-bl?exclude_antragsteller=${exclude}&min_n=3`;
|
let url = `/api/auswertungen/stimm-index-cross-bl?exclude_antragsteller=${exclude}&min_n=3`;
|
||||||
|
|||||||
@ -13,10 +13,12 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.auswertungen import (
|
from app.auswertungen import (
|
||||||
|
aggregate_empfehlungs_konsistenz,
|
||||||
aggregate_heuchelei,
|
aggregate_heuchelei,
|
||||||
aggregate_stimm_index,
|
aggregate_stimm_index,
|
||||||
aggregate_stimm_index_cross_bl,
|
aggregate_stimm_index_cross_bl,
|
||||||
aggregate_stimm_index_pro_wert,
|
aggregate_stimm_index_pro_wert,
|
||||||
|
export_stimmverhalten_csv,
|
||||||
_wert_score_for_assessment,
|
_wert_score_for_assessment,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -107,71 +109,76 @@ def sample_db(tmp_path: Path) -> Path:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# ─── Assessments ───
|
# ─── Assessments ───
|
||||||
|
# Format: (ds, bl, datum, fraktionen, score, matrix, wp_scores, empfehlung)
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
|
UNTER = "Uneingeschränkt unterstützen"
|
||||||
|
AENDR = "Unterstützen mit Änderungen"
|
||||||
|
UEBR = "Überarbeiten"
|
||||||
|
ABLEH = "Ablehnen"
|
||||||
assessments = [
|
assessments = [
|
||||||
# NRW WP18
|
# NRW WP18 — High-Score-Anträge mit GWÖ-Empfehlung positiv
|
||||||
("18/A", "NRW", "2024-01-15", '["GRÜNE"]', 8.5,
|
("18/A", "NRW", "2024-01-15", '["GRÜNE"]', 8.5,
|
||||||
_matrix(("A1", 4), ("B2", 3), ("C3", 5), ("D4", 4), ("E5", 5)),
|
_matrix(("A1", 4), ("B2", 3), ("C3", 5), ("D4", 4), ("E5", 5)),
|
||||||
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
|
_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,
|
("18/B", "NRW", "2024-02-15", '["GRÜNE"]', 7.5,
|
||||||
_matrix(("A2", 3), ("C3", 4), ("D4", 3), ("E5", 4)),
|
_matrix(("A2", 3), ("C3", 4), ("D4", 3), ("E5", 4)),
|
||||||
_wp_scores(("GRÜNE", 8, True), ("CDU", 2, False),
|
_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,
|
("18/C", "NRW", "2024-03-15", '["GRÜNE"]', 9.0,
|
||||||
_matrix(("A3", 5), ("D4", 4), ("E5", 5)),
|
_matrix(("A3", 5), ("D4", 4), ("E5", 5)),
|
||||||
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
|
_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,
|
("18/D", "NRW", "2024-04-15", '["GRÜNE"]', 7.0,
|
||||||
_matrix(("B2", 3), ("C2", 4), ("D2", 3)),
|
_matrix(("B2", 3), ("C2", 4), ("D2", 3)),
|
||||||
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
|
_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,
|
("18/E", "NRW", "2024-05-15", '["GRÜNE"]', 8.0,
|
||||||
_matrix(("A1", 4), ("B1", 3), ("E5", 4)),
|
_matrix(("A1", 4), ("B1", 3), ("E5", 4)),
|
||||||
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
|
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
|
||||||
("SPD", 7, False), ("AfD", 1, False))),
|
("SPD", 7, False), ("AfD", 1, False)), UNTER),
|
||||||
# AfD-Antrag mit niedrigem Score (Anti-Pattern)
|
# AfD-Antrag mit niedrigem Score → empfehlung=ablehnen
|
||||||
("18/F", "NRW", "2024-06-15", '["AfD"]', 2.0,
|
("18/F", "NRW", "2024-06-15", '["AfD"]', 2.0,
|
||||||
_matrix(("A1", -3), ("B2", -2), ("E5", -4)),
|
_matrix(("A1", -3), ("B2", -2), ("E5", -4)),
|
||||||
_wp_scores(("AfD", 8, True), ("GRÜNE", 1, False),
|
_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,
|
("18/G", "NRW", "2024-07-15", '["AfD"]', 1.5,
|
||||||
_matrix(("A1", -4), ("E5", -5)),
|
_matrix(("A1", -4), ("E5", -5)),
|
||||||
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
|
_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,
|
("18/H", "NRW", "2024-08-15", '["CDU"]', 5.0,
|
||||||
_matrix(("D4", 1), ("D3", 0)),
|
_matrix(("D4", 1), ("D3", 0)),
|
||||||
_wp_scores(("CDU", 7, True), ("GRÜNE", 4, False),
|
_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,
|
("18/I", "NRW", "2024-09-15", '["SPD"]', 6.5,
|
||||||
_matrix(("B2", 3), ("D2", 2)),
|
_matrix(("B2", 3), ("D2", 2)),
|
||||||
_wp_scores(("SPD", 8, True), ("GRÜNE", 6, False),
|
_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,
|
("18/J", "NRW", "2024-10-15", '["SPD"]', 4.0,
|
||||||
_matrix(("D4", 1), ("E5", 0)),
|
_matrix(("D4", 1), ("E5", 0)),
|
||||||
_wp_scores(("SPD", 5, True), ("GRÜNE", 3, False),
|
_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
|
# MV WP8 fuer Cross-BL
|
||||||
("8/A", "MV", "2024-04-01", '["GRÜNE"]', 7.0,
|
("8/A", "MV", "2024-04-01", '["GRÜNE"]', 7.0,
|
||||||
_matrix(("A1", 3), ("D4", 4)),
|
_matrix(("A1", 3), ("D4", 4)),
|
||||||
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
|
_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,
|
("8/B", "MV", "2024-05-01", '["GRÜNE"]', 8.0,
|
||||||
_matrix(("B2", 4), ("D4", 5)),
|
_matrix(("B2", 4), ("D4", 5)),
|
||||||
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
|
_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,
|
("8/C", "MV", "2024-06-01", '["AfD"]', 2.0,
|
||||||
_matrix(("A1", -3), ("E5", -4)),
|
_matrix(("A1", -3), ("E5", -4)),
|
||||||
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
|
_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(
|
conn.execute(
|
||||||
"INSERT INTO assessments (drucksache, title, fraktionen, datum, "
|
"INSERT INTO assessments (drucksache, title, fraktionen, datum, "
|
||||||
"bundesland, gwoe_score, gwoe_matrix, wahlprogramm_scores, "
|
"bundesland, gwoe_score, gwoe_matrix, wahlprogramm_scores, "
|
||||||
"source, model, created_at, updated_at) VALUES "
|
"empfehlung, source, model, created_at, updated_at) VALUES "
|
||||||
"(?, ?, ?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
|
||||||
(ds, f"Test {ds}", fr, dat, bl, sc, mat, wps, now, now),
|
(ds, f"Test {ds}", fr, dat, bl, sc, mat, wps, emp, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── Vote-Results ───
|
# ─── Vote-Results ───
|
||||||
@ -399,3 +406,89 @@ class TestAggregateCrossBl:
|
|||||||
assert "stimm_index" in cell
|
assert "stimm_index" in cell
|
||||||
assert "n_ja" in cell
|
assert "n_ja" in cell
|
||||||
assert "n_nein" 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user