From 2c0e94d29d400769846f2825c26643d83b47cf06 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Sat, 25 Apr 2026 20:55:16 +0200 Subject: [PATCH] feat(#106,#135,#128): Monitoring + abgeordnetenwatch + Wahlprogramm-Check - monitoring.py: taeglicher Scan-Adapter aller aktiven BL, kein Auto-Fetch (#135) - monitoring_digest.html: Mail-Template mit '0-Kontext'-Hinweis - abgeordnetenwatch.py + sync_*.py: Phase 1 Roll-Call-Voting (#106) - 17 Parlamente (16 BL + BT) - 9 BL-spezifische Drucksachen-Patterns + Date-Title-Fallback - 28977 Votes fuer BUND in DB - wahlprogramm_check.py: fehlende Programme erkennen (#128) - NI-Skip-Liste, NRW Empty-Query-Fallback Co-Authored-By: Claude Opus 4.7 (1M context) --- app/abgeordnetenwatch.py | 285 +++++++++++++++++++++++ app/monitoring.py | 332 +++++++++++++++++++++++++++ app/sync_abgeordnetenwatch.py | 157 +++++++++++++ app/templates/monitoring_digest.html | 128 +++++++++++ app/wahlprogramm_check.py | 37 +++ 5 files changed, 939 insertions(+) create mode 100644 app/abgeordnetenwatch.py create mode 100644 app/monitoring.py create mode 100644 app/sync_abgeordnetenwatch.py create mode 100644 app/templates/monitoring_digest.html create mode 100644 app/wahlprogramm_check.py diff --git a/app/abgeordnetenwatch.py b/app/abgeordnetenwatch.py new file mode 100644 index 0000000..949149e --- /dev/null +++ b/app/abgeordnetenwatch.py @@ -0,0 +1,285 @@ +"""Adapter für abgeordnetenwatch.de API v2 (#106 Phase 1). + +Liefert strukturierte Abstimmungsdaten (namentliche Abstimmungen) +pro Bundesland + Bundestag. Daten werden lokal in abgeordnetenwatch_polls +und abgeordnetenwatch_votes gecacht. + +API-Docs: https://www.abgeordnetenwatch.de/api/v2 +""" + +from __future__ import annotations + +import logging +import re +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Mapping unserer BL-Codes auf abgeordnetenwatch parliament-IDs. +# IDs aus GET /api/v2/parliaments (Stand April 2026). +PARLIAMENT_ID: dict[str, int] = { + "BT": 5, # Bundestag (auch "BUND") + "BUND": 5, # Alias + "NRW": 4, + "BE": 2, # Berlin + "HH": 3, # Hamburg + "BW": 6, # Baden-Württemberg + "RP": 7, # Rheinland-Pfalz + "LSA": 8, # Sachsen-Anhalt + "MV": 9, # Mecklenburg-Vorpommern + "HB": 10, # Bremen + "HE": 11, # Hessen + "NI": 12, # Niedersachsen + "BY": 13, # Bayern + "SL": 14, # Saarland + "TH": 15, # Thüringen + "BB": 16, # Brandenburg + "SN": 17, # Sachsen + "SH": 18, # Schleswig-Holstein +} + +_BASE = "https://www.abgeordnetenwatch.de/api/v2" + +# Drucksachen-Extraktion aus field_intro-HTML — pro Landtag eigenes URL-/ +# Dateinamen-Schema. Reihenfolge: erst Generic-Pattern "WP/NR" probieren +# (BUND, HE), dann BL-spezifische Patterns aus den Drucksachen-PDF-URLs. +_DS_PATTERNS: list[re.Pattern] = [ + # Generic: "20/12345" — BUND, HE und ähnliche + re.compile(r"\b(\d{1,2})/(\d{3,5})\b"), + # NRW: MMD18-2142.pdf + re.compile(r"MMD(\d{1,2})-(\d{3,5})\.pdf", re.IGNORECASE), + # BE: d19-0564.pdf + re.compile(r"/d(\d{1,2})-(\d{4})\.pdf", re.IGNORECASE), + # BW: 17_7713_D.pdf + re.compile(r"/(\d{1,2})_(\d{3,5})_D\.pdf", re.IGNORECASE), + # HB: D21L0568.pdf (DL) + re.compile(r"/D(\d{1,2})L(\d{3,5})\.pdf", re.IGNORECASE), + # SH: drucksache-20-00187.pdf + re.compile(r"drucksache-(\d{1,2})-(\d{3,5})\.pdf", re.IGNORECASE), + # SL: Gs17_0503.pdf + re.compile(r"/Gs(\d{1,2})_(\d{3,5})\.pdf", re.IGNORECASE), + # LSA: wp8/drs/d0145… (Reihenfolge: wp dann nr) + re.compile(r"/wp(\d{1,2})/drs/d(\d{3,5})", re.IGNORECASE), + # SN: dok_nr=2150&...&leg_per=8 — params können in beliebiger Reihenfolge auftreten + re.compile(r"dok_nr=(\d{3,5}).*leg_per=(\d{1,2})", re.IGNORECASE), + # RP: 538-18.pdf (Reihenfolge: nr-wp) + re.compile(r"/(\d{3,5})-(\d{1,2})\.pdf", re.IGNORECASE), +] + + +def extract_drucksache_from_intro(html: str) -> Optional[str]: + """Extrahiert die erste Drucksachen-Nummer aus dem field_intro-HTML. + + Probiert mehrere Landtags-spezifische URL-Patterns durch (NRW MMD-, + BW __D.pdf, etc.) und gibt die erste Fundstelle als + "/"-String zurück. Reihenfolge im Match-Tupel ist immer (wp, nr) — + die Patterns selbst kümmern sich um eventuelle URL-Reihenfolgen-Eigenheiten + (RP hat z.B. nr-wp, SN hat dok_nr=...&leg_per=..., dort drehen wir). + """ + if not html: + return None + for pat in _DS_PATTERNS: + m = pat.search(html) + if not m: + continue + # Spezialfall RP: nr-wp im URL → drehen, damit Output wp/nr + if "-" in m.re.pattern and m.re.pattern.startswith("/(\\d{3,5})"): + return f"{m.group(2)}/{m.group(1)}" + # Spezialfall SN: dok_nr (Gruppe 1) + leg_per (Gruppe 2) → wp/nr + if "dok_nr" in m.re.pattern: + return f"{m.group(2)}/{m.group(1)}" + # Standard: (wp, nr) + return f"{m.group(1)}/{m.group(2)}" + return None + + +async def fallback_drucksache_by_date_title( + datum: Optional[str], + titel: Optional[str], + bundesland: str, +) -> Optional[str]: + """Fallback-Drucksachen-Lookup via Datum + Titel gegen die Assessments-DB. + + Wird aufgerufen wenn ``extract_drucksache_from_intro`` kein Pattern findet + (betrifft MV/BY/BB/TH/HH/SL deren intro-HTML keine PDF-URLs enthält). + + Sucht Assessments für ``bundesland`` innerhalb von ±14 Tagen um ``datum`` + und einem Titel-Substring-Match. Gibt die Drucksachen-Nummer des ersten + Treffers zurück oder ``None``. + + Args: + datum: ISO-Datum des Polls (``field_poll_date``, z.B. ``"2026-04-01"``). + titel: Label/Titel des Polls (wird als LIKE-Substring geprüft). + bundesland: Unser BL-Code (z.B. ``"MV"``). + + Returns: + Drucksachen-Nummer als String (z.B. ``"7/1234"``) oder ``None``. + """ + if not datum or not titel: + return None + + # Titel-Substring: nur die ersten 40 Zeichen für den LIKE-Match verwenden, + # da Poll-Labels und Assessment-Titel leicht voneinander abweichen können. + titel_substr = titel.strip()[:40] + + from .config import settings as _settings + import aiosqlite as _aio + + async with _aio.connect(_settings.db_path) as db: + cur = await db.execute( + """ + SELECT drucksache FROM assessments + WHERE bundesland = ? + AND ABS(julianday(datum) - julianday(?)) < 14 + AND LOWER(title) LIKE ? + ORDER BY ABS(julianday(datum) - julianday(?)) + LIMIT 1 + """, + (bundesland.upper(), datum, f"%{titel_substr.lower()}%", datum), + ) + row = await cur.fetchone() + + if row: + logger.debug( + "fallback_drucksache_by_date_title: %s/%s → %s", + bundesland, datum, row[0], + ) + return row[0] + return None + + +async def fetch_polls(bundesland_code: str, limit: int = 100) -> list[dict]: + """Holt aktuelle Abstimmungen für ein Bundesland von abgeordnetenwatch. + + Gibt eine Liste von Poll-Dicts zurück; jedes Dict enthält zusätzlich + den geparsten Key ``drucksache`` (kann None sein). + + Args: + bundesland_code: Unser BL-Code (z.B. "NRW", "BT", "BUND"). + limit: Maximale Anzahl Polls; wird als range_end übergeben. + + Returns: + Liste von Poll-Dicts mit den Feldern aus der API plus ``drucksache``. + + Raises: + ValueError: Wenn der bundesland_code nicht in PARLIAMENT_ID ist. + httpx.HTTPError: Bei Netzwerkproblemen. + """ + parliament_id = PARLIAMENT_ID.get(bundesland_code.upper()) + if parliament_id is None: + raise ValueError( + f"Unbekannter BL-Code '{bundesland_code}'. " + f"Bekannte Codes: {sorted(PARLIAMENT_ID.keys())}" + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + # Zuerst aktuellen ParliamentPeriod für das Parlament holen — + # /polls filtert nach field_legislature (period-id), NICHT parliament-id. + pp_resp = await client.get( + f"{_BASE}/parliament-periods", + params={"parliament": parliament_id, "type": "legislature", "range_end": 5}, + ) + pp_resp.raise_for_status() + periods = (pp_resp.json() or {}).get("data") or [] + # Aktuelle Periode: sortiere nach start-date desc, nimm die neueste + current = sorted( + periods, + key=lambda x: x.get("start_date_period") or "", + reverse=True, + ) + if not current: + logger.warning("Keine ParliamentPeriod für %s (parliament_id=%d)", + bundesland_code, parliament_id) + return [] + period_id = current[0]["id"] + + # Polls für diese Periode + resp = await client.get( + f"{_BASE}/polls", + params={"field_legislature": period_id, "range_end": limit}, + ) + resp.raise_for_status() + data = resp.json() + + polls_raw: list[dict] = data.get("data") or [] + polls = [] + for p in polls_raw: + intro_html = p.get("field_intro") or "" + polls.append({ + "id": p.get("id"), + "label": p.get("label") or p.get("field_poll_date", ""), + "field_poll_date": p.get("field_poll_date"), + "field_accepted": p.get("field_accepted"), + "field_topics": p.get("field_topics") or [], + "field_intro": intro_html, + "field_legislature": p.get("field_legislature") or {}, + "drucksache": extract_drucksache_from_intro(intro_html), + }) + + logger.info( + "abgeordnetenwatch: %d polls für %s (parliament_id=%d)", + len(polls), bundesland_code, parliament_id, + ) + return polls + + +async def fetch_votes_for_poll(poll_id: int) -> list[dict]: + """Holt namentliche Einzelstimmen für eine Abstimmung. + + Args: + poll_id: ID der Abstimmung (aus polls[].id). + + Returns: + Liste von Vote-Dicts mit den Feldern: + poll_id, politician_id, politician_name, partei, vote. + vote ist einer von: "yes", "no", "abstain", "no_show". + + Raises: + httpx.HTTPError: Bei Netzwerkproblemen. + """ + # /votes?poll=X funktioniert (empirisch ermittelt); + # NICHT field_poll (500) und NICHT /polls/{id}?related_data=votes + # (liefert leeres related_data). Einfaches ?poll=. + url = f"{_BASE}/votes" + params = {"poll": poll_id, "range_end": 1000} + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + data = resp.json() + + votes_raw: list[dict] = data.get("data") or [] + votes = [] + for v in votes_raw: + politician = v.get("mandate") or v.get("politician") or {} + politician_id = politician.get("id") or v.get("mandate_id") + politician_name = politician.get("label") or politician.get("name") or "" + + # Partei aus politician.party oder fraction + partei = "" + party = politician.get("party") or {} + if isinstance(party, dict): + partei = party.get("label") or party.get("short_label") or "" + fraction = v.get("fraction") or {} + if not partei and isinstance(fraction, dict): + partei = fraction.get("full_name") or fraction.get("label") or "" + + vote_value = (v.get("vote") or "").lower() + # API liefert "yes"/"no"/"abstain"/"no_show" — direkt übernehmen + if vote_value not in ("yes", "no", "abstain", "no_show"): + vote_value = "no_show" + + votes.append({ + "poll_id": poll_id, + "politician_id": politician_id, + "politician_name": politician_name, + "partei": partei, + "vote": vote_value, + }) + + logger.info( + "abgeordnetenwatch: %d votes für poll_id=%d", len(votes), poll_id + ) + return votes diff --git a/app/monitoring.py b/app/monitoring.py new file mode 100644 index 0000000..dab5ed3 --- /dev/null +++ b/app/monitoring.py @@ -0,0 +1,332 @@ +"""Täglicher Monitoring-Scan für neue Landtags-Drucksachen (#135). + +Nur Metadaten — kein PDF-Download, kein LLM-Call. + +Ablauf: +1. Iteriert alle aktiven Bundesländer via aktive_bundeslaender(). +2. Ruft adapter.search("", limit=50) (Fallback: " " oder "*") auf. +3. UPSERTs Treffer in monitoring_scans. seen_first_at bleibt stabil, + last_seen_at wird immer gesetzt. +4. Aggregiert Ergebnisse in monitoring_daily_summary. +5. Gibt ScanResult zurück, aus dem run_monitoring_digest() den + Mail-Digest baut. + +Kosten-Schätzung (Qwen Plus, Stand April 2026): + Quelle: https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-7b-14b-72b-api-pricing + Input: 0.0004 USD / 1 K Token + Output: 0.0012 USD / 1 K Token + Kurs: 1 USD = 0.93 EUR (Näherung April 2026) +""" +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from .bundeslaender import aktive_bundeslaender + +logger = logging.getLogger(__name__) + +# ─── Kosten-Schätzung ──────────────────────────────────────────────────────── +# Preise aus DashScope-Dokumentation (USD, Stand April 2026): +# https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-7b-14b-72b-api-pricing +_QWEN_PLUS_INPUT_USD_PER_1K = 0.0004 +_QWEN_PLUS_OUTPUT_USD_PER_1K = 0.0012 +_USD_TO_EUR = 0.93 # Näherungskurs April 2026 (als Konstante OK für Schätzung) + +# Default-Annahmen pro Analyse (Durchschnittswerte aus Produktionsbetrieb) +_DEFAULT_AVG_IN_TOKENS = 20_000 +_DEFAULT_AVG_OUT_TOKENS = 3_000 + + +def estimate_cost_qwen_plus( + n_new: int, + avg_in_tokens: int = _DEFAULT_AVG_IN_TOKENS, + avg_out_tokens: int = _DEFAULT_AVG_OUT_TOKENS, +) -> float: + """Schätzt die Analysekosten in EUR für n_new neue Drucksachen (Qwen Plus). + + Rechnet auf Basis der offiziellen DashScope-Preise, Umrechnung USD→EUR + mit festem Näherungskurs. Ergebnis ist eine Schätzung, keine Garantie. + + Args: + n_new: Anzahl neuer Drucksachen. + avg_in_tokens: Durchschnittliche Input-Token pro Antrag (Default 20 000). + avg_out_tokens: Durchschnittliche Output-Token pro Antrag (Default 3 000). + + Returns: + Geschätzte Kosten in EUR. + """ + if n_new <= 0: + return 0.0 + input_cost_usd = (avg_in_tokens / 1000) * _QWEN_PLUS_INPUT_USD_PER_1K * n_new + output_cost_usd = (avg_out_tokens / 1000) * _QWEN_PLUS_OUTPUT_USD_PER_1K * n_new + total_eur = (input_cost_usd + output_cost_usd) * _USD_TO_EUR + return round(total_eur, 4) + + +# ─── Datenklassen ──────────────────────────────────────────────────────────── + +@dataclass +class BundeslandScanResult: + """Scan-Ergebnis für ein einzelnes Bundesland.""" + bundesland: str + total_seen: int = 0 + new_count: int = 0 + error: str | None = None + + +@dataclass +class DailyScanResult: + """Gesamtergebnis eines daily_scan()-Laufs.""" + scan_date: str # YYYY-MM-DD + results: list[BundeslandScanResult] = field(default_factory=list) + new_total: int = 0 # Summe aller new_count + total_seen: int = 0 # Summe aller total_seen + estimated_cost_eur: float = 0.0 + errors: list[str] = field(default_factory=list) + + +# ─── Adapter-Suche ─────────────────────────────────────────────────────────── + +DEFAULT_DAILY_LIMIT = 50 + +# Bundesländer, die vom täglichen Monitoring-Scan ausgenommen sind. +# NI (Niedersachsen): NILAS-Portal erfordert Login — unauthentifizierte Anfragen +# liefern Login-Page-HTML, das der JSON-Comment-Parser als ~50 Junk-Records parsed. +# Ausnahme bleibt bis ein gültiger HAR-Capture vorliegt (siehe Issue #22). +_MONITORING_SKIP: frozenset[str] = frozenset({"NI"}) + + +async def _search_adapter(adapter, bundesland_code: str, limit: int = DEFAULT_DAILY_LIMIT) -> list: + """Sucht via Adapter nach aktuellen Drucksachen. + + Probiert der Reihe nach: leerer String, Leerzeichen, Sternchen — + und fängt alle Exceptions ab, damit ein Adapter-Fehler den + Gesamt-Scan nicht abbricht. ``limit`` steuert pro-Adapter-Obergrenze; + für Initial-Seeding ggf. höher setzen. + """ + for query in ("", " ", "*"): + try: + results = await adapter.search(query, limit=limit) + return results + except Exception as e: + if query == "*": + # Alle Versuche gescheitert — Exception nach oben durchreichen + raise + logger.debug( + "%s: search(%r) fehlgeschlagen (%s), versuche nächsten Query", + bundesland_code, query, e, + ) + return [] + + +# ─── Haupt-Scan ────────────────────────────────────────────────────────────── + +async def daily_scan(limit: int = DEFAULT_DAILY_LIMIT) -> DailyScanResult: + """Täglicher Scan aller aktiven Bundesländer nach neuen Drucksachen. + + Kein PDF-Download, kein LLM-Call — nur Metadaten. ``limit`` gilt + pro Adapter; für Initial-Seeding größer setzen (z.B. 500). + """ + from .parlamente import ADAPTERS + from .database import upsert_monitoring_scan, upsert_monitoring_summary + + now_utc = datetime.now(timezone.utc) + scan_date = now_utc.strftime("%Y-%m-%d") + now_iso = now_utc.strftime("%Y-%m-%dT%H:%M:%S") + + result = DailyScanResult(scan_date=scan_date) + + active_bls = aktive_bundeslaender() + + for bl in active_bls: + if bl.code in _MONITORING_SKIP: + logger.debug("%s: Monitoring-Skip aktiv — übersprungen", bl.code) + continue + + adapter = ADAPTERS.get(bl.code) + if adapter is None: + logger.debug("Kein Adapter für %s — übersprungen", bl.code) + continue + + bl_result = BundeslandScanResult(bundesland=bl.code) + + try: + docs = await _search_adapter(adapter, bl.code, limit=limit) + except Exception as exc: + err_msg = f"{type(exc).__name__}: {str(exc)[:500]}" + logger.exception("Adapter-Fehler bei %s", bl.code) + bl_result.error = err_msg + result.errors.append(f"{bl.code}: {err_msg}") + await upsert_monitoring_summary( + scan_date=scan_date, + bundesland=bl.code, + total_seen=0, + new_count=0, + errors=err_msg, + ) + result.results.append(bl_result) + continue + + bl_result.total_seen = len(docs) + new_this_bl = 0 + + for doc in docs: + try: + is_new = await upsert_monitoring_scan( + bundesland=doc.bundesland, + drucksache=doc.drucksache, + title=doc.title, + datum=doc.datum, + typ=doc.typ, + typ_normiert=doc.typ_normiert, + fraktionen=doc.fraktionen, + link=doc.link, + now=now_iso, + ) + if is_new: + new_this_bl += 1 + except Exception: + logger.exception( + "DB-UPSERT fehlgeschlagen für %s/%s — wird übersprungen", + bl.code, getattr(doc, "drucksache", "?"), + ) + + bl_result.new_count = new_this_bl + + await upsert_monitoring_summary( + scan_date=scan_date, + bundesland=bl.code, + total_seen=bl_result.total_seen, + new_count=bl_result.new_count, + errors=None, + ) + + logger.info( + "%s: %d gesehen, %d neu", + bl.code, bl_result.total_seen, bl_result.new_count, + ) + result.results.append(bl_result) + + result.new_total = sum(r.new_count for r in result.results) + result.total_seen = sum(r.total_seen for r in result.results) + result.estimated_cost_eur = estimate_cost_qwen_plus(result.new_total) + + return result + + +# ─── Mail-Digest ───────────────────────────────────────────────────────────── + +async def run_monitoring_digest(recipient: str) -> dict: + """Führt daily_scan() durch und verschickt den Ergebnis-Digest per Mail. + + Args: + recipient: Empfänger-Adresse (typischerweise der Admin). + + Returns: + dict mit Scan-Statistiken + {"mail_sent": bool}. + """ + from .mail import send_mail + from .database import get_monitoring_new_today + from jinja2 import Environment, FileSystemLoader + from pathlib import Path + + scan_result = await daily_scan() + + # Neue Drucksachen für den heutigen Tag laden + new_docs = await get_monitoring_new_today(scan_result.scan_date) + + # Mail-Inhalt via Template rendern + tmpl_dir = Path(__file__).resolve().parent / "templates" + env = Environment(loader=FileSystemLoader(str(tmpl_dir)), autoescape=True) + tmpl = env.get_template("monitoring_digest.html") + + html_body = tmpl.render( + scan_date=scan_result.scan_date, + new_total=scan_result.new_total, + total_seen=scan_result.total_seen, + estimated_cost_eur=scan_result.estimated_cost_eur, + results=scan_result.results, + new_docs=new_docs, + errors=scan_result.errors, + ) + + # Plaintext-Variante + text_body = _render_plain(scan_result, new_docs) + + subject = ( + f"[GWÖ-Monitor] {scan_result.scan_date} — " + f"{scan_result.new_total} neue Drucksachen" + + (f" ({len(scan_result.errors)} Fehler)" if scan_result.errors else "") + ) + + mail_sent = False + try: + await send_mail(recipient, subject, text_body, html_body) + mail_sent = True + logger.info("Monitoring-Digest verschickt an %s", recipient) + except Exception: + logger.exception("Monitoring-Digest: Mail-Versand fehlgeschlagen") + + return { + "scan_date": scan_result.scan_date, + "new_total": scan_result.new_total, + "total_seen": scan_result.total_seen, + "estimated_cost_eur": scan_result.estimated_cost_eur, + "error_count": len(scan_result.errors), + "mail_sent": mail_sent, + } + + +def _render_plain(scan_result: DailyScanResult, new_docs: list[dict]) -> str: + """Baut den Plaintext-Part des Monitoring-Digests.""" + from .config import settings + + lines = [ + f"GWÖ-Antragsprüfer — Monitoring-Digest {scan_result.scan_date}", + "=" * 60, + "", + f"Neue Drucksachen: {scan_result.new_total}", + f"Gesamt gesehen: {scan_result.total_seen}", + f"Kosten-Schätzung: {scan_result.estimated_cost_eur:.4f} EUR", + "", + ] + + if scan_result.errors: + lines.append(f"Fehler ({len(scan_result.errors)}):") + for e in scan_result.errors: + lines.append(f" • {e}") + lines.append("") + + lines.append("Bundesland-Übersicht:") + for r in scan_result.results: + status = f"✓ {r.new_count} neu / {r.total_seen} gesehen" + if r.error: + status = f"✗ Fehler: {r.error[:80]}" + lines.append(f" {r.bundesland:6s} {status}") + lines.append("") + + if new_docs: + lines.append(f"Neue Drucksachen ({len(new_docs)}):") + for doc in new_docs[:30]: + title = (doc.get("title") or doc.get("drucksache") or "")[:80] + bl = doc.get("bundesland", "") + drucks = doc.get("drucksache", "") + lines.append(f" [{bl}] {drucks} — {title}") + if len(new_docs) > 30: + lines.append(f" … und {len(new_docs) - 30} weitere") + lines.append("") + + lines.append(f"Webapp: {settings.base_url}") + return "\n".join(lines) + + +if __name__ == "__main__": + # python -m app.monitoring + import sys + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + to = sys.argv[1] if len(sys.argv) > 1 else "mail@tobiasroedel.de" + stats = asyncio.run(run_monitoring_digest(to)) + print(f"Monitoring-Scan fertig: {stats}") diff --git a/app/sync_abgeordnetenwatch.py b/app/sync_abgeordnetenwatch.py new file mode 100644 index 0000000..0dffb7a --- /dev/null +++ b/app/sync_abgeordnetenwatch.py @@ -0,0 +1,157 @@ +"""CLI-Sync-Skript für abgeordnetenwatch.de (#106 Phase 1). + +Holt Polls + namentliche Stimmen für alle oder einen bestimmten BL-Code +und speichert sie via UPSERT in der lokalen SQLite-DB. + +Aufruf: + python -m app.sync_abgeordnetenwatch [--bundesland NRW] [--limit 50] + +Ohne --bundesland werden alle in PARLIAMENT_ID eingetragenen BL-Codes +abgearbeitet (BUND-Alias wird übersprungen, BT genügt). + +Ausgabe: + NRW: 12 polls neu, 340 votes neu + BT: 0 polls neu, 0 votes neu + … +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +async def sync_bundesland(bundesland_code: str, limit: int) -> tuple[int, int]: + """Synct einen BL-Code. Gibt (neue_polls, neue_votes) zurück.""" + from .abgeordnetenwatch import ( + PARLIAMENT_ID, fetch_polls, fetch_votes_for_poll, + fallback_drucksache_by_date_title, + ) + from .database import init_db, upsert_aw_poll, upsert_aw_vote + + await init_db() + + parliament_id = PARLIAMENT_ID[bundesland_code.upper()] + synced_at = datetime.now(timezone.utc).isoformat() + + polls = await fetch_polls(bundesland_code, limit=limit) + + new_polls = 0 + new_votes = 0 + + for poll in polls: + poll_id = poll.get("id") + if poll_id is None: + continue + + legislature = poll.get("field_legislature") or {} + legislature_label = ( + legislature.get("label") or legislature.get("name") or "" + if isinstance(legislature, dict) else str(legislature) + ) + + topics_raw = poll.get("field_topics") or [] + topics = [ + (t.get("label") or t.get("name") or str(t)) + if isinstance(t, dict) else str(t) + for t in topics_raw + ] + + # Primär: Drucksache aus intro-HTML geparst; Fallback über Datum+Titel + # für BL ohne PDF-URL im intro (MV/BY/BB/TH/HH/SL — Fix #142 Phase 3). + drucksache = poll.get("drucksache") + if drucksache is None: + drucksache = await fallback_drucksache_by_date_title( + datum=poll.get("field_poll_date"), + titel=poll.get("label"), + bundesland=bundesland_code, + ) + + is_new_poll = await upsert_aw_poll( + poll_id=poll_id, + parliament_id=parliament_id, + bundesland=bundesland_code.upper(), + drucksache=drucksache, + titel=poll.get("label"), + datum=poll.get("field_poll_date"), + accepted=poll.get("field_accepted"), + topics=topics, + legislature_label=legislature_label, + synced_at=synced_at, + ) + if is_new_poll: + new_polls += 1 + + # Votes laden und speichern + try: + votes = await fetch_votes_for_poll(poll_id) + except Exception: + logger.exception("Fehler beim Laden von Votes für poll_id=%d", poll_id) + continue + + for vote in votes: + politician_id = vote.get("politician_id") + if politician_id is None: + continue + is_new_vote = await upsert_aw_vote( + poll_id=poll_id, + politician_id=politician_id, + politician_name=vote.get("politician_name"), + partei=vote.get("partei"), + vote=vote.get("vote", "no_show"), + ) + if is_new_vote: + new_votes += 1 + + return new_polls, new_votes + + +async def main(bundesland: str | None, limit: int) -> None: + from .abgeordnetenwatch import PARLIAMENT_ID + + # Alle Codes ohne BUND-Alias (BT und BUND zeigen auf die selbe ID) + if bundesland: + codes = [bundesland.upper()] + else: + seen_ids: set[int] = set() + codes = [] + for code, pid in PARLIAMENT_ID.items(): + if pid not in seen_ids: + seen_ids.add(pid) + codes.append(code) + + for code in codes: + try: + new_polls, new_votes = await sync_bundesland(code, limit) + print(f"{code:4s}: {new_polls} polls neu, {new_votes} votes neu") + except Exception: + logger.exception("Fehler beim Sync für %s", code) + print(f"{code:4s}: FEHLER (siehe Log)") + + +def _cli() -> None: + parser = argparse.ArgumentParser( + description="Sync abgeordnetenwatch-Abstimmungsdaten in die lokale DB." + ) + parser.add_argument( + "--bundesland", "-b", + default=None, + help="BL-Code (z.B. NRW, BT). Ohne Angabe: alle Codes.", + ) + parser.add_argument( + "--limit", "-n", + type=int, + default=100, + help="Maximale Anzahl Polls pro BL (default: 100).", + ) + args = parser.parse_args() + asyncio.run(main(args.bundesland, args.limit)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + _cli() diff --git a/app/templates/monitoring_digest.html b/app/templates/monitoring_digest.html new file mode 100644 index 0000000..d54f75f --- /dev/null +++ b/app/templates/monitoring_digest.html @@ -0,0 +1,128 @@ + + + + + +GWÖ-Monitor {{ scan_date }} + + + +

GWÖ-Antragsprüfer — Monitoring {{ scan_date }}

+

Täglicher Scan aller aktiven Bundesländer

+ +{% if new_total == 0 %} +
+ Heute keine Änderungen. Alle {{ total_seen }} in den Landtags-Portalen sichtbaren Drucksachen sind bereits seit dem letzten Scan bekannt. Das heißt: die Portale haben seit gestern keine neuen Anträge publiziert — nicht: der Scan war erfolglos. +
+{% endif %} + + + + + + + + + + + + + + + + {% if errors %} + + + + + {% endif %} +
Neue Drucksachen seit letztem Scan{{ new_total }}
Im Portal aktuell sichtbar (inkl. bekannter){{ total_seen }}
Kosten-Schätzung (alle analysieren) + {{ "%.4f"|format(estimated_cost_eur) }} EUR +  (Qwen Plus, Näherung) +
Adapter-Fehler{{ errors|length }}
+ + +{% if errors %} +
+ Fehler-Details: +
    + {% for e in errors %} +
  • {{ e }}
  • + {% endfor %} +
+
+{% endif %} + + +

Bundesland-Übersicht

+ + + + + + + + + + + {% for r in results %} + + + + + + + {% endfor %} + +
BLGesehenNeuStatus
{{ r.bundesland }}{{ r.total_seen }} + {{ r.new_count }} + + {% if r.error %} + ✗ {{ r.error[:100] }} + {% elif r.new_count > 0 %} + ✓ {{ r.new_count }} neue + {% else %} + keine Änderung + {% endif %} +
+ + +{% if new_docs %} +

+ Neue Drucksachen ({{ new_docs|length }}) +

+{% for doc in new_docs[:30] %} +
+ {{ doc.bundesland }} + {{ doc.drucksache }} + {% if doc.datum %} + {{ doc.datum }} + {% endif %} +
+ {{ (doc.title or doc.drucksache or '')[:120] }} + {% if doc.fraktionen %} +
+ {% if doc.fraktionen is iterable and doc.fraktionen is not string %} + {{ doc.fraktionen | join(', ') }} + {% else %} + {{ doc.fraktionen }} + {% endif %} + + {% endif %} +
+{% endfor %} +{% if new_docs|length > 30 %} +

… und {{ new_docs|length - 30 }} weitere neue Drucksachen.

+{% endif %} +{% else %} +

Keine neuen Drucksachen heute.

+{% endif %} + + +
+

+ GWÖ-Antragsprüfer Monitoring · Kosten-Schätzung basiert auf Qwen-Plus-Preisen (DashScope, April 2026) · + Nur Metadaten — kein LLM-Call im Scan +

+ + diff --git a/app/wahlprogramm_check.py b/app/wahlprogramm_check.py new file mode 100644 index 0000000..2383b5d --- /dev/null +++ b/app/wahlprogramm_check.py @@ -0,0 +1,37 @@ +"""Erkennung fehlender Wahlprogramme (#128). + +Prüft für ein gegebenes Bundesland, welche der im Landtag vertretenen +Fraktionen in der WAHLPROGRAMME-Registry nicht hinterlegt sind. +Wird nach dem LLM-Call in analyze_antrag() aufgerufen, damit das +Assessment-Ergebnis die Lücken explizit ausweist. +""" + +from .bundeslaender import BUNDESLAENDER +from .wahlprogramme import WAHLPROGRAMME + + +def check_missing_programmes(bundesland: str, fraktionen: list[str]) -> list[str]: + """Gibt eine Liste der Fraktions-Namen zurück, für die kein Wahlprogramm + im gegebenen Bundesland hinterlegt ist. + + Args: + bundesland: Bundesland-Code (z.B. "NRW", "BY"). + fraktionen: Liste der Fraktionen, die geprüft werden sollen + (typischerweise aus BUNDESLAENDER[bl].landtagsfraktionen). + + Returns: + Geordnete Liste der Fraktions-Namen ohne hinterlegtes Wahlprogramm. + Leere Liste, wenn für alle Fraktionen Programme vorliegen oder + fraktionen leer ist. + + Raises: + ValueError: Wenn das Bundesland nicht in BUNDESLAENDER bekannt ist. + """ + if bundesland not in BUNDESLAENDER: + raise ValueError(f"Unbekanntes Bundesland: {bundesland!r}") + + if not fraktionen: + return [] + + indexed = WAHLPROGRAMME.get(bundesland, {}) + return [f for f in fraktionen if f not in indexed]