From 9d8a73e2a96ad940f8c107513fe314d6b560577a Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 1 Apr 2026 10:36:22 +0200 Subject: [PATCH] feat: Parteien-Filter, Klassifikation, Umsetzungsbewertung, KI-Neubewertung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vorlagen + Ketten: Partei-Dropdown-Filter mit Badges (#9) - Vorlagen-Detail: Ketten-Klassifikation mit Begründung anzeigen - Vorlagen-Detail: Umsetzungsbewertungen mit Score + Begründung - SPA-Routing: Catch-All für direkten URL-Zugriff - Status-Engine: Begründungen für alle Ketten-Status generieren - Kurze Beschlusstexte (<=5 Zeichen) nicht mehr als Beschluss werten - POST /api/bewertung/vorlagen/{id} + /ketten/{id} für KI-Neubewertung - Frontend: 'Neu bewerten' Button + Kommentarfeld auf beiden Detailseiten - Job-Status-Polling mit Spinner - ALLRIS-Rescrape vor Bewertung noch offen (#10) Closes #9 --- backend/src/tracker/api/models.py | 10 + .../src/tracker/api/routes/abstimmungen.py | 1 + backend/src/tracker/api/routes/bewertung.py | 312 ++++++++++++++++++ backend/src/tracker/api/routes/fraktionen.py | 128 +++++++ backend/src/tracker/api/routes/ketten.py | 12 +- backend/src/tracker/api/routes/orte.py | 1 + backend/src/tracker/api/routes/stats.py | 1 + backend/src/tracker/api/routes/vorlagen.py | 67 +++- backend/src/tracker/core/chains.py | 50 ++- backend/src/tracker/core/kategorien.py | 83 +++++ backend/src/tracker/core/status.py | 137 ++++++-- backend/src/tracker/db/session.py | 3 +- backend/src/tracker/main.py | 27 +- frontend/src/lib/api.ts | 42 +++ .../src/lib/components/UmsetzungBadge.svelte | 37 +++ frontend/src/lib/umsetzung.ts | 84 +++++ frontend/src/routes/+layout.svelte | 1 + frontend/src/routes/fraktionen/+page.svelte | 37 +++ .../routes/fraktionen/[kuerzel]/+page.svelte | 180 ++++++++++ frontend/src/routes/ketten/+page.svelte | 20 +- frontend/src/routes/ketten/[id]/+page.svelte | 83 ++++- frontend/src/routes/vorlagen/+page.svelte | 39 ++- .../src/routes/vorlagen/[id]/+page.svelte | 140 +++++++- scripts/deploy-db.sh | 78 +++++ scripts/fix_missing_summaries.py | 55 +++ scripts/geocode_orte.py | 2 +- scripts/geocode_pending.py | 93 ++++++ scripts/ketten_match.py | 281 ++++++++++++++++ scripts/repair_ketten_match.py | 49 +++ scripts/run_ketten_match.sh | 56 ++++ static/_app/env.js | 1 + static/_app/immutable/assets/app.CoxKXDok.css | 1 + .../immutable/assets/leaflet.CIGW-MKW.css | 1 + static/_app/immutable/chunks/3I_XkZiy.js | 1 + static/_app/immutable/chunks/6IKeDOr0.js | 1 + static/_app/immutable/chunks/B-uV6-Xr.js | 1 + static/_app/immutable/chunks/B08B5jt4.js | 2 + static/_app/immutable/chunks/BCXcTGin.js | 1 + static/_app/immutable/chunks/Bkzsmr9I.js | 1 + static/_app/immutable/chunks/Br6sCvve.js | 1 + static/_app/immutable/chunks/C-x9yHfs.js | 1 + static/_app/immutable/chunks/CBOKTDOo.js | 1 + static/_app/immutable/chunks/CMkOXag5.js | 1 + static/_app/immutable/chunks/CSnrw0fY.js | 1 + static/_app/immutable/chunks/CTIvq_GE.js | 1 + static/_app/immutable/chunks/CWOupeSg.js | 1 + static/_app/immutable/chunks/Cgke0YGN.js | 1 + static/_app/immutable/chunks/CjPBq9Bq.js | 1 + static/_app/immutable/chunks/Cjw4vZKn.js | 1 + static/_app/immutable/chunks/D2u1A_4g.js | 2 + static/_app/immutable/chunks/D6E-zrqv.js | 1 + static/_app/immutable/chunks/DAfY0XTB.js | 1 + static/_app/immutable/chunks/DCPIP6Ym.js | 1 + static/_app/immutable/chunks/DDErvS7v.js | 1 + static/_app/immutable/chunks/DIGUPa-Q.js | 1 + static/_app/immutable/chunks/DVOkFnep.js | 2 + static/_app/immutable/chunks/DaCWmHjB.js | 1 + static/_app/immutable/chunks/DfJQ0EIT.js | 2 + static/_app/immutable/chunks/DiDp2_zb.js | 1 + static/_app/immutable/chunks/DiIboHMF.js | 1 + static/_app/immutable/chunks/DjXdyWBG.js | 1 + static/_app/immutable/chunks/Duumi1XQ.js | 1 + static/_app/immutable/chunks/DxJV8wOg.js | 1 + static/_app/immutable/chunks/OvWQM58r.js | 1 + static/_app/immutable/chunks/PPVm8Dsz.js | 1 + static/_app/immutable/chunks/QfvBL-nR.js | 1 + static/_app/immutable/chunks/RVjQLo13.js | 1 + static/_app/immutable/chunks/fSdafo1a.js | 1 + static/_app/immutable/chunks/kjB3f-xG.js | 1 + static/_app/immutable/chunks/qS6hbb4Y.js | 1 + static/_app/immutable/chunks/splFp8Bu.js | 1 + static/_app/immutable/chunks/trpXq522.js | 1 + static/_app/immutable/chunks/utcFFRIM.js | 1 + static/_app/immutable/chunks/yhBelVs6.js | 1 + static/_app/immutable/entry/app.BZjvA3WU.js | 2 + static/_app/immutable/entry/app.CPVPz_La.js | 2 + static/_app/immutable/entry/start.BlI5Xt52.js | 1 + static/_app/immutable/entry/start.Cz3VRzIK.js | 1 + static/_app/immutable/nodes/0.BHTRPJLe.js | 1 + static/_app/immutable/nodes/0.DId8iTp4.js | 1 + static/_app/immutable/nodes/1.CYfReXZ2.js | 1 + static/_app/immutable/nodes/1.fI0LOJ0P.js | 1 + static/_app/immutable/nodes/10.DtCBgXe3.js | 2 + static/_app/immutable/nodes/10.tOVf7a-k.js | 2 + static/_app/immutable/nodes/2.BUzpf9hM.js | 1 + static/_app/immutable/nodes/2.CLqOxIAV.js | 1 + static/_app/immutable/nodes/3.C4_c_H5u.js | 1 + static/_app/immutable/nodes/3.Cw03XrzI.js | 1 + static/_app/immutable/nodes/4.CGqLxVsa.js | 1 + static/_app/immutable/nodes/4.lGPSnkVN.js | 1 + static/_app/immutable/nodes/5.BiFm8qpr.js | 1 + static/_app/immutable/nodes/5.ClnAgA1G.js | 1 + static/_app/immutable/nodes/6.CG1t51nm.js | 6 + static/_app/immutable/nodes/6.Q5ZP4RpW.js | 6 + static/_app/immutable/nodes/7.BtrismLT.js | 1 + static/_app/immutable/nodes/7._-pAQSFO.js | 1 + static/_app/immutable/nodes/8.BCiD6gja.js | 2 + static/_app/immutable/nodes/8.DN4MFhnj.js | 2 + static/_app/immutable/nodes/9.BwN-S9Xs.js | 1 + static/_app/immutable/nodes/9.CTwKRSNc.js | 1 + static/_app/version.json | 1 + static/index.html | 41 +++ static/robots.txt | 3 + 103 files changed, 2185 insertions(+), 59 deletions(-) create mode 100644 backend/src/tracker/api/routes/bewertung.py create mode 100644 backend/src/tracker/api/routes/fraktionen.py create mode 100644 backend/src/tracker/core/kategorien.py create mode 100644 frontend/src/lib/components/UmsetzungBadge.svelte create mode 100644 frontend/src/lib/umsetzung.ts create mode 100644 frontend/src/routes/fraktionen/+page.svelte create mode 100644 frontend/src/routes/fraktionen/[kuerzel]/+page.svelte create mode 100755 scripts/deploy-db.sh create mode 100644 scripts/fix_missing_summaries.py create mode 100644 scripts/geocode_pending.py create mode 100644 scripts/ketten_match.py create mode 100644 scripts/repair_ketten_match.py create mode 100755 scripts/run_ketten_match.sh create mode 100644 static/_app/env.js create mode 100644 static/_app/immutable/assets/app.CoxKXDok.css create mode 100644 static/_app/immutable/assets/leaflet.CIGW-MKW.css create mode 100644 static/_app/immutable/chunks/3I_XkZiy.js create mode 100644 static/_app/immutable/chunks/6IKeDOr0.js create mode 100644 static/_app/immutable/chunks/B-uV6-Xr.js create mode 100644 static/_app/immutable/chunks/B08B5jt4.js create mode 100644 static/_app/immutable/chunks/BCXcTGin.js create mode 100644 static/_app/immutable/chunks/Bkzsmr9I.js create mode 100644 static/_app/immutable/chunks/Br6sCvve.js create mode 100644 static/_app/immutable/chunks/C-x9yHfs.js create mode 100644 static/_app/immutable/chunks/CBOKTDOo.js create mode 100644 static/_app/immutable/chunks/CMkOXag5.js create mode 100644 static/_app/immutable/chunks/CSnrw0fY.js create mode 100644 static/_app/immutable/chunks/CTIvq_GE.js create mode 100644 static/_app/immutable/chunks/CWOupeSg.js create mode 100644 static/_app/immutable/chunks/Cgke0YGN.js create mode 100644 static/_app/immutable/chunks/CjPBq9Bq.js create mode 100644 static/_app/immutable/chunks/Cjw4vZKn.js create mode 100644 static/_app/immutable/chunks/D2u1A_4g.js create mode 100644 static/_app/immutable/chunks/D6E-zrqv.js create mode 100644 static/_app/immutable/chunks/DAfY0XTB.js create mode 100644 static/_app/immutable/chunks/DCPIP6Ym.js create mode 100644 static/_app/immutable/chunks/DDErvS7v.js create mode 100644 static/_app/immutable/chunks/DIGUPa-Q.js create mode 100644 static/_app/immutable/chunks/DVOkFnep.js create mode 100644 static/_app/immutable/chunks/DaCWmHjB.js create mode 100644 static/_app/immutable/chunks/DfJQ0EIT.js create mode 100644 static/_app/immutable/chunks/DiDp2_zb.js create mode 100644 static/_app/immutable/chunks/DiIboHMF.js create mode 100644 static/_app/immutable/chunks/DjXdyWBG.js create mode 100644 static/_app/immutable/chunks/Duumi1XQ.js create mode 100644 static/_app/immutable/chunks/DxJV8wOg.js create mode 100644 static/_app/immutable/chunks/OvWQM58r.js create mode 100644 static/_app/immutable/chunks/PPVm8Dsz.js create mode 100644 static/_app/immutable/chunks/QfvBL-nR.js create mode 100644 static/_app/immutable/chunks/RVjQLo13.js create mode 100644 static/_app/immutable/chunks/fSdafo1a.js create mode 100644 static/_app/immutable/chunks/kjB3f-xG.js create mode 100644 static/_app/immutable/chunks/qS6hbb4Y.js create mode 100644 static/_app/immutable/chunks/splFp8Bu.js create mode 100644 static/_app/immutable/chunks/trpXq522.js create mode 100644 static/_app/immutable/chunks/utcFFRIM.js create mode 100644 static/_app/immutable/chunks/yhBelVs6.js create mode 100644 static/_app/immutable/entry/app.BZjvA3WU.js create mode 100644 static/_app/immutable/entry/app.CPVPz_La.js create mode 100644 static/_app/immutable/entry/start.BlI5Xt52.js create mode 100644 static/_app/immutable/entry/start.Cz3VRzIK.js create mode 100644 static/_app/immutable/nodes/0.BHTRPJLe.js create mode 100644 static/_app/immutable/nodes/0.DId8iTp4.js create mode 100644 static/_app/immutable/nodes/1.CYfReXZ2.js create mode 100644 static/_app/immutable/nodes/1.fI0LOJ0P.js create mode 100644 static/_app/immutable/nodes/10.DtCBgXe3.js create mode 100644 static/_app/immutable/nodes/10.tOVf7a-k.js create mode 100644 static/_app/immutable/nodes/2.BUzpf9hM.js create mode 100644 static/_app/immutable/nodes/2.CLqOxIAV.js create mode 100644 static/_app/immutable/nodes/3.C4_c_H5u.js create mode 100644 static/_app/immutable/nodes/3.Cw03XrzI.js create mode 100644 static/_app/immutable/nodes/4.CGqLxVsa.js create mode 100644 static/_app/immutable/nodes/4.lGPSnkVN.js create mode 100644 static/_app/immutable/nodes/5.BiFm8qpr.js create mode 100644 static/_app/immutable/nodes/5.ClnAgA1G.js create mode 100644 static/_app/immutable/nodes/6.CG1t51nm.js create mode 100644 static/_app/immutable/nodes/6.Q5ZP4RpW.js create mode 100644 static/_app/immutable/nodes/7.BtrismLT.js create mode 100644 static/_app/immutable/nodes/7._-pAQSFO.js create mode 100644 static/_app/immutable/nodes/8.BCiD6gja.js create mode 100644 static/_app/immutable/nodes/8.DN4MFhnj.js create mode 100644 static/_app/immutable/nodes/9.BwN-S9Xs.js create mode 100644 static/_app/immutable/nodes/9.CTwKRSNc.js create mode 100644 static/_app/version.json create mode 100644 static/index.html create mode 100644 static/robots.txt diff --git a/backend/src/tracker/api/models.py b/backend/src/tracker/api/models.py index a08e937..9a9db36 100644 --- a/backend/src/tracker/api/models.py +++ b/backend/src/tracker/api/models.py @@ -1,3 +1,4 @@ +from __future__ import annotations """Pydantic response models for the API.""" from datetime import date, datetime @@ -56,6 +57,13 @@ class KiZusammenfassung(BaseModel): partei: str | list[str] | None = None +class UmsetzungsBewertung(BaseModel): + score: float | None = None + begruendung: str | None = None + quelle_vorlage_id: int | None = None + quelle_aktenzeichen: str | None = None + + class VorlageDetail(BaseModel): id: int aktenzeichen: str | None = None @@ -75,6 +83,7 @@ class VorlageDetail(BaseModel): referenzen_ausgehend: list[ReferenzOut] = [] referenzen_eingehend: list[ReferenzOut] = [] kette_id: int | None = None + umsetzungsbewertungen: list[UmsetzungsBewertung] = [] class KettenGliedOut(BaseModel): @@ -104,6 +113,7 @@ class KetteDetail(BaseModel): status_seit: date | None = None letzte_aktivitaet: date | None = None vertagungen_count: int = 0 + begruendung: str | None = None glieder: list[KettenGliedOut] = [] antragsteller: list[ParteiOut] = [] graph: dict | None = None diff --git a/backend/src/tracker/api/routes/abstimmungen.py b/backend/src/tracker/api/routes/abstimmungen.py index 496de91..b8b2c2e 100644 --- a/backend/src/tracker/api/routes/abstimmungen.py +++ b/backend/src/tracker/api/routes/abstimmungen.py @@ -1,3 +1,4 @@ +from __future__ import annotations """API routes for Abstimmungen und Stimmverhalten-Analysen.""" from fastapi import APIRouter, Depends, Query diff --git a/backend/src/tracker/api/routes/bewertung.py b/backend/src/tracker/api/routes/bewertung.py new file mode 100644 index 0000000..4504722 --- /dev/null +++ b/backend/src/tracker/api/routes/bewertung.py @@ -0,0 +1,312 @@ +from __future__ import annotations +"""API routes for KI re-evaluation.""" + +import json +import os +import sqlite3 +import threading +from datetime import datetime + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from tracker.db.session import get_connection +# Note: Re-evaluation should re-scrape ALLRIS data before KI evaluation +# to exclude transfer errors. See Gitea issue for full spec. +# IMPORTANT: Destructive changes (deleting old bewertungen etc.) only after +# explicit user request with explanation. Keep old data as backup where possible. + +router = APIRouter(prefix="/bewertung", tags=["bewertung"]) + + +def _db(): + conn = get_connection() + try: + yield conn + finally: + conn.close() + +# API config +DASHSCOPE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions" +GEMINI_URL_TEMPLATE = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={key}" + +# Job tracking +_jobs: dict[str, dict] = {} + + +def _get_key(name: str) -> str: + """Get API key from env or macOS keychain.""" + val = os.environ.get(name) + if val: + return val + import subprocess + keychain_map = {"QWEN_API_KEY": "qwen-api", "GEMINI_API_KEY": "gemini-api"} + svc = keychain_map.get(name) + if svc: + try: + return subprocess.check_output( + ["security", "find-generic-password", "-s", svc, "-w"], + stderr=subprocess.DEVNULL, + ).decode().strip() + except Exception: + pass + return "" + + +ZUSAMMENFASSUNG_PROMPT = """Analysiere diesen kommunalpolitischen Antrag aus Hagen. + +ZUSÄTZLICHE HINWEISE DES NUTZERS: +{anmerkung} + +DOKUMENT: +{volltext} + +--- + +Erstelle eine strukturierte Zusammenfassung im JSON-Format: + +{{ + "zusammenfassung": "2-3 Sätze, was gefordert wird", + "kernforderung": "Die zentrale Forderung in einem Satz", + "begruendung": "Warum wird das gefordert? (kurz)", + "thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)", + "partei": "Antragstellende Fraktion falls erkennbar", + "orte": [] +}} + +NUR JSON ausgeben, keine Erklärungen.""" + + +KETTEN_MATCH_PROMPT = """Du bist ein Analyst für kommunalpolitische Vorgänge in Hagen. + +ZUSÄTZLICHE HINWEISE DES NUTZERS: +{anmerkung} + +Vergleiche den URSPRÜNGLICHEN ANTRAG/ANFRAGE mit der ANTWORT/BESCHLUSS. +Bewerte ob die ursprüngliche Forderung tatsächlich erfüllt wurde. + +=== URSPRÜNGLICHE FORDERUNG === +Aktenzeichen: {az_ursprung} +{ki_zusammenfassung} + +Volltext: +{volltext_ursprung} + +=== BERATUNGEN & BESCHLÜSSE === +{beratungen_text} + +--- + +Bewerte NUR als JSON: +{{ + "score": <0.0-1.0>, + "bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar", + "begruendung": "1-2 Sätze warum", + "kernpunkt_erfuellt": true/false, + "details": "Was konkret beschlossen/abgelehnt wurde" +}} + +Bewertungsskala: +- 1.0: Forderung vollständig erfüllt, konkreter Beschluss +- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen +- 0.4-0.6: Teilweise erfüllt oder auf den Weg gebracht +- 0.2-0.3: Abgewiegelt — Verwaltung weicht aus, kündigt nur "Prüfung" an +- 0.0-0.1: Nebelkerze — Thema gewechselt oder komplett ignoriert""" + + +class BewertungRequest(BaseModel): + anmerkung: str = "" + + +def _call_qwen(prompt: str) -> dict | None: + key = _get_key("QWEN_API_KEY") + if not key: + return None + try: + resp = httpx.post( + DASHSCOPE_URL, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json={"model": "qwen-plus-latest", "messages": [{"role": "user", "content": prompt}], "temperature": 0.3}, + timeout=180, + ) + resp.raise_for_status() + content = resp.json()["choices"][0]["message"]["content"] + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + return json.loads(content.strip()) + except Exception as e: + return {"error": str(e)} + + +def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str): + """Background task: re-evaluate a Vorlage.""" + try: + conn = get_connection() + + row = conn.execute("SELECT volltext_clean, aktenzeichen FROM vorlagen WHERE id = ?", (vorlage_id,)).fetchone() + if not row or not row["volltext_clean"]: + _jobs[job_id] = {"status": "error", "error": "Kein Volltext vorhanden"} + return + + volltext = row["volltext_clean"] + if len(volltext) > 12000: + volltext = volltext[:12000] + "\n[...gekürzt...]" + + prompt = ZUSAMMENFASSUNG_PROMPT.format(anmerkung=anmerkung or "(keine)", volltext=volltext) + result = _call_qwen(prompt) + + if not result or "error" in result: + _jobs[job_id] = {"status": "error", "error": str(result)} + return + + # Delete old, insert new + conn.execute("DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung'", (vorlage_id,)) + conn.execute( + """INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version) + VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval')""", + (vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False)), + ) + if result.get("kernforderung"): + conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id)) + conn.commit() + conn.close() + + _jobs[job_id] = {"status": "done", "result": result} + except Exception as e: + _jobs[job_id] = {"status": "error", "error": str(e)} + + +def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str): + """Background task: re-evaluate a Kette.""" + try: + conn = get_connection() + + # Get kette + ursprung + kette = conn.execute( + "SELECT k.*, v.aktenzeichen, v.volltext_clean FROM ketten k JOIN vorlagen v ON k.ursprung_id = v.id WHERE k.id = ?", + (kette_id,), + ).fetchone() + if not kette: + _jobs[job_id] = {"status": "error", "error": "Kette nicht gefunden"} + return + + # Get KI summary of ursprung + ki_row = conn.execute( + "SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1", + (kette["ursprung_id"],), + ).fetchone() + ki_text = "" + if ki_row and ki_row["anmerkungen"]: + try: + ki = json.loads(ki_row["anmerkungen"]) + ki_text = f"Zusammenfassung: {ki.get('zusammenfassung', '')}\nKernforderung: {ki.get('kernforderung', '')}" + except Exception: + pass + + # Collect beratungen + members = conn.execute( + "SELECT vorlage_id FROM ketten_glieder WHERE kette_id = ?", (kette_id,) + ).fetchall() + member_ids = [m["vorlage_id"] for m in members] + placeholders = ",".join("?" * len(member_ids)) + + beratungen = conn.execute( + f"""SELECT b.sitzung_datum, b.rolle, b.ergebnis, b.beschlusstext, b.wortprotokoll, + v.aktenzeichen + FROM beratungen b JOIN vorlagen v ON b.vorlage_id = v.id + WHERE b.vorlage_id IN ({placeholders}) + ORDER BY b.sitzung_datum""", + member_ids, + ).fetchall() + + beratungen_text = "" + for b in beratungen: + beratungen_text += f"\n--- {b['aktenzeichen']} | {b['sitzung_datum']} | {b['rolle']} ---\n" + if b["ergebnis"]: + beratungen_text += f"Ergebnis: {b['ergebnis']}\n" + if b["beschlusstext"]: + beratungen_text += f"Beschlusstext: {b['beschlusstext'][:500]}\n" + if b["wortprotokoll"]: + beratungen_text += f"Wortprotokoll: {b['wortprotokoll'][:1000]}\n" + + volltext = kette["volltext_clean"] or "" + if len(volltext) > 8000: + volltext = volltext[:8000] + "\n[...gekürzt...]" + + prompt = KETTEN_MATCH_PROMPT.format( + anmerkung=anmerkung or "(keine)", + az_ursprung=kette["aktenzeichen"], + ki_zusammenfassung=ki_text, + volltext_ursprung=volltext, + beratungen_text=beratungen_text or "(keine Beratungen vorhanden)", + ) + + result = _call_qwen(prompt) + if not result or "error" in result: + _jobs[job_id] = {"status": "error", "error": str(result)} + return + + # Delete old umsetzung_match, insert new + conn.execute( + "DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match'", + (kette["ursprung_id"],), + ) + conn.execute( + """INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, anmerkungen, modell, prompt_version) + VALUES (?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval')""", + ( + kette["ursprung_id"], + result.get("score"), + result.get("begruendung"), + json.dumps(result, ensure_ascii=False), + ), + ) + + # Rebuild chain status + from tracker.core.chains import build_single_chain + build_single_chain(conn, kette["ursprung_id"]) + conn.commit() + conn.close() + + _jobs[job_id] = {"status": "done", "result": result} + except Exception as e: + _jobs[job_id] = {"status": "error", "error": str(e)} + + +@router.post("/vorlagen/{vorlage_id}") +def reeval_vorlage(vorlage_id: int, req: BewertungRequest, conn=Depends(_db)): + """Trigger KI re-evaluation for a Vorlage.""" + row = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (vorlage_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Vorlage nicht gefunden") + + job_id = f"vorlage-{vorlage_id}-{int(datetime.now().timestamp())}" + _jobs[job_id] = {"status": "running"} + t = threading.Thread(target=_run_zusammenfassung, args=(vorlage_id, req.anmerkung, job_id), daemon=True) + t.start() + return {"job_id": job_id, "status": "running"} + + +@router.post("/ketten/{kette_id}") +def reeval_kette(kette_id: int, req: BewertungRequest, conn=Depends(_db)): + """Trigger KI re-evaluation for a Kette.""" + row = conn.execute("SELECT id FROM ketten WHERE id = ?", (kette_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Kette nicht gefunden") + + job_id = f"kette-{kette_id}-{int(datetime.now().timestamp())}" + _jobs[job_id] = {"status": "running"} + t = threading.Thread(target=_run_ketten_bewertung, args=(kette_id, req.anmerkung, job_id), daemon=True) + t.start() + return {"job_id": job_id, "status": "running"} + + +@router.get("/status/{job_id}") +def get_job_status(job_id: str): + """Check status of a re-evaluation job.""" + if job_id not in _jobs: + raise HTTPException(status_code=404, detail="Job nicht gefunden") + return _jobs[job_id] diff --git a/backend/src/tracker/api/routes/fraktionen.py b/backend/src/tracker/api/routes/fraktionen.py new file mode 100644 index 0000000..a5b379c --- /dev/null +++ b/backend/src/tracker/api/routes/fraktionen.py @@ -0,0 +1,128 @@ +from __future__ import annotations +"""API routes for Fraktions-Dashboard.""" + +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query + +from tracker.core.kategorien import BEWERTUNGSKATEGORIEN +from tracker.db.session import get_connection + +router = APIRouter(prefix="/fraktionen", tags=["Fraktionen"]) + + +def _db(): + conn = get_connection() + try: + yield conn + finally: + conn.close() + + +@router.get("/kategorien") +def get_kategorien(): + """Return category definitions for frontend tooltips.""" + return { + key: { + "label": val["label"], + "farbe": val["farbe"], + "icon": val["icon"], + "beschreibung": val["beschreibung"], + "beispiel": val["beispiel"], + "score_range": val["score_range"], + } + for key, val in BEWERTUNGSKATEGORIEN.items() + } + + +@router.get("") +def list_fraktionen(conn=Depends(_db)): + """List all parties with Antrag counts.""" + rows = conn.execute(""" + SELECT p.id, p.kuerzel, p.name, p.farbe, COUNT(a.vorlage_id) as anzahl + FROM parteien p + LEFT JOIN antragsteller a ON p.id = a.partei_id + GROUP BY p.id + HAVING anzahl > 0 + ORDER BY anzahl DESC + """).fetchall() + return [dict(r) for r in rows] + + +@router.get("/{kuerzel}/dashboard") +def fraktion_dashboard( + kuerzel: str, + jahr: Optional[int] = None, + conn=Depends(_db), +): + """Dashboard for a single Fraktion with Umsetzungsanalyse.""" + # Find party + partei = conn.execute( + "SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,) + ).fetchone() + if not partei: + raise HTTPException(404, f"Fraktion '{kuerzel}' nicht gefunden") + + # Base query: all Vorlagen by this party + jahr_filter = "" + params = [partei["id"]] + if jahr: + jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?" + params.append(str(jahr)) + + # Total Anträge + total = conn.execute(f""" + SELECT COUNT(DISTINCT v.id) as c + FROM vorlagen v + JOIN antragsteller a ON a.vorlage_id = v.id + WHERE a.partei_id = ? {jahr_filter} + """, params).fetchone()["c"] + + # With Ketten-Match results + umsetzung_rows = conn.execute(f""" + SELECT + json_extract(kb.anmerkungen, '$.bewertung') as bewertung, + COUNT(*) as anzahl, + ROUND(AVG(kb.score), 2) as avg_score + FROM vorlagen v + JOIN antragsteller a ON a.vorlage_id = v.id + JOIN ki_bewertungen kb ON kb.vorlage_id = v.id AND kb.typ = 'umsetzung_match' + WHERE a.partei_id = ? {jahr_filter} + GROUP BY bewertung + ORDER BY anzahl DESC + """, params).fetchall() + + umsetzung = [dict(r) for r in umsetzung_rows] + bewertet = sum(r["anzahl"] for r in umsetzung_rows) + + # Top Anträge with scores + antraege = conn.execute(f""" + SELECT v.id, v.aktenzeichen, v.betreff, v.typ, v.datum_eingang, + kb.score as umsetzung_score, + json_extract(kb.anmerkungen, '$.bewertung') as umsetzung_bewertung, + kb.begruendung as umsetzung_begruendung + FROM vorlagen v + JOIN antragsteller a ON a.vorlage_id = v.id + LEFT JOIN ki_bewertungen kb ON kb.vorlage_id = v.id AND kb.typ = 'umsetzung_match' + WHERE a.partei_id = ? {jahr_filter} + ORDER BY v.datum_eingang DESC + LIMIT 200 + """, params).fetchall() + + # Jahre für Filter + jahre = conn.execute(""" + SELECT DISTINCT strftime('%Y', v.datum_eingang) as j + FROM vorlagen v + JOIN antragsteller a ON a.vorlage_id = v.id + WHERE a.partei_id = ? AND v.datum_eingang IS NOT NULL + ORDER BY j DESC + """, [partei["id"]]).fetchall() + + return { + "partei": dict(partei), + "total_antraege": total, + "bewertet": bewertet, + "umsetzung": umsetzung, + "antraege": [dict(r) for r in antraege], + "jahre": [r["j"] for r in jahre], + } diff --git a/backend/src/tracker/api/routes/ketten.py b/backend/src/tracker/api/routes/ketten.py index e08268d..d7899ff 100644 --- a/backend/src/tracker/api/routes/ketten.py +++ b/backend/src/tracker/api/routes/ketten.py @@ -1,3 +1,4 @@ +from __future__ import annotations """API routes for Ketten (chains).""" from fastapi import APIRouter, Depends, HTTPException, Query @@ -31,6 +32,7 @@ def list_ketten( status: str | None = None, typ: str | None = None, suche: str | None = None, + partei: str | None = None, conn=Depends(_db), ): """List Ketten with optional filters.""" @@ -45,6 +47,13 @@ def list_ketten( where_clauses.append("k.typ = ?") params.append(typ) + if partei: + where_clauses.append( + "k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a " + "JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel = ?)" + ) + params.append(partei) + if suche: where_clauses.append("k.thema LIKE ?") params.append(f"%{suche}%") @@ -100,7 +109,7 @@ def get_kette(kette_id: int, conn=Depends(_db)): """Get a single Kette with all Glieder.""" row = conn.execute( """SELECT k.id, k.typ, k.thema, k.status, k.status_seit, - k.letzte_aktivitaet, k.vertagungen_count, k.ursprung_id, + k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id, v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang, v.ist_verwaltungsvorlage FROM ketten k @@ -170,6 +179,7 @@ def get_kette(kette_id: int, conn=Depends(_db)): status_seit=row["status_seit"], letzte_aktivitaet=row["letzte_aktivitaet"], vertagungen_count=row["vertagungen_count"], + begruendung=row["begruendung"], glieder=glieder, antragsteller=antragsteller, graph=graph, diff --git a/backend/src/tracker/api/routes/orte.py b/backend/src/tracker/api/routes/orte.py index c696ac4..11efa8d 100644 --- a/backend/src/tracker/api/routes/orte.py +++ b/backend/src/tracker/api/routes/orte.py @@ -1,3 +1,4 @@ +from __future__ import annotations """API routes for Orte und Karten-Daten.""" from fastapi import APIRouter, Depends diff --git a/backend/src/tracker/api/routes/stats.py b/backend/src/tracker/api/routes/stats.py index 784906d..31ed65b 100644 --- a/backend/src/tracker/api/routes/stats.py +++ b/backend/src/tracker/api/routes/stats.py @@ -1,3 +1,4 @@ +from __future__ import annotations """API routes for Dashboard statistics.""" from fastapi import APIRouter, Depends diff --git a/backend/src/tracker/api/routes/vorlagen.py b/backend/src/tracker/api/routes/vorlagen.py index c5bfa2d..207ba19 100644 --- a/backend/src/tracker/api/routes/vorlagen.py +++ b/backend/src/tracker/api/routes/vorlagen.py @@ -1,3 +1,4 @@ +from __future__ import annotations """API routes for Vorlagen.""" from fastapi import APIRouter, Depends, HTTPException, Query @@ -28,12 +29,13 @@ def _db(): conn.close() -@router.get("", response_model=PaginatedVorlagen) +@router.get("") def list_vorlagen( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), typ: str | None = None, suche: str | None = None, + partei: str | None = None, conn=Depends(_db), ): """List Vorlagen with optional filters.""" @@ -44,6 +46,13 @@ def list_vorlagen( where_clauses.append("v.typ = ?") params.append(typ) + if partei: + where_clauses.append( + "v.id IN (SELECT a.vorlage_id FROM antragsteller a " + "JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel = ?)" + ) + params.append(partei) + if suche: where_clauses.append( "(v.betreff LIKE ? OR v.aktenzeichen LIKE ?" @@ -72,19 +81,36 @@ def list_vorlagen( params + [page_size, offset], ).fetchall() + # Fetch antragsteller for all vorlagen in one query + vorlage_ids = [r["id"] for r in rows] + antragsteller_map: dict = {} + if vorlage_ids: + placeholders = ",".join("?" * len(vorlage_ids)) + ast_rows = conn.execute( + f"""SELECT a.vorlage_id, p.kuerzel, p.name, p.farbe + FROM antragsteller a JOIN parteien p ON a.partei_id = p.id + WHERE a.vorlage_id IN ({placeholders})""", + vorlage_ids, + ).fetchall() + for a in ast_rows: + antragsteller_map.setdefault(a["vorlage_id"], []).append( + {"kuerzel": a["kuerzel"], "name": a["name"], "farbe": a["farbe"]} + ) + items = [ - VorlageKurz( - id=r["id"], - aktenzeichen=r["aktenzeichen"], - typ=r["typ"], - betreff=r["betreff"], - datum_eingang=r["datum_eingang"], - ist_verwaltungsvorlage=bool(r["ist_verwaltungsvorlage"]), - ) + { + "id": r["id"], + "aktenzeichen": r["aktenzeichen"], + "typ": r["typ"], + "betreff": r["betreff"], + "datum_eingang": r["datum_eingang"], + "ist_verwaltungsvorlage": bool(r["ist_verwaltungsvorlage"]), + "antragsteller": antragsteller_map.get(r["id"], []), + } for r in rows ] - return PaginatedVorlagen(items=items, total=total, page=page, page_size=page_size) + return {"items": items, "total": total, "page": page, "page_size": page_size} @router.get("/{vorlage_id}", response_model=VorlageDetail) @@ -157,6 +183,26 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)): except (json.JSONDecodeError, TypeError): pass + # Umsetzungsbewertungen + from tracker.api.models import UmsetzungsBewertung + umsetzungs_rows = conn.execute( + """SELECT kb.score, kb.begruendung, kb.vorlage_id as quelle_id, v2.aktenzeichen as quelle_az + FROM ki_bewertungen kb + LEFT JOIN vorlagen v2 ON v2.id = kb.vorlage_id + WHERE kb.vorlage_id = ? AND kb.typ = 'umsetzung_match' + ORDER BY kb.score DESC""", + (vorlage_id,), + ).fetchall() + umsetzungsbewertungen = [ + UmsetzungsBewertung( + score=u["score"], + begruendung=u["begruendung"], + quelle_vorlage_id=u["quelle_id"], + quelle_aktenzeichen=u["quelle_az"], + ) + for u in umsetzungs_rows + ] + return VorlageDetail( id=row["id"], aktenzeichen=row["aktenzeichen"], @@ -176,4 +222,5 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)): referenzen_eingehend=[ReferenzOut(**r) for r in refs["eingehend"]], kette_id=kette_row["kette_id"] if kette_row else None, ki_zusammenfassung=ki_zusammenfassung, + umsetzungsbewertungen=umsetzungsbewertungen, ) diff --git a/backend/src/tracker/core/chains.py b/backend/src/tracker/core/chains.py index 4b87bc5..99cd9d4 100644 --- a/backend/src/tracker/core/chains.py +++ b/backend/src/tracker/core/chains.py @@ -1,3 +1,4 @@ +from __future__ import annotations """Ketten-Builder: groups Vorlagen into chains based on Aktenzeichen-Suffix references.""" import sqlite3 @@ -97,7 +98,7 @@ def build_chains(conn: sqlite3.Connection) -> int: conn.execute(""" UPDATE ketten SET typ = ?, thema = ?, status = ?, status_seit = ?, - letzte_aktivitaet = ?, vertagungen_count = ? + letzte_aktivitaet = ?, vertagungen_count = ?, begruendung = ? WHERE id = ? """, ( chain_typ, @@ -106,14 +107,15 @@ def build_chains(conn: sqlite3.Connection) -> int: status_info.get("status_seit"), letzte_aktivitaet, status_info.get("vertagungen_count", 0), + status_info.get("begruendung"), kette_id, )) conn.execute("DELETE FROM ketten_glieder WHERE kette_id = ?", (kette_id,)) else: cursor = conn.execute(""" INSERT INTO ketten (ursprung_id, typ, thema, status, status_seit, - letzte_aktivitaet, vertagungen_count) - VALUES (?, ?, ?, ?, ?, ?, ?) + letzte_aktivitaet, vertagungen_count, begruendung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( ursprung["id"], chain_typ, @@ -122,6 +124,7 @@ def build_chains(conn: sqlite3.Connection) -> int: status_info.get("status_seit"), letzte_aktivitaet, status_info.get("vertagungen_count", 0), + status_info.get("begruendung"), )) kette_id = cursor.lastrowid @@ -139,6 +142,47 @@ def build_chains(conn: sqlite3.Connection) -> int: return count +def build_single_chain(conn: sqlite3.Connection, ursprung_id: int) -> None: + """Rebuild a single chain by its ursprung vorlage ID.""" + row = conn.execute( + "SELECT aktenzeichen_basis FROM vorlagen WHERE id = ?", (ursprung_id,) + ).fetchone() + if not row or not row["aktenzeichen_basis"]: + return + + basis = row["aktenzeichen_basis"] + members = conn.execute(""" + SELECT id, aktenzeichen, aktenzeichen_suffix, typ, datum_eingang, betreff + FROM vorlagen WHERE aktenzeichen_basis = ? + ORDER BY CASE WHEN aktenzeichen_suffix IS NULL THEN 0 + ELSE CAST(REPLACE(aktenzeichen_suffix, '-', '') AS INTEGER) END + """, (basis,)).fetchall() + + if not members: + return + + ursprung = members[0] + chain_typ = ursprung["typ"] + if chain_typ not in ("antrag", "anfrage"): + return + + status_info = compute_status(conn, ursprung["id"], chain_typ, members) + dates = [m["datum_eingang"] for m in members if m["datum_eingang"]] + letzte_aktivitaet = max(dates) if dates else ursprung["datum_eingang"] + + existing = conn.execute("SELECT id FROM ketten WHERE ursprung_id = ?", (ursprung["id"],)).fetchone() + if existing: + conn.execute(""" + UPDATE ketten SET status = ?, status_seit = ?, letzte_aktivitaet = ?, + vertagungen_count = ?, begruendung = ? + WHERE id = ? + """, ( + status_info["status"], status_info.get("status_seit"), letzte_aktivitaet, + status_info.get("vertagungen_count", 0), status_info.get("begruendung"), + existing["id"], + )) + + def _determine_rolle(member: sqlite3.Row, position: int) -> str: if position == 0: return "ursprung" diff --git a/backend/src/tracker/core/kategorien.py b/backend/src/tracker/core/kategorien.py new file mode 100644 index 0000000..0049491 --- /dev/null +++ b/backend/src/tracker/core/kategorien.py @@ -0,0 +1,83 @@ +from __future__ import annotations +"""Bewertungskategorien für Ketten-Match Umsetzungsanalyse.""" + +from typing import Dict + +# Canonical categories with metadata +BEWERTUNGSKATEGORIEN: Dict[str, dict] = { + "erfuellt": { + "label": "Erfüllt", + "score_range": [0.8, 1.0], + "farbe": "#22c55e", # green-500 + "icon": "✅", + "beschreibung": "Forderung vollständig oder weitgehend umgesetzt. Konkreter Beschluss liegt vor, der die Kernpunkte des Antrags aufgreift.", + "beispiel": "Antrag auf Zuschuss → Zuschuss in beantragter Höhe einstimmig bewilligt.", + }, + "teilweise": { + "label": "Teilweise", + "score_range": [0.5, 0.7], + "farbe": "#eab308", # yellow-500 + "icon": "⚠️", + "beschreibung": "Kernpunkt wurde adressiert, aber mit Abstrichen. Verwaltung greift Teile auf, lässt andere fallen oder verwässert die Forderung.", + "beispiel": "Antrag auf Radweg → Prüfauftrag für Machbarkeitsstudie statt direktem Baubeschluss.", + }, + "abgewiegelt": { + "label": "Abgewiegelt", + "score_range": [0.2, 0.4], + "farbe": "#f97316", # orange-500 + "icon": "🚫", + "beschreibung": "Verwaltung weicht aus. Kündigt Prüfung an, verweist auf Zuständigkeiten oder beantwortet die Frage nicht substantiell.", + "beispiel": "Anfrage zu Missständen → 'Wird geprüft' ohne Zeitrahmen oder konkrete Zusagen.", + }, + "nebelkerze": { + "label": "Nebelkerze", + "score_range": [0.0, 0.1], + "farbe": "#ef4444", # red-500 + "icon": "💨", + "beschreibung": "Thema komplett ignoriert, Diskussion auf Nebenschauplatz verlagert oder Antrag ohne Behandlung von der Tagesordnung genommen.", + "beispiel": "Antrag zu Sauberkeit → 'Ohne Beschlussfassung' oder keine Wortmeldung.", + }, + "vertagt": { + "label": "Vertagt", + "score_range": None, # Variable score + "farbe": "#8b5cf6", # violet-500 + "icon": "⏳", + "beschreibung": "Antrag explizit verschoben, ruhend gestellt oder in einen anderen Ausschuss verwiesen. Kein inhaltliches Ergebnis, aber formal nicht abgelehnt.", + "beispiel": "Antrag wird zur weiteren Beratung in den Fachausschuss überwiesen.", + }, + "unklar": { + "label": "Unklar", + "score_range": None, + "farbe": "#6b7280", # gray-500 + "icon": "❓", + "beschreibung": "Beschlusslage nicht eindeutig zuordenbar. Möglicherweise fehlen Dokumente oder der Beschlusstext ist nicht aussagekräftig genug.", + "beispiel": "Nur 'Kenntnisnahme' ohne weitere Erläuterung.", + }, +} + + +def score_to_kategorie(score: float) -> str: + """Map a numeric score to the canonical category.""" + if score >= 0.8: + return "erfuellt" + if score >= 0.5: + return "teilweise" + if score >= 0.2: + return "abgewiegelt" + return "nebelkerze" + + +def normalize_kategorie(raw: str) -> str: + """Normalize variant spellings to canonical category.""" + ALIASES = { + "erfüllt": "erfuellt", + "weitgehend erfüllt": "erfuellt", + "weitgehend erfuellt": "erfuellt", + "weitgehend_erfuellt": "erfuellt", + "weitestgehend_erfuellt": "erfuellt", + "weitgehend": "erfuellt", + "ohne beschlussfassung": "nebelkerze", + "ohne_beschlussfassung": "nebelkerze", + } + normalized = raw.strip().lower() + return ALIASES.get(normalized, normalized if normalized in BEWERTUNGSKATEGORIEN else "unklar") diff --git a/backend/src/tracker/core/status.py b/backend/src/tracker/core/status.py index 7b261b2..8a1b109 100644 --- a/backend/src/tracker/core/status.py +++ b/backend/src/tracker/core/status.py @@ -23,7 +23,7 @@ def compute_status( return _status_anfrage(conn, ursprung_id, members) elif chain_typ == "antrag": return _status_antrag(conn, ursprung_id, members) - return {"status": "unbekannt", "status_seit": None, "vertagungen_count": 0} + return {"status": "unbekannt", "status_seit": None, "vertagungen_count": 0, "begruendung": "Unbekannter Kettentyp"} def _status_anfrage( @@ -47,16 +47,21 @@ def _status_anfrage( stellungnahmen = [m for m in members if m["typ"] == "stellungnahme"] has_stellungnahme = len(stellungnahmen) > 0 - # Check for Kenntnisnahme in Beratungen - beratungen = conn.execute(""" - SELECT rolle, ergebnis, sitzung_datum + # Check for Kenntnisnahme in Beratungen (alle Kettenglieder) + member_ids = [m["id"] for m in members] + placeholders = ",".join("?" * len(member_ids)) + beratungen = conn.execute(f""" + SELECT rolle, ergebnis, sitzung_datum, beschlusstext FROM beratungen - WHERE vorlage_id = ? + WHERE vorlage_id IN ({placeholders}) ORDER BY sitzung_datum DESC - """, (ursprung_id,)).fetchall() + """, member_ids).fetchall() has_kenntnisnahme = any( - b["rolle"] and "kenntnisnahme" in b["rolle"].lower() + (b["rolle"] and "kenntnisnahme" in b["rolle"].lower()) + or (b["beschlusstext"] and "kenntnis" in b["beschlusstext"].lower()) + if "beschlusstext" in b.keys() else + (b["rolle"] and "kenntnisnahme" in b["rolle"].lower()) for b in beratungen ) @@ -65,20 +70,28 @@ def _status_anfrage( # Check zurückgezogen if _is_zurueckgezogen(beratungen): - return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": 0} + return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": 0, + "begruendung": "In einer Beratung explizit zurückgezogen."} if has_stellungnahme: if ki_score is not None and ki_score < 0.5: - return {"status": "abgewiegelt", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0} + return {"status": "abgewiegelt", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0, + "begruendung": f"Stellungnahme vorhanden, aber KI-Bewertung nur {ki_score:.0%} — die Antwort geht nicht ausreichend auf die Anfrage ein."} if has_kenntnisnahme and (ki_score is None or ki_score >= 0.7): - return {"status": "beantwortet", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0} - return {"status": "offen", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0} + score_text = f" (KI-Match: {ki_score:.0%})" if ki_score is not None else "" + return {"status": "beantwortet", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0, + "begruendung": f"Stellungnahme liegt vor{score_text}, Kenntnisnahme im Gremium erfolgt."} + return {"status": "offen", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0, + "begruendung": "Stellungnahme vorhanden, aber noch keine Kenntnisnahme im Gremium."} # No Stellungnahme if ursprung_datum and (heute - ursprung_datum).days > VERSANDET_TAGE: - return {"status": "versandet", "status_seit": ursprung_datum, "vertagungen_count": 0} + tage = (heute - ursprung_datum).days + return {"status": "versandet", "status_seit": ursprung_datum, "vertagungen_count": 0, + "begruendung": f"Keine Stellungnahme nach {tage} Tagen."} - return {"status": "angefragt", "status_seit": ursprung_datum, "vertagungen_count": 0} + return {"status": "angefragt", "status_seit": ursprung_datum, "vertagungen_count": 0, + "begruendung": "Anfrage gestellt, Stellungnahme steht noch aus."} def _status_antrag( @@ -103,19 +116,23 @@ def _status_antrag( heute = date.today() ursprung_datum = _parse_date(members[0]["datum_eingang"]) - beratungen = conn.execute(""" - SELECT rolle, ergebnis, sitzung_datum + # Beratungen ALLER Kettenglieder sammeln (nicht nur Ursprung) + member_ids = [m["id"] for m in members] + placeholders = ",".join("?" * len(member_ids)) + beratungen = conn.execute(f""" + SELECT rolle, ergebnis, sitzung_datum, beschlusstext FROM beratungen - WHERE vorlage_id = ? + WHERE vorlage_id IN ({placeholders}) ORDER BY sitzung_datum DESC NULLS LAST - """, (ursprung_id,)).fetchall() + """, member_ids).fetchall() # Count Vertagungen vertagungen = sum(1 for b in beratungen if b["ergebnis"] and "vertagt" in b["ergebnis"].lower()) # Check zurückgezogen if _is_zurueckgezogen(beratungen): - return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen} + return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen, + "begruendung": "In einer Beratung explizit zurückgezogen."} # Check for Berichte in chain berichte = [m for m in members if m["typ"] == "bericht"] @@ -123,9 +140,11 @@ def _status_antrag( # Determine beschluss from beratungen beschluss = _get_beschluss(beratungen) + beschluss_details = _get_beschluss_details(beratungen) if beschluss == "abgelehnt": - return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen} + return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen, + "begruendung": f"In Beratung abgelehnt. {beschluss_details}"} if beschluss == "angenommen": beschluss_datum = _latest_date(beratungen) @@ -136,31 +155,43 @@ def _status_antrag( if ki_score is not None: if ki_score >= 0.7: - return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen} + return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen, + "begruendung": f"Beschlossen ({beschluss_details}), Umsetzungsbericht liegt vor. KI-Bewertung: {ki_score:.0%} Übereinstimmung."} elif ki_score >= 0.4: - return {"status": "teilweise_umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen} + return {"status": "teilweise_umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen, + "begruendung": f"Beschlossen ({beschluss_details}), aber Umsetzungsbericht zeigt nur teilweise Umsetzung. KI-Bewertung: {ki_score:.0%}."} else: - return {"status": "abgewiegelt", "status_seit": bericht_datum, "vertagungen_count": vertagungen} - return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen} + return {"status": "abgewiegelt", "status_seit": bericht_datum, "vertagungen_count": vertagungen, + "begruendung": f"Beschlossen ({beschluss_details}), aber Umsetzungsbericht weicht stark vom Beschluss ab. KI-Bewertung: nur {ki_score:.0%}."} + return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen, + "begruendung": f"Beschlossen ({beschluss_details}), Umsetzungsbericht liegt vor (keine KI-Bewertung)."} # Angenommen but no Bericht if beschluss_datum and (heute - beschluss_datum).days > VERSANDET_TAGE: - return {"status": "versandet", "status_seit": beschluss_datum, "vertagungen_count": vertagungen} + tage = (heute - beschluss_datum).days + return {"status": "versandet", "status_seit": beschluss_datum, "vertagungen_count": vertagungen, + "begruendung": f"Beschlossen ({beschluss_details}), aber seit {tage} Tagen kein Umsetzungsbericht."} - return {"status": "beschlossen", "status_seit": beschluss_datum, "vertagungen_count": vertagungen} + return {"status": "beschlossen", "status_seit": beschluss_datum, "vertagungen_count": vertagungen, + "begruendung": f"Antrag angenommen. {beschluss_details}"} if beschluss == "verwiesen": - return {"status": "verwiesen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen} + return {"status": "verwiesen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen, + "begruendung": f"An anderen Ausschuss überwiesen. {beschluss_details}"} # No final decision yet if beratungen: last = beratungen[0] if last["ergebnis"] and "vertagt" in last["ergebnis"].lower(): - return {"status": "vertagt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen} - return {"status": "in_beratung", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen} + return {"status": "vertagt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen, + "begruendung": f"Zuletzt vertagt ({vertagungen}x insgesamt). Letzte Beratung: {last['sitzung_datum'] or '?'}."} + gremium_text = f"Letzte Beratung: {last['sitzung_datum'] or '?'}, Ergebnis: {last['ergebnis'] or 'k.A.'}." + return {"status": "in_beratung", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen, + "begruendung": f"In Beratung, noch kein Endbeschluss. {gremium_text}"} # No beratungen at all - return {"status": "eingereicht", "status_seit": ursprung_datum, "vertagungen_count": vertagungen} + return {"status": "eingereicht", "status_seit": ursprung_datum, "vertagungen_count": vertagungen, + "begruendung": "Eingereicht, noch keine Beratung erfolgt."} # --- Helpers --- @@ -200,23 +231,65 @@ def _is_zurueckgezogen(beratungen: list[sqlite3.Row]) -> bool: ) +def _get_beschluss_details(beratungen: list[sqlite3.Row]) -> str: + """Build a human-readable summary of the decisive Beratung.""" + for b in beratungen: + ergebnis = (b["ergebnis"] or "").lower() + rolle = (b["rolle"] or "") + beschlusstext = (b["beschlusstext"] or "") if "beschlusstext" in b.keys() else "" + combined = f"{ergebnis} {beschlusstext}".lower() + datum = b["sitzung_datum"] or "?" + + if any(kw in combined for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt", + "einstimmig", "abgelehnt", "kenntnis", "ohne beschlussfassung")): + ergebnis_display = b["ergebnis"] or "behandelt" + parts = [f'Ergebnis: \u201e{ergebnis_display}\u201c ({datum})'] + if beschlusstext and len(beschlusstext) > 5: + snippet = beschlusstext[:150] + ellipsis = "\u2026" if len(beschlusstext) > 150 else "" + parts.append(f'Beschlusstext: \u201e{snippet}{ellipsis}\u201c') + return " ".join(parts) + + if "entscheidung" in rolle.lower() and ergebnis: + return f'Ergebnis: \u201e{b["ergebnis"]}\u201c ({datum})' + + if beschlusstext and len(beschlusstext) > 5 and beschlusstext.lower() not in ("0", "-", ""): + snippet = beschlusstext[:150] + ellipsis = "\u2026" if len(beschlusstext) > 150 else "" + return f'Beschlusstext vorhanden ({datum}): \u201e{snippet}{ellipsis}\u201c.' + return "" + + def _get_beschluss(beratungen: list[sqlite3.Row]) -> str | None: """Determine the final decision from Beratungen. + Checks both ergebnis (OParl) and beschlusstext (scraped) fields. Looks for Entscheidung-role beratungen with a result. """ for b in beratungen: ergebnis = (b["ergebnis"] or "").lower() rolle = (b["rolle"] or "").lower() + beschlusstext = (b["beschlusstext"] or "").lower() if "beschlusstext" in b.keys() else "" - if "abgelehnt" in ergebnis: + # Combine ergebnis and beschlusstext for keyword search + combined = f"{ergebnis} {beschlusstext}" + + if "abgelehnt" in combined: return "abgelehnt" - if "verwiesen" in ergebnis: + if "verwiesen" in combined: return "verwiesen" - if any(kw in ergebnis for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt")): + if any(kw in combined for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt", "einstimmig")): return "angenommen" + if any(kw in combined for kw in ("zur kenntnis genommen", "kenntnisnahme", "kenntnis genommen")): + return "angenommen" # Kenntnisnahme = Vorgang abgeschlossen + if "ohne beschlussfassung" in combined: + return "angenommen" # Behandelt ohne formalen Beschluss = erledigt # If rolle is Entscheidung and there's any ergebnis, it's likely a decision if "entscheidung" in rolle and ergebnis and "vertagt" not in ergebnis: return "angenommen" + # beschlusstext vorhanden und nicht leer/0/kurz = es gab eine Behandlung + if beschlusstext and len(beschlusstext) > 5 and beschlusstext not in ("0", "-", ""): + # Es wurde beraten und es gibt einen Text → vermutlich erledigt + return "angenommen" return None diff --git a/backend/src/tracker/db/session.py b/backend/src/tracker/db/session.py index a7564a7..682310f 100644 --- a/backend/src/tracker/db/session.py +++ b/backend/src/tracker/db/session.py @@ -1,3 +1,4 @@ +from __future__ import annotations """SQLite database connection management.""" import os @@ -9,7 +10,7 @@ from pathlib import Path DB_PATH = Path(os.environ.get("DATABASE_PATH", Path(__file__).resolve().parents[4] / "data" / "tracker.db")) -def get_connection(db_path: Path | str | None = None) -> sqlite3.Connection: +def get_connection(db_path=None) -> sqlite3.Connection: path = str(db_path or DB_PATH) conn = sqlite3.connect(path, detect_types=0) conn.row_factory = sqlite3.Row diff --git a/backend/src/tracker/main.py b/backend/src/tracker/main.py index e40afee..b4f45f2 100644 --- a/backend/src/tracker/main.py +++ b/backend/src/tracker/main.py @@ -1,13 +1,15 @@ +from __future__ import annotations """FastAPI application for Antragstracker Hagen.""" import os from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from tracker.api.routes import abstimmungen, ketten, orte, stats, vorlagen +from tracker.api.routes import abstimmungen, bewertung, fraktionen, ketten, orte, stats, vorlagen app = FastAPI( title="Antragstracker Hagen", @@ -18,7 +20,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -27,6 +29,8 @@ app.include_router(ketten.router, prefix="/api") app.include_router(stats.router, prefix="/api") app.include_router(abstimmungen.router, prefix="/api") app.include_router(orte.router, prefix="/api") +app.include_router(fraktionen.router, prefix="/api") +app.include_router(bewertung.router, prefix="/api") @app.get("/api/health") @@ -36,8 +40,23 @@ def health(): # Serve static frontend files in production # Try multiple paths (Docker vs local dev) +_static_dir: Path | None = None for static_path in ["/app/static", Path(__file__).parent.parent.parent.parent / "static"]: static_dir = Path(static_path) if static_dir.exists() and (static_dir / "index.html").exists(): - app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + _static_dir = static_dir + app.mount("/_app", StaticFiles(directory=str(static_dir / "_app")), name="static-app") break + + +# SPA catch-all: serve index.html for any non-API, non-asset route +@app.get("/{full_path:path}") +async def spa_fallback(request: Request, full_path: str): + if _static_dir is None: + return {"error": "static files not found"} + # Try to serve static file first + file_path = _static_dir / full_path + if file_path.is_file(): + return FileResponse(file_path) + # Fallback to index.html for SPA routing + return FileResponse(_static_dir / "index.html") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 006040b..dc372f8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,6 +95,7 @@ export interface KetteDetail { status_seit: string | null; letzte_aktivitaet: string | null; vertagungen_count: number; + begruendung: string | null; glieder: KettenGliedOut[]; antragsteller: ParteiOut[]; graph: { @@ -167,3 +168,44 @@ export const fetchKetten = (params: Record) => { }; export const fetchKette = (id: number) => get(`/ketten/${id}`); + +// Fraktionen +export interface FraktionDashboard { + partei: { id: number; kuerzel: string; name: string; farbe: string | null }; + total_antraege: number; + bewertet: number; + umsetzung: { bewertung: string; anzahl: number; avg_score: number }[]; + antraege: { + id: number; aktenzeichen: string | null; betreff: string | null; + typ: string | null; datum_eingang: string | null; + umsetzung_score: number | null; umsetzung_bewertung: string | null; + umsetzung_begruendung: string | null; + }[]; + jahre: string[]; +} + +// Re-evaluation +async function post(path: string, body: object): Promise { + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export const reevalVorlage = (id: number, anmerkung: string) => + post<{ job_id: string; status: string }>(`/bewertung/vorlagen/${id}`, { anmerkung }); + +export const reevalKette = (id: number, anmerkung: string) => + post<{ job_id: string; status: string }>(`/bewertung/ketten/${id}`, { anmerkung }); + +export const fetchJobStatus = (jobId: string) => + get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`); + +export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen'); +export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => { + const params = jahr ? `?jahr=${jahr}` : ''; + return get(`/fraktionen/${kuerzel}/dashboard${params}`); +}; diff --git a/frontend/src/lib/components/UmsetzungBadge.svelte b/frontend/src/lib/components/UmsetzungBadge.svelte new file mode 100644 index 0000000..40d5841 --- /dev/null +++ b/frontend/src/lib/components/UmsetzungBadge.svelte @@ -0,0 +1,37 @@ + + + tooltipVisible = true} + onmouseleave={() => tooltipVisible = false} +> + {info.icon} + {info.label} + {#if score !== null && score !== undefined} + ({(score * 100).toFixed(0)}%) + {/if} + + {#if showTooltip && tooltipVisible} +
+
{info.icon} {info.label}
+
{info.beschreibung}
+
Beispiel: {info.beispiel}
+ {#if info.score_range} +
Score: {info.score_range[0]}–{info.score_range[1]}
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/lib/umsetzung.ts b/frontend/src/lib/umsetzung.ts new file mode 100644 index 0000000..83c24d1 --- /dev/null +++ b/frontend/src/lib/umsetzung.ts @@ -0,0 +1,84 @@ +export type UmsetzungKategorie = 'erfuellt' | 'teilweise' | 'abgewiegelt' | 'nebelkerze' | 'vertagt' | 'unklar'; + +interface KategorieInfo { + label: string; + farbe: string; + icon: string; + beschreibung: string; + beispiel: string; + score_range: [number, number] | null; +} + +const KATEGORIEN: Record = { + erfuellt: { + label: 'Erfüllt', + farbe: '#22c55e', + icon: '✅', + beschreibung: 'Forderung vollständig oder weitgehend umgesetzt. Konkreter Beschluss liegt vor, der die Kernpunkte des Antrags aufgreift.', + beispiel: 'Antrag auf Zuschuss → Zuschuss in beantragter Höhe einstimmig bewilligt.', + score_range: [0.8, 1.0], + }, + teilweise: { + label: 'Teilweise', + farbe: '#eab308', + icon: '⚠️', + beschreibung: 'Kernpunkt wurde adressiert, aber mit Abstrichen. Verwaltung greift Teile auf, lässt andere fallen oder verwässert die Forderung.', + beispiel: 'Antrag auf Radweg → Prüfauftrag für Machbarkeitsstudie statt direktem Baubeschluss.', + score_range: [0.5, 0.7], + }, + abgewiegelt: { + label: 'Abgewiegelt', + farbe: '#f97316', + icon: '🚫', + beschreibung: 'Verwaltung weicht aus. Kündigt Prüfung an, verweist auf Zuständigkeiten oder beantwortet die Frage nicht substantiell.', + beispiel: 'Anfrage zu Missständen → „Wird geprüft" ohne Zeitrahmen oder konkrete Zusagen.', + score_range: [0.2, 0.4], + }, + nebelkerze: { + label: 'Nebelkerze', + farbe: '#ef4444', + icon: '💨', + beschreibung: 'Thema komplett ignoriert, Diskussion auf Nebenschauplatz verlagert oder Antrag ohne Behandlung von der Tagesordnung genommen.', + beispiel: 'Antrag zu Sauberkeit → „Ohne Beschlussfassung" oder keine Wortmeldung.', + score_range: [0.0, 0.1], + }, + vertagt: { + label: 'Vertagt', + farbe: '#8b5cf6', + icon: '⏳', + beschreibung: 'Antrag explizit verschoben, ruhend gestellt oder in einen anderen Ausschuss verwiesen. Kein inhaltliches Ergebnis, aber formal nicht abgelehnt.', + beispiel: 'Antrag wird zur weiteren Beratung in den Fachausschuss überwiesen.', + score_range: null, + }, + unklar: { + label: 'Unklar', + farbe: '#6b7280', + icon: '❓', + beschreibung: 'Beschlusslage nicht eindeutig zuordenbar. Möglicherweise fehlen Dokumente oder der Beschlusstext ist nicht aussagekräftig genug.', + beispiel: 'Nur „Kenntnisnahme" ohne weitere Erläuterung.', + score_range: null, + }, +}; + +const FALLBACK: KategorieInfo = { + label: 'Unbekannt', + farbe: '#9ca3af', + icon: '❓', + beschreibung: 'Keine Bewertung vorhanden.', + beispiel: '', + score_range: null, +}; + +export function umsetzungInfo(kategorie: UmsetzungKategorie | string | null): KategorieInfo { + if (!kategorie) return FALLBACK; + return KATEGORIEN[kategorie as UmsetzungKategorie] ?? FALLBACK; +} + +export function scoreToKategorie(score: number): UmsetzungKategorie { + if (score >= 0.8) return 'erfuellt'; + if (score >= 0.5) return 'teilweise'; + if (score >= 0.2) return 'abgewiegelt'; + return 'nebelkerze'; +} + +export { KATEGORIEN }; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e56a7bb..3b1667f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -18,6 +18,7 @@ Vorlagen Abstimmungen Karte + Fraktionen diff --git a/frontend/src/routes/fraktionen/+page.svelte b/frontend/src/routes/fraktionen/+page.svelte new file mode 100644 index 0000000..d6dfc15 --- /dev/null +++ b/frontend/src/routes/fraktionen/+page.svelte @@ -0,0 +1,37 @@ + + + + Fraktionen — Antragstracker Hagen + + +
+

Fraktionen

+ + {#if loading} +
Laden...
+ {:else} + + {/if} +
diff --git a/frontend/src/routes/fraktionen/[kuerzel]/+page.svelte b/frontend/src/routes/fraktionen/[kuerzel]/+page.svelte new file mode 100644 index 0000000..5b4d2ea --- /dev/null +++ b/frontend/src/routes/fraktionen/[kuerzel]/+page.svelte @@ -0,0 +1,180 @@ + + + + {data?.partei?.name ?? kuerzel} — Antragstracker Hagen + + +
+ {#if loading && !data} +
Laden...
+ {:else if error} +
Fehler: {error}
+ {:else if data} + +
+
+
+

{data.partei.name}

+ {data.partei.kuerzel} +
+ ← Alle Fraktionen +
+ + +
+
+
{data.total_antraege}
+
Anträge gesamt
+
+
+
{data.bewertet}
+
Mit Umsetzungsbewertung
+
+ {#each data.umsetzung.filter(u => u.bewertung === 'erfuellt') as u} +
+
{u.anzahl}
+
Erfüllt
+
+ {/each} + {#each data.umsetzung.filter(u => u.bewertung === 'nebelkerze') as u} +
+
{u.anzahl}
+
Nebelkerzen
+
+ {/each} +
+ + + {#if data.bewertet > 0} +
+

Umsetzungsquote

+
+ {#each data.umsetzung as u} + {@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]} + {@const pct = (u.anzahl / data.bewertet) * 100} + {#if info && pct > 0} +
+ {#if pct > 8}{info.label} {pct.toFixed(0)}%{/if} +
+ {/if} + {/each} +
+ +
+ {#each data.umsetzung as u} + {@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]} + {#if info} + + {/if} + {/each} +
+
+ {/if} + + +
+ + {#if filterKategorie} + + {/if} + {filteredAntraege.length} Anträge +
+ + +
+ + + + + + + + + + + {#each filteredAntraege as a} + + + + + + + {/each} + +
AktenzeichenBetreffDatumUmsetzung
+ + {a.aktenzeichen} + + {a.betreff}{formatDate(a.datum_eingang)} + {#if a.umsetzung_bewertung} + + {:else} + + {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/ketten/+page.svelte b/frontend/src/routes/ketten/+page.svelte index c87b40a..1c7f382 100644 --- a/frontend/src/routes/ketten/+page.svelte +++ b/frontend/src/routes/ketten/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { fetchKetten, type KetteKurz, type Paginated } from '$lib/api'; + import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api'; import { formatDate } from '$lib/status'; import StatusBadge from '$lib/components/StatusBadge.svelte'; @@ -14,13 +14,16 @@ let filterStatus = $state(''); let filterTyp = $state(''); let filterSuche = $state(''); + let filterPartei = $state(''); let currentPage = $state(1); + let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]); function syncFromUrl() { const p = new URL(window.location.href).searchParams; filterStatus = p.get('status') || ''; filterTyp = p.get('typ') || ''; filterSuche = p.get('suche') || ''; + filterPartei = p.get('partei') || ''; currentPage = parseInt(p.get('page') || '1'); } @@ -31,6 +34,7 @@ if (filterStatus) params.status = filterStatus; if (filterTyp) params.typ = filterTyp; if (filterSuche) params.suche = filterSuche; + if (filterPartei) params.partei = filterPartei; data = await fetchKetten(params); } catch (e) { error = e instanceof Error ? e.message : 'Fehler'; @@ -44,6 +48,7 @@ if (filterStatus) params.set('status', filterStatus); if (filterTyp) params.set('typ', filterTyp); if (filterSuche) params.set('suche', filterSuche); + if (filterPartei) params.set('partei', filterPartei); currentPage = 1; params.set('page', '1'); goto(`/ketten?${params.toString()}`, { replaceState: true }); @@ -58,7 +63,8 @@ load(); } - onMount(() => { + onMount(async () => { + parteien = await fetchFraktionen(); syncFromUrl(); load(); }); @@ -109,6 +115,16 @@ +
+ + +