feat: Strang-basierte Klassifikation + Explorer + Ampel (#16)

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
This commit is contained in:
Dotty Dotter 2026-04-02 00:36:30 +02:00
parent 6db12e297d
commit f8bc893a54
17 changed files with 1573 additions and 47 deletions

6
.github/WORKFLOW.md vendored
View File

@ -11,3 +11,9 @@
## Hotfixes direkt auf main ## Hotfixes direkt auf main
Nur für: Typo-Fixes, Config-Änderungen, Doku-Updates 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

View File

@ -84,6 +84,8 @@ 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
ki_versionen: list[dict] | None = None
class KettenGliedOut(BaseModel): class KettenGliedOut(BaseModel):
@ -102,6 +104,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):
@ -114,9 +118,13 @@ class KetteDetail(BaseModel):
letzte_aktivitaet: date | None = None letzte_aktivitaet: date | None = None
vertagungen_count: int = 0 vertagungen_count: int = 0
begruendung: str | None = None begruendung: str | None = None
glieder: list[KettenGliedOut] = [] glieder: list[dict] = []
antragsteller: list[ParteiOut] = [] antragsteller: list[ParteiOut] = []
graph: dict | None = None 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): 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

@ -162,18 +162,42 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
_jobs[job_id] = {"status": "error", "error": str(result)} _jobs[job_id] = {"status": "error", "error": str(result)}
return return
# Delete old, insert new # Get previous version for logging
conn.execute("DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung'", (vorlage_id,)) 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( conn.execute(
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version) """INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval')""", VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False)), (vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False),
datetime.now().isoformat()),
) )
if result.get("kernforderung"): if result.get("kernforderung"):
conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id)) 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() 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() conn.close()
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} _jobs[job_id] = {"status": "done", "result": result}
except Exception as e: except Exception as e:
_jobs[job_id] = {"status": "error", "error": str(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)} _jobs[job_id] = {"status": "error", "error": str(result)}
return return
# Delete old umsetzung_match, insert new # Keep old versions, insert new (linked to kette, not vorlage)
conn.execute( conn.execute(
"DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match'", """INSERT INTO ki_bewertungen (vorlage_id, kette_id, typ, score, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
(kette["ursprung_id"],), VALUES (?, ?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
)
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"], kette["ursprung_id"],
kette_id,
result.get("score"), result.get("score"),
result.get("begruendung"), result.get("begruendung"),
json.dumps(result, ensure_ascii=False), json.dumps(result, ensure_ascii=False),
datetime.now().isoformat(),
), ),
) )
# Rebuild chain status # Get previous scores for logging
from tracker.core.chains import build_single_chain prev_ki = conn.execute(
build_single_chain(conn, kette["ursprung_id"]) "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.commit()
conn.close() conn.close()

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
@ -59,8 +60,11 @@ def list_ketten(
params.append(partei) params.append(partei)
if suche: if suche:
where_clauses.append("k.thema LIKE ?") where_clauses.append(
params.append(f"%{suche}%") "(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) # Global filter: Ratsperiode (filter on letzte_aktivitaet)
per_clause, per_params = periode_date_filter(periode, "k.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 "" where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
total = conn.execute( 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"] ).fetchone()["cnt"]
offset = (page - 1) * page_size offset = (page - 1) * page_size
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 +122,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 +137,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
@ -153,8 +161,45 @@ def get_kette(kette_id: int, conn=Depends(_db)):
(kette_id,), (kette_id,),
).fetchall() ).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 = [ glieder = [
KettenGliedOut( {
**KettenGliedOut(
vorlage=VorlageKurz( vorlage=VorlageKurz(
id=g["id"], id=g["id"],
aktenzeichen=g["aktenzeichen"], aktenzeichen=g["aktenzeichen"],
@ -165,7 +210,10 @@ def get_kette(kette_id: int, conn=Depends(_db)):
), ),
position=g["position"], position=g["position"],
rolle=g["rolle"], rolle=g["rolle"],
) ).model_dump(),
"antragsteller": glied_ast.get(g["id"], []),
"beratungen": glied_beratungen.get(g["id"], []),
}
for g in glieder_rows for g in glieder_rows
] ]
@ -183,6 +231,41 @@ 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)
# 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( return KetteDetail(
id=row["id"], id=row["id"],
ursprung=VorlageKurz( ursprung=VorlageKurz(
@ -203,4 +286,8 @@ 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,
umsetzung=umsetzung,
umsetzung_versionen=umsetzung_versionen if umsetzung_versionen else None,
) )

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,22 +281,44 @@ 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()
# KI-Zusammenfassung kette_ampel = None
ki_row = conn.execute( if kette_row:
"SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1", kette_info = conn.execute(
(vorlage_id,), "SELECT strang, status FROM ketten WHERE id = ?",
(kette_row["kette_id"],),
).fetchone() ).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,),
).fetchall()
ki_zusammenfassung = None ki_zusammenfassung = None
if ki_row and ki_row["anmerkungen"]: ki_versionen = []
for i, ki_row in enumerate(ki_rows):
if ki_row["anmerkungen"]:
try: try:
ki_data = json.loads(ki_row["anmerkungen"]) ki_data = json.loads(ki_row["anmerkungen"])
if i == 0:
ki_zusammenfassung = KiZusammenfassung(**ki_data) 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): except (json.JSONDecodeError, TypeError):
pass pass
@ -340,4 +362,6 @@ 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,
ki_versionen=ki_versionen if ki_versionen else None,
) )

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"},
"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,
}

View File

@ -17,10 +17,12 @@ FRAKTION_MAPPING: dict[str, str] = {
"Linke": "Linke", "Linke": "Linke",
"Linke/HAK": "Linke/HAK", "Linke/HAK": "Linke/HAK",
# Hagen Aktiv # Hagen Aktiv (Freie Wählergemeinschaft)
"HAGEN AKTIV": "Hagen Aktiv", "HAGEN AKTIV": "Hagen Aktiv",
"Hagen Aktiv": "Hagen Aktiv", "Hagen Aktiv": "Hagen Aktiv",
"HAK": "Hagen Aktiv",
# HAK (Hagener Aktivistenkreis) — NICHT Hagen Aktiv!
"HAK": "HAK",
"HAK/Die Linke": "HAK/Linke", "HAK/Die Linke": "HAK/Linke",
# BfHo / Die PARTEI # BfHo / Die PARTEI
@ -59,7 +61,7 @@ FRAKTION_MAPPING: dict[str, str] = {
# Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant) # Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant)
RATSFRAKTIONEN = { RATSFRAKTIONEN = {
"SPD", "CDU", "Grüne", "FDP", "AfD", "Linke", "Hagen Aktiv", "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",
} }

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")

View File

@ -68,6 +68,34 @@ export interface VorlageDetail extends VorlageKurz {
kette_id: number | null; 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 { export interface KetteKurz {
id: number; id: number;
ursprung: VorlageKurz | null; ursprung: VorlageKurz | null;
@ -78,6 +106,8 @@ export interface KetteKurz {
letzte_aktivitaet: string | null; letzte_aktivitaet: string | null;
vertagungen_count: number; vertagungen_count: number;
glieder_count: number; glieder_count: number;
strang: string | null;
ampel: AmpelKompakt | null;
} }
export interface KettenGliedOut { export interface KettenGliedOut {
@ -102,6 +132,8 @@ export interface KetteDetail {
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; edges: GraphEdge[];
} | null; } | null;
strang: string | null;
ampel: AmpelData | null;
} }
export interface GraphNode { export interface GraphNode {
@ -216,6 +248,17 @@ export const fetchSuchvorschlaege = (q: string) =>
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`); 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 const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
export interface AmpelDefinition {
straenge: Record<string, {
label: string;
kontrollfrage: string | null;
schritte: { id: string; label: string; endfarbe: string | null }[];
}>;
abzweigungen: Record<string, { label: string; farbe: string }>;
}
export const fetchAmpelDefinition = () => get<AmpelDefinition>('/ampel/definition');
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => { export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (jahr) p.set('jahr', jahr); if (jahr) p.set('jahr', jahr);

View File

@ -0,0 +1,177 @@
<script lang="ts">
/**
* Ampel-Visualisierung: Zeigt den Fortschritt einer Kette als Schritt-Indikator.
* Horizontal (default) oder vertikal, compact oder normal.
*/
interface AmpelSchritt {
id: string;
label: string;
aktiv: boolean;
erreicht: boolean;
farbe: string;
}
interface AmpelAbzweigung {
id: string;
label: string;
farbe: string;
}
interface AmpelData {
strang: string;
strang_label: string;
kontrollfrage: string | null;
schritte: AmpelSchritt[];
abzweigung: AmpelAbzweigung | null;
}
interface Props {
ampel: AmpelData | null;
compact?: boolean;
vertical?: boolean;
}
let { ampel, compact = false, vertical = false }: Props = $props();
const FARB_MAP: Record<string, string> = {
gruen: '#22c55e',
gelb: '#eab308',
rot: '#ef4444',
amber: '#f59e0b',
grau: '#d1d5db',
blau: '#3b82f6',
};
function farbeHex(farbe: string): string {
return FARB_MAP[farbe] || FARB_MAP.grau;
}
// Find the step where the branch-off originates (last reached step)
function abzweigungIndex(): number {
if (!ampel?.schritte) return -1;
let lastReached = -1;
for (let i = 0; i < ampel.schritte.length; i++) {
if (ampel.schritte[i].erreicht) lastReached = i;
}
return lastReached;
}
</script>
{#if ampel}
{#if compact}
<!-- Compact: Just colored dots inline -->
<div class="flex items-center gap-0.5" title="{ampel.strang_label}">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="w-1 h-0.5 rounded-full" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div
class="rounded-full shrink-0"
style="width: {schritt.aktiv ? '10px' : '8px'}; height: {schritt.aktiv ? '10px' : '8px'}; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)};`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 1.5px solid #d1d5db; background: white;`}"
title="{schritt.label}{schritt.aktiv ? ' (aktuell)' : ''}"
></div>
{/each}
{#if ampel.abzweigung}
<div class="w-1 h-0.5 rounded-full" style="background-color: #d1d5db; border-top: 1px dashed #9ca3af;"></div>
<div
class="rounded-full shrink-0"
style="width: 10px; height: 10px; background-color: {farbeHex(ampel.abzweigung.farbe)};"
title="{ampel.abzweigung.label}"
></div>
{/if}
</div>
{:else if vertical}
<!-- Vertical layout for Panel 2 -->
<div class="flex flex-col items-center">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="w-0.5 h-4" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div class="flex items-center gap-3">
<div
class="rounded-full shrink-0 transition-all"
style="width: 20px; height: 20px; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 2px solid #d1d5db; background: white;`}"
></div>
<span class="text-xs {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-500'} whitespace-nowrap">
{schritt.label}
</span>
</div>
<!-- Abzweigung branch off this step -->
{#if ampel.abzweigung && i === abzweigungIndex()}
<div class="flex items-start ml-2.5">
<div class="flex flex-col items-center">
<div class="w-0.5 h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
<div
class="rounded-full shrink-0"
style="width: 20px; height: 20px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
></div>
</div>
<span class="text-xs font-semibold ml-2 mt-3" style="color: {farbeHex(ampel.abzweigung.farbe)}">
{ampel.abzweigung.label}
</span>
</div>
{/if}
{/each}
{#if ampel.kontrollfrage}
<p class="text-xs italic text-gray-400 mt-3 text-center">{ampel.kontrollfrage}</p>
{/if}
</div>
{:else}
<!-- Horizontal (default) -->
<div class="flex flex-col items-start">
<div class="flex items-center">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="h-0.5 w-4 sm:w-6" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div class="flex flex-col items-center">
<div
class="rounded-full shrink-0 transition-all"
style="width: 24px; height: 24px; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 2px solid #d1d5db; background: white;`}"
></div>
<span class="text-[10px] mt-1 {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-400'} text-center max-w-[60px] leading-tight">
{schritt.label}
</span>
</div>
{/each}
</div>
<!-- Abzweigung -->
{#if ampel.abzweigung}
{@const idx = abzweigungIndex()}
{#if idx >= 0}
<!-- Position branch under the correct step -->
<div class="flex items-start" style="margin-left: {idx * (24 + 24)}px;">
<div class="flex flex-col items-center ml-3">
<div class="h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
<div
class="rounded-full shrink-0"
style="width: 24px; height: 24px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
></div>
<span class="text-[10px] mt-1 font-semibold text-center max-w-[70px] leading-tight" style="color: {farbeHex(ampel.abzweigung.farbe)}">
{ampel.abzweigung.label}
</span>
</div>
</div>
{/if}
{/if}
{#if ampel.kontrollfrage}
<p class="text-xs italic text-gray-400 mt-2">{ampel.kontrollfrage}</p>
{/if}
</div>
{/if}
{/if}

View File

@ -37,6 +37,7 @@
</a> </a>
<div class="hidden sm:flex sm:ml-8 space-x-4"> <div class="hidden sm:flex sm:ml-8 space-x-4">
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a> <a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a>
<a href="/explorer" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Explorer</a>
<a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a> <a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a>
<a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a> <a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a>
<a href="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a> <a href="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a>
@ -71,6 +72,7 @@
<div class="sm:hidden border-t border-gray-200 bg-white"> <div class="sm:hidden border-t border-gray-200 bg-white">
<div class="px-2 pt-2 pb-3 space-y-1"> <div class="px-2 pt-2 pb-3 space-y-1">
<a href="/" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Dashboard</a> <a href="/" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Dashboard</a>
<a href="/explorer" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Explorer</a>
<a href="/ketten" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Ketten</a> <a href="/ketten" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Ketten</a>
<a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a> <a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a>
<a href="/abstimmungen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Abstimmungen</a> <a href="/abstimmungen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Abstimmungen</a>

View File

@ -2,6 +2,8 @@
import '../app.css'; import '../app.css';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte'; import { filters, filterVersion } from '$lib/filters.svelte';
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
import Ampel from '$lib/components/Ampel.svelte';
interface Vorlage { interface Vorlage {
id: number; id: number;
@ -30,6 +32,7 @@
let stats = $state<DashboardStats | null>(null); let stats = $state<DashboardStats | null>(null);
let antraege = $state<Vorlage[]>([]); let antraege = $state<Vorlage[]>([]);
let ampelDef = $state<AmpelDefinition | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
@ -103,6 +106,11 @@
const dashSuffix = fqs ? `?${fqs}` : ''; const dashSuffix = fqs ? `?${fqs}` : '';
const vorlagenSuffix = fqs ? `&${fqs}` : ''; const vorlagenSuffix = fqs ? `&${fqs}` : '';
// Load ampel definition once
if (!ampelDef) {
try { ampelDef = await fetchAmpelDefinition(); } catch {}
}
const [dashRes, antraegeRes] = await Promise.all([ const [dashRes, antraegeRes] = await Promise.all([
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`), fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`), fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
@ -310,4 +318,53 @@
</ul> </ul>
{/if} {/if}
</section> </section>
<!-- Ampel-Legende -->
{#if ampelDef}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">🚦 Ampel-Legende</h2>
<p class="text-sm text-gray-500 mb-4">So liest sich die Fortschrittsanzeige im <a href="/explorer" class="text-green-600 hover:underline">Explorer</a>.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each Object.entries(ampelDef.straenge) as [key, strang]}
<div class="border border-gray-100 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-800 mb-2">{strang.label}</h3>
{#if strang.kontrollfrage}
<p class="text-xs italic text-gray-500 mb-3">{strang.kontrollfrage}</p>
{/if}
<!-- Example ampel: show all steps as reached, last one active -->
<Ampel ampel={{
strang: key,
strang_label: strang.label,
kontrollfrage: null,
schritte: strang.schritte.map((s, i) => ({
id: s.id,
label: s.label,
aktiv: i === strang.schritte.length - 1,
erreicht: true,
farbe: i === strang.schritte.length - 1 ? (s.endfarbe || 'blau') : 'grau',
})),
abzweigung: null,
}} />
</div>
{/each}
</div>
<!-- Abzweigungen -->
{#if Object.keys(ampelDef.abzweigungen).length > 0}
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-800 mb-2">Abzweigungen</h3>
<div class="flex flex-wrap gap-3">
{#each Object.entries(ampelDef.abzweigungen) as [key, abzw]}
{@const farbMap = { rot: '#ef4444', amber: '#f59e0b', gelb: '#eab308', grau: '#d1d5db' }}
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style="background-color: {farbMap[abzw.farbe] || '#d1d5db'}"></span>
<span class="text-xs text-gray-600">{abzw.label}</span>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/if} {/if}

View File

@ -0,0 +1,703 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
import { formatDate, typLabel } from '$lib/status';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
import Ampel from '$lib/components/Ampel.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
// State
let ketten: KetteKurz[] = $state([]);
let kettenTotal = $state(0);
let selectedKette: KetteDetail | null = $state(null);
let selectedVorlage: VorlageDetail | null = $state(null);
let loading = $state(true);
let ketteLoading = $state(false);
let vorlageLoading = $state(false);
let error = $state('');
// Filters
let suche = $state('');
let strangFilter = $state('');
let statusFilter = $state('');
let currentPage = $state(1);
const PAGE_SIZE = 30;
// Active IDs
let activeKetteId = $state<number | null>(null);
let activeVorlageId = $state<number | null>(null);
// Mobile tab
let mobileTab = $state<'liste' | 'kette' | 'detail'>('liste');
let showVolltext = $state(false);
let showVersionen = $state(false);
let showUmsetzungVersionen = $state(false);
let showReeval = $state(false);
let reevalAnmerkung = $state('');
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
let reevalError = $state('');
async function triggerReeval() {
if (!selectedVorlage) return;
reevalStatus = 'running';
reevalError = '';
try {
const { job_id } = await reevalVorlage(selectedVorlage.id, reevalAnmerkung);
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const status = await fetchJobStatus(job_id);
if (status.status === 'done') {
reevalStatus = 'done';
selectedVorlage = await fetchVorlage(selectedVorlage!.id);
showReeval = false;
reevalAnmerkung = '';
return;
}
if (status.status === 'error') {
reevalStatus = 'error';
reevalError = status.error || 'Unbekannter Fehler';
return;
}
}
reevalStatus = 'error';
reevalError = 'Timeout nach 3 Minuten';
} catch (e) {
reevalStatus = 'error';
reevalError = e instanceof Error ? e.message : 'Fehler';
}
}
const STRANG_TABS = [
{ value: '', label: 'Alle' },
{ value: 'antrag', label: 'Anträge' },
{ value: 'anfrage', label: 'Anfragen' },
{ value: 'beschlussvorlage', label: 'Beschlussvorlagen' },
{ value: 'mitteilung', label: 'Mitteilungen' },
];
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'in_beratung', label: '⏳ In Beratung' },
{ value: 'beschlossen', label: '🟡 Beschlossen' },
{ value: 'umgesetzt', label: '🟢 Umgesetzt' },
{ value: 'teilweise_umgesetzt', label: '🟡 Teilweise' },
{ value: 'versandet', label: '🔴 Versandet' },
{ value: 'abgelehnt', label: '🔴 Abgelehnt' },
{ value: 'beantwortet', label: '🟢 Beantwortet' },
{ value: 'angefragt', label: '⏳ Angefragt' },
];
const FARB_MAP: Record<string, string> = {
gruen: '#22c55e',
gelb: '#eab308',
rot: '#ef4444',
amber: '#f59e0b',
grau: '#d1d5db',
blau: '#3b82f6',
};
async function loadKetten() {
loading = true;
try {
let params: Record<string, string> = {
page: String(currentPage),
page_size: String(PAGE_SIZE),
};
if (suche) params.suche = suche;
if (strangFilter) params.typ = strangFilter;
if (statusFilter) params.status = statusFilter;
params = mergeFilterParams(params);
const data = await fetchKetten(params);
ketten = data.items;
kettenTotal = data.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
loading = false;
}
}
async function selectKette(id: number) {
if (activeKetteId === id) return;
activeKetteId = id;
activeVorlageId = null;
selectedVorlage = null;
ketteLoading = true;
mobileTab = 'kette';
try {
selectedKette = await fetchKette(id);
// Auto-select the first glied (most recent = last position)
if (selectedKette.glieder.length > 0) {
const sorted = [...selectedKette.glieder].sort((a, b) => b.position - a.position);
selectVorlage(sorted[0].vorlage.id);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
ketteLoading = false;
}
}
async function selectVorlage(id: number) {
if (activeVorlageId === id) return;
activeVorlageId = id;
vorlageLoading = true;
showVolltext = false;
mobileTab = 'detail';
try {
selectedVorlage = await fetchVorlage(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
vorlageLoading = false;
}
}
function handleSearch(e: KeyboardEvent) {
if (e.key === 'Enter') {
currentPage = 1;
loadKetten();
}
}
function changeStrang(value: string) {
strangFilter = value;
currentPage = 1;
loadKetten();
}
function goPage(p: number) {
currentPage = p;
loadKetten();
}
onMount(() => {
loadKetten();
});
// Reload on global filter change
$effect(() => {
filterVersion();
currentPage = 1;
loadKetten();
});
// Sorted glieder for timeline (newest first)
let sortedGlieder = $derived(
selectedKette ? [...selectedKette.glieder].sort((a, b) => b.position - a.position) : []
);
let totalPages = $derived(Math.ceil(kettenTotal / PAGE_SIZE));
</script>
<svelte:head>
<title>Explorer - Antragstracker Hagen</title>
</svelte:head>
<!-- Mobile Tabs -->
<div class="lg:hidden flex border-b border-gray-200 mb-4 bg-white rounded-t-lg">
<button
onclick={() => mobileTab = 'liste'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'liste' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}">
Liste
</button>
<button
onclick={() => mobileTab = 'kette'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'kette' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
disabled={!selectedKette}>
Kette
</button>
<button
onclick={() => mobileTab = 'detail'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'detail' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
disabled={!selectedVorlage}>
Detail
</button>
</div>
<!-- 3-Panel Layout -->
<div class="flex gap-0 lg:gap-0 h-[calc(100vh-12rem)] lg:h-[calc(100vh-11rem)]">
<!-- Panel 1: Ketten-Liste -->
<div class="w-full lg:w-[280px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white rounded-l-lg lg:rounded-l-xl overflow-hidden
{mobileTab !== 'liste' ? 'hidden lg:flex' : 'flex'}">
<!-- Search & Filters -->
<div class="p-3 border-b border-gray-100 space-y-2">
<input
type="text"
bind:value={suche}
placeholder="Suche..."
onkeydown={handleSearch}
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<div class="flex flex-wrap gap-1">
{#each STRANG_TABS as tab}
<button
onclick={() => changeStrang(tab.value)}
class="px-2 py-1 rounded text-xs font-medium transition-all
{strangFilter === tab.value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{tab.label}
</button>
{/each}
</div>
<select
bind:value={statusFilter}
onchange={() => { currentPage = 1; loadKetten(); }}
class="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs focus:ring-2 focus:ring-green-500 bg-white">
{#each STATUS_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Count -->
<div class="px-3 py-1.5 text-xs text-gray-400 border-b border-gray-50">
{kettenTotal} Ketten
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto">
{#if loading && ketten.length === 0}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if ketten.length === 0}
<div class="p-4 text-sm text-gray-500 text-center">Keine Ketten gefunden</div>
{:else}
{#each ketten as kette}
<button
onclick={() => selectKette(kette.id)}
class="w-full text-left px-3 py-2.5 border-b border-gray-50 hover:bg-gray-50 transition-colors
{activeKetteId === kette.id ? 'bg-green-50 border-l-2 border-l-green-600' : 'border-l-2 border-l-transparent'}">
<div class="flex items-center justify-between gap-2 mb-0.5">
<span class="font-mono text-xs font-medium text-green-700 truncate">
{kette.ursprung?.aktenzeichen || `#${kette.id}`}
</span>
{#if kette.ampel}
<span class="flex items-center gap-1 shrink-0">
<span
class="w-3 h-3 rounded-full"
style="background-color: {FARB_MAP[kette.ampel.farbe] || FARB_MAP.grau}"
></span>
<span class="text-[10px] text-gray-500">{kette.ampel.label}</span>
</span>
{/if}
</div>
<p class="text-xs text-gray-600 line-clamp-2 leading-snug">
{kette.thema || kette.ursprung?.betreff || '-'}
</p>
</button>
{/each}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-2 p-3 border-t border-gray-100">
<button
disabled={currentPage <= 1}
onclick={() => goPage(currentPage - 1)}
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button>
<span class="text-xs text-gray-500">{currentPage}/{totalPages}</span>
<button
disabled={currentPage >= totalPages}
onclick={() => goPage(currentPage + 1)}
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button>
</div>
{/if}
{/if}
</div>
</div>
<!-- Panel 2: Kette Detail -->
<div class="w-full lg:w-[220px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white overflow-hidden
{mobileTab !== 'kette' ? 'hidden lg:flex' : 'flex'}">
{#if ketteLoading}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if selectedKette}
<div class="flex-1 overflow-y-auto">
<!-- Ampel -->
<div class="p-4 border-b border-gray-100">
<div class="text-xs font-medium text-gray-500 uppercase mb-3">
{selectedKette.ampel?.strang_label || selectedKette.typ || 'Status'}
</div>
{#if selectedKette.ampel}
<Ampel ampel={selectedKette.ampel} vertical />
{:else}
<StatusBadge status={selectedKette.status} />
{/if}
</div>
<!-- Umsetzungsgrad -->
{#if selectedKette.umsetzung}
{@const u = selectedKette.umsetzung}
<div class="px-4 pb-3 border-b border-gray-100">
<div class="flex items-center gap-2 mb-1.5">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
{u.score >= 0.7 ? 'bg-green-200 text-green-800' : u.score >= 0.4 ? 'bg-amber-200 text-amber-800' : 'bg-red-200 text-red-800'}">
{Math.round((u.score || 0) * 100)}%
</div>
<div>
<div class="text-xs font-semibold {u.score >= 0.7 ? 'text-green-800' : u.score >= 0.4 ? 'text-amber-800' : 'text-red-800'}">
{u.bewertung || (u.score >= 0.7 ? 'Umgesetzt' : u.score >= 0.4 ? 'Teilweise' : 'Kaum umgesetzt')}
</div>
{#if u.kernpunkt_erfuellt !== null && u.kernpunkt_erfuellt !== undefined}
<div class="text-[10px] text-gray-500">
Kernpunkt: {u.kernpunkt_erfuellt ? '✅ erfüllt' : '❌ nicht erfüllt'}
</div>
{/if}
</div>
</div>
{#if u.begruendung}
<p class="text-[11px] text-gray-600 leading-snug">{u.begruendung}</p>
{/if}
{#if u.details}
<p class="text-[10px] text-gray-500 mt-1 leading-snug">{u.details}</p>
{/if}
{#if selectedKette.umsetzung_versionen?.length}
<button onclick={() => showUmsetzungVersionen = !showUmsetzungVersionen}
class="text-[10px] text-gray-400 hover:text-gray-600 mt-2 flex items-center gap-1">
<span>{showUmsetzungVersionen ? '▼' : '▶'}</span>
{selectedKette.umsetzung_versionen.length} vorherige Bewertung{selectedKette.umsetzung_versionen.length > 1 ? 'en' : ''}
</button>
{#if showUmsetzungVersionen}
<div class="mt-2 space-y-2">
{#each selectedKette.umsetzung_versionen as v}
<div class="rounded border border-gray-200 bg-gray-50 p-2">
<div class="flex items-center gap-2 mb-1">
<span class="text-[10px] font-bold {v.score >= 0.7 ? 'text-green-700' : v.score >= 0.4 ? 'text-amber-700' : 'text-red-700'}">
{Math.round((v.score || 0) * 100)}%
</span>
<span class="text-[10px] text-gray-400">{v.erstellt_at || ''}</span>
</div>
<p class="text-[10px] text-gray-500">{v.begruendung}</p>
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
<!-- Timeline -->
<div class="p-3">
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>
<div class="relative">
<!-- Vertical line -->
<div class="absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200"></div>
{#each sortedGlieder as glied, i}
<button
onclick={() => selectVorlage(glied.vorlage.id)}
class="relative w-full text-left pl-8 pr-2 py-2 rounded-lg hover:bg-gray-50 transition-colors mb-1
{activeVorlageId === glied.vorlage.id ? 'bg-green-50' : ''}">
<!-- Dot -->
<div class="absolute left-1.5 top-3.5 w-3 h-3 rounded-full border-2 transition-colors
{activeVorlageId === glied.vorlage.id
? 'bg-green-600 border-green-600'
: 'bg-white border-gray-300'}">
</div>
<!-- Content -->
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-medium text-green-700 truncate">
{glied.vorlage.aktenzeichen || `#${glied.vorlage.id}`}
</span>
</div>
<div class="flex flex-wrap items-center gap-1 mb-0.5">
{#if glied.rolle}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
{glied.rolle}
</span>
{/if}
{#each glied.antragsteller || [] as a}
<span class="text-[10px] px-1 py-0.5 rounded font-medium"
style="background-color: {a.farbe || '#6b7280'}20; color: {a.farbe || '#6b7280'}; border: 1px solid {a.farbe || '#6b7280'}40;">
{a.kuerzel}
</span>
{/each}
</div>
{#each glied.beratungen || [] as b}
{#if b.beschlusstext || b.ergebnis}
<div class="text-[10px] text-gray-500 leading-tight">
<span class="font-medium">{b.gremium || '?'}</span>: {b.beschlusstext || b.ergebnis || ''}
</div>
{/if}
{/each}
{#if glied.vorlage.datum_eingang}
<div class="text-[10px] text-gray-400 mt-0.5">
{formatDate(glied.vorlage.datum_eingang)}
</div>
{/if}
</button>
{/each}
</div>
</div>
</div>
{:else}
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
← Kette auswählen
</div>
{/if}
</div>
<!-- Panel 3: Vorlage Detail -->
<div class="w-full lg:flex-1 lg:min-w-0 flex flex-col bg-white rounded-r-lg lg:rounded-r-xl overflow-hidden
{mobileTab !== 'detail' ? 'hidden lg:flex' : 'flex'}">
{#if vorlageLoading}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if selectedVorlage}
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-5">
<!-- Header -->
<div>
<div class="flex flex-wrap items-center gap-2 mb-2">
{#if selectedVorlage.aktenzeichen}
<h2 class="text-xl font-bold text-gray-900 font-mono">{selectedVorlage.aktenzeichen}</h2>
{/if}
{#if selectedVorlage.typ}
<span class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">{typLabel(selectedVorlage.typ)}</span>
{/if}
{#if selectedVorlage.ist_verwaltungsvorlage}
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700">Verwaltungsvorlage</span>
{/if}
</div>
{#if selectedVorlage.betreff}
<p class="text-gray-700">{selectedVorlage.betreff}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 mt-2 text-sm text-gray-500">
{#if selectedVorlage.datum_eingang}
<span>Eingegangen: <strong>{formatDate(selectedVorlage.datum_eingang)}</strong></span>
{/if}
{#if selectedVorlage.web_url}
<a href={selectedVorlage.web_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">ALLRIS ↗</a>
{/if}
{#if selectedVorlage.pdf_url}
<a href={selectedVorlage.pdf_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">PDF ↗</a>
{/if}
<a href="/vorlagen/{selectedVorlage.id}" class="text-green-600 hover:underline">Vollansicht →</a>
</div>
<!-- Antragsteller -->
{#if selectedVorlage.antragsteller?.length > 0}
<div class="mt-2 flex items-center gap-2">
<span class="text-xs text-gray-500">Antragsteller:</span>
{#each selectedVorlage.antragsteller as p}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style="background-color: {p.farbe || '#e5e7eb'}20; color: {p.farbe || '#4b5563'}; border: 1px solid {p.farbe || '#d1d5db'}">
{p.kuerzel}
</span>
{/each}
</div>
{/if}
</div>
<!-- KI-Zusammenfassung -->
{#if selectedVorlage.ki_zusammenfassung}
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200 p-5">
<h3 class="text-sm font-semibold text-green-800 mb-2 flex items-center gap-1.5">
<span>🤖</span> KI-Zusammenfassung
</h3>
<p class="text-sm text-gray-700 mb-3">{selectedVorlage.ki_zusammenfassung.zusammenfassung}</p>
{#if selectedVorlage.ki_zusammenfassung.kernforderung}
<div class="mb-2">
<span class="text-xs font-medium text-green-700 uppercase">Kernforderung:</span>
<p class="text-sm text-gray-800 font-medium">{selectedVorlage.ki_zusammenfassung.kernforderung}</p>
</div>
{/if}
{#if selectedVorlage.ki_zusammenfassung.begruendung}
<div class="mb-2">
<span class="text-xs font-medium text-green-700 uppercase">Begründung:</span>
<p class="text-xs text-gray-600">{selectedVorlage.ki_zusammenfassung.begruendung}</p>
</div>
{/if}
<div class="flex flex-wrap gap-1.5 mt-3">
{#if selectedVorlage.ki_zusammenfassung.thema}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">📂 {selectedVorlage.ki_zusammenfassung.thema}</span>
{/if}
{#if selectedVorlage.ki_zusammenfassung.partei}
<span class="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-800">🏛️ {selectedVorlage.ki_zusammenfassung.partei}</span>
{/if}
{#each selectedVorlage.ki_zusammenfassung.betroffene_orte || [] as ort}
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">📍 {ort}</span>
{/each}
</div>
</div>
{/if}
<!-- Vorherige KI-Versionen -->
{#if selectedVorlage.ki_versionen?.length}
<div>
<button onclick={() => showVersionen = !showVersionen}
class="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1">
<span>{showVersionen ? '▼' : '▶'}</span>
{selectedVorlage.ki_versionen.length} vorherige Version{selectedVorlage.ki_versionen.length > 1 ? 'en' : ''}
</button>
{#if showVersionen}
<div class="mt-2 space-y-3">
{#each selectedVorlage.ki_versionen as v, i}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400">Version {selectedVorlage.ki_versionen.length - i} · {v.erstellt_at || 'unbekannt'}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-500">{v.prompt_version || ''}</span>
</div>
<p class="text-sm text-gray-600">{v.zusammenfassung}</p>
{#if v.kernforderung}
<p class="text-xs text-gray-500 mt-1"><strong>Kernforderung:</strong> {v.kernforderung}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Umsetzungsbewertung → jetzt in der Ketten-Ansicht (Panel 2) -->
<!-- Neu bewerten -->
<div class="rounded-xl border border-gray-200 p-4">
{#if !showReeval}
<button onclick={() => showReeval = true}
class="text-sm text-green-600 hover:text-green-800 font-medium flex items-center gap-1.5">
<span>🔄</span> Neu bewerten lassen
</button>
{:else}
<h3 class="text-sm font-semibold text-gray-900 mb-2">KI-Neubewertung anstoßen</h3>
<textarea bind:value={reevalAnmerkung} placeholder="Anmerkungen für die KI (optional)"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3 h-20 resize-y focus:ring-2 focus:ring-green-500"
disabled={reevalStatus === 'running'}></textarea>
<div class="flex gap-2 items-center">
<button onclick={triggerReeval} disabled={reevalStatus === 'running'}
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 transition-colors">
{#if reevalStatus === 'running'}
<span class="inline-flex items-center gap-2">
<span class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
KI bewertet…
</span>
{:else}
Bewertung starten
{/if}
</button>
{#if reevalStatus !== 'running'}
<button onclick={() => { showReeval = false; reevalStatus = 'idle'; }}
class="text-sm text-gray-500 hover:text-gray-700">Abbrechen</button>
{/if}
</div>
{#if reevalStatus === 'done'}
<p class="mt-2 text-sm text-green-700 font-medium">✅ Bewertung aktualisiert!</p>
{/if}
{#if reevalStatus === 'error'}
<p class="mt-2 text-sm text-red-600">{reevalError}</p>
{/if}
{/if}
</div>
<!-- Volltext -->
{#if selectedVorlage.volltext_clean}
<div class="rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-900">Volltext</h3>
<button onclick={() => showVolltext = !showVolltext} class="text-xs text-green-600 hover:underline">
{showVolltext ? 'Einklappen' : 'Aufklappen'}
</button>
</div>
{#if showVolltext}
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-xs">{selectedVorlage.volltext_clean}</div>
{:else}
<p class="text-xs text-gray-500 line-clamp-4">{selectedVorlage.volltext_clean}</p>
{/if}
</div>
{/if}
<!-- Beratungen -->
{#if selectedVorlage.beratungen?.length > 0}
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Beratungsfolge</h3>
<div class="space-y-2">
{#each selectedVorlage.beratungen as b}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between p-2.5 rounded-lg border border-gray-100 gap-1.5">
<div>
{#if b.gremium}
<span class="text-sm font-medium text-gray-900">{b.gremium.name}</span>
{/if}
{#if b.rolle}
<span class="text-xs ml-1.5 text-gray-500">({b.rolle})</span>
{/if}
{#if b.ergebnis}
<div class="mt-0.5">
<span class="text-xs px-2 py-0.5 rounded
{b.ergebnis.includes('angenommen') || b.ergebnis.includes('empfohlen') ? 'bg-green-100 text-green-700' :
b.ergebnis.includes('abgelehnt') ? 'bg-red-100 text-red-700' :
b.ergebnis.includes('vertagt') ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-700'}">
{b.ergebnis}
</span>
</div>
{/if}
</div>
<span class="text-xs text-gray-500 shrink-0">{formatDate(b.sitzung_datum)}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Referenzen -->
{#if selectedVorlage.referenzen_ausgehend?.length > 0 || selectedVorlage.referenzen_eingehend?.length > 0}
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Referenzen</h3>
{#if selectedVorlage.referenzen_ausgehend?.length > 0}
<div class="mb-3">
<span class="text-xs font-medium text-gray-500 uppercase">Verweist auf</span>
<div class="space-y-1 mt-1">
{#each selectedVorlage.referenzen_ausgehend as ref}
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
{#if ref.betreff}
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{#if selectedVorlage.referenzen_eingehend?.length > 0}
<div>
<span class="text-xs font-medium text-gray-500 uppercase">Referenziert von</span>
<div class="space-y-1 mt-1">
{#each selectedVorlage.referenzen_eingehend as ref}
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
{#if ref.betreff}
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
{/if}
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
← Vorlage auswählen
</div>
{/if}
</div>
</div>

View File

@ -9,6 +9,7 @@
let error: string | null = $state(null); let error: string | null = $state(null);
let showVolltext = $state(false); let showVolltext = $state(false);
let showReeval = $state(false); let showReeval = $state(false);
let showVersionen = $state(false);
let reevalAnmerkung = $state(''); let reevalAnmerkung = $state('');
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle'); let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
let reevalError = $state(''); let reevalError = $state('');
@ -201,6 +202,33 @@
</div> </div>
{/if} {/if}
<!-- Vorherige KI-Versionen -->
{#if vorlage.ki_versionen?.length}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<button onclick={() => showVersionen = !showVersionen}
class="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1.5">
<span>{showVersionen ? '▼' : '▶'}</span>
{vorlage.ki_versionen.length} vorherige KI-Version{vorlage.ki_versionen.length > 1 ? 'en' : ''}
</button>
{#if showVersionen}
<div class="mt-3 space-y-3">
{#each vorlage.ki_versionen as v, i}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400">Version {vorlage.ki_versionen.length - i} · {v.erstellt_at || 'unbekannt'}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-500">{v.prompt_version || ''}</span>
</div>
<p class="text-sm text-gray-600">{v.zusammenfassung}</p>
{#if v.kernforderung}
<p class="text-xs text-gray-500 mt-1"><strong>Kernforderung:</strong> {v.kernforderung}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Umsetzungsbewertung --> <!-- Umsetzungsbewertung -->
{#if vorlage.umsetzungsbewertungen?.length} {#if vorlage.umsetzungsbewertungen?.length}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">

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)