From f8bc893a5478e09972d3c4f2a3ca81462b67eb98 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Thu, 2 Apr 2026 00:36:30 +0200 Subject: [PATCH] feat: Strang-basierte Klassifikation + Explorer + Ampel (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Features: - 4 Verfahrensstränge: Antrag, Anfrage, Beschlussvorlage, Mitteilung - Ampel-Visualisierung pro Kette (Fortschrittsanzeige mit Abzweigungen) - 3-Panel Explorer (/explorer): Liste | Kette+Ampel | Vorlage-Detail - KI-Bewertungs-Versionierung (alte Versionen aufklappbar) - Neubewertung triggert automatisch Umsetzungs-Score - Bewertungs-Log (bewertungs_log Tabelle) - Umsetzungsgrad an Kette (Score + Begründung) - Antragsteller + Beratungsergebnis pro Kettenglied - HAK und Hagen Aktiv als getrennte Fraktionen - Status-Filter im Explorer - Suche durchsucht Aktenzeichen + Betreff Backend: - tracker/core/ampel.py — Ampel-Definition + get_ampel() - tracker/core/perioden.py — Shared Perioden-Filter - Neues Feld: ketten.strang, ki_bewertungen.kette_id - GET /api/ampel/definition, erweiterte Ketten/Vorlagen-APIs Closes #16 --- .github/WORKFLOW.md | 6 + backend/src/tracker/api/models.py | 10 +- backend/src/tracker/api/routes/ampel.py | 14 + backend/src/tracker/api/routes/bewertung.py | 98 ++- backend/src/tracker/api/routes/ketten.py | 117 ++- backend/src/tracker/api/routes/stats.py | 8 + backend/src/tracker/api/routes/vorlagen.py | 46 +- backend/src/tracker/core/ampel.py | 236 ++++++ .../src/tracker/core/fraktionen_mapping.py | 8 +- backend/src/tracker/main.py | 3 +- frontend/src/lib/api.ts | 43 ++ frontend/src/lib/components/Ampel.svelte | 177 +++++ frontend/src/routes/+layout.svelte | 2 + frontend/src/routes/+page.svelte | 57 ++ frontend/src/routes/explorer/+page.svelte | 703 ++++++++++++++++++ .../src/routes/vorlagen/[id]/+page.svelte | 28 + scripts/migrate_strang.py | 64 ++ 17 files changed, 1573 insertions(+), 47 deletions(-) create mode 100644 backend/src/tracker/api/routes/ampel.py create mode 100644 backend/src/tracker/core/ampel.py create mode 100644 frontend/src/lib/components/Ampel.svelte create mode 100644 frontend/src/routes/explorer/+page.svelte create mode 100644 scripts/migrate_strang.py diff --git a/.github/WORKFLOW.md b/.github/WORKFLOW.md index 0db41c5..1f8191a 100644 --- a/.github/WORKFLOW.md +++ b/.github/WORKFLOW.md @@ -11,3 +11,9 @@ ## Hotfixes direkt auf main Nur für: Typo-Fixes, Config-Änderungen, Doku-Updates + +## Feature/16 — Offene Punkte + +- [ ] Neubewertung: Alte Bewertungen behalten, Versionen untereinander anzeigen (neueste zuerst, mit Zeitstempel + Anlass) +- [ ] Nutzungsanleitung schreiben (Entwurf zur Abstimmung mit Tobias) +- [ ] Branch in main squash-mergen wenn alles passt diff --git a/backend/src/tracker/api/models.py b/backend/src/tracker/api/models.py index 9a9db36..8819032 100644 --- a/backend/src/tracker/api/models.py +++ b/backend/src/tracker/api/models.py @@ -84,6 +84,8 @@ class VorlageDetail(BaseModel): referenzen_eingehend: list[ReferenzOut] = [] kette_id: int | None = None umsetzungsbewertungen: list[UmsetzungsBewertung] = [] + ampel: dict | None = None + ki_versionen: list[dict] | None = None class KettenGliedOut(BaseModel): @@ -102,6 +104,8 @@ class KetteKurz(BaseModel): letzte_aktivitaet: date | None = None vertagungen_count: int = 0 glieder_count: int = 0 + strang: str | None = None + ampel: dict | None = None class KetteDetail(BaseModel): @@ -114,9 +118,13 @@ class KetteDetail(BaseModel): letzte_aktivitaet: date | None = None vertagungen_count: int = 0 begruendung: str | None = None - glieder: list[KettenGliedOut] = [] + glieder: list[dict] = [] antragsteller: list[ParteiOut] = [] graph: dict | None = None + strang: str | None = None + ampel: dict | None = None + umsetzung: dict | None = None + umsetzung_versionen: list[dict] | None = None class PaginatedVorlagen(BaseModel): diff --git a/backend/src/tracker/api/routes/ampel.py b/backend/src/tracker/api/routes/ampel.py new file mode 100644 index 0000000..b2db200 --- /dev/null +++ b/backend/src/tracker/api/routes/ampel.py @@ -0,0 +1,14 @@ +from __future__ import annotations +"""API routes for Ampel definitions.""" + +from fastapi import APIRouter + +from tracker.core.ampel import get_ampel_definition + +router = APIRouter(prefix="/ampel", tags=["Ampel"]) + + +@router.get("/definition") +def ampel_definition(): + """Gibt die komplette Strang-Definition zurück (für Legende im Frontend).""" + return get_ampel_definition() diff --git a/backend/src/tracker/api/routes/bewertung.py b/backend/src/tracker/api/routes/bewertung.py index 4504722..4293261 100644 --- a/backend/src/tracker/api/routes/bewertung.py +++ b/backend/src/tracker/api/routes/bewertung.py @@ -162,19 +162,43 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str): _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,)) + # Get previous version for logging + prev = conn.execute( + "SELECT begruendung FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' ORDER BY id DESC LIMIT 1", + (vorlage_id,), + ).fetchone() + + # Keep old versions, insert new 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)), + """INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version, erstellt_at) + VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""", + (vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False), + datetime.now().isoformat()), ) if result.get("kernforderung"): conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id)) + + # Log + conn.execute( + """INSERT INTO bewertungs_log (vorlage_id, typ, anmerkung, modell, prompt_version, bewertung_vorher, bewertung_nachher, erstellt_at) + VALUES (?, 'zusammenfassung', ?, 'qwen-plus-latest', 'v2-reeval', ?, ?, ?)""", + (vorlage_id, anmerkung, prev["begruendung"] if prev else None, + result.get("zusammenfassung"), datetime.now().isoformat()), + ) conn.commit() + + # Auto-trigger Ketten-Bewertung wenn Vorlage in einer Kette ist + kette_row = conn.execute( + "SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1", + (vorlage_id,), + ).fetchone() conn.close() - _jobs[job_id] = {"status": "done", "result": result} + if kette_row: + _jobs[job_id] = {"status": "running", "result": result, "phase": "umsetzung"} + _run_ketten_bewertung(kette_row["kette_id"], anmerkung, job_id) + else: + _jobs[job_id] = {"status": "done", "result": result} except Exception as e: _jobs[job_id] = {"status": "error", "error": str(e)} @@ -249,25 +273,67 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str): _jobs[job_id] = {"status": "error", "error": str(result)} return - # Delete old umsetzung_match, insert new + # Keep old versions, insert new (linked to kette, not vorlage) 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')""", + """INSERT INTO ki_bewertungen (vorlage_id, kette_id, typ, score, begruendung, anmerkungen, modell, prompt_version, erstellt_at) + VALUES (?, ?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""", ( kette["ursprung_id"], + kette_id, result.get("score"), result.get("begruendung"), json.dumps(result, ensure_ascii=False), + datetime.now().isoformat(), ), ) - # Rebuild chain status - from tracker.core.chains import build_single_chain - build_single_chain(conn, kette["ursprung_id"]) + # Get previous scores for logging + prev_ki = conn.execute( + "SELECT score, begruendung FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match' ORDER BY id DESC LIMIT 1", + (kette["ursprung_id"],), + ).fetchone() + prev_status = kette["status"] + + # Update chain status based on KI score + score = result.get("score", 0) + bewertung = result.get("bewertung", "") + + # Map KI bewertung → Ketten-Status + if score >= 0.7: + new_status = "umgesetzt" + begruendung = f"KI-Bewertung: {score*100:.0f}% umgesetzt. {result.get('begruendung', '')}" + elif score >= 0.4: + new_status = "teilweise_umgesetzt" + begruendung = f"KI-Bewertung: {score*100:.0f}% teilweise umgesetzt. {result.get('begruendung', '')}" + elif bewertung == "abgewiegelt" or bewertung == "nebelkerze": + new_status = "abgewiegelt" + begruendung = f"KI-Bewertung: {score*100:.0f}% — {bewertung}. {result.get('begruendung', '')}" + elif score < 0.3: + new_status = "versandet" + begruendung = f"KI-Bewertung: {score*100:.0f}%. {result.get('begruendung', '')}" + else: + new_status = kette["status"] # Keep current + begruendung = kette["begruendung"] + + conn.execute( + "UPDATE ketten SET status = ?, begruendung = ? WHERE id = ?", + (new_status, begruendung, kette_id), + ) + + # Log + conn.execute( + """INSERT INTO bewertungs_log + (vorlage_id, kette_id, typ, anmerkung, modell, prompt_version, + score_vorher, score_nachher, status_vorher, status_nachher, + bewertung_vorher, bewertung_nachher, erstellt_at) + VALUES (?, ?, 'umsetzung', ?, 'qwen-plus-latest', 'v2-reeval', + ?, ?, ?, ?, ?, ?, ?)""", + (kette["ursprung_id"], kette_id, anmerkung, + prev_ki["score"] if prev_ki else None, score, + prev_status, new_status, + prev_ki["begruendung"] if prev_ki else None, result.get("begruendung"), + datetime.now().isoformat()), + ) conn.commit() conn.close() diff --git a/backend/src/tracker/api/routes/ketten.py b/backend/src/tracker/api/routes/ketten.py index a41f0f3..63a3532 100644 --- a/backend/src/tracker/api/routes/ketten.py +++ b/backend/src/tracker/api/routes/ketten.py @@ -11,6 +11,7 @@ from tracker.api.models import ( ParteiOut, VorlageKurz, ) +from tracker.core.ampel import get_ampel, get_ampel_kompakt from tracker.core.graph import get_kette_graph from tracker.db.session import get_connection @@ -59,8 +60,11 @@ def list_ketten( params.append(partei) if suche: - where_clauses.append("k.thema LIKE ?") - params.append(f"%{suche}%") + where_clauses.append( + "(k.thema LIKE ? OR v.aktenzeichen LIKE ? OR v.betreff LIKE ?)" + ) + like = f"%{suche}%" + params.extend([like, like, like]) # Global filter: Ratsperiode (filter on letzte_aktivitaet) per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet") @@ -81,13 +85,14 @@ def list_ketten( where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" total = conn.execute( - f"SELECT COUNT(*) as cnt FROM ketten k {where_sql}", params + f"SELECT COUNT(*) as cnt FROM ketten k LEFT JOIN vorlagen v ON k.ursprung_id = v.id {where_sql}", params ).fetchone()["cnt"] offset = (page - 1) * page_size rows = conn.execute( f"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit, k.letzte_aktivitaet, k.vertagungen_count, k.ursprung_id, + k.strang, v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang, v.ist_verwaltungsvorlage, (SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count @@ -117,6 +122,8 @@ def list_ketten( letzte_aktivitaet=r["letzte_aktivitaet"], vertagungen_count=r["vertagungen_count"], glieder_count=r["glieder_count"], + strang=r["strang"], + ampel=get_ampel_kompakt(r["strang"] or "", r["status"] or ""), ) for r in rows ] @@ -130,6 +137,7 @@ def get_kette(kette_id: int, conn=Depends(_db)): row = conn.execute( """SELECT k.id, k.typ, k.thema, k.status, k.status_seit, k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id, + k.strang, v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang, v.ist_verwaltungsvorlage FROM ketten k @@ -153,19 +161,59 @@ def get_kette(kette_id: int, conn=Depends(_db)): (kette_id,), ).fetchall() + # Collect IDs for batch queries + glied_ids = [g["id"] for g in glieder_rows] + placeholders = ",".join("?" * len(glied_ids)) if glied_ids else "0" + + # Antragsteller per Glied + glied_ast: dict[int, list] = {} + if glied_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})""", + glied_ids, + ).fetchall() + for a in ast_rows: + glied_ast.setdefault(a["vorlage_id"], []).append( + {"kuerzel": a["kuerzel"], "name": a["name"], "farbe": a["farbe"]} + ) + + # Beratungen per Glied (Gremium + Ergebnis) + glied_beratungen: dict[int, list] = {} + if glied_ids: + ber_rows = conn.execute( + f"""SELECT b.vorlage_id, g.name as gremium, b.ergebnis, b.beschlusstext, b.sitzung_datum + FROM beratungen b LEFT JOIN gremien g ON b.gremium_id = g.id + WHERE b.vorlage_id IN ({placeholders}) + ORDER BY b.sitzung_datum""", + glied_ids, + ).fetchall() + for b in ber_rows: + glied_beratungen.setdefault(b["vorlage_id"], []).append({ + "gremium": b["gremium"], + "ergebnis": b["ergebnis"], + "beschlusstext": b["beschlusstext"][:200] if b["beschlusstext"] else None, + "sitzung_datum": b["sitzung_datum"], + }) + glieder = [ - KettenGliedOut( - vorlage=VorlageKurz( - id=g["id"], - aktenzeichen=g["aktenzeichen"], - typ=g["typ"], - betreff=g["betreff"], - datum_eingang=g["datum_eingang"], - ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]), - ), - position=g["position"], - rolle=g["rolle"], - ) + { + **KettenGliedOut( + vorlage=VorlageKurz( + id=g["id"], + aktenzeichen=g["aktenzeichen"], + typ=g["typ"], + betreff=g["betreff"], + datum_eingang=g["datum_eingang"], + ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]), + ), + position=g["position"], + rolle=g["rolle"], + ).model_dump(), + "antragsteller": glied_ast.get(g["id"], []), + "beratungen": glied_beratungen.get(g["id"], []), + } for g in glieder_rows ] @@ -183,6 +231,41 @@ def get_kette(kette_id: int, conn=Depends(_db)): # Graph/Perlenschnur data graph = get_kette_graph(conn, kette_id) + # Umsetzungsbewertung (alle Versionen für diese Kette, neueste zuerst) + umsetzung = None + umsetzung_versionen = [] + import json as _json + ub_rows = conn.execute( + """SELECT score, begruendung, anmerkungen, erstellt_at, prompt_version + FROM ki_bewertungen + WHERE kette_id = ? AND typ = 'umsetzung_match' + ORDER BY id DESC""", + (kette_id,), + ).fetchall() + for i, ub_row in enumerate(ub_rows): + details = {} + if ub_row["anmerkungen"]: + try: + details = _json.loads(ub_row["anmerkungen"]) + except Exception: + pass + entry = { + "score": ub_row["score"], + "bewertung": details.get("bewertung", ""), + "begruendung": ub_row["begruendung"], + "kernpunkt_erfuellt": details.get("kernpunkt_erfuellt"), + "details": details.get("details", ""), + "erstellt_at": ub_row["erstellt_at"], + "prompt_version": ub_row["prompt_version"], + } + if i == 0: + umsetzung = entry + else: + umsetzung_versionen.append(entry) + + strang = row["strang"] + ampel_data = get_ampel(strang or "", row["status"] or "") + return KetteDetail( id=row["id"], ursprung=VorlageKurz( @@ -203,4 +286,8 @@ def get_kette(kette_id: int, conn=Depends(_db)): glieder=glieder, antragsteller=antragsteller, graph=graph, + strang=strang, + ampel=ampel_data, + umsetzung=umsetzung, + umsetzung_versionen=umsetzung_versionen if umsetzung_versionen else None, ) diff --git a/backend/src/tracker/api/routes/stats.py b/backend/src/tracker/api/routes/stats.py index a7655f3..3882336 100644 --- a/backend/src/tracker/api/routes/stats.py +++ b/backend/src/tracker/api/routes/stats.py @@ -198,6 +198,13 @@ def get_dashboard_stats( abgelehnt = _k_count("abgelehnt") total_bewertet = umgesetzt + teilweise + versandet + beschlossen + abgelehnt + # Aufschlüsselung nach Strang + strang_rows = conn.execute(f""" + SELECT strang, COUNT(*) as c FROM ketten k + {k_where + (' AND' if k_where else 'WHERE')} strang IS NOT NULL + GROUP BY strang ORDER BY c DESC + """.replace("WHERE AND", "WHERE"), k_params).fetchall() + return { "vorlagen_total": vorlagen_total, "ketten_total": ketten_total, @@ -212,6 +219,7 @@ def get_dashboard_stats( "abgelehnt": abgelehnt, "total_bewertet": total_bewertet, }, + "nach_strang": [{"strang": r["strang"], "anzahl": r["c"]} for r in strang_rows], } diff --git a/backend/src/tracker/api/routes/vorlagen.py b/backend/src/tracker/api/routes/vorlagen.py index d5fee85..3a3fa07 100644 --- a/backend/src/tracker/api/routes/vorlagen.py +++ b/backend/src/tracker/api/routes/vorlagen.py @@ -281,24 +281,46 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)): # Referenzen refs = get_references_for_vorlage(conn, vorlage_id) - # Kette-Zugehörigkeit + # Kette-Zugehörigkeit + Ampel + from tracker.core.ampel import get_ampel kette_row = conn.execute( "SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1", (vorlage_id,), ).fetchone() - # KI-Zusammenfassung - ki_row = conn.execute( - "SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1", + kette_ampel = None + if kette_row: + kette_info = conn.execute( + "SELECT strang, status FROM ketten WHERE id = ?", + (kette_row["kette_id"],), + ).fetchone() + if kette_info and kette_info["strang"]: + kette_ampel = get_ampel(kette_info["strang"], kette_info["status"] or "") + + # KI-Zusammenfassung (alle Versionen, neueste zuerst) + ki_rows = conn.execute( + "SELECT anmerkungen, erstellt_at, prompt_version FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' ORDER BY id DESC", (vorlage_id,), - ).fetchone() + ).fetchall() ki_zusammenfassung = None - if ki_row and ki_row["anmerkungen"]: - try: - ki_data = json.loads(ki_row["anmerkungen"]) - ki_zusammenfassung = KiZusammenfassung(**ki_data) - except (json.JSONDecodeError, TypeError): - pass + ki_versionen = [] + for i, ki_row in enumerate(ki_rows): + if ki_row["anmerkungen"]: + try: + ki_data = json.loads(ki_row["anmerkungen"]) + if i == 0: + ki_zusammenfassung = KiZusammenfassung(**ki_data) + else: + ki_versionen.append({ + "zusammenfassung": ki_data.get("zusammenfassung", ""), + "kernforderung": ki_data.get("kernforderung", ""), + "begruendung": ki_data.get("begruendung", ""), + "thema": ki_data.get("thema", ""), + "erstellt_at": ki_row["erstellt_at"], + "prompt_version": ki_row["prompt_version"], + }) + except (json.JSONDecodeError, TypeError): + pass # Umsetzungsbewertungen from tracker.api.models import UmsetzungsBewertung @@ -340,4 +362,6 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)): kette_id=kette_row["kette_id"] if kette_row else None, ki_zusammenfassung=ki_zusammenfassung, umsetzungsbewertungen=umsetzungsbewertungen, + ampel=kette_ampel, + ki_versionen=ki_versionen if ki_versionen else None, ) diff --git a/backend/src/tracker/core/ampel.py b/backend/src/tracker/core/ampel.py new file mode 100644 index 0000000..583f271 --- /dev/null +++ b/backend/src/tracker/core/ampel.py @@ -0,0 +1,236 @@ +"""Ampel-Darstellungsschicht: Strang-basierte Klassifikation mit Ampel-Visualisierung. + +Dies ist eine reine Darstellungsschicht über der bestehenden Status-Engine (core/status.py). +Die Status-Engine bleibt unverändert — die Ampel mappt deren Ergebnisse auf visuelle Schritte. +""" + +from __future__ import annotations + +# Definiert die Zustände pro Strang in Reihenfolge +STRANG_ZUSTAENDE: dict[str, list[dict]] = { + "antrag": [ + {"id": "eingereicht", "label": "Eingereicht", "endfarbe": None}, + {"id": "in_beratung", "label": "In Beratung", "endfarbe": None}, + {"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"}, + {"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"}, + ], + "beschlussvorlage": [ + {"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None}, + {"id": "in_beratung", "label": "In Beratung", "endfarbe": None}, + {"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"}, + {"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"}, + ], + "anfrage": [ + {"id": "angefragt", "label": "Angefragt", "endfarbe": "gelb"}, + {"id": "beantwortet", "label": "Beantwortet", "endfarbe": "gruen"}, + ], + "mitteilung": [ + {"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None}, + {"id": "zur_kenntnis", "label": "Zur Kenntnis genommen", "endfarbe": "grau"}, + ], +} + +# Endstatus die von der Hauptreihe abzweigen +ABZWEIGUNGEN: dict[str, dict] = { + "abgelehnt": {"label": "Abgelehnt", "farbe": "rot"}, + "abgewiegelt": {"label": "Abgewiegelt", "farbe": "rot"}, + "versandet": {"label": "Versandet", "farbe": "rot"}, + "teilweise_umgesetzt": {"label": "Teilweise umgesetzt", "farbe": "amber"}, + "verwiesen": {"label": "Verwiesen", "farbe": "gelb"}, + "zurueckgezogen": {"label": "Zurückgezogen", "farbe": "grau"}, +} + +# Labels für Stränge +STRANG_LABELS: dict[str, str] = { + "antrag": "Antrag", + "beschlussvorlage": "Beschlussvorlage", + "anfrage": "Anfrage", + "mitteilung": "Mitteilung", + "sonstig": "Sonstig", +} + +# Kontrollfragen pro Strang +KONTROLLFRAGEN: dict[str, str | None] = { + "antrag": "Hat die Verwaltung umgesetzt?", + "beschlussvorlage": "Wurde so umgesetzt wie beschlossen?", + "anfrage": "Wurde befriedigend geantwortet?", + "mitteilung": None, +} + +# Mapping: DB-Status → (Schritt-ID der letzten erreichten Position, ist Abzweigung?) +# Für jeden Strang kann das Mapping unterschiedlich sein. +# Wir definieren ein generisches Mapping und strang-spezifische Overrides. + +_STATUS_TO_SCHRITT: dict[str, dict[str, tuple[str, bool]]] = { + "antrag": { + "eingereicht": ("eingereicht", False), + "in_beratung": ("in_beratung", False), + "vertagt": ("in_beratung", False), # Vertagt = noch in Beratung + "beschlossen": ("beschlossen", False), + "umgesetzt": ("umgesetzt", False), + "versandet": ("beschlossen", True), + "abgelehnt": ("in_beratung", True), + "teilweise_umgesetzt": ("beschlossen", True), + "verwiesen": ("in_beratung", True), + "zurückgezogen": ("in_beratung", True), + "zurueckgezogen": ("in_beratung", True), + "abgewiegelt": ("beschlossen", True), + "offen": ("in_beratung", False), + }, + "beschlussvorlage": { + "eingereicht": ("vorgelegt", False), + "vorgelegt": ("vorgelegt", False), + "in_beratung": ("in_beratung", False), + "vertagt": ("in_beratung", False), + "beschlossen": ("beschlossen", False), + "umgesetzt": ("umgesetzt", False), + "versandet": ("beschlossen", True), + "abgelehnt": ("in_beratung", True), + "teilweise_umgesetzt": ("beschlossen", True), + "verwiesen": ("in_beratung", True), + "zurückgezogen": ("in_beratung", True), + "zurueckgezogen": ("in_beratung", True), + "abgewiegelt": ("beschlossen", True), + "offen": ("in_beratung", False), + }, + "anfrage": { + "angefragt": ("angefragt", False), + "beantwortet": ("beantwortet", False), + "offen": ("angefragt", False), + "abgewiegelt": ("angefragt", True), + "versandet": ("angefragt", True), + "zurückgezogen": ("angefragt", True), + "zurueckgezogen": ("angefragt", True), + }, + "mitteilung": { + "vorgelegt": ("vorgelegt", False), + "zur_kenntnis": ("zur_kenntnis", False), + "eingereicht": ("vorgelegt", False), + "beantwortet": ("zur_kenntnis", False), # Mapped to equivalent + "beschlossen": ("zur_kenntnis", False), + }, +} + + +def _normalize_abzweigung_id(status: str) -> str: + """Normalize status string to ABZWEIGUNGEN key.""" + mapping = { + "zurückgezogen": "zurueckgezogen", + } + return mapping.get(status, status) + + +def get_ampel(strang: str, aktueller_status: str) -> dict | None: + """Gibt die Ampel-Daten für Frontend zurück. + + Returns None if strang is unknown or 'sonstig'. + """ + if not strang or strang not in STRANG_ZUSTAENDE: + return None + + schritte_def = STRANG_ZUSTAENDE[strang] + status_map = _STATUS_TO_SCHRITT.get(strang, {}) + + # Determine position and whether it's a branch-off + mapping = status_map.get(aktueller_status or "") + if mapping is None: + # Unknown status — show first step as active + aktiver_schritt_id = schritte_def[0]["id"] + ist_abzweigung = False + else: + aktiver_schritt_id, ist_abzweigung = mapping + + # Find index of active step + schritt_ids = [s["id"] for s in schritte_def] + try: + aktiver_idx = schritt_ids.index(aktiver_schritt_id) + except ValueError: + aktiver_idx = 0 + + # Build schritte list + schritte = [] + for i, s in enumerate(schritte_def): + if ist_abzweigung: + # Bei Abzweigung: alle bis aktiver_idx sind "erreicht", keiner ist "aktiv" + erreicht = i <= aktiver_idx + aktiv = False + else: + erreicht = i <= aktiver_idx + aktiv = i == aktiver_idx + + # Farbe bestimmen + if aktiv and s["endfarbe"]: + farbe = s["endfarbe"] + elif aktiv: + farbe = "blau" # Aktiver Schritt ohne spezielle Endfarbe + elif erreicht: + farbe = "grau" # Bereits durchlaufen + else: + farbe = "grau" # Noch nicht erreicht + + schritte.append({ + "id": s["id"], + "label": s["label"], + "aktiv": aktiv, + "erreicht": erreicht, + "farbe": farbe, + }) + + # Abzweigung + abzweigung = None + if ist_abzweigung: + norm_status = _normalize_abzweigung_id(aktueller_status or "") + if norm_status in ABZWEIGUNGEN: + abzw = ABZWEIGUNGEN[norm_status] + abzweigung = { + "id": norm_status, + "label": abzw["label"], + "farbe": abzw["farbe"], + } + + return { + "strang": strang, + "strang_label": STRANG_LABELS.get(strang, strang.capitalize()), + "kontrollfrage": KONTROLLFRAGEN.get(strang), + "schritte": schritte, + "abzweigung": abzweigung, + } + + +def get_ampel_kompakt(strang: str, aktueller_status: str) -> dict | None: + """Kompakte Ampel-Version für Listen: nur aktueller Schritt + Farbe.""" + ampel = get_ampel(strang, aktueller_status) + if not ampel: + return None + + if ampel["abzweigung"]: + return { + "schritt": ampel["abzweigung"]["label"], + "farbe": ampel["abzweigung"]["farbe"], + "ist_abzweigung": True, + } + + aktiver = next((s for s in ampel["schritte"] if s["aktiv"]), None) + if aktiver: + return { + "schritt": aktiver["label"], + "farbe": aktiver["farbe"], + "ist_abzweigung": False, + } + + return None + + +def get_ampel_definition() -> dict: + """Gibt die komplette Strang-Definition zurück (für Legende im Frontend).""" + return { + "straenge": { + strang: { + "label": STRANG_LABELS.get(strang, strang.capitalize()), + "kontrollfrage": KONTROLLFRAGEN.get(strang), + "schritte": schritte, + } + for strang, schritte in STRANG_ZUSTAENDE.items() + }, + "abzweigungen": ABZWEIGUNGEN, + } diff --git a/backend/src/tracker/core/fraktionen_mapping.py b/backend/src/tracker/core/fraktionen_mapping.py index a84c828..844ef6b 100644 --- a/backend/src/tracker/core/fraktionen_mapping.py +++ b/backend/src/tracker/core/fraktionen_mapping.py @@ -17,10 +17,12 @@ FRAKTION_MAPPING: dict[str, str] = { "Linke": "Linke", "Linke/HAK": "Linke/HAK", - # Hagen Aktiv + # Hagen Aktiv (Freie Wählergemeinschaft) "HAGEN AKTIV": "Hagen Aktiv", "Hagen Aktiv": "Hagen Aktiv", - "HAK": "Hagen Aktiv", + + # HAK (Hagener Aktivistenkreis) — NICHT Hagen Aktiv! + "HAK": "HAK", "HAK/Die Linke": "HAK/Linke", # BfHo / Die PARTEI @@ -59,7 +61,7 @@ FRAKTION_MAPPING: dict[str, str] = { # Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant) RATSFRAKTIONEN = { "SPD", "CDU", "Grüne", "FDP", "AfD", "Linke", "Hagen Aktiv", - "BfHo", "BfHo/Die PARTEI", "BSW", "Freie Wähler", + "HAK", "BfHo", "BfHo/Die PARTEI", "BSW", "Die PARTEI", } diff --git a/backend/src/tracker/main.py b/backend/src/tracker/main.py index b4f45f2..5e867fb 100644 --- a/backend/src/tracker/main.py +++ b/backend/src/tracker/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from tracker.api.routes import abstimmungen, bewertung, fraktionen, ketten, orte, stats, vorlagen +from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, ketten, orte, stats, vorlagen app = FastAPI( title="Antragstracker Hagen", @@ -31,6 +31,7 @@ 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.include_router(ampel.router, prefix="/api") @app.get("/api/health") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ccedd5c..d117cb5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,6 +68,34 @@ export interface VorlageDetail extends VorlageKurz { kette_id: number | null; } +export interface AmpelSchritt { + id: string; + label: string; + aktiv: boolean; + erreicht: boolean; + farbe: string; +} + +export interface AmpelAbzweigung { + id: string; + label: string; + farbe: string; +} + +export interface AmpelData { + strang: string; + strang_label: string; + kontrollfrage: string | null; + schritte: AmpelSchritt[]; + abzweigung: AmpelAbzweigung | null; +} + +export interface AmpelKompakt { + schritt: string; + farbe: string; + ist_abzweigung: boolean; +} + export interface KetteKurz { id: number; ursprung: VorlageKurz | null; @@ -78,6 +106,8 @@ export interface KetteKurz { letzte_aktivitaet: string | null; vertagungen_count: number; glieder_count: number; + strang: string | null; + ampel: AmpelKompakt | null; } export interface KettenGliedOut { @@ -102,6 +132,8 @@ export interface KetteDetail { nodes: GraphNode[]; edges: GraphEdge[]; } | null; + strang: string | null; + ampel: AmpelData | null; } export interface GraphNode { @@ -216,6 +248,17 @@ export const fetchSuchvorschlaege = (q: string) => get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`); export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen'); +export interface AmpelDefinition { + straenge: Record; + abzweigungen: Record; +} + +export const fetchAmpelDefinition = () => get('/ampel/definition'); + export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => { const p = new URLSearchParams(); if (jahr) p.set('jahr', jahr); diff --git a/frontend/src/lib/components/Ampel.svelte b/frontend/src/lib/components/Ampel.svelte new file mode 100644 index 0000000..08a1c49 --- /dev/null +++ b/frontend/src/lib/components/Ampel.svelte @@ -0,0 +1,177 @@ + + +{#if ampel} + {#if compact} + +
+ {#each ampel.schritte as schritt, i} + {#if i > 0} +
+ {/if} +
+ {/each} + {#if ampel.abzweigung} +
+
+ {/if} +
+ + {:else if vertical} + +
+ {#each ampel.schritte as schritt, i} + {#if i > 0} +
+ {/if} +
+
+ + {schritt.label} + +
+ + {#if ampel.abzweigung && i === abzweigungIndex()} +
+
+
+
+
+ + {ampel.abzweigung.label} + +
+ {/if} + {/each} + {#if ampel.kontrollfrage} +

{ampel.kontrollfrage}

+ {/if} +
+ + {:else} + +
+
+ {#each ampel.schritte as schritt, i} + {#if i > 0} +
+ {/if} +
+
+ + {schritt.label} + +
+ {/each} +
+ + {#if ampel.abzweigung} + {@const idx = abzweigungIndex()} + {#if idx >= 0} + +
+
+
+
+ + {ampel.abzweigung.label} + +
+
+ {/if} + {/if} + {#if ampel.kontrollfrage} +

{ampel.kontrollfrage}

+ {/if} +
+ {/if} +{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c5d44f1..68ce05e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -37,6 +37,7 @@