diff --git a/backend/src/tracker/api/routes/bewertung.py b/backend/src/tracker/api/routes/bewertung.py index 4293261..e6ce0e8 100644 --- a/backend/src/tracker/api/routes/bewertung.py +++ b/backend/src/tracker/api/routes/bewertung.py @@ -72,9 +72,17 @@ Erstelle eine strukturierte Zusammenfassung im JSON-Format: "begruendung": "Warum wird das gefordert? (kurz)", "thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)", "partei": "Antragstellende Fraktion falls erkennbar", - "orte": [] + "orte": [], + "fristen": [] }} +Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont? +Wenn ja, ergänze im JSON: +"fristen": [ + {{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}} +] +Wenn keine Fristen erkennbar: "fristen": [] + NUR JSON ausgeben, keine Erklärungen.""" @@ -104,9 +112,13 @@ Bewerte NUR als JSON: "bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar", "begruendung": "1-2 Sätze warum", "kernpunkt_erfuellt": true/false, - "details": "Was konkret beschlossen/abgelehnt wurde" + "details": "Was konkret beschlossen/abgelehnt wurde", + "fristen": [{{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}}] }} +Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont? +Wenn ja, ergänze "fristen" entsprechend. Wenn keine Fristen erkennbar: "fristen": [] + Bewertungsskala: - 1.0: Forderung vollständig erfüllt, konkreter Beschluss - 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen @@ -119,6 +131,30 @@ class BewertungRequest(BaseModel): anmerkung: str = "" +def _insert_ki_fristen(conn, fristen_list: list, kette_id: int | None, vorlage_id: int | None): + """Insert KI-extracted fristen into the fristen table.""" + if not fristen_list: + return + for f in fristen_list: + typ = f.get("typ", "sonstiges") + datum = f.get("datum") + beschreibung = f.get("beschreibung") + if not datum: + continue + # Check for duplicate (same kette, same datum, same beschreibung) + existing = conn.execute( + "SELECT id FROM fristen WHERE kette_id = ? AND datum = ? AND beschreibung = ? AND quelle = 'ki_extraktion'", + (kette_id, datum, beschreibung), + ).fetchone() + if existing: + continue + conn.execute( + """INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at) + VALUES (?, ?, ?, ?, ?, 'ki_extraktion', 'offen', ?)""", + (kette_id, vorlage_id, typ, datum, beschreibung, datetime.now().isoformat()), + ) + + def _call_qwen(prompt: str) -> dict | None: key = _get_key("QWEN_API_KEY") if not key: @@ -178,6 +214,18 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str): if result.get("kernforderung"): conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id)) + # Extract and insert KI-detected fristen + kette_row_for_fristen = conn.execute( + "SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1", + (vorlage_id,), + ).fetchone() + _insert_ki_fristen( + conn, + result.get("fristen", []), + kette_row_for_fristen["kette_id"] if kette_row_for_fristen else None, + vorlage_id, + ) + # Log conn.execute( """INSERT INTO bewertungs_log (vorlage_id, typ, anmerkung, modell, prompt_version, bewertung_vorher, bewertung_nachher, erstellt_at) @@ -320,6 +368,9 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str): (new_status, begruendung, kette_id), ) + # Extract and insert KI-detected fristen + _insert_ki_fristen(conn, result.get("fristen", []), kette_id, kette["ursprung_id"]) + # Log conn.execute( """INSERT INTO bewertungs_log diff --git a/backend/src/tracker/api/routes/fristen.py b/backend/src/tracker/api/routes/fristen.py new file mode 100644 index 0000000..2fc957c --- /dev/null +++ b/backend/src/tracker/api/routes/fristen.py @@ -0,0 +1,198 @@ +from __future__ import annotations +"""API routes for Fristen-Tracking (Issue #17).""" + +from datetime import date, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from tracker.db.session import get_connection + +router = APIRouter(prefix="/fristen", tags=["fristen"]) + + +def _db(): + conn = get_connection() + try: + yield conn + finally: + conn.close() + + +class FristCreate(BaseModel): + kette_id: int + vorlage_id: Optional[int] = None + typ: str # überarbeitung, bericht, prüfung, umsetzung, sonstiges + datum: str # YYYY-MM-DD + beschreibung: Optional[str] = None + + +class FristUpdate(BaseModel): + status: Optional[str] = None + datum: Optional[str] = None + beschreibung: Optional[str] = None + + +@router.get("") +def list_fristen( + kette_id: Optional[int] = None, + status: Optional[str] = Query(None, description="offen, überfällig, erfüllt, alle"), + typ: Optional[str] = None, + page: int = 1, + page_size: int = 50, + conn=Depends(_db), +): + """Fristen auflisten mit Joins auf ketten + vorlagen.""" + where_clauses = [] + params: list = [] + + if kette_id is not None: + where_clauses.append("f.kette_id = ?") + params.append(kette_id) + + if status and status != "alle": + if status == "überfällig": + where_clauses.append("(f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')))") + else: + where_clauses.append("f.status = ?") + params.append(status) + + if typ: + where_clauses.append("f.typ = ?") + params.append(typ) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + # Count + total = conn.execute(f"SELECT COUNT(*) as cnt FROM fristen f{where_sql}", params).fetchone()["cnt"] + + # Query with joins + offset = (page - 1) * page_size + rows = conn.execute( + f"""SELECT f.*, + k.status AS kette_status, + v_ursprung.aktenzeichen AS kette_aktenzeichen, + v_ursprung.betreff AS kette_betreff, + v_frist.aktenzeichen AS vorlage_aktenzeichen + FROM fristen f + LEFT JOIN ketten k ON f.kette_id = k.id + LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id + LEFT JOIN vorlagen v_frist ON f.vorlage_id = v_frist.id + {where_sql} + ORDER BY + CASE WHEN f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')) THEN 0 + WHEN f.status = 'offen' THEN 1 + ELSE 2 END, + f.datum ASC + LIMIT ? OFFSET ?""", + params + [page_size, offset], + ).fetchall() + + items = [] + for r in rows: + item = dict(r) + # Mark overdue in response + if item["status"] == "offen" and item["datum"] and item["datum"] < date.today().isoformat(): + item["status"] = "überfällig" + items.append(item) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.post("") +def create_frist(body: FristCreate, conn=Depends(_db)): + """Neue Frist manuell anlegen.""" + # Validate kette exists + kette = conn.execute("SELECT id FROM ketten WHERE id = ?", (body.kette_id,)).fetchone() + if not kette: + raise HTTPException(status_code=404, detail="Kette nicht gefunden") + + if body.vorlage_id: + vorlage = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (body.vorlage_id,)).fetchone() + if not vorlage: + raise HTTPException(status_code=404, detail="Vorlage nicht gefunden") + + cursor = conn.execute( + """INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at) + VALUES (?, ?, ?, ?, ?, 'manuell', 'offen', ?)""", + (body.kette_id, body.vorlage_id, body.typ, body.datum, body.beschreibung, datetime.now().isoformat()), + ) + conn.commit() + frist_id = cursor.lastrowid + + row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone() + return dict(row) + + +@router.patch("/{frist_id}") +def update_frist(frist_id: int, body: FristUpdate, conn=Depends(_db)): + """Frist aktualisieren (Status, Datum, Beschreibung).""" + existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Frist nicht gefunden") + + updates = [] + params = [] + + if body.status is not None: + updates.append("status = ?") + params.append(body.status) + if body.datum is not None: + updates.append("datum = ?") + params.append(body.datum) + if body.beschreibung is not None: + updates.append("beschreibung = ?") + params.append(body.beschreibung) + + if not updates: + raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") + + updates.append("aktualisiert_at = ?") + params.append(datetime.now().isoformat()) + params.append(frist_id) + + conn.execute(f"UPDATE fristen SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone() + return dict(row) + + +@router.delete("/{frist_id}") +def delete_frist(frist_id: int, conn=Depends(_db)): + """Frist löschen.""" + existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Frist nicht gefunden") + + conn.execute("DELETE FROM fristen WHERE id = ?", (frist_id,)) + conn.commit() + return {"ok": True, "deleted": frist_id} + + +@router.get("/ueberfaellig") +def get_ueberfaellige(conn=Depends(_db)): + """Überfällige Fristen abrufen und automatisch Status setzen.""" + today = date.today().isoformat() + + # Update status for overdue fristen + conn.execute( + "UPDATE fristen SET status = 'überfällig', aktualisiert_at = ? WHERE status = 'offen' AND datum < ?", + (datetime.now().isoformat(), today), + ) + conn.commit() + + rows = conn.execute( + """SELECT f.*, + k.status AS kette_status, + v_ursprung.aktenzeichen AS kette_aktenzeichen, + v_ursprung.betreff AS kette_betreff + FROM fristen f + LEFT JOIN ketten k ON f.kette_id = k.id + LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id + WHERE f.status = 'überfällig' + ORDER BY f.datum ASC""", + ).fetchall() + + return {"items": [dict(r) for r in rows], "total": len(rows)} diff --git a/backend/src/tracker/main.py b/backend/src/tracker/main.py index 5e867fb..7582bd6 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, ampel, bewertung, fraktionen, ketten, orte, stats, vorlagen +from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, fristen, ketten, orte, stats, vorlagen app = FastAPI( title="Antragstracker Hagen", @@ -20,7 +20,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST"], + allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["*"], ) @@ -32,6 +32,7 @@ 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.include_router(fristen.router, prefix="/api") @app.get("/api/health") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d117cb5..d8118e6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -259,6 +259,50 @@ export interface AmpelDefinition { export const fetchAmpelDefinition = () => get('/ampel/definition'); +// Fristen API +export interface Frist { + id: number; + kette_id: number | null; + vorlage_id: number | null; + typ: string; + datum: string; + beschreibung: string | null; + quelle: string; + status: string; + erstellt_at: string | null; + aktualisiert_at: string | null; + kette_status: string | null; + kette_aktenzeichen: string | null; + kette_betreff: string | null; + vorlage_aktenzeichen: string | null; +} + +export const fetchFristen = (params: Record) => { + const qs = new URLSearchParams(params).toString(); + return get>(`/fristen?${qs}`); +}; + +export const fetchUeberfaelligeFristen = () => + get<{ items: Frist[]; total: number }>('/fristen/ueberfaellig'); + +export const createFrist = (body: { kette_id: number; vorlage_id?: number; typ: string; datum: string; beschreibung?: string }) => + post('/fristen', body); + +export async function patchFrist(id: number, body: { status?: string; datum?: string; beschreibung?: string }): Promise { + const res = await fetch(`${BASE}/fristen/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export async function deleteFrist(id: number): Promise { + const res = await fetch(`${BASE}/fristen/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); +} + export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => { const p = new URLSearchParams(); if (jahr) p.set('jahr', jahr); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 68ce05e..716382f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -42,6 +42,7 @@ Vorlagen Abstimmungen Karte + Fristen Fraktionen @@ -77,6 +78,7 @@ 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 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 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">Karte + 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">Fristen 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">Fraktionen diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 98ff2ba..b4d2e95 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -2,7 +2,7 @@ import '../app.css'; import { goto } from '$app/navigation'; import { filters, filterVersion } from '$lib/filters.svelte'; - import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api'; + import { fetchAmpelDefinition, fetchUeberfaelligeFristen, type AmpelDefinition } from '$lib/api'; import Ampel from '$lib/components/Ampel.svelte'; interface Vorlage { @@ -33,6 +33,7 @@ let stats = $state(null); let antraege = $state([]); let ampelDef = $state(null); + let ueberfaelligeCount = $state(0); let loading = $state(true); let error = $state(''); @@ -111,11 +112,14 @@ try { ampelDef = await fetchAmpelDefinition(); } catch {} } - const [dashRes, antraegeRes] = await Promise.all([ + const [dashRes, antraegeRes, fristenRes] = await Promise.all([ fetch(`${API_BASE}/stats/dashboard${dashSuffix}`), fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`), + fetchUeberfaelligeFristen().catch(() => ({ items: [], total: 0 })), ]); + ueberfaelligeCount = fristenRes.total; + if (dashRes.ok) { stats = await dashRes.json(); } else { @@ -181,6 +185,26 @@ + + +

