feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024): - app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs) - app/templates/v2/: base.html + 11 Screens + 8 Component-Macros - AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores) - v2 ist jetzt Default unter / — classic unter /classic - Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129) - Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle - Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie, Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze Backend-Erweiterungen: - main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout}, /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.) - og_card.py + og_template: Open-Graph-Bilder via Playwright (#141) - wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138) - auswertungen.py: BL-Filter + get_wahlperioden Helper (#137) - auth.py: Direct-Access-Grant + Refresh-Token-Cookie Classic-Updates: - Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ -77,6 +77,7 @@ def _load_assessments(db_path: Optional[Path] = None) -> list[dict]:
|
||||
|
||||
def aggregate_matrix(
|
||||
filter_wp: Optional[str] = None,
|
||||
filter_bl: Optional[str] = None,
|
||||
db_path: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Aggregate assessments to a 2D matrix.
|
||||
@ -89,12 +90,16 @@ def aggregate_matrix(
|
||||
"<bl>": {"<partei>": {"n": int, "avg": float}}
|
||||
},
|
||||
"filter_wp": <filter_wp> | None,
|
||||
"filter_bl": <filter_bl> | None,
|
||||
"total": int,
|
||||
}``
|
||||
|
||||
``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``;
|
||||
nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine
|
||||
WP-Einschränkung (alle WPs zusammen).
|
||||
|
||||
``filter_bl`` schränkt auf ein Bundesland ein (z.B. ``"NRW"``);
|
||||
``None`` = alle Bundesländer.
|
||||
"""
|
||||
rows = _load_assessments(db_path)
|
||||
|
||||
@ -108,6 +113,8 @@ def aggregate_matrix(
|
||||
bl = row["bundesland"]
|
||||
if not bl:
|
||||
continue
|
||||
if filter_bl is not None and bl != filter_bl:
|
||||
continue
|
||||
if filter_wp is not None:
|
||||
wp = wahlperiode_for(row["datum"], bl)
|
||||
if wp != filter_wp:
|
||||
@ -134,10 +141,28 @@ def aggregate_matrix(
|
||||
"parteien": sorted(parteien),
|
||||
"cells": cells,
|
||||
"filter_wp": filter_wp,
|
||||
"filter_bl": filter_bl,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1b. Hilfsfunktion: Liste aller bekannten Wahlperioden
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_wahlperioden(db_path: Optional[Path] = None) -> list[str]:
|
||||
"""Gibt alle bekannten Wahlperioden aus den vorhandenen Assessments zurück,
|
||||
aufsteigend sortiert."""
|
||||
rows = _load_assessments(db_path)
|
||||
wps: set[str] = set()
|
||||
for r in rows:
|
||||
wp = wahlperiode_for(r["drucksache"], r["bundesland"])
|
||||
if wp:
|
||||
wps.add(wp)
|
||||
return sorted(wps)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Zeitreihe pro (BL, Partei) über alle Wahlperioden
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
53
app/auth.py
@ -233,6 +233,30 @@ async def require_admin(request: Request) -> dict:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def keycloak_admin_token() -> str:
|
||||
"""Holt ein Admin-Token vom Keycloak-Master-Realm.
|
||||
|
||||
Verwendet die Credentials aus den Umgebungsvariablen KEYCLOAK_ADMIN_USER
|
||||
und KEYCLOAK_ADMIN_PASSWORD. Wirft HTTPException bei Fehlschlag.
|
||||
"""
|
||||
import httpx
|
||||
if not settings.keycloak_admin_user or not settings.keycloak_admin_password:
|
||||
raise HTTPException(status_code=500, detail="Keycloak-Admin-Credentials nicht konfiguriert")
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.keycloak_url}/realms/master/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": settings.keycloak_admin_user,
|
||||
"password": settings.keycloak_admin_password,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="Keycloak-Verbindung fehlgeschlagen")
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def keycloak_login_url(redirect_uri: str) -> str:
|
||||
"""Baut die Keycloak-Login-URL für den Browser-Redirect."""
|
||||
if not _is_auth_enabled():
|
||||
@ -245,3 +269,32 @@ def keycloak_login_url(redirect_uri: str) -> str:
|
||||
f"&response_type=code"
|
||||
f"&scope=openid profile email"
|
||||
)
|
||||
|
||||
|
||||
async def direct_login(username: str, password: str) -> dict:
|
||||
"""Login via Keycloak Direct Access Grant (#129).
|
||||
|
||||
Gibt bei Erfolg {access_token, refresh_token, expires_in} zurück.
|
||||
Wirft HTTPException bei Fehler (falsche Credentials, Account gesperrt, etc.).
|
||||
"""
|
||||
if not _is_auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="Auth nicht aktiviert")
|
||||
token_url = f"{_keycloak_issuer()}/protocol/openid-connect/token"
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": settings.keycloak_client_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
error = resp.json().get("error_description", "Ungültige Anmeldedaten")
|
||||
raise HTTPException(status_code=401, detail=error)
|
||||
if resp.status_code != 200:
|
||||
error = resp.json().get("error_description", f"Keycloak-Fehler ({resp.status_code})")
|
||||
raise HTTPException(status_code=resp.status_code, detail=error)
|
||||
return resp.json()
|
||||
|
||||
@ -20,15 +20,40 @@ class Settings(BaseSettings):
|
||||
llm_model_default: str = "qwen-plus-latest"
|
||||
llm_model_premium: str = "qwen-max"
|
||||
|
||||
# Keycloak (TODO)
|
||||
# Embedding-Modell: neue Rows werden immer mit embedding_model_write geschrieben,
|
||||
# Lese-Queries filtern nach embedding_model_read. Zwei Settings erlauben einen
|
||||
# Zero-Downtime-Switch von v3 auf v4 (siehe Issue #123):
|
||||
# Phase 1: write=v4, read=v3 → Prod läuft weiter, Reindex füllt v4-Rows
|
||||
# Phase 2: write=v4, read=v4 → Switch aktiv, alte v3-Rows können gelöscht werden
|
||||
embedding_model_write: str = "text-embedding-v4"
|
||||
embedding_model_read: str = "text-embedding-v3"
|
||||
embedding_dimensions: int = 1024
|
||||
|
||||
# Keycloak
|
||||
keycloak_url: str = ""
|
||||
keycloak_realm: str = ""
|
||||
keycloak_client_id: str = ""
|
||||
keycloak_admin_user: str = ""
|
||||
keycloak_admin_password: str = ""
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# SMTP (Issue #124 E-Mail-Benachrichtigung)
|
||||
# 1blu: smtp.1blu.de:465 SSL, username = Postfachname (NICHT E-Mail!),
|
||||
# z.B. "q294440_0-gwoe-toppyr". Passwort via ENV SMTP_PASSWORD.
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 465
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_email: str = "noreply@toppyr.de"
|
||||
smtp_from_name: str = "GWÖ-Antragsprüfer"
|
||||
# URL-Basis für Links in Mails (Unsubscribe, Detail-Ansicht)
|
||||
base_url: str = "https://gwoe.toppyr.de"
|
||||
# Token für Unsubscribe-Links (HMAC-Secret)
|
||||
unsubscribe_secret: str = "change-me-in-prod"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
1297
app/main.py
121
app/og_card.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Open-Graph-Bild-Rendering via Playwright (#141).
|
||||
|
||||
Rendert /v2/og-template?drucksache=X als PNG 1200×630.
|
||||
Cache in data/og-cache/ mit Key SHA256(drucksache + updated_at).
|
||||
|
||||
Öffentliche API:
|
||||
``render_og_card(drucksache, updated_at, base_url)``
|
||||
→ PNG-Bytes oder None bei Fehler
|
||||
|
||||
``cache_key(drucksache, updated_at)``
|
||||
→ Hex-String (SHA-256 Kurzform, 16 Zeichen)
|
||||
|
||||
``get_cached(drucksache, updated_at, cache_dir)``
|
||||
→ Path der gecacheten Datei oder None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "og-cache"
|
||||
|
||||
|
||||
def cache_key(drucksache: str, updated_at: str) -> str:
|
||||
"""Berechnet den Cache-Schlüssel als 16-stelligen SHA-256-Präfix.
|
||||
|
||||
Args:
|
||||
drucksache: Drucksachen-ID (z.B. "NRW-18/1234").
|
||||
updated_at: ISO-Zeitstempel des letzten Updates aus der Datenbank.
|
||||
|
||||
Returns:
|
||||
16 Hex-Zeichen (64-Bit-Präfix des SHA-256).
|
||||
"""
|
||||
raw = f"{drucksache}|{updated_at}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _cache_path(drucksache: str, updated_at: str, cache_dir: Path) -> Path:
|
||||
key = cache_key(drucksache, updated_at)
|
||||
safe_name = drucksache.replace("/", "_").replace(" ", "_")
|
||||
return cache_dir / f"{safe_name}_{key}.png"
|
||||
|
||||
|
||||
def get_cached(
|
||||
drucksache: str,
|
||||
updated_at: str,
|
||||
cache_dir: Optional[Path] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Gibt den Pfad der gecacheten PNG-Datei zurück, wenn sie existiert.
|
||||
|
||||
Args:
|
||||
drucksache: Drucksachen-ID.
|
||||
updated_at: ISO-Zeitstempel — ändert sich dieser, ist der Cache ungültig.
|
||||
cache_dir: Verzeichnis für den Cache. Standard: data/og-cache/.
|
||||
|
||||
Returns:
|
||||
Path-Objekt wenn Treffer, sonst None.
|
||||
"""
|
||||
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
|
||||
path = _cache_path(drucksache, updated_at, cache_dir)
|
||||
return path if path.exists() else None
|
||||
|
||||
|
||||
def render_og_card(
|
||||
drucksache: str,
|
||||
updated_at: str,
|
||||
base_url: str = "http://127.0.0.1:8000",
|
||||
cache_dir: Optional[Path] = None,
|
||||
) -> Optional[bytes]:
|
||||
"""Rendert die OG-Karte als PNG via Playwright und legt sie im Cache ab.
|
||||
|
||||
Bei Cache-Hit wird das Rendering übersprungen.
|
||||
|
||||
Args:
|
||||
drucksache: Drucksachen-ID (URL-kodierbar).
|
||||
updated_at: ISO-Zeitstempel für den Cache-Key.
|
||||
base_url: Interne Basis-URL der App (Playwright greift darauf zu).
|
||||
cache_dir: Cache-Verzeichnis. Standard: data/og-cache/.
|
||||
|
||||
Returns:
|
||||
PNG-Bytes bei Erfolg, None bei Fehler.
|
||||
"""
|
||||
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cached = get_cached(drucksache, updated_at, cache_dir)
|
||||
if cached:
|
||||
logger.debug("OG-Cache-Hit für %s", drucksache)
|
||||
return cached.read_bytes()
|
||||
|
||||
dest = _cache_path(drucksache, updated_at, cache_dir)
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
import urllib.parse
|
||||
|
||||
encoded = urllib.parse.quote(drucksache, safe="")
|
||||
url = f"{base_url}/v2/og-template?drucksache={encoded}"
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"])
|
||||
page = browser.new_page(viewport={"width": 1200, "height": 630})
|
||||
page.goto(url, wait_until="networkidle", timeout=15000)
|
||||
png_bytes = page.screenshot(
|
||||
clip={"x": 0, "y": 0, "width": 1200, "height": 630},
|
||||
type="png",
|
||||
)
|
||||
browser.close()
|
||||
|
||||
dest.write_bytes(png_bytes)
|
||||
logger.info("OG-Karte gerendert: %s → %s", drucksache, dest.name)
|
||||
return png_bytes
|
||||
|
||||
except Exception:
|
||||
logger.exception("Playwright-Render fehlgeschlagen für %s", drucksache)
|
||||
return None
|
||||
66
app/queue.py
@ -217,11 +217,14 @@ async def graceful_shutdown(timeout: int = 900):
|
||||
timeout, sum(1 for j in _jobs.values() if j.get("status") == "processing"))
|
||||
|
||||
|
||||
async def re_enqueue_pending():
|
||||
async def re_enqueue_pending(analysis_callback=None):
|
||||
"""Re-enqueue jobs that were queued or processing when the container died.
|
||||
|
||||
Reads drucksache + bundesland from the jobs table and re-triggers
|
||||
the full analysis pipeline. This makes the queue crash-safe.
|
||||
Jobs WITH a drucksache column get re-enqueued automatically (if callback provided).
|
||||
Jobs WITHOUT drucksache (legacy) get marked as stale and cleaned up.
|
||||
|
||||
Args:
|
||||
analysis_callback: async function(job_id, drucksache, text, bundesland, model, doc)
|
||||
"""
|
||||
import aiosqlite
|
||||
from .config import settings
|
||||
@ -229,35 +232,72 @@ async def re_enqueue_pending():
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"SELECT id, bundesland, input_preview FROM jobs "
|
||||
"SELECT id, bundesland, drucksache, model FROM jobs "
|
||||
"WHERE status IN ('queued', 'processing') ORDER BY created_at"
|
||||
)
|
||||
pending = await rows.fetchall()
|
||||
|
||||
if not pending:
|
||||
# Alte stale-Jobs ohne drucksache aufräumen
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
deleted = await db.execute(
|
||||
"DELETE FROM jobs WHERE status='stale' AND (drucksache IS NULL OR drucksache='')"
|
||||
)
|
||||
if deleted.rowcount > 0:
|
||||
logger.info("Cleaned up %d legacy stale jobs without drucksache", deleted.rowcount)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
logger.info("Re-enqueueing %d pending jobs from previous run", len(pending))
|
||||
logger.info("Found %d pending jobs from previous run", len(pending))
|
||||
|
||||
# Importiere hier um Zirkularität zu vermeiden
|
||||
from .parlamente import get_adapter
|
||||
|
||||
re_enqueued = 0
|
||||
marked_stale = 0
|
||||
for row in pending:
|
||||
job_id = row["id"]
|
||||
bundesland = row["bundesland"] or "NRW"
|
||||
drucksache = row["drucksache"]
|
||||
model = row["model"] or "qwen-plus"
|
||||
|
||||
# Drucksache aus input_preview extrahieren — das Feld enthält
|
||||
# die ersten 500 Zeichen des Antragstexts, aber wir brauchen
|
||||
# die Drucksache. Prüfe ob ein Assessment fehlt das diesen
|
||||
# Job betrifft. Wenn ja: die Drucksache steht nicht im Job.
|
||||
# Markiere als stale und der User kann manuell re-triggern.
|
||||
if not drucksache or not analysis_callback:
|
||||
# Legacy-Job ohne Drucksache oder kein Callback → stale markieren
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE jobs SET status='stale', updated_at=datetime('now') WHERE id=?",
|
||||
(job_id,),
|
||||
)
|
||||
await db.commit()
|
||||
re_enqueued += 1
|
||||
marked_stale += 1
|
||||
continue
|
||||
|
||||
logger.info("Marked %d jobs as stale (re-trigger via UI)", re_enqueued)
|
||||
# Job mit Drucksache → neu enqueuen
|
||||
try:
|
||||
adapter = get_adapter(bundesland)
|
||||
doc = await adapter.get_document(drucksache)
|
||||
if not doc:
|
||||
raise ValueError(f"Drucksache {drucksache} nicht gefunden")
|
||||
text = await adapter.download_text(drucksache)
|
||||
if not text:
|
||||
raise ValueError(f"PDF-Text für {drucksache} leer")
|
||||
|
||||
position = await enqueue(
|
||||
job_id,
|
||||
analysis_callback,
|
||||
job_id, drucksache, text, bundesland, model, doc,
|
||||
drucksache=drucksache,
|
||||
)
|
||||
re_enqueued += 1
|
||||
logger.info("Re-enqueued %s (%s) at position %d", drucksache, bundesland, position)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Could not re-enqueue %s (%s): %s — marking stale", drucksache, bundesland, e)
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE jobs SET status='stale', error=?, updated_at=datetime('now') WHERE id=?",
|
||||
(str(e)[:200], job_id),
|
||||
)
|
||||
await db.commit()
|
||||
marked_stale += 1
|
||||
|
||||
logger.info("Re-enqueued %d jobs, marked %d stale", re_enqueued, marked_stale)
|
||||
|
||||
32
app/static/v2/fonts.css
Normal file
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* fonts.css — Nunito Sans self-hosted (Fallback für Avenir)
|
||||
* Google Fonts v19, Latin-Subset, woff2
|
||||
* font-display: swap verhindert FOIT; size-adjust korrigiert metrischen
|
||||
* Versatz gegenüber Avenir Next (ca. 95 % — empirisch grob gemessen).
|
||||
*/
|
||||
|
||||
/* ── Normal (variable font, deckt Gewichte 300–900 ab) ──────────── */
|
||||
@font-face {
|
||||
font-family: "Nunito Sans";
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url("/static/v2/fonts/nunito-sans-latin-variable.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
size-adjust: 95%;
|
||||
}
|
||||
|
||||
/* ── Italic 400 ─────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Nunito Sans";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/v2/fonts/nunito-sans-italic-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
size-adjust: 95%;
|
||||
}
|
||||
BIN
app/static/v2/fonts/nunito-sans-italic-latin.woff2
Normal file
BIN
app/static/v2/fonts/nunito-sans-latin-variable.woff2
Normal file
1
app/static/v2/icons/phosphor/arrow-square-out.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
1
app/static/v2/icons/phosphor/book-open.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></svg>
|
||||
|
After Width: | Height: | Size: 396 B |
1
app/static/v2/icons/phosphor/bookmark-simple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
1
app/static/v2/icons/phosphor/chart-bar.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
1
app/static/v2/icons/phosphor/circle-half.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,16.37a86.4,86.4,0,0,1,16,3V212.67a86.4,86.4,0,0,1-16,3Zm32,9.26a87.81,87.81,0,0,1,16,10.54V195.83a87.81,87.81,0,0,1-16,10.54ZM40,128a88.11,88.11,0,0,1,80-87.63V215.63A88.11,88.11,0,0,1,40,128Zm160,50.54V77.46a87.82,87.82,0,0,1,0,101.08Z"/></svg>
|
||||
|
After Width: | Height: | Size: 396 B |
1
app/static/v2/icons/phosphor/envelope-simple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
1
app/static/v2/icons/phosphor/file-csv.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M48,180c0,11,7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,85.78,206.4,30.06,30.06,0,0,1,64,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,64,160C55.18,160,48,169,48,180Zm79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84-1.25-.81-1.23-1-1.12-1.9a4.57,4.57,0,0,1,2-3.67c4.6-3.12,15.34-1.73,19.82-.56A8,8,0,0,0,142,145.86c-2.12-.55-21-5.22-32.84,2.76a20.58,20.58,0,0,0-9,14.95c-2,15.88,13.65,20.41,23,23.11,12.06,3.49,13.12,4.92,12.78,7.59-.31,2.41-1.26,3.34-2.14,3.93-4.6,3.06-15.17,1.56-19.55.36A8,8,0,0,0,109.94,214a61.34,61.34,0,0,0,15.19,2c5.82,0,12.3-1,17.49-4.46a20.82,20.82,0,0,0,9.19-15.23C154,179,137.49,174.17,127.6,171.31Zm83.09-26.84a8,8,0,0,0-10.23,4.84L188,184.21l-12.47-34.9a8,8,0,0,0-15.07,5.38l20,56a8,8,0,0,0,15.07,0l20-56A8,8,0,0,0,210.69,144.47ZM216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-27.31-8L160,51.31V80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
app/static/v2/icons/phosphor/file-plus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-40-64a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,152Z"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
1
app/static/v2/icons/phosphor/graph.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 754 B |
1
app/static/v2/icons/phosphor/info.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></svg>
|
||||
|
After Width: | Height: | Size: 348 B |
1
app/static/v2/icons/phosphor/key.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></svg>
|
||||
|
After Width: | Height: | Size: 640 B |
1
app/static/v2/icons/phosphor/list-checks.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 567 B |
1
app/static/v2/icons/phosphor/magnifying-glass-plus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,112a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Zm77.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88.11,88.11,0,1,1,11.31-11.31l50.07,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
1
app/static/v2/icons/phosphor/magnifying-glass.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
|
||||
|
After Width: | Height: | Size: 243 B |
1
app/static/v2/icons/phosphor/moon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
1
app/static/v2/icons/phosphor/rss.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M106.91,149.09A71.53,71.53,0,0,1,128,200a8,8,0,0,1-16,0,56,56,0,0,0-56-56,8,8,0,0,1,0-16A71.53,71.53,0,0,1,106.91,149.09ZM56,80a8,8,0,0,0,0,16A104,104,0,0,1,160,200a8,8,0,0,0,16,0A120,120,0,0,0,56,80Zm118.79,1.21A166.9,166.9,0,0,0,56,32a8,8,0,0,0,0,16A151,151,0,0,1,163.48,92.52,151,151,0,0,1,208,200a8,8,0,0,0,16,0A166.9,166.9,0,0,0,174.79,81.21ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 492 B |
1
app/static/v2/icons/phosphor/sign-out.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
1
app/static/v2/icons/phosphor/stack.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg>
|
||||
|
After Width: | Height: | Size: 483 B |
1
app/static/v2/icons/phosphor/sun.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>
|
||||
|
After Width: | Height: | Size: 685 B |
1
app/static/v2/icons/phosphor/tag.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></svg>
|
||||
|
After Width: | Height: | Size: 345 B |
1
app/static/v2/icons/phosphor/user-check.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M144,157.68a68,68,0,1,0-71.9,0c-20.65,6.76-39.23,19.39-54.17,37.17a8,8,0,0,0,12.25,10.3C50.25,181.19,77.91,168,108,168s57.75,13.19,77.87,37.15a8,8,0,0,0,12.25-10.3C183.18,177.07,164.6,164.44,144,157.68ZM56,100a52,52,0,1,1,52,52A52.06,52.06,0,0,1,56,100Zm197.66,33.66-32,32a8,8,0,0,1-11.32,0l-16-16a8,8,0,0,1,11.32-11.32L216,148.69l26.34-26.35a8,8,0,0,1,11.32,11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 465 B |
1
app/static/v2/icons/phosphor/user.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
|
||||
|
After Width: | Height: | Size: 340 B |
104
app/static/v2/tokens.css
Normal file
@ -0,0 +1,104 @@
|
||||
/*
|
||||
* tokens.css — ECOnGOOD Corporate Design Tokens (Manual Juni 2024)
|
||||
* Alle v2-Komponenten referenzieren ausschließlich diese Variablen.
|
||||
* Kein Hardcoded Hex-Wert in Screens oder Primitives.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Vier Grundfarben (Manual Seite 5) ─────────────────────────── */
|
||||
--ecg-dark: #5A5A5A; /* Dunkelgrau — primary text, PANTONE 425 U */
|
||||
--ecg-green: #889E33; /* Grün — accent, PANTONE 583 U */
|
||||
--ecg-blue: #009DA5; /* Blau — accent, PANTONE 320 U */
|
||||
--ecg-light: #BFBFBF; /* Hellgrau — hairlines, infografiken */
|
||||
--ecg-teal: #009DA5; /* Alias für --ecg-blue (Kompatibilität) */
|
||||
|
||||
/* ── Oberflächen ────────────────────────────────────────────────── */
|
||||
--paper: #FFFFFF;
|
||||
--surface: #F7F7F5;
|
||||
--hairline: #E6E6E3;
|
||||
|
||||
/* ── Semantische Oberflächen-Aliase (Dark-Mode-fähig) ───────────── */
|
||||
--ecg-card-bg: #FFFFFF; /* Karten-Hintergrund (= --paper) */
|
||||
--ecg-bg-subtle: #F7F7F5; /* Subtiler Hintergrund (= --surface) */
|
||||
--ecg-border: #E6E6E3; /* Rahmenfarbe (= --hairline) */
|
||||
--ecg-text-muted: #8C8C8C; /* Gedämpfter Text */
|
||||
|
||||
/* ── Score-Band — Tints aus ECG-Grün / zurückhaltendem Warn-Rot ── */
|
||||
--score-high-bg: #E8EED1;
|
||||
--score-high-fg: #5E6F1F;
|
||||
--score-mid-bg: #F1F1EE;
|
||||
--score-mid-fg: #5A5A5A;
|
||||
--score-low-bg: #F1DCDA;
|
||||
--score-low-fg: #9A2A2A;
|
||||
|
||||
/* ── Score-Chip-Farben (Fraktions-Tabelle) ──────────────────────── */
|
||||
--score-chip-green-bg: #CDDAA1; /* = --redline-ins-bg */
|
||||
--score-chip-green-fg: #236020; /* AA 5.1:1 auf chip-green-bg */
|
||||
--score-chip-mid-bg: #fff3cd;
|
||||
--score-chip-mid-fg: #7d5a00;
|
||||
--score-chip-red-bg: #EFC9C3; /* = --redline-del-bg */
|
||||
--score-chip-red-fg: #a00000;
|
||||
|
||||
/* ── Redline-Farben (nur für diff-Markup, nie als UI-Chrome) ────── */
|
||||
--redline-del-bg: #EFC9C3;
|
||||
--redline-ins-bg: #CDDAA1;
|
||||
--redline-contra: #9A2A2A;
|
||||
|
||||
/* ── Typografie ─────────────────────────────────────────────────── */
|
||||
--font-sans: "Avenir Next", "Avenir", "Nunito Sans", Arial, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", monospace;
|
||||
|
||||
/* ── Spacing-Raster (4-px-Basis) ───────────────────────────────── */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
--sidebar-width: 230px;
|
||||
--content-max: 1180px;
|
||||
--breakpoint-mobile: 900px;
|
||||
|
||||
}
|
||||
|
||||
/* ── Dark-Mode-Variante ─────────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
--ecg-dark: #D0D0CC;
|
||||
--ecg-light: #444440;
|
||||
--paper: #1A1A18;
|
||||
--surface: #222220;
|
||||
--hairline: #333330;
|
||||
|
||||
/* ── Semantische Oberflächen-Aliase (Dark) ──────────────────────── */
|
||||
--ecg-card-bg: #222220; /* = --surface */
|
||||
--ecg-bg-subtle: #2A2A28;
|
||||
--ecg-border: #333330; /* = --hairline */
|
||||
--ecg-text-muted: #888884;
|
||||
|
||||
/* Score-Bänder im Dark Mode: Chroma halten, Lightness leicht erhöht */
|
||||
--score-high-bg: #2A3010;
|
||||
--score-high-fg: #AABE55;
|
||||
--score-mid-bg: #252523;
|
||||
--score-mid-fg: #C0C0BC;
|
||||
--score-low-bg: #2E1515;
|
||||
--score-low-fg: #E07070;
|
||||
|
||||
/* ── Score-Chip-Farben (Dark) ───────────────────────────────────── */
|
||||
--score-chip-green-bg: #1E3010;
|
||||
--score-chip-green-fg: #8FBF6F;
|
||||
--score-chip-mid-bg: #2A2510;
|
||||
--score-chip-mid-fg: #C9A840;
|
||||
--score-chip-red-bg: #301010;
|
||||
--score-chip-red-fg: #E07070;
|
||||
|
||||
/* ── Redline (Dark) — Chroma gedämpft, lesbar auf dunklem Ground ── */
|
||||
--redline-del-bg: #3A1A18;
|
||||
--redline-ins-bg: #1E2E0A;
|
||||
--redline-contra: #C04040;
|
||||
}
|
||||
1048
app/static/v2/v2.css
Normal file
19
app/templates/_header.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!-- _header.html — gemeinsame Kopfzeile für Unterseiten (legal, methodik, quellen, auswertungen)
|
||||
Variablen (alle optional mit Defaults):
|
||||
app_name — Anwendungsname (immer verfügbar)
|
||||
page_title — Seitentitel rechts neben dem App-Namen; leer = nicht anzeigen
|
||||
back_url — Ziel des Zurück-Links; Default: /
|
||||
back_label — Linktext; Default: "← {{ app_name }}"
|
||||
header_nav — zusätzliche Nav-Links als HTML-String (selten benötigt)
|
||||
-->
|
||||
<div class="header">
|
||||
<a href="{{ back_url | default('/') }}" style="color:var(--color-blue);text-decoration:none;">
|
||||
{{ back_label | default('← ' + app_name) }}
|
||||
</a>
|
||||
{% if page_title %}
|
||||
<h1 style="color:var(--color-blue);font-size:1.5rem;">{{ page_title }}</h1>
|
||||
{% endif %}
|
||||
{% if header_nav %}
|
||||
<nav>{{ header_nav | safe }}</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -8,7 +8,7 @@
|
||||
:root {
|
||||
--color-darkgray: #5a5a5a;
|
||||
--color-green: #889e33;
|
||||
--color-blue: #009da5;
|
||||
--color-blue: #007a80;
|
||||
--color-lightgray: #bfbfbf;
|
||||
--color-bg: #f5f5f5;
|
||||
--color-orange: #F7941D;
|
||||
@ -160,12 +160,12 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Auswertungen — Bundesland × Partei × Wahlperiode</h1>
|
||||
<nav>
|
||||
<a href="/">← zurück zur Suche</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
</nav>
|
||||
{% set page_title = 'Auswertungen — Bundesland × Partei × Wahlperiode' %}
|
||||
{% set header_nav = '<a href="/quellen">Quellen</a>' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div style="background:#e8f4f8;border-left:4px solid #007a80;padding:0.6rem 1.2rem;font-size:0.9rem;color:#333;">
|
||||
Diese Seite ist auch direkt in der Haupt-App verfügbar: <a href="/?mode=auswertungen" style="color:#007a80;">zur integrierten Auswertungs-Ansicht →</a>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
@ -198,6 +198,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:2rem;color:var(--color-blue);">Thema × Fraktion</h2>
|
||||
<p style="font-size:0.85rem;color:#666;margin-bottom:1rem;">Ø-GWÖ-Score pro Thema und Fraktion. Klick auf eine Zelle für Details. Grün = GWÖ-freundlich, Rot = GWÖ-kritisch.</p>
|
||||
<div id="themen-container"><div class="empty-state">Lade Themen-Matrix …</div></div>
|
||||
|
||||
<script src="/static/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const wpFilter = document.getElementById('wp-filter');
|
||||
const reloadBtn = document.getElementById('reload');
|
||||
@ -274,12 +279,43 @@
|
||||
body.innerHTML = '<p style="color:#888;">Keine Daten für diese Kombination.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>';
|
||||
for (const row of z.wahlperioden) {
|
||||
html += `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`;
|
||||
// Chart + Tabelle
|
||||
body.innerHTML = '<canvas id="zeitreihe-chart" style="max-height:300px;margin-bottom:1rem;"></canvas>' +
|
||||
'<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>' +
|
||||
z.wahlperioden.map(row => `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
// Chart.js rendern
|
||||
if (window.Chart) {
|
||||
const ctx = document.getElementById('zeitreihe-chart');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: z.wahlperioden.map(r => 'WP ' + r.wp),
|
||||
datasets: [{
|
||||
label: `Ø GWÖ-Score ${partei} (${bundesland})`,
|
||||
data: z.wahlperioden.map(r => r.avg),
|
||||
borderColor: '#009da5',
|
||||
backgroundColor: 'rgba(0,157,165,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 5,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: { min: 0, max: 10, title: { display: true, text: 'GWÖ-Score' } },
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
afterLabel: (ctx) => `n=${z.wahlperioden[ctx.dataIndex].n} Anträge`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
body.innerHTML = html;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<p style="color:#d00;">Fehler: ${e}</p>`;
|
||||
}
|
||||
@ -298,6 +334,41 @@
|
||||
});
|
||||
|
||||
loadMatrix();
|
||||
loadThemenMatrix();
|
||||
|
||||
async function loadThemenMatrix() {
|
||||
const container = document.getElementById('themen-container');
|
||||
try {
|
||||
const r = await fetch('/api/auswertungen/themen-matrix');
|
||||
const data = await r.json();
|
||||
if (!data.themen.length) {
|
||||
container.innerHTML = '<div class="empty-state">Noch zu wenige Assessments für Themen-Analyse.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="matrix"><thead><tr><th class="row-header">Thema</th>';
|
||||
for (const frak of data.fraktionen) {
|
||||
html += `<th>${frak}</th>`;
|
||||
}
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const thema of data.themen) {
|
||||
html += `<tr><th class="row-header">${thema}</th>`;
|
||||
for (const frak of data.fraktionen) {
|
||||
const cell = (data.cells[thema] || {})[frak];
|
||||
if (cell) {
|
||||
const cls = scoreClass(cell.avg);
|
||||
html += `<td class="cell-with-data ${cls}" title="${thema} × ${frak}: Ø ${cell.avg}/10 (${cell.n} Anträge)">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
|
||||
} else {
|
||||
html += '<td class="empty">—</td>';
|
||||
}
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="empty-state">Fehler: ${e}</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
600
app/templates/legal.html
Normal file
@ -0,0 +1,600 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} — {{ app_name }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-darkgray: #5a5a5a;
|
||||
--color-green: #889e33;
|
||||
--color-blue: #007a80;
|
||||
--color-lightgray: #bfbfbf;
|
||||
--color-bg: #f5f5f5;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Avenir', 'Segoe UI', sans-serif;
|
||||
color: var(--color-darkgray);
|
||||
line-height: 1.6;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--color-lightgray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header h1 { color: var(--color-blue); font-size: 1.5rem; }
|
||||
.header a { color: var(--color-blue); text-decoration: none; }
|
||||
.container { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
|
||||
h2 {
|
||||
color: var(--color-blue);
|
||||
margin: 2rem 0 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--color-blue);
|
||||
}
|
||||
h3 { color: var(--color-green); margin: 1.5rem 0 0.5rem; }
|
||||
h4 { margin: 1rem 0 0.3rem; }
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.card p, .card ul { margin-bottom: 0.8rem; }
|
||||
.card ul { padding-left: 1.5rem; }
|
||||
.card li { margin-bottom: 0.3rem; }
|
||||
.card a { color: var(--color-blue); }
|
||||
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
th, td { text-align: left; padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; }
|
||||
th { color: var(--color-blue); font-size: 0.85rem; }
|
||||
.footer {
|
||||
text-align: center; padding: 2rem; color: #888;
|
||||
font-size: 0.85rem; border-top: 1px solid #eee; margin-top: 2rem;
|
||||
}
|
||||
.footer a { color: var(--color-blue); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% set page_title = title %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="container">
|
||||
{% if section == 'impressum' %}
|
||||
|
||||
<!-- ===== IMPRESSUM ===== -->
|
||||
<h2>Impressum</h2>
|
||||
<div class="card">
|
||||
<h3>Angaben gemäß § 5 DDG</h3>
|
||||
<p>
|
||||
Tobias Rödel<br>
|
||||
Rüggeweg 25<br>
|
||||
58093 Hagen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Kontakt</h3>
|
||||
<p>
|
||||
Telefon: 0170 3039817<br>
|
||||
Telefax: 02331 9814882<br>
|
||||
E-Mail: mail@tobiasroedel.de
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Umsatzsteuer-ID</h3>
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: DE421290194
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Berufshaftpflichtversicherung</h3>
|
||||
<p>
|
||||
exali GmbH<br>
|
||||
Franz-Kobinger-Str. 9<br>
|
||||
86157 Augsburg<br>
|
||||
Geltungsraum: Deutschland
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Redaktionell verantwortlich</h3>
|
||||
<p>
|
||||
Tobias Rödel<br>
|
||||
Rüggeweg 25<br>
|
||||
58093 Hagen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>EU-Streitschlichtung</h3>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
|
||||
bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>.
|
||||
</p>
|
||||
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h3>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% elif section == 'datenschutz' %}
|
||||
|
||||
<!-- ===== DATENSCHUTZERKLÄRUNG ===== -->
|
||||
<h2>Datenschutzerklärung</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>1. Datenschutz auf einen Blick</h3>
|
||||
|
||||
<h4>Allgemeine Hinweise</h4>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||
personenbezogenen Daten passiert, wenn Sie diese Website besuchen.
|
||||
Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert
|
||||
werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen
|
||||
Sie unserer unter diesem Text aufgeführten Datenschutzerklärung.
|
||||
</p>
|
||||
|
||||
<h4>Datenerfassung auf dieser Website</h4>
|
||||
<p><strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong></p>
|
||||
<p>
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
|
||||
Dessen Kontaktdaten können Sie dem Abschnitt „Hinweis zur verantwortlichen
|
||||
Stelle" in dieser Datenschutzerklärung entnehmen.
|
||||
</p>
|
||||
|
||||
<p><strong>Wie erfassen wir Ihre Daten?</strong></p>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen
|
||||
(z. B. bei der Registrierung). Andere Daten werden automatisch oder nach
|
||||
Ihrer Einwilligung beim Besuch der Website durch unsere IT-Systeme erfasst.
|
||||
Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem
|
||||
oder Uhrzeit des Seitenaufrufs). Die Erfassung dieser Daten erfolgt
|
||||
automatisch, sobald Sie diese Website betreten.
|
||||
</p>
|
||||
|
||||
<p><strong>Wofür nutzen wir Ihre Daten?</strong></p>
|
||||
<p>
|
||||
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der
|
||||
Website zu gewährleisten. Weitere Daten können zur Analyse Ihres
|
||||
Nutzerverhaltens verwendet werden — wir setzen jedoch <strong>keine
|
||||
Analyse-/Tracking-Tools</strong> ein.
|
||||
</p>
|
||||
|
||||
<p><strong>Welche Rechte haben Sie bezüglich Ihrer Daten?</strong></p>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger
|
||||
und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben
|
||||
außerdem ein Recht, die Berichtigung oder Löschung dieser Daten zu verlangen.
|
||||
Wenn Sie eine Einwilligung zur Datenverarbeitung erteilt haben, können Sie
|
||||
diese Einwilligung jederzeit für die Zukunft widerrufen. Außerdem haben Sie
|
||||
das Recht, unter bestimmten Umständen die Einschränkung der Verarbeitung
|
||||
Ihrer personenbezogenen Daten zu verlangen. Des Weiteren steht Ihnen ein
|
||||
Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.
|
||||
</p>
|
||||
<p>Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit an uns wenden.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>2. Hosting</h3>
|
||||
<p>
|
||||
Wir hosten die Inhalte unserer Website bei folgendem Anbieter:
|
||||
</p>
|
||||
|
||||
<h4>Externes Hosting</h4>
|
||||
<p>
|
||||
Diese Website wird extern gehostet bei:<br>
|
||||
<strong>netcup GmbH</strong><br>
|
||||
Daimlerstraße 25, 76185 Karlsruhe, Deutschland
|
||||
</p>
|
||||
<p>
|
||||
Die personenbezogenen Daten, die auf dieser Website erfasst werden, werden
|
||||
auf den Servern des Hosters gespeichert. Hierbei kann es sich v. a. um
|
||||
IP-Adressen, Kontaktanfragen, Meta- und Kommunikationsdaten, Nutzungsdaten
|
||||
und sonstige Daten, die über eine Website generiert werden, handeln.
|
||||
</p>
|
||||
<p>
|
||||
Das externe Hosting erfolgt zum Zwecke der Vertragserfüllung gegenüber
|
||||
unseren potenziellen und bestehenden Nutzern (Art. 6 Abs. 1 lit. b DSGVO)
|
||||
und im Interesse einer sicheren, schnellen und effizienten Bereitstellung
|
||||
unseres Online-Angebots durch einen professionellen Anbieter (Art. 6 Abs. 1
|
||||
lit. f DSGVO).
|
||||
</p>
|
||||
|
||||
<h4>Auftragsverarbeitung</h4>
|
||||
<p>
|
||||
Wir haben einen Vertrag über Auftragsverarbeitung (AVV) zur Nutzung des
|
||||
oben genannten Dienstes geschlossen. Hierbei handelt es sich um einen
|
||||
datenschutzrechtlich vorgeschriebenen Vertrag, der gewährleistet, dass
|
||||
dieser die personenbezogenen Daten unserer Websitebesucher nur nach unseren
|
||||
Weisungen und unter Einhaltung der DSGVO verarbeitet.
|
||||
</p>
|
||||
|
||||
<h4>Authentifizierung (Keycloak SSO)</h4>
|
||||
<p>
|
||||
Für die Benutzeranmeldung setzen wir Keycloak ein, eine Open-Source-Lösung
|
||||
für Identity- und Access-Management. Keycloak läuft auf demselben Server
|
||||
bei netcup. Bei der Registrierung werden Vorname, Nachname, E-Mail-Adresse
|
||||
und Benutzername gespeichert. Diese Daten werden ausschließlich für die
|
||||
Authentifizierung und Benutzerverwaltung verwendet.
|
||||
</p>
|
||||
|
||||
<h4>Cookies</h4>
|
||||
<p>
|
||||
Diese Website verwendet ausschließlich <strong>funktional notwendige
|
||||
Cookies</strong>. Ein Cookie (<code>access_token</code>) wird nach
|
||||
erfolgreicher Anmeldung gesetzt und enthält ein JWT-Token zur
|
||||
Authentifizierung. Es werden <strong>keine Tracking-Cookies</strong>,
|
||||
keine Analyse-Cookies und keine Cookies von Drittanbietern gesetzt.
|
||||
</p>
|
||||
<table style="width:100%;font-size:0.9rem;border-collapse:collapse;margin:0.5rem 0;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #ddd;">
|
||||
<th style="text-align:left;padding:0.3rem;">Name</th>
|
||||
<th style="text-align:left;padding:0.3rem;">Zweck</th>
|
||||
<th style="text-align:left;padding:0.3rem;">Speicherdauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #eee;">
|
||||
<td style="padding:0.3rem;"><code>access_token</code></td>
|
||||
<td style="padding:0.3rem;">Authentifizierung (JWT)</td>
|
||||
<td style="padding:0.3rem;">Session (max. 5 Minuten)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>3. Allgemeine Hinweise und Pflichtinformationen</h3>
|
||||
|
||||
<h4>Datenschutz</h4>
|
||||
<p>
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr
|
||||
ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend
|
||||
den gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
</p>
|
||||
<p>
|
||||
Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der
|
||||
Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser
|
||||
Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||
</p>
|
||||
|
||||
<h4>Hinweis zur verantwortlichen Stelle</h4>
|
||||
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
|
||||
<p>
|
||||
Tobias Rödel<br>
|
||||
Rüggeweg 25<br>
|
||||
58093 Hagen
|
||||
</p>
|
||||
<p>
|
||||
Telefon: 0170 3039817<br>
|
||||
E-Mail: mail@tobiasroedel.de
|
||||
</p>
|
||||
<p>
|
||||
Verantwortliche Stelle ist die natürliche oder juristische Person, die allein
|
||||
oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von
|
||||
personenbezogenen Daten (z. B. Namen, E-Mail-Adressen o. Ä.) entscheidet.
|
||||
</p>
|
||||
|
||||
<h4>Speicherdauer</h4>
|
||||
<p>
|
||||
Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer
|
||||
genannt wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck
|
||||
für die Datenverarbeitung entfällt. Wenn Sie ein berechtigtes Löschersuchen
|
||||
geltend machen oder eine Einwilligung zur Datenverarbeitung widerrufen, werden
|
||||
Ihre Daten gelöscht, sofern wir keine anderen rechtlich zulässigen Gründe
|
||||
für die Speicherung Ihrer personenbezogenen Daten haben; in letzterem Fall
|
||||
erfolgt die Löschung nach Fortfall dieser Gründe.
|
||||
</p>
|
||||
|
||||
<h4>Allgemeine Hinweise zu den Rechtsgrundlagen der Datenverarbeitung auf dieser Website</h4>
|
||||
<p>
|
||||
Sofern Sie in die Datenverarbeitung eingewilligt haben, verarbeiten wir Ihre
|
||||
personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. a DSGVO bzw.
|
||||
Art. 9 Abs. 2 lit. a DSGVO. Im Falle einer ausdrücklichen Einwilligung in die
|
||||
Übertragung personenbezogener Daten in Drittstaaten erfolgt die Datenverarbeitung
|
||||
außerdem auf Grundlage von Art. 49 Abs. 1 lit. a DSGVO. Sofern die Verarbeitung
|
||||
zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen
|
||||
erforderlich ist, verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1
|
||||
lit. b DSGVO. Ferner verarbeiten wir Ihre Daten, sofern diese zur Erfüllung
|
||||
einer rechtlichen Verpflichtung erforderlich sind auf Grundlage von Art. 6
|
||||
Abs. 1 lit. c DSGVO. Die Datenverarbeitung kann ferner auf Grundlage unseres
|
||||
berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO erfolgen.
|
||||
</p>
|
||||
|
||||
<h4>Widerruf Ihrer Einwilligung zur Datenverarbeitung</h4>
|
||||
<p>
|
||||
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung
|
||||
möglich. Sie können eine bereits erteilte Einwilligung jederzeit widerrufen.
|
||||
Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom
|
||||
Widerruf unberührt.
|
||||
</p>
|
||||
|
||||
<h4>Widerspruchsrecht gegen die Datenerhebung in besonderen Fällen (Art. 21 DSGVO)</h4>
|
||||
<p>
|
||||
<strong>Wenn die Datenverarbeitung auf Grundlage von Art. 6 Abs. 1 lit. e oder
|
||||
f DSGVO erfolgt, haben Sie jederzeit das Recht, aus Gründen, die sich aus Ihrer
|
||||
besonderen Situation ergeben, gegen die Verarbeitung Ihrer personenbezogenen Daten
|
||||
Widerspruch einzulegen. Die jeweilige Rechtsgrundlage, auf denen eine Verarbeitung
|
||||
beruht, entnehmen Sie dieser Datenschutzerklärung. Wenn Sie Widerspruch einlegen,
|
||||
werden wir Ihre betroffenen personenbezogenen Daten nicht mehr verarbeiten, es sei
|
||||
denn, wir können zwingende schutzwürdige Gründe für die Verarbeitung nachweisen,
|
||||
die Ihre Interessen, Rechte und Freiheiten überwiegen oder die Verarbeitung dient
|
||||
der Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Widerspruch
|
||||
nach Art. 21 Abs. 1 DSGVO).</strong>
|
||||
</p>
|
||||
|
||||
<h4>Beschwerderecht bei der zuständigen Aufsichtsbehörde</h4>
|
||||
<p>
|
||||
Im Falle von Verstößen gegen die DSGVO steht den Betroffenen ein Beschwerderecht
|
||||
bei einer Aufsichtsbehörde zu. Das Beschwerderecht besteht unbeschadet anderweitiger
|
||||
verwaltungsrechtlicher oder gerichtlicher Rechtsbehelfe.
|
||||
</p>
|
||||
|
||||
<h4>Recht auf Datenübertragbarkeit</h4>
|
||||
<p>
|
||||
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder in
|
||||
Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten
|
||||
in einem gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die
|
||||
direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt
|
||||
dies nur, soweit es technisch machbar ist.
|
||||
</p>
|
||||
|
||||
<h4>Auskunft, Berichtigung und Löschung</h4>
|
||||
<p>
|
||||
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht
|
||||
auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten,
|
||||
deren Herkunft und Empfänger und den Zweck der Datenverarbeitung und ggf. ein
|
||||
Recht auf Berichtigung oder Löschung dieser Daten. Hierzu sowie zu weiteren
|
||||
Fragen zum Thema personenbezogene Daten können Sie sich jederzeit an uns wenden.
|
||||
</p>
|
||||
|
||||
<h4>Recht auf Einschränkung der Verarbeitung</h4>
|
||||
<p>
|
||||
Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen
|
||||
Daten zu verlangen. Hierzu können Sie sich jederzeit an uns wenden.
|
||||
</p>
|
||||
|
||||
<h4>SSL- bzw. TLS-Verschlüsselung</h4>
|
||||
<p>
|
||||
Diese Seite nutzt aus Sicherheitsgründen und zum Schutz der Übertragung
|
||||
vertraulicher Inhalte, wie zum Beispiel Anfragen, die Sie an uns als
|
||||
Seitenbetreiber senden, eine SSL- bzw. TLS-Verschlüsselung. Eine
|
||||
verschlüsselte Verbindung erkennen Sie daran, dass die Adresszeile des
|
||||
Browsers von „http://" auf „https://" wechselt und an dem Schloss-Symbol
|
||||
in Ihrer Browserzeile.
|
||||
</p>
|
||||
<p>
|
||||
Wenn die SSL- bzw. TLS-Verschlüsselung aktiviert ist, können die Daten,
|
||||
die Sie an uns übermitteln, nicht von Dritten mitgelesen werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>4. Datenerfassung auf dieser Website</h3>
|
||||
|
||||
<h4>Server-Log-Dateien</h4>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in
|
||||
so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns
|
||||
übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<p>
|
||||
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht
|
||||
vorgenommen.
|
||||
</p>
|
||||
<p>
|
||||
Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f
|
||||
DSGVO. Der Websitebetreiber hat ein berechtigtes Interesse an der technisch
|
||||
fehlerfreien Darstellung und der Optimierung seiner Website — hierzu müssen
|
||||
die Server-Log-Dateien erfasst werden.
|
||||
</p>
|
||||
|
||||
<h4>Cookies</h4>
|
||||
<p>
|
||||
Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine
|
||||
Datenpakete und richten auf Ihrem Endgerät keinen Schaden an. Sie werden
|
||||
entweder vorübergehend für die Dauer einer Sitzung (Session-Cookies) oder
|
||||
dauerhaft (permanente Cookies) auf Ihrem Endgerät gespeichert.
|
||||
</p>
|
||||
<p>Diese Website verwendet ausschließlich folgende Cookies:</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th><th>Typ</th></tr>
|
||||
<tr>
|
||||
<td><code>access_token</code></td>
|
||||
<td>Authentifizierung (Keycloak-JWT nach Login)</td>
|
||||
<td>Bis zum Schließen des Browsers</td>
|
||||
<td>Session / Notwendig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>theme</code></td>
|
||||
<td>Speicherung der bevorzugten Darstellung (hell/dunkel)</td>
|
||||
<td>Bis zur manuellen Löschung (localStorage)</td>
|
||||
<td>Funktional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sortierung</code></td>
|
||||
<td>Speicherung der bevorzugten Sortierung</td>
|
||||
<td>Bis zur manuellen Löschung (localStorage)</td>
|
||||
<td>Funktional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>selectedBundesland</code></td>
|
||||
<td>Speicherung des zuletzt gewählten Bundeslandes</td>
|
||||
<td>Bis zur manuellen Löschung (localStorage)</td>
|
||||
<td>Funktional</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
Wir setzen <strong>keine Tracking-, Analyse- oder Werbe-Cookies</strong> ein.
|
||||
</p>
|
||||
<p>
|
||||
Die Speicherung von technisch notwendigen Cookies erfolgt auf Grundlage von
|
||||
Art. 6 Abs. 1 lit. f DSGVO. Der Websitebetreiber hat ein berechtigtes
|
||||
Interesse an der Speicherung von technisch notwendigen Cookies zur technisch
|
||||
fehlerfreien und optimierten Bereitstellung seiner Dienste. Die funktionalen
|
||||
Cookies (Theme, Sortierung, Bundesland) werden ausschließlich lokal im Browser
|
||||
gespeichert (localStorage) und nicht an den Server übermittelt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>5. Registrierung und Authentifizierung</h3>
|
||||
|
||||
<h4>Keycloak Single Sign-On (SSO)</h4>
|
||||
<p>
|
||||
Für die Benutzerregistrierung und -anmeldung nutzen wir einen
|
||||
selbstgehosteten <strong>Keycloak-Server</strong> (sso.toppyr.de). Keycloak
|
||||
ist eine Open-Source-Identitäts- und Zugriffsverwaltungslösung. Es findet
|
||||
<strong>keine Datenübermittlung an Dritte</strong> statt — der Keycloak-Server
|
||||
wird vom selben Betreiber auf derselben Infrastruktur betrieben.
|
||||
</p>
|
||||
<p>Bei der Registrierung und Anmeldung werden folgende Daten verarbeitet:</p>
|
||||
<ul>
|
||||
<li>Vorname, Nachname</li>
|
||||
<li>E-Mail-Adresse</li>
|
||||
<li>Benutzername</li>
|
||||
<li>Gehashtes Passwort (serverseitig, nicht einsehbar)</li>
|
||||
<li>Zeitpunkt der Registrierung und letzten Anmeldung</li>
|
||||
</ul>
|
||||
<p>
|
||||
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO
|
||||
(Vertragserfüllung / Bereitstellung des Dienstes) und Art. 6 Abs. 1 lit. a
|
||||
DSGVO (Einwilligung durch aktive Registrierung).
|
||||
</p>
|
||||
<p>
|
||||
Ihr Account kann auf Anfrage jederzeit gelöscht werden. Wenden Sie sich
|
||||
dazu an die im Impressum genannte Kontaktadresse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>6. Nutzung von KI-Diensten (Datenverarbeitung durch Dritte)</h3>
|
||||
|
||||
<h4>Alibaba Cloud / DashScope API</h4>
|
||||
<p>
|
||||
Für die automatisierte Analyse von Parlamentsanträgen nutzen wir das
|
||||
Sprachmodell <strong>Qwen Plus</strong> über die DashScope-API der
|
||||
<strong>Alibaba Cloud International</strong>
|
||||
(dashscope-intl.aliyuncs.com).
|
||||
</p>
|
||||
<p>Dabei werden folgende Daten an den Dienst übermittelt:</p>
|
||||
<ul>
|
||||
<li>Der Volltext des zu analysierenden Parlamentsantrags (öffentlich zugängliches Dokument)</li>
|
||||
<li>Relevante Ausschnitte aus öffentlich zugänglichen Wahlprogrammen</li>
|
||||
</ul>
|
||||
<p>
|
||||
Es werden <strong>keine personenbezogenen Daten</strong> der Nutzer:innen an
|
||||
DashScope übermittelt. Die übermittelten Texte sind ausschließlich öffentlich
|
||||
zugängliche parlamentarische Dokumente und Wahlprogramme.
|
||||
</p>
|
||||
<p>
|
||||
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO
|
||||
(berechtigtes Interesse an der automatisierten Analyse öffentlicher
|
||||
Parlamentsdokumente).
|
||||
</p>
|
||||
<p>
|
||||
Weitere Informationen zum Datenschutz bei Alibaba Cloud:
|
||||
<a href="https://www.alibabacloud.com/help/en/legal/latest/chinese-mainland-privacy-policy" target="_blank" rel="noopener">Alibaba Cloud Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<h4>Embedding-Verarbeitung</h4>
|
||||
<p>
|
||||
Für die Zuordnung von Wahlprogramm-Zitaten werden Textabschnitte über die
|
||||
DashScope-API in numerische Vektoren (Embeddings) umgewandelt. Auch hierbei
|
||||
werden ausschließlich öffentlich zugängliche Wahlprogramm-Texte verarbeitet,
|
||||
keine personenbezogenen Daten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>7. Gespeicherte Nutzungsdaten</h3>
|
||||
|
||||
<h4>Lesezeichen und Kommentare</h4>
|
||||
<p>
|
||||
Registrierte Nutzer:innen können Anträge mit Lesezeichen versehen und
|
||||
Kommentare hinterlassen. Diese Daten werden in unserer Datenbank gespeichert
|
||||
und sind mit dem Benutzerkonto verknüpft. Bei einer Löschung des Benutzerkontos
|
||||
werden auch alle zugehörigen Lesezeichen und Kommentare gelöscht.
|
||||
</p>
|
||||
<p>
|
||||
Die Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO
|
||||
(Bereitstellung der Funktionalität auf Nutzerwunsch).
|
||||
</p>
|
||||
|
||||
<h4>Bewertungsdaten (Assessments)</h4>
|
||||
<p>
|
||||
Die durch die KI-Analyse erzeugten Bewertungen von Parlamentsanträgen
|
||||
enthalten keine personenbezogenen Daten. Sie bestehen aus Bewertungsscores,
|
||||
Begründungen und Zitaten aus öffentlich zugänglichen Dokumenten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>8. Keine Nutzung von Analyse-Tools</h3>
|
||||
<p>
|
||||
Diese Website verwendet <strong>keine Analyse- oder Tracking-Dienste</strong>
|
||||
wie Google Analytics, Matomo oder vergleichbare Tools. Es findet kein
|
||||
Tracking des Nutzerverhaltens statt. Es werden keine Nutzungsprofile erstellt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>9. Keine externen Schriften oder CDNs</h3>
|
||||
<p>
|
||||
Diese Website lädt <strong>keine Schriften von externen Servern</strong>
|
||||
(z. B. Google Fonts). Alle verwendeten Schriftarten sind lokal gehostet
|
||||
bzw. Systemschriftarten. Es findet daher keine Datenübertragung an
|
||||
Schrift-Anbieter statt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>10. Datenübermittlung an Parlaments-Server</h3>
|
||||
<p>
|
||||
Bei der Suche nach Parlamentsanträgen oder dem Herunterladen von
|
||||
Antrags-PDFs werden Anfragen an die öffentlichen Dokumentationssysteme
|
||||
der jeweiligen Landesparlamente weitergeleitet (z. B. OPAL NRW,
|
||||
ParLDok Berlin, EDAS Sachsen). Dabei werden die von Ihnen eingegebenen
|
||||
Suchbegriffe und Ihre IP-Adresse an den jeweiligen Parlaments-Server
|
||||
übermittelt.
|
||||
</p>
|
||||
<p>
|
||||
Diese Übermittlung ist technisch notwendig für die Bereitstellung des
|
||||
Dienstes und erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.
|
||||
Die Datenschutzbestimmungen der jeweiligen Landesparlamente gelten
|
||||
zusätzlich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ app_name }} ·
|
||||
<a href="/impressum">Impressum</a> ·
|
||||
<a href="/datenschutz">Datenschutz</a> ·
|
||||
<a href="/">Zurück zur Startseite</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -8,7 +8,7 @@
|
||||
:root {
|
||||
--color-darkgray: #5a5a5a;
|
||||
--color-green: #889e33;
|
||||
--color-blue: #009da5;
|
||||
--color-blue: #007a80;
|
||||
--color-lightgray: #bfbfbf;
|
||||
--color-bg: #f5f5f5;
|
||||
--color-amber: #ffc107;
|
||||
@ -45,6 +45,7 @@
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.card p + p { margin-top: 0.5rem; }
|
||||
.matrix-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(5, 1fr);
|
||||
@ -52,169 +53,391 @@
|
||||
font-size: 0.8rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.matrix-grid .cell {
|
||||
padding: 0.4rem;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.matrix-grid .header-cell {
|
||||
background: var(--color-blue);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.matrix-grid .row-header {
|
||||
background: var(--color-green);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
.matrix-grid .cell { padding: 0.4rem; text-align: center; background: #f8f9fa; border: 1px solid #e0e0e0; }
|
||||
.matrix-grid .header-cell { background: var(--color-blue); color: white; font-weight: bold; }
|
||||
.matrix-grid .row-header { background: var(--color-green); color: white; font-weight: bold; text-align: left; }
|
||||
details { margin: 0.5rem 0; }
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
details summary { cursor: pointer; color: var(--color-blue); font-weight: 600; padding: 0.3rem 0; }
|
||||
details summary:hover { text-decoration: underline; }
|
||||
.pipeline-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: flex-start; gap: 1rem;
|
||||
margin: 0.75rem 0; padding: 0.75rem;
|
||||
background: #f8f9fa; border-radius: 6px;
|
||||
border-left: 3px solid var(--color-blue);
|
||||
}
|
||||
.step-num {
|
||||
background: var(--color-blue);
|
||||
color: white;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue); color: white;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: bold; font-size: 0.85rem; flex-shrink: 0;
|
||||
}
|
||||
.note {
|
||||
background: #fff3cd;
|
||||
border-left: 3px solid var(--color-amber);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: #fff3cd; border-left: 3px solid var(--color-amber);
|
||||
padding: 0.75rem 1rem; margin: 1rem 0; border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
th, td { padding: 0.5rem; border: 1px solid #e0e0e0; text-align: left; font-size: 0.9rem; }
|
||||
th { background: #f0f0f0; }
|
||||
a { color: var(--color-blue); }
|
||||
.footer { text-align: center; padding: 2rem; color: #999; font-size: 0.85rem; }
|
||||
ul { margin: 0.5rem 0 0.5rem 1.5rem; }
|
||||
li { margin: 0.3rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ app_name }}</h1>
|
||||
<a href="/">Bewertungen</a>
|
||||
<a href="/auswertungen">Auswertungen</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
<strong>Methodik</strong>
|
||||
</div>
|
||||
{% set page_title = 'Methodik' %}
|
||||
{% set header_nav = '<a href="/">Startseite</a> <a href="/quellen">Quellen</a> <strong>Methodik</strong>' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="container">
|
||||
<h2>Wie funktioniert der GWÖ-Antragsprüfer?</h2>
|
||||
|
||||
<h2>Was ist die Gemeinwohl-Ökonomie?</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
Der GWÖ-Antragsprüfer bewertet Parlamentsanträge automatisch nach der
|
||||
<strong>Gemeinwohl-Ökonomie Matrix 2.0 für Gemeinden</strong>. Jede Bewertung
|
||||
analysiert drei Dimensionen: GWÖ-Treue, Übereinstimmung mit Wahlprogrammen
|
||||
und Übereinstimmung mit Grundsatzprogrammen der Parteien.
|
||||
Die <strong>Gemeinwohl-Ökonomie (GWÖ)</strong> ist ein Wirtschaftsmodell, das den
|
||||
Erfolg wirtschaftlichen Handelns nicht am Gewinn, sondern am <strong>Beitrag zum
|
||||
Gemeinwohl</strong> misst. Entwickelt von Christian Felber (2010), wird die GWÖ
|
||||
von einer internationalen Bewegung mit über 11.000 Unterstützern, 4.500
|
||||
Mitgliedern und 1.000 bilanzierten Organisationen getragen.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Alle Bewertungen werden durch ein KI-Sprachmodell erzeugt und anschließend
|
||||
<strong>automatisch verifiziert</strong> — Zitate werden gegen die Originaltexte
|
||||
der Wahlprogramme geprüft. Wörtliche Treffer werden als <em>verifiziert</em>
|
||||
markiert, paraphrasierte Stellen als <em>nicht wörtlich im Programm</em>
|
||||
gekennzeichnet.
|
||||
|
||||
<h3>Das Bewertungsmodell: die Gemeinwohl-Bilanz</h3>
|
||||
<p>
|
||||
Das Kernstück ist die <strong>Gemeinwohl-Bilanz</strong>: ein standardisiertes
|
||||
Bewertungsverfahren, das Organisationen anhand einer Matrix aus fünf Werten
|
||||
(Menschenwürde, Solidarität, ökologische Nachhaltigkeit, soziale Gerechtigkeit,
|
||||
Transparenz & Demokratie) und fünf Berührungsgruppen bewertet.
|
||||
</p>
|
||||
<p>
|
||||
Ursprünglich wurde dieses Modell <strong>für Unternehmen</strong> entwickelt.
|
||||
Die aktuelle <strong>Unternehmens-Matrix (Version 5.1)</strong> bewertet
|
||||
Lieferketten, Mitarbeitende, Kund:innen, Eigentümer:innen und das
|
||||
gesellschaftliche Umfeld. Über 1.000 Unternehmen in 35 Ländern haben bereits
|
||||
eine Gemeinwohl-Bilanz erstellt.
|
||||
</p>
|
||||
<p style="font-size:0.9rem;">
|
||||
→ <a href="https://germany.econgood.org/wp-content/uploads/sites/8/2025/02/ECOnGOOD_Arbeitsbuch_5_1.pdf" target="_blank">Arbeitsbuch Unternehmen 5.1 (PDF)</a> ·
|
||||
<a href="https://germany.econgood.org/tools/gemeinwohl-matrix/" target="_blank">Matrix-Übersicht</a>
|
||||
</p>
|
||||
|
||||
<h3>Adaption für die öffentliche Hand</h3>
|
||||
<p>
|
||||
Für <strong>Gemeinden und die öffentliche Hand</strong> gibt es seit 2017 eine
|
||||
eigenständige Adaption: das <strong>Arbeitsbuch für Gemeinden Version 2.0</strong>.
|
||||
Es überträgt die Unternehmens-Matrix auf kommunale Handlungsfelder:
|
||||
statt „Kund:innen" stehen <em>Bürger:innen</em> im Fokus, statt „Lieferkette"
|
||||
geht es um <em>öffentliche Beschaffung</em>, statt „Gewinnverteilung" um
|
||||
<em>Haushalts- und Finanzpolitik</em>.
|
||||
Eine aktualisierte Version 2.1.A wird seit 2023 im Pilotbetrieb erprobt.
|
||||
</p>
|
||||
<p style="font-size:0.9rem;">
|
||||
→ <a href="https://germany.econgood.org/wp-content/uploads/sites/8/2022/05/Arbeitsbuch-Gemeinden_2.pdf" target="_blank">Arbeitsbuch Gemeinden 2.0 (PDF)</a> ·
|
||||
<a href="https://germany.econgood.org/wp-content/uploads/sites/8/2024/04/20231103_Arbeitsbuch-2_1_A-final.pdf" target="_blank">Version 2.1.A Pilotfassung (PDF)</a>
|
||||
</p>
|
||||
|
||||
<h3>Anwendung auf Parlamentsanträge</h3>
|
||||
<p>
|
||||
<strong>Dieser Antragsprüfer</strong> nutzt die Gemeinde-Matrix 2.0 als
|
||||
Bewertungsrahmen und wendet sie systematisch auf Parlamentsanträge aller
|
||||
deutschen Landtage und des Bundestags an. Das ist ein Anwendungsfall, der im
|
||||
ursprünglichen GWÖ-Konzept nicht vorgesehen war — aber die gleiche Wertebasis
|
||||
teilt: Parlamentsanträge gestalten die Rahmenbedingungen, unter denen Gemeinden
|
||||
handeln. Ihre Gemeinwohl-Wirkung zu messen macht sie vergleichbar und transparent.
|
||||
</p>
|
||||
<p style="font-size:0.9rem;color:var(--color-muted);">
|
||||
Mehr zur Bewegung:
|
||||
<a href="https://germany.econgood.org" target="_blank">GWÖ Deutschland</a> ·
|
||||
<a href="https://austria.econgood.org/gemeinden/" target="_blank">GWÖ Gemeinden Österreich</a> ·
|
||||
<a href="https://germany.econgood.org/tools/gemeinwohl-matrix/" target="_blank">Arbeitsmaterialien</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Die GWÖ-Matrix 2.0</h2>
|
||||
<h2>Was macht der GWÖ-Antragsprüfer?</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>Die Matrix besteht aus <strong>5 Berührungsgruppen</strong> (Zeilen) und
|
||||
<strong>5 Werten</strong> (Spalten) = 25 Themenfelder:</p>
|
||||
<p>
|
||||
Der Antragsprüfer wendet die GWÖ-Matrix systematisch auf <strong>Parlamentsanträge
|
||||
aller 16 Landtage und des Bundestags</strong> an. Jeder Antrag wird automatisch
|
||||
analysiert und erhält:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>GWÖ-Score (0–10)</strong> — wie stark fördert oder widerspricht der Antrag den fünf Gemeinwohl-Werten?</li>
|
||||
<li><strong>25-Felder-Matrix</strong> — detaillierte Bewertung für jede Kombination aus Berührungsgruppe und Wert</li>
|
||||
<li><strong>Wahlprogramm-Treue</strong> — wie gut passt der Antrag zu den Wahl- und Grundsatzprogrammen der Fraktionen, belegt mit verifizierten Zitaten?</li>
|
||||
<li><strong>Verbesserungsvorschläge</strong> — konkrete Textänderungen im Redline-Format, die den GWÖ-Score erhöhen würden</li>
|
||||
</ul>
|
||||
<p>
|
||||
Ziel ist <strong>Transparenz</strong>: Bürger:innen können nachvollziehen, welche
|
||||
Anträge dem Gemeinwohl dienen — und welche dagegen arbeiten. Die Bewertungen sind
|
||||
öffentlich, maschinenlesbar (JSON/CSV/Atom-Feed) und unter CC BY 4.0 lizenziert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Die GWÖ-Matrix 2.0 für Gemeinden</h2>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>5 Berührungsgruppen</strong> (Zeilen) × <strong>5 Werte</strong> (Spalten) = 25 Bewertungsfelder.
|
||||
Jedes Feld wird von <strong>−5</strong> (fundamental widersprechend) bis <strong>+5</strong>
|
||||
(stark fördernd) bewertet. Der GWÖ-Score (0–10) ist ein gewichteter Durchschnitt.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Die fünf Werte (Spalten)</h3>
|
||||
<table>
|
||||
<tr><th style="width:30%;">Wert</th><th>Leitfrage</th></tr>
|
||||
<tr><td><strong>1. Menschenwürde</strong></td><td>Werden Grundrechte geschützt? Rechtliche Gleichstellung? Schutz vor Diskriminierung?</td></tr>
|
||||
<tr><td><strong>2. Solidarität</strong></td><td>Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft? Kooperation statt Konkurrenz?</td></tr>
|
||||
<tr><td><strong>3. Ökologische Nachhaltigkeit</strong></td><td>Klimaschutz? Ressourcenschonung? Biodiversität? Kreislaufwirtschaft?</td></tr>
|
||||
<tr><td><strong>4. Soziale Gerechtigkeit</strong></td><td>Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung? Chancengleichheit?</td></tr>
|
||||
<tr><td><strong>5. Transparenz & Demokratie</strong></td><td>Bürgerbeteiligung? Offenlegung? Demokratische Prozesse? Rechenschaftspflicht?</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Die fünf Berührungsgruppen (Zeilen)</h3>
|
||||
<table>
|
||||
<tr><th style="width:30%;">Gruppe</th><th>Wer ist gemeint?</th></tr>
|
||||
<tr><td><strong>A · Lieferant:innen</strong></td><td>Externe Beschaffung, Lieferketten, Dienstleister:innen — unter welchen Bedingungen kauft die öffentliche Hand ein?</td></tr>
|
||||
<tr><td><strong>B · Finanzen</strong></td><td>Umgang mit öffentlichen Mitteln, Haushalt, Steuerzahler:innen — wohin fließt das Geld?</td></tr>
|
||||
<tr><td><strong>C · Verwaltung</strong></td><td>Mandatsträger:innen, Mitarbeitende, Ehrenamtliche — wie wird intern gearbeitet?</td></tr>
|
||||
<tr><td><strong>D · Bürger:innen</strong></td><td>Wirkung innerhalb der Grenzen, Daseinsvorsorge — was haben die Menschen vor Ort davon?</td></tr>
|
||||
<tr><td><strong>E · Gesellschaft & Natur</strong></td><td>Wirkung über die Grenzen hinaus, Zukunft — welche Spuren hinterlassen wir?</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Alle 25 Felder im Detail</h3>
|
||||
<p style="margin-bottom:1rem;color:var(--color-muted);font-size:0.9rem;">Klicke auf ein Feld für die ausführliche Erklärung.</p>
|
||||
<div id="field-explain" style="display:none;background:#f0f8f0;border-left:3px solid var(--color-green);padding:1rem 1.25rem;margin-bottom:1rem;border-radius:4px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<strong id="field-explain-title" style="font-size:1.05rem;"></strong>
|
||||
<button onclick="document.getElementById('field-explain').style.display='none'" style="background:none;border:none;font-size:1.1rem;cursor:pointer;color:#888;">✕</button>
|
||||
</div>
|
||||
<div id="field-explain-text" style="margin-top:0.5rem;line-height:1.7;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fieldInfo = {
|
||||
"A1": {
|
||||
label: "Grundrechtsschutz in der Lieferkette",
|
||||
praxis: "Wenn die öffentliche Hand Büromöbel, Dienstkleidung oder IT-Geräte beschafft: Unter welchen Bedingungen wurden diese hergestellt? Werden Lieferanten verpflichtet, Arbeitsschutzstandards und Menschenrechte einzuhalten? Gibt es Ausschlusskriterien für Produkte aus Kinderarbeit oder Zwangsarbeit? In der Praxis bedeutet das z.\u00a0B. die Aufnahme von ILO-Kernarbeitsnormen in Vergabekriterien oder die Bevorzugung fair zertifizierter Anbieter.",
|
||||
theorie: "Die GWÖ versteht Menschenwürde als unteilbar und kettenübergreifend: Wer öffentlich einkauft, trägt Mitverantwortung für die Bedingungen am Anfang der Wertschöpfungskette. Das Feld A1 operationalisiert den Zusammenhang zwischen kommunaler Beschaffung und globalem Menschenrechtsschutz — analog zum Lieferkettensorgfaltspflichtengesetz, aber mit weicherem, wertebasiertem Maßstab."
|
||||
},
|
||||
"A2": {
|
||||
label: "Nutzen für die Gemeinde",
|
||||
praxis: "Beauftragt die Kommune den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleiben Steuergelder in der Region und schaffen Arbeitsplätze vor Ort? In der Praxis geht es um regionale Vergabestrategien, Losaufteilung zugunsten kleinerer Betriebe und die Berücksichtigung von Gemeinwohlkriterien neben dem Preis.",
|
||||
theorie: "Solidarität beginnt in der Nachbarschaft. Die GWÖ-Matrix misst hier, ob die Beschaffungspolitik einer Kommune aktiv zur regionalen Wertschöpfung beiträgt. Das entspricht dem Subsidiaritätsprinzip: Aufgaben sollen möglichst nah an den Betroffenen erledigt werden, wirtschaftliche Kreisläufe möglichst lokal geschlossen werden."
|
||||
},
|
||||
"A3": {
|
||||
label: "Ökologische Verantwortung in der Lieferkette",
|
||||
praxis: "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa transportiert? Sind Recyclingquoten Teil der Ausschreibung? Konkret: Gibt es Vorgaben zu CO₂-Fußabdruck, Verpackungsvermeidung oder biologischem Anbau in den Leistungsbeschreibungen?",
|
||||
theorie: "Die ökologische Säule der GWÖ fordert, dass Umweltkosten nicht externalisiert werden. Jede Beschaffungsentscheidung hat einen ökologischen Fußabdruck — A3 macht diesen sichtbar und bewertet, ob die Kommune ihre Marktmacht für Nachhaltigkeitsziele einsetzt."
|
||||
},
|
||||
"A4": {
|
||||
label: "Soziale Verantwortung in der Lieferkette",
|
||||
praxis: "Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Haben Subunternehmer die gleichen Arbeitsbedingungen wie Festangestellte? Wird bei der Vergabe geprüft, ob Mindestlöhne eingehalten werden, ob Zeitarbeit missbraucht wird, ob Saisonarbeiter:innen angemessen untergebracht sind?",
|
||||
theorie: "Soziale Gerechtigkeit endet nicht am Werkstor. Die GWÖ bewertet die gesamte Wertschöpfungskette nach dem Prinzip: Wer von öffentlichen Aufträgen profitiert, muss auch soziale Mindeststandards garantieren. A4 prüft, ob diese Standards vertraglich verankert und kontrolliert werden."
|
||||
},
|
||||
"A5": {
|
||||
label: "Rechenschaft und Mitsprache bei Beschaffung",
|
||||
praxis: "Können Bürger:innen nachschauen, welche Firma den Auftrag für den Straßenbau bekommen hat — und warum? Gibt es ein öffentliches Vergaberegister? Werden Vergabeentscheidungen im Rat diskutiert oder hinter verschlossenen Türen getroffen? Können Betroffene (z.\u00a0B. Anwohner:innen) Einspruch erheben?",
|
||||
theorie: "Transparenz ist das Immunsystem der Demokratie. Die GWÖ fordert Offenlegung als Regelfall, nicht als Ausnahme. A5 misst, ob Beschaffungsprozesse für die Öffentlichkeit nachvollziehbar und mitgestaltbar sind — ein demokratisches Grundprinzip, das in der kommunalen Praxis oft zu kurz kommt."
|
||||
},
|
||||
"B1": {
|
||||
label: "Ethisches Finanzgebaren",
|
||||
praxis: "Liegt das Geld Ihrer Stadt bei einer Bank, die auch Waffengeschäfte oder fossile Großprojekte finanziert? Oder bei einer ethischen Bank, die in soziale und ökologische Projekte investiert? Werden Kassenkredite bei der erstbesten Großbank aufgenommen — oder gibt es ethische Anlagerichtlinien?",
|
||||
theorie: "Die GWÖ betrachtet Geld als Mittel zum Zweck, nicht als Selbstzweck. B1 bewertet, ob kommunale Finanzentscheidungen ethischen Kriterien folgen. Das schließt die Wahl der Hausbank, Anlagestrategien für Rücklagen und die Konditionen von Kassenkrediten ein. Vorbild sind Kommunen, die explizite Negativlisten für Rüstung, fossile Energien und Steueroasen in ihren Anlagerichtlinien verankert haben."
|
||||
},
|
||||
"B2": {
|
||||
label: "Gemeinnutz im Finanzgebaren",
|
||||
praxis: "Fließen Steuergelder in einen neuen Radweg für alle — oder in eine Umgehungsstraße, die nur dem Gewerbegebiet nützt? Werden Subventionen nach Gemeinwohlkriterien vergeben oder nach Lobby-Stärke? Profitiert die Allgemeinheit oder eine kleine Gruppe?",
|
||||
theorie: "Solidarität in der Finanzpolitik heißt: Öffentliches Geld dient öffentlichen Zwecken. B2 misst, ob Haushaltsentscheidungen dem Gemeinwohl dienen. Die GWÖ unterscheidet zwischen Investitionen, die allen zugutekommen (Bibliotheken, ÖPNV, Grünflächen), und solchen, die nur partikulare Interessen bedienen."
|
||||
},
|
||||
"B3": {
|
||||
label: "Ökologische Verantwortung der Finanzpolitik",
|
||||
praxis: "Investiert die Kommune in Solaranlagen auf Schuldächern? Oder wird das Geld in klimaschädliche Infrastruktur gesteckt? Gibt es einen kommunalen Klimafonds? Werden Folgekosten des Klimawandels (Hochwasserschutz, Hitzeanpassung) im Haushalt berücksichtigt?",
|
||||
theorie: "Ökologische Nachhaltigkeit muss sich im Haushalt widerspiegeln. B3 bewertet, ob die Kommune ihr Geld so einsetzt, dass ökologische Ziele unterstützt werden. Die GWÖ fordert hier die Internalisierung externer Kosten: Wer klimaschädlich investiert, muss die Folgekosten mitrechnen."
|
||||
},
|
||||
"B4": {
|
||||
label: "Soziale Verantwortung der Finanzpolitik",
|
||||
praxis: "Bekommen ärmere Stadtteile genauso viel Geld für Spielplätze und Schulen wie reiche Viertel? Gibt es eine bewusste Umverteilung zugunsten benachteiligter Quartiere? Werden soziale Folgekosten von Sparmaßnahmen berücksichtigt?",
|
||||
theorie: "Soziale Gerechtigkeit erfordert bewusste Verteilungsentscheidungen. B4 misst, ob der Haushalt Ungleichheit verringert oder verstärkt. Die GWÖ orientiert sich am Rawlsschen Differenzprinzip: Eine Ungleichverteilung ist nur dann gerechtfertigt, wenn sie den am schlechtesten Gestellten zugutekommt."
|
||||
},
|
||||
"B5": {
|
||||
label: "Partizipation in der Finanzpolitik",
|
||||
praxis: "Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können, ob das Geld in die Bibliothek oder den Sportplatz fließt? Werden Haushaltsentwürfe verständlich aufbereitet? Können Vereine und Initiativen Projektmittel beantragen?",
|
||||
theorie: "Demokratie braucht finanzielle Transparenz. B5 bewertet, ob Bürger:innen Einblick in und Einfluss auf die Verwendung ihrer Steuergelder haben. Das reicht vom lesbaren Haushaltsbericht bis zum deliberativen Bürgerhaushalt nach dem Vorbild von Porto Alegre."
|
||||
},
|
||||
"C1": {
|
||||
label: "Individuelle Rechts- und Gleichstellung",
|
||||
praxis: "Werden in der Stadtverwaltung Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen auf eine Stelle? Gibt es Schutz vor Mobbing und Diskriminierung? Werden Führungspositionen quotiert besetzt? Gibt es anonymisierte Bewerbungsverfahren?",
|
||||
theorie: "Die Menschenwürde der Mitarbeitenden ist die Grundlage jeder guten Verwaltung. C1 bewertet, ob die Kommune als Arbeitgeberin Gleichstellung aktiv fördert — nicht nur gesetzliche Mindeststandards einhält, sondern darüber hinausgeht. Die GWÖ misst hier die Differenz zwischen formaler Gleichberechtigung und gelebter Gleichstellung."
|
||||
},
|
||||
"C2": {
|
||||
label: "Gemeinsame Zielvereinbarung für das Gemeinwohl",
|
||||
praxis: "Hat die Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen? Gibt es ein Leitbild, das über Wahlperioden hinaus Bestand hat? Werden Ziele messbar formuliert und regelmäßig überprüft? Oder kocht jedes Amt sein eigenes Süppchen?",
|
||||
theorie: "Solidarität innerhalb der Verwaltung bedeutet: Alle ziehen am selben Strang. C2 misst den Grad der internen Kohärenz — ob Gemeinwohlziele als Querschnittsaufgabe verstanden werden oder in Ressortdenken versanden. Die GWÖ orientiert sich hier am Konzept der lernenden Organisation."
|
||||
},
|
||||
"C3": {
|
||||
label: "Förderung ökologischen Verhaltens intern",
|
||||
praxis: "Fahren die Mitarbeiter:innen des Rathauses mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine? Wird Papier eingespart, doppelseitig gedruckt, auf Ökostrom umgestellt? Werden Dienstreisen klimakompensiert?",
|
||||
theorie: "Die Kommune hat eine Vorbildfunktion. C3 bewertet, ob ökologisches Verhalten intern gefördert und belohnt wird. Die GWÖ argumentiert: Wer von Bürger:innen nachhaltiges Handeln erwartet, muss selbst vorangehen. Das umfasst sowohl strukturelle Maßnahmen (Gebäudesanierung, Fuhrpark-Umstellung) als auch Alltagsverhalten."
|
||||
},
|
||||
"C4": {
|
||||
label: "Gerechte Verteilung von Arbeit",
|
||||
praxis: "Können Eltern in der Verwaltung Teilzeit arbeiten, ohne Karrierenachteile? Gibt es flexible Arbeitszeiten für pflegende Angehörige? Werden prekäre Beschäftigungsverhältnisse (Befristungen, Minijobs) in der Kommune minimiert?",
|
||||
theorie: "Soziale Gerechtigkeit beginnt beim eigenen Personal. C4 misst, ob die Kommune als Arbeitgeberin faire Arbeitsbedingungen schafft — insbesondere für die Vereinbarkeit von Beruf und Privatleben, für Menschen mit Betreuungspflichten und für diejenigen in den unteren Lohngruppen."
|
||||
},
|
||||
"C5": {
|
||||
label: "Transparente Kommunikation und demokratische Prozesse intern",
|
||||
praxis: "Können Sie die Sitzungsprotokolle des Stadtrats online lesen? Verstehen Sie, warum Entscheidungen so und nicht anders gefallen sind? Gibt es eine Fehlerkultur in der Verwaltung? Werden Beschwerden ernst genommen?",
|
||||
theorie: "Transparenz nach innen und außen ist die Voraussetzung für Vertrauen. C5 bewertet, ob Entscheidungsprozesse nachvollziehbar, Informationsflüsse offen und Feedback-Kanäle funktional sind. Die GWÖ versteht Verwaltung nicht als Apparat, sondern als demokratische Dienstleisterin."
|
||||
},
|
||||
"D1": {
|
||||
label: "Schutz des Individuums, Rechtsgleichheit",
|
||||
praxis: "Werden Sie auf dem Amt fair behandelt — egal ob Sie einen deutschen oder ausländischen Namen haben? Schützt die Polizei alle gleich? Gibt es barrierefreie Zugänge, mehrsprachige Formulare, kultursensible Angebote? Wird Racial Profiling systematisch verhindert?",
|
||||
theorie: "Menschenwürde bedeutet: Jeder Mensch hat den gleichen Wert, unabhängig von Herkunft, Geschlecht, Religion oder sozialem Status. D1 misst, ob die Kommune diesen Grundsatz in der täglichen Interaktion mit ihren Bürger:innen verwirklicht — nicht nur rechtlich, sondern auch in der gelebten Verwaltungskultur."
|
||||
},
|
||||
"D2": {
|
||||
label: "Gesamtwohl in der Gemeinde",
|
||||
praxis: "Profitiert die ganze Stadt von dem Antrag — oder nur ein Stadtteil, eine Altersgruppe, eine Einkommensschicht? Werden Interessen abgewogen? Gibt es Folgenabschätzungen, die alle Bevölkerungsgruppen berücksichtigen?",
|
||||
theorie: "Solidarität auf kommunaler Ebene heißt: Das Gesamtwohl geht vor Partikularinteressen. D2 bewertet, ob politische Entscheidungen dem Nutzen aller dienen. Die GWÖ warnt vor der 'Tyrannei der Mehrheit' ebenso wie vor der Dominanz organisierter Minderheiten — der Maßstab ist das inklusive Gemeinwohl."
|
||||
},
|
||||
"D3": {
|
||||
label: "Ökologische Gestaltung der öffentlichen Leistung",
|
||||
praxis: "Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren? Wird das Regenwasser im Park versickert statt in die Kanalisation geleitet? Sind öffentliche Gebäude energetisch saniert? Gibt es Trinkwasserbrunnen statt Einwegflaschen in den Ämtern?",
|
||||
theorie: "Jede kommunale Dienstleistung hat einen ökologischen Fußabdruck. D3 bewertet, ob die Daseinsvorsorge nachhaltig gestaltet ist. Die GWÖ argumentiert: Öffentliche Leistungen erreichen alle — deshalb ist ihr ökologischer Hebel besonders groß."
|
||||
},
|
||||
"D4": {
|
||||
label: "Soziale Gestaltung der öffentlichen Leistung",
|
||||
praxis: "Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner noch einen Arzttermin in der Nähe? Findet die Familie mit drei Kindern eine bezahlbare Wohnung? Sind Bibliotheken, Schwimmbäder, Kulturangebote für alle zugänglich — oder nur für die, die es sich leisten können?",
|
||||
theorie: "Soziale Gerechtigkeit in der Daseinsvorsorge ist der Kern kommunaler Politik. D4 misst, ob grundlegende öffentliche Leistungen — Bildung, Gesundheit, Wohnen, Mobilität, Kultur — für alle Einkommensgruppen zugänglich sind. Die GWÖ orientiert sich hier am Konzept der 'Capabilities' (Amartya Sen): Was nützt ein Recht, wenn man es sich nicht leisten kann?"
|
||||
},
|
||||
"D5": {
|
||||
label: "Transparente Kommunikation und demokratische Einbindung",
|
||||
praxis: "Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird? Gibt es Bürgerversammlungen, Online-Beteiligung, Jugendparlamente? Werden Planungsprozesse offen kommuniziert? Können Betroffene Einspruch erheben — und wird der ernst genommen?",
|
||||
theorie: "Demokratie ist mehr als Wahlen alle vier Jahre. D5 bewertet die Qualität der alltäglichen demokratischen Beteiligung. Die GWÖ fordert deliberative Demokratie auf kommunaler Ebene: Bürger:innen sollen nicht nur informiert, sondern aktiv in Entscheidungsprozesse eingebunden werden."
|
||||
},
|
||||
"E1": {
|
||||
label: "Menschenwürdiges Leben für zukünftige Generationen",
|
||||
praxis: "Hinterlassen wir unseren Enkeln einen Schuldenberg und versiegelte Flächen? Oder investieren wir heute so, dass auch 2050 noch gute Lebensbedingungen herrschen? Gibt es eine Generationenbilanz im Haushalt? Werden langfristige Folgekosten mitgedacht?",
|
||||
theorie: "Die Menschenwürde hat eine zeitliche Dimension. E1 bewertet, ob die Kommune die Interessen zukünftiger Generationen systematisch berücksichtigt. Die GWÖ bezieht sich hier auf Hans Jonas' 'Prinzip Verantwortung': Handle so, dass die Wirkungen deiner Handlung verträglich sind mit der Permanenz echten menschlichen Lebens auf Erden."
|
||||
},
|
||||
"E2": {
|
||||
label: "Beitrag zum Gesamtwohl über die Gemeindegrenzen hinaus",
|
||||
praxis: "Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden? Gibt es regionale Kooperationen, interkommunale Zusammenarbeit, gemeinsame Projekte? Werden Spillover-Effekte (positiv und negativ) auf die Umgebung berücksichtigt?",
|
||||
theorie: "Solidarität endet nicht an der Gemeindegrenze. E2 misst, ob eine Kommune auch das Wohl der Region, des Landes und darüber hinaus mitdenkt. Die GWÖ warnt vor kommunalem Egoismus — wenn z.\u00a0B. ein Gewerbegebiet Steuereinnahmen generiert, aber den Nachbarn den Verkehr aufbürdet."
|
||||
},
|
||||
"E3": {
|
||||
label: "Verantwortung für ökologische Auswirkungen jenseits der Gemeinde",
|
||||
praxis: "Denkt Ihre Kommune beim Einkauf an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten? Werden globale Umweltwirkungen lokaler Entscheidungen sichtbar gemacht?",
|
||||
theorie: "Die ökologische Krise ist global, aber die Verursachung ist lokal. E3 bewertet, ob eine Kommune ihre ökologische Verantwortung über die eigenen Grenzen hinaus wahrnimmt. Die GWÖ argumentiert: Wer billig einkauft und die Umweltkosten in andere Länder exportiert, lebt auf Kosten anderer — auch wenn die Bilanz vor Ort grün aussieht."
|
||||
},
|
||||
"E4": {
|
||||
label: "Beitrag zum sozialen Ausgleich",
|
||||
praxis: "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden? Werden faire Handelsprodukte bevorzugt? Engagiert sich die Kommune in der Entwicklungszusammenarbeit?",
|
||||
theorie: "Soziale Gerechtigkeit im globalen Maßstab ist die anspruchsvollste Dimension der GWÖ. E4 bewertet, ob eine Kommune über den eigenen Tellerrand schaut und zum Abbau globaler Ungleichheit beiträgt. Das kann über Fairtrade-Beschaffung, Städtepartnerschaften oder solidarische Finanzierungsmodelle geschehen."
|
||||
},
|
||||
"E5": {
|
||||
label: "Transparente und demokratische Mitbestimmung auf übergeordneter Ebene",
|
||||
praxis: "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen (Pariser Klimaabkommen, SDGs) aktiv unterstützt? Gibt es kommunale Resolutionen zu überregionalen Themen? Engagiert sich die Stadt in kommunalen Netzwerken?",
|
||||
theorie: "Demokratie braucht Fürsprecher auf allen Ebenen. E5 bewertet, ob eine Kommune ihre demokratische Stimme auch jenseits der eigenen Zuständigkeit erhebt. Die GWÖ versteht Kommunen als demokratische Akteure im Mehrebenensystem — nicht als passive Vollstrecker übergeordneter Politik."
|
||||
}
|
||||
};
|
||||
|
||||
function showFieldInfo(code) {
|
||||
const info = fieldInfo[code];
|
||||
if (!info) return;
|
||||
const el = document.getElementById('field-explain');
|
||||
document.getElementById('field-explain-title').textContent = code + ': ' + info.label;
|
||||
document.getElementById('field-explain-text').innerHTML =
|
||||
'<p style="margin-bottom:0.75rem;"><strong>Praktische Bedeutung:</strong> ' + info.praxis + '</p>' +
|
||||
'<p><strong>Theoretischer Hintergrund:</strong> ' + info.theorie + '</p>';
|
||||
el.style.display = 'block';
|
||||
el.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="matrix-grid">
|
||||
<div class="cell"></div>
|
||||
<div class="header-cell">Menschen­würde</div>
|
||||
<div class="header-cell">Solidarität</div>
|
||||
<div class="header-cell">Ökologische Nachhaltig­keit</div>
|
||||
<div class="header-cell">Soziale Gerechtig­keit</div>
|
||||
<div class="header-cell">Transparenz & Demokratie</div>
|
||||
<div class="header-cell">1. Menschen­würde</div>
|
||||
<div class="header-cell">2. Solidarität</div>
|
||||
<div class="header-cell">3. Ökol. Nachh.</div>
|
||||
<div class="header-cell">4. Soz. Gerecht.</div>
|
||||
<div class="header-cell">5. Transparenz</div>
|
||||
|
||||
<div class="row-header">A · Lieferanten</div>
|
||||
<div class="cell">A1</div><div class="cell">A2</div><div class="cell">A3</div><div class="cell">A4</div><div class="cell">A5</div>
|
||||
<div class="row-header">A · Lieferant:innen</div>
|
||||
<div class="cell" style="cursor:pointer;" title="Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt?" onclick="showFieldInfo('A1')"><strong>A1</strong><br><small>Grundrechtsschutz in der Lieferkette</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Beauftragt die Stadt den Betrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland?" onclick="showFieldInfo('A2')"><strong>A2</strong><br><small>Nutzen für die Gemeinde</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Werden bei Aufträgen Klimastandards verlangt? Kommt das Schulessen regional?" onclick="showFieldInfo('A3')"><strong>A3</strong><br><small>Ökologische Verantwortung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn?" onclick="showFieldInfo('A4')"><strong>A4</strong><br><small>Soziale Verantwortung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Können Sie nachschauen, welche Firma den Auftrag bekam — und warum?" onclick="showFieldInfo('A5')"><strong>A5</strong><br><small>Rechenschaft & Mitsprache</small></div>
|
||||
|
||||
<div class="row-header">B · Finanzen</div>
|
||||
<div class="cell">B1</div><div class="cell">B2</div><div class="cell">B3</div><div class="cell">B4</div><div class="cell">B5</div>
|
||||
<div class="cell" style="cursor:pointer;" title="Liegt das Geld Ihrer Stadt bei einer ethischen Bank — oder bei einer, die Waffengeschäfte finanziert?" onclick="showFieldInfo('B1')"><strong>B1</strong><br><small>Ethisches Finanzgebaren</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Fließen Steuergelder in Radwege für alle — oder in eine Umgehungsstraße nur fürs Gewerbegebiet?" onclick="showFieldInfo('B2')"><strong>B2</strong><br><small>Gemeinnutz im Haushalt</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Investiert die Kommune in Solaranlagen auf Schuldächern?" onclick="showFieldInfo('B3')"><strong>B3</strong><br><small>Ökologische Finanzpolitik</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Bekommen ärmere Stadtteile genauso viel für Spielplätze wie reiche?" onclick="showFieldInfo('B4')"><strong>B4</strong><br><small>Soziale Finanzpolitik</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können?" onclick="showFieldInfo('B5')"><strong>B5</strong><br><small>Partizipation im Haushalt</small></div>
|
||||
|
||||
<div class="row-header">C · Verwaltung</div>
|
||||
<div class="cell">C1</div><div class="cell">C2</div><div class="cell">C3</div><div class="cell">C4</div><div class="cell">C5</div>
|
||||
<div class="cell" style="cursor:pointer;" title="Werden Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen?" onclick="showFieldInfo('C1')"><strong>C1</strong><br><small>Gleichstellung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Hat die Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen?" onclick="showFieldInfo('C2')"><strong>C2</strong><br><small>Gemeinsame Ziele</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Fahren Mitarbeitende mit dem Dienstrad oder dem SUV? Vegetarisches Kantinenessen?" onclick="showFieldInfo('C3')"><strong>C3</strong><br><small>Ökologisches Verhalten</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Können Eltern in der Verwaltung Teilzeit arbeiten ohne Karrierenachteile?" onclick="showFieldInfo('C4')"><strong>C4</strong><br><small>Gerechte Arbeitsteilung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Können Sie die Sitzungsprotokolle online lesen? Verstehen Sie die Entscheidungen?" onclick="showFieldInfo('C5')"><strong>C5</strong><br><small>Transparenz & Demokratie</small></div>
|
||||
|
||||
<div class="row-header">D · Bürger</div>
|
||||
<div class="cell">D1</div><div class="cell">D2</div><div class="cell">D3</div><div class="cell">D4</div><div class="cell">D5</div>
|
||||
<div class="row-header">D · Bürger:innen</div>
|
||||
<div class="cell" style="cursor:pointer;" title="Werden Sie auf dem Amt fair behandelt — egal welchen Namen Sie tragen?" onclick="showFieldInfo('D1')"><strong>D1</strong><br><small>Rechtsgleichheit</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Profitiert die ganze Stadt — oder nur ein Stadtteil, eine Altersgruppe?" onclick="showFieldInfo('D2')"><strong>D2</strong><br><small>Gesamtwohl</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Kommt der Strom aus Erneuerbaren? Wird Regenwasser versickert statt in die Kanalisation geleitet?" onclick="showFieldInfo('D3')"><strong>D3</strong><br><small>Ökol. öffentliche Leistung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner einen Arzttermin?" onclick="showFieldInfo('D4')"><strong>D4</strong><br><small>Soziale öffentliche Leistung</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird?" onclick="showFieldInfo('D5')"><strong>D5</strong><br><small>Demokratische Einbindung</small></div>
|
||||
|
||||
<div class="row-header">E · Gesellschaft</div>
|
||||
<div class="cell">E1</div><div class="cell">E2</div><div class="cell">E3</div><div class="cell">E4</div><div class="cell">E5</div>
|
||||
<div class="cell" style="cursor:pointer;" title="Hinterlassen wir unseren Enkeln einen Schuldenberg — oder investieren wir für 2050?" onclick="showFieldInfo('E1')"><strong>E1</strong><br><small>Zukünftige Generationen</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden?" onclick="showFieldInfo('E2')"><strong>E2</strong><br><small>Beitrag zum Gesamtwohl</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Denkt die Kommune an den CO₂-Fußabdruck, an Regenwälder, an Wasserverbrauch in Dürregebieten?" onclick="showFieldInfo('E3')"><strong>E3</strong><br><small>Ökologische Auswirkungen</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Unterstützt die Stadt strukturschwache Regionen? Partnerschaften im globalen Süden?" onclick="showFieldInfo('E4')"><strong>E4</strong><br><small>Sozialer Ausgleich</small></div>
|
||||
<div class="cell" style="cursor:pointer;" title="Setzt sich die Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene?" onclick="showFieldInfo('E5')"><strong>E5</strong><br><small>Demokratische Mitbestimmung</small></div>
|
||||
</div>
|
||||
|
||||
<p>Jedes Feld wird auf einer Skala von <strong>-5</strong> (fundamental widersprechend)
|
||||
bis <strong>+5</strong> (stark fördernd) bewertet. Der Gesamtscore (0-10) gewichtet
|
||||
die Matrix-Bewertungen und berücksichtigt Ausschlusskriterien:</p>
|
||||
|
||||
<table>
|
||||
<details>
|
||||
<summary>Bewertungsskala</summary>
|
||||
<table style="margin-top:0.5rem;">
|
||||
<tr><th>Symbol</th><th>Rating</th><th>Bedeutung</th></tr>
|
||||
<tr><td>++</td><td>+4 bis +5</td><td>Stark fördernd, vorbildlich</td></tr>
|
||||
<tr><td>+</td><td>+1 bis +3</td><td>Fördernd</td></tr>
|
||||
<tr><td>○</td><td>0</td><td>Neutral / nicht berührt</td></tr>
|
||||
<tr><td>−</td><td>-1 bis -3</td><td>Widersprechend</td></tr>
|
||||
<tr><td>−−</td><td>-4 bis -5</td><td>Stark widersprechend</td></tr>
|
||||
<tr><td>−</td><td>−1 bis −3</td><td>Widersprechend</td></tr>
|
||||
<tr><td>−−</td><td>−4 bis −5</td><td>Stark widersprechend</td></tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Mehr zur GWÖ-Matrix</summary>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die Matrix basiert auf dem
|
||||
<a href="https://econgood.org" target="_blank">Arbeitsbuch der Gemeinwohl-Ökonomie</a>.
|
||||
Die Adaption für Gemeinden fokussiert auf kommunale Handlungsfelder:
|
||||
Beschaffung, Haushalt, Verwaltung, Daseinsvorsorge und überregionale Wirkung.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2>Analyse-Pipeline</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>Jede Bewertung durchläuft fünf Schritte:</p>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<strong>Antrags-Text herunterladen</strong><br>
|
||||
Der Volltext wird automatisch aus dem jeweiligen Landtags-Portal geholt
|
||||
({{ adapter_count }} Parlamente angebunden). Der PDF-Text wird via PyMuPDF extrahiert.
|
||||
<strong>Antragstext laden</strong><br>
|
||||
Der PDF-Volltext wird aus dem Landtags-Portal geholt
|
||||
({{ adapter_count }} Parlamente angebunden). Nur echte Anträge und
|
||||
Gesetzentwürfe werden analysiert — Kleine Anfragen, Berichte und
|
||||
Beschlussempfehlungen werden automatisch erkannt und übersprungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<strong>Relevante Wahlprogramm-Passagen suchen</strong><br>
|
||||
Für <strong>alle Fraktionen der Wahlperiode</strong> werden per Embedding-Suche
|
||||
(Qwen text-embedding-v3) die thematisch relevantesten Passagen aus Wahl- und
|
||||
Grundsatzprogrammen gesucht (Top-5 pro Partei, Cosinus-Ähnlichkeit ≥ 0.45).
|
||||
<strong>Wahlprogramm-Passagen suchen</strong><br>
|
||||
Per semantischer Suche ({{ embedding_model }}, 1024 Dimensionen) werden für
|
||||
<strong>jede Fraktion</strong> die thematisch relevantesten Passagen aus
|
||||
Wahl- und Grundsatzprogrammen gefunden. Aktuell {{ programme_count }} Programme
|
||||
mit {{ chunk_count }} Textabschnitten indexiert.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -222,9 +445,10 @@
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<strong>KI-Bewertung</strong><br>
|
||||
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der GWÖ-Matrix
|
||||
und vergleicht ihn mit den gefundenen Programm-Passagen. Der Prompt enthält
|
||||
strikte Regeln für die Quellenangabe (nur wörtliche Zitate aus den vorgelegten Passagen).
|
||||
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der
|
||||
GWÖ-Matrix und vergleicht ihn mit den gefundenen Programmpassagen.
|
||||
Der Prompt erzwingt die Verwendung wörtlicher Zitate — das Modell darf
|
||||
keine Quellenangaben frei erfinden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -232,180 +456,81 @@
|
||||
<div class="step-num">4</div>
|
||||
<div>
|
||||
<strong>Zitat-Verifikation</strong><br>
|
||||
Jedes vom Modell genannte Zitat wird <strong>server-seitig verifiziert</strong>:
|
||||
Der zitierte Text muss als Substring (oder 5-Wort-Sequenz) in einem der
|
||||
vorgelegten Chunks auffindbar sein. Nicht-verifizierbare Zitate werden
|
||||
verworfen — Quellenangabe und Seitenzahl werden aus dem echten Treffer
|
||||
rekonstruiert, nicht aus der Modell-Ausgabe übernommen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">5</div>
|
||||
<div>
|
||||
<strong>Persistierung & Darstellung</strong><br>
|
||||
Die verifizierte Bewertung wird gespeichert. Klick auf ein Zitat öffnet
|
||||
das Original-Wahlprogramm-PDF mit <strong>gelb markierter Fundstelle</strong>.
|
||||
Jedes Zitat wird <strong>server-seitig verifiziert</strong>: der Text muss
|
||||
als Substring im Original-PDF auffindbar sein. Quellenangabe und Seitenzahl
|
||||
werden aus dem echten Treffer rekonstruiert — die Modell-Ausgabe wird für diese
|
||||
Felder verworfen. Klick auf ein Zitat öffnet das PDF mit markierter Fundstelle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Technische Details zum Sprachmodell</summary>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<table>
|
||||
<summary>Technische Details</summary>
|
||||
<table style="margin-top:0.5rem;">
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Modell</td><td>{{ model_name }}</td></tr>
|
||||
<tr><td>Anbieter</td><td>DashScope (Alibaba Cloud)</td></tr>
|
||||
<tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr>
|
||||
<tr><td>Embedding-Modell</td><td>text-embedding-v3 (1024 Dimensionen)</td></tr>
|
||||
<tr><td>Sprachmodell</td><td>{{ model_name }} (DashScope / Alibaba Cloud)</td></tr>
|
||||
<tr><td>Embedding-Modell</td><td>{{ embedding_model }} (1024 Dimensionen)</td></tr>
|
||||
<tr><td>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr>
|
||||
<tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr>
|
||||
<tr><td>Zitat-Verifikation</td><td>Substring- oder 5-Wort-Anker-Match gegen Original-PDF</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2>Beispiel einer Bewertung</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>Am Beispiel eines fiktiven Antrags "Kostenfreies Schulessen in allen Grundschulen":</p>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<strong>Antragstext wird geladen</strong><br>
|
||||
"Der Landtag wolle beschließen: Die Landesregierung wird aufgefordert,
|
||||
ein Programm für kostenfreies Mittagessen in allen Grundschulen …"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<strong>Embedding-Suche findet relevante Passagen</strong><br>
|
||||
Für jede Fraktion (z.B. SPD, CDU, GRÜNE, FDP, AfD) werden die thematisch
|
||||
nächsten Abschnitte aus den Wahlprogrammen gesucht. Beispiel:
|
||||
<em>"Wir setzen uns für gesunde Ernährung in Kitas und Schulen ein"</em>
|
||||
(GRÜNE Wahlprogramm, S. 47).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<strong>KI bewertet den Antrag</strong><br>
|
||||
GWÖ-Score: <strong>7/10</strong> — berührt D4 (Soziale öffentliche Leistung, ++),
|
||||
E3 (Ökologische Auswirkungen, +), B2 (Gemeinnutz im Finanzgebaren, +).
|
||||
Wahlprogramm-Passung GRÜNE: 9/10 mit Zitat aus S. 47.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">4</div>
|
||||
<div>
|
||||
<strong>Zitat wird verifiziert</strong><br>
|
||||
Der Server prüft: steht "gesunde Ernährung in Kitas und Schulen"
|
||||
wirklich auf S. 47 des GRÜNE-Wahlprogramms? ✓ Ja → Zitat wird übernommen.
|
||||
Quellenangabe wird aus dem echten Treffer konstruiert.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Wahlprogramm-Vergleich</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
Für jede Fraktion der aktuellen Wahlperiode wird die <strong>Passung</strong>
|
||||
des Antrags zu zwei Programmen bewertet:
|
||||
</p>
|
||||
<ul style="margin: 0.5rem 0 0.5rem 1.5rem;">
|
||||
<li><strong>Wahlprogramm</strong> — das Landtags-Wahlprogramm der jeweiligen Legislaturperiode</li>
|
||||
<li><strong>Grundsatzprogramm</strong> — das aktuelle Bundespartei-Grundsatzprogramm</li>
|
||||
</ul>
|
||||
<p>
|
||||
Aktuell sind <strong>{{ programme_count }} Programme</strong> indexiert
|
||||
({{ chunk_count }} Textabschnitte). Die vollständige Liste ist auf der
|
||||
<a href="/quellen">Quellen-Seite</a> einsehbar.
|
||||
</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Wichtig:</strong> Wenn für eine Fraktion kein Programm im Index vorhanden ist,
|
||||
wird kein Score vergeben — stattdessen erscheint ein Hinweis. Bewertungen
|
||||
basieren ausschließlich auf verifizierbaren Quellen, nicht auf dem Trainingswissen
|
||||
des Sprachmodells.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Qualitätssicherung</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Zitat-Verifikation (Sub-D)</h3>
|
||||
<p>
|
||||
Ein automatisierter Property-Test prüft für jedes in der Datenbank gespeicherte
|
||||
Zitat, ob der zitierte Text tatsächlich auf der angegebenen Seite des
|
||||
Wahlprogramm-PDFs vorkommt (Substring- oder 5-Wort-Anker-Match). Dieses
|
||||
Verfahren hat im April 2026 drei halluzinierte Zitate aufgedeckt und zur
|
||||
Implementierung der server-seitigen Verifikation geführt.
|
||||
</p>
|
||||
|
||||
<h3>Server-seitige Quellen-Rekonstruktion</h3>
|
||||
<p>
|
||||
Das Sprachmodell darf keine Quellenangaben (Programmname, Seitenzahl) frei
|
||||
erfinden. Nach jeder Analyse wird jedes Zitat gegen die tatsächlich vorgelegten
|
||||
Textabschnitte abgeglichen. Quellenangabe und URL werden aus dem gefundenen
|
||||
Treffer <strong>server-seitig konstruiert</strong> — die Modell-Ausgabe für
|
||||
diese Felder wird verworfen.
|
||||
</p>
|
||||
|
||||
<h3>Automatische Neu-Analyse</h3>
|
||||
<p>
|
||||
Wenn ein Nutzer auf ein Zitat klickt und die Textstelle im PDF nicht auffindbar
|
||||
ist (z.B. bei älteren Bewertungen vor der Verifikations-Einführung), wird der
|
||||
Antrag automatisch mit der aktuellen Pipeline neu analysiert.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Automatische Zitat-Verifikation</strong> — jedes Zitat wird gegen das
|
||||
Original-PDF geprüft. Nicht-verifizierbare Zitate werden verworfen. Dieses
|
||||
System hat im April 2026 drei LLM-halluzinierte Zitate aufgedeckt.</li>
|
||||
<li><strong>Typ-Filterung</strong> — nur abstimmbare Drucksachen (Anträge,
|
||||
Gesetzentwürfe) werden bewertet. Kleine Anfragen, Berichte und andere
|
||||
nicht-abstimmbare Dokumente werden automatisch erkannt und ausgeschlossen.</li>
|
||||
<li><strong>Automatische Neu-Analyse</strong> — wenn ein Zitat im PDF nicht
|
||||
auffindbar ist, wird der Antrag mit der aktuellen Pipeline neu analysiert.</li>
|
||||
<li><strong>Open Data</strong> — alle Bewertungen sind als JSON und CSV exportierbar
|
||||
(CC BY 4.0). Der Atom-Feed ermöglicht automatische Benachrichtigung bei neuen
|
||||
Bewertungen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Einschränkungen</h2>
|
||||
|
||||
<div class="card">
|
||||
<ul style="margin-left: 1.5rem;">
|
||||
<li><strong>Keine juristische Bewertung</strong> — die GWÖ-Analyse ist eine
|
||||
wertebasierte Einordnung, keine Rechtsprüfung.</li>
|
||||
<ul>
|
||||
<li><strong>Wertebasierte Einordnung, keine Rechtsprüfung</strong> — die Analyse
|
||||
bewertet nach GWÖ-Kriterien, nicht nach juristischer Zulässigkeit.</li>
|
||||
<li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen
|
||||
aufweisen. Die Bewertungen sollten als Orientierung verstanden werden,
|
||||
nicht als objektive Wahrheit.</li>
|
||||
<li><strong>Nur indexierte Programme</strong> — Parteien ohne hinterlegtes
|
||||
Programm können nicht zuverlässig bewertet werden.</li>
|
||||
<li><strong>Keine Analyse des Abstimmungsverhaltens</strong> — bewertet wird
|
||||
der Antragstext, nicht ob oder wie darüber abgestimmt wurde.</li>
|
||||
<li><strong>Aktualität</strong> — Wahlprogramme werden einmalig zur Wahl
|
||||
indexiert und nicht automatisch aktualisiert.</li>
|
||||
aufweisen. Bewertungen sind Orientierung, nicht objektive Wahrheit.</li>
|
||||
<li><strong>Programmabhängig</strong> — Fraktionen ohne hinterlegtes Wahlprogramm
|
||||
erhalten keinen Programm-Vergleich.</li>
|
||||
<li><strong>Antragstext, nicht Umsetzung</strong> — bewertet wird was im Antrag
|
||||
steht, nicht ob oder wie es umgesetzt wird.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Datenquellen</h2>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>{{ adapter_count }} Parlamente</strong> sind angebunden:</p>
|
||||
<p><strong>{{ adapter_count }} Parlamente</strong> angebunden:</p>
|
||||
<table>
|
||||
<tr><th>Parlament</th><th>Doku-System</th></tr>
|
||||
<tr><th>Parlament</th><th>System</th></tr>
|
||||
{% for bl in bundeslaender %}
|
||||
<tr>
|
||||
<td>{{ bl.name }} ({{ bl.code }})</td>
|
||||
<td>{{ bl.doku_system }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ bl.name }} ({{ bl.code }})</td><td>{{ bl.doku_system }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p style="margin-top: 1rem;">
|
||||
<a href="/quellen">Vollständige Programm-Liste</a> ·
|
||||
<a href="https://docs.toppyr.de/gwoe-antragspruefer/reference/adapter-capabilities/" target="_blank">Technische Adapter-Vergleichsmatrix</a> ·
|
||||
<a href="https://docs.toppyr.de/gwoe-antragspruefer/adr/" target="_blank">Architektur-Entscheidungen (ADRs)</a>
|
||||
<a href="/quellen">Programme & Quellen</a> ·
|
||||
<a href="/api/auswertungen/export.json" download>Open Data (JSON)</a> ·
|
||||
<a href="/api/feed.xml">Atom-Feed</a> ·
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ app_name }} · <a href="https://econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||
{{ app_name }} · <a href="https://germany.econgood.org" target="_blank">Gemeinwohl-Ökonomie</a> ·
|
||||
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -179,10 +179,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/">{{ app_name }}</a></h1>
|
||||
<span>→ Quellen</span>
|
||||
</header>
|
||||
{% set page_title = 'Quellen' %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||
|
||||
282
app/templates/v2/base.html
Normal file
@ -0,0 +1,282 @@
|
||||
{% from "v2/components/icon.html" import icon %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}GWÖ-Antragsprüfer{% endblock %}</title>
|
||||
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
|
||||
|
||||
{# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
|
||||
<link rel="stylesheet" href="/static/v2/tokens.css">
|
||||
<link rel="stylesheet" href="/static/v2/fonts.css">
|
||||
<link rel="stylesheet" href="/static/v2/v2.css">
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body class="v2">
|
||||
|
||||
{% block body %}
|
||||
{# AppShell inline, damit {% block main %} aus Screen-Templates rendert.
|
||||
include propagiert Blocks nicht (Jinja2-Limitierung), darum direkt hier. #}
|
||||
|
||||
<div id="v2-overlay" class="v2-overlay"></div>
|
||||
|
||||
<div class="v2-shell">
|
||||
|
||||
<aside id="v2-sidebar" class="v2-sidebar">
|
||||
<div class="v2-brand">
|
||||
GWÖ-<span class="grn">ANTRAGS</span><span class="blu">PRÜFER</span>
|
||||
</div>
|
||||
<div class="v2-brand-sub">Matrix 2.0 · Gemeinden</div>
|
||||
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Lesen</div>
|
||||
<a href="/" class="v2-nav-item {% if v2_active_nav == 'durchsuchen' %}active{% endif %}"
|
||||
aria-current="{% if v2_active_nav == 'durchsuchen' %}page{% endif %}">
|
||||
{{ icon("magnifying-glass", 14) }} Durchsuchen
|
||||
{% if assessment_count is defined and assessment_count %}
|
||||
<span class="v2-nav-count">{{ assessment_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
|
||||
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
|
||||
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
|
||||
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Prüfen</div>
|
||||
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
|
||||
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Daten</div>
|
||||
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
|
||||
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
|
||||
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
|
||||
</div>
|
||||
|
||||
{% if is_admin is defined and is_admin %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Administration</div>
|
||||
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
|
||||
<a href="/v2/admin/queue" class="v2-nav-item">{{ icon("list-checks", 14) }} Queue</a>
|
||||
<a href="/v2/admin/abos" class="v2-nav-item">{{ icon("envelope-simple", 14) }} Abos</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<header class="v2-topbar">
|
||||
<button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">☰</button>
|
||||
<span class="v2-topbar-spacer"></span>
|
||||
<a href="/classic" class="v2-back-link">{{ icon("arrow-square-out", 13) }} Klassische Ansicht</a>
|
||||
<a href="/methodik">{{ icon("info", 13) }} Methodik</a>
|
||||
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
|
||||
|
||||
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
|
||||
<div id="v2-auth-control" style="display:inline-flex;align-items:center;">
|
||||
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
|
||||
</div>
|
||||
|
||||
<button id="v2-theme-toggle"
|
||||
onclick="window.__v2CycleTheme && window.__v2CycleTheme()"
|
||||
aria-label="Farbschema wechseln"
|
||||
style="background:none;border:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);letter-spacing:0.06em;text-transform:uppercase;opacity:0.75;padding:0;display:inline-flex;align-items:center;gap:4px;">
|
||||
<span id="v2-theme-icon">{{ icon("circle-half", 14) }}</span><span id="v2-theme-label">Auto</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="v2-main" id="v2-main">
|
||||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="v2-footer">
|
||||
<span>GWÖ-Antragsprüfer · Matrix 2.0 · CC BY 4.0</span>
|
||||
<a href="/methodik">Methodik</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer">Quellcode</a>
|
||||
<span class="v2-topbar-spacer"></span>
|
||||
<a href="/classic" style="color:var(--ecg-green);opacity:1;">Zurück zur klassischen Ansicht</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
/* ── Dark-Mode Toggle ────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const STORAGE_KEY = 'gwoe.theme';
|
||||
const PREF_KEY = 'gwoe.ui';
|
||||
const root = document.documentElement;
|
||||
|
||||
function applyTheme(theme) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const current = localStorage.getItem(STORAGE_KEY) || 'auto';
|
||||
const next = { auto: 'light', light: 'dark', dark: 'auto' }[current] || 'auto';
|
||||
applyTheme(next);
|
||||
updateToggleLabel();
|
||||
}
|
||||
|
||||
function updateToggleLabel() {
|
||||
const labelEl = document.getElementById('v2-theme-label');
|
||||
const iconEl = document.getElementById('v2-theme-icon');
|
||||
if (!labelEl) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY) || 'auto';
|
||||
const labels = { auto: 'Auto', light: 'Hell', dark: 'Dunkel' };
|
||||
const icons = {
|
||||
auto: 'circle-half',
|
||||
light: 'sun',
|
||||
dark: 'moon'
|
||||
};
|
||||
labelEl.textContent = labels[current] || 'Auto';
|
||||
if (iconEl) {
|
||||
const iconName = icons[current] || 'circle-half';
|
||||
iconEl.dataset.icon = iconName;
|
||||
// Replace icon SVG dynamically via fetch (icons are static files)
|
||||
fetch('/static/v2/icons/phosphor/' + iconName + '.svg')
|
||||
.then(r => r.text())
|
||||
.then(svg => { iconEl.innerHTML = svg; })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Restore stored theme
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) applyTheme(stored);
|
||||
|
||||
window.__v2CycleTheme = cycleTheme;
|
||||
document.addEventListener('DOMContentLoaded', updateToggleLabel);
|
||||
})();
|
||||
|
||||
/* ── UI Preference (v2 = Default) ───────────────────────────────── */
|
||||
/* AUTO-REDIRECT DEAKTIVIERT — er verursacht Loop mit aggressivem classic-localStorage-Set.
|
||||
Umschalter via Topbar-Link „Klassische Ansicht" reicht als Opt-Out. */
|
||||
(function () {
|
||||
// Alte Preference-Spuren löschen, damit niemand festklebt
|
||||
localStorage.removeItem('gwoe.ui');
|
||||
return;
|
||||
// dead code weiter unten, bewusst belassen für Nachvollziehbarkeit
|
||||
// Wenn Nutzer:in zuvor explizit "classic" gewählt hat, zu /classic weiterleiten.
|
||||
// gwoe.ui='classic' wird von index.html gesetzt, wenn man /classic besucht.
|
||||
// Nach Rückkehr via "Zum neuen Design"-Link überschreibt v2 das localStorage,
|
||||
// damit die Präferenz für den nächsten Tab-Start korrekt ist.
|
||||
var pref = localStorage.getItem('gwoe.ui');
|
||||
if (pref === 'classic') {
|
||||
// Einmal weiterleiten; danach bleibt Nutzer:in auf v2 (kein Loop)
|
||||
localStorage.removeItem('gwoe.ui');
|
||||
window.location.replace('/classic');
|
||||
} else {
|
||||
localStorage.setItem('gwoe.ui', 'v2');
|
||||
}
|
||||
})();
|
||||
|
||||
/* ── Mobile Sidebar Toggle ───────────────────────────────────────── */
|
||||
(function () {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const toggle = document.getElementById('v2-menu-toggle');
|
||||
const sidebar = document.getElementById('v2-sidebar');
|
||||
const overlay = document.getElementById('v2-overlay');
|
||||
|
||||
if (!toggle || !sidebar || !overlay) return;
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('open');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', function () {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% block body_scripts %}{% endblock %}
|
||||
|
||||
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
|
||||
{% include "v2/components/auth_modal.html" %}
|
||||
|
||||
<script>
|
||||
/* ── v2 Auth-State — Topbar-Control ─────────────────────────────────── */
|
||||
(function () {
|
||||
|
||||
var TOPBAR_CONTROL_ID = 'v2-auth-control';
|
||||
|
||||
var BTN_BASE = [
|
||||
'background:none',
|
||||
'border:none',
|
||||
'cursor:pointer',
|
||||
'font-family:var(--font-sans)',
|
||||
'font-size:11px',
|
||||
'color:var(--ecg-dark)',
|
||||
'letter-spacing:0.06em',
|
||||
'text-transform:uppercase',
|
||||
'opacity:0.75',
|
||||
'padding:0',
|
||||
'display:inline-flex',
|
||||
'align-items:center',
|
||||
'gap:4px'
|
||||
].join(';');
|
||||
|
||||
function renderUnauthenticated(container) {
|
||||
container.innerHTML =
|
||||
'<button style="' + BTN_BASE + '" onclick="v2AuthModalOpen()" aria-label="Anmelden">' +
|
||||
'{{ icon("key", 13) | replace("\"", "\'") }} Anmelden' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function renderAuthenticated(container, user) {
|
||||
var name = user.preferred_username || user.name || user.sub || 'Konto';
|
||||
container.innerHTML =
|
||||
'<span style="' + BTN_BASE + ';cursor:default;gap:4px;">' +
|
||||
'{{ icon("user", 13) | replace("\"", "\'") }} ' +
|
||||
'<span style="max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + name + '">' + name + '</span>' +
|
||||
'</span>' +
|
||||
' ' +
|
||||
'<button style="' + BTN_BASE + '" onclick="v2AuthLogout()" aria-label="Abmelden">' +
|
||||
'{{ icon("sign-out", 13) | replace("\"", "\'") }} Abmelden' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
async function initV2Auth() {
|
||||
var container = document.getElementById(TOPBAR_CONTROL_ID);
|
||||
if (!container) return;
|
||||
try {
|
||||
var resp = await fetch('/api/auth/me');
|
||||
var data = await resp.json();
|
||||
if (data && data.authenticated) {
|
||||
renderAuthenticated(container, data);
|
||||
} else {
|
||||
renderUnauthenticated(container);
|
||||
}
|
||||
} catch (_) {
|
||||
renderUnauthenticated(container);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
// Server-seitig Cookies löschen (HttpOnly → client-side nicht möglich)
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
} catch (e) { /* ignore, reload trotzdem */ }
|
||||
location.reload();
|
||||
}
|
||||
|
||||
window.v2AuthLogout = logout;
|
||||
document.addEventListener('DOMContentLoaded', initV2Auth);
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
133
app/templates/v2/components/appshell.html
Normal file
@ -0,0 +1,133 @@
|
||||
{#
|
||||
appshell.html — AppShell-Macro für GWÖ-Antragsprüfer v2
|
||||
|
||||
Rendert die zweispaltige Shell mit Sidebar (230 px Desktop) und Drawer
|
||||
(< 900 px). Wird per {% include %} aus base.html eingebettet;
|
||||
der eigentliche Seiteninhalt kommt über den Jinja2-Block "main".
|
||||
|
||||
Navigation-Gruppen: LESEN / PRÜFEN / DATEN / ADMIN (laut Brief §04).
|
||||
Aktiver Eintrag: v2_active_nav wird vom Screen-Template gesetzt.
|
||||
#}
|
||||
|
||||
{# Overlay für mobilen Drawer #}
|
||||
<div id="v2-overlay" class="v2-overlay"></div>
|
||||
|
||||
<div class="v2-shell">
|
||||
|
||||
{# ── Sidebar ──────────────────────────────────────────────────── #}
|
||||
<aside id="v2-sidebar" class="v2-sidebar">
|
||||
<div class="v2-brand">
|
||||
GWÖ-<span class="grn">ANTRAGS</span><span class="blu">PRÜFER</span>
|
||||
</div>
|
||||
<div class="v2-brand-sub">Matrix 2.0 · Gemeinden</div>
|
||||
|
||||
<nav aria-label="Hauptnavigation">
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Lesen</div>
|
||||
<a href="/"
|
||||
class="v2-nav-item {% if v2_active_nav == 'durchsuchen' %}active{% endif %}"
|
||||
aria-current="{% if v2_active_nav == 'durchsuchen' %}page{% endif %}">
|
||||
Durchsuchen
|
||||
{% if assessment_count is defined and assessment_count %}
|
||||
<span class="v2-nav-count">{{ assessment_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/v2/merkliste"
|
||||
class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">
|
||||
Merkliste
|
||||
</a>
|
||||
<a href="/v2/tags"
|
||||
class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">
|
||||
Tags
|
||||
</a>
|
||||
<a href="/v2/cluster"
|
||||
class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">
|
||||
Cluster
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Prüfen</div>
|
||||
<a href="/v2/neu"
|
||||
class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">
|
||||
Neuer Antrag
|
||||
</a>
|
||||
<a href="/v2/batch"
|
||||
class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">
|
||||
Batch-Analyse
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Daten</div>
|
||||
<a href="/auswertungen"
|
||||
class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">
|
||||
Auswertungen
|
||||
</a>
|
||||
<a href="/api/auswertungen/export.csv"
|
||||
class="v2-nav-item {% if v2_active_nav == 'export' %}active{% endif %}">
|
||||
Export · API
|
||||
</a>
|
||||
<a href="/api/feed.xml"
|
||||
class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">
|
||||
Atom-Feed
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if is_admin is defined and is_admin %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Administration</div>
|
||||
<a href="/v2/admin/freischaltungen"
|
||||
class="v2-nav-item {% if v2_active_nav == 'freischaltungen' %}active{% endif %}">
|
||||
Freischaltungen
|
||||
</a>
|
||||
<a href="/v2/admin/queue"
|
||||
class="v2-nav-item {% if v2_active_nav == 'queue' %}active{% endif %}">
|
||||
Queue
|
||||
</a>
|
||||
<a href="/v2/admin/abos"
|
||||
class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">
|
||||
Abos
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{# ── Topbar ───────────────────────────────────────────────────── #}
|
||||
<header class="v2-topbar">
|
||||
<button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">
|
||||
☰
|
||||
</button>
|
||||
<span class="v2-topbar-spacer"></span>
|
||||
<a href="/classic" class="v2-back-link">Klassische Ansicht</a>
|
||||
<a href="/methodik">Methodik</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
<button id="v2-theme-toggle"
|
||||
aria-label="Farbschema wechseln (Hell / Dunkel / Auto)"
|
||||
onclick="window.__v2CycleTheme && window.__v2CycleTheme()"
|
||||
style="background:none;border:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);letter-spacing:0.06em;text-transform:uppercase;opacity:0.75;padding:0;">
|
||||
Auto
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{# ── Main Content ─────────────────────────────────────────────── #}
|
||||
<main class="v2-main" id="v2-main">
|
||||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{# ── Footer ───────────────────────────────────────────────────── #}
|
||||
<footer class="v2-footer">
|
||||
<span>GWÖ-Antragsprüfer · Matrix 2.0 · CC BY 4.0</span>
|
||||
<a href="/methodik">Methodik</a>
|
||||
<a href="/quellen">Quellen</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer">Quellcode</a>
|
||||
<span class="v2-topbar-spacer"></span>
|
||||
<a href="/classic" style="color:var(--ecg-green);opacity:1;">Zurück zur klassischen Ansicht</a>
|
||||
</footer>
|
||||
|
||||
</div>{# .v2-shell #}
|
||||
181
app/templates/v2/components/auth_modal.html
Normal file
@ -0,0 +1,181 @@
|
||||
{#
|
||||
auth_modal.html — Login- und Registrierungs-Modal für v2
|
||||
Einbinden via: {% include "v2/components/auth_modal.html" %}
|
||||
Öffnen via: document.getElementById('v2-auth-modal').style.display = 'flex'
|
||||
#}
|
||||
{% from "v2/components/icon.html" import icon %}
|
||||
|
||||
<!-- ── v2 Auth Modal ──────────────────────────────────────────────────── -->
|
||||
<div id="v2-auth-modal"
|
||||
role="dialog" aria-modal="true" aria-labelledby="v2-auth-modal-title"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:400;justify-content:center;align-items:center;"
|
||||
onclick="if(event.target===this)v2AuthModalClose()">
|
||||
|
||||
<div style="background:var(--paper);border-radius:8px;padding:var(--space-6);max-width:440px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:var(--font-sans);">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-5);">
|
||||
<h3 id="v2-auth-modal-title"
|
||||
style="margin:0;font-family:var(--font-sans);font-size:1.1rem;font-weight:700;color:var(--ecg-blue);letter-spacing:0.03em;">
|
||||
Anmelden
|
||||
</h3>
|
||||
<button onclick="v2AuthModalClose()"
|
||||
aria-label="Modal schließen"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--ecg-dark);opacity:0.6;font-size:1.2rem;line-height:1;padding:0;">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;border-bottom:2px solid var(--hairline);margin-bottom:var(--space-5);">
|
||||
<button id="v2-auth-tab-login"
|
||||
onclick="v2AuthSwitchTab('login')"
|
||||
style="flex:1;padding:var(--space-2) var(--space-3);border:none;background:none;cursor:pointer;font-family:var(--font-sans);font-size:0.9rem;font-weight:700;color:var(--ecg-blue);border-bottom:2px solid var(--ecg-blue);margin-bottom:-2px;">
|
||||
Anmelden
|
||||
</button>
|
||||
<button id="v2-auth-tab-register"
|
||||
onclick="v2AuthSwitchTab('register')"
|
||||
style="flex:1;padding:var(--space-2) var(--space-3);border:none;background:none;cursor:pointer;font-family:var(--font-sans);font-size:0.9rem;font-weight:400;color:var(--ecg-light);margin-bottom:-2px;">
|
||||
Registrieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="v2-auth-login-form" onsubmit="v2AuthSubmitLogin(event)"
|
||||
style="display:flex;flex-direction:column;gap:var(--space-3);">
|
||||
<input name="username" placeholder="Benutzername" required autocomplete="username"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<input name="password" type="password" placeholder="Passwort" required autocomplete="current-password"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<button type="submit"
|
||||
style="padding:var(--space-3);background:var(--ecg-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form id="v2-auth-register-form" onsubmit="v2AuthSubmitRegister(event)"
|
||||
style="display:none;flex-direction:column;gap:var(--space-3);">
|
||||
<input name="firstName" placeholder="Vorname" required autocomplete="given-name"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<input name="lastName" placeholder="Nachname" required autocomplete="family-name"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<input name="email" type="email" placeholder="E-Mail-Adresse" required autocomplete="email"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<input name="username" placeholder="Benutzername (frei wählbar)" required autocomplete="username"
|
||||
style="padding:var(--space-2) var(--space-3);border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:0.95rem;background:var(--surface);color:var(--ecg-dark);outline:none;">
|
||||
<button type="submit"
|
||||
style="padding:var(--space-3);background:var(--ecg-green);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
|
||||
Freischaltung beantragen
|
||||
</button>
|
||||
<p style="margin:0;font-size:0.78rem;color:var(--ecg-light);">
|
||||
Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Status-Anzeige (Fehler / Erfolg) -->
|
||||
<div id="v2-auth-status" style="margin-top:var(--space-3);font-size:0.875rem;min-height:1.2em;"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── v2 Auth Modal ───────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
|
||||
function open() {
|
||||
const modal = document.getElementById('v2-auth-modal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
// Status leeren bei jedem Öffnen
|
||||
const status = document.getElementById('v2-auth-status');
|
||||
if (status) status.innerHTML = '';
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modal = document.getElementById('v2-auth-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
const loginForm = document.getElementById('v2-auth-login-form');
|
||||
const registerForm = document.getElementById('v2-auth-register-form');
|
||||
const tabLogin = document.getElementById('v2-auth-tab-login');
|
||||
const tabRegister = document.getElementById('v2-auth-tab-register');
|
||||
const title = document.getElementById('v2-auth-modal-title');
|
||||
const status = document.getElementById('v2-auth-status');
|
||||
|
||||
if (!loginForm || !registerForm) return;
|
||||
|
||||
const isLogin = tab === 'login';
|
||||
loginForm.style.display = isLogin ? 'flex' : 'none';
|
||||
registerForm.style.display = isLogin ? 'none' : 'flex';
|
||||
|
||||
// Tab-Stile
|
||||
const activeStyle = 'color:var(--ecg-blue);font-weight:700;border-bottom:2px solid var(--ecg-blue);margin-bottom:-2px;';
|
||||
const inactiveStyle = 'color:var(--ecg-light);font-weight:400;margin-bottom:-2px;';
|
||||
tabLogin.style.cssText += isLogin ? activeStyle : inactiveStyle;
|
||||
tabRegister.style.cssText += isLogin ? inactiveStyle : activeStyle;
|
||||
|
||||
if (title) title.textContent = isLogin ? 'Anmelden' : 'Registrieren';
|
||||
if (status) status.innerHTML = '';
|
||||
}
|
||||
|
||||
async function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const status = document.getElementById('v2-auth-status');
|
||||
status.innerHTML = '<span style="color:var(--ecg-blue);">Anmeldung läuft\u2026</span>';
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(new FormData(form)).toString()
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.authenticated) {
|
||||
status.innerHTML = '<span style="color:var(--ecg-green);">Angemeldet.</span>';
|
||||
close();
|
||||
location.reload();
|
||||
} else {
|
||||
status.innerHTML = '<span style="color:#c33;">' + (data.detail || 'Anmeldung fehlgeschlagen') + '</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
status.innerHTML = '<span style="color:#c33;">' + err.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRegister(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const status = document.getElementById('v2-auth-status');
|
||||
status.innerHTML = '<span style="color:var(--ecg-blue);">Wird registriert\u2026</span>';
|
||||
try {
|
||||
const resp = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(new FormData(form)).toString()
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
status.innerHTML = '<span style="color:var(--ecg-green);">Freischaltung beantragt. Sie erhalten eine E-Mail, sobald Ihr Konto aktiviert ist.</span>';
|
||||
form.reset();
|
||||
} else {
|
||||
status.innerHTML = '<span style="color:#c33;">' + (data.detail || 'Registrierung fehlgeschlagen') + '</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
status.innerHTML = '<span style="color:#c33;">' + err.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ESC schließt Modal
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') close();
|
||||
});
|
||||
|
||||
// Globale API für Topbar-Button und externe Aufrufer
|
||||
window.v2AuthModalOpen = open;
|
||||
window.v2AuthModalClose = close;
|
||||
window.v2AuthSwitchTab = switchTab;
|
||||
window.v2AuthSubmitLogin = submitLogin;
|
||||
window.v2AuthSubmitRegister = submitRegister;
|
||||
|
||||
})();
|
||||
</script>
|
||||
32
app/templates/v2/components/chip.html
Normal file
@ -0,0 +1,32 @@
|
||||
{#
|
||||
chip.html — Filter/Tag-Chip
|
||||
|
||||
Props:
|
||||
label : Anzeigetext
|
||||
active : bool — ob der Chip selektiert ist (default False)
|
||||
variant : "default" | "green" | "dark"
|
||||
href : Optionaler Link. Ohne href wird ein <button> gerendert.
|
||||
attrs : Optionaler Dict mit zusätzlichen HTML-Attributen (z.B. data-*)
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/chip.html" import chip %}
|
||||
{{ chip("Bundesweit", active=True) }}
|
||||
{{ chip("BW", href="/v2?bl=BW") }}
|
||||
{{ chip("Score 8–10", active=True, variant="green") }}
|
||||
#}
|
||||
|
||||
{% macro chip(label, active=False, variant="default", href="", attrs={}) %}
|
||||
{% set classes = "v2-chip" %}
|
||||
{% if variant != "default" %}{% set classes = classes ~ " " ~ variant %}{% endif %}
|
||||
{% if active %}{% set classes = classes ~ " active" %}{% endif %}
|
||||
|
||||
{% if href %}
|
||||
<a class="{{ classes }}" href="{{ href }}"
|
||||
{% for k, v in attrs.items() %}{{ k }}="{{ v }}" {% endfor %}
|
||||
>{{ label }}</a>
|
||||
{% else %}
|
||||
<button class="{{ classes }}" type="button"
|
||||
{% for k, v in attrs.items() %}{{ k }}="{{ v }}" {% endfor %}
|
||||
>{{ label }}</button>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
1
app/templates/v2/components/icon.html
Normal file
@ -0,0 +1 @@
|
||||
{% macro icon(name, size=16, cls="") %}<span class="v2-icon {{ cls }}" style="display:inline-flex;width:{{ size }}px;height:{{ size }}px;flex-shrink:0;vertical-align:middle;" aria-hidden="true">{% include "v2/icons/phosphor/" ~ name ~ ".svg" %}</span>{% endmacro %}
|
||||
30
app/templates/v2/components/kasten.html
Normal file
@ -0,0 +1,30 @@
|
||||
{#
|
||||
kasten.html — ECOnGOOD-Kasten (4 Varianten, Manual Seite 13)
|
||||
|
||||
Props:
|
||||
variant : "solid-green" | "solid-blue" | "outline-green" | "outline-blue"
|
||||
title : Optionaler Kasten-Titel (h4, Avenir Black, color:inherit)
|
||||
body : Fließtext oder HTML-String
|
||||
caller : Optionaler Jinja2-Caller-Block für komplexen Body-Inhalt
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/kasten.html" import kasten %}
|
||||
{{ kasten("solid-green", "Hinweis", "Body-Text.") }}
|
||||
|
||||
Mit Caller-Block:
|
||||
{% call kasten("outline-blue", "Titel") %}
|
||||
<p>Komplexer Inhalt mit <strong>Markup</strong>.</p>
|
||||
{% endcall %}
|
||||
#}
|
||||
|
||||
{% macro kasten(variant="outline-green", title="", body="") %}
|
||||
<div class="v2-kasten {{ variant }}">
|
||||
{% if title %}
|
||||
<h4>{{ title }}</h4>
|
||||
{% endif %}
|
||||
{% if body %}
|
||||
<p>{{ body }}</p>
|
||||
{% endif %}
|
||||
{{ caller() if caller is defined else "" }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
69
app/templates/v2/components/matrix_mini.html
Normal file
@ -0,0 +1,69 @@
|
||||
{#
|
||||
matrix_mini.html — GWÖ-Matrix 5×5 Minidarstellung
|
||||
|
||||
Props:
|
||||
matrix : Dict mit Schlüsseln A1–E5, je Wert ein Dict:
|
||||
{ "rating": int (-2 bis 2), "symbol": str ("++"|"+"|"○"|"−"|"−−") }
|
||||
Fehlende Felder werden als neutral (○) dargestellt.
|
||||
|
||||
Farbstufen-Klassen (CSS in v2.css):
|
||||
m-pp : rating 2 (++ stark fördernd) — ECG-Grün auf Weiß
|
||||
m-p : rating 1 (+ fördernd) — Grün-Tint
|
||||
m-0 : rating 0 (○ neutral) — Weiß
|
||||
m-n : rating -1 (− widersprechend) — Rot-Tint
|
||||
m-nn : rating -2 (−− stark widerspr.)— Dunkelrot
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/matrix_mini.html" import matrix_mini %}
|
||||
{{ matrix_mini(assessment.matrix) }}
|
||||
#}
|
||||
|
||||
{% macro matrix_mini(matrix) %}
|
||||
{% set rows = ["A", "B", "C", "D", "E"] %}
|
||||
{% set cols = ["1", "2", "3", "4", "5"] %}
|
||||
{% set row_labels = {"A": "A · Liefer.", "B": "B · Finanzen", "C": "C · Verwalt.", "D": "D · Bürger", "E": "E · Gesell."} %}
|
||||
{% set col_labels = {"1": "Würde", "2": "Solid.", "3": "Ökol.", "4": "Soz.", "5": "Trans."} %}
|
||||
|
||||
{% macro rating_class(r) %}
|
||||
{% if r == 2 %}m-pp
|
||||
{% elif r == 1 %}m-p
|
||||
{% elif r == -1 %}m-n
|
||||
{% elif r == -2 %}m-nn
|
||||
{% else %}m-0{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<div class="v2-matrix-mini" role="table" aria-label="GWÖ-Matrix 5×5">
|
||||
{# Header-Zeile #}
|
||||
<div class="hdr" role="columnheader"></div>
|
||||
{% for c in cols %}
|
||||
<div class="hdr" role="columnheader">{{ col_labels[c] }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Daten-Zeilen #}
|
||||
{% for r in rows %}
|
||||
<div class="rhdr" role="rowheader">{{ row_labels[r] }}</div>
|
||||
{% for c in cols %}
|
||||
{% set key = r ~ c %}
|
||||
{% set cell = matrix[key] if matrix is defined and key in matrix else {} %}
|
||||
{% set rating = cell.rating | default(0) | int %}
|
||||
{% set symbol = cell.symbol | default("○") %}
|
||||
<div class="{{ rating_class(rating) | trim }}"
|
||||
role="cell"
|
||||
title="{{ key }}: {{ symbol }}"
|
||||
aria-label="{{ key }}, {{ symbol }}, {% if rating == 2 %}stark fördernd{% elif rating == 1 %}fördernd{% elif rating == 0 %}neutral{% elif rating == -1 %}widersprechend{% else %}stark widersprechend{% endif %}"
|
||||
onclick="if(typeof v2ShowMatrixFieldInfo==='function')v2ShowMatrixFieldInfo('{{ key }}')"
|
||||
style="cursor:pointer;">
|
||||
{{ symbol }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="v2-matrix-legend" aria-hidden="true">
|
||||
<span>++ stark fördernd</span>
|
||||
<span>+ fördernd</span>
|
||||
<span>○ neutral</span>
|
||||
<span>− widersprechend</span>
|
||||
<span>−− stark widerspr.</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
34
app/templates/v2/components/quote_card.html
Normal file
@ -0,0 +1,34 @@
|
||||
{#
|
||||
quote_card.html — Zitat-Karte mit Verifikations-Siegel
|
||||
|
||||
Props:
|
||||
text : str — Zitattext (wird kursiv gesetzt)
|
||||
source : str — Quellenangabe (z.B. "Wahlprogramm 2026 · S. 84")
|
||||
verified : bool — Zeigt ✓ verifiziert-Siegel (default True)
|
||||
contra : bool — Widerspruch-Variante (rote Border, default False)
|
||||
pdf_href : str — Optionaler Link zu PDF-Viewer mit Seiten-Anker
|
||||
|
||||
Farbcodierung:
|
||||
contra=False: border-left var(--ecg-blue), Siegel Grün
|
||||
contra=True: border-left var(--redline-contra), Siegel Rot
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/quote_card.html" import quote_card %}
|
||||
{{ quote_card("Wir verpflichten...", "Wahlprogramm 2026 · S. 84") }}
|
||||
{{ quote_card("Konkurrenz abzulehnen...", "Grundsatzprogramm · S. 42", contra=True) }}
|
||||
#}
|
||||
|
||||
{% macro quote_card(text, source="", verified=True, contra=False, pdf_href="") %}
|
||||
<div class="v2-quote {% if contra %}contra{% endif %}">
|
||||
<div class="q-body">„{{ text }}"</div>
|
||||
<cite>
|
||||
{% if verified %}
|
||||
<span class="verified">{% if contra %}✗{% else %}✓{% endif %} {% if contra %}Programm-Widerspruch{% else %}verifiziert{% endif %}</span>
|
||||
{% endif %}
|
||||
{{ source }}
|
||||
{% if pdf_href %}
|
||||
· <a href="{{ pdf_href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
|
||||
{% endif %}
|
||||
</cite>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
44
app/templates/v2/components/redline.html
Normal file
@ -0,0 +1,44 @@
|
||||
{#
|
||||
redline.html — Diff-Renderer für Redline-Vorschläge
|
||||
|
||||
Rendert die vom Backend gelieferten {del, ins}-Segmente als Mono-Block.
|
||||
Keine neue Diff-Logik im Frontend — der LLM-Output muss bereits
|
||||
formatierte Segmente enthalten (via v5-Prompt-Format).
|
||||
|
||||
Props:
|
||||
original : str — Original-Textauszug aus dem Antrag (für Kontext)
|
||||
vorschlag : str — Verbesserter Text; darf **fett** (ins) und
|
||||
~~durchgestrichen~~ (del) als Markdown-Marker enthalten,
|
||||
die zu <span class="ins"> / <span class="del"> gerendert werden.
|
||||
Backend kann alternativ bereits HTML liefern.
|
||||
segments : list[dict] optional — vorberechnete Segmente:
|
||||
[{"type": "del"|"ins"|"ctx", "text": "..."}]
|
||||
Wenn gesetzt, wird original/vorschlag ignoriert.
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/redline.html" import redline %}
|
||||
{{ redline("§ 3 Abs. 2 auf Antrag", "§ 3 Abs. 2 **verpflichtend**") }}
|
||||
{{ redline(segments=[{"type":"ctx","text":"§ 3 Abs. 2 "},{"type":"del","text":"auf Antrag"},{"type":"ins","text":"verpflichtend"}]) }}
|
||||
#}
|
||||
|
||||
{% macro redline(original="", vorschlag="", segments=none) %}
|
||||
<div class="v2-redline" role="region" aria-label="Redline-Vorschlag">
|
||||
{% if segments %}
|
||||
{# Segment-basiertes Rendering (bevorzugt) #}
|
||||
{% for seg in segments %}
|
||||
{% if seg.type == "del" %}<span class="del">{{ seg.text }}</span>
|
||||
{% elif seg.type == "ins" %}<span class="ins">{{ seg.text }}</span>
|
||||
{% else %}{{ seg.text }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Markdown-Marker-Rendering: **text** → ins, ~~text~~ → del #}
|
||||
{# Jinja2 hat kein eingebautes Regex-Replace, daher nutzen wir einen #}
|
||||
{# Inline-Namespace-Hack + einfaches Zeichen-für-Zeichen-Parsing. #}
|
||||
{# Für komplexere Fälle sollten Segmente vom Backend geliefert werden. #}
|
||||
{{ vorschlag | replace("**", "§INS§") | replace("~~", "§DEL§") }}
|
||||
{# Hinweis: Für Phase 2 sollte der Screen das Backend auffordern, #}
|
||||
{# segments direkt zu liefern. Dieser Fallback ist ein Stub. #}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
66
app/templates/v2/components/result_row.html
Normal file
@ -0,0 +1,66 @@
|
||||
{#
|
||||
result_row.html — Ergebnislisten-Zeile
|
||||
|
||||
Props (über assessment-Dict):
|
||||
assessment.score : float (0–10)
|
||||
assessment.title : str — Antragstitel (Avenir Black, 14.5 px)
|
||||
assessment.drucksache : str — Drucksache-ID
|
||||
assessment.bundesland : str — Bundesland-Kürzel
|
||||
assessment.parteien : list[str] — Liste der einreichenden Fraktionen
|
||||
assessment.tags : list[str] — Themen-Tags (optional)
|
||||
assessment.datum : str — Datum (YYYY-MM-DD oder lesbar)
|
||||
assessment.href : str — Link zur Detailseite
|
||||
|
||||
Score-Band-Klassen:
|
||||
s-high : Score >= 8 (Grün-Tint)
|
||||
s-mid : Score 5–7 (Grau)
|
||||
s-low : Score < 5 (Rot-Tint)
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/result_row.html" import result_row %}
|
||||
{% for a in assessments %}
|
||||
{{ result_row(a) }}
|
||||
{% endfor %}
|
||||
#}
|
||||
|
||||
{% macro result_row(assessment) %}
|
||||
{% set score = assessment.score | float %}
|
||||
{% if score >= 8 %}
|
||||
{% set band = "s-high" %}
|
||||
{% elif score >= 5 %}
|
||||
{% set band = "s-mid" %}
|
||||
{% else %}
|
||||
{% set band = "s-low" %}
|
||||
{% endif %}
|
||||
|
||||
<a class="v2-result-row"
|
||||
href="{{ assessment.href | default('/v2/antrag/' ~ assessment.drucksache) }}"
|
||||
aria-label="{{ assessment.title }} — Score {{ '%.1f'|format(score) }}">
|
||||
|
||||
<div class="v2-score-cell {{ band }}" aria-label="Score {{ '%.1f'|format(score) }}">
|
||||
{{ "%.1f" | format(score) }}
|
||||
<small>Score</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="v2-r-title">{{ assessment.title }}</div>
|
||||
<div class="v2-r-sub">
|
||||
{% for p in (assessment.parteien | default([])) %}
|
||||
<span class="v2-party-chip">{{ p }}</span>
|
||||
{% endfor %}
|
||||
· Drucksache {{ assessment.drucksache }}
|
||||
{% if assessment.tags is defined and assessment.tags %}
|
||||
· {{ assessment.tags | join(", ") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v2-r-state">
|
||||
{{ assessment.bundesland | default("") }}
|
||||
{% if assessment.parlament is defined %} · {{ assessment.parlament }}{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="v2-r-date">{{ assessment.datum | default("") }}</div>
|
||||
|
||||
</a>
|
||||
{% endmacro %}
|
||||
32
app/templates/v2/components/score_hero.html
Normal file
@ -0,0 +1,32 @@
|
||||
{#
|
||||
score_hero.html — Großer Score-Block für die Detailseite
|
||||
|
||||
Props:
|
||||
score : float (0–10) — der GWÖ-Score
|
||||
verdict_title : str — kurzes Urteil (z.B. "Vorbildlich"), UPPERCASE
|
||||
verdict_body : str — ein bis zwei Sätze Urteilsbeschreibung
|
||||
|
||||
Verhalten:
|
||||
- score >= 8: var(--ecg-green) als Akzentfarbe
|
||||
- score < 5: var(--redline-contra) als Akzentfarbe (CSS-Klasse "low")
|
||||
- 5–7: Neutral (var(--ecg-dark))
|
||||
|
||||
Verwendung:
|
||||
{% from "v2/components/score_hero.html" import score_hero %}
|
||||
{{ score_hero(9.1, "Vorbildlich", "Starker Beitrag zur ökologischen Nachhaltigkeit.") }}
|
||||
#}
|
||||
|
||||
{% macro score_hero(score, verdict_title="", verdict_body="") %}
|
||||
{% set s = score | float %}
|
||||
{% if s < 5 %}{% set modifier = "low" %}{% else %}{% set modifier = "" %}{% endif %}
|
||||
|
||||
<div class="v2-score-hero {{ modifier }}" role="region" aria-label="GWÖ-Score {{ '%.1f'|format(s) }} von 10">
|
||||
<div class="big-num" aria-hidden="true">
|
||||
{{ "%.1f" | format(s) }}<span class="slash">/10</span>
|
||||
</div>
|
||||
<div class="verdict">
|
||||
{% if verdict_title %}<b>{{ verdict_title }}</b>{% endif %}
|
||||
{{ verdict_body }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
1
app/templates/v2/icons/phosphor/arrow-square-out.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
1
app/templates/v2/icons/phosphor/book-open.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></svg>
|
||||
|
After Width: | Height: | Size: 396 B |
1
app/templates/v2/icons/phosphor/bookmark-simple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
1
app/templates/v2/icons/phosphor/chart-bar.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
1
app/templates/v2/icons/phosphor/circle-half.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,16.37a86.4,86.4,0,0,1,16,3V212.67a86.4,86.4,0,0,1-16,3Zm32,9.26a87.81,87.81,0,0,1,16,10.54V195.83a87.81,87.81,0,0,1-16,10.54ZM40,128a88.11,88.11,0,0,1,80-87.63V215.63A88.11,88.11,0,0,1,40,128Zm160,50.54V77.46a87.82,87.82,0,0,1,0,101.08Z"/></svg>
|
||||
|
After Width: | Height: | Size: 396 B |
1
app/templates/v2/icons/phosphor/envelope-simple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
1
app/templates/v2/icons/phosphor/file-csv.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M48,180c0,11,7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,85.78,206.4,30.06,30.06,0,0,1,64,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,64,160C55.18,160,48,169,48,180Zm79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84-1.25-.81-1.23-1-1.12-1.9a4.57,4.57,0,0,1,2-3.67c4.6-3.12,15.34-1.73,19.82-.56A8,8,0,0,0,142,145.86c-2.12-.55-21-5.22-32.84,2.76a20.58,20.58,0,0,0-9,14.95c-2,15.88,13.65,20.41,23,23.11,12.06,3.49,13.12,4.92,12.78,7.59-.31,2.41-1.26,3.34-2.14,3.93-4.6,3.06-15.17,1.56-19.55.36A8,8,0,0,0,109.94,214a61.34,61.34,0,0,0,15.19,2c5.82,0,12.3-1,17.49-4.46a20.82,20.82,0,0,0,9.19-15.23C154,179,137.49,174.17,127.6,171.31Zm83.09-26.84a8,8,0,0,0-10.23,4.84L188,184.21l-12.47-34.9a8,8,0,0,0-15.07,5.38l20,56a8,8,0,0,0,15.07,0l20-56A8,8,0,0,0,210.69,144.47ZM216,88v24a8,8,0,0,1-16,0V96H152a8,8,0,0,1-8-8V40H56v72a8,8,0,0,1-16,0V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-27.31-8L160,51.31V80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
app/templates/v2/icons/phosphor/file-plus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-40-64a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,152Z"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
1
app/templates/v2/icons/phosphor/graph.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 754 B |
1
app/templates/v2/icons/phosphor/info.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></svg>
|
||||
|
After Width: | Height: | Size: 348 B |
1
app/templates/v2/icons/phosphor/key.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></svg>
|
||||
|
After Width: | Height: | Size: 640 B |
1
app/templates/v2/icons/phosphor/list-checks.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,112a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Zm77.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88.11,88.11,0,1,1,11.31-11.31l50.07,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
1
app/templates/v2/icons/phosphor/magnifying-glass.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
|
||||
|
After Width: | Height: | Size: 243 B |
1
app/templates/v2/icons/phosphor/moon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
1
app/templates/v2/icons/phosphor/rss.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M106.91,149.09A71.53,71.53,0,0,1,128,200a8,8,0,0,1-16,0,56,56,0,0,0-56-56,8,8,0,0,1,0-16A71.53,71.53,0,0,1,106.91,149.09ZM56,80a8,8,0,0,0,0,16A104,104,0,0,1,160,200a8,8,0,0,0,16,0A120,120,0,0,0,56,80Zm118.79,1.21A166.9,166.9,0,0,0,56,32a8,8,0,0,0,0,16A151,151,0,0,1,163.48,92.52,151,151,0,0,1,208,200a8,8,0,0,0,16,0A166.9,166.9,0,0,0,174.79,81.21ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 492 B |
1
app/templates/v2/icons/phosphor/sign-out.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
1
app/templates/v2/icons/phosphor/stack.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"/></svg>
|
||||
|
After Width: | Height: | Size: 483 B |
1
app/templates/v2/icons/phosphor/sun.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>
|
||||
|
After Width: | Height: | Size: 685 B |
1
app/templates/v2/icons/phosphor/tag.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></svg>
|
||||
|
After Width: | Height: | Size: 345 B |
1
app/templates/v2/icons/phosphor/user-check.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M144,157.68a68,68,0,1,0-71.9,0c-20.65,6.76-39.23,19.39-54.17,37.17a8,8,0,0,0,12.25,10.3C50.25,181.19,77.91,168,108,168s57.75,13.19,77.87,37.15a8,8,0,0,0,12.25-10.3C183.18,177.07,164.6,164.44,144,157.68ZM56,100a52,52,0,1,1,52,52A52.06,52.06,0,0,1,56,100Zm197.66,33.66-32,32a8,8,0,0,1-11.32,0l-16-16a8,8,0,0,1,11.32-11.32L216,148.69l26.34-26.35a8,8,0,0,1,11.32,11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 465 B |
1
app/templates/v2/icons/phosphor/user.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
|
||||
|
After Width: | Height: | Size: 340 B |
221
app/templates/v2/og_template.html
Normal file
@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OG Card — {{ antrag.drucksache if antrag else "GWÖ" }}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #0d1117 0%, #1a2435 60%, #0d1117 100%);
|
||||
padding: 56px 72px 48px;
|
||||
}
|
||||
|
||||
/* Dekorativer Akzent-Streifen oben */
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #2d9e5f 0%, #2a7db5 100%);
|
||||
}
|
||||
|
||||
/* Wortmarke */
|
||||
.brand {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 36px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.brand .grn { color: #2d9e5f; }
|
||||
.brand .blu { color: #2a7db5; }
|
||||
|
||||
/* Haupt-Inhalt: Score + Titelblock nebeneinander */
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
/* Score-Block links */
|
||||
.score-block {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 104px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.score-num.high { color: #2d9e5f; }
|
||||
.score-num.mid { color: #e6edf3; }
|
||||
.score-num.low { color: #e05252; }
|
||||
|
||||
.score-denom {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: #8b949e;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Trennlinie */
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(230,237,243,0.12);
|
||||
}
|
||||
|
||||
/* Titelblock rechts */
|
||||
.title-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.antrag-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #e6edf3;
|
||||
margin-bottom: 24px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(230,237,243,0.08);
|
||||
color: #8b949e;
|
||||
border: 1px solid rgba(230,237,243,0.12);
|
||||
}
|
||||
|
||||
.chip.partei-cdu { background: rgba(0,0,0,0.4); color: #ccc; }
|
||||
.chip.partei-spd { background: rgba(180,30,30,0.25); color: #f08080; }
|
||||
.chip.partei-grune { background: rgba(45,158,95,0.25); color: #5dcf8a; }
|
||||
.chip.partei-fdp { background: rgba(230,186,0,0.25); color: #f5d34f; }
|
||||
.chip.partei-afd { background: rgba(0,100,170,0.25); color: #7ec8f5; }
|
||||
.chip.partei-linke { background: rgba(140,30,140,0.25); color: #df8ade; }
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(230,237,243,0.08);
|
||||
font-size: 12px;
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
.footer-url { letter-spacing: 0.04em; }
|
||||
.footer-matrix { font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
|
||||
<div class="brand">
|
||||
GWÖ-<span class="grn">ANTRAGS</span><span class="blu">PRÜFER</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
{% if antrag is defined and antrag %}
|
||||
{% set s = antrag.score | float %}
|
||||
{% if s >= 8 %}{% set score_cls = "high" %}
|
||||
{% elif s < 5 %}{% set score_cls = "low" %}
|
||||
{% else %}{% set score_cls = "mid" %}{% endif %}
|
||||
|
||||
<div class="score-block">
|
||||
<div class="score-num {{ score_cls }}">{{ "%.1f"|format(s) }}</div>
|
||||
<div class="score-denom">/ 10</div>
|
||||
{% if antrag.verdict_title %}
|
||||
<div class="verdict">{{ antrag.verdict_title }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="title-block">
|
||||
<div class="antrag-title">{{ antrag.title }}</div>
|
||||
<div class="meta">
|
||||
{% if antrag.bundesland %}
|
||||
<span class="chip">{{ antrag.bundesland }}</span>
|
||||
{% endif %}
|
||||
{% if antrag.drucksache %}
|
||||
<span class="chip">{{ antrag.drucksache }}</span>
|
||||
{% endif %}
|
||||
{% for partei in (antrag.parteien or []) %}
|
||||
{% set pcls = partei | lower | replace("ü","u") | replace("ä","a") | replace("ö","o") | replace("/","") | replace(" ","") | replace("90","") | replace("bündnis","grune") | replace("dielinke","linke") %}
|
||||
<span class="chip partei-{{ pcls }}">{{ partei }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="title-block">
|
||||
<div class="antrag-title" style="font-size:36px;">GWÖ-Antragsprüfer</div>
|
||||
<div class="meta">
|
||||
<span class="chip">Matrix 2.0 · Gemeinden</span>
|
||||
<span class="chip">gwoe.toppyr.de</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span class="footer-url">gwoe.toppyr.de</span>
|
||||
<span class="footer-matrix">Gemeinwohl-Ökonomie · Matrix 2.0 · Gemeinden</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
129
app/templates/v2/screens/admin_abos.html
Normal file
@ -0,0 +1,129 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Abo-Verwaltung — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "admin_abos" %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Abo-Verwaltung</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
E-Mail-Abonnements aller Nutzer*innen · nur für Admins
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">
|
||||
Lade Abonnements …
|
||||
</div>
|
||||
|
||||
<div id="empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<h4>Keine Abonnements vorhanden</h4>
|
||||
<p>Es wurden noch keine E-Mail-Abonnements eingerichtet.</p>
|
||||
</div>
|
||||
|
||||
<div id="error" class="v2-kasten outline-blue" style="display:none;">
|
||||
<h4>Fehler beim Laden</h4>
|
||||
<p id="error-msg"></p>
|
||||
</div>
|
||||
|
||||
<div id="table-wrap" style="display:none;">
|
||||
<div id="abo-count" style="font-family:var(--font-mono);font-size:11px;opacity:0.5;margin-bottom:10px;"></div>
|
||||
<table class="v2-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Bundesland</th>
|
||||
<th>Partei</th>
|
||||
<th>Rhythmus</th>
|
||||
<th>Zuletzt gesendet</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="abo-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
(async function () {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const errorEl = document.getElementById('error');
|
||||
const errorMsgEl = document.getElementById('error-msg');
|
||||
const tableEl = document.getElementById('table-wrap');
|
||||
const tbodyEl = document.getElementById('abo-rows');
|
||||
const countEl = document.getElementById('abo-count');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorMsgEl.textContent = msg;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
|
||||
function fmtDate(ts) {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
||||
catch (_) { return ts; }
|
||||
}
|
||||
|
||||
async function deleteAbo(subId, btn) {
|
||||
if (!confirm('Abo #' + subId + ' wirklich löschen?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const resp = await fetch('/api/subscriptions/' + subId, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
btn.textContent = 'Fehler: ' + (data.detail || resp.status);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const row = btn.closest('tr');
|
||||
if (row) {
|
||||
row.style.opacity = '0.3';
|
||||
row.style.transition = 'opacity .3s';
|
||||
setTimeout(() => row.remove(), 350);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Fehler';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/subscriptions');
|
||||
if (resp.status === 403) { showError('Zugriff verweigert (kein Admin).'); return; }
|
||||
if (!resp.ok) { showError('HTTP ' + resp.status); return; }
|
||||
const abos = await resp.json();
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
if (!abos.length) { emptyEl.style.display = ''; return; }
|
||||
|
||||
countEl.textContent = abos.length + ' Abonnement' + (abos.length !== 1 ? 'e' : '');
|
||||
|
||||
tbodyEl.innerHTML = abos.map(a => `
|
||||
<tr id="abo-${a.id}">
|
||||
<td style="font-family:var(--font-mono);font-size:11px;opacity:0.6;">${a.id}</td>
|
||||
<td style="font-size:12px;">${a.email || a.user_id || '—'}</td>
|
||||
<td>${a.bundesland || '<span style="opacity:.4">alle</span>'}</td>
|
||||
<td>${a.partei || '<span style="opacity:.4">alle</span>'}</td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${a.frequency || 'weekly'}</td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${fmtDate(a.last_sent_at)}</td>
|
||||
<td>
|
||||
<button class="v2-admin-btn danger" onclick="deleteAboRow(${a.id}, this)">
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
tableEl.style.display = '';
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
|
||||
window.deleteAboRow = deleteAbo;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
118
app/templates/v2/screens/admin_freischaltungen.html
Normal file
@ -0,0 +1,118 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Freischaltungen — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "admin_freischaltungen" %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Freischaltungen</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Ausstehende Registrierungen · nur für Admins
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">
|
||||
Lade ausstehende Freischaltungen …
|
||||
</div>
|
||||
|
||||
<div id="empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<h4>Keine ausstehenden Freischaltungen</h4>
|
||||
<p>Alle Registrierungen wurden bereits bearbeitet.</p>
|
||||
</div>
|
||||
|
||||
<div id="error" class="v2-kasten outline-blue" style="display:none;">
|
||||
<h4>Fehler beim Laden</h4>
|
||||
<p id="error-msg"></p>
|
||||
</div>
|
||||
|
||||
<div id="table-wrap" style="display:none;">
|
||||
<table class="v2-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User-ID</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Name</th>
|
||||
<th>Registriert</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
(async function () {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const errorEl = document.getElementById('error');
|
||||
const errorMsgEl = document.getElementById('error-msg');
|
||||
const tableEl = document.getElementById('table-wrap');
|
||||
const tbodyEl = document.getElementById('user-rows');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorMsgEl.textContent = msg;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/pending-users');
|
||||
if (resp.status === 403) { showError('Zugriff verweigert (kein Admin).'); return; }
|
||||
if (!resp.ok) { showError('HTTP ' + resp.status); return; }
|
||||
const users = await resp.json();
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
if (!users.length) { emptyEl.style.display = ''; return; }
|
||||
|
||||
tbodyEl.innerHTML = users.map(u => `
|
||||
<tr id="row-${u.id}">
|
||||
<td style="font-family:var(--font-mono);font-size:11px;opacity:0.7;">${u.id}</td>
|
||||
<td>${u.email || '—'}</td>
|
||||
<td>${[u.firstName, u.lastName].filter(Boolean).join(' ') || '—'}</td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${u.createdTimestamp ? new Date(u.createdTimestamp).toLocaleDateString('de-DE') : '—'}</td>
|
||||
<td>
|
||||
<button class="v2-admin-btn" onclick="approveUser('${u.id}', this)">
|
||||
Freischalten
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
tableEl.style.display = '';
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
window.approveUser = async function(userId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('user_id', userId);
|
||||
const resp = await fetch('/api/auth/approve-user', { method: 'POST', body: fd });
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
btn.textContent = 'Fehler: ' + (data.detail || resp.status);
|
||||
btn.style.color = 'var(--ecg-blue)';
|
||||
return;
|
||||
}
|
||||
const row = document.getElementById('row-' + userId);
|
||||
if (row) {
|
||||
row.style.opacity = '0.4';
|
||||
row.querySelector('td:last-child').textContent = 'Freigeschaltet ✓';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Fehler';
|
||||
btn.style.color = 'var(--ecg-blue)';
|
||||
}
|
||||
};
|
||||
|
||||
await loadUsers();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
175
app/templates/v2/screens/admin_queue.html
Normal file
@ -0,0 +1,175 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Queue-Status — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "admin_queue" %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;display:flex;align-items:baseline;gap:16px;">
|
||||
<div>
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Queue-Status</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Analyse-Jobs · automatische Aktualisierung alle 5 s
|
||||
</p>
|
||||
</div>
|
||||
<span id="refresh-indicator" style="font-family:var(--font-mono);font-size:11px;opacity:0.4;margin-left:auto;">—</span>
|
||||
</div>
|
||||
|
||||
<div id="loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">
|
||||
Lade Queue …
|
||||
</div>
|
||||
|
||||
<div id="error" class="v2-kasten outline-blue" style="display:none;">
|
||||
<h4>Fehler beim Laden</h4>
|
||||
<p id="error-msg"></p>
|
||||
</div>
|
||||
|
||||
<div id="content" style="display:none;">
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px;">
|
||||
<div class="v2-kasten" style="text-align:center;">
|
||||
<div style="font-family:var(--font-mono);font-size:28px;font-weight:700;color:var(--ecg-teal);" id="stat-running">—</div>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;opacity:0.6;margin-top:4px;">Läuft</div>
|
||||
</div>
|
||||
<div class="v2-kasten" style="text-align:center;">
|
||||
<div style="font-family:var(--font-mono);font-size:28px;font-weight:700;color:var(--ecg-blue);" id="stat-queued">—</div>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;opacity:0.6;margin-top:4px;">Wartend</div>
|
||||
</div>
|
||||
<div class="v2-kasten" style="text-align:center;">
|
||||
<div style="font-family:var(--font-mono);font-size:28px;font-weight:700;color:var(--ecg-dark);opacity:0.5;" id="stat-failed">—</div>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;opacity:0.6;margin-top:4px;">Fehlgeschlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Laufende Jobs -->
|
||||
<div style="margin-bottom:24px;">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.08em;opacity:0.5;margin-bottom:8px;text-transform:uppercase;">Laufende Jobs</div>
|
||||
<div id="running-empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<p>Keine laufenden Jobs.</p>
|
||||
</div>
|
||||
<table id="running-table" class="v2-admin-table" style="display:none;">
|
||||
<thead>
|
||||
<tr><th>Drucksache</th><th>Status</th><th>Gestartet</th></tr>
|
||||
</thead>
|
||||
<tbody id="running-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Wartende Jobs -->
|
||||
<div style="margin-bottom:24px;">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.08em;opacity:0.5;margin-bottom:8px;text-transform:uppercase;">Wartende Jobs</div>
|
||||
<div id="queued-empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<p>Keine wartenden Jobs.</p>
|
||||
</div>
|
||||
<table id="queued-table" class="v2-admin-table" style="display:none;">
|
||||
<thead>
|
||||
<tr><th>Drucksache</th><th>Bundesland</th><th>Eingereiht</th></tr>
|
||||
</thead>
|
||||
<tbody id="queued-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Fehlgeschlagene Jobs -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.08em;opacity:0.5;margin-bottom:8px;text-transform:uppercase;">Fehlgeschlagene Jobs</div>
|
||||
<div id="failed-empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<p>Keine fehlgeschlagenen Jobs.</p>
|
||||
</div>
|
||||
<table id="failed-table" class="v2-admin-table" style="display:none;">
|
||||
<thead>
|
||||
<tr><th>Drucksache</th><th>Fehler</th><th>Zeit</th></tr>
|
||||
</thead>
|
||||
<tbody id="failed-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
let firstLoad = true;
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
||||
catch (_) { return ts; }
|
||||
}
|
||||
|
||||
function renderTable(tbodyId, tableId, emptyId, rows, renderRow) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
const table = document.getElementById(tableId);
|
||||
const emptyEl = document.getElementById(emptyId);
|
||||
if (!rows || !rows.length) {
|
||||
table.style.display = 'none';
|
||||
emptyEl.style.display = '';
|
||||
} else {
|
||||
emptyEl.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(renderRow).join('');
|
||||
table.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await fetch('/api/queue/status');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
|
||||
if (firstLoad) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('content').style.display = '';
|
||||
firstLoad = false;
|
||||
}
|
||||
|
||||
// Statistik-Kacheln
|
||||
const running = data.running || [];
|
||||
const queued = data.queued || data.waiting || [];
|
||||
const failed = data.failed || [];
|
||||
|
||||
document.getElementById('stat-running').textContent = running.length;
|
||||
document.getElementById('stat-queued').textContent = queued.length;
|
||||
document.getElementById('stat-failed').textContent = failed.length;
|
||||
|
||||
// Laufende Jobs
|
||||
renderTable('running-rows', 'running-table', 'running-empty', running, j => `
|
||||
<tr>
|
||||
<td style="font-family:var(--font-mono);font-size:12px;">${j.drucksache || j.id || '—'}</td>
|
||||
<td><span class="v2-admin-badge running">${j.status || 'running'}</span></td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${fmtTime(j.started_at || j.created_at)}</td>
|
||||
</tr>`);
|
||||
|
||||
// Wartende Jobs
|
||||
renderTable('queued-rows', 'queued-table', 'queued-empty', queued, j => `
|
||||
<tr>
|
||||
<td style="font-family:var(--font-mono);font-size:12px;">${j.drucksache || j.id || '—'}</td>
|
||||
<td>${j.bundesland || '—'}</td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${fmtTime(j.created_at || j.enqueued_at)}</td>
|
||||
</tr>`);
|
||||
|
||||
// Fehlgeschlagene Jobs
|
||||
renderTable('failed-rows', 'failed-table', 'failed-empty', failed, j => `
|
||||
<tr>
|
||||
<td style="font-family:var(--font-mono);font-size:12px;">${j.drucksache || j.id || '—'}</td>
|
||||
<td style="font-size:11px;color:var(--ecg-blue);max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||
title="${(j.error || '').replace(/"/g,'"')}">${j.error || '—'}</td>
|
||||
<td style="font-family:var(--font-mono);font-size:11px;">${fmtTime(j.failed_at || j.updated_at)}</td>
|
||||
</tr>`);
|
||||
|
||||
document.getElementById('refresh-indicator').textContent =
|
||||
'Aktualisiert ' + new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
} catch (e) {
|
||||
if (firstLoad) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('error-msg').textContent = e.message;
|
||||
document.getElementById('error').style.display = '';
|
||||
firstLoad = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
app/templates/v2/screens/admin_wahlprogramme.html
Normal file
@ -0,0 +1,122 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Wahlprogramme — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "admin_wahlprogramme" %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">
|
||||
Wahlprogramm-Beschaffung
|
||||
</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Halbautomatisch (#138) · nur Lücken mit Kandidaten-URL aus wahlprogramm-links.yaml
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="flash" class="v2-kasten" style="display:none;margin-bottom:16px;"></div>
|
||||
|
||||
{% if not missing %}
|
||||
<div class="v2-kasten" style="opacity:0.7;">
|
||||
<p style="font-family:var(--font-mono);font-size:13px;">Keine Lücken gefunden — alle registrierten Einträge haben eine Datei.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;font-family:var(--font-mono);">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--ecg-border, rgba(255,255,255,0.1));opacity:0.5;">
|
||||
<th style="text-align:left;padding:8px 12px;">BL</th>
|
||||
<th style="text-align:left;padding:8px 12px;">Partei</th>
|
||||
<th style="text-align:left;padding:8px 12px;">Dateiname</th>
|
||||
<th style="text-align:left;padding:8px 12px;">Kandidat-URL</th>
|
||||
<th style="padding:8px 12px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in missing %}
|
||||
{% set first_url = entry.kandidaten[0].url if entry.kandidaten else "" %}
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);" data-bl="{{ entry.bl }}" data-partei="{{ entry.partei }}">
|
||||
<td style="padding:10px 12px;font-weight:700;color:var(--ecg-teal);">{{ entry.bl }}</td>
|
||||
<td style="padding:10px 12px;">{{ entry.partei }}</td>
|
||||
<td style="padding:10px 12px;opacity:0.6;">
|
||||
{{ entry.dateiname or "— noch nicht registriert —" }}
|
||||
</td>
|
||||
<td style="padding:10px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<input type="text"
|
||||
class="url-input"
|
||||
value="{{ first_url }}"
|
||||
style="width:100%;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.12);
|
||||
border-radius:4px;padding:4px 8px;color:inherit;font-family:inherit;font-size:12px;"
|
||||
>
|
||||
</td>
|
||||
<td style="padding:10px 12px;">
|
||||
<button class="fetch-btn v2-btn"
|
||||
style="font-size:11px;padding:4px 14px;cursor:pointer;"
|
||||
onclick="fetchProgramm(this)">
|
||||
Laden
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:24px;font-size:11px;font-family:var(--font-mono);opacity:0.4;">
|
||||
Nach einem erfolgreichen Download müssen die Embeddings neu indexiert werden:
|
||||
<code>python -m app.reindex_embeddings</code>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
async function fetchProgramm(btn) {
|
||||
const row = btn.closest("tr");
|
||||
const bl = row.dataset.bl;
|
||||
const partei = row.dataset.partei;
|
||||
const url = row.querySelector(".url-input").value.trim();
|
||||
const flash = document.getElementById("flash");
|
||||
|
||||
if (!url) {
|
||||
showFlash("Keine URL eingetragen.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "…";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/admin/wahlprogramm-fetch", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({bl, partei, url}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (resp.ok) {
|
||||
const note = data.changed ? "gespeichert" : "unverändert";
|
||||
showFlash(
|
||||
`${bl}/${partei}: ${note} — SHA ${data.sha256.slice(0,12)}…`,
|
||||
"ok",
|
||||
);
|
||||
if (data.changed) row.style.opacity = "0.4";
|
||||
} else {
|
||||
showFlash(`Fehler: ${data.detail || resp.status}`, "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showFlash(`Netzwerkfehler: ${err}`, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Laden";
|
||||
}
|
||||
}
|
||||
|
||||
function showFlash(msg, type) {
|
||||
const el = document.getElementById("flash");
|
||||
el.textContent = msg;
|
||||
el.style.display = "block";
|
||||
el.style.borderColor = type === "ok"
|
||||
? "var(--ecg-green, #2d9e5f)"
|
||||
: "var(--redline-contra, #e05252)";
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => { el.style.display = "none"; }, 8000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
790
app/templates/v2/screens/antrag_detail.html
Normal file
@ -0,0 +1,790 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% from "v2/components/score_hero.html" import score_hero %}
|
||||
{% from "v2/components/matrix_mini.html" import matrix_mini %}
|
||||
{% from "v2/components/quote_card.html" import quote_card %}
|
||||
{% from "v2/components/kasten.html" import kasten %}
|
||||
{% from "v2/components/redline.html" import redline %}
|
||||
|
||||
{% block title %}
|
||||
{% if antrag is defined and antrag %}{{ antrag.title }} — {% endif %}GWÖ-Antragsprüfer
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
{% if antrag is defined and antrag %}
|
||||
{# ── Open-Graph / Twitter-Card-Meta (#141) ────────────────────────── #}
|
||||
{% set _og_img = "/api/og/" ~ (antrag.drucksache | urlencode) ~ ".png" %}
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
|
||||
<meta property="og:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
|
||||
<meta property="og:image" content="{{ _og_img }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{% if antrag.updated_at_raw %}
|
||||
<meta property="og:updated_time" content="{{ antrag.updated_at_raw }}">
|
||||
{% endif %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
|
||||
<meta name="twitter:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
|
||||
<meta name="twitter:image" content="{{ _og_img }}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "durchsuchen" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
{# ── Fehlerfall ──────────────────────────────────────────────────── #}
|
||||
{% if error is defined and error %}
|
||||
<div class="v2-kasten" style="border-color:var(--redline-contra);margin-top:32px;">
|
||||
<h3 style="color:var(--redline-contra);">Antrag nicht gefunden</h3>
|
||||
<p>{{ error }}</p>
|
||||
<p><a href="/">← Zurück zur Übersicht</a></p>
|
||||
</div>
|
||||
|
||||
{% elif antrag is not defined or not antrag %}
|
||||
{# ── Demo-Daten wenn kein echtes Antrag-Objekt übergeben ────────── #}
|
||||
{% set antrag = {
|
||||
"drucksache": "18/4412",
|
||||
"bundesland": "BW",
|
||||
"parlament": "Landtag",
|
||||
"typ": "Antrag",
|
||||
"datum": "12.04.2026",
|
||||
"analysiert": "14.04.2026",
|
||||
"modell": "qwen-plus",
|
||||
"parteien": ["GRÜNE", "SPD"],
|
||||
"zitate_count": 3,
|
||||
"title": "Kommunale Wärmeplanung bis 2028 verpflichtend machen",
|
||||
"score": 9.1,
|
||||
"verdict_title": "Vorbildlich",
|
||||
"verdict_body": "Starker Beitrag zur ökologischen Nachhaltigkeit und Transparenz auf kommunaler Ebene.",
|
||||
"zusammenfassung": "Der Antrag verpflichtet Kommunen ab 10 000 Einwohner:innen zur Erstellung einer kommunalen Wärmeplanung bis Ende 2028.",
|
||||
"staerkster_wert": {
|
||||
"titel": "Ökologische Nachhaltigkeit",
|
||||
"text": "Verpflichtende Wärmeplanung führt zu messbaren Klimazielen. E3 = ++, D3 = ++."
|
||||
},
|
||||
"schwaechster_wert": {
|
||||
"titel": "Soziale Gerechtigkeit",
|
||||
"text": "Kostenverteilung auf Mieter:innen versus Eigentümer:innen ist im Antrag nicht geregelt."
|
||||
},
|
||||
"redline": {
|
||||
"segments": [
|
||||
{"type": "ctx", "text": "§ 3 Abs. 2 "},
|
||||
{"type": "del", "text": "auf Antrag"},
|
||||
{"type": "ins", "text": "verpflichtend"},
|
||||
{"type": "ctx", "text": " eine sozialverträgliche Umlage"}
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"A1": {"rating": 0, "symbol": "○"}, "A2": {"rating": 1, "symbol": "+"},
|
||||
"A3": {"rating": 2, "symbol": "++"}, "A4": {"rating": 0, "symbol": "○"},
|
||||
"A5": {"rating": 1, "symbol": "+"},
|
||||
"B1": {"rating": 0, "symbol": "○"}, "B2": {"rating": 1, "symbol": "+"},
|
||||
"B3": {"rating": 2, "symbol": "++"}, "B4": {"rating": -1, "symbol": "−"},
|
||||
"B5": {"rating": 1, "symbol": "+"},
|
||||
"C1": {"rating": 0, "symbol": "○"}, "C2": {"rating": 1, "symbol": "+"},
|
||||
"C3": {"rating": 1, "symbol": "+"}, "C4": {"rating": 0, "symbol": "○"},
|
||||
"C5": {"rating": 2, "symbol": "++"},
|
||||
"D1": {"rating": 1, "symbol": "+"}, "D2": {"rating": 1, "symbol": "+"},
|
||||
"D3": {"rating": 2, "symbol": "++"}, "D4": {"rating": 1, "symbol": "+"},
|
||||
"D5": {"rating": 2, "symbol": "++"},
|
||||
"E1": {"rating": 1, "symbol": "+"}, "E2": {"rating": 2, "symbol": "++"},
|
||||
"E3": {"rating": 2, "symbol": "++"}, "E4": {"rating": 1, "symbol": "+"},
|
||||
"E5": {"rating": 1, "symbol": "+"}
|
||||
},
|
||||
"zitate": [
|
||||
{
|
||||
"text": "Wir verpflichten alle Kommunen zu einer verbindlichen kommunalen Wärmeplanung bis 2028.",
|
||||
"source": "Wahlprogramm GRÜNE 2022 · S. 84",
|
||||
"partei": "GRÜNE",
|
||||
"verified": True,
|
||||
"contra": False,
|
||||
"pdf_href": "/api/wahlprogramm-cite?pid=gruene-nrw-2022&seite=84&q=Wärmeplanung"
|
||||
}
|
||||
],
|
||||
"verbesserungen": [],
|
||||
"staerken": [],
|
||||
"schwaechen": []
|
||||
} %}
|
||||
|
||||
{# Fallthrough: Demo-Daten rendern wie echte Daten #}
|
||||
{% set _render = True %}
|
||||
{% else %}
|
||||
{% set _render = True %}
|
||||
{% endif %}
|
||||
|
||||
{# ── Eigentlicher Detail-Inhalt ──────────────────────────────────── #}
|
||||
{% if _render is defined and _render and antrag is defined and antrag %}
|
||||
|
||||
{# ── Zurück-Link ─────────────────────────────────────────────────── #}
|
||||
<p style="font-family:var(--font-mono);font-size:11px;margin-bottom:20px;">
|
||||
<a href="/">← Zurück zur Übersicht</a>
|
||||
</p>
|
||||
|
||||
{# ── Split-Layout ────────────────────────────────────────────────── #}
|
||||
<div class="v2-detail">
|
||||
|
||||
{# ── Linke Spalte: Redaktionelle Analyse ── #}
|
||||
<div class="left">
|
||||
<div class="v2-antrag-id">
|
||||
{{ antrag.bundesland | default("") }}
|
||||
{% if antrag.drucksache %} · Drs. {{ antrag.drucksache }}{% endif %}
|
||||
{% if antrag.typ %} · {{ antrag.typ }}{% endif %}
|
||||
{% if antrag.datum %} · eingebracht {{ antrag.datum }}{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="v2-big-title">{{ antrag.title | default("Antrag") }}</h1>
|
||||
|
||||
{% if antrag.parteien or antrag.analysiert %}
|
||||
<div class="v2-byline">
|
||||
{% if antrag.parteien %}Eingebracht von {{ antrag.parteien | join(", ") }}{% endif %}
|
||||
{% if antrag.analysiert %} — Analyse {{ antrag.analysiert }}{% endif %}
|
||||
{% if antrag.modell %}, {{ antrag.modell }}{% endif %}
|
||||
{% if antrag.zitate_count %} · {{ antrag.zitate_count }} Zitat{{ "e" if antrag.zitate_count != 1 else "" }} verifiziert{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if antrag.zusammenfassung %}
|
||||
<h3 class="v2-h3">Zusammenfassung</h3>
|
||||
<p style="font-size:14.5px;line-height:1.55">{{ antrag.zusammenfassung }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# Stärkster Wert #}
|
||||
{% if antrag.staerkster_wert and antrag.staerkster_wert.text %}
|
||||
<div class="v2-kasten outline-green" style="margin-top:20px;">
|
||||
<h4>Stärkster Wert{% if antrag.staerkster_wert.titel %} — {{ antrag.staerkster_wert.titel }}{% endif %}</h4>
|
||||
<p>{{ antrag.staerkster_wert.text }}</p>
|
||||
</div>
|
||||
{% elif antrag.staerken %}
|
||||
<div class="v2-kasten outline-green" style="margin-top:20px;">
|
||||
<h4>Stärken</h4>
|
||||
<ul style="margin:0;padding-left:1.2em;">
|
||||
{% for s in antrag.staerken %}
|
||||
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Schwächster Wert #}
|
||||
{% if antrag.schwaechster_wert and antrag.schwaechster_wert.text %}
|
||||
<div class="v2-kasten outline-blue">
|
||||
<h4>Schwächster Wert{% if antrag.schwaechster_wert.titel %} — {{ antrag.schwaechster_wert.titel }}{% endif %}</h4>
|
||||
<p>{{ antrag.schwaechster_wert.text }}</p>
|
||||
</div>
|
||||
{% elif antrag.schwaechen %}
|
||||
<div class="v2-kasten outline-blue">
|
||||
<h4>Schwächen</h4>
|
||||
<ul style="margin:0;padding-left:1.2em;">
|
||||
{% for s in antrag.schwaechen %}
|
||||
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Redline-Vorschläge: alle verbesserungen rendern wenn vorhanden #}
|
||||
{% if antrag.verbesserungen %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Redline-Vorschläge</h3>
|
||||
{% for v in antrag.verbesserungen %}
|
||||
<div style="margin-bottom:16px;">
|
||||
{% if antrag.verbesserungen | length > 1 %}
|
||||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.65;margin-bottom:4px;">
|
||||
Vorschlag {{ loop.index }} von {{ antrag.verbesserungen | length }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% from "v2/components/redline.html" import redline %}
|
||||
{% if v.segments %}
|
||||
{{ redline(original=v.original | default(""), segments=v.segments) }}
|
||||
{% else %}
|
||||
{{ redline(original=v.original | default(""), vorschlag=v.vorschlag | default("")) }}
|
||||
{% endif %}
|
||||
{% if v.begruendung %}
|
||||
<p style="font-size:12px;color:var(--ecg-dark);opacity:0.75;margin:4px 0 0;font-family:var(--font-mono);">
|
||||
{{ v.begruendung }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif antrag.redline and antrag.redline.segments %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Redline-Vorschlag</h3>
|
||||
{% from "v2/components/redline.html" import redline %}
|
||||
{{ redline(segments=antrag.redline.segments) }}
|
||||
{% endif %}
|
||||
|
||||
</div>{# .left #}
|
||||
|
||||
{# ── Rechte Spalte: Bewertungs-Panel ── #}
|
||||
<div class="right">
|
||||
<div class="v2-antrag-id">Bewertung</div>
|
||||
|
||||
{{ score_hero(antrag.score | default(0), antrag.verdict_title | default(""), antrag.verdict_body | default("")) }}
|
||||
|
||||
{# ── Merkliste-Stern (#140) ── #}
|
||||
<div style="margin-top:12px;margin-bottom:4px;">
|
||||
<button id="v2-merkliste-btn"
|
||||
onclick="v2DetailMerklisteToggle()"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;
|
||||
border:1px solid var(--hairline);border-radius:4px;background:none;
|
||||
cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
<span id="v2-merkliste-star">☆</span>
|
||||
<span id="v2-merkliste-label">Merken</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ── Namentliche Abstimmung (#106 Phase 1) ── #}
|
||||
{% if antrag.abstimmungsverhalten %}
|
||||
{% set aw = antrag.abstimmungsverhalten %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Namentliche Abstimmung</h3>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:10px;">
|
||||
{% if aw.datum %}{{ aw.datum }} · {% endif %}
|
||||
{% if aw.accepted %}Angenommen{% else %}Abgelehnt{% endif %}
|
||||
</div>
|
||||
{% for f in aw.fraktionen %}
|
||||
{% set total = (f.yes + f.no + f.abstain + f.no_show) | int %}
|
||||
{% if total > 0 %}
|
||||
<div style="margin-bottom:8px;">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;margin-bottom:3px;display:flex;justify-content:space-between;">
|
||||
<span>{{ f.partei }}</span>
|
||||
<span style="opacity:0.65;">{{ f.yes }}✓ {{ f.abstain }}○ {{ f.no }}✗</span>
|
||||
</div>
|
||||
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--ecg-light,#f0f0f0);">
|
||||
{% if f.yes > 0 %}
|
||||
<div style="flex:{{ f.yes }};background:#2da44e;" title="{{ f.yes }} Ja"></div>
|
||||
{% endif %}
|
||||
{% if f.abstain > 0 %}
|
||||
<div style="flex:{{ f.abstain }};background:#adb5bd;" title="{{ f.abstain }} Enthaltung"></div>
|
||||
{% endif %}
|
||||
{% if f.no > 0 %}
|
||||
<div style="flex:{{ f.no }};background:#cf222e;" title="{{ f.no }} Nein"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}{# abstimmungsverhalten #}
|
||||
|
||||
{% if antrag.matrix %}
|
||||
<h3 class="v2-h3">Matrix 2.0 · 25 Felder</h3>
|
||||
{{ matrix_mini(antrag.matrix) }}
|
||||
{% endif %}
|
||||
|
||||
{# Fraktions-Score-Tabelle (Fix 2+3): auch Fraktionen ohne Zitate sichtbar #}
|
||||
{% if antrag.fraktions_scores %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
|
||||
<div class="v2-fraktions-scores">
|
||||
{% for fs in antrag.fraktions_scores %}
|
||||
<div class="v2-fraktion-row">
|
||||
<div class="v2-fraktion-label">
|
||||
{{ fs.fraktion }}
|
||||
{% if fs.ist_antragsteller %}<span class="v2-badge-antragsteller" title="Antragstellende Fraktion">A</span>{% endif %}
|
||||
{% if fs.ist_regierung %}<span class="v2-badge-regierung" title="Regierungsfraktion">R</span>{% endif %}
|
||||
</div>
|
||||
<div class="v2-fraktion-scores">
|
||||
{% set wp_score = fs.wahlprogramm.score | float %}
|
||||
{% set pp_score = fs.parteiprogramm.score | float %}
|
||||
<span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}"
|
||||
title="Wahlprogramm-Treue: {{ fs.wahlprogramm.begruendung }}">
|
||||
WP {{ "%.0f"|format(wp_score) }}/10
|
||||
</span>
|
||||
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}"
|
||||
title="Parteiprogramm-Treue: {{ fs.parteiprogramm.begruendung }}">
|
||||
PP {{ "%.0f"|format(pp_score) }}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Zitate nach Partei gruppiert; Fraktion ohne Zitate erhält Hinweis via fraktions_scores-Block oben #}
|
||||
{% if antrag.zitate %}
|
||||
{% set current_partei = namespace(value="") %}
|
||||
{% for z in antrag.zitate %}
|
||||
{% if z.partei != current_partei.value %}
|
||||
{% set current_partei.value = z.partei %}
|
||||
<h3 class="v2-h3" style="margin-top:24px;">Belege — {{ z.partei }}</h3>
|
||||
{% endif %}
|
||||
{{ quote_card(z.text, z.source, z.verified | default(True), z.contra | default(False), z.pdf_href | default("")) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Aktions-Links #}
|
||||
<div style="margin-top:24px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.85;display:flex;gap:16px;flex-wrap:wrap;">
|
||||
<a href="/api/assessment/pdf?drucksache={{ antrag.drucksache | urlencode }}"
|
||||
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
|
||||
PDF-Bericht
|
||||
</a>
|
||||
<a href="/api/assessment?drucksache={{ antrag.drucksache | urlencode }}"
|
||||
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
|
||||
JSON-Export
|
||||
</a>
|
||||
<a href="/antrag/{{ antrag.drucksache }}"
|
||||
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
|
||||
Permalink
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Voting-Block ─────────────────────────────────────────────── #}
|
||||
<div style="margin-top:24px;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertung treffend?</div>
|
||||
<div id="v2-vote-overall" style="display:flex;gap:8px;align-items:center;">
|
||||
<button id="v2-vote-up"
|
||||
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','up')"
|
||||
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
|
||||
👍 <span id="v2-vote-up-count">0</span>
|
||||
</button>
|
||||
<button id="v2-vote-down"
|
||||
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','down')"
|
||||
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
|
||||
👎 <span id="v2-vote-down-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Share-Block ──────────────────────────────────────────────── #}
|
||||
<div style="margin-top:20px;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button onclick="v2DetailShare('threads')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Threads
|
||||
</button>
|
||||
<button onclick="v2DetailShare('twitter')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
X
|
||||
</button>
|
||||
<button onclick="v2DetailShareMastodon()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Mastodon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Re-Analyze-Block ─────────────────────────────────────────── #}
|
||||
<div style="margin-top:20px;">
|
||||
<button id="v2-reanalyze-btn"
|
||||
onclick="v2DetailReAnalyze(this)"
|
||||
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.8;">
|
||||
Neu analysieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ── Bewertungs-Historie ───────────────────────────────────────── #}
|
||||
<div style="margin-top:24px;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertungs-Historie</div>
|
||||
<div id="v2-history-list">
|
||||
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Lade…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{# .right #}
|
||||
|
||||
</div>{# .v2-detail #}
|
||||
|
||||
{# ── Kommentare ───────────────────────────────────────────────────────── #}
|
||||
<div style="margin-top:40px;border-top:1px solid var(--hairline);padding-top:28px;">
|
||||
<h3 class="v2-h3" style="margin-bottom:16px;">Kommentare</h3>
|
||||
|
||||
<div id="v2-comments-list" style="margin-bottom:20px;">
|
||||
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.5;">Lade…</span>
|
||||
</div>
|
||||
|
||||
{# Kommentar-Formular — wird per JS eingeblendet wenn angemeldet #}
|
||||
<div id="v2-comment-form" style="display:none;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Kommentar hinzufügen</div>
|
||||
<textarea id="v2-comment-input"
|
||||
rows="3"
|
||||
placeholder="Kommentar…"
|
||||
style="width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:13px;background:var(--surface);color:var(--ecg-dark);resize:vertical;margin-bottom:8px;outline:none;"></textarea>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<select id="v2-comment-visibility"
|
||||
style="padding:5px 8px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-mono);font-size:11px;background:var(--surface);color:var(--ecg-dark);">
|
||||
<option value="all">Öffentlich</option>
|
||||
<option value="authenticated">Nur Angemeldete</option>
|
||||
<option value="private">Nur ich</option>
|
||||
</select>
|
||||
<button onclick="v2DetailAddComment('{{ antrag.drucksache | e }}')"
|
||||
style="padding:5px 14px;border:none;border-radius:4px;background:var(--ecg-blue);color:#fff;cursor:pointer;font-family:var(--font-mono);font-size:11px;font-weight:700;">
|
||||
Absenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="v2-comment-login-hint" style="display:none;">
|
||||
<button onclick="v2AuthModalOpen()"
|
||||
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-blue);">
|
||||
Anmelden um zu kommentieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Matrix-Feld-Info-Modal ───────────────────────────────────────────── #}
|
||||
<div id="v2-matrix-field-modal"
|
||||
role="dialog" aria-modal="true" aria-label="Matrix-Feld Erklärung"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
||||
z-index:9100;align-items:center;justify-content:center;"
|
||||
onclick="if(event.target===this)this.style.display='none'">
|
||||
<div style="background:var(--ecg-card-bg,#fff);border:1px solid var(--ecg-border,#ddd);
|
||||
border-radius:8px;padding:28px 32px;min-width:280px;max-width:480px;
|
||||
font-family:var(--font-sans);font-size:14px;color:var(--ecg-dark);
|
||||
line-height:1.55;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;">
|
||||
<strong id="v2-matrix-field-title"
|
||||
style="font-family:var(--font-display,inherit);font-size:16px;font-weight:900;
|
||||
color:var(--ecg-teal,#009da5);letter-spacing:0.03em;"></strong>
|
||||
<button onclick="document.getElementById('v2-matrix-field-modal').style.display='none'"
|
||||
style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--ecg-dark);
|
||||
opacity:0.55;padding:0;line-height:1;"
|
||||
aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<p id="v2-matrix-field-text" style="margin:0;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}{# _render #}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
/* Escape auf der Detailseite → history.back() (außer wenn Matrix-Modal offen) */
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !e.target.matches('input, textarea, select')) {
|
||||
var modal = document.getElementById('v2-matrix-field-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
modal.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Matrix-Erklärungen als JSON in den Browser übertragen #}
|
||||
{% if matrix_explanations is defined %}
|
||||
<script>
|
||||
window._v2MatrixExplanations = {{ matrix_explanations | tojson }};
|
||||
window.v2ShowMatrixFieldInfo = function(field) {
|
||||
var explains = window._v2MatrixExplanations || {};
|
||||
var text = explains[field] || '';
|
||||
var titleEl = document.getElementById('v2-matrix-field-title');
|
||||
var textEl = document.getElementById('v2-matrix-field-text');
|
||||
var modal = document.getElementById('v2-matrix-field-modal');
|
||||
if (!modal) return;
|
||||
if (titleEl) titleEl.textContent = 'Feld ' + field;
|
||||
if (textEl) textEl.textContent = text || '(Keine Erklärung vorhanden)';
|
||||
modal.style.display = 'flex';
|
||||
};
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if antrag is defined and antrag and antrag.drucksache %}
|
||||
<script>
|
||||
(function () {
|
||||
var DRS = {{ antrag.drucksache | tojson }};
|
||||
var BL = {{ antrag.bundesland | tojson }};
|
||||
var SHARE_THR = {{ (antrag.share_threads or '') | tojson }};
|
||||
var SHARE_TWI = {{ (antrag.share_twitter or '') | tojson }};
|
||||
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
|
||||
var TITLE = {{ antrag.title | tojson }};
|
||||
var SCORE = {{ antrag.score | tojson }};
|
||||
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
|
||||
|
||||
var currentUser = null;
|
||||
|
||||
/* ── Auth-State laden ─────────────────────────────────────────── */
|
||||
async function initAuth() {
|
||||
try {
|
||||
var resp = await fetch('/api/auth/me');
|
||||
var data = await resp.json();
|
||||
currentUser = (data && data.authenticated) ? data : null;
|
||||
} catch (_) { currentUser = null; }
|
||||
|
||||
var form = document.getElementById('v2-comment-form');
|
||||
var loginHint = document.getElementById('v2-comment-login-hint');
|
||||
if (currentUser) {
|
||||
if (form) form.style.display = 'block';
|
||||
if (loginHint) loginHint.style.display = 'none';
|
||||
} else {
|
||||
if (form) form.style.display = 'none';
|
||||
if (loginHint) loginHint.style.display = 'block';
|
||||
}
|
||||
loadComments();
|
||||
loadVotes();
|
||||
}
|
||||
|
||||
/* ── Kommentare ───────────────────────────────────────────────── */
|
||||
async function loadComments() {
|
||||
var container = document.getElementById('v2-comments-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
var comments = await fetch('/api/comments?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
|
||||
var visible = comments.filter(function(c) {
|
||||
if (c.visibility === 'all') return true;
|
||||
if (!currentUser) return false;
|
||||
if (c.visibility === 'authenticated') return true;
|
||||
if (c.visibility === 'private') return c.user_id === currentUser.sub;
|
||||
if (c.visibility && c.visibility.startsWith('group:')) return true;
|
||||
return false;
|
||||
});
|
||||
if (visible.length === 0) {
|
||||
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Noch keine Kommentare.</span>';
|
||||
return;
|
||||
}
|
||||
function visBadge(v) {
|
||||
var lbl = v === 'private' ? '👤' : v === 'authenticated' ? '🔒' : '🌐';
|
||||
return '<span style="font-family:var(--font-mono);font-size:10px;background:var(--surface);border:1px solid var(--hairline);padding:1px 4px;border-radius:2px;">' + lbl + '</span>';
|
||||
}
|
||||
container.innerHTML = visible.map(function(c) {
|
||||
var dateStr = new Date(c.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
var delBtn = (currentUser && currentUser.sub === c.user_id)
|
||||
? '<button onclick="v2DetailDeleteComment(' + c.id + ')" style="margin-left:auto;background:none;border:none;color:#c33;cursor:pointer;font-family:var(--font-mono);font-size:11px;padding:0;" title="Löschen">✕</button>'
|
||||
: '';
|
||||
return '<div style="padding:10px 0;border-bottom:1px solid var(--hairline);">'
|
||||
+ '<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:5px;display:flex;align-items:center;gap:6px;">'
|
||||
+ '<strong>' + (c.user_name || 'Anonym') + '</strong>'
|
||||
+ visBadge(c.visibility)
|
||||
+ '<span style="opacity:0.6;">' + dateStr + '</span>'
|
||||
+ delBtn
|
||||
+ '</div>'
|
||||
+ '<div style="font-size:13.5px;line-height:1.5;">' + c.text + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch (_) {
|
||||
document.getElementById('v2-comments-list').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
window.v2DetailAddComment = async function(drucksache) {
|
||||
var input = document.getElementById('v2-comment-input');
|
||||
var visSel = document.getElementById('v2-comment-visibility');
|
||||
if (!input || !input.value.trim()) return;
|
||||
var visibility = visSel ? visSel.value : 'all';
|
||||
await fetch('/api/comment', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'drucksache=' + encodeURIComponent(drucksache)
|
||||
+ '&text=' + encodeURIComponent(input.value)
|
||||
+ '&visibility=' + visibility
|
||||
});
|
||||
input.value = '';
|
||||
loadComments();
|
||||
};
|
||||
|
||||
window.v2DetailDeleteComment = async function(commentId) {
|
||||
await fetch('/api/comment/' + commentId, {method: 'DELETE'});
|
||||
loadComments();
|
||||
};
|
||||
|
||||
/* ── Voting ───────────────────────────────────────────────────── */
|
||||
async function loadVotes() {
|
||||
try {
|
||||
var data = await fetch('/api/votes?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
|
||||
var counts = (data.counts && data.counts.overall) ? data.counts.overall : {up:0, down:0};
|
||||
var myVote = data.my_votes && data.my_votes.overall;
|
||||
var upEl = document.getElementById('v2-vote-up-count');
|
||||
var downEl = document.getElementById('v2-vote-down-count');
|
||||
var upBtn = document.getElementById('v2-vote-up');
|
||||
var downBtn = document.getElementById('v2-vote-down');
|
||||
if (upEl) upEl.textContent = counts.up || 0;
|
||||
if (downEl) downEl.textContent = counts.down || 0;
|
||||
if (upBtn) {
|
||||
upBtn.style.background = myVote === 'up' ? 'rgba(137,158,51,0.12)' : '';
|
||||
upBtn.style.borderColor = myVote === 'up' ? 'var(--ecg-green)' : '';
|
||||
}
|
||||
if (downBtn) {
|
||||
downBtn.style.background = myVote === 'down' ? 'rgba(220,53,69,0.10)' : '';
|
||||
downBtn.style.borderColor = myVote === 'down' ? '#dc3545' : '';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.v2DetailCastVote = async function(drucksache, vote) {
|
||||
if (!currentUser) { v2AuthModalOpen(); return; }
|
||||
try {
|
||||
var resp = await fetch('/api/vote', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'drucksache=' + encodeURIComponent(drucksache) + '&target=overall&vote=' + vote
|
||||
});
|
||||
if (resp.ok) loadVotes();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
/* ── Share ────────────────────────────────────────────────────── */
|
||||
function buildShareText(platform) {
|
||||
var LIMITS = {twitter: 240, threads: 460, mastodon: 460};
|
||||
var limit = LIMITS[platform] || 460;
|
||||
var text;
|
||||
if (platform === 'twitter' && SHARE_TWI) text = SHARE_TWI;
|
||||
else if (platform === 'threads' && SHARE_THR) text = SHARE_THR;
|
||||
else if (platform === 'mastodon' && SHARE_MAS) text = SHARE_MAS;
|
||||
else {
|
||||
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
|
||||
text = emoji + ' GWÖ-Score ' + SCORE + '/10: „' + TITLE.substring(0, 70) + '" (' + DRS + ')\n\n#Gemeinwohl #GWÖ';
|
||||
}
|
||||
if (text.length > limit) text = text.substring(0, limit - 1) + '…';
|
||||
return text;
|
||||
}
|
||||
|
||||
window.v2DetailShare = function(platform) {
|
||||
var text = buildShareText(platform) + '\n' + PERMALINK;
|
||||
var urls = {
|
||||
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
|
||||
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text)
|
||||
};
|
||||
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
|
||||
};
|
||||
|
||||
window.v2DetailShareMastodon = function() {
|
||||
var text = buildShareText('mastodon') + '\n' + PERMALINK;
|
||||
var instance = localStorage.getItem('mastodon_instance');
|
||||
if (!instance) {
|
||||
instance = prompt('Deine Mastodon-Instanz (z.B. mastodon.social):');
|
||||
if (!instance) return;
|
||||
instance = instance.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
localStorage.setItem('mastodon_instance', instance);
|
||||
}
|
||||
window.open('https://' + instance + '/share?text=' + encodeURIComponent(text), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
/* ── Re-Analyze ───────────────────────────────────────────────── */
|
||||
function pollReAnalyze(jobId, btn) {
|
||||
fetch('/status/' + jobId)
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'completed') {
|
||||
btn.textContent = 'Fertig — lade neu…';
|
||||
setTimeout(function(){ location.reload(); }, 800);
|
||||
} else if (data.status === 'failed') {
|
||||
btn.textContent = 'Analyse fehlgeschlagen';
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
setTimeout(function(){ pollReAnalyze(jobId, btn); }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function() { btn.textContent = 'Polling-Fehler'; btn.disabled = false; });
|
||||
}
|
||||
|
||||
window.v2DetailReAnalyze = async function(btn) {
|
||||
if (!currentUser) { v2AuthModalOpen(); return; }
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Lösche alte Bewertung…';
|
||||
try {
|
||||
var delResp = await fetch('/api/assessment/delete?drucksache=' + encodeURIComponent(DRS), {method: 'DELETE'});
|
||||
if (delResp.status === 401) {
|
||||
btn.textContent = 'Nicht angemeldet';
|
||||
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
|
||||
return;
|
||||
}
|
||||
btn.textContent = 'Analyse läuft…';
|
||||
var resp = await fetch('/api/analyze-drucksache', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'drucksache=' + encodeURIComponent(DRS) + '&bundesland=' + encodeURIComponent(BL)
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
btn.textContent = 'Nicht angemeldet';
|
||||
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
if (data.status === 'queued') {
|
||||
btn.textContent = 'Wird analysiert…';
|
||||
pollReAnalyze(data.job_id, btn);
|
||||
} else {
|
||||
btn.textContent = 'Fehler: ' + (data.detail || 'unbekannt');
|
||||
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Fehler: ' + e.message;
|
||||
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Merkliste (#140) ────────────────────────────────────────── */
|
||||
var _merklisteActive = false;
|
||||
|
||||
async function initMerkliste() {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
var resp = await fetch('/api/me/merkliste');
|
||||
if (!resp.ok) return;
|
||||
var entries = await resp.json();
|
||||
var isInList = entries.some(function(e) { return e.antrag_id === DRS; });
|
||||
_updateMerkliste(isInList);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _updateMerkliste(active) {
|
||||
_merklisteActive = active;
|
||||
var star = document.getElementById('v2-merkliste-star');
|
||||
var label = document.getElementById('v2-merkliste-label');
|
||||
var btn = document.getElementById('v2-merkliste-btn');
|
||||
if (!star) return;
|
||||
star.textContent = active ? '★' : '☆';
|
||||
label.textContent = active ? 'Gemerkt' : 'Merken';
|
||||
if (btn) {
|
||||
btn.style.background = active ? 'rgba(0,157,165,0.10)' : '';
|
||||
btn.style.borderColor = active ? 'var(--ecg-teal)' : '';
|
||||
btn.style.color = active ? 'var(--ecg-teal)' : '';
|
||||
}
|
||||
}
|
||||
|
||||
window.v2DetailMerklisteToggle = async function() {
|
||||
if (!currentUser) { v2AuthModalOpen(); return; }
|
||||
try {
|
||||
if (_merklisteActive) {
|
||||
var r = await fetch('/api/me/merkliste/' + encodeURIComponent(DRS), { method: 'DELETE' });
|
||||
if (r.ok) _updateMerkliste(false);
|
||||
} else {
|
||||
var r = await fetch('/api/me/merkliste', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ antrag_id: DRS })
|
||||
});
|
||||
if (r.ok) _updateMerkliste(true);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
/* ── Bewertungs-Historie ─────────────────────────────────────── */
|
||||
async function loadHistory() {
|
||||
var container = document.getElementById('v2-history-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
var entries = await fetch('/api/assessment/history?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Nur eine Version vorhanden.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = entries.map(function(v, i) {
|
||||
var dateStr = v.created_at
|
||||
? new Date(v.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
|
||||
: '–';
|
||||
var version = v.version !== undefined ? v.version : (entries.length - i);
|
||||
return '<div style="display:flex;justify-content:space-between;align-items:baseline;'
|
||||
+ 'padding:5px 0;border-bottom:1px solid var(--hairline);'
|
||||
+ 'font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">'
|
||||
+ '<span>v' + version + ' <span style="opacity:0.55;">' + dateStr + '</span></span>'
|
||||
+ '<a href="/api/assessment?drucksache=' + encodeURIComponent(DRS) + '&version=' + version + '" target="_blank" rel="noopener"'
|
||||
+ ' style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">JSON</a>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch (_) {
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initAuth();
|
||||
initMerkliste();
|
||||
loadHistory();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
430
app/templates/v2/screens/auswertungen.html
Normal file
@ -0,0 +1,430 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Auswertungen — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "auswertungen" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/chart.umd.min.js"></script>
|
||||
<style>
|
||||
.auswert-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--ecg-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.auswert-tab {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--ecg-dark);
|
||||
opacity: 0.55;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.auswert-tab:hover { opacity: 0.85; }
|
||||
.auswert-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--ecg-teal);
|
||||
color: var(--ecg-teal);
|
||||
}
|
||||
.auswert-panel { display: none; }
|
||||
.auswert-panel.active { display: block; }
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.controls-bar select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
.controls-bar button {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
.controls-bar button.primary {
|
||||
background: var(--ecg-teal);
|
||||
color: #fff;
|
||||
border-color: var(--ecg-teal);
|
||||
}
|
||||
.matrix-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--ecg-card-bg);
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
table.gwoe-matrix {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
min-width: 400px;
|
||||
}
|
||||
table.gwoe-matrix th, table.gwoe-matrix td {
|
||||
border: 1px solid var(--ecg-border);
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.gwoe-matrix th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
table.gwoe-matrix th.row-h { text-align: left; }
|
||||
table.gwoe-matrix .s-high { background: rgba(136,158,51,0.22); font-weight: 700; }
|
||||
table.gwoe-matrix .s-mid { background: rgba(247,148,29,0.15); }
|
||||
table.gwoe-matrix .s-low { background: rgba(200,0,0,0.13); font-weight: 700; }
|
||||
table.gwoe-matrix .empty { color: var(--ecg-dark); opacity: 0.3; }
|
||||
table.gwoe-matrix td.clickable { cursor: pointer; }
|
||||
table.gwoe-matrix td.clickable:hover { background: rgba(0,157,165,0.1); }
|
||||
.meta-line {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Modal */
|
||||
.v2-modal-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 500;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v2-modal-backdrop.show { display: flex; }
|
||||
.v2-modal {
|
||||
background: var(--ecg-card-bg);
|
||||
border-radius: 6px;
|
||||
padding: 20px 24px;
|
||||
max-width: 580px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.v2-modal h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
color: var(--ecg-teal);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.v2-modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--ecg-dark);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.v2-modal-close:hover { opacity: 1; }
|
||||
table.modal-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
table.modal-table th, table.modal-table td {
|
||||
border: 1px solid var(--ecg-border);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Auswertungen</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Bundesland × Partei · Thema × Fraktion · Cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="auswert-tabs" role="tablist">
|
||||
<button class="auswert-tab active" role="tab" onclick="switchTab('bl-partei', this)">BL × Partei</button>
|
||||
<button class="auswert-tab" role="tab" onclick="switchTab('themen', this)">Thema × Fraktion</button>
|
||||
<button class="auswert-tab" role="tab" onclick="switchTab('cluster', this)">Cluster</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel 1: BL × Partei -->
|
||||
<div class="auswert-panel active" id="panel-bl-partei">
|
||||
<div class="controls-bar">
|
||||
<label for="wp-filter">Wahlperiode:</label>
|
||||
<select id="wp-filter">
|
||||
<option value="">— alle WPs —</option>
|
||||
{% for wp in wahlperioden %}
|
||||
<option value="{{ wp }}">{{ wp }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="bl-filter">Bundesland:</label>
|
||||
<select id="bl-filter">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="primary" onclick="loadBlMatrix()">Laden</button>
|
||||
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
|
||||
</div>
|
||||
<div id="bl-matrix-wrap" class="matrix-wrap">
|
||||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>
|
||||
</div>
|
||||
<div id="bl-matrix-meta" class="meta-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Panel 2: Thema × Fraktion -->
|
||||
<div class="auswert-panel" id="panel-themen">
|
||||
<div class="controls-bar">
|
||||
<label for="themen-bl-filter">Bundesland:</label>
|
||||
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="themen-matrix-wrap" class="matrix-wrap">
|
||||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel 3: Cluster-Link -->
|
||||
<div class="auswert-panel" id="panel-cluster">
|
||||
<div class="v2-kasten outline-blue">
|
||||
<h4>Cluster-Ansicht</h4>
|
||||
<p style="font-size:12px;">
|
||||
Die interaktive Cluster-Übersicht finden Sie unter
|
||||
<a href="/v2/cluster" style="color:var(--ecg-teal);">/v2/cluster</a>.
|
||||
Sie zeigt thematisch ähnliche Anträge als redaktionelle Liste und verlinkt
|
||||
zur Force-Graph-Visualisierung.
|
||||
</p>
|
||||
<a href="/v2/cluster"
|
||||
style="display:inline-block;margin-top:8px;font-family:var(--font-mono);font-size:11px;
|
||||
padding:6px 14px;background:var(--ecg-teal);color:#fff;border-radius:3px;text-decoration:none;">
|
||||
Zur Cluster-Ansicht →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zeitreihen-Modal -->
|
||||
<div class="v2-modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
|
||||
<div class="v2-modal" onclick="event.stopPropagation()">
|
||||
<button class="v2-modal-close" onclick="closeModal()">×</button>
|
||||
<h2 id="modal-title">Zeitreihe</h2>
|
||||
<div id="modal-body" style="font-size:12px;">Lade …</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
let _tabLoaded = { 'bl-partei': false, 'themen': false };
|
||||
|
||||
function switchTab(id, btn) {
|
||||
document.querySelectorAll('.auswert-tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.auswert-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('panel-' + id).classList.add('active');
|
||||
|
||||
if (id === 'themen' && !_tabLoaded.themen) {
|
||||
loadThemenMatrix();
|
||||
_tabLoaded.themen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scoreClass(avg) {
|
||||
if (avg == null) return '';
|
||||
if (avg >= 6) return 's-high';
|
||||
if (avg >= 3) return 's-mid';
|
||||
return 's-low';
|
||||
}
|
||||
|
||||
async function loadBlMatrix() {
|
||||
const wrap = document.getElementById('bl-matrix-wrap');
|
||||
const metaEl = document.getElementById('bl-matrix-meta');
|
||||
const wp = document.getElementById('wp-filter').value;
|
||||
const bl = document.getElementById('bl-filter').value;
|
||||
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
|
||||
metaEl.textContent = '';
|
||||
|
||||
let url = '/api/auswertungen/matrix';
|
||||
const params = [];
|
||||
if (wp) params.push('wahlperiode=' + encodeURIComponent(wp));
|
||||
if (bl) params.push('bundesland=' + encodeURIComponent(bl));
|
||||
if (params.length) url += '?' + params.join('&');
|
||||
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
if (!data.bundeslaender || !data.bundeslaender.length) {
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Keine Assessments für diesen Filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Bundesland</th>';
|
||||
for (const partei of data.parteien) html += `<th>${partei}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const bundesland of data.bundeslaender) {
|
||||
html += `<tr><th class="row-h">${bundesland}</th>`;
|
||||
for (const partei of data.parteien) {
|
||||
const cell = (data.cells[bundesland] || {})[partei];
|
||||
if (cell) {
|
||||
html += `<td class="clickable ${scoreClass(cell.avg)}"
|
||||
onclick="showZeitreihe('${bundesland.replace(/'/g,"\\'")}','${partei.replace(/'/g,"\\'")}')">
|
||||
${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small>
|
||||
</td>`;
|
||||
} else {
|
||||
html += '<td class="empty">—</td>';
|
||||
}
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
wrap.innerHTML = html;
|
||||
metaEl.textContent = `${data.total} Assessments | Filter: ${data.filter_wp || 'alle WPs'}`;
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThemenMatrix() {
|
||||
const wrap = document.getElementById('themen-matrix-wrap');
|
||||
const bl = document.getElementById('themen-bl-filter').value;
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
|
||||
|
||||
let url = '/api/auswertungen/themen-matrix';
|
||||
if (bl) url += '?bundesland=' + encodeURIComponent(bl);
|
||||
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
if (!data.themen || !data.themen.length) {
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Noch zu wenige Assessments für Themen-Analyse.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="gwoe-matrix"><thead><tr><th class="row-h">Thema</th>';
|
||||
for (const frak of data.fraktionen) html += `<th>${frak}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const thema of data.themen) {
|
||||
html += `<tr><th class="row-h">${thema}</th>`;
|
||||
for (const frak of data.fraktionen) {
|
||||
const cell = (data.cells[thema] || {})[frak];
|
||||
if (cell) {
|
||||
html += `<td class="${scoreClass(cell.avg)}" title="${thema} × ${frak}: Ø ${cell.avg}/10 (${cell.n} Anträge)">
|
||||
${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small>
|
||||
</td>`;
|
||||
} else {
|
||||
html += '<td class="empty">—</td>';
|
||||
}
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
wrap.innerHTML = html;
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<div style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showZeitreihe(bundesland, partei) {
|
||||
const backdrop = document.getElementById('modal-backdrop');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
title.textContent = bundesland + ' × ' + partei;
|
||||
body.innerHTML = '<p style="font-family:var(--font-mono);font-size:12px;opacity:0.6;">Lade Zeitreihe …</p>';
|
||||
backdrop.classList.add('show');
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/auswertungen/zeitreihe?bundesland=${encodeURIComponent(bundesland)}&partei=${encodeURIComponent(partei)}`);
|
||||
const z = await r.json();
|
||||
|
||||
if (!z.wahlperioden || !z.wahlperioden.length) {
|
||||
body.innerHTML = '<p style="font-family:var(--font-mono);font-size:12px;opacity:0.6;">Keine Daten für diese Kombination.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML =
|
||||
'<canvas id="zeitreihe-chart" style="max-height:260px;margin-bottom:1rem;"></canvas>' +
|
||||
'<table class="modal-table"><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>' +
|
||||
z.wahlperioden.map(row => `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
|
||||
if (window.Chart) {
|
||||
const ctx = document.getElementById('zeitreihe-chart');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: z.wahlperioden.map(r => 'WP ' + r.wp),
|
||||
datasets: [{
|
||||
label: `Ø GWÖ-Score ${partei} (${bundesland})`,
|
||||
data: z.wahlperioden.map(r => r.avg),
|
||||
borderColor: '#009da5',
|
||||
backgroundColor: 'rgba(0,157,165,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 5,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: { min: 0, max: 10, title: { display: true, text: 'GWÖ-Score' } }
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
afterLabel: (ctx) => `n=${z.wahlperioden[ctx.dataIndex].n} Anträge`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
body.innerHTML = `<p style="color:#c00;font-family:var(--font-mono);font-size:12px;">Fehler: ${e}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(ev) {
|
||||
if (!ev || ev.target.id === 'modal-backdrop') {
|
||||
document.getElementById('modal-backdrop').classList.remove('show');
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') document.getElementById('modal-backdrop').classList.remove('show');
|
||||
});
|
||||
|
||||
// Load BL-Matrix on init
|
||||
loadBlMatrix();
|
||||
</script>
|
||||
{% endblock %}
|
||||
151
app/templates/v2/screens/batch.html
Normal file
@ -0,0 +1,151 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Batch-Analyse — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "batch" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.batch-form {
|
||||
max-width: 520px;
|
||||
}
|
||||
.batch-form label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.batch-form select {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.batch-form select:focus { outline: none; border-color: var(--ecg-teal); }
|
||||
.batch-submit {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 24px;
|
||||
background: var(--ecg-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.batch-submit:hover { opacity: 0.85; }
|
||||
.batch-submit:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
#batch-status {
|
||||
margin-top: 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.batch-ok { color: var(--ecg-green); }
|
||||
.batch-err { color: #c00; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Batch-Analyse</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Mehrere ungeprüfte Anträge eines Bundeslandes auf einmal analysieren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="v2-kasten outline-blue" style="max-width:520px;margin-bottom:1.5rem;">
|
||||
<p style="font-size:12px;">
|
||||
Die Batch-Analyse sucht ungeprüfte Anträge des gewählten Bundeslandes und reiht sie
|
||||
in die Analyse-Queue ein. Die Jobs laufen im Hintergrund — der Fortschritt ist in der
|
||||
<a href="/classic?mode=queue" style="color:var(--ecg-teal);">Queue-Ansicht (klassisch)</a> einsehbar.
|
||||
</p>
|
||||
<p style="font-size:11px;opacity:0.65;margin-top:6px;">
|
||||
Hinweis: Batch-Analyse erfordert Admin-Rechte. Bei fehlendem Zugriff schlägt der Aufruf mit 403 fehl.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="batch-form" onsubmit="startBatch(event)">
|
||||
|
||||
<label for="batch-bl">Bundesland</label>
|
||||
<select id="batch-bl" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="batch-limit">Maximale Anzahl Anträge</label>
|
||||
<select id="batch-limit" name="limit">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="batch-submit" id="batch-btn">Batch starten</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div id="batch-status"></div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
async function startBatch(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('batch-btn');
|
||||
const statusEl = document.getElementById('batch-status');
|
||||
|
||||
const bundesland = document.getElementById('batch-bl').value;
|
||||
const limit = parseInt(document.getElementById('batch-limit').value, 10);
|
||||
|
||||
btn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
statusEl.className = '';
|
||||
statusEl.textContent = 'Starte Batch …';
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('bundesland', bundesland);
|
||||
fd.append('limit', limit);
|
||||
|
||||
const resp = await fetch('/api/batch-analyze', { method: 'POST', body: fd });
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Kein Zugriff — Admin-Rechte erforderlich.');
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
statusEl.className = 'batch-ok';
|
||||
const queued = data.queued || data.jobs || 0;
|
||||
statusEl.textContent = `Batch gestartet: ${queued} Anträge in die Queue eingereiht. Fortschritt: `;
|
||||
const link = document.createElement('a');
|
||||
link.href = '/classic?mode=queue';
|
||||
link.style.color = 'var(--ecg-teal)';
|
||||
link.textContent = 'Queue ansehen →';
|
||||
statusEl.appendChild(link);
|
||||
|
||||
} catch (err) {
|
||||
statusEl.className = 'batch-err';
|
||||
statusEl.textContent = 'Fehler: ' + err.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
247
app/templates/v2/screens/cluster.html
Normal file
@ -0,0 +1,247 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Cluster — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "cluster" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.cluster-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
padding: 10px 12px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.cluster-toolbar select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
.cluster-card {
|
||||
background: var(--ecg-card-bg);
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
.cluster-card:hover { border-color: var(--ecg-teal); }
|
||||
.cluster-card h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
color: var(--ecg-teal);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.cluster-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.65;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cluster-score {
|
||||
font-weight: 700;
|
||||
color: var(--ecg-green);
|
||||
}
|
||||
.cluster-fraktionen {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.fraktion-bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
border: 1px solid var(--ecg-border);
|
||||
}
|
||||
/* Cluster detail panel */
|
||||
#cluster-detail {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
#cluster-detail-back {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ecg-teal);
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
#cluster-detail-back:hover { text-decoration: underline; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Cluster</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Thematisch ähnliche Anträge · Cosine-Similarity über Embeddings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="cluster-toolbar">
|
||||
<label for="cl-bl">Bundesland:</label>
|
||||
<select id="cl-bl" onchange="loadClusters()">
|
||||
<option value="">Bundesweit</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="cl-thr" style="margin-left:8px;">Schwellenwert:</label>
|
||||
<input type="range" id="cl-thr" min="0.40" max="0.80" step="0.05" value="0.55"
|
||||
style="width:100px;"
|
||||
oninput="document.getElementById('cl-thr-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
onchange="loadClusters()">
|
||||
<span id="cl-thr-val" style="min-width:30px;">0.55</span>
|
||||
|
||||
<button onclick="loadClusters()"
|
||||
style="font-family:var(--font-mono);font-size:11px;padding:4px 10px;
|
||||
background:var(--ecg-teal);color:#fff;border:none;border-radius:3px;cursor:pointer;
|
||||
margin-left:8px;">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main list view -->
|
||||
<div id="cluster-list">
|
||||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Cluster …</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail view (shown when cluster is clicked) -->
|
||||
<div id="cluster-detail">
|
||||
<span id="cluster-detail-back" onclick="showList()">← Zurück zur Übersicht</span>
|
||||
<div id="cluster-detail-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Link to classic Force-Graph -->
|
||||
<div style="margin-top:1.5rem;font-size:11px;font-family:var(--font-mono);opacity:0.6;">
|
||||
Vollständige Force-Graph-Visualisierung:
|
||||
<a href="/classic?mode=clusters" style="color:var(--ecg-teal);">Klassische Ansicht →</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
let _clusters = [];
|
||||
|
||||
async function loadClusters() {
|
||||
const listEl = document.getElementById('cluster-list');
|
||||
const bl = document.getElementById('cl-bl').value;
|
||||
const thr = document.getElementById('cl-thr').value;
|
||||
|
||||
listEl.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Cluster …</div>';
|
||||
document.getElementById('cluster-detail').style.display = 'none';
|
||||
listEl.style.display = '';
|
||||
|
||||
let url = '/api/clusters?threshold=' + thr;
|
||||
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
_clusters = data.clusters || [];
|
||||
|
||||
if (!_clusters.length) {
|
||||
listEl.innerHTML = '<div class="v2-kasten outline-green"><h4>Keine Cluster gefunden</h4><p>Mit diesem Schwellenwert entstehen keine Gruppen. Versuchen Sie einen niedrigeren Wert.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by size descending
|
||||
_clusters.sort((a, b) => (b.members || []).length - (a.members || []).length);
|
||||
|
||||
// Top-10 list
|
||||
const top = _clusters.slice(0, 10);
|
||||
listEl.innerHTML = top.map((cl, idx) => renderClusterCard(cl, idx)).join('');
|
||||
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div style="color:var(--ecg-dark);font-family:var(--font-mono);font-size:12px;">Fehler: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClusterCard(cl, idx) {
|
||||
const members = cl.members || [];
|
||||
const avgScore = cl.avg_score != null ? parseFloat(cl.avg_score).toFixed(1) : '—';
|
||||
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
|
||||
const fraktionen = cl.fraktionen || {};
|
||||
|
||||
const frakBars = Object.entries(fraktionen)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([f, n]) => `<span class="fraktion-bar">${f} <strong>${n}</strong></span>`)
|
||||
.join('');
|
||||
|
||||
return `<div class="cluster-card" onclick="showCluster(${idx})">
|
||||
<h3>${label}</h3>
|
||||
<div class="cluster-meta">
|
||||
<span>${members.length} Antrag${members.length !== 1 ? 'e' : ''}</span>
|
||||
<span class="cluster-score">Ø ${avgScore}</span>
|
||||
</div>
|
||||
${frakBars ? `<div class="cluster-fraktionen">${frakBars}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function showCluster(idx) {
|
||||
const cl = _clusters[idx];
|
||||
if (!cl) return;
|
||||
|
||||
document.getElementById('cluster-list').style.display = 'none';
|
||||
const detail = document.getElementById('cluster-detail');
|
||||
const content = document.getElementById('cluster-detail-content');
|
||||
detail.style.display = '';
|
||||
|
||||
const members = cl.members || [];
|
||||
const label = cl.label || cl.title || ('Cluster ' + (idx + 1));
|
||||
const avgScore = cl.avg_score != null ? parseFloat(cl.avg_score).toFixed(1) : '—';
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom:1rem;">
|
||||
<h2 style="font-family:var(--font-display);font-size:18px;color:var(--ecg-teal);">${label}</h2>
|
||||
<p style="font-family:var(--font-mono);font-size:12px;opacity:0.65;">
|
||||
${members.length} Anträge · Ø Score ${avgScore}
|
||||
</p>
|
||||
</div>
|
||||
<div role="list">
|
||||
${members.map(m => `
|
||||
<a href="/antrag/${encodeURIComponent(m.drucksache || m)}"
|
||||
class="v2-result-row" style="display:block;text-decoration:none;">
|
||||
<div class="v2-result-meta">
|
||||
<span class="v2-chip" style="font-size:10px;">${m.bundesland || ''}</span>
|
||||
<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${m.drucksache || m}</span>
|
||||
</div>
|
||||
<div class="v2-result-title">${m.titel || m.drucksache || m}</div>
|
||||
${m.gwoe_score != null ? `<div style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-teal);font-weight:700;">Score ${parseFloat(m.gwoe_score).toFixed(1)}</div>` : ''}
|
||||
</a>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function showList() {
|
||||
document.getElementById('cluster-list').style.display = '';
|
||||
document.getElementById('cluster-detail').style.display = 'none';
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadClusters();
|
||||
</script>
|
||||
{% endblock %}
|
||||
323
app/templates/v2/screens/durchsuchen.html
Normal file
@ -0,0 +1,323 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% from "v2/components/result_row.html" import result_row %}
|
||||
{% from "v2/components/chip.html" import chip %}
|
||||
|
||||
{% block title %}Durchsuchen — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "durchsuchen" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #}
|
||||
<div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche">
|
||||
<button class="v2-chip active" data-bl="ALL" onclick="v2SetBl(this,'ALL')">Bundesweit</button>
|
||||
{% for code in bl_codes %}
|
||||
<button class="v2-chip" data-bl="{{ code }}" onclick="v2SetBl(this,'{{ code }}')">{{ code }}</button>
|
||||
{% endfor %}
|
||||
<span class="v2-toolbar-sep"></span>
|
||||
<input class="v2-search"
|
||||
type="search"
|
||||
placeholder="Anträge durchsuchen …"
|
||||
aria-label="Anträge durchsuchen"
|
||||
id="v2-search-input">
|
||||
</div>
|
||||
|
||||
{# ── Score-Filter + Sortierung ───────────────────────────────────── #}
|
||||
<div class="v2-toolbar" role="toolbar" aria-label="Score-Filter und Sortierung" style="border:0;padding:4px 0;margin:0 0 8px;position:static;">
|
||||
<button class="v2-chip active" data-band="ALL" onclick="v2SetBand(this,'ALL')">Alle Scores</button>
|
||||
<button class="v2-chip" data-band="HIGH" onclick="v2SetBand(this,'HIGH')">Score 8–10</button>
|
||||
<button class="v2-chip" data-band="MID" onclick="v2SetBand(this,'MID')">5–7</button>
|
||||
<button class="v2-chip" data-band="LOW" onclick="v2SetBand(this,'LOW')">0–4</button>
|
||||
<span class="v2-toolbar-sep"></span>
|
||||
<select id="v2-sort"
|
||||
aria-label="Sortierung"
|
||||
onchange="v2ApplySort(this.value)"
|
||||
style="padding:4px 8px;border:1px solid var(--hairline);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:11px;background:var(--surface);
|
||||
color:var(--ecg-dark);cursor:pointer;">
|
||||
<option value="score-desc">Score ↓</option>
|
||||
<option value="score-asc">Score ↑</option>
|
||||
<option value="date-desc">Datum ↓</option>
|
||||
<option value="date-asc">Datum ↑</option>
|
||||
<option value="drucksache-desc">Drs.-Nr. ↓</option>
|
||||
<option value="drucksache-asc">Drs.-Nr. ↑</option>
|
||||
<option value="title-asc">Titel A–Z</option>
|
||||
<option value="title-desc">Titel Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# ── Ergebnisliste ───────────────────────────────────────────────── #}
|
||||
<div id="v2-results" role="list">
|
||||
|
||||
{% if assessments %}
|
||||
{% for a in assessments %}
|
||||
{{ result_row(a) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="font-family:var(--font-mono);font-size:13px;color:var(--ecg-dark);opacity:0.6;margin-top:32px;">
|
||||
Noch keine Bewertungen in der Datenbank.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>{# #v2-results #}
|
||||
|
||||
{# ── Empty-State ─────────────────────────────────────────────────── #}
|
||||
<div id="v2-empty-state" class="v2-kasten outline-green" style="display:none;margin-top:32px;">
|
||||
<h4>Keine Ergebnisse</h4>
|
||||
<p>Die aktuelle Filterauswahl liefert keine Treffer.
|
||||
<button onclick="v2ResetFilters()"
|
||||
style="background:none;border:none;color:var(--ecg-green);cursor:pointer;font-weight:900;text-decoration:underline;font-size:inherit;font-family:inherit;">
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
/* ── v2 Listenfilter ─────────────────────────────────────────────── */
|
||||
(function () {
|
||||
var activeBl = 'ALL';
|
||||
var activeBand = 'ALL';
|
||||
|
||||
function getRows() {
|
||||
return document.querySelectorAll('#v2-results .v2-result-row');
|
||||
}
|
||||
|
||||
function getScore(row) {
|
||||
var cell = row.querySelector('.v2-score-cell');
|
||||
return cell ? parseFloat(cell.textContent) || 0 : 0;
|
||||
}
|
||||
|
||||
function getBl(row) {
|
||||
var state = row.querySelector('.v2-r-state');
|
||||
return state ? state.textContent.trim().split('·')[0].trim() : '';
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var q = (document.getElementById('v2-search-input').value || '').toLowerCase().trim();
|
||||
var rows = getRows();
|
||||
var visible = 0;
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var score = getScore(row);
|
||||
var bl = getBl(row);
|
||||
var text = row.textContent.toLowerCase();
|
||||
|
||||
var blOk = (activeBl === 'ALL') || (bl === activeBl);
|
||||
var bandOk = (activeBand === 'ALL') ||
|
||||
(activeBand === 'HIGH' && score >= 8) ||
|
||||
(activeBand === 'MID' && score >= 5 && score < 8) ||
|
||||
(activeBand === 'LOW' && score < 5);
|
||||
var qOk = !q || text.includes(q);
|
||||
|
||||
var show = blOk && bandOk && qOk;
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
var empty = document.getElementById('v2-empty-state');
|
||||
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
window.v2SetBl = function (btn, code) {
|
||||
activeBl = code;
|
||||
document.querySelectorAll('[data-bl]').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.bl === code);
|
||||
});
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
window.v2SetBand = function (btn, band) {
|
||||
activeBand = band;
|
||||
document.querySelectorAll('[data-band]').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.band === band);
|
||||
});
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
window.v2ResetFilters = function () {
|
||||
document.getElementById('v2-search-input').value = '';
|
||||
v2SetBl(null, 'ALL');
|
||||
v2SetBand(null, 'ALL');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.getElementById('v2-search-input');
|
||||
if (input) input.addEventListener('input', applyFilters);
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Sort ────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
var SORT_KEY = 'gwoe.v2-sort';
|
||||
|
||||
function getRowVal(row, field) {
|
||||
if (field === 'score') {
|
||||
var cell = row.querySelector('.v2-score-cell');
|
||||
return cell ? parseFloat(cell.textContent) || 0 : 0;
|
||||
}
|
||||
if (field === 'title') {
|
||||
var titleEl = row.querySelector('.v2-r-title');
|
||||
return titleEl ? titleEl.textContent.trim().toLowerCase() : '';
|
||||
}
|
||||
if (field === 'date') {
|
||||
var dateEl = row.querySelector('.v2-r-date');
|
||||
return dateEl ? dateEl.textContent.trim() : '';
|
||||
}
|
||||
if (field === 'drucksache') {
|
||||
var stateEl = row.querySelector('.v2-r-state');
|
||||
return stateEl ? stateEl.textContent.trim() : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function sortRows(sortVal) {
|
||||
var parts = sortVal.split('-');
|
||||
var field = parts.slice(0, -1).join('-');
|
||||
var dir = parts[parts.length - 1]; // 'asc' or 'desc'
|
||||
var container = document.getElementById('v2-results');
|
||||
if (!container) return;
|
||||
var rows = Array.from(container.querySelectorAll('.v2-result-row'));
|
||||
rows.sort(function(a, b) {
|
||||
var va = getRowVal(a, field);
|
||||
var vb = getRowVal(b, field);
|
||||
var cmp;
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
cmp = va - vb;
|
||||
} else {
|
||||
cmp = String(va).localeCompare(String(vb), 'de');
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function(row) { container.appendChild(row); });
|
||||
}
|
||||
|
||||
window.v2ApplySort = function(val) {
|
||||
try { localStorage.setItem(SORT_KEY, val); } catch (_) {}
|
||||
sortRows(val);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var savedSort;
|
||||
try { savedSort = localStorage.getItem(SORT_KEY); } catch (_) {}
|
||||
var sortSelect = document.getElementById('v2-sort');
|
||||
if (savedSort && sortSelect) {
|
||||
sortSelect.value = savedSort;
|
||||
sortRows(savedSort);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Keyboard Shortcuts (#116 port to v2) ────────────────────────── */
|
||||
(function () {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Nicht in Input-Feldern auslösen
|
||||
if (e.target.matches('input, textarea, select')) return;
|
||||
|
||||
var rows = Array.from(document.querySelectorAll('#v2-results .v2-result-row'));
|
||||
var active = document.querySelector('#v2-results .v2-result-row.v2-kb-active');
|
||||
var idx = active ? rows.indexOf(active) : -1;
|
||||
|
||||
switch (e.key) {
|
||||
case 'j': // nächster Eintrag
|
||||
e.preventDefault();
|
||||
if (!rows.length) break;
|
||||
idx = Math.min(idx + 1, rows.length - 1);
|
||||
if (active) active.classList.remove('v2-kb-active');
|
||||
rows[idx].classList.add('v2-kb-active');
|
||||
rows[idx].scrollIntoView({ block: 'nearest' });
|
||||
break;
|
||||
|
||||
case 'k': // vorheriger Eintrag
|
||||
e.preventDefault();
|
||||
if (!rows.length) break;
|
||||
idx = Math.max(idx - 1, 0);
|
||||
if (active) active.classList.remove('v2-kb-active');
|
||||
rows[idx].classList.add('v2-kb-active');
|
||||
rows[idx].scrollIntoView({ block: 'nearest' });
|
||||
break;
|
||||
|
||||
case 'Enter': // Eintrag öffnen
|
||||
if (active) {
|
||||
e.preventDefault();
|
||||
var href = active.getAttribute('href');
|
||||
if (href) window.location.href = href;
|
||||
}
|
||||
break;
|
||||
|
||||
case '/': // Suche fokussieren
|
||||
e.preventDefault();
|
||||
var searchInput = document.getElementById('v2-search-input');
|
||||
if (searchInput) searchInput.focus();
|
||||
break;
|
||||
|
||||
case '?': // Shortcuts-Hilfe
|
||||
e.preventDefault();
|
||||
var modal = document.getElementById('v2-kb-help-modal');
|
||||
if (modal) {
|
||||
modal.style.display = modal.style.display === 'flex' ? 'none' : 'flex';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape': // Hilfe-Modal schließen (auf Detailseiten: history.back() — dort eigener Handler)
|
||||
var helpModal = document.getElementById('v2-kb-help-modal');
|
||||
if (helpModal && helpModal.style.display === 'flex') {
|
||||
e.preventDefault();
|
||||
helpModal.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ── Keyboard-Hilfe-Modal ───────────────────────────────────────── #}
|
||||
<div id="v2-kb-help-modal"
|
||||
role="dialog" aria-modal="true" aria-label="Tastenkürzel"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
||||
z-index:9000;align-items:center;justify-content:center;"
|
||||
onclick="if(event.target===this)this.style.display='none'">
|
||||
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-border);
|
||||
border-radius:8px;padding:28px 32px;min-width:280px;max-width:400px;
|
||||
font-family:var(--font-mono);font-size:13px;color:var(--ecg-dark);">
|
||||
<div style="font-family:var(--font-display);font-size:15px;font-weight:900;
|
||||
color:var(--ecg-teal);margin-bottom:16px;letter-spacing:0.03em;">
|
||||
Tastenkürzel
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;line-height:1.9;">
|
||||
<tr><td style="width:50px;"><kbd>j</kbd></td><td>Nächster Antrag</td></tr>
|
||||
<tr><td><kbd>k</kbd></td><td>Vorheriger Antrag</td></tr>
|
||||
<tr><td><kbd>Enter</kbd></td><td>Antrag öffnen</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>Detail schließen / zurück</td></tr>
|
||||
<tr><td><kbd>/</kbd></td><td>Suche fokussieren</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>Diese Hilfe</td></tr>
|
||||
</table>
|
||||
<button onclick="document.getElementById('v2-kb-help-modal').style.display='none'"
|
||||
style="margin-top:18px;font-family:var(--font-mono);font-size:11px;
|
||||
background:none;border:1px solid var(--ecg-border);border-radius:4px;
|
||||
padding:5px 14px;cursor:pointer;color:var(--ecg-dark);">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.v2-kb-active {
|
||||
outline: 2px solid var(--ecg-teal);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--ecg-border);
|
||||
border: 1px solid color-mix(in srgb, var(--ecg-border) 60%, #000);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
317
app/templates/v2/screens/landtag_suche.html
Normal file
@ -0,0 +1,317 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% from "v2/components/icon.html" import icon %}
|
||||
|
||||
{% block title %}Landtag-Suche — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "landtag_suche" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.ls-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
max-width: 680px;
|
||||
}
|
||||
.ls-form label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ls-form input[type="search"],
|
||||
.ls-form select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
.ls-form input[type="search"]:focus,
|
||||
.ls-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ecg-teal);
|
||||
}
|
||||
.ls-q { flex: 1 1 280px; }
|
||||
.ls-bl { flex: 0 0 120px; }
|
||||
.ls-submit {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 20px;
|
||||
background: var(--ecg-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
transition: opacity 0.1s;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.ls-submit:hover { opacity: 0.85; }
|
||||
.ls-submit:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
#ls-results { margin-top: 8px; }
|
||||
|
||||
.ls-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--ecg-border);
|
||||
}
|
||||
.ls-row:last-child { border-bottom: none; }
|
||||
.ls-drucksache {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ecg-dark);
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
min-width: 100px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.ls-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ecg-dark);
|
||||
flex: 1;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.ls-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ls-title a:hover { text-decoration: underline; }
|
||||
.ls-actions { flex-shrink: 0; }
|
||||
.ls-btn-analyse {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 5px 12px;
|
||||
background: var(--ecg-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.ls-btn-analyse:hover { opacity: 0.85; }
|
||||
.ls-btn-analyse:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.ls-badge-done {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
background: color-mix(in srgb, var(--ecg-teal) 15%, transparent);
|
||||
color: var(--ecg-teal);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--ecg-teal);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ls-spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes ls-spin { to { transform: rotate(360deg); } }
|
||||
#ls-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ecg-dark);
|
||||
opacity: 0.65;
|
||||
margin-bottom: 12px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.ls-error { color: #c00; opacity: 1 !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">
|
||||
Landtag-Suche
|
||||
</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Drucksachen direkt aus dem Landtags-Portal suchen — nicht nur aus der eigenen Datenbank
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="ls-form" onsubmit="lsSearch(event)">
|
||||
<div class="ls-q">
|
||||
<label for="ls-q-input">Suchbegriff</label>
|
||||
<input type="search"
|
||||
id="ls-q-input"
|
||||
name="q"
|
||||
placeholder="Stichwort oder Drucksachen-Nr. …"
|
||||
autocomplete="off"
|
||||
required
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
|
||||
</div>
|
||||
<div class="ls-bl">
|
||||
<label for="ls-bl-select">Bundesland</label>
|
||||
<select id="ls-bl-select" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="ls-submit" id="ls-btn">
|
||||
{{ icon("magnifying-glass-plus", 14) }} Suchen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="ls-status"></div>
|
||||
<div id="ls-results"></div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
/* Bekannte Assessments aus der DB laden, um „Bereits bewertet"-Status zu zeigen */
|
||||
var lsCheckedIds = new Set();
|
||||
(function () {
|
||||
fetch('/api/assessments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
(data || []).forEach(function (a) {
|
||||
if (a.drucksache) lsCheckedIds.add(a.drucksache);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
})();
|
||||
|
||||
/* Auth-Status ermitteln */
|
||||
var lsIsAuth = false;
|
||||
fetch('/api/auth/me')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { lsIsAuth = !!(d && d.authenticated); })
|
||||
.catch(function () {});
|
||||
|
||||
async function lsSearch(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
var q = (document.getElementById('ls-q-input').value || '').trim();
|
||||
var bl = document.getElementById('ls-bl-select').value;
|
||||
|
||||
if (q.length < 2) {
|
||||
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = document.getElementById('ls-btn');
|
||||
var status = document.getElementById('ls-status');
|
||||
var results = document.getElementById('ls-results');
|
||||
|
||||
btn.disabled = true;
|
||||
status.className = '';
|
||||
status.innerHTML = '<span class="ls-spinner"></span> Suche im Landtag ' + bl + ' …';
|
||||
results.innerHTML = '';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/search-landtag?q=' + encodeURIComponent(q) + '&bundesland=' + encodeURIComponent(bl));
|
||||
var data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
status.className = 'ls-error';
|
||||
status.textContent = data.error;
|
||||
} else if (!data.length) {
|
||||
status.textContent = 'Keine Treffer für „' + q + '" im Landtag ' + bl + '.';
|
||||
} else {
|
||||
status.textContent = data.length + ' Treffer';
|
||||
results.innerHTML = data.map(function (item) {
|
||||
return renderRow(item, bl);
|
||||
}).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
status.className = 'ls-error';
|
||||
status.textContent = 'Fehler: ' + err.message;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function renderRow(item, bl) {
|
||||
var ds = item.drucksache || '';
|
||||
var title = escHtml(item.title || item.titel || ds);
|
||||
var url = item.url || '';
|
||||
var done = lsCheckedIds.has(ds);
|
||||
|
||||
var titleHtml = url
|
||||
? '<a href="' + escHtml(url) + '" target="_blank" rel="noopener">' + title + '</a>'
|
||||
: title;
|
||||
|
||||
var actionHtml;
|
||||
if (done) {
|
||||
actionHtml = '<span class="ls-badge-done">Bewertet → <a href="/antrag/' + encodeURIComponent(ds) + '" style="color:inherit;">Ansehen</a></span>';
|
||||
} else if (lsIsAuth) {
|
||||
actionHtml = '<button class="ls-btn-analyse" onclick="lsAnalyse(this,\'' + escAttr(ds) + '\',\'' + escAttr(bl) + '\')">Analysieren</button>';
|
||||
} else {
|
||||
actionHtml = '<span style="font-family:var(--font-mono);font-size:10px;opacity:0.5;">Anmeldung nötig</span>';
|
||||
}
|
||||
|
||||
return '<div class="ls-row">'
|
||||
+ '<div class="ls-drucksache">' + escHtml(ds) + '</div>'
|
||||
+ '<div class="ls-title">' + titleHtml + '</div>'
|
||||
+ '<div class="ls-actions">' + actionHtml + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
async function lsAnalyse(btn, drucksache, bundesland) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('drucksache', drucksache);
|
||||
fd.append('bundesland', bundesland);
|
||||
|
||||
var resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Anmeldung nötig';
|
||||
btn.title = 'Sitzung abgelaufen — bitte erneut anmelden';
|
||||
if (typeof window.v2AuthModalOpen === 'function') {
|
||||
window.v2AuthModalOpen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var err = await resp.json().catch(function () { return { detail: resp.statusText }; });
|
||||
throw new Error(err.detail || ('HTTP ' + resp.status));
|
||||
}
|
||||
var data = await resp.json();
|
||||
var ds = data.drucksache || drucksache;
|
||||
// Backend gibt {job_id, drucksache} zurück (Queue) — nicht direkt redirecten,
|
||||
// sondern auf /antrag/{ds} gehen, dort wird dann ggf. der Polling-Status sichtbar
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Fehler';
|
||||
btn.title = err.message;
|
||||
console.error('Analyse-Fehler', err);
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/'/g, "\\'");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
264
app/templates/v2/screens/legal.html
Normal file
@ -0,0 +1,264 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}{{ title }} — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.legal-body h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
color: var(--ecg-teal);
|
||||
margin: 2rem 0 0.75rem;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 2px solid var(--ecg-teal);
|
||||
}
|
||||
.legal-body h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
color: var(--ecg-green);
|
||||
margin: 1.25rem 0 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.legal-body h4 { font-size: 13px; margin: 1rem 0 0.3rem; color: var(--ecg-dark); }
|
||||
.legal-body p { margin-bottom: 0.6rem; font-size: 13px; }
|
||||
.legal-body ul { margin: 0.4rem 0 0.8rem 1.4rem; font-size: 13px; }
|
||||
.legal-body li { margin: 0.25rem 0; }
|
||||
.legal-body a { color: var(--ecg-teal); }
|
||||
.legal-body strong { color: var(--ecg-dark); }
|
||||
.legal-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.legal-body th, .legal-body td {
|
||||
border: 1px solid var(--ecg-border);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.legal-body th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
.legal-card {
|
||||
background: var(--ecg-card-bg);
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 2rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="legal-body" style="max-width:760px;">
|
||||
|
||||
{% if section == 'impressum' %}
|
||||
|
||||
<h2>Impressum</h2>
|
||||
<div class="legal-card">
|
||||
<h3>Angaben gemäß § 5 DDG</h3>
|
||||
<p>Tobias Rödel<br>Rüggeweg 25<br>58093 Hagen</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>Kontakt</h3>
|
||||
<p>Telefon: 0170 3039817<br>Telefax: 02331 9814882<br>E-Mail: mail@tobiasroedel.de</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>Umsatzsteuer-ID</h3>
|
||||
<p>Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: DE421290194</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>Berufshaftpflichtversicherung</h3>
|
||||
<p>exali GmbH<br>Franz-Kobinger-Str. 9<br>86157 Augsburg<br>Geltungsraum: Deutschland</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>Redaktionell verantwortlich</h3>
|
||||
<p>Tobias Rödel<br>Rüggeweg 25<br>58093 Hagen</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>EU-Streitschlichtung</h3>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>.
|
||||
</p>
|
||||
<p>Die E-Mail-Adresse findet sich oben im Impressum.</p>
|
||||
</div>
|
||||
<div class="legal-card">
|
||||
<h3>Verbraucherstreitbeilegung</h3>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% elif section == 'datenschutz' %}
|
||||
|
||||
<h2>Datenschutzerklärung</h2>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>1. Datenschutz auf einen Blick</h3>
|
||||
<h4>Allgemeine Hinweise</h4>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||
personenbezogenen Daten passiert, wenn Sie diese Website besuchen.
|
||||
</p>
|
||||
<h4>Datenerfassung auf dieser Website</h4>
|
||||
<p><strong>Wer ist verantwortlich?</strong></p>
|
||||
<p>Die Datenverarbeitung erfolgt durch den Websitebetreiber (Kontaktdaten siehe Impressum).</p>
|
||||
<p><strong>Wie erfassen wir Ihre Daten?</strong></p>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen.
|
||||
Andere Daten werden automatisch beim Besuch durch unsere IT-Systeme erfasst
|
||||
(z. B. Internetbrowser, Betriebssystem, Uhrzeit).
|
||||
</p>
|
||||
<p><strong>Welche Rechte haben Sie?</strong></p>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht auf unentgeltliche Auskunft, Berichtigung oder Löschung
|
||||
Ihrer gespeicherten personenbezogenen Daten sowie ein Beschwerderecht bei der
|
||||
zuständigen Aufsichtsbehörde.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>2. Hosting</h3>
|
||||
<h4>Externes Hosting</h4>
|
||||
<p>
|
||||
Diese Website wird extern gehostet bei:<br>
|
||||
<strong>netcup GmbH</strong><br>
|
||||
Daimlerstraße 25, 76185 Karlsruhe, Deutschland
|
||||
</p>
|
||||
<h4>Authentifizierung (Keycloak SSO)</h4>
|
||||
<p>
|
||||
Für die Benutzeranmeldung setzen wir Keycloak ein. Bei der Registrierung werden
|
||||
Vorname, Nachname, E-Mail-Adresse und Benutzername gespeichert.
|
||||
</p>
|
||||
<h4>Cookies</h4>
|
||||
<p>Diese Website verwendet ausschließlich <strong>funktional notwendige Cookies</strong>.</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr>
|
||||
<tr><td><code>access_token</code></td><td>Authentifizierung (JWT)</td><td>Session (max. 5 Min.)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>3. Allgemeine Hinweise und Pflichtinformationen</h3>
|
||||
<h4>Hinweis zur verantwortlichen Stelle</h4>
|
||||
<p>Tobias Rödel<br>Rüggeweg 25<br>58093 Hagen<br>Telefon: 0170 3039817<br>E-Mail: mail@tobiasroedel.de</p>
|
||||
<h4>Speicherdauer</h4>
|
||||
<p>
|
||||
Soweit keine speziellere Speicherdauer angegeben wurde, verbleiben personenbezogene Daten
|
||||
bei uns, bis der Zweck für die Datenverarbeitung entfällt.
|
||||
</p>
|
||||
<h4>SSL- bzw. TLS-Verschlüsselung</h4>
|
||||
<p>Diese Seite nutzt aus Sicherheitsgründen SSL- bzw. TLS-Verschlüsselung.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>4. Datenerfassung auf dieser Website</h3>
|
||||
<h4>Server-Log-Dateien</h4>
|
||||
<p>Der Provider erhebt und speichert automatisch Server-Log-Dateien, die Ihr Browser übermittelt:</p>
|
||||
<ul>
|
||||
<li>Browsertyp und -version</li>
|
||||
<li>Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<h4>Cookies</h4>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th><th>Typ</th></tr>
|
||||
<tr><td><code>access_token</code></td><td>Authentifizierung (Keycloak-JWT)</td><td>Bis Browser-Schließen</td><td>Session / Notwendig</td></tr>
|
||||
<tr><td><code>theme</code></td><td>Darstellung (hell/dunkel)</td><td>Bis manueller Löschung (localStorage)</td><td>Funktional</td></tr>
|
||||
<tr><td><code>sortierung</code></td><td>Bevorzugte Sortierung</td><td>Bis manueller Löschung (localStorage)</td><td>Funktional</td></tr>
|
||||
<tr><td><code>selectedBundesland</code></td><td>Zuletzt gewähltes Bundesland</td><td>Bis manueller Löschung (localStorage)</td><td>Funktional</td></tr>
|
||||
</table>
|
||||
<p>Es werden <strong>keine Tracking-, Analyse- oder Werbe-Cookies</strong> eingesetzt.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>5. Registrierung und Authentifizierung</h3>
|
||||
<h4>Keycloak Single Sign-On (SSO)</h4>
|
||||
<p>
|
||||
Für Registrierung und Anmeldung nutzen wir einen selbstgehosteten Keycloak-Server (sso.toppyr.de).
|
||||
Es findet <strong>keine Datenübermittlung an Dritte</strong> statt.
|
||||
</p>
|
||||
<p>Bei der Registrierung werden folgende Daten verarbeitet:</p>
|
||||
<ul>
|
||||
<li>Vorname, Nachname</li>
|
||||
<li>E-Mail-Adresse</li>
|
||||
<li>Benutzername</li>
|
||||
<li>Gehashtes Passwort (serverseitig)</li>
|
||||
<li>Zeitpunkt der Registrierung und letzten Anmeldung</li>
|
||||
</ul>
|
||||
<p>Ihr Account kann auf Anfrage jederzeit gelöscht werden.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>6. Nutzung von KI-Diensten (Datenverarbeitung durch Dritte)</h3>
|
||||
<h4>Alibaba Cloud / DashScope API</h4>
|
||||
<p>
|
||||
Für die Analyse von Parlamentsanträgen nutzen wir das Sprachmodell <strong>Qwen Plus</strong>
|
||||
über die DashScope-API der <strong>Alibaba Cloud International</strong>.
|
||||
</p>
|
||||
<p>Dabei werden folgende Daten an den Dienst übermittelt:</p>
|
||||
<ul>
|
||||
<li>Der Volltext des zu analysierenden Parlamentsantrags (öffentlich zugänglich)</li>
|
||||
<li>Relevante Ausschnitte aus öffentlichen Wahlprogrammen</li>
|
||||
</ul>
|
||||
<p>Es werden <strong>keine personenbezogenen Daten</strong> der Nutzer:innen übermittelt.</p>
|
||||
<p style="font-size:11px;">
|
||||
<a href="https://www.alibabacloud.com/help/en/legal/latest/chinese-mainland-privacy-policy" target="_blank" rel="noopener">Alibaba Cloud Privacy Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>7. Gespeicherte Nutzungsdaten</h3>
|
||||
<p>
|
||||
Registrierte Nutzer:innen können Anträge mit Lesezeichen versehen und Kommentare hinterlassen.
|
||||
Diese Daten werden mit dem Benutzerkonto verknüpft und bei Konto-Löschung entfernt.
|
||||
</p>
|
||||
<h4>Merkliste</h4>
|
||||
<p>
|
||||
Angemeldete Nutzer:innen können Anträge zur Merkliste hinzufügen, um sie geräteübergreifend
|
||||
abrufbar zu halten. Die gespeicherten Antrags-IDs werden serverseitig in der Datenbank
|
||||
mit der Benutzerkennung verknüpft. Bei Account-Löschung werden alle Merklisten-Einträge
|
||||
dieses Kontos ebenfalls gelöscht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>8. Keine Analyse-Tools</h3>
|
||||
<p>
|
||||
Diese Website verwendet <strong>keine Analyse- oder Tracking-Dienste</strong>
|
||||
wie Google Analytics, Matomo oder vergleichbare Tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>9. Keine externen Schriften oder CDNs</h3>
|
||||
<p>
|
||||
Diese Website lädt <strong>keine Schriften von externen Servern</strong>.
|
||||
Alle verwendeten Schriftarten sind lokal gehostet bzw. Systemschriftarten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-card">
|
||||
<h3>10. Datenübermittlung an Parlaments-Server</h3>
|
||||
<p>
|
||||
Bei der Suche nach Parlamentsanträgen werden Anfragen an die öffentlichen
|
||||
Dokumentationssysteme der jeweiligen Landesparlamente weitergeleitet.
|
||||
Dabei werden die eingegebenen Suchbegriffe und die IP-Adresse an den
|
||||
jeweiligen Parlaments-Server übermittelt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>{# .legal-body #}
|
||||
{% endblock %}
|
||||
269
app/templates/v2/screens/merkliste.html
Normal file
@ -0,0 +1,269 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Merkliste — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "merkliste" %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Merkliste</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Gemerkte Anträge · nur für angemeldete Nutzer:innen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% from "v2/components/result_row.html" import result_row %}
|
||||
|
||||
<!-- Not-logged-in state (shown if auth-check fails) -->
|
||||
<div id="merkliste-login-hint" class="v2-kasten outline-blue" style="display:none;">
|
||||
<h4>Anmeldung erforderlich</h4>
|
||||
<p>Die Merkliste ist nur für angemeldete Nutzer:innen verfügbar.</p>
|
||||
<a id="merkliste-login-link" href="/api/auth/login-url"
|
||||
style="display:inline-block;margin-top:8px;font-family:var(--font-mono);font-size:11px;
|
||||
padding:6px 14px;background:var(--ecg-teal);color:#fff;border-radius:3px;text-decoration:none;">
|
||||
Anmelden
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="merkliste-loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">
|
||||
Lade Merkliste …
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="merkliste-empty" class="v2-kasten outline-green" style="display:none;">
|
||||
<h4>Merkliste ist leer</h4>
|
||||
<p>Sie haben noch keine Anträge gemerkt. Klicken Sie auf das Stern-Symbol auf einer Antragsseite.</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="merkliste-results" role="list"></div>
|
||||
|
||||
<!-- Migration-Modal: erscheint einmalig wenn localStorage-Einträge vorhanden -->
|
||||
<div id="merkliste-migration-modal"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;align-items:center;justify-content:center;">
|
||||
<div style="background:var(--surface,#fff);border:1px solid var(--hairline);border-radius:8px;
|
||||
padding:28px 32px;max-width:440px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||
<h3 style="font-family:var(--font-display);font-size:16px;color:var(--ecg-teal);margin:0 0 12px;">
|
||||
Lokale Merkliste übernehmen
|
||||
</h3>
|
||||
<p id="merkliste-migration-text" style="font-size:13px;margin:0 0 20px;line-height:1.55;">
|
||||
Es wurden lokale Einträge gefunden. Ins Konto übernehmen?
|
||||
</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button id="merkliste-migration-yes"
|
||||
style="padding:7px 18px;border:none;border-radius:4px;background:var(--ecg-teal);color:#fff;
|
||||
cursor:pointer;font-family:var(--font-mono);font-size:12px;font-weight:700;">
|
||||
Übernehmen
|
||||
</button>
|
||||
<button id="merkliste-migration-no"
|
||||
style="padding:7px 18px;border:1px solid var(--hairline);border-radius:4px;background:none;
|
||||
cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
|
||||
Nein, danke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
(async function () {
|
||||
var loadingEl = document.getElementById('merkliste-loading');
|
||||
var loginEl = document.getElementById('merkliste-login-hint');
|
||||
var emptyEl = document.getElementById('merkliste-empty');
|
||||
var resultsEl = document.getElementById('merkliste-results');
|
||||
|
||||
// Check auth first
|
||||
var currentUser = null;
|
||||
try {
|
||||
var authResp = await fetch('/api/auth/me');
|
||||
var authData = await authResp.json();
|
||||
if (!authData.authenticated) {
|
||||
loadingEl.style.display = 'none';
|
||||
loginEl.style.display = '';
|
||||
var loginLinkEl = document.getElementById('merkliste-login-link');
|
||||
var loginUrlResp = await fetch('/api/auth/login-url?redirect=' + encodeURIComponent('/v2/merkliste'));
|
||||
var loginUrlData = await loginUrlResp.json();
|
||||
if (loginUrlData.url) loginLinkEl.href = loginUrlData.url;
|
||||
return;
|
||||
}
|
||||
currentUser = authData;
|
||||
} catch (e) {
|
||||
loadingEl.style.display = 'none';
|
||||
loginEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Migration: localStorage → server (einmalig nach Login)
|
||||
await checkLocalStorageMigration();
|
||||
|
||||
// Load merkliste from server
|
||||
try {
|
||||
var resp = await fetch('/api/me/merkliste');
|
||||
if (resp.status === 401) {
|
||||
loadingEl.style.display = 'none';
|
||||
loginEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
var entries = await resp.json();
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
if (!entries.length) {
|
||||
emptyEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch assessment details for each entry
|
||||
var assessments = [];
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
try {
|
||||
var r = await fetch('/api/assessment?drucksache=' + encodeURIComponent(entry.antrag_id));
|
||||
if (r.ok) {
|
||||
var a = await r.json();
|
||||
if (a && a.drucksache) {
|
||||
a._notiz = entry.notiz || '';
|
||||
a._merkliste_created = entry.created_at || '';
|
||||
assessments.push(a);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
|
||||
if (!assessments.length) {
|
||||
emptyEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = assessments.map(function(a) { return renderRow(a); }).join('');
|
||||
} catch (e) {
|
||||
loadingEl.style.display = 'none';
|
||||
loadingEl.textContent = 'Fehler beim Laden der Merkliste: ' + e.message;
|
||||
loadingEl.style.display = '';
|
||||
}
|
||||
|
||||
// ── localStorage-Migration ──────────────────────────────────────────────
|
||||
async function checkLocalStorageMigration() {
|
||||
// Bereits migriert?
|
||||
if (localStorage.getItem('merkliste_migrated')) return;
|
||||
|
||||
// Alte localStorage-Schlüssel aus Classic-UI
|
||||
var raw = localStorage.getItem('bookmarks') || localStorage.getItem('bookmark') || null;
|
||||
if (!raw) return;
|
||||
var ids = [];
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
ids = parsed.map(function(x) {
|
||||
return (typeof x === 'string') ? x : (x.drucksache || x.antrag_id || x.id || null);
|
||||
}).filter(Boolean);
|
||||
}
|
||||
} catch (_) { return; }
|
||||
if (!ids.length) return;
|
||||
|
||||
var modal = document.getElementById('merkliste-migration-modal');
|
||||
var textEl = document.getElementById('merkliste-migration-text');
|
||||
var yesBtn = document.getElementById('merkliste-migration-yes');
|
||||
var noBtn = document.getElementById('merkliste-migration-no');
|
||||
|
||||
textEl.textContent = ids.length + ' lokal gespeicherte' + (ids.length === 1 ? 'n Eintrag' : ' Einträge') + ' gefunden. Ins Konto übernehmen?';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
yesBtn.addEventListener('click', async function() {
|
||||
modal.style.display = 'none';
|
||||
var entries = ids.map(function(id) { return { antrag_id: id }; });
|
||||
try {
|
||||
var r = await fetch('/api/me/merkliste/bulk-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entries: entries })
|
||||
});
|
||||
if (r.ok) {
|
||||
var result = await r.json();
|
||||
localStorage.setItem('merkliste_migrated', '1');
|
||||
localStorage.removeItem('bookmarks');
|
||||
localStorage.removeItem('bookmark');
|
||||
showToast(result.imported + ' Einträge übernommen');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
noBtn.addEventListener('click', function() {
|
||||
modal.style.display = 'none';
|
||||
localStorage.setItem('merkliste_migrated', '1');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Eintrag aus Merkliste entfernen ─────────────────────────────────────
|
||||
window.merkliste_remove = async function(antragId) {
|
||||
try {
|
||||
var r = await fetch('/api/me/merkliste/' + encodeURIComponent(antragId), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
var el = document.getElementById('merkliste-row-' + CSS.escape(antragId));
|
||||
if (el) el.remove();
|
||||
if (!resultsEl.querySelectorAll('.merkliste-entry').length) {
|
||||
emptyEl.style.display = '';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
function showToast(msg) {
|
||||
var t = document.createElement('div');
|
||||
t.textContent = msg;
|
||||
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' +
|
||||
'background:var(--ecg-teal);color:#fff;padding:10px 20px;border-radius:4px;' +
|
||||
'font-family:var(--font-mono);font-size:12px;z-index:99999;box-shadow:0 4px 12px rgba(0,0,0,0.2);';
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function() { t.remove(); }, 3000);
|
||||
}
|
||||
|
||||
function renderRow(a) {
|
||||
var score = (typeof a.gwoe_score === 'number') ? a.gwoe_score.toFixed(1) : '—';
|
||||
var bl = a.bundesland || '';
|
||||
var fraktion = (a.fraktionen || []).join(', ');
|
||||
var title = a.title || a.titel || a.drucksache;
|
||||
var dateStr = a._merkliste_created
|
||||
? new Date(a._merkliste_created).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'})
|
||||
: '';
|
||||
var notiz = a._notiz
|
||||
? '<div style="font-size:12px;color:var(--ecg-dark);opacity:0.65;margin-top:4px;font-family:var(--font-mono);">' + escHtml(a._notiz) + '</div>'
|
||||
: '';
|
||||
return '<div id="merkliste-row-' + escAttr(a.drucksache) + '" class="merkliste-entry"'
|
||||
+ ' style="border-bottom:1px solid var(--hairline);padding:12px 0;display:flex;gap:12px;align-items:flex-start;">'
|
||||
+ '<div style="flex:1;">'
|
||||
+ '<a href="/antrag/' + encodeURIComponent(a.drucksache) + '" class="v2-result-row"'
|
||||
+ ' style="display:block;text-decoration:none;">'
|
||||
+ '<div class="v2-result-meta">'
|
||||
+ '<span class="v2-chip" style="font-size:10px;">' + escHtml(bl) + '</span>'
|
||||
+ '<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">' + escHtml(a.drucksache) + '</span>'
|
||||
+ (fraktion ? '<span style="font-size:11px;opacity:0.6;">' + escHtml(fraktion) + '</span>' : '')
|
||||
+ (dateStr ? '<span style="font-size:11px;opacity:0.45;font-family:var(--font-mono);">gemerkt ' + dateStr + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="v2-result-title">' + escHtml(title) + '</div>'
|
||||
+ '<div class="v2-result-score" style="font-family:var(--font-mono);font-size:13px;color:var(--ecg-teal);font-weight:700;">'
|
||||
+ 'Score ' + score
|
||||
+ '</div>'
|
||||
+ '</a>'
|
||||
+ notiz
|
||||
+ '</div>'
|
||||
+ '<button onclick="merkliste_remove(' + JSON.stringify(a.drucksache) + ')"'
|
||||
+ ' title="Aus Merkliste entfernen"'
|
||||
+ ' style="padding:4px 8px;border:1px solid var(--hairline);border-radius:4px;background:none;'
|
||||
+ 'cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);'
|
||||
+ 'opacity:0.65;flex-shrink:0;margin-top:2px;">✕</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s || '').replace(/[^a-zA-Z0-9_-]/g,'_');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
460
app/templates/v2/screens/methodik.html
Normal file
@ -0,0 +1,460 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Methodik — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.meth-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.meth-layout { grid-template-columns: 1fr; }
|
||||
.meth-toc { display: none; }
|
||||
}
|
||||
.meth-toc {
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.8;
|
||||
border-left: 2px solid var(--ecg-green);
|
||||
padding-left: 12px;
|
||||
color: var(--ecg-dark);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.meth-toc a {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.meth-toc a:hover { opacity: 1; color: var(--ecg-green); }
|
||||
.meth-toc .toc-section { font-weight: 700; margin-top: 8px; opacity: 1; }
|
||||
.meth-body h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
color: var(--ecg-teal);
|
||||
margin: 2rem 0 0.75rem;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 2px solid var(--ecg-teal);
|
||||
}
|
||||
.meth-body h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
color: var(--ecg-green);
|
||||
margin: 1.25rem 0 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.meth-body p { margin-bottom: 0.6rem; }
|
||||
.meth-body ul { margin: 0.4rem 0 0.8rem 1.4rem; }
|
||||
.meth-body li { margin: 0.25rem 0; }
|
||||
.meth-body a { color: var(--ecg-teal); }
|
||||
.meth-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.meth-body th, .meth-body td {
|
||||
border: 1px solid var(--ecg-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.meth-body th { background: var(--ecg-bg-subtle); font-weight: 700; font-size: 11px; }
|
||||
.meth-note {
|
||||
background: rgba(136,158,51,0.1);
|
||||
border-left: 3px solid var(--ecg-green);
|
||||
padding: 10px 14px;
|
||||
margin: 1rem 0;
|
||||
font-size: 13px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
/* Interactive matrix grid */
|
||||
.gwoe-matrix-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px repeat(5, 1fr);
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.gwoe-matrix-grid .gc { padding: 5px 4px; text-align: center; background: var(--ecg-bg-subtle); border: 1px solid var(--ecg-border); }
|
||||
.gwoe-matrix-grid .gh { background: var(--ecg-teal); color: #fff; font-weight: 700; }
|
||||
.gwoe-matrix-grid .gr { background: var(--ecg-green); color: #fff; font-weight: 700; text-align: left; padding-left: 6px; }
|
||||
.gwoe-matrix-grid .gc.clickable { cursor: pointer; transition: background 0.1s; }
|
||||
.gwoe-matrix-grid .gc.clickable:hover { background: rgba(0,157,165,0.12); }
|
||||
#field-explain {
|
||||
display: none;
|
||||
background: var(--ecg-bg-subtle);
|
||||
border-left: 3px solid var(--ecg-green);
|
||||
padding: 12px 16px;
|
||||
margin: 0.5rem 0 1rem;
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
#field-explain strong { font-family: var(--font-display); }
|
||||
.pipeline-step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin: 10px 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--ecg-teal);
|
||||
}
|
||||
.step-num {
|
||||
background: var(--ecg-teal);
|
||||
color: #fff;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 12px; flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 2rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Methodik</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
GWÖ-Matrix 2.0 · Gemeinden · Transparenz-Dokumentation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="meth-layout">
|
||||
|
||||
<!-- TOC -->
|
||||
<nav class="meth-toc" aria-label="Inhaltsverzeichnis">
|
||||
<div class="toc-section">Inhalt</div>
|
||||
<a href="#gwoe">Was ist die GWÖ?</a>
|
||||
<a href="#was-macht">Was macht der Prüfer?</a>
|
||||
<a href="#matrix">Die Matrix 2.0</a>
|
||||
<a href="#pipeline">Analyse-Pipeline</a>
|
||||
<a href="#qualitaet">Qualitätssicherung</a>
|
||||
<a href="#einschraenkungen">Einschränkungen</a>
|
||||
<a href="#datenquellen">Datenquellen</a>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="meth-body">
|
||||
|
||||
<section id="gwoe">
|
||||
<h2>Was ist die Gemeinwohl-Ökonomie?</h2>
|
||||
<div class="v2-kasten outline-blue">
|
||||
<p>
|
||||
Die <strong>Gemeinwohl-Ökonomie (GWÖ)</strong> ist ein Wirtschaftsmodell, das den
|
||||
Erfolg wirtschaftlichen Handelns nicht am Gewinn, sondern am <strong>Beitrag zum
|
||||
Gemeinwohl</strong> misst. Entwickelt von Christian Felber (2010), trägt die GWÖ
|
||||
eine internationale Bewegung mit über 11.000 Unterstützer:innen,
|
||||
4.500 Mitgliedern und 1.000 bilanzierten Organisationen.
|
||||
</p>
|
||||
|
||||
<h3>Das Bewertungsmodell: die Gemeinwohl-Bilanz</h3>
|
||||
<p>
|
||||
Das Kernstück ist die <strong>Gemeinwohl-Bilanz</strong>: ein standardisiertes
|
||||
Bewertungsverfahren nach einer Matrix aus fünf Werten
|
||||
(Menschenwürde, Solidarität, ökologische Nachhaltigkeit, soziale Gerechtigkeit,
|
||||
Transparenz & Demokratie) und fünf Berührungsgruppen.
|
||||
Die aktuelle <strong>Unternehmens-Matrix (Version 5.1)</strong> ist in über 35 Ländern erprobt.
|
||||
</p>
|
||||
<p style="font-size:11px;">
|
||||
→ <a href="https://germany.econgood.org/wp-content/uploads/sites/8/2025/02/ECOnGOOD_Arbeitsbuch_5_1.pdf" target="_blank">Arbeitsbuch Unternehmen 5.1 (PDF)</a> ·
|
||||
<a href="https://germany.econgood.org/tools/gemeinwohl-matrix/" target="_blank">Matrix-Übersicht</a>
|
||||
</p>
|
||||
|
||||
<h3>Adaption für die öffentliche Hand</h3>
|
||||
<p>
|
||||
Für <strong>Gemeinden</strong> gibt es seit 2017 das <strong>Arbeitsbuch für Gemeinden Version 2.0</strong>.
|
||||
Es überträgt die Unternehmens-Matrix auf kommunale Handlungsfelder:
|
||||
statt „Kund:innen" stehen <em>Bürger:innen</em> im Fokus, statt „Lieferkette"
|
||||
geht es um <em>öffentliche Beschaffung</em>.
|
||||
Eine aktualisierte Version 2.1.A läuft seit 2023 im Pilotbetrieb.
|
||||
</p>
|
||||
<p style="font-size:11px;">
|
||||
→ <a href="https://germany.econgood.org/wp-content/uploads/sites/8/2022/05/Arbeitsbuch-Gemeinden_2.pdf" target="_blank">Arbeitsbuch Gemeinden 2.0 (PDF)</a> ·
|
||||
<a href="https://germany.econgood.org/wp-content/uploads/sites/8/2024/04/20231103_Arbeitsbuch-2_1_A-final.pdf" target="_blank">Version 2.1.A Pilotfassung (PDF)</a>
|
||||
</p>
|
||||
|
||||
<h3>Anwendung auf Parlamentsanträge</h3>
|
||||
<p>
|
||||
<strong>Dieser Antragsprüfer</strong> nutzt die Gemeinde-Matrix 2.0 als
|
||||
Bewertungsrahmen und wendet sie systematisch auf Parlamentsanträge aller
|
||||
deutschen Landtage und des Bundestags an. Parlamentsanträge gestalten die
|
||||
Rahmenbedingungen, unter denen Gemeinden handeln — ihre Gemeinwohl-Wirkung
|
||||
zu messen macht sie vergleichbar und transparent.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="was-macht">
|
||||
<h2>Was macht der GWÖ-Antragsprüfer?</h2>
|
||||
<div class="v2-kasten outline-green">
|
||||
<p>Jeder Antrag wird automatisch analysiert und erhält:</p>
|
||||
<ul>
|
||||
<li><strong>GWÖ-Score (0–10)</strong> — wie stark fördert oder widerspricht der Antrag den fünf Gemeinwohl-Werten?</li>
|
||||
<li><strong>25-Felder-Matrix</strong> — detaillierte Bewertung für jede Kombination aus Berührungsgruppe und Wert</li>
|
||||
<li><strong>Wahlprogramm-Treue</strong> — wie gut passt der Antrag zu den Wahl- und Grundsatzprogrammen der Fraktionen, belegt mit verifizierten Zitaten?</li>
|
||||
<li><strong>Verbesserungsvorschläge</strong> — konkrete Textänderungen im Redline-Format</li>
|
||||
</ul>
|
||||
<p style="margin-top:0.5rem;">
|
||||
Ziel ist <strong>Transparenz</strong>: Bürger:innen können nachvollziehen, welche
|
||||
Anträge dem Gemeinwohl dienen — und welche dagegen arbeiten.
|
||||
Die Bewertungen sind öffentlich, maschinenlesbar (JSON/CSV/Atom-Feed) und unter CC BY 4.0 lizenziert.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="matrix">
|
||||
<h2>Die GWÖ-Matrix 2.0 für Gemeinden</h2>
|
||||
<div class="v2-kasten outline-blue">
|
||||
<p><strong>5 Berührungsgruppen</strong> (Zeilen) × <strong>5 Werte</strong> (Spalten) = 25 Bewertungsfelder.
|
||||
Jedes Feld wird von <strong>−5</strong> (fundamental widersprechend) bis <strong>+5</strong>
|
||||
(stark fördernd) bewertet. Der GWÖ-Score (0–10) ist ein gewichteter Durchschnitt.</p>
|
||||
</div>
|
||||
|
||||
<h3>Die fünf Werte (Spalten)</h3>
|
||||
<table>
|
||||
<tr><th style="width:30%;">Wert</th><th>Leitfrage</th></tr>
|
||||
<tr><td><strong>1. Menschenwürde</strong></td><td>Werden Grundrechte geschützt? Rechtliche Gleichstellung? Schutz vor Diskriminierung?</td></tr>
|
||||
<tr><td><strong>2. Solidarität</strong></td><td>Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft? Kooperation statt Konkurrenz?</td></tr>
|
||||
<tr><td><strong>3. Ökologische Nachhaltigkeit</strong></td><td>Klimaschutz? Ressourcenschonung? Biodiversität? Kreislaufwirtschaft?</td></tr>
|
||||
<tr><td><strong>4. Soziale Gerechtigkeit</strong></td><td>Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung? Chancengleichheit?</td></tr>
|
||||
<tr><td><strong>5. Transparenz & Demokratie</strong></td><td>Bürgerbeteiligung? Offenlegung? Demokratische Prozesse? Rechenschaftspflicht?</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Die fünf Berührungsgruppen (Zeilen)</h3>
|
||||
<table>
|
||||
<tr><th style="width:30%;">Gruppe</th><th>Wer ist gemeint?</th></tr>
|
||||
<tr><td><strong>A · Lieferant:innen</strong></td><td>Externe Beschaffung, Lieferketten, Dienstleister:innen</td></tr>
|
||||
<tr><td><strong>B · Finanzen</strong></td><td>Umgang mit öffentlichen Mitteln, Haushalt, Steuerzahler:innen</td></tr>
|
||||
<tr><td><strong>C · Verwaltung</strong></td><td>Mandatsträger:innen, Mitarbeitende, Ehrenamtliche</td></tr>
|
||||
<tr><td><strong>D · Bürger:innen</strong></td><td>Wirkung innerhalb der Grenzen, Daseinsvorsorge</td></tr>
|
||||
<tr><td><strong>E · Gesellschaft & Natur</strong></td><td>Wirkung über die Grenzen hinaus, Zukunft</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Alle 25 Felder</h3>
|
||||
<p style="font-size:11px;opacity:0.7;margin-bottom:8px;">Klick auf ein Feld für Details.</p>
|
||||
|
||||
<div id="field-explain">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<strong id="field-explain-title"></strong>
|
||||
<button onclick="document.getElementById('field-explain').style.display='none'"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--ecg-dark);opacity:0.5;font-size:14px;">✕</button>
|
||||
</div>
|
||||
<div id="field-explain-text" style="margin-top:8px;line-height:1.6;"></div>
|
||||
</div>
|
||||
|
||||
<div class="gwoe-matrix-grid">
|
||||
<div class="gc"></div>
|
||||
<div class="gc gh">Menschen­würde</div>
|
||||
<div class="gc gh">Solidarität</div>
|
||||
<div class="gc gh">Ökol. Nachh.</div>
|
||||
<div class="gc gh">Soz. Gerecht.</div>
|
||||
<div class="gc gh">Transparenz</div>
|
||||
|
||||
<div class="gc gr">A · Lieferant:innen</div>
|
||||
<div class="gc clickable" onclick="showField('A1')"><strong>A1</strong><br><small>Grundrechte Lieferkette</small></div>
|
||||
<div class="gc clickable" onclick="showField('A2')"><strong>A2</strong><br><small>Nutzen Gemeinde</small></div>
|
||||
<div class="gc clickable" onclick="showField('A3')"><strong>A3</strong><br><small>Ökol. Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A4')"><strong>A4</strong><br><small>Soziale Verantwortung</small></div>
|
||||
<div class="gc clickable" onclick="showField('A5')"><strong>A5</strong><br><small>Rechenschaft</small></div>
|
||||
|
||||
<div class="gc gr">B · Finanzen</div>
|
||||
<div class="gc clickable" onclick="showField('B1')"><strong>B1</strong><br><small>Eth. Finanzgebaren</small></div>
|
||||
<div class="gc clickable" onclick="showField('B2')"><strong>B2</strong><br><small>Gemeinnutz</small></div>
|
||||
<div class="gc clickable" onclick="showField('B3')"><strong>B3</strong><br><small>Ökol. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B4')"><strong>B4</strong><br><small>Soz. Finanzpolitik</small></div>
|
||||
<div class="gc clickable" onclick="showField('B5')"><strong>B5</strong><br><small>Partizipation</small></div>
|
||||
|
||||
<div class="gc gr">C · Verwaltung</div>
|
||||
<div class="gc clickable" onclick="showField('C1')"><strong>C1</strong><br><small>Gleichstellung</small></div>
|
||||
<div class="gc clickable" onclick="showField('C2')"><strong>C2</strong><br><small>Gemeinsame Ziele</small></div>
|
||||
<div class="gc clickable" onclick="showField('C3')"><strong>C3</strong><br><small>Ökol. Verhalten</small></div>
|
||||
<div class="gc clickable" onclick="showField('C4')"><strong>C4</strong><br><small>Gerechte Arbeit</small></div>
|
||||
<div class="gc clickable" onclick="showField('C5')"><strong>C5</strong><br><small>Transparenz intern</small></div>
|
||||
|
||||
<div class="gc gr">D · Bürger:innen</div>
|
||||
<div class="gc clickable" onclick="showField('D1')"><strong>D1</strong><br><small>Rechtsgleichheit</small></div>
|
||||
<div class="gc clickable" onclick="showField('D2')"><strong>D2</strong><br><small>Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('D3')"><strong>D3</strong><br><small>Ökol. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D4')"><strong>D4</strong><br><small>Soz. Leistung</small></div>
|
||||
<div class="gc clickable" onclick="showField('D5')"><strong>D5</strong><br><small>Demokratie</small></div>
|
||||
|
||||
<div class="gc gr">E · Gesellschaft</div>
|
||||
<div class="gc clickable" onclick="showField('E1')"><strong>E1</strong><br><small>Zukunft</small></div>
|
||||
<div class="gc clickable" onclick="showField('E2')"><strong>E2</strong><br><small>Beitrag Gesamtwohl</small></div>
|
||||
<div class="gc clickable" onclick="showField('E3')"><strong>E3</strong><br><small>Ökol. Auswirkungen</small></div>
|
||||
<div class="gc clickable" onclick="showField('E4')"><strong>E4</strong><br><small>Sozialer Ausgleich</small></div>
|
||||
<div class="gc clickable" onclick="showField('E5')"><strong>E5</strong><br><small>Demokratie global</small></div>
|
||||
</div>
|
||||
|
||||
<details style="font-size:12px;margin-top:8px;">
|
||||
<summary style="cursor:pointer;color:var(--ecg-teal);font-weight:600;padding:4px 0;">Bewertungsskala</summary>
|
||||
<table style="margin-top:6px;font-size:11px;">
|
||||
<tr><th>Symbol</th><th>Rating</th><th>Bedeutung</th></tr>
|
||||
<tr><td>++</td><td>+4 bis +5</td><td>Stark fördernd, vorbildlich</td></tr>
|
||||
<tr><td>+</td><td>+1 bis +3</td><td>Fördernd</td></tr>
|
||||
<tr><td>○</td><td>0</td><td>Neutral / nicht berührt</td></tr>
|
||||
<tr><td>−</td><td>−1 bis −3</td><td>Widersprechend</td></tr>
|
||||
<tr><td>−−</td><td>−4 bis −5</td><td>Stark widersprechend</td></tr>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="pipeline">
|
||||
<h2>Analyse-Pipeline</h2>
|
||||
<div class="v2-kasten outline-blue">
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<strong>Antragstext laden</strong><br>
|
||||
Der PDF-Volltext wird aus dem Landtags-Portal geholt
|
||||
({{ adapter_count }} Parlamente angebunden). Nur echte Anträge und
|
||||
Gesetzentwürfe werden analysiert — Kleine Anfragen werden übersprungen.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<strong>Wahlprogramm-Passagen suchen</strong><br>
|
||||
Per semantischer Suche ({{ embedding_model }}, 1024 Dimensionen) werden für
|
||||
<strong>jede Fraktion</strong> die thematisch relevantesten Passagen aus
|
||||
Wahl- und Grundsatzprogrammen gefunden. Aktuell {{ programme_count }} Programme
|
||||
mit {{ chunk_count }} Textabschnitten indexiert.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<strong>KI-Bewertung</strong><br>
|
||||
Ein Sprachmodell ({{ model_name }}) bewertet den Antrag anhand der
|
||||
GWÖ-Matrix und vergleicht ihn mit den gefundenen Programmpassagen.
|
||||
Der Prompt erzwingt die Verwendung wörtlicher Zitate.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pipeline-step">
|
||||
<div class="step-num">4</div>
|
||||
<div>
|
||||
<strong>Zitat-Verifikation</strong><br>
|
||||
Jedes Zitat wird <strong>server-seitig verifiziert</strong>: der Text muss
|
||||
als Substring im Original-PDF auffindbar sein. Quellenangabe und Seitenzahl
|
||||
werden aus dem echten Treffer rekonstruiert — die Modell-Ausgabe wird für diese
|
||||
Felder verworfen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details style="font-size:12px;margin-top:8px;">
|
||||
<summary style="cursor:pointer;color:var(--ecg-teal);font-weight:600;padding:4px 0;">Technische Details</summary>
|
||||
<table style="margin-top:6px;font-size:11px;">
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Sprachmodell</td><td>{{ model_name }} (DashScope / Alibaba Cloud)</td></tr>
|
||||
<tr><td>Embedding-Modell</td><td>{{ embedding_model }} (1024 Dimensionen)</td></tr>
|
||||
<tr><td>Chunk-Größe</td><td>400 Wörter, 50 Wörter Overlap</td></tr>
|
||||
<tr><td>Retry bei Parse-Fehlern</td><td>3 Versuche mit steigender Temperatur</td></tr>
|
||||
<tr><td>Zitat-Verifikation</td><td>Substring- oder 5-Wort-Anker-Match gegen Original-PDF</td></tr>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="qualitaet">
|
||||
<h2>Qualitätssicherung</h2>
|
||||
<div class="v2-kasten outline-green">
|
||||
<ul>
|
||||
<li><strong>Automatische Zitat-Verifikation</strong> — jedes Zitat wird gegen das
|
||||
Original-PDF geprüft. Nicht-verifizierbare Zitate werden verworfen.</li>
|
||||
<li><strong>Typ-Filterung</strong> — nur abstimmbare Drucksachen (Anträge,
|
||||
Gesetzentwürfe) werden bewertet.</li>
|
||||
<li><strong>Automatische Neu-Analyse</strong> — wenn ein Zitat nicht auffindbar ist,
|
||||
wird der Antrag mit der aktuellen Pipeline neu analysiert.</li>
|
||||
<li><strong>Open Data</strong> — alle Bewertungen sind als JSON und CSV exportierbar
|
||||
(CC BY 4.0).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="einschraenkungen">
|
||||
<h2>Einschränkungen</h2>
|
||||
<div class="v2-kasten outline-blue">
|
||||
<ul>
|
||||
<li><strong>Wertebasierte Einordnung, keine Rechtsprüfung</strong></li>
|
||||
<li><strong>KI-Bias</strong> — Sprachmodelle können systematische Verzerrungen aufweisen. Bewertungen sind Orientierung, nicht objektive Wahrheit.</li>
|
||||
<li><strong>Programmabhängig</strong> — Fraktionen ohne hinterlegtes Wahlprogramm erhalten keinen Programm-Vergleich.</li>
|
||||
<li><strong>Antragstext, nicht Umsetzung</strong> — bewertet wird was im Antrag steht, nicht ob es umgesetzt wird.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="datenquellen">
|
||||
<h2>Datenquellen</h2>
|
||||
<div class="v2-kasten outline-green">
|
||||
<p><strong>{{ adapter_count }} Parlamente</strong> angebunden:</p>
|
||||
<table>
|
||||
<tr><th>Parlament</th><th>System</th></tr>
|
||||
{% for bl in bundeslaender %}
|
||||
<tr><td>{{ bl.name }} ({{ bl.code }})</td><td>{{ bl.doku_system }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p style="margin-top:0.75rem;font-size:11px;">
|
||||
<a href="/quellen">Programme & Quellen</a> ·
|
||||
<a href="/api/auswertungen/export.json" download>Open Data (JSON)</a> ·
|
||||
<a href="/api/feed.xml">Atom-Feed</a> ·
|
||||
<a href="https://repo.toppyr.de/tobias/gwoe-antragspruefer" target="_blank">Quellcode</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>{# .meth-body #}
|
||||
</div>{# .meth-layout #}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
const fieldInfo = {
|
||||
"A1": { label: "Grundrechtsschutz in der Lieferkette", text: "<p><strong>Praxis:</strong> Wenn die öffentliche Hand Büromöbel, IT-Geräte oder Dienstkleidung beschafft: Unter welchen Bedingungen wurden diese hergestellt? Werden Lieferant:innen verpflichtet, Arbeitsschutzstandards und Menschenrechte einzuhalten?</p><p><strong>Theorie:</strong> Die GWÖ versteht Menschenwürde als unteilbar und kettenübergreifend. Das Feld A1 operationalisiert den Zusammenhang zwischen kommunaler Beschaffung und globalem Menschenrechtsschutz.</p>" },
|
||||
"A2": { label: "Nutzen für die Gemeinde", text: "<p><strong>Praxis:</strong> Beauftragt die Kommune den Betrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleiben Steuergelder in der Region?</p><p><strong>Theorie:</strong> Solidarität beginnt in der Nachbarschaft. Die GWÖ misst, ob die Beschaffungspolitik aktiv zur regionalen Wertschöpfung beiträgt.</p>" },
|
||||
"A3": { label: "Ökologische Verantwortung in der Lieferkette", text: "<p><strong>Praxis:</strong> Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen?</p><p><strong>Theorie:</strong> Die ökologische Säule der GWÖ fordert, dass Umweltkosten nicht externalisiert werden.</p>" },
|
||||
"A4": { label: "Soziale Verantwortung in der Lieferkette", text: "<p><strong>Praxis:</strong> Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Werden Mindestlöhne kontrolliert?</p><p><strong>Theorie:</strong> Soziale Gerechtigkeit endet nicht am Werkstor.</p>" },
|
||||
"A5": { label: "Rechenschaft und Mitsprache bei Beschaffung", text: "<p><strong>Praxis:</strong> Können Bürger:innen nachschauen, welche Firma den Auftrag bekommen hat — und warum? Gibt es ein öffentliches Vergaberegister?</p><p><strong>Theorie:</strong> Transparenz ist das Immunsystem der Demokratie. A5 misst, ob Beschaffungsprozesse nachvollziehbar sind.</p>" },
|
||||
"B1": { label: "Ethisches Finanzgebaren", text: "<p><strong>Praxis:</strong> Liegt das Geld bei einer ethischen Bank — oder bei einer, die Waffengeschäfte finanziert? Gibt es ethische Anlagerichtlinien?</p><p><strong>Theorie:</strong> Die GWÖ betrachtet Geld als Mittel zum Zweck, nicht als Selbstzweck.</p>" },
|
||||
"B2": { label: "Gemeinnutz im Finanzgebaren", text: "<p><strong>Praxis:</strong> Fließen Steuergelder in einen Radweg für alle — oder in eine Umgehungsstraße nur fürs Gewerbegebiet?</p><p><strong>Theorie:</strong> Solidarität in der Finanzpolitik heißt: öffentliches Geld dient öffentlichen Zwecken.</p>" },
|
||||
"B3": { label: "Ökologische Verantwortung der Finanzpolitik", text: "<p><strong>Praxis:</strong> Investiert die Kommune in Solaranlagen? Werden Folgekosten des Klimawandels berücksichtigt?</p><p><strong>Theorie:</strong> Ökologische Nachhaltigkeit muss sich im Haushalt widerspiegeln.</p>" },
|
||||
"B4": { label: "Soziale Verantwortung der Finanzpolitik", text: "<p><strong>Praxis:</strong> Bekommen ärmere Stadtteile genauso viel für Spielplätze wie reiche?</p><p><strong>Theorie:</strong> Soziale Gerechtigkeit erfordert bewusste Verteilungsentscheidungen.</p>" },
|
||||
"B5": { label: "Partizipation in der Finanzpolitik", text: "<p><strong>Praxis:</strong> Gibt es einen Bürgerhaushalt? Werden Haushaltsentwürfe verständlich aufbereitet?</p><p><strong>Theorie:</strong> Demokratie braucht finanzielle Transparenz.</p>" },
|
||||
"C1": { label: "Individuelle Rechts- und Gleichstellung", text: "<p><strong>Praxis:</strong> Werden Frauen gleich bezahlt? Gibt es Schutz vor Mobbing? Anonymisierte Bewerbungsverfahren?</p><p><strong>Theorie:</strong> Die Menschenwürde der Mitarbeitenden ist die Grundlage jeder guten Verwaltung.</p>" },
|
||||
"C2": { label: "Gemeinsame Zielvereinbarung für das Gemeinwohl", text: "<p><strong>Praxis:</strong> Hat die Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen?</p><p><strong>Theorie:</strong> Solidarität innerhalb der Verwaltung bedeutet: alle ziehen am selben Strang.</p>" },
|
||||
"C3": { label: "Förderung ökologischen Verhaltens intern", text: "<p><strong>Praxis:</strong> Fahren Mitarbeitende mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine?</p><p><strong>Theorie:</strong> Die Kommune hat eine Vorbildfunktion.</p>" },
|
||||
"C4": { label: "Gerechte Verteilung von Arbeit", text: "<p><strong>Praxis:</strong> Können Eltern in der Verwaltung Teilzeit arbeiten ohne Karrierenachteile?</p><p><strong>Theorie:</strong> Soziale Gerechtigkeit beginnt beim eigenen Personal.</p>" },
|
||||
"C5": { label: "Transparente Kommunikation intern", text: "<p><strong>Praxis:</strong> Können Bürger:innen die Sitzungsprotokolle online lesen?</p><p><strong>Theorie:</strong> Transparenz nach innen und außen ist die Voraussetzung für Vertrauen.</p>" },
|
||||
"D1": { label: "Schutz des Individuums, Rechtsgleichheit", text: "<p><strong>Praxis:</strong> Werden Bürger:innen auf dem Amt fair behandelt — egal welchen Namen sie tragen?</p><p><strong>Theorie:</strong> Menschenwürde bedeutet: jeder Mensch hat den gleichen Wert.</p>" },
|
||||
"D2": { label: "Gesamtwohl in der Gemeinde", text: "<p><strong>Praxis:</strong> Profitiert die ganze Stadt — oder nur ein Stadtteil, eine Altersgruppe?</p><p><strong>Theorie:</strong> Solidarität auf kommunaler Ebene heißt: das Gesamtwohl geht vor Partikularinteressen.</p>" },
|
||||
"D3": { label: "Ökologische Gestaltung der öffentlichen Leistung", text: "<p><strong>Praxis:</strong> Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren?</p><p><strong>Theorie:</strong> Jede kommunale Dienstleistung hat einen ökologischen Fußabdruck.</p>" },
|
||||
"D4": { label: "Soziale Gestaltung der öffentlichen Leistung", text: "<p><strong>Praxis:</strong> Kann sich die alleinerziehende Mutter den Kitaplatz leisten?</p><p><strong>Theorie:</strong> Soziale Gerechtigkeit in der Daseinsvorsorge ist der Kern kommunaler Politik.</p>" },
|
||||
"D5": { label: "Transparente Kommunikation und demokratische Einbindung", text: "<p><strong>Praxis:</strong> Werden Bürger:innen gefragt, bevor die Straße vor ihrem Haus umgebaut wird?</p><p><strong>Theorie:</strong> Demokratie ist mehr als Wahlen alle vier Jahre.</p>" },
|
||||
"E1": { label: "Menschenwürdiges Leben für zukünftige Generationen", text: "<p><strong>Praxis:</strong> Hinterlassen wir unseren Enkeln einen Schuldenberg — oder investieren wir heute so, dass auch 2050 gute Lebensbedingungen herrschen?</p><p><strong>Theorie:</strong> Die Menschenwürde hat eine zeitliche Dimension.</p>" },
|
||||
"E2": { label: "Beitrag zum Gesamtwohl über die Gemeindegrenzen hinaus", text: "<p><strong>Praxis:</strong> Hilft der Antrag nur der eigenen Stadt — oder auch den Nachbargemeinden?</p><p><strong>Theorie:</strong> Solidarität endet nicht an der Gemeindegrenze.</p>" },
|
||||
"E3": { label: "Verantwortung für ökologische Auswirkungen jenseits der Gemeinde", text: "<p><strong>Praxis:</strong> Denkt die Kommune beim Einkauf an den CO₂-Fußabdruck jenseits der eigenen Grenzen?</p><p><strong>Theorie:</strong> Die ökologische Krise ist global, aber die Verursachung ist lokal.</p>" },
|
||||
"E4": { label: "Beitrag zum sozialen Ausgleich", text: "<p><strong>Praxis:</strong> Unterstützt die Stadt strukturschwache Regionen? Gibt es Fairtrade-Beschaffung?</p><p><strong>Theorie:</strong> Soziale Gerechtigkeit im globalen Maßstab ist die anspruchsvollste Dimension der GWÖ.</p>" },
|
||||
"E5": { label: "Transparente und demokratische Mitbestimmung auf übergeordneter Ebene", text: "<p><strong>Praxis:</strong> Setzt sich die Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene?</p><p><strong>Theorie:</strong> Demokratie braucht Fürsprecher auf allen Ebenen.</p>" },
|
||||
};
|
||||
|
||||
function showField(code) {
|
||||
const info = fieldInfo[code];
|
||||
if (!info) return;
|
||||
const el = document.getElementById('field-explain');
|
||||
document.getElementById('field-explain-title').textContent = code + ': ' + info.label;
|
||||
document.getElementById('field-explain-text').innerHTML = info.text;
|
||||
el.style.display = 'block';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
176
app/templates/v2/screens/neu.html
Normal file
@ -0,0 +1,176 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Neuer Antrag — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "neu" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.neu-form {
|
||||
max-width: 560px;
|
||||
}
|
||||
.neu-form label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.neu-form input[type="text"],
|
||||
.neu-form select {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ecg-card-bg);
|
||||
color: var(--ecg-dark);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.neu-form input[type="text"]:focus,
|
||||
.neu-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ecg-teal);
|
||||
}
|
||||
.neu-submit {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 24px;
|
||||
background: var(--ecg-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.neu-submit:hover { opacity: 0.85; }
|
||||
.neu-submit:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
#neu-status {
|
||||
margin-top: 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.neu-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ecg-teal);
|
||||
}
|
||||
.neu-spinner {
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid var(--ecg-teal);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.neu-error { color: #c00; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Neuer Antrag prüfen</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Drucksachen-Nr. eingeben · Analyse startet sofort
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="v2-kasten outline-blue" style="max-width:560px;margin-bottom:1.5rem;">
|
||||
<p style="font-size:12px;">
|
||||
Der Prüfer holt den Antragstext aus dem Landtags-Portal, bewertet ihn nach der GWÖ-Matrix 2.0
|
||||
und zeigt Wahlprogramm-Treue sowie Verbesserungsvorschläge. Die Analyse dauert 30–90 Sekunden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="neu-form" onsubmit="startAnalyse(event)">
|
||||
|
||||
<label for="neu-drucksache">Drucksachen-Nummer</label>
|
||||
<input type="text" id="neu-drucksache" name="drucksache"
|
||||
placeholder="z. B. 18/12345 oder NRW-18/12345"
|
||||
required autocomplete="off">
|
||||
|
||||
<label for="neu-bl">Bundesland</label>
|
||||
<select id="neu-bl" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="neu-model">Modell</label>
|
||||
<select id="neu-model" name="model">
|
||||
<option value="">Standard ({{ default_model }})</option>
|
||||
<option value="qwen-plus">qwen-plus</option>
|
||||
<option value="qwen-max">qwen-max</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="neu-submit" id="neu-btn">Analyse starten</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div id="neu-status">
|
||||
<div id="neu-progress" class="neu-progress" style="display:none;">
|
||||
<div class="neu-spinner"></div>
|
||||
<span id="neu-progress-text">Analyse läuft …</span>
|
||||
</div>
|
||||
<div id="neu-error" class="neu-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
async function startAnalyse(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('neu-btn');
|
||||
const statusEl = document.getElementById('neu-status');
|
||||
const progEl = document.getElementById('neu-progress');
|
||||
const progText = document.getElementById('neu-progress-text');
|
||||
const errEl = document.getElementById('neu-error');
|
||||
|
||||
const drucksache = document.getElementById('neu-drucksache').value.trim();
|
||||
const bundesland = document.getElementById('neu-bl').value;
|
||||
const model = document.getElementById('neu-model').value;
|
||||
|
||||
if (!drucksache) return;
|
||||
|
||||
btn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
progEl.style.display = '';
|
||||
errEl.style.display = 'none';
|
||||
progText.textContent = 'Analyse läuft … (30–90 s)';
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('drucksache', drucksache);
|
||||
fd.append('bundesland', bundesland);
|
||||
if (model) fd.append('model', model);
|
||||
|
||||
const resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
// Redirect to result page
|
||||
const ds = data.drucksache || drucksache;
|
||||
progText.textContent = 'Analyse abgeschlossen. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 600);
|
||||
|
||||
} catch (err) {
|
||||
progEl.style.display = 'none';
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Fehler: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
219
app/templates/v2/screens/quellen.html
Normal file
@ -0,0 +1,219 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Quellen — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.quellen-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.prog-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: var(--ecg-card-bg);
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.prog-card img {
|
||||
width: 64px;
|
||||
height: auto;
|
||||
border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.prog-meta { font-size: 11px; opacity: 0.6; margin-top: 2px; margin-bottom: 6px; }
|
||||
.prog-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.badge-spd { background: #e3000f; color: #fff; }
|
||||
.badge-cdu { background: #2c2c2c; color: #fff; }
|
||||
.badge-gruene,.badge-grüne { background: #46962b; color: #fff; }
|
||||
.badge-fdp { background: #ffed00; color: #222; }
|
||||
.badge-afd { background: #009ee0; color: #fff; }
|
||||
.badge-linke { background: #be3075; color: #fff; }
|
||||
.badge-type-wp { background: var(--ecg-teal); color: #fff; }
|
||||
.badge-type-pp { background: var(--ecg-green); color: #fff; }
|
||||
.indexed-ok { color: var(--ecg-green); font-size: 11px; font-family: var(--font-mono); }
|
||||
.indexed-no { color: var(--ecg-dark); opacity: 0.4; font-size: 11px; font-family: var(--font-mono); }
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.stat-cell {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.stat-val { font-family: var(--font-display); font-size: 28px; color: var(--ecg-teal); font-weight: 900; }
|
||||
.stat-lbl { font-size: 11px; opacity: 0.6; margin-top: 2px; }
|
||||
.section-h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
color: var(--ecg-teal);
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 2px solid var(--ecg-teal);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 2rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Quellen & Referenzdokumente</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Wahl- und Grundsatzprogramme · semantisch indexiert
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="v2-kasten outline-blue" style="margin-bottom:1.5rem;">
|
||||
<p>
|
||||
Der GWÖ-Antragsprüfer vergleicht parlamentarische Anträge mit den Wahl- und Grundsatzprogrammen
|
||||
der Parteien. Hier finden Sie alle verwendeten Originaldokumente zum Download.
|
||||
</p>
|
||||
<p style="font-size:11px;opacity:0.7;margin-top:6px;">
|
||||
Die Programme werden semantisch indexiert, um relevante Passagen für jeden Antrag zu finden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Indexierungsstatus -->
|
||||
<div class="v2-kasten outline-green" style="margin-bottom:1.5rem;">
|
||||
<h4>Indexierungsstatus</h4>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-cell">
|
||||
<div class="stat-val">{{ status.indexed }}</div>
|
||||
<div class="stat-lbl">Indexiert</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-val">{{ status.total }}</div>
|
||||
<div class="stat-lbl">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<button onclick="indexAll()"
|
||||
style="font-family:var(--font-mono);font-size:11px;padding:5px 12px;background:var(--ecg-teal);color:#fff;border:none;border-radius:3px;cursor:pointer;">
|
||||
Alle Programme indexieren (Admin)
|
||||
</button>
|
||||
<span id="index-status" style="margin-left:10px;font-size:11px;opacity:0.7;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wahlprogramme nach BL -->
|
||||
{% for bl_name, bl_progs in wahlprogramme_grouped %}
|
||||
<h2 class="section-h2">{{ bl_name }}</h2>
|
||||
<div class="quellen-grid">
|
||||
{% for prog in bl_progs %}
|
||||
<div class="prog-card">
|
||||
<a href="{{ prog.pdf_url }}" target="_blank" style="flex-shrink:0;">
|
||||
<img src="/api/programme/thumbnail/{{ prog.id }}" alt="{{ prog.name }}"
|
||||
loading="lazy" onerror="this.style.display='none'">
|
||||
</a>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-family:var(--font-display);font-size:13px;margin-bottom:3px;">
|
||||
<span class="prog-badge badge-{{ prog.partei|lower|replace('ü','ue')|replace('ä','ae')|replace('ö','oe') }}">{{ prog.partei }}</span>
|
||||
{{ prog.name }}
|
||||
</div>
|
||||
<div class="prog-meta">
|
||||
<span class="prog-badge badge-type-wp">Wahlprogramm</span>
|
||||
{% if prog.bundesland %}{{ prog.bundesland }}{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<a href="{{ prog.pdf_url }}" target="_blank"
|
||||
style="font-size:11px;font-family:var(--font-mono);color:var(--ecg-teal);">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
{% for s in status.programmes if s.id == prog.id %}
|
||||
{% if s.indexed %}
|
||||
<span class="indexed-ok">✓ {{ s.chunks }} Chunks</span>
|
||||
{% else %}
|
||||
<span class="indexed-no">○ Nicht indexiert</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Grundsatzprogramme -->
|
||||
<h2 class="section-h2">Grundsatzprogramme (Bundesebene)</h2>
|
||||
<div class="quellen-grid">
|
||||
{% for prog in grundsatzprogramme %}
|
||||
<div class="prog-card">
|
||||
<a href="{{ prog.pdf_url }}" target="_blank" style="flex-shrink:0;">
|
||||
<img src="/api/programme/thumbnail/{{ prog.id }}" alt="{{ prog.name }}"
|
||||
loading="lazy" onerror="this.style.display='none'">
|
||||
</a>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-family:var(--font-display);font-size:13px;margin-bottom:3px;">
|
||||
<span class="prog-badge badge-{{ prog.partei|lower|replace('ü','ue')|replace('ä','ae')|replace('ö','oe') }}">{{ prog.partei }}</span>
|
||||
{{ prog.name }}
|
||||
</div>
|
||||
<div class="prog-meta">
|
||||
<span class="prog-badge badge-type-pp">Grundsatzprogramm</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<a href="{{ prog.pdf_url }}" target="_blank"
|
||||
style="font-size:11px;font-family:var(--font-mono);color:var(--ecg-teal);">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
{% for s in status.programmes if s.id == prog.id %}
|
||||
{% if s.indexed %}
|
||||
<span class="indexed-ok">✓ {{ s.chunks }} Chunks</span>
|
||||
{% else %}
|
||||
<span class="indexed-no">○ Nicht indexiert</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="v2-kasten outline-green" style="margin-top:1rem;">
|
||||
<h4>Hinweise zur Methodik</h4>
|
||||
<ul style="margin:6px 0 0 1.2rem;font-size:12px;">
|
||||
<li>Die Programme werden in semantische Chunks aufgeteilt (~400 Wörter)</li>
|
||||
<li>Jeder Chunk wird mit einem Embedding-Modell vektorisiert</li>
|
||||
<li>Bei der Analyse wird der Antrag ebenfalls vektorisiert</li>
|
||||
<li>Die ähnlichsten Passagen werden als Kontext an das LLM übergeben</li>
|
||||
<li>Das LLM zitiert nur, wenn eine Passage wirklich zur Argumentation passt</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
async function indexAll() {
|
||||
const statusEl = document.getElementById('index-status');
|
||||
statusEl.textContent = 'Indexierung gestartet …';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('all_programmes', 'true');
|
||||
const resp = await fetch('/api/programme/index', { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
statusEl.textContent = 'Indexierung läuft für ' + data.programmes.length + ' Programme';
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
179
app/templates/v2/screens/tags.html
Normal file
@ -0,0 +1,179 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Tags — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "tags" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--ecg-border);
|
||||
background: var(--ecg-bg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
color: var(--ecg-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tag-pill:hover, .tag-pill.active {
|
||||
background: var(--ecg-green);
|
||||
border-color: var(--ecg-green);
|
||||
color: #fff;
|
||||
}
|
||||
.tag-pill .tag-count {
|
||||
opacity: 0.65;
|
||||
font-size: 10px;
|
||||
}
|
||||
.tag-pill.active .tag-count { opacity: 0.9; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1.5rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Tags</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Anträge nach Thema filtern
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tag Cloud -->
|
||||
<div id="tag-cloud" class="tag-cloud">
|
||||
<span style="font-size:12px;opacity:0.5;font-family:var(--font-mono);">Lade Tags …</span>
|
||||
</div>
|
||||
|
||||
<!-- Active filters display -->
|
||||
<div id="active-filters" style="display:none;margin-bottom:12px;">
|
||||
<span style="font-size:11px;font-family:var(--font-mono);opacity:0.6;">Aktive Filter: </span>
|
||||
<span id="active-filter-list"></span>
|
||||
<button onclick="clearFilters()"
|
||||
style="margin-left:8px;font-size:11px;font-family:var(--font-mono);background:none;
|
||||
border:1px solid var(--ecg-border);border-radius:3px;padding:2px 8px;cursor:pointer;
|
||||
color:var(--ecg-dark);opacity:0.7;">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="tag-results" role="list">
|
||||
<span style="font-size:12px;opacity:0.4;font-family:var(--font-mono);">Wählen Sie einen Tag aus.</span>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
let _allItems = [];
|
||||
let _selectedTags = new Set();
|
||||
|
||||
async function init() {
|
||||
// Load all assessments
|
||||
const resp = await fetch('/api/assessments');
|
||||
const data = await resp.json();
|
||||
_allItems = Array.isArray(data) ? data : (data.assessments || []);
|
||||
|
||||
// Build tag frequency map
|
||||
const freq = {};
|
||||
for (const a of _allItems) {
|
||||
for (const t of (a.themen || [])) {
|
||||
freq[t] = (freq[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency descending
|
||||
const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
const cloud = document.getElementById('tag-cloud');
|
||||
if (!sorted.length) {
|
||||
cloud.innerHTML = '<span style="font-size:12px;opacity:0.5;font-family:var(--font-mono);">Keine Tags vorhanden.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
cloud.innerHTML = sorted.map(([tag, count]) =>
|
||||
`<button class="tag-pill" data-tag="${tag.replace(/"/g,'"')}" onclick="toggleTag(this,'${tag.replace(/'/g,"'")}')">
|
||||
${tag} <span class="tag-count">${count}</span>
|
||||
</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function toggleTag(btn, tag) {
|
||||
if (_selectedTags.has(tag)) {
|
||||
_selectedTags.delete(tag);
|
||||
btn.classList.remove('active');
|
||||
} else {
|
||||
_selectedTags.add(tag);
|
||||
btn.classList.add('active');
|
||||
}
|
||||
renderFiltered();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
_selectedTags.clear();
|
||||
document.querySelectorAll('.tag-pill.active').forEach(b => b.classList.remove('active'));
|
||||
renderFiltered();
|
||||
}
|
||||
|
||||
function renderFiltered() {
|
||||
const activeFilters = document.getElementById('active-filters');
|
||||
const filterList = document.getElementById('active-filter-list');
|
||||
const resultsEl = document.getElementById('tag-results');
|
||||
|
||||
if (!_selectedTags.size) {
|
||||
activeFilters.style.display = 'none';
|
||||
resultsEl.innerHTML = '<span style="font-size:12px;opacity:0.4;font-family:var(--font-mono);">Wählen Sie einen Tag aus.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
activeFilters.style.display = '';
|
||||
filterList.innerHTML = Array.from(_selectedTags).map(t =>
|
||||
`<span class="tag-pill active" style="cursor:default;">${t}</span>`
|
||||
).join(' ');
|
||||
|
||||
const filtered = _allItems.filter(a => {
|
||||
const tags = new Set(a.themen || []);
|
||||
return Array.from(_selectedTags).every(t => tags.has(t));
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
resultsEl.innerHTML = '<div class="v2-kasten outline-green"><h4>Keine Ergebnisse</h4><p>Die Filterauswahl liefert keine Treffer.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = filtered.map(a => {
|
||||
const score = (typeof a.gwoe_score === 'number') ? a.gwoe_score.toFixed(1) : '—';
|
||||
const bl = a.bundesland || '';
|
||||
const fraktion = (a.fraktionen || []).join(', ');
|
||||
const title = a.titel || a.drucksache;
|
||||
return `<a href="/antrag/${encodeURIComponent(a.drucksache)}"
|
||||
class="v2-result-row"
|
||||
style="display:block;text-decoration:none;">
|
||||
<div class="v2-result-meta">
|
||||
<span class="v2-chip" style="font-size:10px;">${bl}</span>
|
||||
<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${a.drucksache}</span>
|
||||
${fraktion ? `<span style="font-size:11px;opacity:0.6;">${fraktion}</span>` : ''}
|
||||
</div>
|
||||
<div class="v2-result-title">${title}</div>
|
||||
<div class="v2-result-score" style="font-family:var(--font-mono);font-size:13px;color:var(--ecg-teal);font-weight:700;">
|
||||
Score ${score}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
init().catch(e => {
|
||||
document.getElementById('tag-cloud').innerHTML =
|
||||
'<span style="font-size:12px;color:red;font-family:var(--font-mono);">Fehler: ' + e.message + '</span>';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -11,11 +11,10 @@ import re
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# Drucksache-Format: erlaubt sind alle bisher in den 10 aktiven Bundesländern
|
||||
# beobachteten Schreibweisen — z.B. "8/6390", "18/12345", "8/6390(neu)",
|
||||
# "23/3700-A". Restriktiv genug, um Path-Traversal (../, /etc/passwd) und
|
||||
# trivial-injection (?, ;, <, >, &, =) abzufangen.
|
||||
_DRUCKSACHE_RE = re.compile(r"^\d{1,3}/\d{1,7}([-(].{1,20})?$")
|
||||
# Drucksache-Format: erlaubt sind alle bisher beobachteten Schreibweisen:
|
||||
# "8/6390", "18/12345", "8/6390(neu)", "23/3700-A", "21/754S" (HB: S=Stadtbürgerschaft).
|
||||
# Restriktiv genug für Path-Traversal-Schutz (#57 Befund #3).
|
||||
_DRUCKSACHE_RE = re.compile(r"^\d{1,3}/\d{1,7}[A-Z]?([-(].{1,20})?$")
|
||||
|
||||
|
||||
def validate_drucksache(drucksache: str) -> str:
|
||||
|
||||
39
app/wahlprogramm-links.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
# Kuratierte URL-Kandidaten für fehlende Wahlprogramme (#138).
|
||||
# Gepflegt als Admin-Aufgabe — nur halbautomatisch (kein Auto-Download).
|
||||
#
|
||||
# Struktur:
|
||||
# BL:
|
||||
# PARTEI:
|
||||
# - url: https://...
|
||||
# titel: "Vollständiger Programm-Titel"
|
||||
# jahr: 2024
|
||||
# sha256: "" # optional; nach erstem Download ausfüllen
|
||||
#
|
||||
# Einträge hier landen in `suggest_candidates(bl, partei)`.
|
||||
# Nur PDFs, keine Webseiten.
|
||||
|
||||
NRW:
|
||||
BSW:
|
||||
- url: https://bsw-nrw.de/wp-content/uploads/wahlprogramm-bsw-nrw-2022.pdf
|
||||
titel: "BSW NRW Wahlprogramm 2022 (Platzhalter — URL prüfen)"
|
||||
jahr: 2022
|
||||
sha256: ""
|
||||
|
||||
TH:
|
||||
FDP:
|
||||
- url: https://www.fdp-thueringen.de/files/fdp-th-wahlprogramm-2024.pdf
|
||||
titel: "FDP Thüringen Wahlprogramm 2024 (Platzhalter — URL prüfen)"
|
||||
jahr: 2024
|
||||
sha256: ""
|
||||
|
||||
BB:
|
||||
LINKE:
|
||||
- url: https://www.dielinke-bb.de/fileadmin/lb/Dokumente/wahlprogramm-linke-bb-2024.pdf
|
||||
titel: "DIE LINKE Brandenburg Wahlprogramm 2024 (Platzhalter — URL prüfen)"
|
||||
jahr: 2024
|
||||
sha256: ""
|
||||
GRÜNE:
|
||||
- url: https://gruene-bb.de/wp-content/uploads/wahlprogramm-gruene-bb-2024.pdf
|
||||
titel: "BÜNDNIS 90/DIE GRÜNEN Brandenburg Wahlprogramm 2024 (Platzhalter — URL prüfen)"
|
||||
jahr: 2024
|
||||
sha256: ""
|
||||
290
app/wahlprogramm_fetch.py
Normal file
@ -0,0 +1,290 @@
|
||||
"""Halbautomatische Wahlprogramm-Beschaffung (#138).
|
||||
|
||||
Workflow:
|
||||
1. ``check_missing_programmes(bl, fraktionen)`` liefert Lücken
|
||||
2. ``suggest_candidates(bl, partei)`` schlägt URL-Kandidaten vor
|
||||
(aus wahlprogramm-links.yaml im selben Verzeichnis)
|
||||
3. ``fetch_and_verify(url, dest_path, expected_sha)`` lädt, prüft SHA-256,
|
||||
speichert in app/static/referenzen/ — oder bricht bei SHA-Abweichung ab
|
||||
4. Re-Indexing via ``reindex_embeddings`` muss danach manuell ausgelöst werden
|
||||
|
||||
CLI:
|
||||
python -m app.wahlprogramm_fetch --check [--bl BL]
|
||||
python -m app.wahlprogramm_fetch --fetch BL PARTEI [--yes]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LINKS_FILE = Path(__file__).parent / "wahlprogramm-links.yaml"
|
||||
_REFERENZEN_DIR = Path(__file__).parent / "static" / "referenzen"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML-Quelle laden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_links() -> dict:
|
||||
"""Lädt wahlprogramm-links.yaml. Gibt leeres Dict zurück, wenn Datei fehlt."""
|
||||
if not _LINKS_FILE.exists():
|
||||
logger.warning("wahlprogramm-links.yaml nicht gefunden: %s", _LINKS_FILE)
|
||||
return {}
|
||||
with _LINKS_FILE.open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh) or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Öffentliche API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def suggest_candidates(bundesland: str, partei: str) -> list[dict]:
|
||||
"""Gibt URL-Kandidaten aus wahlprogramm-links.yaml für BL+Partei zurück.
|
||||
|
||||
Args:
|
||||
bundesland: Bundesland-Code (z.B. "NRW").
|
||||
partei: Partei-Kürzel (z.B. "BSW").
|
||||
|
||||
Returns:
|
||||
Liste von Dicts mit mindestens ``url`` und ``titel``. Leer, wenn
|
||||
keine Einträge vorhanden.
|
||||
"""
|
||||
data = _load_links()
|
||||
bl_block = data.get(bundesland, {})
|
||||
partei_block = bl_block.get(partei, [])
|
||||
if isinstance(partei_block, dict):
|
||||
partei_block = [partei_block]
|
||||
return list(partei_block)
|
||||
|
||||
|
||||
def sha256_of_file(path: Path) -> str:
|
||||
"""Berechnet den SHA-256-Hash einer Datei als Hex-String."""
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def fetch_and_verify(
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
expected_sha: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Lädt eine Datei herunter und prüft optional den SHA-256-Hash.
|
||||
|
||||
SHA-Gate-Logik:
|
||||
- Existiert ``dest_path`` bereits, wird der bisherige Hash gespeichert.
|
||||
- Nach dem Download wird der neue Hash verglichen.
|
||||
- Bei Abweichung wird die temporäre Datei gelöscht und ein Fehler zurückgegeben
|
||||
(niemals stillschweigend überschreiben).
|
||||
|
||||
Args:
|
||||
url: Download-URL der PDF-Datei.
|
||||
dest_path: Ziel-Pfad (typischerweise in app/static/referenzen/).
|
||||
expected_sha: Wenn angegeben, muss der Download-Hash übereinstimmen.
|
||||
|
||||
Returns:
|
||||
Dict mit den Schlüsseln:
|
||||
- ``ok`` (bool): True bei Erfolg.
|
||||
- ``sha256`` (str): SHA-256 der heruntergeladenen Datei.
|
||||
- ``prev_sha256`` (str|None): SHA-256 der bisherigen Datei, falls vorhanden.
|
||||
- ``error`` (str|None): Fehlermeldung bei Misserfolg.
|
||||
- ``changed`` (bool): True, wenn sich die Datei gegenüber der bisherigen Version geändert hat.
|
||||
"""
|
||||
prev_sha: Optional[str] = None
|
||||
if dest_path.exists():
|
||||
prev_sha = sha256_of_file(dest_path)
|
||||
|
||||
tmp_path = dest_path.with_suffix(".tmp")
|
||||
try:
|
||||
logger.info("Lade %s → %s", url, tmp_path)
|
||||
_referenzen_dir = dest_path.parent
|
||||
_referenzen_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "GWOeAntragspruefer/1.0 (+https://gwoe.toppyr.de)"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
tmp_path.write_bytes(resp.read())
|
||||
|
||||
new_sha = sha256_of_file(tmp_path)
|
||||
|
||||
# SHA-Gate gegen expected_sha
|
||||
if expected_sha and new_sha != expected_sha:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return {
|
||||
"ok": False,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"changed": False,
|
||||
"error": (
|
||||
f"SHA-Prüfung fehlgeschlagen: erwartet {expected_sha[:12]}…, "
|
||||
f"erhalten {new_sha[:12]}…"
|
||||
),
|
||||
}
|
||||
|
||||
# SHA-Gate gegen bisherige Datei
|
||||
if prev_sha and new_sha == prev_sha:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.info("Datei unverändert (SHA %s…), kein Überschreiben.", new_sha[:12])
|
||||
return {
|
||||
"ok": True,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"changed": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
tmp_path.rename(dest_path)
|
||||
logger.info("Gespeichert: %s (SHA %s…)", dest_path.name, new_sha[:12])
|
||||
return {
|
||||
"ok": True,
|
||||
"sha256": new_sha,
|
||||
"prev_sha256": prev_sha,
|
||||
"changed": True,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.exception("Fehler beim Download von %s", url)
|
||||
return {
|
||||
"ok": False,
|
||||
"sha256": "",
|
||||
"prev_sha256": prev_sha,
|
||||
"changed": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def get_missing_programmes(bundesland: Optional[str] = None) -> list[dict]:
|
||||
"""Liefert alle BL/Partei-Kombinationen mit Kandidaten-URL, aber fehlender Datei.
|
||||
|
||||
Args:
|
||||
bundesland: Wenn angegeben, nur dieses Bundesland prüfen.
|
||||
|
||||
Returns:
|
||||
Liste von Dicts mit ``bl``, ``partei``, ``dateiname``, ``kandidaten``.
|
||||
"""
|
||||
from .wahlprogramme import WAHLPROGRAMME
|
||||
|
||||
missing: list[dict] = []
|
||||
data = _load_links()
|
||||
|
||||
bl_keys = [bundesland] if bundesland else list(data.keys())
|
||||
for bl in bl_keys:
|
||||
bl_block = data.get(bl, {})
|
||||
for partei, kandidaten in bl_block.items():
|
||||
if isinstance(kandidaten, dict):
|
||||
kandidaten = [kandidaten]
|
||||
|
||||
wp_info = WAHLPROGRAMME.get(bl, {}).get(partei)
|
||||
if wp_info:
|
||||
dateiname = wp_info["file"]
|
||||
dest = _REFERENZEN_DIR / dateiname
|
||||
if dest.exists():
|
||||
continue # Datei liegt bereits vor
|
||||
else:
|
||||
dateiname = None # noch nicht in WAHLPROGRAMME registriert
|
||||
|
||||
missing.append({
|
||||
"bl": bl,
|
||||
"partei": partei,
|
||||
"dateiname": dateiname,
|
||||
"kandidaten": kandidaten,
|
||||
})
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cli() -> None:
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Halbautomatische Wahlprogramm-Beschaffung (#138)",
|
||||
)
|
||||
parser.add_argument("--check", action="store_true", help="Lücken auflisten")
|
||||
parser.add_argument("--bl", help="Bundesland-Filter für --check")
|
||||
parser.add_argument("--fetch", nargs=2, metavar=("BL", "PARTEI"),
|
||||
help="Wahlprogramm für BL/PARTEI herunterladen")
|
||||
parser.add_argument("--url", help="URL überschreiben (statt erster Kandidat aus YAML)")
|
||||
parser.add_argument("--yes", action="store_true",
|
||||
help="Nicht interaktiv bestätigen (gefährlich)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
missing = get_missing_programmes(args.bl)
|
||||
if not missing:
|
||||
print("Keine Lücken gefunden.")
|
||||
for entry in missing:
|
||||
cands = entry["kandidaten"]
|
||||
cand_str = cands[0]["url"] if cands else "(keine URL hinterlegt)"
|
||||
print(
|
||||
f" {entry['bl']:6} {entry['partei']:15} "
|
||||
f"{'(noch nicht registriert)' if not entry['dateiname'] else entry['dateiname']:35} "
|
||||
f"→ {cand_str}"
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if args.fetch:
|
||||
bl, partei = args.fetch
|
||||
candidates = suggest_candidates(bl, partei)
|
||||
if args.url:
|
||||
url = args.url
|
||||
elif candidates:
|
||||
url = candidates[0]["url"]
|
||||
print(f"Kandidat: {url}")
|
||||
else:
|
||||
print(f"Keine URL-Kandidaten für {bl}/{partei} in wahlprogramm-links.yaml.")
|
||||
sys.exit(1)
|
||||
|
||||
from .wahlprogramme import WAHLPROGRAMME
|
||||
wp_info = WAHLPROGRAMME.get(bl, {}).get(partei)
|
||||
if not wp_info:
|
||||
print(
|
||||
f"WARNUNG: {bl}/{partei} ist noch nicht in wahlprogramme.py eingetragen.\n"
|
||||
"Die Datei wird heruntergeladen, muss aber manuell registriert werden."
|
||||
)
|
||||
dateiname = f"{partei.lower()}-{bl.lower()}-neu.pdf"
|
||||
else:
|
||||
dateiname = wp_info["file"]
|
||||
|
||||
dest = _REFERENZEN_DIR / dateiname
|
||||
|
||||
if not args.yes:
|
||||
confirm = input(f"Download {url} → {dest}? [j/N] ").strip().lower()
|
||||
if confirm not in ("j", "ja", "y", "yes"):
|
||||
print("Abgebrochen.")
|
||||
sys.exit(0)
|
||||
|
||||
result = fetch_and_verify(url, dest)
|
||||
if result["ok"]:
|
||||
change_note = "geändert" if result["changed"] else "unverändert"
|
||||
print(f"OK ({change_note}) — SHA-256: {result['sha256'][:16]}…")
|
||||
if result["changed"]:
|
||||
print("Hinweis: Embeddings müssen neu indexiert werden (python -m app.reindex_embeddings).")
|
||||
else:
|
||||
print(f"FEHLER: {result['error']}")
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||