Compare commits
2 Commits
aa9e5699f0
...
9d8a73e2a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8a73e2a9 | ||
|
|
14d4be5d67 |
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""Pydantic response models for the API."""
|
"""Pydantic response models for the API."""
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@ -56,6 +57,13 @@ class KiZusammenfassung(BaseModel):
|
|||||||
partei: str | list[str] | None = None
|
partei: str | list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UmsetzungsBewertung(BaseModel):
|
||||||
|
score: float | None = None
|
||||||
|
begruendung: str | None = None
|
||||||
|
quelle_vorlage_id: int | None = None
|
||||||
|
quelle_aktenzeichen: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class VorlageDetail(BaseModel):
|
class VorlageDetail(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
aktenzeichen: str | None = None
|
aktenzeichen: str | None = None
|
||||||
@ -75,6 +83,7 @@ class VorlageDetail(BaseModel):
|
|||||||
referenzen_ausgehend: list[ReferenzOut] = []
|
referenzen_ausgehend: list[ReferenzOut] = []
|
||||||
referenzen_eingehend: list[ReferenzOut] = []
|
referenzen_eingehend: list[ReferenzOut] = []
|
||||||
kette_id: int | None = None
|
kette_id: int | None = None
|
||||||
|
umsetzungsbewertungen: list[UmsetzungsBewertung] = []
|
||||||
|
|
||||||
|
|
||||||
class KettenGliedOut(BaseModel):
|
class KettenGliedOut(BaseModel):
|
||||||
@ -104,6 +113,7 @@ class KetteDetail(BaseModel):
|
|||||||
status_seit: date | None = None
|
status_seit: date | None = None
|
||||||
letzte_aktivitaet: date | None = None
|
letzte_aktivitaet: date | None = None
|
||||||
vertagungen_count: int = 0
|
vertagungen_count: int = 0
|
||||||
|
begruendung: str | None = None
|
||||||
glieder: list[KettenGliedOut] = []
|
glieder: list[KettenGliedOut] = []
|
||||||
antragsteller: list[ParteiOut] = []
|
antragsteller: list[ParteiOut] = []
|
||||||
graph: dict | None = None
|
graph: dict | None = None
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""API routes for Abstimmungen und Stimmverhalten-Analysen."""
|
"""API routes for Abstimmungen und Stimmverhalten-Analysen."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|||||||
312
backend/src/tracker/api/routes/bewertung.py
Normal file
312
backend/src/tracker/api/routes/bewertung.py
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
"""API routes for KI re-evaluation."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tracker.db.session import get_connection
|
||||||
|
# Note: Re-evaluation should re-scrape ALLRIS data before KI evaluation
|
||||||
|
# to exclude transfer errors. See Gitea issue for full spec.
|
||||||
|
# IMPORTANT: Destructive changes (deleting old bewertungen etc.) only after
|
||||||
|
# explicit user request with explanation. Keep old data as backup where possible.
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bewertung", tags=["bewertung"])
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# API config
|
||||||
|
DASHSCOPE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||||
|
GEMINI_URL_TEMPLATE = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={key}"
|
||||||
|
|
||||||
|
# Job tracking
|
||||||
|
_jobs: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_key(name: str) -> str:
|
||||||
|
"""Get API key from env or macOS keychain."""
|
||||||
|
val = os.environ.get(name)
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
import subprocess
|
||||||
|
keychain_map = {"QWEN_API_KEY": "qwen-api", "GEMINI_API_KEY": "gemini-api"}
|
||||||
|
svc = keychain_map.get(name)
|
||||||
|
if svc:
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["security", "find-generic-password", "-s", svc, "-w"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).decode().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
ZUSAMMENFASSUNG_PROMPT = """Analysiere diesen kommunalpolitischen Antrag aus Hagen.
|
||||||
|
|
||||||
|
ZUSÄTZLICHE HINWEISE DES NUTZERS:
|
||||||
|
{anmerkung}
|
||||||
|
|
||||||
|
DOKUMENT:
|
||||||
|
{volltext}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Erstelle eine strukturierte Zusammenfassung im JSON-Format:
|
||||||
|
|
||||||
|
{{
|
||||||
|
"zusammenfassung": "2-3 Sätze, was gefordert wird",
|
||||||
|
"kernforderung": "Die zentrale Forderung in einem Satz",
|
||||||
|
"begruendung": "Warum wird das gefordert? (kurz)",
|
||||||
|
"thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)",
|
||||||
|
"partei": "Antragstellende Fraktion falls erkennbar",
|
||||||
|
"orte": []
|
||||||
|
}}
|
||||||
|
|
||||||
|
NUR JSON ausgeben, keine Erklärungen."""
|
||||||
|
|
||||||
|
|
||||||
|
KETTEN_MATCH_PROMPT = """Du bist ein Analyst für kommunalpolitische Vorgänge in Hagen.
|
||||||
|
|
||||||
|
ZUSÄTZLICHE HINWEISE DES NUTZERS:
|
||||||
|
{anmerkung}
|
||||||
|
|
||||||
|
Vergleiche den URSPRÜNGLICHEN ANTRAG/ANFRAGE mit der ANTWORT/BESCHLUSS.
|
||||||
|
Bewerte ob die ursprüngliche Forderung tatsächlich erfüllt wurde.
|
||||||
|
|
||||||
|
=== URSPRÜNGLICHE FORDERUNG ===
|
||||||
|
Aktenzeichen: {az_ursprung}
|
||||||
|
{ki_zusammenfassung}
|
||||||
|
|
||||||
|
Volltext:
|
||||||
|
{volltext_ursprung}
|
||||||
|
|
||||||
|
=== BERATUNGEN & BESCHLÜSSE ===
|
||||||
|
{beratungen_text}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bewerte NUR als JSON:
|
||||||
|
{{
|
||||||
|
"score": <0.0-1.0>,
|
||||||
|
"bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar",
|
||||||
|
"begruendung": "1-2 Sätze warum",
|
||||||
|
"kernpunkt_erfuellt": true/false,
|
||||||
|
"details": "Was konkret beschlossen/abgelehnt wurde"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Bewertungsskala:
|
||||||
|
- 1.0: Forderung vollständig erfüllt, konkreter Beschluss
|
||||||
|
- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen
|
||||||
|
- 0.4-0.6: Teilweise erfüllt oder auf den Weg gebracht
|
||||||
|
- 0.2-0.3: Abgewiegelt — Verwaltung weicht aus, kündigt nur "Prüfung" an
|
||||||
|
- 0.0-0.1: Nebelkerze — Thema gewechselt oder komplett ignoriert"""
|
||||||
|
|
||||||
|
|
||||||
|
class BewertungRequest(BaseModel):
|
||||||
|
anmerkung: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _call_qwen(prompt: str) -> dict | None:
|
||||||
|
key = _get_key("QWEN_API_KEY")
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
DASHSCOPE_URL,
|
||||||
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||||
|
json={"model": "qwen-plus-latest", "messages": [{"role": "user", "content": prompt}], "temperature": 0.3},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content = resp.json()["choices"][0]["message"]["content"]
|
||||||
|
if "```json" in content:
|
||||||
|
content = content.split("```json")[1].split("```")[0]
|
||||||
|
elif "```" in content:
|
||||||
|
content = content.split("```")[1].split("```")[0]
|
||||||
|
return json.loads(content.strip())
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
|
||||||
|
"""Background task: re-evaluate a Vorlage."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
row = conn.execute("SELECT volltext_clean, aktenzeichen FROM vorlagen WHERE id = ?", (vorlage_id,)).fetchone()
|
||||||
|
if not row or not row["volltext_clean"]:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": "Kein Volltext vorhanden"}
|
||||||
|
return
|
||||||
|
|
||||||
|
volltext = row["volltext_clean"]
|
||||||
|
if len(volltext) > 12000:
|
||||||
|
volltext = volltext[:12000] + "\n[...gekürzt...]"
|
||||||
|
|
||||||
|
prompt = ZUSAMMENFASSUNG_PROMPT.format(anmerkung=anmerkung or "(keine)", volltext=volltext)
|
||||||
|
result = _call_qwen(prompt)
|
||||||
|
|
||||||
|
if not result or "error" in result:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": str(result)}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete old, insert new
|
||||||
|
conn.execute("DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung'", (vorlage_id,))
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version)
|
||||||
|
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
|
||||||
|
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
if result.get("kernforderung"):
|
||||||
|
conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_jobs[job_id] = {"status": "done", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
|
||||||
|
"""Background task: re-evaluate a Kette."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
# Get kette + ursprung
|
||||||
|
kette = conn.execute(
|
||||||
|
"SELECT k.*, v.aktenzeichen, v.volltext_clean FROM ketten k JOIN vorlagen v ON k.ursprung_id = v.id WHERE k.id = ?",
|
||||||
|
(kette_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not kette:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": "Kette nicht gefunden"}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get KI summary of ursprung
|
||||||
|
ki_row = conn.execute(
|
||||||
|
"SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1",
|
||||||
|
(kette["ursprung_id"],),
|
||||||
|
).fetchone()
|
||||||
|
ki_text = ""
|
||||||
|
if ki_row and ki_row["anmerkungen"]:
|
||||||
|
try:
|
||||||
|
ki = json.loads(ki_row["anmerkungen"])
|
||||||
|
ki_text = f"Zusammenfassung: {ki.get('zusammenfassung', '')}\nKernforderung: {ki.get('kernforderung', '')}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Collect beratungen
|
||||||
|
members = conn.execute(
|
||||||
|
"SELECT vorlage_id FROM ketten_glieder WHERE kette_id = ?", (kette_id,)
|
||||||
|
).fetchall()
|
||||||
|
member_ids = [m["vorlage_id"] for m in members]
|
||||||
|
placeholders = ",".join("?" * len(member_ids))
|
||||||
|
|
||||||
|
beratungen = conn.execute(
|
||||||
|
f"""SELECT b.sitzung_datum, b.rolle, b.ergebnis, b.beschlusstext, b.wortprotokoll,
|
||||||
|
v.aktenzeichen
|
||||||
|
FROM beratungen b JOIN vorlagen v ON b.vorlage_id = v.id
|
||||||
|
WHERE b.vorlage_id IN ({placeholders})
|
||||||
|
ORDER BY b.sitzung_datum""",
|
||||||
|
member_ids,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
beratungen_text = ""
|
||||||
|
for b in beratungen:
|
||||||
|
beratungen_text += f"\n--- {b['aktenzeichen']} | {b['sitzung_datum']} | {b['rolle']} ---\n"
|
||||||
|
if b["ergebnis"]:
|
||||||
|
beratungen_text += f"Ergebnis: {b['ergebnis']}\n"
|
||||||
|
if b["beschlusstext"]:
|
||||||
|
beratungen_text += f"Beschlusstext: {b['beschlusstext'][:500]}\n"
|
||||||
|
if b["wortprotokoll"]:
|
||||||
|
beratungen_text += f"Wortprotokoll: {b['wortprotokoll'][:1000]}\n"
|
||||||
|
|
||||||
|
volltext = kette["volltext_clean"] or ""
|
||||||
|
if len(volltext) > 8000:
|
||||||
|
volltext = volltext[:8000] + "\n[...gekürzt...]"
|
||||||
|
|
||||||
|
prompt = KETTEN_MATCH_PROMPT.format(
|
||||||
|
anmerkung=anmerkung or "(keine)",
|
||||||
|
az_ursprung=kette["aktenzeichen"],
|
||||||
|
ki_zusammenfassung=ki_text,
|
||||||
|
volltext_ursprung=volltext,
|
||||||
|
beratungen_text=beratungen_text or "(keine Beratungen vorhanden)",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _call_qwen(prompt)
|
||||||
|
if not result or "error" in result:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": str(result)}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete old umsetzung_match, insert new
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match'",
|
||||||
|
(kette["ursprung_id"],),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, anmerkungen, modell, prompt_version)
|
||||||
|
VALUES (?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
|
||||||
|
(
|
||||||
|
kette["ursprung_id"],
|
||||||
|
result.get("score"),
|
||||||
|
result.get("begruendung"),
|
||||||
|
json.dumps(result, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rebuild chain status
|
||||||
|
from tracker.core.chains import build_single_chain
|
||||||
|
build_single_chain(conn, kette["ursprung_id"])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_jobs[job_id] = {"status": "done", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
_jobs[job_id] = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vorlagen/{vorlage_id}")
|
||||||
|
def reeval_vorlage(vorlage_id: int, req: BewertungRequest, conn=Depends(_db)):
|
||||||
|
"""Trigger KI re-evaluation for a Vorlage."""
|
||||||
|
row = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (vorlage_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||||
|
|
||||||
|
job_id = f"vorlage-{vorlage_id}-{int(datetime.now().timestamp())}"
|
||||||
|
_jobs[job_id] = {"status": "running"}
|
||||||
|
t = threading.Thread(target=_run_zusammenfassung, args=(vorlage_id, req.anmerkung, job_id), daemon=True)
|
||||||
|
t.start()
|
||||||
|
return {"job_id": job_id, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ketten/{kette_id}")
|
||||||
|
def reeval_kette(kette_id: int, req: BewertungRequest, conn=Depends(_db)):
|
||||||
|
"""Trigger KI re-evaluation for a Kette."""
|
||||||
|
row = conn.execute("SELECT id FROM ketten WHERE id = ?", (kette_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Kette nicht gefunden")
|
||||||
|
|
||||||
|
job_id = f"kette-{kette_id}-{int(datetime.now().timestamp())}"
|
||||||
|
_jobs[job_id] = {"status": "running"}
|
||||||
|
t = threading.Thread(target=_run_ketten_bewertung, args=(kette_id, req.anmerkung, job_id), daemon=True)
|
||||||
|
t.start()
|
||||||
|
return {"job_id": job_id, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{job_id}")
|
||||||
|
def get_job_status(job_id: str):
|
||||||
|
"""Check status of a re-evaluation job."""
|
||||||
|
if job_id not in _jobs:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
return _jobs[job_id]
|
||||||
128
backend/src/tracker/api/routes/fraktionen.py
Normal file
128
backend/src/tracker/api/routes/fraktionen.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
"""API routes for Fraktions-Dashboard."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from tracker.core.kategorien import BEWERTUNGSKATEGORIEN
|
||||||
|
from tracker.db.session import get_connection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/fraktionen", tags=["Fraktionen"])
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kategorien")
|
||||||
|
def get_kategorien():
|
||||||
|
"""Return category definitions for frontend tooltips."""
|
||||||
|
return {
|
||||||
|
key: {
|
||||||
|
"label": val["label"],
|
||||||
|
"farbe": val["farbe"],
|
||||||
|
"icon": val["icon"],
|
||||||
|
"beschreibung": val["beschreibung"],
|
||||||
|
"beispiel": val["beispiel"],
|
||||||
|
"score_range": val["score_range"],
|
||||||
|
}
|
||||||
|
for key, val in BEWERTUNGSKATEGORIEN.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_fraktionen(conn=Depends(_db)):
|
||||||
|
"""List all parties with Antrag counts."""
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT p.id, p.kuerzel, p.name, p.farbe, COUNT(a.vorlage_id) as anzahl
|
||||||
|
FROM parteien p
|
||||||
|
LEFT JOIN antragsteller a ON p.id = a.partei_id
|
||||||
|
GROUP BY p.id
|
||||||
|
HAVING anzahl > 0
|
||||||
|
ORDER BY anzahl DESC
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{kuerzel}/dashboard")
|
||||||
|
def fraktion_dashboard(
|
||||||
|
kuerzel: str,
|
||||||
|
jahr: Optional[int] = None,
|
||||||
|
conn=Depends(_db),
|
||||||
|
):
|
||||||
|
"""Dashboard for a single Fraktion with Umsetzungsanalyse."""
|
||||||
|
# Find party
|
||||||
|
partei = conn.execute(
|
||||||
|
"SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,)
|
||||||
|
).fetchone()
|
||||||
|
if not partei:
|
||||||
|
raise HTTPException(404, f"Fraktion '{kuerzel}' nicht gefunden")
|
||||||
|
|
||||||
|
# Base query: all Vorlagen by this party
|
||||||
|
jahr_filter = ""
|
||||||
|
params = [partei["id"]]
|
||||||
|
if jahr:
|
||||||
|
jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?"
|
||||||
|
params.append(str(jahr))
|
||||||
|
|
||||||
|
# Total Anträge
|
||||||
|
total = conn.execute(f"""
|
||||||
|
SELECT COUNT(DISTINCT v.id) as c
|
||||||
|
FROM vorlagen v
|
||||||
|
JOIN antragsteller a ON a.vorlage_id = v.id
|
||||||
|
WHERE a.partei_id = ? {jahr_filter}
|
||||||
|
""", params).fetchone()["c"]
|
||||||
|
|
||||||
|
# With Ketten-Match results
|
||||||
|
umsetzung_rows = conn.execute(f"""
|
||||||
|
SELECT
|
||||||
|
json_extract(kb.anmerkungen, '$.bewertung') as bewertung,
|
||||||
|
COUNT(*) as anzahl,
|
||||||
|
ROUND(AVG(kb.score), 2) as avg_score
|
||||||
|
FROM vorlagen v
|
||||||
|
JOIN antragsteller a ON a.vorlage_id = v.id
|
||||||
|
JOIN ki_bewertungen kb ON kb.vorlage_id = v.id AND kb.typ = 'umsetzung_match'
|
||||||
|
WHERE a.partei_id = ? {jahr_filter}
|
||||||
|
GROUP BY bewertung
|
||||||
|
ORDER BY anzahl DESC
|
||||||
|
""", params).fetchall()
|
||||||
|
|
||||||
|
umsetzung = [dict(r) for r in umsetzung_rows]
|
||||||
|
bewertet = sum(r["anzahl"] for r in umsetzung_rows)
|
||||||
|
|
||||||
|
# Top Anträge with scores
|
||||||
|
antraege = conn.execute(f"""
|
||||||
|
SELECT v.id, v.aktenzeichen, v.betreff, v.typ, v.datum_eingang,
|
||||||
|
kb.score as umsetzung_score,
|
||||||
|
json_extract(kb.anmerkungen, '$.bewertung') as umsetzung_bewertung,
|
||||||
|
kb.begruendung as umsetzung_begruendung
|
||||||
|
FROM vorlagen v
|
||||||
|
JOIN antragsteller a ON a.vorlage_id = v.id
|
||||||
|
LEFT JOIN ki_bewertungen kb ON kb.vorlage_id = v.id AND kb.typ = 'umsetzung_match'
|
||||||
|
WHERE a.partei_id = ? {jahr_filter}
|
||||||
|
ORDER BY v.datum_eingang DESC
|
||||||
|
LIMIT 200
|
||||||
|
""", params).fetchall()
|
||||||
|
|
||||||
|
# Jahre für Filter
|
||||||
|
jahre = conn.execute("""
|
||||||
|
SELECT DISTINCT strftime('%Y', v.datum_eingang) as j
|
||||||
|
FROM vorlagen v
|
||||||
|
JOIN antragsteller a ON a.vorlage_id = v.id
|
||||||
|
WHERE a.partei_id = ? AND v.datum_eingang IS NOT NULL
|
||||||
|
ORDER BY j DESC
|
||||||
|
""", [partei["id"]]).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"partei": dict(partei),
|
||||||
|
"total_antraege": total,
|
||||||
|
"bewertet": bewertet,
|
||||||
|
"umsetzung": umsetzung,
|
||||||
|
"antraege": [dict(r) for r in antraege],
|
||||||
|
"jahre": [r["j"] for r in jahre],
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""API routes for Ketten (chains)."""
|
"""API routes for Ketten (chains)."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@ -31,6 +32,7 @@ def list_ketten(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
typ: str | None = None,
|
typ: str | None = None,
|
||||||
suche: str | None = None,
|
suche: str | None = None,
|
||||||
|
partei: str | None = None,
|
||||||
conn=Depends(_db),
|
conn=Depends(_db),
|
||||||
):
|
):
|
||||||
"""List Ketten with optional filters."""
|
"""List Ketten with optional filters."""
|
||||||
@ -45,6 +47,13 @@ def list_ketten(
|
|||||||
where_clauses.append("k.typ = ?")
|
where_clauses.append("k.typ = ?")
|
||||||
params.append(typ)
|
params.append(typ)
|
||||||
|
|
||||||
|
if partei:
|
||||||
|
where_clauses.append(
|
||||||
|
"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a "
|
||||||
|
"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel = ?)"
|
||||||
|
)
|
||||||
|
params.append(partei)
|
||||||
|
|
||||||
if suche:
|
if suche:
|
||||||
where_clauses.append("k.thema LIKE ?")
|
where_clauses.append("k.thema LIKE ?")
|
||||||
params.append(f"%{suche}%")
|
params.append(f"%{suche}%")
|
||||||
@ -100,7 +109,7 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
"""Get a single Kette with all Glieder."""
|
"""Get a single Kette with all Glieder."""
|
||||||
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.ursprung_id,
|
k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id,
|
||||||
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
|
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
|
||||||
v.ist_verwaltungsvorlage
|
v.ist_verwaltungsvorlage
|
||||||
FROM ketten k
|
FROM ketten k
|
||||||
@ -170,6 +179,7 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
status_seit=row["status_seit"],
|
status_seit=row["status_seit"],
|
||||||
letzte_aktivitaet=row["letzte_aktivitaet"],
|
letzte_aktivitaet=row["letzte_aktivitaet"],
|
||||||
vertagungen_count=row["vertagungen_count"],
|
vertagungen_count=row["vertagungen_count"],
|
||||||
|
begruendung=row["begruendung"],
|
||||||
glieder=glieder,
|
glieder=glieder,
|
||||||
antragsteller=antragsteller,
|
antragsteller=antragsteller,
|
||||||
graph=graph,
|
graph=graph,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""API routes for Orte und Karten-Daten."""
|
"""API routes for Orte und Karten-Daten."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""API routes for Dashboard statistics."""
|
"""API routes for Dashboard statistics."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""API routes for Vorlagen."""
|
"""API routes for Vorlagen."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@ -28,12 +29,13 @@ def _db():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=PaginatedVorlagen)
|
@router.get("")
|
||||||
def list_vorlagen(
|
def list_vorlagen(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
typ: str | None = None,
|
typ: str | None = None,
|
||||||
suche: str | None = None,
|
suche: str | None = None,
|
||||||
|
partei: str | None = None,
|
||||||
conn=Depends(_db),
|
conn=Depends(_db),
|
||||||
):
|
):
|
||||||
"""List Vorlagen with optional filters."""
|
"""List Vorlagen with optional filters."""
|
||||||
@ -44,9 +46,24 @@ def list_vorlagen(
|
|||||||
where_clauses.append("v.typ = ?")
|
where_clauses.append("v.typ = ?")
|
||||||
params.append(typ)
|
params.append(typ)
|
||||||
|
|
||||||
|
if partei:
|
||||||
|
where_clauses.append(
|
||||||
|
"v.id IN (SELECT a.vorlage_id FROM antragsteller a "
|
||||||
|
"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel = ?)"
|
||||||
|
)
|
||||||
|
params.append(partei)
|
||||||
|
|
||||||
if suche:
|
if suche:
|
||||||
where_clauses.append("(v.betreff LIKE ? OR v.aktenzeichen LIKE ?)")
|
where_clauses.append(
|
||||||
params.extend([f"%{suche}%", f"%{suche}%"])
|
"(v.betreff LIKE ? OR v.aktenzeichen LIKE ?"
|
||||||
|
" OR v.volltext_clean LIKE ?"
|
||||||
|
" OR v.id IN ("
|
||||||
|
" SELECT kb.vorlage_id FROM ki_bewertungen kb"
|
||||||
|
" WHERE kb.typ = 'zusammenfassung' AND kb.begruendung LIKE ?"
|
||||||
|
"))"
|
||||||
|
)
|
||||||
|
like = f"%{suche}%"
|
||||||
|
params.extend([like, like, like, like])
|
||||||
|
|
||||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||||
|
|
||||||
@ -64,19 +81,36 @@ def list_vorlagen(
|
|||||||
params + [page_size, offset],
|
params + [page_size, offset],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
# Fetch antragsteller for all vorlagen in one query
|
||||||
|
vorlage_ids = [r["id"] for r in rows]
|
||||||
|
antragsteller_map: dict = {}
|
||||||
|
if vorlage_ids:
|
||||||
|
placeholders = ",".join("?" * len(vorlage_ids))
|
||||||
|
ast_rows = conn.execute(
|
||||||
|
f"""SELECT a.vorlage_id, p.kuerzel, p.name, p.farbe
|
||||||
|
FROM antragsteller a JOIN parteien p ON a.partei_id = p.id
|
||||||
|
WHERE a.vorlage_id IN ({placeholders})""",
|
||||||
|
vorlage_ids,
|
||||||
|
).fetchall()
|
||||||
|
for a in ast_rows:
|
||||||
|
antragsteller_map.setdefault(a["vorlage_id"], []).append(
|
||||||
|
{"kuerzel": a["kuerzel"], "name": a["name"], "farbe": a["farbe"]}
|
||||||
|
)
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
VorlageKurz(
|
{
|
||||||
id=r["id"],
|
"id": r["id"],
|
||||||
aktenzeichen=r["aktenzeichen"],
|
"aktenzeichen": r["aktenzeichen"],
|
||||||
typ=r["typ"],
|
"typ": r["typ"],
|
||||||
betreff=r["betreff"],
|
"betreff": r["betreff"],
|
||||||
datum_eingang=r["datum_eingang"],
|
"datum_eingang": r["datum_eingang"],
|
||||||
ist_verwaltungsvorlage=bool(r["ist_verwaltungsvorlage"]),
|
"ist_verwaltungsvorlage": bool(r["ist_verwaltungsvorlage"]),
|
||||||
)
|
"antragsteller": antragsteller_map.get(r["id"], []),
|
||||||
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
return PaginatedVorlagen(items=items, total=total, page=page, page_size=page_size)
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vorlage_id}", response_model=VorlageDetail)
|
@router.get("/{vorlage_id}", response_model=VorlageDetail)
|
||||||
@ -149,6 +183,26 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Umsetzungsbewertungen
|
||||||
|
from tracker.api.models import UmsetzungsBewertung
|
||||||
|
umsetzungs_rows = conn.execute(
|
||||||
|
"""SELECT kb.score, kb.begruendung, kb.vorlage_id as quelle_id, v2.aktenzeichen as quelle_az
|
||||||
|
FROM ki_bewertungen kb
|
||||||
|
LEFT JOIN vorlagen v2 ON v2.id = kb.vorlage_id
|
||||||
|
WHERE kb.vorlage_id = ? AND kb.typ = 'umsetzung_match'
|
||||||
|
ORDER BY kb.score DESC""",
|
||||||
|
(vorlage_id,),
|
||||||
|
).fetchall()
|
||||||
|
umsetzungsbewertungen = [
|
||||||
|
UmsetzungsBewertung(
|
||||||
|
score=u["score"],
|
||||||
|
begruendung=u["begruendung"],
|
||||||
|
quelle_vorlage_id=u["quelle_id"],
|
||||||
|
quelle_aktenzeichen=u["quelle_az"],
|
||||||
|
)
|
||||||
|
for u in umsetzungs_rows
|
||||||
|
]
|
||||||
|
|
||||||
return VorlageDetail(
|
return VorlageDetail(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
aktenzeichen=row["aktenzeichen"],
|
aktenzeichen=row["aktenzeichen"],
|
||||||
@ -168,4 +222,5 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
|
|||||||
referenzen_eingehend=[ReferenzOut(**r) for r in refs["eingehend"]],
|
referenzen_eingehend=[ReferenzOut(**r) for r in refs["eingehend"]],
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""Ketten-Builder: groups Vorlagen into chains based on Aktenzeichen-Suffix references."""
|
"""Ketten-Builder: groups Vorlagen into chains based on Aktenzeichen-Suffix references."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@ -97,7 +98,7 @@ def build_chains(conn: sqlite3.Connection) -> int:
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE ketten
|
UPDATE ketten
|
||||||
SET typ = ?, thema = ?, status = ?, status_seit = ?,
|
SET typ = ?, thema = ?, status = ?, status_seit = ?,
|
||||||
letzte_aktivitaet = ?, vertagungen_count = ?
|
letzte_aktivitaet = ?, vertagungen_count = ?, begruendung = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (
|
""", (
|
||||||
chain_typ,
|
chain_typ,
|
||||||
@ -106,14 +107,15 @@ def build_chains(conn: sqlite3.Connection) -> int:
|
|||||||
status_info.get("status_seit"),
|
status_info.get("status_seit"),
|
||||||
letzte_aktivitaet,
|
letzte_aktivitaet,
|
||||||
status_info.get("vertagungen_count", 0),
|
status_info.get("vertagungen_count", 0),
|
||||||
|
status_info.get("begruendung"),
|
||||||
kette_id,
|
kette_id,
|
||||||
))
|
))
|
||||||
conn.execute("DELETE FROM ketten_glieder WHERE kette_id = ?", (kette_id,))
|
conn.execute("DELETE FROM ketten_glieder WHERE kette_id = ?", (kette_id,))
|
||||||
else:
|
else:
|
||||||
cursor = conn.execute("""
|
cursor = conn.execute("""
|
||||||
INSERT INTO ketten (ursprung_id, typ, thema, status, status_seit,
|
INSERT INTO ketten (ursprung_id, typ, thema, status, status_seit,
|
||||||
letzte_aktivitaet, vertagungen_count)
|
letzte_aktivitaet, vertagungen_count, begruendung)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
ursprung["id"],
|
ursprung["id"],
|
||||||
chain_typ,
|
chain_typ,
|
||||||
@ -122,6 +124,7 @@ def build_chains(conn: sqlite3.Connection) -> int:
|
|||||||
status_info.get("status_seit"),
|
status_info.get("status_seit"),
|
||||||
letzte_aktivitaet,
|
letzte_aktivitaet,
|
||||||
status_info.get("vertagungen_count", 0),
|
status_info.get("vertagungen_count", 0),
|
||||||
|
status_info.get("begruendung"),
|
||||||
))
|
))
|
||||||
kette_id = cursor.lastrowid
|
kette_id = cursor.lastrowid
|
||||||
|
|
||||||
@ -139,6 +142,47 @@ def build_chains(conn: sqlite3.Connection) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_chain(conn: sqlite3.Connection, ursprung_id: int) -> None:
|
||||||
|
"""Rebuild a single chain by its ursprung vorlage ID."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT aktenzeichen_basis FROM vorlagen WHERE id = ?", (ursprung_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row["aktenzeichen_basis"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
basis = row["aktenzeichen_basis"]
|
||||||
|
members = conn.execute("""
|
||||||
|
SELECT id, aktenzeichen, aktenzeichen_suffix, typ, datum_eingang, betreff
|
||||||
|
FROM vorlagen WHERE aktenzeichen_basis = ?
|
||||||
|
ORDER BY CASE WHEN aktenzeichen_suffix IS NULL THEN 0
|
||||||
|
ELSE CAST(REPLACE(aktenzeichen_suffix, '-', '') AS INTEGER) END
|
||||||
|
""", (basis,)).fetchall()
|
||||||
|
|
||||||
|
if not members:
|
||||||
|
return
|
||||||
|
|
||||||
|
ursprung = members[0]
|
||||||
|
chain_typ = ursprung["typ"]
|
||||||
|
if chain_typ not in ("antrag", "anfrage"):
|
||||||
|
return
|
||||||
|
|
||||||
|
status_info = compute_status(conn, ursprung["id"], chain_typ, members)
|
||||||
|
dates = [m["datum_eingang"] for m in members if m["datum_eingang"]]
|
||||||
|
letzte_aktivitaet = max(dates) if dates else ursprung["datum_eingang"]
|
||||||
|
|
||||||
|
existing = conn.execute("SELECT id FROM ketten WHERE ursprung_id = ?", (ursprung["id"],)).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE ketten SET status = ?, status_seit = ?, letzte_aktivitaet = ?,
|
||||||
|
vertagungen_count = ?, begruendung = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", (
|
||||||
|
status_info["status"], status_info.get("status_seit"), letzte_aktivitaet,
|
||||||
|
status_info.get("vertagungen_count", 0), status_info.get("begruendung"),
|
||||||
|
existing["id"],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _determine_rolle(member: sqlite3.Row, position: int) -> str:
|
def _determine_rolle(member: sqlite3.Row, position: int) -> str:
|
||||||
if position == 0:
|
if position == 0:
|
||||||
return "ursprung"
|
return "ursprung"
|
||||||
|
|||||||
83
backend/src/tracker/core/kategorien.py
Normal file
83
backend/src/tracker/core/kategorien.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
"""Bewertungskategorien für Ketten-Match Umsetzungsanalyse."""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
# Canonical categories with metadata
|
||||||
|
BEWERTUNGSKATEGORIEN: Dict[str, dict] = {
|
||||||
|
"erfuellt": {
|
||||||
|
"label": "Erfüllt",
|
||||||
|
"score_range": [0.8, 1.0],
|
||||||
|
"farbe": "#22c55e", # green-500
|
||||||
|
"icon": "✅",
|
||||||
|
"beschreibung": "Forderung vollständig oder weitgehend umgesetzt. Konkreter Beschluss liegt vor, der die Kernpunkte des Antrags aufgreift.",
|
||||||
|
"beispiel": "Antrag auf Zuschuss → Zuschuss in beantragter Höhe einstimmig bewilligt.",
|
||||||
|
},
|
||||||
|
"teilweise": {
|
||||||
|
"label": "Teilweise",
|
||||||
|
"score_range": [0.5, 0.7],
|
||||||
|
"farbe": "#eab308", # yellow-500
|
||||||
|
"icon": "⚠️",
|
||||||
|
"beschreibung": "Kernpunkt wurde adressiert, aber mit Abstrichen. Verwaltung greift Teile auf, lässt andere fallen oder verwässert die Forderung.",
|
||||||
|
"beispiel": "Antrag auf Radweg → Prüfauftrag für Machbarkeitsstudie statt direktem Baubeschluss.",
|
||||||
|
},
|
||||||
|
"abgewiegelt": {
|
||||||
|
"label": "Abgewiegelt",
|
||||||
|
"score_range": [0.2, 0.4],
|
||||||
|
"farbe": "#f97316", # orange-500
|
||||||
|
"icon": "🚫",
|
||||||
|
"beschreibung": "Verwaltung weicht aus. Kündigt Prüfung an, verweist auf Zuständigkeiten oder beantwortet die Frage nicht substantiell.",
|
||||||
|
"beispiel": "Anfrage zu Missständen → 'Wird geprüft' ohne Zeitrahmen oder konkrete Zusagen.",
|
||||||
|
},
|
||||||
|
"nebelkerze": {
|
||||||
|
"label": "Nebelkerze",
|
||||||
|
"score_range": [0.0, 0.1],
|
||||||
|
"farbe": "#ef4444", # red-500
|
||||||
|
"icon": "💨",
|
||||||
|
"beschreibung": "Thema komplett ignoriert, Diskussion auf Nebenschauplatz verlagert oder Antrag ohne Behandlung von der Tagesordnung genommen.",
|
||||||
|
"beispiel": "Antrag zu Sauberkeit → 'Ohne Beschlussfassung' oder keine Wortmeldung.",
|
||||||
|
},
|
||||||
|
"vertagt": {
|
||||||
|
"label": "Vertagt",
|
||||||
|
"score_range": None, # Variable score
|
||||||
|
"farbe": "#8b5cf6", # violet-500
|
||||||
|
"icon": "⏳",
|
||||||
|
"beschreibung": "Antrag explizit verschoben, ruhend gestellt oder in einen anderen Ausschuss verwiesen. Kein inhaltliches Ergebnis, aber formal nicht abgelehnt.",
|
||||||
|
"beispiel": "Antrag wird zur weiteren Beratung in den Fachausschuss überwiesen.",
|
||||||
|
},
|
||||||
|
"unklar": {
|
||||||
|
"label": "Unklar",
|
||||||
|
"score_range": None,
|
||||||
|
"farbe": "#6b7280", # gray-500
|
||||||
|
"icon": "❓",
|
||||||
|
"beschreibung": "Beschlusslage nicht eindeutig zuordenbar. Möglicherweise fehlen Dokumente oder der Beschlusstext ist nicht aussagekräftig genug.",
|
||||||
|
"beispiel": "Nur 'Kenntnisnahme' ohne weitere Erläuterung.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def score_to_kategorie(score: float) -> str:
|
||||||
|
"""Map a numeric score to the canonical category."""
|
||||||
|
if score >= 0.8:
|
||||||
|
return "erfuellt"
|
||||||
|
if score >= 0.5:
|
||||||
|
return "teilweise"
|
||||||
|
if score >= 0.2:
|
||||||
|
return "abgewiegelt"
|
||||||
|
return "nebelkerze"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_kategorie(raw: str) -> str:
|
||||||
|
"""Normalize variant spellings to canonical category."""
|
||||||
|
ALIASES = {
|
||||||
|
"erfüllt": "erfuellt",
|
||||||
|
"weitgehend erfüllt": "erfuellt",
|
||||||
|
"weitgehend erfuellt": "erfuellt",
|
||||||
|
"weitgehend_erfuellt": "erfuellt",
|
||||||
|
"weitestgehend_erfuellt": "erfuellt",
|
||||||
|
"weitgehend": "erfuellt",
|
||||||
|
"ohne beschlussfassung": "nebelkerze",
|
||||||
|
"ohne_beschlussfassung": "nebelkerze",
|
||||||
|
}
|
||||||
|
normalized = raw.strip().lower()
|
||||||
|
return ALIASES.get(normalized, normalized if normalized in BEWERTUNGSKATEGORIEN else "unklar")
|
||||||
@ -23,7 +23,7 @@ def compute_status(
|
|||||||
return _status_anfrage(conn, ursprung_id, members)
|
return _status_anfrage(conn, ursprung_id, members)
|
||||||
elif chain_typ == "antrag":
|
elif chain_typ == "antrag":
|
||||||
return _status_antrag(conn, ursprung_id, members)
|
return _status_antrag(conn, ursprung_id, members)
|
||||||
return {"status": "unbekannt", "status_seit": None, "vertagungen_count": 0}
|
return {"status": "unbekannt", "status_seit": None, "vertagungen_count": 0, "begruendung": "Unbekannter Kettentyp"}
|
||||||
|
|
||||||
|
|
||||||
def _status_anfrage(
|
def _status_anfrage(
|
||||||
@ -47,16 +47,21 @@ def _status_anfrage(
|
|||||||
stellungnahmen = [m for m in members if m["typ"] == "stellungnahme"]
|
stellungnahmen = [m for m in members if m["typ"] == "stellungnahme"]
|
||||||
has_stellungnahme = len(stellungnahmen) > 0
|
has_stellungnahme = len(stellungnahmen) > 0
|
||||||
|
|
||||||
# Check for Kenntnisnahme in Beratungen
|
# Check for Kenntnisnahme in Beratungen (alle Kettenglieder)
|
||||||
beratungen = conn.execute("""
|
member_ids = [m["id"] for m in members]
|
||||||
SELECT rolle, ergebnis, sitzung_datum
|
placeholders = ",".join("?" * len(member_ids))
|
||||||
|
beratungen = conn.execute(f"""
|
||||||
|
SELECT rolle, ergebnis, sitzung_datum, beschlusstext
|
||||||
FROM beratungen
|
FROM beratungen
|
||||||
WHERE vorlage_id = ?
|
WHERE vorlage_id IN ({placeholders})
|
||||||
ORDER BY sitzung_datum DESC
|
ORDER BY sitzung_datum DESC
|
||||||
""", (ursprung_id,)).fetchall()
|
""", member_ids).fetchall()
|
||||||
|
|
||||||
has_kenntnisnahme = any(
|
has_kenntnisnahme = any(
|
||||||
b["rolle"] and "kenntnisnahme" in b["rolle"].lower()
|
(b["rolle"] and "kenntnisnahme" in b["rolle"].lower())
|
||||||
|
or (b["beschlusstext"] and "kenntnis" in b["beschlusstext"].lower())
|
||||||
|
if "beschlusstext" in b.keys() else
|
||||||
|
(b["rolle"] and "kenntnisnahme" in b["rolle"].lower())
|
||||||
for b in beratungen
|
for b in beratungen
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -65,20 +70,28 @@ def _status_anfrage(
|
|||||||
|
|
||||||
# Check zurückgezogen
|
# Check zurückgezogen
|
||||||
if _is_zurueckgezogen(beratungen):
|
if _is_zurueckgezogen(beratungen):
|
||||||
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": 0}
|
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": 0,
|
||||||
|
"begruendung": "In einer Beratung explizit zurückgezogen."}
|
||||||
|
|
||||||
if has_stellungnahme:
|
if has_stellungnahme:
|
||||||
if ki_score is not None and ki_score < 0.5:
|
if ki_score is not None and ki_score < 0.5:
|
||||||
return {"status": "abgewiegelt", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
|
return {"status": "abgewiegelt", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0,
|
||||||
|
"begruendung": f"Stellungnahme vorhanden, aber KI-Bewertung nur {ki_score:.0%} — die Antwort geht nicht ausreichend auf die Anfrage ein."}
|
||||||
if has_kenntnisnahme and (ki_score is None or ki_score >= 0.7):
|
if has_kenntnisnahme and (ki_score is None or ki_score >= 0.7):
|
||||||
return {"status": "beantwortet", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
|
score_text = f" (KI-Match: {ki_score:.0%})" if ki_score is not None else ""
|
||||||
return {"status": "offen", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0}
|
return {"status": "beantwortet", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0,
|
||||||
|
"begruendung": f"Stellungnahme liegt vor{score_text}, Kenntnisnahme im Gremium erfolgt."}
|
||||||
|
return {"status": "offen", "status_seit": _vorlage_date(stellungnahmen[0]), "vertagungen_count": 0,
|
||||||
|
"begruendung": "Stellungnahme vorhanden, aber noch keine Kenntnisnahme im Gremium."}
|
||||||
|
|
||||||
# No Stellungnahme
|
# No Stellungnahme
|
||||||
if ursprung_datum and (heute - ursprung_datum).days > VERSANDET_TAGE:
|
if ursprung_datum and (heute - ursprung_datum).days > VERSANDET_TAGE:
|
||||||
return {"status": "versandet", "status_seit": ursprung_datum, "vertagungen_count": 0}
|
tage = (heute - ursprung_datum).days
|
||||||
|
return {"status": "versandet", "status_seit": ursprung_datum, "vertagungen_count": 0,
|
||||||
|
"begruendung": f"Keine Stellungnahme nach {tage} Tagen."}
|
||||||
|
|
||||||
return {"status": "angefragt", "status_seit": ursprung_datum, "vertagungen_count": 0}
|
return {"status": "angefragt", "status_seit": ursprung_datum, "vertagungen_count": 0,
|
||||||
|
"begruendung": "Anfrage gestellt, Stellungnahme steht noch aus."}
|
||||||
|
|
||||||
|
|
||||||
def _status_antrag(
|
def _status_antrag(
|
||||||
@ -103,19 +116,23 @@ def _status_antrag(
|
|||||||
heute = date.today()
|
heute = date.today()
|
||||||
ursprung_datum = _parse_date(members[0]["datum_eingang"])
|
ursprung_datum = _parse_date(members[0]["datum_eingang"])
|
||||||
|
|
||||||
beratungen = conn.execute("""
|
# Beratungen ALLER Kettenglieder sammeln (nicht nur Ursprung)
|
||||||
SELECT rolle, ergebnis, sitzung_datum
|
member_ids = [m["id"] for m in members]
|
||||||
|
placeholders = ",".join("?" * len(member_ids))
|
||||||
|
beratungen = conn.execute(f"""
|
||||||
|
SELECT rolle, ergebnis, sitzung_datum, beschlusstext
|
||||||
FROM beratungen
|
FROM beratungen
|
||||||
WHERE vorlage_id = ?
|
WHERE vorlage_id IN ({placeholders})
|
||||||
ORDER BY sitzung_datum DESC NULLS LAST
|
ORDER BY sitzung_datum DESC NULLS LAST
|
||||||
""", (ursprung_id,)).fetchall()
|
""", member_ids).fetchall()
|
||||||
|
|
||||||
# Count Vertagungen
|
# Count Vertagungen
|
||||||
vertagungen = sum(1 for b in beratungen if b["ergebnis"] and "vertagt" in b["ergebnis"].lower())
|
vertagungen = sum(1 for b in beratungen if b["ergebnis"] and "vertagt" in b["ergebnis"].lower())
|
||||||
|
|
||||||
# Check zurückgezogen
|
# Check zurückgezogen
|
||||||
if _is_zurueckgezogen(beratungen):
|
if _is_zurueckgezogen(beratungen):
|
||||||
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
|
return {"status": "zurückgezogen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": "In einer Beratung explizit zurückgezogen."}
|
||||||
|
|
||||||
# Check for Berichte in chain
|
# Check for Berichte in chain
|
||||||
berichte = [m for m in members if m["typ"] == "bericht"]
|
berichte = [m for m in members if m["typ"] == "bericht"]
|
||||||
@ -123,9 +140,11 @@ def _status_antrag(
|
|||||||
|
|
||||||
# Determine beschluss from beratungen
|
# Determine beschluss from beratungen
|
||||||
beschluss = _get_beschluss(beratungen)
|
beschluss = _get_beschluss(beratungen)
|
||||||
|
beschluss_details = _get_beschluss_details(beratungen)
|
||||||
|
|
||||||
if beschluss == "abgelehnt":
|
if beschluss == "abgelehnt":
|
||||||
return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
|
return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"In Beratung abgelehnt. {beschluss_details}"}
|
||||||
|
|
||||||
if beschluss == "angenommen":
|
if beschluss == "angenommen":
|
||||||
beschluss_datum = _latest_date(beratungen)
|
beschluss_datum = _latest_date(beratungen)
|
||||||
@ -136,31 +155,43 @@ def _status_antrag(
|
|||||||
|
|
||||||
if ki_score is not None:
|
if ki_score is not None:
|
||||||
if ki_score >= 0.7:
|
if ki_score >= 0.7:
|
||||||
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
|
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"Beschlossen ({beschluss_details}), Umsetzungsbericht liegt vor. KI-Bewertung: {ki_score:.0%} Übereinstimmung."}
|
||||||
elif ki_score >= 0.4:
|
elif ki_score >= 0.4:
|
||||||
return {"status": "teilweise_umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
|
return {"status": "teilweise_umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"Beschlossen ({beschluss_details}), aber Umsetzungsbericht zeigt nur teilweise Umsetzung. KI-Bewertung: {ki_score:.0%}."}
|
||||||
else:
|
else:
|
||||||
return {"status": "abgewiegelt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
|
return {"status": "abgewiegelt", "status_seit": bericht_datum, "vertagungen_count": vertagungen,
|
||||||
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen}
|
"begruendung": f"Beschlossen ({beschluss_details}), aber Umsetzungsbericht weicht stark vom Beschluss ab. KI-Bewertung: nur {ki_score:.0%}."}
|
||||||
|
return {"status": "umgesetzt", "status_seit": bericht_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"Beschlossen ({beschluss_details}), Umsetzungsbericht liegt vor (keine KI-Bewertung)."}
|
||||||
|
|
||||||
# Angenommen but no Bericht
|
# Angenommen but no Bericht
|
||||||
if beschluss_datum and (heute - beschluss_datum).days > VERSANDET_TAGE:
|
if beschluss_datum and (heute - beschluss_datum).days > VERSANDET_TAGE:
|
||||||
return {"status": "versandet", "status_seit": beschluss_datum, "vertagungen_count": vertagungen}
|
tage = (heute - beschluss_datum).days
|
||||||
|
return {"status": "versandet", "status_seit": beschluss_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"Beschlossen ({beschluss_details}), aber seit {tage} Tagen kein Umsetzungsbericht."}
|
||||||
|
|
||||||
return {"status": "beschlossen", "status_seit": beschluss_datum, "vertagungen_count": vertagungen}
|
return {"status": "beschlossen", "status_seit": beschluss_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"Antrag angenommen. {beschluss_details}"}
|
||||||
|
|
||||||
if beschluss == "verwiesen":
|
if beschluss == "verwiesen":
|
||||||
return {"status": "verwiesen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
|
return {"status": "verwiesen", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"An anderen Ausschuss überwiesen. {beschluss_details}"}
|
||||||
|
|
||||||
# No final decision yet
|
# No final decision yet
|
||||||
if beratungen:
|
if beratungen:
|
||||||
last = beratungen[0]
|
last = beratungen[0]
|
||||||
if last["ergebnis"] and "vertagt" in last["ergebnis"].lower():
|
if last["ergebnis"] and "vertagt" in last["ergebnis"].lower():
|
||||||
return {"status": "vertagt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
|
return {"status": "vertagt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
|
||||||
return {"status": "in_beratung", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen}
|
"begruendung": f"Zuletzt vertagt ({vertagungen}x insgesamt). Letzte Beratung: {last['sitzung_datum'] or '?'}."}
|
||||||
|
gremium_text = f"Letzte Beratung: {last['sitzung_datum'] or '?'}, Ergebnis: {last['ergebnis'] or 'k.A.'}."
|
||||||
|
return {"status": "in_beratung", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": f"In Beratung, noch kein Endbeschluss. {gremium_text}"}
|
||||||
|
|
||||||
# No beratungen at all
|
# No beratungen at all
|
||||||
return {"status": "eingereicht", "status_seit": ursprung_datum, "vertagungen_count": vertagungen}
|
return {"status": "eingereicht", "status_seit": ursprung_datum, "vertagungen_count": vertagungen,
|
||||||
|
"begruendung": "Eingereicht, noch keine Beratung erfolgt."}
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
@ -200,23 +231,65 @@ def _is_zurueckgezogen(beratungen: list[sqlite3.Row]) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_beschluss_details(beratungen: list[sqlite3.Row]) -> str:
|
||||||
|
"""Build a human-readable summary of the decisive Beratung."""
|
||||||
|
for b in beratungen:
|
||||||
|
ergebnis = (b["ergebnis"] or "").lower()
|
||||||
|
rolle = (b["rolle"] or "")
|
||||||
|
beschlusstext = (b["beschlusstext"] or "") if "beschlusstext" in b.keys() else ""
|
||||||
|
combined = f"{ergebnis} {beschlusstext}".lower()
|
||||||
|
datum = b["sitzung_datum"] or "?"
|
||||||
|
|
||||||
|
if any(kw in combined for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt",
|
||||||
|
"einstimmig", "abgelehnt", "kenntnis", "ohne beschlussfassung")):
|
||||||
|
ergebnis_display = b["ergebnis"] or "behandelt"
|
||||||
|
parts = [f'Ergebnis: \u201e{ergebnis_display}\u201c ({datum})']
|
||||||
|
if beschlusstext and len(beschlusstext) > 5:
|
||||||
|
snippet = beschlusstext[:150]
|
||||||
|
ellipsis = "\u2026" if len(beschlusstext) > 150 else ""
|
||||||
|
parts.append(f'Beschlusstext: \u201e{snippet}{ellipsis}\u201c')
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
if "entscheidung" in rolle.lower() and ergebnis:
|
||||||
|
return f'Ergebnis: \u201e{b["ergebnis"]}\u201c ({datum})'
|
||||||
|
|
||||||
|
if beschlusstext and len(beschlusstext) > 5 and beschlusstext.lower() not in ("0", "-", ""):
|
||||||
|
snippet = beschlusstext[:150]
|
||||||
|
ellipsis = "\u2026" if len(beschlusstext) > 150 else ""
|
||||||
|
return f'Beschlusstext vorhanden ({datum}): \u201e{snippet}{ellipsis}\u201c.'
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _get_beschluss(beratungen: list[sqlite3.Row]) -> str | None:
|
def _get_beschluss(beratungen: list[sqlite3.Row]) -> str | None:
|
||||||
"""Determine the final decision from Beratungen.
|
"""Determine the final decision from Beratungen.
|
||||||
|
|
||||||
|
Checks both ergebnis (OParl) and beschlusstext (scraped) fields.
|
||||||
Looks for Entscheidung-role beratungen with a result.
|
Looks for Entscheidung-role beratungen with a result.
|
||||||
"""
|
"""
|
||||||
for b in beratungen:
|
for b in beratungen:
|
||||||
ergebnis = (b["ergebnis"] or "").lower()
|
ergebnis = (b["ergebnis"] or "").lower()
|
||||||
rolle = (b["rolle"] or "").lower()
|
rolle = (b["rolle"] or "").lower()
|
||||||
|
beschlusstext = (b["beschlusstext"] or "").lower() if "beschlusstext" in b.keys() else ""
|
||||||
|
|
||||||
if "abgelehnt" in ergebnis:
|
# Combine ergebnis and beschlusstext for keyword search
|
||||||
|
combined = f"{ergebnis} {beschlusstext}"
|
||||||
|
|
||||||
|
if "abgelehnt" in combined:
|
||||||
return "abgelehnt"
|
return "abgelehnt"
|
||||||
if "verwiesen" in ergebnis:
|
if "verwiesen" in combined:
|
||||||
return "verwiesen"
|
return "verwiesen"
|
||||||
if any(kw in ergebnis for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt")):
|
if any(kw in combined for kw in ("angenommen", "empfohlen", "beschlossen", "zugestimmt", "einstimmig")):
|
||||||
return "angenommen"
|
return "angenommen"
|
||||||
|
if any(kw in combined for kw in ("zur kenntnis genommen", "kenntnisnahme", "kenntnis genommen")):
|
||||||
|
return "angenommen" # Kenntnisnahme = Vorgang abgeschlossen
|
||||||
|
if "ohne beschlussfassung" in combined:
|
||||||
|
return "angenommen" # Behandelt ohne formalen Beschluss = erledigt
|
||||||
# If rolle is Entscheidung and there's any ergebnis, it's likely a decision
|
# If rolle is Entscheidung and there's any ergebnis, it's likely a decision
|
||||||
if "entscheidung" in rolle and ergebnis and "vertagt" not in ergebnis:
|
if "entscheidung" in rolle and ergebnis and "vertagt" not in ergebnis:
|
||||||
return "angenommen"
|
return "angenommen"
|
||||||
|
# beschlusstext vorhanden und nicht leer/0/kurz = es gab eine Behandlung
|
||||||
|
if beschlusstext and len(beschlusstext) > 5 and beschlusstext not in ("0", "-", ""):
|
||||||
|
# Es wurde beraten und es gibt einen Text → vermutlich erledigt
|
||||||
|
return "angenommen"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""SQLite database connection management."""
|
"""SQLite database connection management."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -9,7 +10,7 @@ from pathlib import Path
|
|||||||
DB_PATH = Path(os.environ.get("DATABASE_PATH", Path(__file__).resolve().parents[4] / "data" / "tracker.db"))
|
DB_PATH = Path(os.environ.get("DATABASE_PATH", Path(__file__).resolve().parents[4] / "data" / "tracker.db"))
|
||||||
|
|
||||||
|
|
||||||
def get_connection(db_path: Path | str | None = None) -> sqlite3.Connection:
|
def get_connection(db_path=None) -> sqlite3.Connection:
|
||||||
path = str(db_path or DB_PATH)
|
path = str(db_path or DB_PATH)
|
||||||
conn = sqlite3.connect(path, detect_types=0)
|
conn = sqlite3.connect(path, detect_types=0)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""FastAPI application for Antragstracker Hagen."""
|
"""FastAPI application for Antragstracker Hagen."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from tracker.api.routes import abstimmungen, ketten, orte, stats, vorlagen
|
from tracker.api.routes import abstimmungen, bewertung, fraktionen, ketten, orte, stats, vorlagen
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Antragstracker Hagen",
|
title="Antragstracker Hagen",
|
||||||
@ -18,7 +20,7 @@ app = FastAPI(
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["GET"],
|
allow_methods=["GET", "POST"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,6 +29,8 @@ app.include_router(ketten.router, prefix="/api")
|
|||||||
app.include_router(stats.router, prefix="/api")
|
app.include_router(stats.router, prefix="/api")
|
||||||
app.include_router(abstimmungen.router, prefix="/api")
|
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(bewertung.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@ -36,8 +40,23 @@ def health():
|
|||||||
|
|
||||||
# Serve static frontend files in production
|
# Serve static frontend files in production
|
||||||
# Try multiple paths (Docker vs local dev)
|
# Try multiple paths (Docker vs local dev)
|
||||||
|
_static_dir: Path | None = None
|
||||||
for static_path in ["/app/static", Path(__file__).parent.parent.parent.parent / "static"]:
|
for static_path in ["/app/static", Path(__file__).parent.parent.parent.parent / "static"]:
|
||||||
static_dir = Path(static_path)
|
static_dir = Path(static_path)
|
||||||
if static_dir.exists() and (static_dir / "index.html").exists():
|
if static_dir.exists() and (static_dir / "index.html").exists():
|
||||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
_static_dir = static_dir
|
||||||
|
app.mount("/_app", StaticFiles(directory=str(static_dir / "_app")), name="static-app")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# SPA catch-all: serve index.html for any non-API, non-asset route
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def spa_fallback(request: Request, full_path: str):
|
||||||
|
if _static_dir is None:
|
||||||
|
return {"error": "static files not found"}
|
||||||
|
# Try to serve static file first
|
||||||
|
file_path = _static_dir / full_path
|
||||||
|
if file_path.is_file():
|
||||||
|
return FileResponse(file_path)
|
||||||
|
# Fallback to index.html for SPA routing
|
||||||
|
return FileResponse(_static_dir / "index.html")
|
||||||
|
|||||||
@ -95,6 +95,7 @@ export interface KetteDetail {
|
|||||||
status_seit: string | null;
|
status_seit: string | null;
|
||||||
letzte_aktivitaet: string | null;
|
letzte_aktivitaet: string | null;
|
||||||
vertagungen_count: number;
|
vertagungen_count: number;
|
||||||
|
begruendung: string | null;
|
||||||
glieder: KettenGliedOut[];
|
glieder: KettenGliedOut[];
|
||||||
antragsteller: ParteiOut[];
|
antragsteller: ParteiOut[];
|
||||||
graph: {
|
graph: {
|
||||||
@ -167,3 +168,44 @@ export const fetchKetten = (params: Record<string, string>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchKette = (id: number) => get<KetteDetail>(`/ketten/${id}`);
|
export const fetchKette = (id: number) => get<KetteDetail>(`/ketten/${id}`);
|
||||||
|
|
||||||
|
// Fraktionen
|
||||||
|
export interface FraktionDashboard {
|
||||||
|
partei: { id: number; kuerzel: string; name: string; farbe: string | null };
|
||||||
|
total_antraege: number;
|
||||||
|
bewertet: number;
|
||||||
|
umsetzung: { bewertung: string; anzahl: number; avg_score: number }[];
|
||||||
|
antraege: {
|
||||||
|
id: number; aktenzeichen: string | null; betreff: string | null;
|
||||||
|
typ: string | null; datum_eingang: string | null;
|
||||||
|
umsetzung_score: number | null; umsetzung_bewertung: string | null;
|
||||||
|
umsetzung_begruendung: string | null;
|
||||||
|
}[];
|
||||||
|
jahre: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-evaluation
|
||||||
|
async function post<T>(path: string, body: object): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reevalVorlage = (id: number, anmerkung: string) =>
|
||||||
|
post<{ job_id: string; status: string }>(`/bewertung/vorlagen/${id}`, { anmerkung });
|
||||||
|
|
||||||
|
export const reevalKette = (id: number, anmerkung: string) =>
|
||||||
|
post<{ job_id: string; status: string }>(`/bewertung/ketten/${id}`, { anmerkung });
|
||||||
|
|
||||||
|
export const fetchJobStatus = (jobId: string) =>
|
||||||
|
get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`);
|
||||||
|
|
||||||
|
export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
|
||||||
|
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
|
||||||
|
const params = jahr ? `?jahr=${jahr}` : '';
|
||||||
|
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${params}`);
|
||||||
|
};
|
||||||
|
|||||||
37
frontend/src/lib/components/UmsetzungBadge.svelte
Normal file
37
frontend/src/lib/components/UmsetzungBadge.svelte
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { umsetzungInfo, type UmsetzungKategorie } from '$lib/umsetzung';
|
||||||
|
|
||||||
|
let { kategorie, score = null, showTooltip = true }: {
|
||||||
|
kategorie: string | null;
|
||||||
|
score?: number | null;
|
||||||
|
showTooltip?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const info = $derived(umsetzungInfo(kategorie as UmsetzungKategorie));
|
||||||
|
let tooltipVisible = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="relative inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium cursor-help"
|
||||||
|
style="background-color: {info.farbe}20; color: {info.farbe}; border: 1px solid {info.farbe}40;"
|
||||||
|
onmouseenter={() => tooltipVisible = true}
|
||||||
|
onmouseleave={() => tooltipVisible = false}
|
||||||
|
>
|
||||||
|
<span class="mr-1">{info.icon}</span>
|
||||||
|
{info.label}
|
||||||
|
{#if score !== null && score !== undefined}
|
||||||
|
<span class="ml-1 opacity-60">({(score * 100).toFixed(0)}%)</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showTooltip && tooltipVisible}
|
||||||
|
<div class="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg pointer-events-none">
|
||||||
|
<div class="font-bold mb-1">{info.icon} {info.label}</div>
|
||||||
|
<div class="mb-2 leading-relaxed">{info.beschreibung}</div>
|
||||||
|
<div class="text-gray-400 italic">Beispiel: {info.beispiel}</div>
|
||||||
|
{#if info.score_range}
|
||||||
|
<div class="mt-1 text-gray-400">Score: {info.score_range[0]}–{info.score_range[1]}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-gray-900 rotate-45"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
84
frontend/src/lib/umsetzung.ts
Normal file
84
frontend/src/lib/umsetzung.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
export type UmsetzungKategorie = 'erfuellt' | 'teilweise' | 'abgewiegelt' | 'nebelkerze' | 'vertagt' | 'unklar';
|
||||||
|
|
||||||
|
interface KategorieInfo {
|
||||||
|
label: string;
|
||||||
|
farbe: string;
|
||||||
|
icon: string;
|
||||||
|
beschreibung: string;
|
||||||
|
beispiel: string;
|
||||||
|
score_range: [number, number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KATEGORIEN: Record<UmsetzungKategorie, KategorieInfo> = {
|
||||||
|
erfuellt: {
|
||||||
|
label: 'Erfüllt',
|
||||||
|
farbe: '#22c55e',
|
||||||
|
icon: '✅',
|
||||||
|
beschreibung: 'Forderung vollständig oder weitgehend umgesetzt. Konkreter Beschluss liegt vor, der die Kernpunkte des Antrags aufgreift.',
|
||||||
|
beispiel: 'Antrag auf Zuschuss → Zuschuss in beantragter Höhe einstimmig bewilligt.',
|
||||||
|
score_range: [0.8, 1.0],
|
||||||
|
},
|
||||||
|
teilweise: {
|
||||||
|
label: 'Teilweise',
|
||||||
|
farbe: '#eab308',
|
||||||
|
icon: '⚠️',
|
||||||
|
beschreibung: 'Kernpunkt wurde adressiert, aber mit Abstrichen. Verwaltung greift Teile auf, lässt andere fallen oder verwässert die Forderung.',
|
||||||
|
beispiel: 'Antrag auf Radweg → Prüfauftrag für Machbarkeitsstudie statt direktem Baubeschluss.',
|
||||||
|
score_range: [0.5, 0.7],
|
||||||
|
},
|
||||||
|
abgewiegelt: {
|
||||||
|
label: 'Abgewiegelt',
|
||||||
|
farbe: '#f97316',
|
||||||
|
icon: '🚫',
|
||||||
|
beschreibung: 'Verwaltung weicht aus. Kündigt Prüfung an, verweist auf Zuständigkeiten oder beantwortet die Frage nicht substantiell.',
|
||||||
|
beispiel: 'Anfrage zu Missständen → „Wird geprüft" ohne Zeitrahmen oder konkrete Zusagen.',
|
||||||
|
score_range: [0.2, 0.4],
|
||||||
|
},
|
||||||
|
nebelkerze: {
|
||||||
|
label: 'Nebelkerze',
|
||||||
|
farbe: '#ef4444',
|
||||||
|
icon: '💨',
|
||||||
|
beschreibung: 'Thema komplett ignoriert, Diskussion auf Nebenschauplatz verlagert oder Antrag ohne Behandlung von der Tagesordnung genommen.',
|
||||||
|
beispiel: 'Antrag zu Sauberkeit → „Ohne Beschlussfassung" oder keine Wortmeldung.',
|
||||||
|
score_range: [0.0, 0.1],
|
||||||
|
},
|
||||||
|
vertagt: {
|
||||||
|
label: 'Vertagt',
|
||||||
|
farbe: '#8b5cf6',
|
||||||
|
icon: '⏳',
|
||||||
|
beschreibung: 'Antrag explizit verschoben, ruhend gestellt oder in einen anderen Ausschuss verwiesen. Kein inhaltliches Ergebnis, aber formal nicht abgelehnt.',
|
||||||
|
beispiel: 'Antrag wird zur weiteren Beratung in den Fachausschuss überwiesen.',
|
||||||
|
score_range: null,
|
||||||
|
},
|
||||||
|
unklar: {
|
||||||
|
label: 'Unklar',
|
||||||
|
farbe: '#6b7280',
|
||||||
|
icon: '❓',
|
||||||
|
beschreibung: 'Beschlusslage nicht eindeutig zuordenbar. Möglicherweise fehlen Dokumente oder der Beschlusstext ist nicht aussagekräftig genug.',
|
||||||
|
beispiel: 'Nur „Kenntnisnahme" ohne weitere Erläuterung.',
|
||||||
|
score_range: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK: KategorieInfo = {
|
||||||
|
label: 'Unbekannt',
|
||||||
|
farbe: '#9ca3af',
|
||||||
|
icon: '❓',
|
||||||
|
beschreibung: 'Keine Bewertung vorhanden.',
|
||||||
|
beispiel: '',
|
||||||
|
score_range: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function umsetzungInfo(kategorie: UmsetzungKategorie | string | null): KategorieInfo {
|
||||||
|
if (!kategorie) return FALLBACK;
|
||||||
|
return KATEGORIEN[kategorie as UmsetzungKategorie] ?? FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreToKategorie(score: number): UmsetzungKategorie {
|
||||||
|
if (score >= 0.8) return 'erfuellt';
|
||||||
|
if (score >= 0.5) return 'teilweise';
|
||||||
|
if (score >= 0.2) return 'abgewiegelt';
|
||||||
|
return 'nebelkerze';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { KATEGORIEN };
|
||||||
@ -18,6 +18,7 @@
|
|||||||
<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>
|
||||||
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
|
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
|
||||||
|
<a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
37
frontend/src/routes/fraktionen/+page.svelte
Normal file
37
frontend/src/routes/fraktionen/+page.svelte
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fetchFraktionen } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let fraktionen = $state<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
fraktionen = await fetchFraktionen();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Fraktionen — Antragstracker Hagen</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto p-6">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Fraktionen</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-gray-500">Laden...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each fraktionen as f}
|
||||||
|
<a href="/fraktionen/{f.kuerzel}"
|
||||||
|
class="block p-4 rounded-lg border hover:shadow-md transition-shadow"
|
||||||
|
style="border-left: 4px solid {f.farbe || '#6b7280'}">
|
||||||
|
<div class="font-bold text-lg" style="color: {f.farbe || '#374151'}">{f.kuerzel}</div>
|
||||||
|
<div class="text-sm text-gray-600">{f.name}</div>
|
||||||
|
<div class="mt-2 text-2xl font-semibold">{f.anzahl}</div>
|
||||||
|
<div class="text-xs text-gray-500">Anträge & Anfragen</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
180
frontend/src/routes/fraktionen/[kuerzel]/+page.svelte
Normal file
180
frontend/src/routes/fraktionen/[kuerzel]/+page.svelte
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { fetchFraktionDashboard, type FraktionDashboard } from '$lib/api';
|
||||||
|
import UmsetzungBadge from '$lib/components/UmsetzungBadge.svelte';
|
||||||
|
import { KATEGORIEN } from '$lib/umsetzung';
|
||||||
|
import { formatDate } from '$lib/status';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let data = $state<FraktionDashboard | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedJahr = $state<string>('');
|
||||||
|
let filterKategorie = $state<string>('');
|
||||||
|
let kuerzel = $derived($page.params.kuerzel);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined);
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
// Reload when filters change
|
||||||
|
$effect(() => {
|
||||||
|
if (kuerzel) loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredAntraege = $derived(
|
||||||
|
data?.antraege.filter(a => !filterKategorie || a.umsetzung_bewertung === filterKategorie) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate percentages for bar chart
|
||||||
|
let umsetzungPct = $derived(() => {
|
||||||
|
if (!data || data.bewertet === 0) return [];
|
||||||
|
return data.umsetzung.map(u => ({
|
||||||
|
...u,
|
||||||
|
pct: ((u.anzahl / data!.bewertet) * 100).toFixed(1),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data?.partei?.name ?? kuerzel} — Antragstracker Hagen</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-6xl mx-auto p-6">
|
||||||
|
{#if loading && !data}
|
||||||
|
<div class="text-gray-500">Laden...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-red-600">Fehler: {error}</div>
|
||||||
|
{:else if data}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-3 h-12 rounded" style="background-color: {data.partei.farbe || '#6b7280'}"></div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{data.partei.name}</h1>
|
||||||
|
<span class="text-sm text-gray-500">{data.partei.kuerzel}</span>
|
||||||
|
</div>
|
||||||
|
<a href="/fraktionen" class="ml-auto text-sm text-blue-600 hover:underline">← Alle Fraktionen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-lg border p-4">
|
||||||
|
<div class="text-3xl font-bold">{data.total_antraege}</div>
|
||||||
|
<div class="text-sm text-gray-500">Anträge gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg border p-4">
|
||||||
|
<div class="text-3xl font-bold">{data.bewertet}</div>
|
||||||
|
<div class="text-sm text-gray-500">Mit Umsetzungsbewertung</div>
|
||||||
|
</div>
|
||||||
|
{#each data.umsetzung.filter(u => u.bewertung === 'erfuellt') as u}
|
||||||
|
<div class="bg-green-50 rounded-lg border border-green-200 p-4">
|
||||||
|
<div class="text-3xl font-bold text-green-700">{u.anzahl}</div>
|
||||||
|
<div class="text-sm text-green-600">Erfüllt</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each data.umsetzung.filter(u => u.bewertung === 'nebelkerze') as u}
|
||||||
|
<div class="bg-red-50 rounded-lg border border-red-200 p-4">
|
||||||
|
<div class="text-3xl font-bold text-red-700">{u.anzahl}</div>
|
||||||
|
<div class="text-sm text-red-600">Nebelkerzen</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Umsetzungs-Übersicht (Horizontal Bar) -->
|
||||||
|
{#if data.bewertet > 0}
|
||||||
|
<div class="bg-white rounded-lg border p-6 mb-8">
|
||||||
|
<h2 class="font-bold mb-4">Umsetzungsquote</h2>
|
||||||
|
<div class="flex rounded-full overflow-hidden h-8 mb-4">
|
||||||
|
{#each data.umsetzung as u}
|
||||||
|
{@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]}
|
||||||
|
{@const pct = (u.anzahl / data.bewertet) * 100}
|
||||||
|
{#if info && pct > 0}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center text-xs font-medium text-white transition-all"
|
||||||
|
style="width: {pct}%; background-color: {info.farbe};"
|
||||||
|
title="{info.label}: {u.anzahl} ({pct.toFixed(1)}%)"
|
||||||
|
>
|
||||||
|
{#if pct > 8}{info.label} {pct.toFixed(0)}%{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm">
|
||||||
|
{#each data.umsetzung as u}
|
||||||
|
{@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]}
|
||||||
|
{#if info}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 hover:opacity-70 transition-opacity"
|
||||||
|
class:opacity-40={filterKategorie && filterKategorie !== u.bewertung}
|
||||||
|
onclick={() => filterKategorie = filterKategorie === u.bewertung ? '' : u.bewertung}
|
||||||
|
>
|
||||||
|
<span class="w-3 h-3 rounded-full inline-block" style="background-color: {info.farbe}"></span>
|
||||||
|
{info.label}: {u.anzahl}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex gap-4 mb-4">
|
||||||
|
<select bind:value={selectedJahr} onchange={loadData} class="border rounded px-3 py-1.5 text-sm">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
{#each data.jahre as j}
|
||||||
|
<option value={j}>{j}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if filterKategorie}
|
||||||
|
<button onclick={() => filterKategorie = ''} class="text-sm text-blue-600 hover:underline">
|
||||||
|
Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm text-gray-500 ml-auto">{filteredAntraege.length} Anträge</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anträge-Liste -->
|
||||||
|
<div class="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Aktenzeichen</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Betreff</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Datum</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Umsetzung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y">
|
||||||
|
{#each filteredAntraege as a}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="/vorlagen/{a.id}" class="text-blue-600 hover:underline font-mono text-xs">
|
||||||
|
{a.aktenzeichen}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 max-w-md truncate">{a.betreff}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{formatDate(a.datum_eingang)}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{#if a.umsetzung_bewertung}
|
||||||
|
<UmsetzungBadge kategorie={a.umsetzung_bewertung} score={a.umsetzung_score} />
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400 text-xs">–</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { fetchKetten, type KetteKurz, type Paginated } from '$lib/api';
|
import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api';
|
||||||
import { formatDate } from '$lib/status';
|
import { formatDate } from '$lib/status';
|
||||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||||
|
|
||||||
@ -14,13 +14,16 @@
|
|||||||
let filterStatus = $state('');
|
let filterStatus = $state('');
|
||||||
let filterTyp = $state('');
|
let filterTyp = $state('');
|
||||||
let filterSuche = $state('');
|
let filterSuche = $state('');
|
||||||
|
let filterPartei = $state('');
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
|
let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
|
||||||
|
|
||||||
function syncFromUrl() {
|
function syncFromUrl() {
|
||||||
const p = new URL(window.location.href).searchParams;
|
const p = new URL(window.location.href).searchParams;
|
||||||
filterStatus = p.get('status') || '';
|
filterStatus = p.get('status') || '';
|
||||||
filterTyp = p.get('typ') || '';
|
filterTyp = p.get('typ') || '';
|
||||||
filterSuche = p.get('suche') || '';
|
filterSuche = p.get('suche') || '';
|
||||||
|
filterPartei = p.get('partei') || '';
|
||||||
currentPage = parseInt(p.get('page') || '1');
|
currentPage = parseInt(p.get('page') || '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +34,7 @@
|
|||||||
if (filterStatus) params.status = filterStatus;
|
if (filterStatus) params.status = filterStatus;
|
||||||
if (filterTyp) params.typ = filterTyp;
|
if (filterTyp) params.typ = filterTyp;
|
||||||
if (filterSuche) params.suche = filterSuche;
|
if (filterSuche) params.suche = filterSuche;
|
||||||
|
if (filterPartei) params.partei = filterPartei;
|
||||||
data = await fetchKetten(params);
|
data = await fetchKetten(params);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler';
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
@ -44,6 +48,7 @@
|
|||||||
if (filterStatus) params.set('status', filterStatus);
|
if (filterStatus) params.set('status', filterStatus);
|
||||||
if (filterTyp) params.set('typ', filterTyp);
|
if (filterTyp) params.set('typ', filterTyp);
|
||||||
if (filterSuche) params.set('suche', filterSuche);
|
if (filterSuche) params.set('suche', filterSuche);
|
||||||
|
if (filterPartei) params.set('partei', filterPartei);
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
goto(`/ketten?${params.toString()}`, { replaceState: true });
|
goto(`/ketten?${params.toString()}`, { replaceState: true });
|
||||||
@ -58,7 +63,8 @@
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
parteien = await fetchFraktionen();
|
||||||
syncFromUrl();
|
syncFromUrl();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@ -109,6 +115,16 @@
|
|||||||
<option value="anfrage">Anfrage</option>
|
<option value="anfrage">Anfrage</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="partei" class="block text-xs font-medium text-gray-500 mb-1">Partei</label>
|
||||||
|
<select id="partei" bind:value={filterPartei} onchange={applyFilters}
|
||||||
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each parteien as p}
|
||||||
|
<option value={p.kuerzel}>{p.kuerzel} ({p.anzahl})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button onclick={applyFilters}
|
<button onclick={applyFilters}
|
||||||
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
|
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
|
||||||
Filtern
|
Filtern
|
||||||
|
|||||||
@ -1,13 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { fetchKette, type KetteDetail } from '$lib/api';
|
import { fetchKette, reevalKette, fetchJobStatus, type KetteDetail } from '$lib/api';
|
||||||
import { statusInfo, typLabel, formatDate } from '$lib/status';
|
import { statusInfo, typLabel, formatDate } from '$lib/status';
|
||||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||||
import Perlenschnur from '$lib/components/Perlenschnur.svelte';
|
import Perlenschnur from '$lib/components/Perlenschnur.svelte';
|
||||||
|
|
||||||
let kette: KetteDetail | null = $state(null);
|
let kette: KetteDetail | null = $state(null);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
|
let showReeval = $state(false);
|
||||||
|
let reevalAnmerkung = $state('');
|
||||||
|
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
||||||
|
let reevalError = $state('');
|
||||||
|
|
||||||
|
async function triggerReeval() {
|
||||||
|
if (!kette) return;
|
||||||
|
reevalStatus = 'running';
|
||||||
|
reevalError = '';
|
||||||
|
try {
|
||||||
|
const { job_id } = await reevalKette(kette.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';
|
||||||
|
kette = await fetchKette(kette!.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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@ -75,6 +109,53 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Begründung -->
|
||||||
|
{#if kette.begruendung}
|
||||||
|
<div class="mt-4 p-3 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<span class="text-xs font-medium text-gray-500 uppercase">Klassifikation</span>
|
||||||
|
<p class="text-sm text-gray-700 mt-1">{kette.begruendung}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Neu bewerten -->
|
||||||
|
<div class="mt-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> Kette neu bewerten lassen
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="p-4 rounded-lg border border-green-200 bg-green-50">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-2">KI-Neubewertung der Kette</h3>
|
||||||
|
<textarea bind:value={reevalAnmerkung} placeholder="Anmerkungen für die KI (optional) — z.B. 'Der Antrag wurde mündlich im Ausschuss behandelt' oder 'Bitte Wortprotokoll stärker gewichten'"
|
||||||
|
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 focus:border-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 disabled:cursor-wait 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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Perlenschnur Timeline -->
|
<!-- Perlenschnur Timeline -->
|
||||||
|
|||||||
@ -1,21 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { fetchVorlagen, type VorlageKurz, type Paginated } from '$lib/api';
|
import { fetchVorlagen, fetchFraktionen, type VorlageKurz, type Paginated } from '$lib/api';
|
||||||
import { formatDate } from '$lib/status';
|
import { formatDate } from '$lib/status';
|
||||||
|
|
||||||
let data: Paginated<VorlageKurz> | null = $state(null);
|
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
|
||||||
|
let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
|
||||||
|
|
||||||
|
function highlight(text: string | null, query: string): string {
|
||||||
|
if (!text || !query) return text || '-';
|
||||||
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark class="bg-yellow-200 rounded px-0.5">$1</mark>');
|
||||||
|
}
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
let filterTyp = $state('');
|
let filterTyp = $state('');
|
||||||
let filterSuche = $state('');
|
let filterSuche = $state('');
|
||||||
|
let filterPartei = $state('');
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
|
|
||||||
function syncFromUrl() {
|
function syncFromUrl() {
|
||||||
const p = new URL(window.location.href).searchParams;
|
const p = new URL(window.location.href).searchParams;
|
||||||
filterTyp = p.get('typ') || '';
|
filterTyp = p.get('typ') || '';
|
||||||
filterSuche = p.get('suche') || '';
|
filterSuche = p.get('suche') || '';
|
||||||
|
filterPartei = p.get('partei') || '';
|
||||||
currentPage = parseInt(p.get('page') || '1');
|
currentPage = parseInt(p.get('page') || '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +34,7 @@
|
|||||||
const params: Record<string, string> = { page: String(currentPage), page_size: '50' };
|
const params: Record<string, string> = { page: String(currentPage), page_size: '50' };
|
||||||
if (filterTyp) params.typ = filterTyp;
|
if (filterTyp) params.typ = filterTyp;
|
||||||
if (filterSuche) params.suche = filterSuche;
|
if (filterSuche) params.suche = filterSuche;
|
||||||
|
if (filterPartei) params.partei = filterPartei;
|
||||||
data = await fetchVorlagen(params);
|
data = await fetchVorlagen(params);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler';
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
@ -37,6 +47,7 @@
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filterTyp) params.set('typ', filterTyp);
|
if (filterTyp) params.set('typ', filterTyp);
|
||||||
if (filterSuche) params.set('suche', filterSuche);
|
if (filterSuche) params.set('suche', filterSuche);
|
||||||
|
if (filterPartei) params.set('partei', filterPartei);
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
goto(`/vorlagen?${params.toString()}`, { replaceState: true });
|
goto(`/vorlagen?${params.toString()}`, { replaceState: true });
|
||||||
@ -51,7 +62,8 @@
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
parteien = await fetchFraktionen();
|
||||||
syncFromUrl();
|
syncFromUrl();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@ -71,7 +83,7 @@
|
|||||||
<div class="flex flex-wrap gap-3 items-end">
|
<div class="flex flex-wrap gap-3 items-end">
|
||||||
<div>
|
<div>
|
||||||
<label for="suche" class="block text-xs font-medium text-gray-500 mb-1">Suche</label>
|
<label for="suche" class="block text-xs font-medium text-gray-500 mb-1">Suche</label>
|
||||||
<input id="suche" type="text" bind:value={filterSuche} placeholder="Betreff oder Aktenzeichen..."
|
<input id="suche" type="text" bind:value={filterSuche} placeholder="Volltextsuche..."
|
||||||
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') applyFilters(); }} />
|
onkeydown={(e) => { if (e.key === 'Enter') applyFilters(); }} />
|
||||||
</div>
|
</div>
|
||||||
@ -86,6 +98,16 @@
|
|||||||
<option value="bericht">Bericht</option>
|
<option value="bericht">Bericht</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="partei" class="block text-xs font-medium text-gray-500 mb-1">Partei</label>
|
||||||
|
<select id="partei" bind:value={filterPartei} onchange={applyFilters}
|
||||||
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each parteien as p}
|
||||||
|
<option value={p.kuerzel}>{p.kuerzel} ({p.anzahl})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button onclick={applyFilters}
|
<button onclick={applyFilters}
|
||||||
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
|
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
|
||||||
Filtern
|
Filtern
|
||||||
@ -108,6 +130,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktenzeichen</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktenzeichen</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Betreff</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Betreff</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Partei</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -117,10 +140,36 @@
|
|||||||
<tr class="hover:bg-gray-50 transition-colors cursor-pointer" onclick={() => goto(`/vorlagen/${v.id}`)}>
|
<tr class="hover:bg-gray-50 transition-colors cursor-pointer" onclick={() => goto(`/vorlagen/${v.id}`)}>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="/vorlagen/{v.id}" class="font-mono text-sm font-medium text-green-700 hover:underline">
|
<a href="/vorlagen/{v.id}" class="font-mono text-sm font-medium text-green-700 hover:underline">
|
||||||
{v.aktenzeichen || `#${v.id}`}
|
{#if filterSuche}
|
||||||
|
{@html highlight(v.aktenzeichen || `#${v.id}`, filterSuche)}
|
||||||
|
{:else}
|
||||||
|
{v.aktenzeichen || `#${v.id}`}
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-700 max-w-lg truncate">{v.betreff || '-'}</td>
|
<td class="px-4 py-3 text-sm text-gray-700 max-w-lg truncate">
|
||||||
|
{#if filterSuche}
|
||||||
|
{@html highlight(v.betreff, filterSuche)}
|
||||||
|
{:else}
|
||||||
|
{v.betreff || '-'}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{#if v.antragsteller?.length}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each v.antragsteller as a}
|
||||||
|
<a href="/vorlagen?partei={a.kuerzel}"
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium hover:opacity-80"
|
||||||
|
style="background-color: {a.farbe || '#6b7280'}20; color: {a.farbe || '#6b7280'}; border: 1px solid {a.farbe || '#6b7280'}40;"
|
||||||
|
onclick={(e) => e.stopPropagation()}>
|
||||||
|
{a.kuerzel}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400 text-xs">–</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 capitalize">{v.typ || '-'}</td>
|
<td class="px-4 py-3 text-sm text-gray-600 capitalize">{v.typ || '-'}</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-500">{formatDate(v.datum_eingang)}</td>
|
<td class="px-4 py-3 text-sm text-gray-500">{formatDate(v.datum_eingang)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,17 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { fetchVorlage, type VorlageDetail } from '$lib/api';
|
import { fetchVorlage, fetchKette, reevalVorlage, fetchJobStatus, type VorlageDetail } from '$lib/api';
|
||||||
import { typLabel, formatDate } from '$lib/status';
|
import { typLabel, formatDate } from '$lib/status';
|
||||||
|
|
||||||
let vorlage: VorlageDetail | null = $state(null);
|
let vorlage: VorlageDetail | null = $state(null);
|
||||||
|
let ketteInfo: { status: string; begruendung: string | null; id: number } | null = $state(null);
|
||||||
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 reevalAnmerkung = $state('');
|
||||||
|
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
||||||
|
let reevalError = $state('');
|
||||||
|
|
||||||
|
async function triggerReeval() {
|
||||||
|
if (!vorlage) return;
|
||||||
|
reevalStatus = 'running';
|
||||||
|
reevalError = '';
|
||||||
|
try {
|
||||||
|
const { job_id } = await reevalVorlage(vorlage.id, reevalAnmerkung);
|
||||||
|
// Poll for completion
|
||||||
|
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';
|
||||||
|
// Reload vorlage
|
||||||
|
vorlage = await fetchVorlage(vorlage!.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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt($page.params.id);
|
const id = parseInt($page.params.id);
|
||||||
vorlage = await fetchVorlage(id);
|
vorlage = await fetchVorlage(id);
|
||||||
|
if (vorlage?.kette_id) {
|
||||||
|
const kette = await fetchKette(vorlage.kette_id);
|
||||||
|
ketteInfo = { status: kette.status, begruendung: kette.begruendung, id: kette.id };
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler';
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
}
|
}
|
||||||
@ -135,6 +176,103 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Ketten-Status & Klassifikation -->
|
||||||
|
{#if ketteInfo}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<span>🏷️</span> Klassifikation
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold
|
||||||
|
{ketteInfo.status === 'umgesetzt' ? 'bg-green-100 text-green-800' :
|
||||||
|
ketteInfo.status === 'beschlossen' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
ketteInfo.status === 'teilweise_umgesetzt' ? 'bg-amber-100 text-amber-800' :
|
||||||
|
ketteInfo.status === 'abgelehnt' || ketteInfo.status === 'abgewiegelt' ? 'bg-red-100 text-red-800' :
|
||||||
|
ketteInfo.status === 'versandet' ? 'bg-gray-100 text-gray-800' :
|
||||||
|
ketteInfo.status === 'in_beratung' ? 'bg-purple-100 text-purple-800' :
|
||||||
|
'bg-gray-100 text-gray-700'}">
|
||||||
|
{ketteInfo.status.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<a href="/ketten/{ketteInfo.id}" class="text-sm text-green-600 hover:underline">→ Kette anzeigen</a>
|
||||||
|
</div>
|
||||||
|
{#if ketteInfo.begruendung}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed">{ketteInfo.begruendung}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Umsetzungsbewertung -->
|
||||||
|
{#if vorlage.umsetzungsbewertungen?.length}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<span>📊</span> Umsetzungsbewertung
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each vorlage.umsetzungsbewertungen as ub, i}
|
||||||
|
<div class="p-4 rounded-lg border {ub.score >= 0.7 ? 'border-green-200 bg-green-50' : ub.score >= 0.4 ? 'border-amber-200 bg-amber-50' : 'border-red-200 bg-red-50'}">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold
|
||||||
|
{ub.score >= 0.7 ? 'bg-green-200 text-green-800' : ub.score >= 0.4 ? 'bg-amber-200 text-amber-800' : 'bg-red-200 text-red-800'}">
|
||||||
|
{Math.round((ub.score || 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium {ub.score >= 0.7 ? 'text-green-800' : ub.score >= 0.4 ? 'text-amber-800' : 'text-red-800'}">
|
||||||
|
{ub.score >= 0.7 ? 'Weitgehend umgesetzt' : ub.score >= 0.4 ? 'Teilweise umgesetzt' : 'Kaum umgesetzt'}
|
||||||
|
</span>
|
||||||
|
{#if vorlage.umsetzungsbewertungen.length > 1}
|
||||||
|
<span class="text-xs text-gray-500 ml-2">Bewertung {i + 1}/{vorlage.umsetzungsbewertungen.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if ub.begruendung}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed">{ub.begruendung}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Neu bewerten -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm 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) — z.B. 'Bitte den Beschlusstext genauer auswerten' oder 'Antrag wurde im Ausschuss XY besprochen'"
|
||||||
|
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 focus:border-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 disabled:cursor-wait 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 -->
|
<!-- Volltext -->
|
||||||
{#if vorlage.volltext_clean}
|
{#if vorlage.volltext_clean}
|
||||||
<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">
|
||||||
|
|||||||
78
scripts/deploy-db.sh
Executable file
78
scripts/deploy-db.sh
Executable file
@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy DB zum VServer
|
||||||
|
# Usage: ./scripts/deploy-db.sh [--dry-run]
|
||||||
|
set -e
|
||||||
|
|
||||||
|
LOCAL_DB="data/tracker_remote.db"
|
||||||
|
REMOTE_DB="/opt/antragstracker/data/tracker.db"
|
||||||
|
REMOTE_HOST="vserver"
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
if [ "$1" = "--dry-run" ]; then
|
||||||
|
DRY_RUN=true
|
||||||
|
echo "🔍 DRY RUN — keine Änderungen"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "=== DB Deploy ==="
|
||||||
|
echo "Lokale DB: $(du -sh $LOCAL_DB | cut -f1)"
|
||||||
|
|
||||||
|
# 0. Schema-Validierung
|
||||||
|
echo "0. Schema prüfen..."
|
||||||
|
EXPECTED_TABLES="vorlagen beratungen ketten ketten_glieder ki_bewertungen parteien antragsteller gremien orte referenzen"
|
||||||
|
MISSING=""
|
||||||
|
for tbl in $EXPECTED_TABLES; do
|
||||||
|
EXISTS=$(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$tbl'" 2>/dev/null)
|
||||||
|
if [ "$EXISTS" != "1" ]; then
|
||||||
|
MISSING="$MISSING $tbl"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$MISSING" ]; then
|
||||||
|
echo "❌ FEHLER: Fehlende Tabellen:$MISSING"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ Alle ${#EXPECTED_TABLES[@]} Tabellen vorhanden"
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
echo ""
|
||||||
|
echo " Vorlagen: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM vorlagen')"
|
||||||
|
echo " Ketten: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ketten')"
|
||||||
|
echo " KI-Bew.: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ki_bewertungen')"
|
||||||
|
echo " Matches: $(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM ki_bewertungen WHERE typ='umsetzung_match'")"
|
||||||
|
echo " Orte: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM orte WHERE lat IS NOT NULL')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Flush WAL journal
|
||||||
|
echo " Flushing WAL..."
|
||||||
|
sqlite3 "$LOCAL_DB" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo "🔍 Würde hochladen: $(du -sh $LOCAL_DB | cut -f1) nach $REMOTE_HOST:$REMOTE_DB"
|
||||||
|
echo "🔍 Prod-DB Größe: $(ssh $REMOTE_HOST "du -sh $REMOTE_DB 2>/dev/null | cut -f1" || echo "N/A")"
|
||||||
|
echo ""
|
||||||
|
echo "DRY RUN fertig. Ohne --dry-run ausführen zum Deployen."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Backup auf VServer
|
||||||
|
BACKUP_NAME="${REMOTE_DB}.bak-$(date +%Y%m%d-%H%M)"
|
||||||
|
echo "1. Backup Prod-DB..."
|
||||||
|
ssh $REMOTE_HOST "cp $REMOTE_DB $BACKUP_NAME"
|
||||||
|
echo " → $BACKUP_NAME"
|
||||||
|
|
||||||
|
# 2. Upload
|
||||||
|
echo "2. Upload..."
|
||||||
|
scp "$LOCAL_DB" "$REMOTE_HOST:$REMOTE_DB"
|
||||||
|
|
||||||
|
# 3. Restart
|
||||||
|
echo "3. Restart Backend..."
|
||||||
|
ssh $REMOTE_HOST 'cd /opt/antragstracker && docker compose restart antragstracker' 2>&1 | grep -v "level=warning"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Deploy fertig"
|
||||||
|
echo "Backup: $BACKUP_NAME"
|
||||||
|
echo "Rollback: ssh $REMOTE_HOST 'cp $BACKUP_NAME $REMOTE_DB && cd /opt/antragstracker && docker compose restart antragstracker'"
|
||||||
55
scripts/fix_missing_summaries.py
Normal file
55
scripts/fix_missing_summaries.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fix fehlende KI-Zusammenfassungen mit Claude Haiku."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DB = Path(__file__).resolve().parent.parent / "data" / "tracker_remote.db"
|
||||||
|
API_KEY = os.popen("security find-generic-password -s anthropic-api -w 2>/dev/null").read().strip()
|
||||||
|
|
||||||
|
PROMPT = """Fasse diese kommunalpolitische Vorlage aus Hagen in 2-3 Sätzen zusammen.
|
||||||
|
Fokus: Was wird gefordert/vorgeschlagen? Von wem? Was ist das Ziel?
|
||||||
|
|
||||||
|
Vorlage {az}:
|
||||||
|
{text}"""
|
||||||
|
|
||||||
|
def summarize(az, text):
|
||||||
|
resp = httpx.post("https://api.anthropic.com/v1/messages",
|
||||||
|
headers={"x-api-key": API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
||||||
|
json={"model": "claude-haiku-4-5-20241022", "max_tokens": 300, "messages": [
|
||||||
|
{"role": "user", "content": PROMPT.format(az=az, text=text[:8000])}
|
||||||
|
]}, timeout=30)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()["content"][0]["text"]
|
||||||
|
print(f" ERROR {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(DB))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
missing = conn.execute("""
|
||||||
|
SELECT v.id, v.aktenzeichen, v.volltext_clean
|
||||||
|
FROM vorlagen v
|
||||||
|
WHERE v.volltext_clean IS NOT NULL AND v.volltext_clean != ''
|
||||||
|
AND v.id NOT IN (SELECT vorlage_id FROM ki_bewertungen WHERE typ='zusammenfassung')
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
print(f"Fehlende Zusammenfassungen: {len(missing)}")
|
||||||
|
for row in missing:
|
||||||
|
print(f" {row['aktenzeichen']}...", end=" ")
|
||||||
|
summary = summarize(row["aktenzeichen"], row["volltext_clean"])
|
||||||
|
if summary:
|
||||||
|
conn.execute("""INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, modell, prompt_version)
|
||||||
|
VALUES (?, 'zusammenfassung', 0, ?, 'claude-haiku-4-5', 'v2-fix')""",
|
||||||
|
(row["id"], summary))
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ {summary[:60]}")
|
||||||
|
else:
|
||||||
|
print("✗")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"\nFertig.")
|
||||||
|
conn.close()
|
||||||
@ -38,7 +38,7 @@ def get_db():
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def geocode_ort(client: httpx.Client, name: str) -> tuple[float, float] | None:
|
def geocode_ort(client: httpx.Client, name: str) -> "tuple[float, float] | None":
|
||||||
"""Geocodiert einen Ort in Hagen."""
|
"""Geocodiert einen Ort in Hagen."""
|
||||||
# Verschiedene Suchvarianten
|
# Verschiedene Suchvarianten
|
||||||
queries = [
|
queries = [
|
||||||
|
|||||||
93
scripts/geocode_pending.py
Normal file
93
scripts/geocode_pending.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Geocodiert pending Orte via Nominatim (1 req/s)."""
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DB = Path(__file__).resolve().parent.parent / "data" / "tracker_remote.db"
|
||||||
|
NOMINATIM = "https://nominatim.openstreetmap.org/search"
|
||||||
|
UA = "Antragstracker-Hagen/1.0 (tobias.roedel@econgood.org)"
|
||||||
|
HAGEN_BBOX = "7.35,51.30,7.65,51.45"
|
||||||
|
|
||||||
|
|
||||||
|
def geocode(client: httpx.Client, name: str) -> Optional[Tuple[float, float]]:
|
||||||
|
# Clean name: remove trailing "Hagen" to avoid duplication
|
||||||
|
clean = name.strip().rstrip(",").strip()
|
||||||
|
if clean.lower().endswith(" hagen"):
|
||||||
|
clean = clean[:-6].strip().rstrip(",").strip()
|
||||||
|
|
||||||
|
# Try multiple query variants, progressively less strict
|
||||||
|
queries = [
|
||||||
|
(f"{clean}, Hagen, Germany", True), # bounded to Hagen
|
||||||
|
(f"{clean}, Hagen, NRW", False), # unbounded fallback
|
||||||
|
(f"{name}, Germany", False), # original name
|
||||||
|
]
|
||||||
|
for q, bounded in queries:
|
||||||
|
try:
|
||||||
|
params = {"q": q, "format": "json", "limit": 1}
|
||||||
|
if bounded:
|
||||||
|
params["viewbox"] = HAGEN_BBOX
|
||||||
|
params["bounded"] = 1
|
||||||
|
r = client.get(NOMINATIM, params=params,
|
||||||
|
headers={"User-Agent": UA}, timeout=10)
|
||||||
|
if r.status_code == 200 and r.json():
|
||||||
|
d = r.json()[0]
|
||||||
|
lat, lon = float(d["lat"]), float(d["lon"])
|
||||||
|
# Sanity check: roughly in Hagen area
|
||||||
|
if 51.25 <= lat <= 51.50 and 7.30 <= lon <= 7.70:
|
||||||
|
return lat, lon
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(3.0) # Conservative: Nominatim blocks aggressively
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--limit", type=int, default=500)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(DB))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
pending = conn.execute(
|
||||||
|
"SELECT id, name FROM orte WHERE geocode_status='pending' ORDER BY vorlage_count DESC LIMIT ?",
|
||||||
|
(args.limit,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_pending = conn.execute("SELECT COUNT(*) FROM orte WHERE geocode_status='pending'").fetchone()[0]
|
||||||
|
print(f"Pending: {len(pending)} (von {total_pending} total)")
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
client = httpx.Client()
|
||||||
|
|
||||||
|
for i, row in enumerate(pending):
|
||||||
|
coords = geocode(client, row["name"])
|
||||||
|
if coords:
|
||||||
|
conn.execute("UPDATE orte SET lat=?, lon=?, geocode_status='success' WHERE id=?",
|
||||||
|
(coords[0], coords[1], row["id"]))
|
||||||
|
success += 1
|
||||||
|
if success % 20 == 0:
|
||||||
|
print(f" [{i+1}/{len(pending)}] ✓ {success} geocoded, ✗ {failed} failed")
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE orte SET geocode_status='failed' WHERE id=?", (row["id"],))
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
total_geo = success # just this run
|
||||||
|
print(f"\n✓ {success} | ✗ {failed} | Gesamt: {success + failed}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
281
scripts/ketten_match.py
Normal file
281
scripts/ketten_match.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Ketten-Match: Vergleicht Ursprungsforderung mit Blatt-Antwort via Gemini Flash.
|
||||||
|
Bewertet ob Anträge tatsächlich erfüllt wurden.
|
||||||
|
Überwacht Free Tier Limits (5h-Fenster, pausiert bei 85%).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = PROJECT_ROOT / "data" / "tracker_remote.db"
|
||||||
|
LOG_FILE = PROJECT_ROOT / "data" / "ketten_match.log"
|
||||||
|
|
||||||
|
# Gemini API
|
||||||
|
GEMINI_KEY = os.environ.get("GEMINI_API_KEY") or os.popen("security find-generic-password -s gemini-api -w 2>/dev/null").read().strip()
|
||||||
|
GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={GEMINI_KEY}"
|
||||||
|
MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
MAX_REQUESTS_PER_5H = 1500
|
||||||
|
PAUSE_THRESHOLD = 0.85
|
||||||
|
WINDOW_SECONDS = 5 * 3600
|
||||||
|
|
||||||
|
PROMPT = """Du bist ein Analyst für kommunalpolitische Vorgänge in Hagen.
|
||||||
|
|
||||||
|
Vergleiche den URSPRÜNGLICHEN ANTRAG/ANFRAGE mit der ANTWORT/BESCHLUSS.
|
||||||
|
Bewerte ob die ursprüngliche Forderung tatsächlich erfüllt wurde.
|
||||||
|
|
||||||
|
=== URSPRÜNGLICHE FORDERUNG ===
|
||||||
|
Aktenzeichen: {az_ursprung}
|
||||||
|
Typ: {typ_ursprung}
|
||||||
|
{ki_zusammenfassung}
|
||||||
|
|
||||||
|
Volltext (Auszug):
|
||||||
|
{volltext_ursprung}
|
||||||
|
|
||||||
|
=== ANTWORT / BESCHLUSS ===
|
||||||
|
Aktenzeichen: {az_blatt}
|
||||||
|
Typ: {typ_blatt}
|
||||||
|
Beschlussart: {beschlussart}
|
||||||
|
|
||||||
|
Beschlusstext:
|
||||||
|
{beschlusstext}
|
||||||
|
|
||||||
|
Wortprotokoll:
|
||||||
|
{wortprotokoll}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bewerte NUR als JSON:
|
||||||
|
{{
|
||||||
|
"score": <0.0-1.0>,
|
||||||
|
"bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar",
|
||||||
|
"begruendung": "1-2 Sätze warum",
|
||||||
|
"kernpunkt_erfuellt": true/false,
|
||||||
|
"details": "Was konkret beschlossen/abgelehnt wurde"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Bewertungsskala:
|
||||||
|
- 1.0: Forderung vollständig erfüllt, konkreter Beschluss
|
||||||
|
- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen
|
||||||
|
- 0.4-0.6: Teilweise erfüllt oder auf den Weg gebracht
|
||||||
|
- 0.2-0.3: Abgewiegelt — Verwaltung weicht aus, kündigt nur "Prüfung" an
|
||||||
|
- 0.0-0.1: Nebelkerze — Thema gewechselt oder komplett ignoriert"""
|
||||||
|
|
||||||
|
def _score_to_bewertung(score: float) -> str:
|
||||||
|
if score >= 0.8: return "erfuellt"
|
||||||
|
if score >= 0.5: return "teilweise"
|
||||||
|
if score >= 0.2: return "abgewiegelt"
|
||||||
|
return "nebelkerze"
|
||||||
|
|
||||||
|
db_lock = Lock()
|
||||||
|
log_lock = Lock()
|
||||||
|
stats = {"success": 0, "failed": 0, "throttled": 0, "requests": 0, "window_start": time.time()}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
ts = time.strftime("%H:%M:%S")
|
||||||
|
line = f"[{ts}] {msg}"
|
||||||
|
print(line)
|
||||||
|
with log_lock:
|
||||||
|
with open(LOG_FILE, "a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit():
|
||||||
|
elapsed = time.time() - stats["window_start"]
|
||||||
|
if elapsed > WINDOW_SECONDS:
|
||||||
|
stats["requests"] = 0
|
||||||
|
stats["window_start"] = time.time()
|
||||||
|
return
|
||||||
|
usage_pct = stats["requests"] / MAX_REQUESTS_PER_5H
|
||||||
|
if usage_pct >= PAUSE_THRESHOLD:
|
||||||
|
remaining = WINDOW_SECONDS - elapsed
|
||||||
|
log(f"⏸️ 85% Limit ({stats['requests']}/{MAX_REQUESTS_PER_5H}). Pause {remaining/60:.0f} Min.")
|
||||||
|
time.sleep(remaining + 10)
|
||||||
|
stats["requests"] = 0
|
||||||
|
stats["window_start"] = time.time()
|
||||||
|
log("▶️ Weiter")
|
||||||
|
|
||||||
|
|
||||||
|
def call_gemini(prompt: str, max_retries: int = 5) -> Optional[dict]:
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
check_rate_limit()
|
||||||
|
stats["requests"] += 1
|
||||||
|
try:
|
||||||
|
resp = httpx.post(GEMINI_URL, json={
|
||||||
|
"contents": [{"parts": [{"text": prompt}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.1,
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"maxOutputTokens": 2048,
|
||||||
|
"thinkingConfig": {"thinkingBudget": 0},
|
||||||
|
}
|
||||||
|
}, timeout=60)
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
stats["throttled"] += 1
|
||||||
|
time.sleep(min(10, attempt + 2))
|
||||||
|
continue
|
||||||
|
if resp.status_code != 200:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = resp.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||||||
|
# Clean markdown code fences
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r'^```\w*\n?', '', text)
|
||||||
|
text = re.sub(r'\n?```$', '', text)
|
||||||
|
# Try full JSON parse (multiple attempts)
|
||||||
|
for parse_text in [text, text.replace('\n', ' '), re.sub(r'[\x00-\x1f]', ' ', text)]:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(parse_text)
|
||||||
|
if isinstance(parsed, dict) and "score" in parsed:
|
||||||
|
parsed["score"] = float(parsed["score"])
|
||||||
|
if not parsed.get("bewertung"):
|
||||||
|
parsed["bewertung"] = _score_to_bewertung(parsed["score"])
|
||||||
|
if not parsed.get("begruendung"):
|
||||||
|
parsed["begruendung"] = parsed.get("details", "Keine Begründung")
|
||||||
|
return parsed
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
# Regex fallback — extract all fields
|
||||||
|
score_m = re.search(r'"score"\s*:\s*([\d.]+)', text)
|
||||||
|
bew_m = re.search(r'"bewertung"\s*:\s*"([^"]+)"', text)
|
||||||
|
grund_m = re.search(r'"begruendung"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', text)
|
||||||
|
detail_m = re.search(r'"details"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', text)
|
||||||
|
kern_m = re.search(r'"kernpunkt_erfuellt"\s*:\s*(true|false)', text)
|
||||||
|
if score_m:
|
||||||
|
score = float(score_m.group(1))
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"bewertung": bew_m.group(1) if bew_m else _score_to_bewertung(score),
|
||||||
|
"begruendung": grund_m.group(1) if grund_m else (detail_m.group(1) if detail_m else "Regex-Fallback"),
|
||||||
|
"kernpunkt_erfuellt": kern_m.group(1) == "true" if kern_m else score >= 0.5,
|
||||||
|
"details": detail_m.group(1) if detail_m else "",
|
||||||
|
}
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def process_kette(kette_id: int, data: dict) -> bool:
|
||||||
|
prompt = PROMPT.format(**data)
|
||||||
|
result = call_gemini(prompt)
|
||||||
|
if not result:
|
||||||
|
stats["failed"] += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
score = result.get("score", 0)
|
||||||
|
bewertung = result.get("bewertung", "unklar")
|
||||||
|
|
||||||
|
with db_lock:
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, anmerkungen, modell, prompt_version)
|
||||||
|
VALUES (?, 'umsetzung_match', ?, ?, ?, ?, 'v1-ketten-match')
|
||||||
|
""", (data["ursprung_id"], score, result.get("begruendung", ""), json.dumps(result, ensure_ascii=False), MODEL))
|
||||||
|
conn.execute("UPDATE vorlagen SET ki_status = 'matched' WHERE id = ?", (data["ursprung_id"],))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
stats["success"] += 1
|
||||||
|
log(f" ✓ {data['az_ursprung']} → {bewertung} ({score:.1f}): {result.get('begruendung', '')[:60]}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending(conn, limit):
|
||||||
|
return conn.execute("""
|
||||||
|
SELECT k.id as kette_id, v_u.id as ursprung_id,
|
||||||
|
v_u.aktenzeichen as az_ursprung, v_u.typ as typ_ursprung,
|
||||||
|
v_u.volltext_clean as volltext_ursprung,
|
||||||
|
v_b.aktenzeichen as az_blatt, v_b.typ as typ_blatt,
|
||||||
|
b.beschlussart, b.beschlusstext, b.wortprotokoll,
|
||||||
|
ki.begruendung as ki_zusammenfassung
|
||||||
|
FROM ketten k
|
||||||
|
JOIN ketten_glieder kg_u ON kg_u.kette_id = k.id AND kg_u.position = 0
|
||||||
|
JOIN vorlagen v_u ON kg_u.vorlage_id = v_u.id
|
||||||
|
JOIN ketten_glieder kg_b ON kg_b.kette_id = k.id
|
||||||
|
AND kg_b.position = (SELECT MAX(position) FROM ketten_glieder WHERE kette_id = k.id)
|
||||||
|
JOIN vorlagen v_b ON kg_b.vorlage_id = v_b.id
|
||||||
|
LEFT JOIN beratungen b ON b.vorlage_id = v_b.id AND b.beschlusstext IS NOT NULL
|
||||||
|
LEFT JOIN ki_bewertungen ki ON ki.vorlage_id = v_u.id AND ki.typ = 'zusammenfassung'
|
||||||
|
LEFT JOIN ki_bewertungen ki_match ON ki_match.vorlage_id = v_u.id AND ki_match.typ = 'umsetzung_match'
|
||||||
|
WHERE b.beschlusstext IS NOT NULL
|
||||||
|
AND v_u.volltext_clean IS NOT NULL AND v_u.volltext_clean != ''
|
||||||
|
AND ki_match.id IS NULL
|
||||||
|
GROUP BY k.id
|
||||||
|
ORDER BY v_u.datum_eingang DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,)).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--batch-size", type=int, default=100)
|
||||||
|
parser.add_argument("--workers", type=int, default=3)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
log(f"=== Ketten-Match ({MODEL}) ===")
|
||||||
|
log(f"Workers: {args.workers}, Batch: {args.batch_size}")
|
||||||
|
|
||||||
|
if not GEMINI_KEY:
|
||||||
|
log("FEHLER: Kein API-Key!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
rows = get_pending(conn, args.batch_size)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
pending = [{
|
||||||
|
"kette_id": r["kette_id"], "ursprung_id": r["ursprung_id"],
|
||||||
|
"az_ursprung": r["az_ursprung"], "typ_ursprung": r["typ_ursprung"] or "",
|
||||||
|
"volltext_ursprung": (r["volltext_ursprung"] or "")[:5000],
|
||||||
|
"az_blatt": r["az_blatt"] or "", "typ_blatt": r["typ_blatt"] or "",
|
||||||
|
"beschlussart": r["beschlussart"] or "",
|
||||||
|
"beschlusstext": (r["beschlusstext"] or "")[:2000],
|
||||||
|
"wortprotokoll": (r["wortprotokoll"] or "")[:2000],
|
||||||
|
"ki_zusammenfassung": (r["ki_zusammenfassung"] or "")[:500],
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
log(f"Zu verarbeiten: {len(pending)}")
|
||||||
|
if not pending:
|
||||||
|
log("Alle fertig!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
futures = {executor.submit(process_kette, p["kette_id"], p): p["az_ursprung"] for p in pending}
|
||||||
|
for f in as_completed(futures):
|
||||||
|
try: f.result()
|
||||||
|
except Exception as e: log(f" ✗ {futures[f]}: {e}")
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
log(f"\n=== Batch fertig ===")
|
||||||
|
log(f"✓ {stats['success']} | ✗ {stats['failed']} | ⏳ {stats['throttled']} throttled")
|
||||||
|
log(f"Dauer: {elapsed:.0f}s | {stats['success']/max(elapsed,1):.2f} docs/sec")
|
||||||
|
log(f"5h-Fenster: {stats['requests']}/{MAX_REQUESTS_PER_5H} ({stats['requests']*100/MAX_REQUESTS_PER_5H:.0f}%)")
|
||||||
|
return 0 if not pending or stats["success"] >= len(pending) else 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
49
scripts/repair_ketten_match.py
Normal file
49
scripts/repair_ketten_match.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Repariert kaputte ketten_match Einträge:
|
||||||
|
- begruendung enthält rohes JSON statt Klartext
|
||||||
|
- bewertung ist 'unklar' obwohl Score eindeutig ist
|
||||||
|
Löscht kaputte Einträge → run_ketten_match.sh matched sie erneut mit gefixtem Script.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB = Path(__file__).resolve().parent.parent / "data" / "tracker_remote.db"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(DB))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Zähle kaputte
|
||||||
|
broken = conn.execute("""
|
||||||
|
SELECT COUNT(*) as n FROM ki_bewertungen
|
||||||
|
WHERE typ='umsetzung_match' AND begruendung LIKE '{%'
|
||||||
|
""").fetchone()["n"]
|
||||||
|
|
||||||
|
print(f"Kaputte Einträge: {broken}")
|
||||||
|
|
||||||
|
if broken > 0:
|
||||||
|
# Lösche alle kaputten → werden vom Runner erneut bewertet
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM ki_bewertungen
|
||||||
|
WHERE typ='umsetzung_match' AND begruendung LIKE '{%'
|
||||||
|
""")
|
||||||
|
# Setze ki_status zurück für betroffene Vorlagen
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE vorlagen SET ki_status = NULL
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT vorlage_id FROM ki_bewertungen WHERE typ='umsetzung_match'
|
||||||
|
) OR (ki_status = 'matched' AND id NOT IN (
|
||||||
|
SELECT vorlage_id FROM ki_bewertungen WHERE typ='umsetzung_match'
|
||||||
|
))
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
remaining = conn.execute("""
|
||||||
|
SELECT COUNT(*) as n FROM ki_bewertungen WHERE typ='umsetzung_match'
|
||||||
|
""").fetchone()["n"]
|
||||||
|
|
||||||
|
print(f"Gelöscht: {broken}")
|
||||||
|
print(f"Verbleibend (intakt): {remaining}")
|
||||||
|
print(f"→ Runner wird {broken} erneut matchen")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
56
scripts/run_ketten_match.sh
Executable file
56
scripts/run_ketten_match.sh
Executable file
@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Chunked Ketten-Match Runner
|
||||||
|
# Läuft in Batches à 200, pausiert 30s zwischen Batches
|
||||||
|
# Gemini Free Tier: 1500 RPM / 1M TPM — wir bleiben drunter
|
||||||
|
# set -e # removed: single batch failure should not kill runner
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BATCH_SIZE=200
|
||||||
|
WORKERS=3
|
||||||
|
PAUSE=30
|
||||||
|
MAX_BATCHES=50 # Safety cap
|
||||||
|
|
||||||
|
echo "=== Chunked Ketten-Match Runner ==="
|
||||||
|
echo "Batch: $BATCH_SIZE | Workers: $WORKERS | Pause: ${PAUSE}s"
|
||||||
|
|
||||||
|
for i in $(seq 1 $MAX_BATCHES); do
|
||||||
|
echo ""
|
||||||
|
echo "--- Batch $i/$MAX_BATCHES ($(date +%H:%M:%S)) ---"
|
||||||
|
|
||||||
|
# Check ob noch was offen ist
|
||||||
|
REMAINING=$(sqlite3 data/tracker_remote.db "
|
||||||
|
SELECT COUNT(*) FROM ketten k
|
||||||
|
JOIN ketten_glieder kg_u ON kg_u.kette_id = k.id AND kg_u.position = 0
|
||||||
|
JOIN vorlagen v_u ON kg_u.vorlage_id = v_u.id
|
||||||
|
JOIN ketten_glieder kg_b ON kg_b.kette_id = k.id
|
||||||
|
AND kg_b.position = (SELECT MAX(position) FROM ketten_glieder WHERE kette_id = k.id)
|
||||||
|
JOIN vorlagen v_b ON kg_b.vorlage_id = v_b.id
|
||||||
|
LEFT JOIN beratungen b ON b.vorlage_id = v_b.id AND b.beschlusstext IS NOT NULL
|
||||||
|
LEFT JOIN ki_bewertungen ki_match ON ki_match.vorlage_id = v_u.id AND ki_match.typ = 'umsetzung_match'
|
||||||
|
WHERE b.beschlusstext IS NOT NULL
|
||||||
|
AND v_u.volltext_clean IS NOT NULL AND v_u.volltext_clean != ''
|
||||||
|
AND ki_match.id IS NULL
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Noch offen: $REMAINING"
|
||||||
|
|
||||||
|
if [ "$REMAINING" -eq 0 ]; then
|
||||||
|
echo "✅ Alle Ketten bewertet!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 scripts/ketten_match.py --batch-size $BATCH_SIZE --workers $WORKERS
|
||||||
|
|
||||||
|
DONE=$(sqlite3 data/tracker_remote.db "SELECT COUNT(*) FROM ki_bewertungen WHERE typ='umsetzung_match'")
|
||||||
|
echo "Gesamt bewertet: $DONE"
|
||||||
|
|
||||||
|
if [ "$i" -lt "$MAX_BATCHES" ] && [ "$REMAINING" -gt "$BATCH_SIZE" ]; then
|
||||||
|
echo "Pause ${PAUSE}s..."
|
||||||
|
sleep $PAUSE
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Runner beendet ==="
|
||||||
|
TOTAL=$(sqlite3 data/tracker_remote.db "SELECT COUNT(*) FROM ki_bewertungen WHERE typ='umsetzung_match'")
|
||||||
|
echo "Gesamt bewertet: $TOTAL"
|
||||||
1
static/_app/env.js
Normal file
1
static/_app/env.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const env={}
|
||||||
1
static/_app/immutable/assets/app.CoxKXDok.css
Normal file
1
static/_app/immutable/assets/app.CoxKXDok.css
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/assets/leaflet.CIGW-MKW.css
Normal file
1
static/_app/immutable/assets/leaflet.CIGW-MKW.css
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/3I_XkZiy.js
Normal file
1
static/_app/immutable/chunks/3I_XkZiy.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/6IKeDOr0.js
Normal file
1
static/_app/immutable/chunks/6IKeDOr0.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a5 as l,a6 as o,a7 as u,a8 as n,a9 as d,B as m,F as p,a4 as _,aa as v,ab as b}from"./Cjw4vZKn.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(t,s=!0){this.anchor=t,this.#a=s}#f=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),e=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(f);r&&(o(r.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(n()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!e?(this.#i.add(i),u(f,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,a]of this.#e)s.includes(e)||(o(a.effect),this.#e.delete(e))};ensure(t,s){var e=m,a=b();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(a){var i=document.createDocumentFragment(),f=n();i.append(f),this.#e.set(t,{effect:d(()=>s(f)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),a){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#f),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#f(e)}}export{w as B};
|
||||||
1
static/_app/immutable/chunks/B-uV6-Xr.js
Normal file
1
static/_app/immutable/chunks/B-uV6-Xr.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{i as A,j as L,P as D,g as P,p as T,a as b,k as B,l as Y,D as x,m as M,o as N,q as U,v as h,w as q,x as w,y as z,z as $,S as j,L as y}from"./DAfY0XTB.js";import{c as C}from"./splFp8Bu.js";function F(r,a,t,s){var f=!U||(t&h)!==0,v=(t&M)!==0,E=(t&$)!==0,n=s,S=!0,g=()=>(S&&(S=!1,n=E?N(s):s),n);let u;if(v){var O=j in r||y in r;u=A(r,a)?.set??(O&&a in r?e=>r[a]=e:void 0)}var _,I=!1;v?[_,I]=C(()=>r[a]):_=r[a],_===void 0&&s!==void 0&&(_=g(),u&&(f&&L(),u(_)));var i;if(f?i=()=>{var e=r[a];return e===void 0?g():(S=!0,e)}:i=()=>{var e=r[a];return e!==void 0&&(n=void 0),e===void 0?n:e},f&&(t&D)===0)return i;if(u){var R=r.$$legacy;return(function(e,l){return arguments.length>0?((!f||!l||R||I)&&u(l?i():e),e):i()})}var c=!1,d=((t&q)!==0?w:z)(()=>(c=!1,i()));v&&P(d);var m=Y;return(function(e,l){if(arguments.length>0){const o=l?P(d):f&&v?T(e):e;return b(d,o),c=!0,n!==void 0&&(n=o),e}return B&&c||(m.f&x)!==0?d.v:P(d)})}export{F as p};
|
||||||
2
static/_app/immutable/chunks/B08B5jt4.js
Normal file
2
static/_app/immutable/chunks/B08B5jt4.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
const e=[...`
|
||||||
|
\r\f \v\uFEFF`];function o(t,f,u){var n=t==null?"":""+t;if(f&&(n=n?n+" "+f:f),u){for(var s of Object.keys(u))if(u[s])n=n?n+" "+s:s;else if(n.length)for(var i=s.length,l=0;(l=n.indexOf(s,l))>=0;){var r=l+i;(l===0||e.includes(n[l-1]))&&(r===n.length||e.includes(n[r]))?n=(l===0?"":n.substring(0,l))+n.substring(r+1):l=r}}return n===""?null:n}function c(t,f){return t==null?null:String(t)}export{o as a,c as t};
|
||||||
1
static/_app/immutable/chunks/BCXcTGin.js
Normal file
1
static/_app/immutable/chunks/BCXcTGin.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{A as m,B as v,C as _,o as b,E as i,F as y}from"./DAfY0XTB.js";function k(e,l,u=l){var s=new WeakSet;m(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=o(e)?t(a):a,u(a),v!==null&&s.add(v),await _(),a!==(a=l())){var d=e.selectionStart,f=e.selectionEnd,n=e.value.length;if(e.value=a??"",f!==null){var c=e.value.length;d===f&&f===n&&c>n?(e.selectionStart=c,e.selectionEnd=c):(e.selectionStart=d,e.selectionEnd=Math.min(f,c))}}}),(y&&e.defaultValue!==e.value||b(l)==null&&e.value)&&(u(o(e)?t(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}o(e)&&r===t(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function o(e){var l=e.type;return l==="number"||l==="range"}function t(e){return e===""?null:+e}export{k as b};
|
||||||
1
static/_app/immutable/chunks/Bkzsmr9I.js
Normal file
1
static/_app/immutable/chunks/Bkzsmr9I.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{ai as v,a8 as d,ac as u,aF as T,l,aG as p,aH as h,F as i,a4 as s,aI as E,Z as y,aJ as g,a2 as w,aK as N}from"./Cjw4vZKn.js";const M=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function x(t){return M?.createHTML(t)??t}function A(t){var r=v("template");return r.innerHTML=x(t.replaceAll("<!>","<!---->")),r.content}function a(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function I(t,r){var e=(r&p)!==0,f=(r&h)!==0,n,_=!t.startsWith("<!>");return()=>{if(i)return a(s,null),s;n===void 0&&(n=A(_?t:"<!>"+t),e||(n=u(n)));var o=f||T?document.importNode(n,!0):n.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;a(c,m)}else a(o,o);return o}}function O(t=""){if(!i){var r=d(t+"");return a(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),a(e,e),e}function P(){if(i)return a(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),a(r,e),t}function R(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{R as a,a as b,P as c,I as f,O as t};
|
||||||
1
static/_app/immutable/chunks/Br6sCvve.js
Normal file
1
static/_app/immutable/chunks/Br6sCvve.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{Q as c,F as o,R as l,T as b,U as p,V as v,W as g,X as f,Y as m}from"./DAfY0XTB.js";import{B as y}from"./Duumi1XQ.js";function F(_,d,h=!1){var n;o&&(n=m,l());var s=new y(_),u=h?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,f(!1),s.ensure(a,r),f(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;d((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{F as i};
|
||||||
1
static/_app/immutable/chunks/C-x9yHfs.js
Normal file
1
static/_app/immutable/chunks/C-x9yHfs.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as y}from"./DVOkFnep.js";import{F as r}from"./DAfY0XTB.js";function a(t,e,f,i){var l=t.__style;if(r||l!==e){var s=y(e);(!r||s!==t.getAttribute("style"))&&(s==null?t.removeAttribute("style"):t.style.cssText=s),t.__style=e}return i}export{a as s};
|
||||||
1
static/_app/immutable/chunks/CBOKTDOo.js
Normal file
1
static/_app/immutable/chunks/CBOKTDOo.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a1 as z,Q as Z,ao as G,F as A,W as M,a5 as J,R as re,g as U,U as ae,ap as fe,V as X,X as O,Y as H,a7 as K,aq as ie,ar as P,B as le,as as C,a2 as L,at as se,a4 as ue,y as oe,H as te,au as q,av as ve,aw as de,O as ce,ax as Q,ay as pe,D as ge,Z as $,a0 as j,az as B,af as _e,aA as Ee,aB as he,aC as me,a3 as Te,_ as Ce,a8 as V,aD as we,aE as Ae}from"./DAfY0XTB.js";function Ie(e,a){return a}function Se(e,a,s){for(var u=[],t=a.length,r,i=a.length,p=0;p<t;p++){let E=a[p];j(E,()=>{if(r){if(r.pending.delete(E),r.done.add(E),r.pending.size===0){var v=e.outrogroups;Y(e,q(r.done)),v.delete(r),v.size===0&&(e.outrogroups=null)}}else i-=1},!1)}if(i===0){var l=u.length===0&&s!==null;if(l){var c=s,f=c.parentNode;me(f),f.append(c),e.items.clear()}Y(e,a,!l)}else r={pending:new Set(a),done:new Set},(e.outrogroups??=new Set).add(r)}function Y(e,a,s=!0){var u;if(e.pending.size>0){u=new Set;for(const i of e.pending.values())for(const p of i)u.add(e.items.get(p).e)}for(var t=0;t<a.length;t++){var r=a[t];if(u?.has(r)){r.f|=C;const i=document.createDocumentFragment();Te(r,i)}else Ce(a[t],s)}}var W;function Ne(e,a,s,u,t,r=null){var i=e,p=new Map,l=(a&G)!==0;if(l){var c=e;i=A?M(J(c)):c.appendChild(z())}A&&re();var f=null,E=oe(()=>{var d=s();return te(d)?d:d==null?[]:q(d)}),v,h=new Map,m=!0;function I(d){(S.effect.f&ge)===0&&(S.pending.delete(d),S.fallback=f,De(S,v,i,a,u),f!==null&&(v.length===0?(f.f&C)===0?$(f):(f.f^=C,k(f,null,i)):j(f,()=>{f=null})))}function n(d){S.pending.delete(d)}var o=Z(()=>{v=U(E);var d=v.length;let _=!1;if(A){var b=ae(i)===fe;b!==(d===0)&&(i=X(),M(i),O(!1),_=!0)}for(var D=new Set,g=le,N=ue(),R=0;R<d;R+=1){A&&H.nodeType===K&&H.data===ie&&(i=H,_=!0,O(!1));var T=v[R],y=u(T,R),w=m?null:p.get(y);w?(w.v&&P(w.v,T),w.i&&P(w.i,R),N&&g.unskip_effect(w.e)):(w=Re(p,m?i:W??=z(),T,y,R,t,a,s),m||(w.e.f|=C),p.set(y,w)),D.add(y)}if(d===0&&r&&!f&&(m?f=L(()=>r(i)):(f=L(()=>r(W??=z())),f.f|=C)),d>D.size&&se(),A&&d>0&&M(X()),!m)if(h.set(g,D),N){for(const[ee,ne]of p)D.has(ee)||g.skip_effect(ne.e);g.oncommit(I),g.ondiscard(n)}else I(g);_&&O(!0),U(E)}),S={effect:o,items:p,pending:h,outrogroups:null,fallback:f};m=!1,A&&(i=H)}function x(e){for(;e!==null&&(e.f&Ee)===0;)e=e.next;return e}function De(e,a,s,u,t){var r=(u&he)!==0,i=a.length,p=e.items,l=x(e.effect.first),c,f=null,E,v=[],h=[],m,I,n,o;if(r)for(o=0;o<i;o+=1)m=a[o],I=t(m,o),n=p.get(I).e,(n.f&C)===0&&(n.nodes?.a?.measure(),(E??=new Set).add(n));for(o=0;o<i;o+=1){if(m=a[o],I=t(m,o),n=p.get(I).e,e.outrogroups!==null)for(const T of e.outrogroups)T.pending.delete(n),T.done.delete(n);if((n.f&B)!==0&&($(n),r&&(n.nodes?.a?.unfix(),(E??=new Set).delete(n))),(n.f&C)!==0)if(n.f^=C,n===l)k(n,null,s);else{var S=f?f.next:l;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),F(e,f,n),F(e,n,S),k(n,S,s),f=n,v=[],h=[],l=x(f.next);continue}if(n!==l){if(c!==void 0&&c.has(n)){if(v.length<h.length){var d=h[0],_;f=d.prev;var b=v[0],D=v[v.length-1];for(_=0;_<v.length;_+=1)k(v[_],d,s);for(_=0;_<h.length;_+=1)c.delete(h[_]);F(e,b.prev,D.next),F(e,f,b),F(e,D,d),l=d,f=D,o-=1,v=[],h=[]}else c.delete(n),k(n,l,s),F(e,n.prev,n.next),F(e,n,f===null?e.effect.first:f.next),F(e,f,n),f=n;continue}for(v=[],h=[];l!==null&&l!==n;)(c??=new Set).add(l),h.push(l),l=x(l.next);if(l===null)continue}(n.f&C)===0&&v.push(n),f=n,l=x(n.next)}if(e.outrogroups!==null){for(const T of e.outrogroups)T.pending.size===0&&(Y(e,q(T.done)),e.outrogroups?.delete(T));e.outrogroups.size===0&&(e.outrogroups=null)}if(l!==null||c!==void 0){var g=[];if(c!==void 0)for(n of c)(n.f&B)===0&&g.push(n);for(;l!==null;)(l.f&B)===0&&l!==e.fallback&&g.push(l),l=x(l.next);var N=g.length;if(N>0){var R=(u&G)!==0&&i===0?s:null;if(r){for(o=0;o<N;o+=1)g[o].nodes?.a?.measure();for(o=0;o<N;o+=1)g[o].nodes?.a?.fix()}Se(e,g,R)}}r&&_e(()=>{if(E!==void 0)for(n of E)n.nodes?.a?.apply()})}function Re(e,a,s,u,t,r,i,p){var l=(i&ve)!==0?(i&de)===0?ce(s,!1,!1):Q(s):null,c=(i&pe)!==0?Q(t):null;return{v:l,i:c,e:L(()=>(r(a,l??s,c??t,p),()=>{e.delete(u)}))}}function k(e,a,s){if(e.nodes)for(var u=e.nodes.start,t=e.nodes.end,r=a&&(a.f&C)===0?a.nodes.start:s;u!==null;){var i=V(u);if(r.before(u),u===t)return;u=i}}function F(e,a,s){a===null?e.effect.first=s:a.next=s,s===null?e.effect.last=a:s.prev=a}function xe(e,a){let s=null,u=A;var t;if(A){s=H;for(var r=J(document.head);r!==null&&(r.nodeType!==K||r.data!==e);)r=V(r);if(r===null)O(!1);else{var i=V(r);r.remove(),M(i)}}A||(t=document.head.appendChild(z()));try{Z(()=>a(t),we|Ae)}finally{u&&(O(!0),M(s))}}export{Ne as e,xe as h,Ie as i};
|
||||||
1
static/_app/immutable/chunks/CMkOXag5.js
Normal file
1
static/_app/immutable/chunks/CMkOXag5.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{c as y,a as p,f as _}from"./Bkzsmr9I.js";import{d as j,f as S,h as B,c as i,r as n,b as c,t as v,g as r,u as I}from"./Cjw4vZKn.js";import{s as m}from"./DfJQ0EIT.js";import{i as q}from"./kjB3f-xG.js";import{s as w}from"./RVjQLo13.js";import{s as d}from"./CWOupeSg.js";import{p as z}from"./fSdafo1a.js";import{s as A}from"./utcFFRIM.js";var C=_('<a><span class="mr-1"> </span> </a>'),D=_('<span><span class="mr-1"> </span> </span>');function N(x,o){j(o,!0);let h=z(o,"linked",3,!1);const s=I(()=>A(o.status));var u=y(),b=S(u);{var g=e=>{var t=C(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{w(t,"href",`/ketten?status=${o.status??""}`),d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""} hover:opacity-80 transition-opacity`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)},k=e=>{var t=D(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""}`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)};q(b,e=>{h()&&o.status?e(g):e(k,-1)})}p(x,u),B()}export{N as S};
|
||||||
1
static/_app/immutable/chunks/CSnrw0fY.js
Normal file
1
static/_app/immutable/chunks/CSnrw0fY.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{A as m,B as v,C as _,o as b,E as i,F as y}from"./Cjw4vZKn.js";function k(e,l,u=l){var s=new WeakSet;m(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=o(e)?t(a):a,u(a),v!==null&&s.add(v),await _(),a!==(a=l())){var d=e.selectionStart,f=e.selectionEnd,n=e.value.length;if(e.value=a??"",f!==null){var c=e.value.length;d===f&&f===n&&c>n?(e.selectionStart=c,e.selectionEnd=c):(e.selectionStart=d,e.selectionEnd=Math.min(f,c))}}}),(y&&e.defaultValue!==e.value||b(l)==null&&e.value)&&(u(o(e)?t(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}o(e)&&r===t(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function o(e){var l=e.type;return l==="number"||l==="range"}function t(e){return e===""?null:+e}export{k as b};
|
||||||
1
static/_app/immutable/chunks/CTIvq_GE.js
Normal file
1
static/_app/immutable/chunks/CTIvq_GE.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/CWOupeSg.js
Normal file
1
static/_app/immutable/chunks/CWOupeSg.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as b}from"./B08B5jt4.js";import{F as g}from"./Cjw4vZKn.js";function A(i,h,f,N,t,r){var o=i.__className;if(g||o!==f||o===void 0){var a=b(f,N,r);(!g||a!==i.getAttribute("class"))&&(a==null?i.removeAttribute("class"):i.className=a),i.__className=f}else if(r&&t!==r)for(var l in r){var u=!!r[l];(t==null||u!==!!t[l])&&i.classList.toggle(l,u)}return r}export{A as s};
|
||||||
1
static/_app/immutable/chunks/Cgke0YGN.js
Normal file
1
static/_app/immutable/chunks/Cgke0YGN.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const a=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api";async function e(t){const n=await fetch(`${a}${t}`);if(!n.ok)throw new Error(`API error: ${n.status}`);return n.json()}const r=t=>{const n=new URLSearchParams(t).toString();return e(`/vorlagen?${n}`)},s=t=>e(`/vorlagen/${t}`),c=t=>{const n=new URLSearchParams(t).toString();return e(`/ketten?${n}`)},i=t=>e(`/ketten/${t}`),f=()=>e("/fraktionen"),h=(t,n)=>{const o=n?`?jahr=${n}`:"";return e(`/fraktionen/${t}/dashboard${o}`)};export{c as a,s as b,i as c,r as d,h as e,f};
|
||||||
1
static/_app/immutable/chunks/CjPBq9Bq.js
Normal file
1
static/_app/immutable/chunks/CjPBq9Bq.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/Cjw4vZKn.js
Normal file
1
static/_app/immutable/chunks/Cjw4vZKn.js
Normal file
File diff suppressed because one or more lines are too long
2
static/_app/immutable/chunks/D2u1A_4g.js
Normal file
2
static/_app/immutable/chunks/D2u1A_4g.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/D6E-zrqv.js
Normal file
1
static/_app/immutable/chunks/D6E-zrqv.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{t as b}from"./DVOkFnep.js";import{F as g}from"./DAfY0XTB.js";function A(i,h,t,N,f,r){var o=i.__className;if(g||o!==t||o===void 0){var a=b(t,N,r);(!g||a!==i.getAttribute("class"))&&(a==null?i.removeAttribute("class"):i.className=a),i.__className=t}else if(r&&f!==r)for(var l in r){var u=!!r[l];(f==null||u!==!!f[l])&&i.classList.toggle(l,u)}return r}export{A as s};
|
||||||
1
static/_app/immutable/chunks/DAfY0XTB.js
Normal file
1
static/_app/immutable/chunks/DAfY0XTB.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/DCPIP6Ym.js
Normal file
1
static/_app/immutable/chunks/DCPIP6Ym.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{ab as v,a1 as d,a5 as u,aF as T,l,aG as p,aH as h,F as i,Y as s,aI as E,R as y,aJ as g,W as w,aK as N}from"./DAfY0XTB.js";const M=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function b(t){return M?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=b(t.replaceAll("<!>","<!---->")),r.content}function a(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function R(t,r){var e=(r&p)!==0,f=(r&h)!==0,n,_=!t.startsWith("<!>");return()=>{if(i)return a(s,null),s;n===void 0&&(n=x(_?t:"<!>"+t),e||(n=u(n)));var o=f||T?document.importNode(n,!0):n.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;a(c,m)}else a(o,o);return o}}function I(t=""){if(!i){var r=d(t+"");return a(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),a(e,e),e}function O(){if(i)return a(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),a(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const A="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(A);export{P as a,a as b,O as c,R as f,I as t};
|
||||||
1
static/_app/immutable/chunks/DDErvS7v.js
Normal file
1
static/_app/immutable/chunks/DDErvS7v.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{am as o,an as t,q as c,o as l}from"./DAfY0XTB.js";function a(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&a(),c&&t.l!==null?u(t).m.push(e):o(()=>{const n=l(e);if(typeof n=="function")return n})}function u(e){var n=e.l;return n.u??={a:[],b:[],m:[]}}export{r as o};
|
||||||
1
static/_app/immutable/chunks/DIGUPa-Q.js
Normal file
1
static/_app/immutable/chunks/DIGUPa-Q.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{N as o,o as a,ae as d}from"./DAfY0XTB.js";function p(s,u,n){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,n));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function _(s,u=o){let n=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,n)){const b=!i.length;for(const e of t)e[1](),i.push(e,s);if(b){for(let e=0;e<i.length;e+=2)i[e][0](i[e+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const e=[r,b];return t.add(e),t.size===1&&(n=u(c,f)||o),r(s),()=>{t.delete(e),t.size===0&&n&&(n(),n=null)}}return{set:c,update:f,subscribe:l}}function h(s){let u;return p(s,n=>u=n)(),u}export{h as g,p as s,_ as w};
|
||||||
2
static/_app/immutable/chunks/DVOkFnep.js
Normal file
2
static/_app/immutable/chunks/DVOkFnep.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import{F as l,af as u,ag as d,ah as g,ai as v,aj as h,ak as A,al as m}from"./DAfY0XTB.js";const i=[...`
|
||||||
|
\r\f \v\uFEFF`];function I(r,s,t){var e=r==null?"":""+r;if(s&&(e=e?e+" "+s:s),t){for(var n of Object.keys(t))if(t[n])e=e?e+" "+n:n;else if(e.length)for(var f=n.length,o=0;(o=e.indexOf(n,o))>=0;){var a=o+f;(o===0||i.includes(e[o-1]))&&(a===e.length||i.includes(e[a]))?e=(o===0?"":e.substring(0,o))+e.substring(a+1):o=a}}return e===""?null:e}function y(r,s){return r==null?null:String(r)}const p=Symbol("is custom element"),S=Symbol("is html"),L=h?"link":"LINK";function E(r){if(l){var s=!1,t=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var e=r.value;c(r,"value",null),r.value=e}if(r.hasAttribute("checked")){var n=r.checked;c(r,"checked",null),r.checked=n}}};r.__on_r=t,u(t),d()}}function c(r,s,t,e){var n=M(r);l&&(n[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===L)||n[s]!==(n[s]=t)&&(s==="loading"&&(r[g]=t),t==null?r.removeAttribute(s):typeof t!="string"&&N(r).includes(s)?r[s]=t:r.setAttribute(s,t))}function M(r){return r.__attributes??={[p]:r.nodeName.includes("-"),[S]:r.namespaceURI===v}}var _=new Map;function N(r){var s=r.getAttribute("is")||r.nodeName,t=_.get(s);if(t)return t;_.set(s,t=[]);for(var e,n=r,f=Element.prototype;f!==n;){e=m(n);for(var o in e)e[o].set&&t.push(o);n=A(n)}return t}export{y as a,E as r,c as s,I as t};
|
||||||
1
static/_app/immutable/chunks/DaCWmHjB.js
Normal file
1
static/_app/immutable/chunks/DaCWmHjB.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a8 as z,Y as G,F as A,a2 as M,ac as J,Z as ae,g as P,a0 as re,ao as fe,a1 as Q,a3 as O,a4 as H,ae as K,ap as ie,aq as U,B as le,ar as C,a9 as L,as as se,ab as ue,y as oe,H as te,at as V,au as ve,av as de,O as ce,aw as X,ax as pe,D as ge,a5 as W,a7 as $,ay as B,az as _e,aA as Ee,aa as he,a6 as me,af as Y,aB as j,Q as Te,aC as Ce,aD as we,aE as Ae}from"./Cjw4vZKn.js";function Ne(e,r){return r}function Se(e,r,s){for(var u=[],t=r.length,a,i=r.length,p=0;p<t;p++){let E=r[p];$(E,()=>{if(a){if(a.pending.delete(E),a.done.add(E),a.pending.size===0){var v=e.outrogroups;q(e,V(a.done)),v.delete(a),v.size===0&&(e.outrogroups=null)}}else i-=1},!1)}if(i===0){var l=u.length===0&&s!==null;if(l){var c=s,f=c.parentNode;Ee(f),f.append(c),e.items.clear()}q(e,r,!l)}else a={pending:new Set(r),done:new Set},(e.outrogroups??=new Set).add(a)}function q(e,r,s=!0){var u;if(e.pending.size>0){u=new Set;for(const i of e.pending.values())for(const p of i)u.add(e.items.get(p).e)}for(var t=0;t<r.length;t++){var a=r[t];if(u?.has(a)){a.f|=C;const i=document.createDocumentFragment();he(a,i)}else me(r[t],s)}}var Z;function Re(e,r,s,u,t,a=null){var i=e,p=new Map,l=(r&j)!==0;if(l){var c=e;i=A?M(J(c)):c.appendChild(z())}A&&ae();var f=null,E=oe(()=>{var d=s();return te(d)?d:d==null?[]:V(d)}),v,h=new Map,m=!0;function N(d){(S.effect.f&ge)===0&&(S.pending.delete(d),S.fallback=f,De(S,v,i,r,u),f!==null&&(v.length===0?(f.f&C)===0?W(f):(f.f^=C,k(f,null,i)):$(f,()=>{f=null})))}function n(d){S.pending.delete(d)}var o=G(()=>{v=P(E);var d=v.length;let _=!1;if(A){var b=re(i)===fe;b!==(d===0)&&(i=Q(),M(i),O(!1),_=!0)}for(var D=new Set,g=le,R=ue(),F=0;F<d;F+=1){A&&H.nodeType===K&&H.data===ie&&(i=H,_=!0,O(!1));var T=v[F],y=u(T,F),w=m?null:p.get(y);w?(w.v&&U(w.v,T),w.i&&U(w.i,F),R&&g.unskip_effect(w.e)):(w=Fe(p,m?i:Z??=z(),T,y,F,t,r,s),m||(w.e.f|=C),p.set(y,w)),D.add(y)}if(d===0&&a&&!f&&(m?f=L(()=>a(i)):(f=L(()=>a(Z??=z())),f.f|=C)),d>D.size&&se(),A&&d>0&&M(Q()),!m)if(h.set(g,D),R){for(const[ee,ne]of p)D.has(ee)||g.skip_effect(ne.e);g.oncommit(N),g.ondiscard(n)}else N(g);_&&O(!0),P(E)}),S={effect:o,items:p,pending:h,outrogroups:null,fallback:f};m=!1,A&&(i=H)}function x(e){for(;e!==null&&(e.f&_e)===0;)e=e.next;return e}function De(e,r,s,u,t){var a=(u&Ce)!==0,i=r.length,p=e.items,l=x(e.effect.first),c,f=null,E,v=[],h=[],m,N,n,o;if(a)for(o=0;o<i;o+=1)m=r[o],N=t(m,o),n=p.get(N).e,(n.f&C)===0&&(n.nodes?.a?.measure(),(E??=new Set).add(n));for(o=0;o<i;o+=1){if(m=r[o],N=t(m,o),n=p.get(N).e,e.outrogroups!==null)for(const T of e.outrogroups)T.pending.delete(n),T.done.delete(n);if((n.f&B)!==0&&(W(n),a&&(n.nodes?.a?.unfix(),(E??=new Set).delete(n))),(n.f&C)!==0)if(n.f^=C,n===l)k(n,null,s);else{var S=f?f.next:l;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),I(e,f,n),I(e,n,S),k(n,S,s),f=n,v=[],h=[],l=x(f.next);continue}if(n!==l){if(c!==void 0&&c.has(n)){if(v.length<h.length){var d=h[0],_;f=d.prev;var b=v[0],D=v[v.length-1];for(_=0;_<v.length;_+=1)k(v[_],d,s);for(_=0;_<h.length;_+=1)c.delete(h[_]);I(e,b.prev,D.next),I(e,f,b),I(e,D,d),l=d,f=D,o-=1,v=[],h=[]}else c.delete(n),k(n,l,s),I(e,n.prev,n.next),I(e,n,f===null?e.effect.first:f.next),I(e,f,n),f=n;continue}for(v=[],h=[];l!==null&&l!==n;)(c??=new Set).add(l),h.push(l),l=x(l.next);if(l===null)continue}(n.f&C)===0&&v.push(n),f=n,l=x(n.next)}if(e.outrogroups!==null){for(const T of e.outrogroups)T.pending.size===0&&(q(e,V(T.done)),e.outrogroups?.delete(T));e.outrogroups.size===0&&(e.outrogroups=null)}if(l!==null||c!==void 0){var g=[];if(c!==void 0)for(n of c)(n.f&B)===0&&g.push(n);for(;l!==null;)(l.f&B)===0&&l!==e.fallback&&g.push(l),l=x(l.next);var R=g.length;if(R>0){var F=(u&j)!==0&&i===0?s:null;if(a){for(o=0;o<R;o+=1)g[o].nodes?.a?.measure();for(o=0;o<R;o+=1)g[o].nodes?.a?.fix()}Se(e,g,F)}}a&&Te(()=>{if(E!==void 0)for(n of E)n.nodes?.a?.apply()})}function Fe(e,r,s,u,t,a,i,p){var l=(i&ve)!==0?(i&de)===0?ce(s,!1,!1):X(s):null,c=(i&pe)!==0?X(t):null;return{v:l,i:c,e:L(()=>(a(r,l??s,c??t,p),()=>{e.delete(u)}))}}function k(e,r,s){if(e.nodes)for(var u=e.nodes.start,t=e.nodes.end,a=r&&(r.f&C)===0?r.nodes.start:s;u!==null;){var i=Y(u);if(a.before(u),u===t)return;u=i}}function I(e,r,s){r===null?e.effect.first=s:r.next=s,s===null?e.effect.last=r:s.prev=r}function xe(e,r){let s=null,u=A;var t;if(A){s=H;for(var a=J(document.head);a!==null&&(a.nodeType!==K||a.data!==e);)a=Y(a);if(a===null)O(!1);else{var i=Y(a);a.remove(),M(i)}}A||(t=document.head.appendChild(z()));try{G(()=>r(t),we|Ae)}finally{u&&(O(!0),M(s))}}export{Re as e,xe as h,Ne as i};
|
||||||
2
static/_app/immutable/chunks/DfJQ0EIT.js
Normal file
2
static/_app/immutable/chunks/DfJQ0EIT.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/chunks/DiDp2_zb.js
Normal file
1
static/_app/immutable/chunks/DiDp2_zb.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{s as e}from"./CTIvq_GE.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};
|
||||||
1
static/_app/immutable/chunks/DiIboHMF.js
Normal file
1
static/_app/immutable/chunks/DiIboHMF.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{N as o,o as a,al as d}from"./Cjw4vZKn.js";function p(s,u,e){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,e));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function _(s,u=o){let e=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,e)){const b=!i.length;for(const n of t)n[1](),i.push(n,s);if(b){for(let n=0;n<i.length;n+=2)i[n][0](i[n+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const n=[r,b];return t.add(n),t.size===1&&(e=u(c,f)||o),r(s),()=>{t.delete(n),t.size===0&&e&&(e(),e=null)}}return{set:c,update:f,subscribe:l}}function h(s){let u;return p(s,e=>u=e)(),u}export{h as g,p as s,_ as w};
|
||||||
1
static/_app/immutable/chunks/DjXdyWBG.js
Normal file
1
static/_app/immutable/chunks/DjXdyWBG.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{s as f,g as c}from"./DiIboHMF.js";import{G as l,M as b,N as a,O as _,g as p,a as d}from"./Cjw4vZKn.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:_(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:d(s.source,o)}),i=!1}return e&&t in n?c(e):p(s.source)}function y(){const e={};function r(){l(()=>{for(var n in e)e[n].unsubscribe();b(e,t,{enumerable:!1,value:!0})})}return[e,r]}function N(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,N as c,y as s};
|
||||||
1
static/_app/immutable/chunks/Duumi1XQ.js
Normal file
1
static/_app/immutable/chunks/Duumi1XQ.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{Z as l,_ as o,a0 as u,a1 as n,a2 as d,B as m,F as p,Y as _,a3 as v,a4 as k}from"./DAfY0XTB.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(t,s=!0){this.anchor=t,this.#a=s}#f=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),e=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(f);r&&(o(r.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(n()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!e?(this.#i.add(i),u(f,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,a]of this.#e)s.includes(e)||(o(a.effect),this.#e.delete(e))};ensure(t,s){var e=m,a=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(a){var i=document.createDocumentFragment(),f=n();i.append(f),this.#e.set(t,{effect:d(()=>s(f)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),a){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#f),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#f(e)}}export{w as B};
|
||||||
1
static/_app/immutable/chunks/DxJV8wOg.js
Normal file
1
static/_app/immutable/chunks/DxJV8wOg.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{c as y,a as p,f as _}from"./DCPIP6Ym.js";import{d as j,f as S,h as B,c as i,r as n,b as c,t as v,g as r,u as I}from"./DAfY0XTB.js";import{s as m}from"./D2u1A_4g.js";import{i as q}from"./Br6sCvve.js";import{s as w}from"./DVOkFnep.js";import{s as d}from"./D6E-zrqv.js";import{p as z}from"./B-uV6-Xr.js";import{s as A}from"./utcFFRIM.js";var C=_('<a><span class="mr-1"> </span> </a>'),D=_('<span><span class="mr-1"> </span> </span>');function N(x,o){j(o,!0);let h=z(o,"linked",3,!1);const s=I(()=>A(o.status));var u=y(),b=S(u);{var g=e=>{var t=C(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{w(t,"href",`/ketten?status=${o.status??""}`),d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""} hover:opacity-80 transition-opacity`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)},k=e=>{var t=D(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""}`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)};q(b,e=>{h()&&o.status?e(g):e(k,-1)})}p(x,u),B()}export{N as S};
|
||||||
1
static/_app/immutable/chunks/OvWQM58r.js
Normal file
1
static/_app/immutable/chunks/OvWQM58r.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{s as e}from"./3I_XkZiy.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};
|
||||||
1
static/_app/immutable/chunks/PPVm8Dsz.js
Normal file
1
static/_app/immutable/chunks/PPVm8Dsz.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const p="modulepreload",y=function(u,i){return new URL(u,i).href},v={},w=function(i,a,f){let d=Promise.resolve();if(a&&a.length>0){let E=function(e){return Promise.all(e.map(o=>Promise.resolve(o).then(s=>({status:"fulfilled",value:s}),s=>({status:"rejected",reason:s}))))};const r=document.getElementsByTagName("link"),t=document.querySelector("meta[property=csp-nonce]"),m=t?.nonce||t?.getAttribute("nonce");d=E(a.map(e=>{if(e=y(e,f),e in v)return;v[e]=!0;const o=e.endsWith(".css"),s=o?'[rel="stylesheet"]':"";if(f)for(let c=r.length-1;c>=0;c--){const l=r[c];if(l.href===e&&(!o||l.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${e}"]${s}`))return;const n=document.createElement("link");if(n.rel=o?"stylesheet":p,o||(n.as="script"),n.crossOrigin="",n.href=e,m&&n.setAttribute("nonce",m),document.head.appendChild(n),o)return new Promise((c,l)=>{n.addEventListener("load",c),n.addEventListener("error",()=>l(new Error(`Unable to preload CSS for ${e}`)))})}))}function h(r){const t=new Event("vite:preloadError",{cancelable:!0});if(t.payload=r,window.dispatchEvent(t),!t.defaultPrevented)throw r}return d.then(r=>{for(const t of r||[])t.status==="rejected"&&h(t.reason);return i().catch(h)})};export{w as _};
|
||||||
1
static/_app/immutable/chunks/QfvBL-nR.js
Normal file
1
static/_app/immutable/chunks/QfvBL-nR.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{t as y}from"./B08B5jt4.js";import{F as r}from"./Cjw4vZKn.js";function n(t,e,f,i){var l=t.__style;if(r||l!==e){var s=y(e);(!r||s!==t.getAttribute("style"))&&(s==null?t.removeAttribute("style"):t.style.cssText=s),t.__style=e}return i}export{n as s};
|
||||||
1
static/_app/immutable/chunks/RVjQLo13.js
Normal file
1
static/_app/immutable/chunks/RVjQLo13.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{F as i,Q as n,R as d,T as v,U as u,V as h,W as l,X as g}from"./Cjw4vZKn.js";const A=Symbol("is custom element"),T=Symbol("is html"),m=h?"link":"LINK";function N(r){if(i){var s=!1,e=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var t=r.value;_(r,"value",null),r.value=t}if(r.hasAttribute("checked")){var o=r.checked;_(r,"checked",null),r.checked=o}}};r.__on_r=e,n(e),d()}}function _(r,s,e,t){var o=p(r);i&&(o[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===m)||o[s]!==(o[s]=e)&&(s==="loading"&&(r[v]=e),e==null?r.removeAttribute(s):typeof e!="string"&&L(r).includes(s)?r[s]=e:r.setAttribute(s,e))}function p(r){return r.__attributes??={[A]:r.nodeName.includes("-"),[T]:r.namespaceURI===u}}var c=new Map;function L(r){var s=r.getAttribute("is")||r.nodeName,e=c.get(s);if(e)return e;c.set(s,e=[]);for(var t,o=r,f=Element.prototype;f!==o;){t=g(o);for(var a in t)t[a].set&&e.push(a);o=l(o)}return e}export{N as r,_ as s};
|
||||||
1
static/_app/immutable/chunks/fSdafo1a.js
Normal file
1
static/_app/immutable/chunks/fSdafo1a.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{i as A,j as L,P as D,g as P,p as T,a as b,k as B,l as Y,D as x,m as M,o as N,q as U,v as h,w as q,x as w,y as z,z as $,S as j,L as y}from"./Cjw4vZKn.js";import{c as C}from"./DjXdyWBG.js";function F(r,a,t,s){var f=!U||(t&h)!==0,v=(t&M)!==0,E=(t&$)!==0,n=s,S=!0,g=()=>(S&&(S=!1,n=E?N(s):s),n);let u;if(v){var O=j in r||y in r;u=A(r,a)?.set??(O&&a in r?e=>r[a]=e:void 0)}var _,I=!1;v?[_,I]=C(()=>r[a]):_=r[a],_===void 0&&s!==void 0&&(_=g(),u&&(f&&L(),u(_)));var i;if(f?i=()=>{var e=r[a];return e===void 0?g():(S=!0,e)}:i=()=>{var e=r[a];return e!==void 0&&(n=void 0),e===void 0?n:e},f&&(t&D)===0)return i;if(u){var R=r.$$legacy;return(function(e,l){return arguments.length>0?((!f||!l||R||I)&&u(l?i():e),e):i()})}var c=!1,d=((t&q)!==0?w:z)(()=>(c=!1,i()));v&&P(d);var m=Y;return(function(e,l){if(arguments.length>0){const o=l?P(d):f&&v?T(e):e;return b(d,o),c=!0,n!==void 0&&(n=o),e}return B&&c||(m.f&x)!==0?d.v:P(d)})}export{F as p};
|
||||||
1
static/_app/immutable/chunks/kjB3f-xG.js
Normal file
1
static/_app/immutable/chunks/kjB3f-xG.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{Y as c,F as o,Z as l,_ as b,a0 as p,a1 as v,a2 as g,a3 as _,a4 as m}from"./Cjw4vZKn.js";import{B as y}from"./6IKeDOr0.js";function T(f,d,h=!1){var n;o&&(n=m,l());var s=new y(f),u=h?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,_(!1),s.ensure(a,r),_(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;d((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{T as i};
|
||||||
1
static/_app/immutable/chunks/qS6hbb4Y.js
Normal file
1
static/_app/immutable/chunks/qS6hbb4Y.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{A as s,B as v,e as o,G as c,H as b,I as m,J as h}from"./Cjw4vZKn.js";function d(e,r,f=!1){if(e.multiple){if(r==null)return;if(!b(r))return m();for(var a of e.options)a.selected=r.includes(i(a));return}for(a of e.options){var t=i(a);if(h(t,r)){a.selected=!0;return}}(!f||r!==void 0)&&(e.selectedIndex=-1)}function y(e){var r=new MutationObserver(()=>{d(e,e.__value)});r.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),c(()=>{r.disconnect()})}function S(e,r,f=r){var a=new WeakSet,t=!0;s(e,"change",u=>{var l=u?"[selected]":":checked",n;if(e.multiple)n=[].map.call(e.querySelectorAll(l),i);else{var _=e.querySelector(l)??e.querySelector("option:not([disabled])");n=_&&i(_)}f(n),e.__value=n,v!==null&&a.add(v)}),o(()=>{var u=r();if(e===document.activeElement){var l=v;if(a.has(l))return}if(d(e,u,t),t&&u===void 0){var n=e.querySelector(":checked");n!==null&&(u=i(n),f(u))}e.__value=u,t=!1}),y(e)}function i(e){return"__value"in e?e.__value:e.value}export{S as b};
|
||||||
1
static/_app/immutable/chunks/splFp8Bu.js
Normal file
1
static/_app/immutable/chunks/splFp8Bu.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{s as f,g as c}from"./DIGUPa-Q.js";import{G as l,M as b,N as a,O as _,g as p,a as d}from"./DAfY0XTB.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:_(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:d(s.source,o)}),i=!1}return e&&t in n?c(e):p(s.source)}function y(){const e={};function r(){l(()=>{for(var n in e)e[n].unsubscribe();b(e,t,{enumerable:!1,value:!0})})}return[e,r]}function N(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,N as c,y as s};
|
||||||
1
static/_app/immutable/chunks/trpXq522.js
Normal file
1
static/_app/immutable/chunks/trpXq522.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{am as o,an as t,q as c,o as l}from"./Cjw4vZKn.js";function a(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&a(),c&&t.l!==null?u(t).m.push(e):o(()=>{const n=l(e);if(typeof n=="function")return n})}function u(e){var n=e.l;return n.u??={a:[],b:[],m:[]}}export{r as o};
|
||||||
1
static/_app/immutable/chunks/utcFFRIM.js
Normal file
1
static/_app/immutable/chunks/utcFFRIM.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const t={angefragt:{emoji:"📝",label:"Angefragt",color:"bg-blue-100 text-blue-800"},beantwortet:{emoji:"✅",label:"Beantwortet",color:"bg-green-100 text-green-800"},offen:{emoji:"⏳",label:"Offen",color:"bg-yellow-100 text-yellow-800"},abgewiegelt:{emoji:"⚠️",label:"Abgewiegelt",color:"bg-orange-100 text-orange-800"},versandet:{emoji:"💀",label:"Versandet",color:"bg-red-100 text-red-800"},zurückgezogen:{emoji:"🔙",label:"Zurückgezogen",color:"bg-gray-100 text-gray-800"},eingereicht:{emoji:"📝",label:"Eingereicht",color:"bg-blue-100 text-blue-800"},in_beratung:{emoji:"🔄",label:"In Beratung",color:"bg-indigo-100 text-indigo-800"},vertagt:{emoji:"⏸️",label:"Vertagt",color:"bg-amber-100 text-amber-800"},verwiesen:{emoji:"↪️",label:"Verwiesen",color:"bg-purple-100 text-purple-800"},beschlossen:{emoji:"📋",label:"Beschlossen",color:"bg-teal-100 text-teal-800"},umgesetzt:{emoji:"✅",label:"Umgesetzt",color:"bg-green-100 text-green-800"},teilweise_umgesetzt:{emoji:"🔶",label:"Teilw. umgesetzt",color:"bg-lime-100 text-lime-800"},abgelehnt:{emoji:"❌",label:"Abgelehnt",color:"bg-red-100 text-red-800"},still_uebernommen:{emoji:"🔄✨",label:"Still übernommen",color:"bg-pink-100 text-pink-800"}};function r(e){return e?t[e]??{emoji:"❓",label:e,color:"bg-gray-100 text-gray-600"}:{emoji:"❓",label:"Unbekannt",color:"bg-gray-100 text-gray-600"}}const l={antrag:"Antrag",anfrage:"Anfrage",stellungnahme:"Stellungnahme",bericht:"Bericht"};function o(e){return e?l[e]??e:"Unbekannt"}function n(e){if(!e)return"–";try{return new Date(e).toLocaleDateString("de-DE",{day:"2-digit",month:"2-digit",year:"numeric"})}catch{return e}}export{n as f,r as s,o as t};
|
||||||
1
static/_app/immutable/chunks/yhBelVs6.js
Normal file
1
static/_app/immutable/chunks/yhBelVs6.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{A as s,B as v,e as o,G as c,H as b,I as m,J as h}from"./DAfY0XTB.js";function d(e,r,f=!1){if(e.multiple){if(r==null)return;if(!b(r))return m();for(var a of e.options)a.selected=r.includes(i(a));return}for(a of e.options){var t=i(a);if(h(t,r)){a.selected=!0;return}}(!f||r!==void 0)&&(e.selectedIndex=-1)}function y(e){var r=new MutationObserver(()=>{d(e,e.__value)});r.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),c(()=>{r.disconnect()})}function S(e,r,f=r){var a=new WeakSet,t=!0;s(e,"change",u=>{var l=u?"[selected]":":checked",n;if(e.multiple)n=[].map.call(e.querySelectorAll(l),i);else{var _=e.querySelector(l)??e.querySelector("option:not([disabled])");n=_&&i(_)}f(n),e.__value=n,v!==null&&a.add(v)}),o(()=>{var u=r();if(e===document.activeElement){var l=v;if(a.has(l))return}if(d(e,u,t),t&&u===void 0){var n=e.querySelector(":checked");n!==null&&(u=i(n),f(u))}e.__value=u,t=!1}),y(e)}function i(e){return"__value"in e?e.__value:e.value}export{S as b};
|
||||||
2
static/_app/immutable/entry/app.BZjvA3WU.js
Normal file
2
static/_app/immutable/entry/app.BZjvA3WU.js
Normal file
File diff suppressed because one or more lines are too long
2
static/_app/immutable/entry/app.CPVPz_La.js
Normal file
2
static/_app/immutable/entry/app.CPVPz_La.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/entry/start.BlI5Xt52.js
Normal file
1
static/_app/immutable/entry/start.BlI5Xt52.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{l as o,a as r}from"../chunks/CTIvq_GE.js";export{o as load_css,r as start};
|
||||||
1
static/_app/immutable/entry/start.Cz3VRzIK.js
Normal file
1
static/_app/immutable/entry/start.Cz3VRzIK.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{l as o,a as r}from"../chunks/3I_XkZiy.js";export{o as load_css,r as start};
|
||||||
1
static/_app/immutable/nodes/0.BHTRPJLe.js
Normal file
1
static/_app/immutable/nodes/0.BHTRPJLe.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as d,f as x}from"../chunks/Bkzsmr9I.js";import{Y as i,_ as l,b as p,c as n,r as o}from"../chunks/Cjw4vZKn.js";import{B as c}from"../chunks/6IKeDOr0.js";/* empty css */function f(r,s,...e){var t=new c(r);i(()=>{const a=s()??null;t.ensure(a,a&&(m=>a(m,...e)))},l)}const g=!1,u=!1,k=Object.freeze(Object.defineProperty({__proto__:null,prerender:g,ssr:u},Symbol.toStringTag,{value:"Module"}));var v=x('<div class="min-h-screen bg-gray-50"><nav class="bg-white border-b border-gray-200 shadow-sm"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center space-x-8"><a href="/" class="text-xl font-bold text-gray-900">Antragstracker <span class="text-green-600">Hagen</span></a> <div class="hidden sm:flex 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="/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="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a> <a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a> <a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a></div></div></div></div></nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><!></main></div>');function w(r,s){var e=v(),t=p(n(e),2),a=n(t);f(a,()=>s.children),o(t),o(e),d(r,e)}export{w as component,k as universal};
|
||||||
1
static/_app/immutable/nodes/0.DId8iTp4.js
Normal file
1
static/_app/immutable/nodes/0.DId8iTp4.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as d,f as x}from"../chunks/DCPIP6Ym.js";import{Q as i,T as l,b as p,c as n,r as o}from"../chunks/DAfY0XTB.js";import{B as c}from"../chunks/Duumi1XQ.js";/* empty css */function f(r,s,...e){var t=new c(r);i(()=>{const a=s()??null;t.ensure(a,a&&(m=>a(m,...e)))},l)}const g=!1,u=!1,k=Object.freeze(Object.defineProperty({__proto__:null,prerender:g,ssr:u},Symbol.toStringTag,{value:"Module"}));var v=x('<div class="min-h-screen bg-gray-50"><nav class="bg-white border-b border-gray-200 shadow-sm"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center space-x-8"><a href="/" class="text-xl font-bold text-gray-900">Antragstracker <span class="text-green-600">Hagen</span></a> <div class="hidden sm:flex 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="/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="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a> <a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a> <a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a></div></div></div></div></nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><!></main></div>');function w(r,s){var e=v(),t=p(n(e),2),a=n(t);f(a,()=>s.children),o(t),o(e),d(r,e)}export{w as component,k as universal};
|
||||||
1
static/_app/immutable/nodes/1.CYfReXZ2.js
Normal file
1
static/_app/immutable/nodes/1.CYfReXZ2.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as b,f as x}from"../chunks/DCPIP6Ym.js";import{an as k,aL as y,am as i,o as $,aM as l,aN as E,g as v,aO as L,x as M,aP as N,d as O,f as P,t as j,h as q,c as u,r as m,b as w}from"../chunks/DAfY0XTB.js";import{s as g}from"../chunks/D2u1A_4g.js";import{s as z,p as _}from"../chunks/CTIvq_GE.js";function A(r=!1){const t=k,e=t.l.u;if(!e)return;let a=()=>L(t.s);if(r){let o=0,s={};const f=M(()=>{let p=!1;const c=t.s;for(const n in c)c[n]!==s[n]&&(s[n]=c[n],p=!0);return p&&o++,o});a=()=>v(f)}e.b.length&&y(()=>{d(t,a),l(e.b)}),i(()=>{const o=$(()=>e.m.map(E));return()=>{for(const s of o)typeof s=="function"&&s()}}),e.a.length&&i(()=>{d(t,a),l(e.a)})}function d(r,t){if(r.l.s)for(const e of r.l.s)v(e);t()}N();const B={get error(){return _.error},get status(){return _.status}};z.updated.check;const h=B;var C=x("<h1> </h1> <p> </p>",1);function I(r,t){O(t,!1),A();var e=C(),a=P(e),o=u(a,!0);m(a);var s=w(a,2),f=u(s,!0);m(s),j(()=>{g(o,h.status),g(f,h.error?.message)}),b(r,e),q()}export{I as component};
|
||||||
1
static/_app/immutable/nodes/1.fI0LOJ0P.js
Normal file
1
static/_app/immutable/nodes/1.fI0LOJ0P.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as b,f as x}from"../chunks/Bkzsmr9I.js";import{an as k,aL as y,am as i,o as $,aM as l,aN as E,g as v,aO as L,x as M,aP as N,d as O,f as P,t as j,h as q,c as u,r as m,b as w}from"../chunks/Cjw4vZKn.js";import{s as g}from"../chunks/DfJQ0EIT.js";import{s as z,p as _}from"../chunks/3I_XkZiy.js";function A(r=!1){const t=k,e=t.l.u;if(!e)return;let a=()=>L(t.s);if(r){let o=0,s={};const f=M(()=>{let p=!1;const c=t.s;for(const n in c)c[n]!==s[n]&&(s[n]=c[n],p=!0);return p&&o++,o});a=()=>v(f)}e.b.length&&y(()=>{d(t,a),l(e.b)}),i(()=>{const o=$(()=>e.m.map(E));return()=>{for(const s of o)typeof s=="function"&&s()}}),e.a.length&&i(()=>{d(t,a),l(e.a)})}function d(r,t){if(r.l.s)for(const e of r.l.s)v(e);t()}N();const B={get error(){return _.error},get status(){return _.status}};z.updated.check;const h=B;var C=x("<h1> </h1> <p> </p>",1);function I(r,t){O(t,!1),A();var e=C(),a=P(e),o=u(a,!0);m(a);var s=w(a,2),f=u(s,!0);m(s),j(()=>{g(o,h.status),g(f,h.error?.message)}),b(r,e),q()}export{I as component};
|
||||||
2
static/_app/immutable/nodes/10.DtCBgXe3.js
Normal file
2
static/_app/immutable/nodes/10.DtCBgXe3.js
Normal file
File diff suppressed because one or more lines are too long
2
static/_app/immutable/nodes/10.tOVf7a-k.js
Normal file
2
static/_app/immutable/nodes/10.tOVf7a-k.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/2.BUzpf9hM.js
Normal file
1
static/_app/immutable/nodes/2.BUzpf9hM.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as l,f as v}from"../chunks/DCPIP6Ym.js";import{p as S,s as $,t as m,a as u,g as o,e as Q,$ as T,b as d,c as s,r as a,n as _}from"../chunks/DAfY0XTB.js";import{s as n}from"../chunks/D2u1A_4g.js";import{i as U}from"../chunks/Br6sCvve.js";import{h as W,e as X,i as Y}from"../chunks/CBOKTDOo.js";/* empty css */var ee=v('<div class="p-6 text-center text-red-500"> </div>'),te=v('<div class="p-6 text-center text-gray-500"> </div>'),ae=v('<div class="p-6 text-center text-gray-500">Keine Anträge gefunden</div>'),se=v('<li class="px-6 py-4 hover:bg-gray-50 cursor-pointer"><div class="flex justify-between items-start"><div class="flex-1"><div class="flex items-center gap-2"><span class="font-mono text-sm text-green-700 bg-green-50 px-2 py-0.5 rounded"> </span> <span class="text-xs text-gray-400"> </span></div> <p class="mt-1 text-gray-700 line-clamp-2"> </p></div> <span class="text-xs px-2 py-1 rounded-full bg-yellow-100 text-yellow-800">⏳ offen</span></div></li>'),re=v('<ul class="divide-y divide-gray-100"></ul>'),oe=v('<main class="min-h-screen bg-gray-50"><header class="bg-green-700 text-white py-6 shadow-lg"><div class="max-w-6xl mx-auto px-4"><h1 class="text-3xl font-bold">🏛️ Antragstracker Hagen</h1> <p class="text-green-100 mt-1">Kommunale Anträge & Anfragen nachverfolgen</p></div></header> <div class="max-w-6xl mx-auto px-4 py-8"><div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"><div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-green-600"> </div> <div class="text-gray-500 text-sm">Vorlagen</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-blue-600"> </div> <div class="text-gray-500 text-sm">Ketten</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-purple-600">41</div> <div class="text-gray-500 text-sm">Gremien</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-orange-600">2004–2026</div> <div class="text-gray-500 text-sm">Zeitraum</div></div></div> <section class="bg-white rounded-lg shadow"><div class="px-6 py-4 border-b border-gray-200"><h2 class="text-xl font-semibold text-gray-800">📋 Aktuelle Anträge</h2></div> <!></section></div></main>');function ge(K){let g=S({vorlagen:0,beratungen:0,ketten:0,gremien:0}),x=$(S([])),R=$(!0),p=$("");const i=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api";async function B(){console.log("API_BASE:",i);try{console.log("Fetching health...");const e=await fetch(`${i}/health`);if(console.log("Health response:",e.status),e.ok){console.log("Fetching vorlagen...");const r=await fetch(`${i}/vorlagen?page_size=1`);console.log("Vorlagen response:",r.status);const c=await r.json();g.vorlagen=c.total;const f=await(await fetch(`${i}/ketten?page_size=1`)).json();g.ketten=f.total}else u(p,`Health check failed: ${e.status}`);const t=await fetch(`${i}/vorlagen?typ=antrag&page_size=10`);if(console.log("Antraege response:",t.status),t.ok){const r=await t.json();console.log("Antraege data:",r.items.length),u(x,r.items,!0)}}catch(e){console.error("API Fehler:",e),u(p,`Fehler: ${e}`)}finally{u(R,!1),console.log("Loading done, antraege:",o(x).length)}}B();var w=oe();W("1uha8ag",e=>{Q(()=>{T.title="Antragstracker Hagen"})});var j=d(s(w),2),y=s(j),b=s(y),z=s(b),E=s(z,!0);a(z),_(2),a(b);var D=d(b,2),F=s(D),V=s(F,!0);a(F),_(2),a(D),_(4),a(y);var H=d(y,2),G=d(s(H),2);{var Z=e=>{var t=ee(),r=s(t,!0);a(t),m(()=>n(r,o(p))),l(e,t)},q=e=>{var t=te(),r=s(t);a(t),m(()=>n(r,`Lade Daten... (API: ${i})`)),l(e,t)},C=e=>{var t=ae();l(e,t)},J=e=>{var t=re();X(t,21,()=>o(x),Y,(r,c)=>{var h=se(),f=s(h),I=s(f),k=s(I),A=s(k),M=s(A,!0);a(A);var L=d(A,2),N=s(L,!0);a(L),a(k);var P=d(k,2),O=s(P,!0);a(P),a(I),_(2),a(f),a(h),m(()=>{n(M,o(c).aktenzeichen),n(N,o(c).datum_eingang),n(O,o(c).betreff)}),l(r,h)}),a(t),l(e,t)};U(G,e=>{o(p)?e(Z):o(R)?e(q,1):o(x).length===0?e(C,2):e(J,-1)})}a(H),a(j),a(w),m((e,t)=>{n(E,e),n(V,t)},[()=>g.vorlagen.toLocaleString(),()=>g.ketten.toLocaleString()]),l(K,w)}export{ge as component};
|
||||||
1
static/_app/immutable/nodes/2.CLqOxIAV.js
Normal file
1
static/_app/immutable/nodes/2.CLqOxIAV.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as l,f as v}from"../chunks/Bkzsmr9I.js";import{p as S,s as $,t as m,a as u,g as o,e as Q,$ as T,b as d,c as s,r as a,n as _}from"../chunks/Cjw4vZKn.js";import{s as n}from"../chunks/DfJQ0EIT.js";import{i as U}from"../chunks/kjB3f-xG.js";import{h as W,e as X,i as Y}from"../chunks/DaCWmHjB.js";/* empty css */var ee=v('<div class="p-6 text-center text-red-500"> </div>'),te=v('<div class="p-6 text-center text-gray-500"> </div>'),ae=v('<div class="p-6 text-center text-gray-500">Keine Anträge gefunden</div>'),se=v('<li class="px-6 py-4 hover:bg-gray-50 cursor-pointer"><div class="flex justify-between items-start"><div class="flex-1"><div class="flex items-center gap-2"><span class="font-mono text-sm text-green-700 bg-green-50 px-2 py-0.5 rounded"> </span> <span class="text-xs text-gray-400"> </span></div> <p class="mt-1 text-gray-700 line-clamp-2"> </p></div> <span class="text-xs px-2 py-1 rounded-full bg-yellow-100 text-yellow-800">⏳ offen</span></div></li>'),re=v('<ul class="divide-y divide-gray-100"></ul>'),oe=v('<main class="min-h-screen bg-gray-50"><header class="bg-green-700 text-white py-6 shadow-lg"><div class="max-w-6xl mx-auto px-4"><h1 class="text-3xl font-bold">🏛️ Antragstracker Hagen</h1> <p class="text-green-100 mt-1">Kommunale Anträge & Anfragen nachverfolgen</p></div></header> <div class="max-w-6xl mx-auto px-4 py-8"><div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"><div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-green-600"> </div> <div class="text-gray-500 text-sm">Vorlagen</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-blue-600"> </div> <div class="text-gray-500 text-sm">Ketten</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-purple-600">41</div> <div class="text-gray-500 text-sm">Gremien</div></div> <div class="bg-white rounded-lg shadow p-4 text-center"><div class="text-3xl font-bold text-orange-600">2004–2026</div> <div class="text-gray-500 text-sm">Zeitraum</div></div></div> <section class="bg-white rounded-lg shadow"><div class="px-6 py-4 border-b border-gray-200"><h2 class="text-xl font-semibold text-gray-800">📋 Aktuelle Anträge</h2></div> <!></section></div></main>');function ge(K){let g=S({vorlagen:0,beratungen:0,ketten:0,gremien:0}),x=$(S([])),R=$(!0),p=$("");const i=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api";async function B(){console.log("API_BASE:",i);try{console.log("Fetching health...");const e=await fetch(`${i}/health`);if(console.log("Health response:",e.status),e.ok){console.log("Fetching vorlagen...");const r=await fetch(`${i}/vorlagen?page_size=1`);console.log("Vorlagen response:",r.status);const c=await r.json();g.vorlagen=c.total;const f=await(await fetch(`${i}/ketten?page_size=1`)).json();g.ketten=f.total}else u(p,`Health check failed: ${e.status}`);const t=await fetch(`${i}/vorlagen?typ=antrag&page_size=10`);if(console.log("Antraege response:",t.status),t.ok){const r=await t.json();console.log("Antraege data:",r.items.length),u(x,r.items,!0)}}catch(e){console.error("API Fehler:",e),u(p,`Fehler: ${e}`)}finally{u(R,!1),console.log("Loading done, antraege:",o(x).length)}}B();var w=oe();W("1uha8ag",e=>{Q(()=>{T.title="Antragstracker Hagen"})});var j=d(s(w),2),y=s(j),b=s(y),z=s(b),E=s(z,!0);a(z),_(2),a(b);var D=d(b,2),F=s(D),V=s(F,!0);a(F),_(2),a(D),_(4),a(y);var H=d(y,2),G=d(s(H),2);{var Z=e=>{var t=ee(),r=s(t,!0);a(t),m(()=>n(r,o(p))),l(e,t)},q=e=>{var t=te(),r=s(t);a(t),m(()=>n(r,`Lade Daten... (API: ${i})`)),l(e,t)},C=e=>{var t=ae();l(e,t)},J=e=>{var t=re();X(t,21,()=>o(x),Y,(r,c)=>{var h=se(),f=s(h),I=s(f),k=s(I),A=s(k),M=s(A,!0);a(A);var L=d(A,2),N=s(L,!0);a(L),a(k);var P=d(k,2),O=s(P,!0);a(P),a(I),_(2),a(f),a(h),m(()=>{n(M,o(c).aktenzeichen),n(N,o(c).datum_eingang),n(O,o(c).betreff)}),l(r,h)}),a(t),l(e,t)};U(G,e=>{o(p)?e(Z):o(R)?e(q,1):o(x).length===0?e(C,2):e(J,-1)})}a(H),a(j),a(w),m((e,t)=>{n(E,e),n(V,t)},[()=>g.vorlagen.toLocaleString(),()=>g.ketten.toLocaleString()]),l(K,w)}export{ge as component};
|
||||||
1
static/_app/immutable/nodes/3.C4_c_H5u.js
Normal file
1
static/_app/immutable/nodes/3.C4_c_H5u.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/3.Cw03XrzI.js
Normal file
1
static/_app/immutable/nodes/3.Cw03XrzI.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/4.CGqLxVsa.js
Normal file
1
static/_app/immutable/nodes/4.CGqLxVsa.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as l,f as n}from"../chunks/Bkzsmr9I.js";import{o as L}from"../chunks/trpXq522.js";import{d as M,s as _,p as j,h as q,e as B,b as c,c as i,a as h,$ as C,g as a,r as e,n as D,t as E}from"../chunks/Cjw4vZKn.js";import{s as f}from"../chunks/DfJQ0EIT.js";import{i as G}from"../chunks/kjB3f-xG.js";import{h as I,e as J,i as K}from"../chunks/DaCWmHjB.js";import{s as N}from"../chunks/RVjQLo13.js";import{s as u}from"../chunks/QfvBL-nR.js";import{f as O}from"../chunks/Cgke0YGN.js";var P=n('<div class="text-gray-500">Laden...</div>'),Q=n('<a class="block p-4 rounded-lg border hover:shadow-md transition-shadow"><div class="font-bold text-lg"> </div> <div class="text-sm text-gray-600"> </div> <div class="mt-2 text-2xl font-semibold"> </div> <div class="text-xs text-gray-500">Anträge & Anfragen</div></a>'),R=n('<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>'),S=n('<div class="max-w-4xl mx-auto p-6"><h1 class="text-2xl font-bold mb-6">Fraktionen</h1> <!></div>');function ra(b,k){M(k,!0);let p=_(j([])),x=_(!0);L(async()=>{h(p,await O(),!0),h(x,!1)});var v=S();I("caopi2",t=>{B(()=>{C.title="Fraktionen — Antragstracker Hagen"})});var y=c(i(v),2);{var $=t=>{var s=P();l(t,s)},w=t=>{var s=R();J(s,21,()=>a(p),K,(z,r)=>{var o=Q(),d=i(o),A=i(d,!0);e(d);var m=c(d,2),F=i(m,!0);e(m);var g=c(m,2),H=i(g,!0);e(g),D(2),e(o),E(()=>{N(o,"href",`/fraktionen/${a(r).kuerzel??""}`),u(o,`border-left: 4px solid ${(a(r).farbe||"#6b7280")??""}`),u(d,`color: ${(a(r).farbe||"#374151")??""}`),f(A,a(r).kuerzel),f(F,a(r).name),f(H,a(r).anzahl)}),l(z,o)}),e(s),l(t,s)};G(y,t=>{a(x)?t($):t(w,-1)})}e(v),l(b,v),q()}export{ra as component};
|
||||||
1
static/_app/immutable/nodes/4.lGPSnkVN.js
Normal file
1
static/_app/immutable/nodes/4.lGPSnkVN.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{a as l,f as n}from"../chunks/DCPIP6Ym.js";import{o as L}from"../chunks/DDErvS7v.js";import{d as M,s as _,p as j,h as q,e as B,b as c,c as i,a as h,$ as C,g as a,r as e,n as D,t as E}from"../chunks/DAfY0XTB.js";import{s as f}from"../chunks/D2u1A_4g.js";import{i as G}from"../chunks/Br6sCvve.js";import{h as I,e as J,i as K}from"../chunks/CBOKTDOo.js";import{s as N}from"../chunks/DVOkFnep.js";import{s as u}from"../chunks/C-x9yHfs.js";import{f as O}from"../chunks/Cgke0YGN.js";var P=n('<div class="text-gray-500">Laden...</div>'),Q=n('<a class="block p-4 rounded-lg border hover:shadow-md transition-shadow"><div class="font-bold text-lg"> </div> <div class="text-sm text-gray-600"> </div> <div class="mt-2 text-2xl font-semibold"> </div> <div class="text-xs text-gray-500">Anträge & Anfragen</div></a>'),R=n('<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>'),S=n('<div class="max-w-4xl mx-auto p-6"><h1 class="text-2xl font-bold mb-6">Fraktionen</h1> <!></div>');function ra(b,k){M(k,!0);let p=_(j([])),x=_(!0);L(async()=>{h(p,await O(),!0),h(x,!1)});var v=S();I("caopi2",t=>{B(()=>{C.title="Fraktionen — Antragstracker Hagen"})});var y=c(i(v),2);{var $=t=>{var s=P();l(t,s)},w=t=>{var s=R();J(s,21,()=>a(p),K,(z,r)=>{var o=Q(),d=i(o),A=i(d,!0);e(d);var m=c(d,2),F=i(m,!0);e(m);var g=c(m,2),H=i(g,!0);e(g),D(2),e(o),E(()=>{N(o,"href",`/fraktionen/${a(r).kuerzel??""}`),u(o,`border-left: 4px solid ${(a(r).farbe||"#6b7280")??""}`),u(d,`color: ${(a(r).farbe||"#374151")??""}`),f(A,a(r).kuerzel),f(F,a(r).name),f(H,a(r).anzahl)}),l(z,o)}),e(s),l(t,s)};G(y,t=>{a(x)?t($):t(w,-1)})}e(v),l(b,v),q()}export{ra as component};
|
||||||
1
static/_app/immutable/nodes/5.BiFm8qpr.js
Normal file
1
static/_app/immutable/nodes/5.BiFm8qpr.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/5.ClnAgA1G.js
Normal file
1
static/_app/immutable/nodes/5.ClnAgA1G.js
Normal file
File diff suppressed because one or more lines are too long
6
static/_app/immutable/nodes/6.CG1t51nm.js
Normal file
6
static/_app/immutable/nodes/6.CG1t51nm.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../assets/leaflet.CIGW-MKW.css"])))=>i.map(i=>d[i]);
|
||||||
|
import{_ as S}from"../chunks/PPVm8Dsz.js";import{a as l,f as n}from"../chunks/DCPIP6Ym.js";import{o as de}from"../chunks/DDErvS7v.js";import{d as ce,s as u,p as B,b as i,f as ve,t as _,h as pe,e as ge,c as r,g as o,$ as me,r as a,a as h}from"../chunks/DAfY0XTB.js";import{d as fe,s as d,a as ue}from"../chunks/D2u1A_4g.js";import{i as L}from"../chunks/Br6sCvve.js";import{h as _e,e as Z,i as q}from"../chunks/CBOKTDOo.js";import{s as he}from"../chunks/DVOkFnep.js";import{s as xe}from"../chunks/D6E-zrqv.js";var be=n('<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>'),ye=n('<div class="h-[500px] flex items-center justify-center"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div></div>'),we=n('<div id="map" class="h-[500px]"></div>'),ke=n('<li><a class="block p-2 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors"><div class="flex items-center gap-2"><span class="font-mono text-xs font-medium text-green-700"> </span> <span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500"> </span></div> <p class="text-xs text-gray-600 mt-1 line-clamp-2"> </p></a></li>'),Oe=n('<ul class="space-y-2"></ul>'),Ae=n('<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><h2 class="text-lg font-semibold text-gray-900 mb-2"> </h2> <p class="text-sm text-gray-500 mb-4"> </p> <!></div>'),Ee=n('<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><p class="text-gray-500 text-sm">Klicke auf einen Marker um die zugehörigen Vorlagen zu sehen.</p></div>'),$e=n('<li><button><span class="font-medium"> </span> <span class="text-gray-400 ml-2"> </span></button></li>'),Ve=n('<div class="mb-6"><h1 class="text-2xl font-bold text-gray-900">📍 Anträge auf der Karte</h1> <p class="text-gray-500 text-sm mt-1">Orte aus Anträgen und Anfragen in Hagen</p></div> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"><div class="lg:col-span-2"><div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"><!></div> <div class="mt-4 text-sm text-gray-500"> </div></div> <div class="space-y-6"><!> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><h2 class="text-lg font-semibold text-gray-900 mb-4">🗺️ Alle Orte</h2> <ul class="space-y-1 max-h-80 overflow-y-auto"></ul></div></div></div>',1);function Ke(J,Q){ce(Q,!0);let g=u(B([])),p=u(null),x=u(B([])),P=u(!0),b=null;const T=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api",U=[51.361,7.476],W=12;async function X(){try{const t=await fetch(`${T}/orte`);t.ok&&h(g,await t.json(),!0)}catch(t){console.error("Fehler beim Laden der Orte:",t)}finally{h(P,!1)}}async function H(t){h(p,t,!0);try{const e=await fetch(`${T}/orte/${t.id}/vorlagen`);e.ok&&h(x,await e.json(),!0)}catch(e){console.error("Fehler:",e)}}de(async()=>{await X();const t=await S(()=>import("../chunks/CjPBq9Bq.js").then(e=>e.l),[],import.meta.url);await S(()=>Promise.resolve({}),__vite__mapDeps([0]),import.meta.url),b=t.map("map").setView(U,W),t.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(b);for(const e of o(g)){const s=t.circleMarker([e.lat,e.lon],{radius:Math.min(8+e.vorlage_count*2,20),fillColor:"#16a34a",color:"#166534",weight:2,opacity:1,fillOpacity:.7}).addTo(b);s.bindPopup(`
|
||||||
|
<strong>${e.name}</strong><br>
|
||||||
|
${e.vorlage_count} Vorlage(n)
|
||||||
|
`),s.on("click",()=>H(e))}});var j=Ve();_e("hbihfm",t=>{var e=be();ge(()=>{me.title="Karte - Antragstracker Hagen"}),l(t,e)});var G=i(ve(j),2),y=r(G),w=r(y),Y=r(w);{var ee=t=>{var e=ye();l(t,e)},te=t=>{var e=we();l(t,e)};L(Y,t=>{o(P)?t(ee):t(te,-1)})}a(w);var I=i(w,2),ae=r(I);a(I),a(y);var K=i(y,2),N=r(K);{var re=t=>{var e=Ae(),s=r(e),v=r(s,!0);a(s);var c=i(s,2),k=r(c);a(c);var m=i(c,2);{var O=A=>{var E=Oe();Z(E,21,()=>o(x),q,(se,f)=>{var $=ke(),V=r($),M=r(V),z=r(M),ie=r(z,!0);a(z);var D=i(z,2),le=r(D,!0);a(D),a(M);var F=i(M,2),ne=r(F,!0);a(F),a(V),a($),_(()=>{he(V,"href",`/vorlagen/${o(f).id??""}`),d(ie,o(f).aktenzeichen),d(le,o(f).typ),d(ne,o(f).betreff)}),l(se,$)}),a(E),l(A,E)};L(m,A=>{o(x).length>0&&A(O)})}a(e),_(()=>{d(v,o(p).name),d(k,`${o(p).vorlage_count??""} Vorlage(n) betreffen diesen Ort`)}),l(t,e)},oe=t=>{var e=Ee();l(t,e)};L(N,t=>{o(p)?t(re):t(oe,-1)})}var R=i(N,2),C=i(r(R),2);Z(C,21,()=>o(g),q,(t,e)=>{var s=$e(),v=r(s),c=r(v),k=r(c,!0);a(c);var m=i(c,2),O=r(m);a(m),a(v),a(s),_(()=>{xe(v,1,`w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm
|
||||||
|
${o(p)?.id===o(e).id?"bg-green-50 text-green-700":"text-gray-700"}`),d(k,o(e).name),d(O,`(${o(e).vorlage_count??""})`)}),ue("click",v,()=>H(o(e))),l(t,s)}),a(C),a(R),a(K),a(G),_(()=>d(ae,`${o(g).length??""} Orte geocodiert • Marker-Größe = Anzahl Vorlagen`)),l(J,j),pe()}fe(["click"]);export{Ke as component};
|
||||||
6
static/_app/immutable/nodes/6.Q5ZP4RpW.js
Normal file
6
static/_app/immutable/nodes/6.Q5ZP4RpW.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../assets/leaflet.CIGW-MKW.css"])))=>i.map(i=>d[i]);
|
||||||
|
import{_ as S}from"../chunks/PPVm8Dsz.js";import{a as l,f as n}from"../chunks/Bkzsmr9I.js";import{o as de}from"../chunks/trpXq522.js";import{d as ce,s as u,p as B,b as i,f as ve,t as _,h as pe,e as ge,c as r,g as o,$ as me,r as a,a as h}from"../chunks/Cjw4vZKn.js";import{d as fe,s as d,a as ue}from"../chunks/DfJQ0EIT.js";import{i as L}from"../chunks/kjB3f-xG.js";import{h as _e,e as Z,i as q}from"../chunks/DaCWmHjB.js";import{s as he}from"../chunks/RVjQLo13.js";import{s as xe}from"../chunks/CWOupeSg.js";var be=n('<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>'),ye=n('<div class="h-[500px] flex items-center justify-center"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div></div>'),we=n('<div id="map" class="h-[500px]"></div>'),ke=n('<li><a class="block p-2 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors"><div class="flex items-center gap-2"><span class="font-mono text-xs font-medium text-green-700"> </span> <span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500"> </span></div> <p class="text-xs text-gray-600 mt-1 line-clamp-2"> </p></a></li>'),Oe=n('<ul class="space-y-2"></ul>'),Ae=n('<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><h2 class="text-lg font-semibold text-gray-900 mb-2"> </h2> <p class="text-sm text-gray-500 mb-4"> </p> <!></div>'),Ee=n('<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><p class="text-gray-500 text-sm">Klicke auf einen Marker um die zugehörigen Vorlagen zu sehen.</p></div>'),$e=n('<li><button><span class="font-medium"> </span> <span class="text-gray-400 ml-2"> </span></button></li>'),Ve=n('<div class="mb-6"><h1 class="text-2xl font-bold text-gray-900">📍 Anträge auf der Karte</h1> <p class="text-gray-500 text-sm mt-1">Orte aus Anträgen und Anfragen in Hagen</p></div> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"><div class="lg:col-span-2"><div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"><!></div> <div class="mt-4 text-sm text-gray-500"> </div></div> <div class="space-y-6"><!> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"><h2 class="text-lg font-semibold text-gray-900 mb-4">🗺️ Alle Orte</h2> <ul class="space-y-1 max-h-80 overflow-y-auto"></ul></div></div></div>',1);function Ke(J,Q){ce(Q,!0);let g=u(B([])),p=u(null),x=u(B([])),P=u(!0),b=null;const T=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api",U=[51.361,7.476],W=12;async function X(){try{const t=await fetch(`${T}/orte`);t.ok&&h(g,await t.json(),!0)}catch(t){console.error("Fehler beim Laden der Orte:",t)}finally{h(P,!1)}}async function H(t){h(p,t,!0);try{const e=await fetch(`${T}/orte/${t.id}/vorlagen`);e.ok&&h(x,await e.json(),!0)}catch(e){console.error("Fehler:",e)}}de(async()=>{await X();const t=await S(()=>import("../chunks/CjPBq9Bq.js").then(e=>e.l),[],import.meta.url);await S(()=>Promise.resolve({}),__vite__mapDeps([0]),import.meta.url),b=t.map("map").setView(U,W),t.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(b);for(const e of o(g)){const s=t.circleMarker([e.lat,e.lon],{radius:Math.min(8+e.vorlage_count*2,20),fillColor:"#16a34a",color:"#166534",weight:2,opacity:1,fillOpacity:.7}).addTo(b);s.bindPopup(`
|
||||||
|
<strong>${e.name}</strong><br>
|
||||||
|
${e.vorlage_count} Vorlage(n)
|
||||||
|
`),s.on("click",()=>H(e))}});var j=Ve();_e("hbihfm",t=>{var e=be();ge(()=>{me.title="Karte - Antragstracker Hagen"}),l(t,e)});var G=i(ve(j),2),y=r(G),w=r(y),Y=r(w);{var ee=t=>{var e=ye();l(t,e)},te=t=>{var e=we();l(t,e)};L(Y,t=>{o(P)?t(ee):t(te,-1)})}a(w);var I=i(w,2),ae=r(I);a(I),a(y);var K=i(y,2),N=r(K);{var re=t=>{var e=Ae(),s=r(e),v=r(s,!0);a(s);var c=i(s,2),k=r(c);a(c);var m=i(c,2);{var O=A=>{var E=Oe();Z(E,21,()=>o(x),q,(se,f)=>{var $=ke(),V=r($),M=r(V),z=r(M),ie=r(z,!0);a(z);var D=i(z,2),le=r(D,!0);a(D),a(M);var F=i(M,2),ne=r(F,!0);a(F),a(V),a($),_(()=>{he(V,"href",`/vorlagen/${o(f).id??""}`),d(ie,o(f).aktenzeichen),d(le,o(f).typ),d(ne,o(f).betreff)}),l(se,$)}),a(E),l(A,E)};L(m,A=>{o(x).length>0&&A(O)})}a(e),_(()=>{d(v,o(p).name),d(k,`${o(p).vorlage_count??""} Vorlage(n) betreffen diesen Ort`)}),l(t,e)},oe=t=>{var e=Ee();l(t,e)};L(N,t=>{o(p)?t(re):t(oe,-1)})}var R=i(N,2),C=i(r(R),2);Z(C,21,()=>o(g),q,(t,e)=>{var s=$e(),v=r(s),c=r(v),k=r(c,!0);a(c);var m=i(c,2),O=r(m);a(m),a(v),a(s),_(()=>{xe(v,1,`w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-sm
|
||||||
|
${o(p)?.id===o(e).id?"bg-green-50 text-green-700":"text-gray-700"}`),d(k,o(e).name),d(O,`(${o(e).vorlage_count??""})`)}),ue("click",v,()=>H(o(e))),l(t,s)}),a(C),a(R),a(K),a(G),_(()=>d(ae,`${o(g).length??""} Orte geocodiert • Marker-Größe = Anzahl Vorlagen`)),l(J,j),pe()}fe(["click"]);export{Ke as component};
|
||||||
1
static/_app/immutable/nodes/7.BtrismLT.js
Normal file
1
static/_app/immutable/nodes/7.BtrismLT.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/7._-pAQSFO.js
Normal file
1
static/_app/immutable/nodes/7._-pAQSFO.js
Normal file
File diff suppressed because one or more lines are too long
2
static/_app/immutable/nodes/8.BCiD6gja.js
Normal file
2
static/_app/immutable/nodes/8.BCiD6gja.js
Normal file
File diff suppressed because one or more lines are too long
2
static/_app/immutable/nodes/8.DN4MFhnj.js
Normal file
2
static/_app/immutable/nodes/8.DN4MFhnj.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/9.BwN-S9Xs.js
Normal file
1
static/_app/immutable/nodes/9.BwN-S9Xs.js
Normal file
File diff suppressed because one or more lines are too long
1
static/_app/immutable/nodes/9.CTwKRSNc.js
Normal file
1
static/_app/immutable/nodes/9.CTwKRSNc.js
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user