feat(#16): Strang-basierte Klassifikation mit Ampel

- Migration: strang-Feld in ketten (scripts/migrate_strang.py)
- core/ampel.py: Ampel-Definition, Status-Mapping, Kontrollfragen
- API: strang+ampel in GET /api/ketten, /api/ketten/{id}
- API: ampel in GET /api/vorlagen/{id} (wenn in Kette)
- API: GET /api/ampel/definition (Legende für Frontend)
- API: nach_strang in GET /api/stats/dashboard
- Migration auf 6354 Ketten ausgeführt
This commit is contained in:
Dotty Dotter 2026-04-01 18:30:24 +02:00
parent 6db12e297d
commit 3758079038
8 changed files with 351 additions and 2 deletions

View File

@ -84,6 +84,7 @@ class VorlageDetail(BaseModel):
referenzen_eingehend: list[ReferenzOut] = [] referenzen_eingehend: list[ReferenzOut] = []
kette_id: int | None = None kette_id: int | None = None
umsetzungsbewertungen: list[UmsetzungsBewertung] = [] umsetzungsbewertungen: list[UmsetzungsBewertung] = []
ampel: dict | None = None
class KettenGliedOut(BaseModel): class KettenGliedOut(BaseModel):
@ -102,6 +103,8 @@ class KetteKurz(BaseModel):
letzte_aktivitaet: date | None = None letzte_aktivitaet: date | None = None
vertagungen_count: int = 0 vertagungen_count: int = 0
glieder_count: int = 0 glieder_count: int = 0
strang: str | None = None
ampel: dict | None = None
class KetteDetail(BaseModel): class KetteDetail(BaseModel):
@ -117,6 +120,8 @@ class KetteDetail(BaseModel):
glieder: list[KettenGliedOut] = [] glieder: list[KettenGliedOut] = []
antragsteller: list[ParteiOut] = [] antragsteller: list[ParteiOut] = []
graph: dict | None = None graph: dict | None = None
strang: str | None = None
ampel: dict | None = None
class PaginatedVorlagen(BaseModel): class PaginatedVorlagen(BaseModel):

View File

@ -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()

View File

@ -11,6 +11,7 @@ from tracker.api.models import (
ParteiOut, ParteiOut,
VorlageKurz, VorlageKurz,
) )
from tracker.core.ampel import get_ampel, get_ampel_kompakt
from tracker.core.graph import get_kette_graph from tracker.core.graph import get_kette_graph
from tracker.db.session import get_connection from tracker.db.session import get_connection
@ -88,6 +89,7 @@ def list_ketten(
rows = conn.execute( rows = conn.execute(
f"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit, f"""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.ursprung_id,
k.strang,
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang, v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
v.ist_verwaltungsvorlage, v.ist_verwaltungsvorlage,
(SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count (SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count
@ -117,6 +119,8 @@ def list_ketten(
letzte_aktivitaet=r["letzte_aktivitaet"], letzte_aktivitaet=r["letzte_aktivitaet"],
vertagungen_count=r["vertagungen_count"], vertagungen_count=r["vertagungen_count"],
glieder_count=r["glieder_count"], glieder_count=r["glieder_count"],
strang=r["strang"],
ampel=get_ampel_kompakt(r["strang"] or "", r["status"] or ""),
) )
for r in rows for r in rows
] ]
@ -130,6 +134,7 @@ def get_kette(kette_id: int, conn=Depends(_db)):
row = conn.execute( row = conn.execute(
"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit, """SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id, 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.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
v.ist_verwaltungsvorlage v.ist_verwaltungsvorlage
FROM ketten k FROM ketten k
@ -183,6 +188,9 @@ def get_kette(kette_id: int, conn=Depends(_db)):
# Graph/Perlenschnur data # Graph/Perlenschnur data
graph = get_kette_graph(conn, kette_id) graph = get_kette_graph(conn, kette_id)
strang = row["strang"]
ampel_data = get_ampel(strang or "", row["status"] or "")
return KetteDetail( return KetteDetail(
id=row["id"], id=row["id"],
ursprung=VorlageKurz( ursprung=VorlageKurz(
@ -203,4 +211,6 @@ def get_kette(kette_id: int, conn=Depends(_db)):
glieder=glieder, glieder=glieder,
antragsteller=antragsteller, antragsteller=antragsteller,
graph=graph, graph=graph,
strang=strang,
ampel=ampel_data,
) )

View File

@ -198,6 +198,13 @@ def get_dashboard_stats(
abgelehnt = _k_count("abgelehnt") abgelehnt = _k_count("abgelehnt")
total_bewertet = umgesetzt + teilweise + versandet + beschlossen + 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 { return {
"vorlagen_total": vorlagen_total, "vorlagen_total": vorlagen_total,
"ketten_total": ketten_total, "ketten_total": ketten_total,
@ -212,6 +219,7 @@ def get_dashboard_stats(
"abgelehnt": abgelehnt, "abgelehnt": abgelehnt,
"total_bewertet": total_bewertet, "total_bewertet": total_bewertet,
}, },
"nach_strang": [{"strang": r["strang"], "anzahl": r["c"]} for r in strang_rows],
} }

View File

@ -281,12 +281,22 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
# Referenzen # Referenzen
refs = get_references_for_vorlage(conn, vorlage_id) 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( kette_row = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1", "SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
(vorlage_id,), (vorlage_id,),
).fetchone() ).fetchone()
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 # KI-Zusammenfassung
ki_row = conn.execute( ki_row = conn.execute(
"SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1", "SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1",
@ -340,4 +350,5 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
kette_id=kette_row["kette_id"] if kette_row else None, kette_id=kette_row["kette_id"] if kette_row else None,
ki_zusammenfassung=ki_zusammenfassung, ki_zusammenfassung=ki_zusammenfassung,
umsetzungsbewertungen=umsetzungsbewertungen, umsetzungsbewertungen=umsetzungsbewertungen,
ampel=kette_ampel,
) )

View File

@ -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"},
"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",
"abgewiegelt": "versandet", # Abgewiegelt → same visual as versandet
}
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,
}

View File

@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles 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( app = FastAPI(
title="Antragstracker Hagen", title="Antragstracker Hagen",
@ -31,6 +31,7 @@ app.include_router(abstimmungen.router, prefix="/api")
app.include_router(orte.router, prefix="/api") app.include_router(orte.router, prefix="/api")
app.include_router(fraktionen.router, prefix="/api") app.include_router(fraktionen.router, prefix="/api")
app.include_router(bewertung.router, prefix="/api") app.include_router(bewertung.router, prefix="/api")
app.include_router(ampel.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

64
scripts/migrate_strang.py Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Migration: Add 'strang' column to ketten and populate based on Ursprungs-Vorlage typ."""
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "data" / "tracker.db"
VORLAGE_TYP_TO_STRANG = {
"antrag": "antrag",
"anfrage": "anfrage",
"beschlussvorlage": "beschlussvorlage",
"mitteilungsvorlage": "mitteilung",
}
def migrate(db_path: Path = DB_PATH):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Check if column already exists
cols = [r[1] for r in conn.execute("PRAGMA table_info(ketten)").fetchall()]
if "strang" not in cols:
print("Adding 'strang' column to ketten...")
conn.execute("ALTER TABLE ketten ADD COLUMN strang TEXT")
conn.commit()
print("Column added.")
else:
print("Column 'strang' already exists.")
# Populate strang based on Ursprungs-Vorlage typ
rows = conn.execute("""
SELECT k.id, v.typ as vorlage_typ
FROM ketten k
LEFT JOIN vorlagen v ON k.ursprung_id = v.id
WHERE k.strang IS NULL
""").fetchall()
if not rows:
print("All ketten already have strang set.")
conn.close()
return
print(f"Updating {len(rows)} ketten...")
counts = {}
for r in rows:
vorlage_typ = r["vorlage_typ"] or ""
strang = VORLAGE_TYP_TO_STRANG.get(vorlage_typ, "sonstig")
conn.execute("UPDATE ketten SET strang = ? WHERE id = ?", (strang, r["id"]))
counts[strang] = counts.get(strang, 0) + 1
conn.commit()
conn.close()
print("Done. Counts per strang:")
for strang, count in sorted(counts.items()):
print(f" {strang}: {count}")
if __name__ == "__main__":
db = Path(sys.argv[1]) if len(sys.argv) > 1 else DB_PATH
migrate(db)