feat(#106): plenum_vote_results-Tabelle + Repository

DB-Schema fuer fraktions-aggregierte Plenum-Abstimmungsergebnisse:
- bundesland, drucksache, quelle_protokoll als Compound-PK
  (eine Drucksache kann mehrfach abgestimmt werden — Ausschuss-Empfehlung
  und finale Beschlussfassung leben nebeneinander)
- ergebnis (angenommen/abgelehnt/ueberwiesen/...), einstimmig-Flag
- fraktionen_ja/_nein/_enthaltung als JSON-Arrays
- quelle_protokoll (z.B. 'MMP18-119') + optional quelle_url
- Index auf (bundesland, drucksache) fuer Lookup-Path

Repository-API:
- upsert_plenum_vote(...) idempotent ueber Compound-PK
- get_plenum_votes(bl, drucksache) → Liste, neueste zuerst

7 Tests fuer Roundtrip, einstimmig-Flag, Idempotenz, Multi-Protokoll-Erhalt,
leere Queries, Unicode-Handling von 'GRÜNE'.

Refs #106 — naechster Schritt: Ingest-CLI gegen NRW-PDFs.
This commit is contained in:
Dotty Dotter 2026-04-28 08:01:26 +02:00
parent d640734641
commit ae3f48be41
2 changed files with 218 additions and 0 deletions

View File

@ -259,6 +259,32 @@ async def init_db():
) )
""") """)
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen (#106).
# Granularitaet: "GRUENE und SPD haben zugestimmt", nicht pro MP — das
# ist der Datentyp, der aus deterministischen Parsern wie
# protokoll_parser_nrw.py rauskommt.
# Compound-PK ueber quelle_protokoll, weil eine Drucksache mehrfach
# abgestimmt werden kann (Ausschuss-Empfehlung + Plenum-Beschluss).
await db.execute("""
CREATE TABLE IF NOT EXISTS 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)
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_pvr_bl_ds "
"ON plenum_vote_results(bundesland, drucksache)"
)
await db.commit() await db.commit()
@ -1181,3 +1207,88 @@ async def get_monitoring_new_today(scan_date: str) -> list[dict]:
pass pass
result.append(d) result.append(d)
return result return result
# ─── Plenum-Vote-Results (#106) ─────────────────────────────────────────────
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen.
# Quelle: protokoll_parser_nrw.py (NRW). BL-uebergreifender Parser ist #126.
async def upsert_plenum_vote(
*,
bundesland: str,
drucksache: str,
ergebnis: str,
einstimmig: bool,
fraktionen_ja: list[str],
fraktionen_nein: list[str],
fraktionen_enthaltung: list[str],
quelle_protokoll: str,
quelle_url: Optional[str] = None,
) -> None:
"""Schreibt ein Abstimmungsergebnis aus einem Plenarprotokoll.
Idempotent ueber den Compound-PK (bundesland, drucksache, quelle_protokoll):
derselbe Eintrag aus demselben Protokoll wird upgesertet, mehrfach-Voten
derselben Drucksache aus verschiedenen Protokollen behalten beide Eintraege.
"""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
await db.execute(
"""
INSERT INTO plenum_vote_results
(bundesland, drucksache, ergebnis, einstimmig,
fraktionen_ja, fraktionen_nein, fraktionen_enthaltung,
quelle_protokoll, quelle_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(bundesland, drucksache, quelle_protokoll) DO UPDATE SET
ergebnis = excluded.ergebnis,
einstimmig = excluded.einstimmig,
fraktionen_ja = excluded.fraktionen_ja,
fraktionen_nein = excluded.fraktionen_nein,
fraktionen_enthaltung = excluded.fraktionen_enthaltung,
quelle_url = excluded.quelle_url,
parsed_at = datetime('now')
""",
(
bundesland,
drucksache,
ergebnis,
1 if einstimmig else 0,
_json.dumps(fraktionen_ja, ensure_ascii=False),
_json.dumps(fraktionen_nein, ensure_ascii=False),
_json.dumps(fraktionen_enthaltung, ensure_ascii=False),
quelle_protokoll,
quelle_url,
),
)
await db.commit()
async def get_plenum_votes(bundesland: str, drucksache: str) -> list[dict]:
"""Alle Plenarprotokoll-Abstimmungen fuer eine Drucksache, neueste zuerst.
Eine Drucksache kann mehrfach abgestimmt werden (z.B. Ueberweisung +
finale Beschlussfassung), deshalb Liste statt Single.
"""
import json as _json
async with aiosqlite.connect(settings.db_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"""
SELECT * FROM plenum_vote_results
WHERE bundesland = ? AND drucksache = ?
ORDER BY parsed_at DESC
""",
(bundesland, drucksache),
)
out = []
for r in await rows.fetchall():
d = dict(r)
d["einstimmig"] = bool(d.get("einstimmig"))
for key in ("fraktionen_ja", "fraktionen_nein", "fraktionen_enthaltung"):
try:
d[key] = _json.loads(d.get(key) or "[]")
except Exception:
d[key] = []
out.append(d)
return out

View File

@ -552,3 +552,110 @@ class TestMerkliste:
assert count == 1 assert count == 1
listed = run(database.merkliste_list("user1")) listed = run(database.merkliste_list("user1"))
assert len([e for e in listed if e["antrag_id"] == "18/9001"]) == 1 assert len([e for e in listed if e["antrag_id"] == "18/9001"]) == 1
# ─── Plenum-Vote-Results (#106) ──────────────────────────────────────────────
class TestPlenumVoteResults:
def test_creates_table(self, db_path):
import aiosqlite
from app import database
run(database.init_db())
async def check():
async with aiosqlite.connect(db_path) as db:
cur = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='plenum_vote_results'"
)
return await cur.fetchone()
assert run(check()) is not None
def test_upsert_and_get_roundtrip(self, initialized_db):
from app import database
run(database.upsert_plenum_vote(
bundesland="NRW",
drucksache="18/1234",
ergebnis="angenommen",
einstimmig=False,
fraktionen_ja=["CDU", "GRÜNE"],
fraktionen_nein=["SPD", "AfD"],
fraktionen_enthaltung=[],
quelle_protokoll="MMP18-119",
quelle_url="https://landtag.nrw.de/MMP18-119.pdf",
))
result = run(database.get_plenum_votes("NRW", "18/1234"))
assert len(result) == 1
r = result[0]
assert r["ergebnis"] == "angenommen"
assert r["einstimmig"] is False
assert r["fraktionen_ja"] == ["CDU", "GRÜNE"]
assert r["fraktionen_nein"] == ["SPD", "AfD"]
assert r["fraktionen_enthaltung"] == []
assert r["quelle_protokoll"] == "MMP18-119"
def test_einstimmig_flag_persisted(self, initialized_db):
from app import database
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/100", ergebnis="überwiesen",
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
fraktionen_enthaltung=[], quelle_protokoll="MMP18-100",
))
result = run(database.get_plenum_votes("NRW", "18/100"))
assert result[0]["einstimmig"] is True
def test_idempotent_upsert_same_protokoll(self, initialized_db):
"""Zweiter Upsert mit demselben Protokoll → ein Eintrag, neue Werte."""
from app import database
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/200", ergebnis="abgelehnt",
einstimmig=False, fraktionen_ja=["AfD"], fraktionen_nein=["CDU", "SPD"],
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
))
# Re-Parse mit aktualisiertem Ergebnis
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/200", ergebnis="zurückgezogen",
einstimmig=False, fraktionen_ja=[], fraktionen_nein=[],
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
))
result = run(database.get_plenum_votes("NRW", "18/200"))
assert len(result) == 1
assert result[0]["ergebnis"] == "zurückgezogen"
def test_multiple_protokolle_keep_separate_records(self, initialized_db):
"""Eine Drucksache, zwei Protokolle (Ueberweisung + finale Abstimmung)
muessen beide erhalten bleiben."""
from app import database
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/300", ergebnis="überwiesen",
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
))
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/300", ergebnis="angenommen",
einstimmig=False, fraktionen_ja=["CDU", "SPD"], fraktionen_nein=["AfD"],
fraktionen_enthaltung=["GRÜNE"], quelle_protokoll="MMP18-119",
))
result = run(database.get_plenum_votes("NRW", "18/300"))
assert len(result) == 2
protokolle = {r["quelle_protokoll"] for r in result}
assert protokolle == {"MMP18-50", "MMP18-119"}
def test_empty_query_returns_empty_list(self, initialized_db):
from app import database
result = run(database.get_plenum_votes("NRW", "99/9999"))
assert result == []
def test_unicode_in_fraktionen_persisted(self, initialized_db):
"""GRÜNE mit Umlaut darf nicht ASCII-kodiert werden."""
from app import database
run(database.upsert_plenum_vote(
bundesland="NRW", drucksache="18/400", ergebnis="angenommen",
einstimmig=False, fraktionen_ja=["GRÜNE", "BÜNDNIS"],
fraktionen_nein=[], fraktionen_enthaltung=[],
quelle_protokoll="MMP18-1",
))
result = run(database.get_plenum_votes("NRW", "18/400"))
assert "GRÜNE" in result[0]["fraktionen_ja"]
assert "BÜNDNIS" in result[0]["fraktionen_ja"]