📊 Vorlagen nach Typ

diff --git a/frontend/src/routes/explorer/+page.svelte b/frontend/src/routes/explorer/+page.svelte index 10925d0..1dd44bb 100644 --- a/frontend/src/routes/explorer/+page.svelte +++ b/frontend/src/routes/explorer/+page.svelte @@ -1,6 +1,6 @@ + + + Fristen - Antragstracker Hagen + + +
+

⏰ Fristen

+

Termine und Fristen im Überblick

+
+ + +
+
+ {#each statusOptions as opt} + + {/each} +
+ +
+ +{#if error} +
{error}
+{/if} + +{#if loading} +
+
+
+{:else if fristen.length === 0} +
+ Keine Fristen gefunden +
+{:else} + + + + +
+ {#each fristen as frist} +
+
+
+ + {frist.datum} + + + {frist.status} + +
+ {frist.quelle === 'ki_extraktion' ? '🤖' : '✏️'} +
+
{typLabels[frist.typ] || frist.typ}
+ {#if frist.beschreibung} +

{frist.beschreibung}

+ {/if} + {#if frist.kette_aktenzeichen} + + {frist.kette_aktenzeichen} + + {/if} + {#if frist.status !== 'erfüllt'} +
+ +
+ {/if} +
+ {/each} +
+ + + {#if totalPages > 1} +
+ + Seite {currentPage} von {totalPages} + +
+ {/if} +{/if} diff --git a/scripts/migrate_fristen.py b/scripts/migrate_fristen.py new file mode 100644 index 0000000..248acac --- /dev/null +++ b/scripts/migrate_fristen.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Migration: Erstellt die fristen-Tabelle für Fristen-Tracking (Issue #17).""" + +import sqlite3 +import sys +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "tracker.db" + + +def migrate(db_path: Path = DB_PATH): + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA foreign_keys = ON") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS fristen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kette_id INTEGER REFERENCES ketten(id), + vorlage_id INTEGER REFERENCES vorlagen(id), + typ TEXT NOT NULL, + datum DATE NOT NULL, + beschreibung TEXT, + quelle TEXT DEFAULT 'manuell', + status TEXT DEFAULT 'offen', + erstellt_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + aktualisiert_at TIMESTAMP + ) + """) + + # Indices for common queries + conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_kette_id ON fristen(kette_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status ON fristen(status)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_datum ON fristen(datum)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status_datum ON fristen(status, datum)") + + conn.commit() + conn.close() + print(f"✅ Migration erfolgreich: fristen-Tabelle in {db_path}") + + +if __name__ == "__main__": + path = Path(sys.argv[1]) if len(sys.argv) > 1 else DB_PATH + migrate(path)