diff --git a/app/auswertungen.py b/app/auswertungen.py index e48191a..68035a5 100644 --- a/app/auswertungen.py +++ b/app/auswertungen.py @@ -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( "": {"": {"n": int, "avg": float}} }, "filter_wp": | None, + "filter_bl": | None, "total": int, }`` ``filter_wp`` ist eine ``"-WP"``-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 # ───────────────────────────────────────────────────────────────────────────── diff --git a/app/auth.py b/app/auth.py index 67547e3..9ca387d 100644 --- a/app/auth.py +++ b/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() diff --git a/app/config.py b/app/config.py index 4d0bf41..5401d1e 100644 --- a/app/config.py +++ b/app/config.py @@ -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"} diff --git a/app/main.py b/app/main.py index eb5cc7b..b62a390 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from typing import Optional from fastapi import FastAPI, File, Form, UploadFile, Request, BackgroundTasks, HTTPException, Depends from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, Response +from pydantic import BaseModel from starlette.middleware.base import BaseHTTPMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -36,11 +37,17 @@ from .database import ( upsert_assessment, import_json_assessments, search_assessments, toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment, + create_subscription, list_subscriptions, list_all_subscriptions, delete_subscription, + delete_subscription_by_id, + upsert_vote, get_votes, + get_assessment_history, + get_abstimmungsverhalten, + merkliste_add, merkliste_remove, merkliste_list, merkliste_bulk_add, ) from .parlamente import get_adapter, ADAPTERS from .bundeslaender import alle_bundeslaender from .analyzer import analyze_antrag -from .auth import get_current_user, require_auth, require_admin, keycloak_login_url, _is_auth_enabled +from .auth import get_current_user, require_auth, require_admin, keycloak_login_url, keycloak_admin_token, _is_auth_enabled def _pick_best_title(llm_title: str, doc_title: Optional[str], drucksache: str) -> str: @@ -122,12 +129,14 @@ templates = Jinja2Templates(directory=str(templates_dir)) @app.on_event("startup") async def startup(): + import asyncio await init_db() init_embeddings_db() - # Job-Queue Worker starten (#95) + # Job-Queue Worker starten (#95) — Worker ZUERST, dann Re-Enqueue + # im Hintergrund (damit die Webapp sofort erreichbar ist) from .queue import start_worker, re_enqueue_pending - await re_enqueue_pending() start_worker() + asyncio.create_task(re_enqueue_pending(analysis_callback=run_drucksache_analysis)) @app.on_event("shutdown") @@ -144,9 +153,9 @@ async def shutdown(): # print(f"Imported {count} assessments from JSON files") -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - """Landing page with upload form.""" +@app.get("/classic", response_class=HTMLResponse) +async def classic_index(request: Request): + """Klassische Ansicht (v1) — jetzt unter /classic erreichbar.""" # Frontend-Liste: synthetischer "ALL"-Eintrag (Bundesweit) zuerst, dann # die echten Bundesländer aus der Konfig. Der "ALL"-Code ist eine reine # Frontend/API-Konvention, kein Eintrag in bundeslaender.py. @@ -197,6 +206,254 @@ async def index(request: Request): }) +# ─── Default: / → v2 (Default-Flip #139 Phase 2) ──────────────────────────── + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + """Startseite — rendert v2-Listenansicht (Default-Flip Phase 2). + + Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf + /antrag/XX-YYYY weitergeleitet, damit Bookmarks weiter funktionieren. + """ + from fastapi.responses import RedirectResponse + # Deep-Link-Kompatibilität: /?drucksache=18/12345 → /antrag/18/12345 + drucksache_param = request.query_params.get("drucksache") + if drucksache_param: + return RedirectResponse(f"/antrag/{drucksache_param}", status_code=301) + + rows = await get_all_assessments(None) + assessments = _rows_to_list(rows) + bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")}) + return templates.TemplateResponse("v2/screens/durchsuchen.html", { + "request": request, + "v2_active_nav": "durchsuchen", + "assessments": assessments, + "bl_codes": bl_codes, + "assessment_count": len(assessments), + }) + + +@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse) +async def antrag_detail(request: Request, drucksache: str): + """v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten.""" + try: + drucksache = validate_drucksache(drucksache) + except Exception: + return templates.TemplateResponse("v2/screens/antrag_detail.html", { + "request": request, + "v2_active_nav": "durchsuchen", + "error": f"Ungültige Drucksachen-ID: {drucksache}", + }, status_code=400) + + row = await get_assessment(drucksache) + if not row: + return templates.TemplateResponse("v2/screens/antrag_detail.html", { + "request": request, + "v2_active_nav": "durchsuchen", + "error": f"Antrag {drucksache} wurde nicht gefunden.", + }, status_code=404) + + antrag = _row_to_detail(row) + # #106 Phase 1: namentliche Abstimmungsdaten ergänzen (optional, kann None sein) + try: + antrag["abstimmungsverhalten"] = await get_abstimmungsverhalten(drucksache) + except Exception: + logger.exception("Fehler beim Laden von Abstimmungsverhalten für %s", drucksache) + antrag["abstimmungsverhalten"] = None + from .models import MATRIX_LABELS + return templates.TemplateResponse("v2/screens/antrag_detail.html", { + "request": request, + "v2_active_nav": "durchsuchen", + "antrag": antrag, + "assessment_count": None, + "matrix_explanations": { + "A1": "Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt? Oder in einer Fabrik, in der Arbeiter:innen ausgebeutet werden? Hier geht es darum, ob die öffentliche Hand beim Einkauf auf Menschenrechte achtet.", + "A2": "Beauftragt die Stadt den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleibt das Geld in der Region und schafft Arbeitsplätze vor Ort?", + "A3": "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa gekarrt?", + "A4": "Verdienen die Reinigungskräfte im Rathaus einen fairen Lohn? Haben Subunternehmer die gleichen Arbeitsbedingungen wie Festangestellte?", + "A5": "Können Sie als Bürger:in nachschauen, welche Firma den Auftrag für den Straßenbau bekommen hat — und warum? Oder passiert das alles hinter verschlossenen Türen?", + "B1": "Liegt das Geld Ihrer Stadt bei einer Bank, die auch Waffengeschäfte finanziert? Oder bei einer ethischen Bank, die in soziale Projekte investiert?", + "B2": "Fließen Ihre Steuergelder in einen neuen Radweg für alle — oder in eine Umgehungsstraße, die nur dem Gewerbegebiet nützt?", + "B3": "Investiert Ihre Kommune in Solaranlagen auf Schuldächern? Oder wird das Geld in klimaschädliche Projekte gesteckt?", + "B4": "Bekommen ärmere Stadtteile genauso viel Geld für Spielplätze und Schulen wie reiche? Oder konzentrieren sich die Investitionen dort, wo die Grundstückspreise schon hoch sind?", + "B5": "Gibt es einen Bürgerhaushalt, bei dem Sie mitbestimmen können, ob das Geld in die Bibliothek oder den Sportplatz fließt? Oder entscheidet das der Stadtrat allein?", + "C1": "Werden in Ihrer Stadtverwaltung Frauen gleich bezahlt? Haben Menschen mit Behinderung faire Chancen auf eine Stelle? Gibt es Schutz vor Mobbing?", + "C2": "Hat Ihre Stadt ein Klimaschutzkonzept, das alle Ämter gemeinsam umsetzen? Oder kocht jedes Amt sein eigenes Süppchen?", + "C3": "Fahren die Mitarbeiter:innen des Rathauses mit dem Dienstrad oder dem SUV? Gibt es vegetarisches Essen in der Kantine?", + "C4": "Können Eltern in der Verwaltung Teilzeit arbeiten, ohne Karrierenachteile? Gibt es flexible Arbeitszeiten für pflegende Angehörige?", + "C5": "Können Sie die Sitzungsprotokolle des Stadtrats online lesen? Verstehen Sie, warum Entscheidungen so und nicht anders gefallen sind?", + "D1": "Werden Sie auf dem Amt fair behandelt — egal ob Sie einen deutschen oder ausländischen Namen haben? Schützt die Polizei alle gleich?", + "D2": "Profitiert die ganze Stadt von dem Antrag — oder nur ein Stadtteil, eine Altersgruppe, eine Einkommensschicht?", + "D3": "Kommt der Strom für die Straßenbeleuchtung aus Erneuerbaren? Wird das Regenwasser im Park versickert statt in die Kanalisation geleitet?", + "D4": "Kann sich die alleinerziehende Mutter den Kitaplatz leisten? Bekommt der Rentner noch einen Arzttermin? Findet die Familie mit drei Kindern eine bezahlbare Wohnung?", + "D5": "Werden Sie gefragt, bevor die Straße vor Ihrem Haus umgebaut wird? Gibt es Bürgerversammlungen, Online-Beteiligung, Jugendparlamente?", + "E1": "Hinterlassen wir unseren Enkeln einen Schuldenberg und versiegelte Flächen? Oder investieren wir heute so, dass auch 2050 noch gute Lebensbedingungen herrschen?", + "E2": "Hilft der Antrag nur Ihrer Stadt — oder auch den Nachbargemeinden? Gibt es regionale Kooperationen, von denen alle profitieren?", + "E3": "Denkt Ihre Kommune beim Einkauf auch an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten?", + "E4": "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden?", + "E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?", + }, + "matrix_labels": MATRIX_LABELS, + }) + + +def _rows_to_list(rows): + """Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht.""" + result = [] + for row in rows: + result.append({ + "drucksache": row.get("drucksache", ""), + "title": row.get("title", ""), + "score": row.get("gwoe_score") or 0.0, + "parteien": row.get("fraktionen", []), + "bundesland": row.get("bundesland", ""), + "tags": row.get("themen", []), + "datum": row.get("datum", ""), + }) + return result + + +from .redline_utils import parse_redline_segments as _parse_redline_segments +from .redline_utils import build_pdf_href as _build_pdf_href + + +def _row_to_detail(row): + """Konvertiert eine DB-Row in das antrag-Dict für den v2-Detailscreen.""" + import datetime + + fraktionen = row.get("fraktionen", []) + gwoe_matrix_raw = row.get("gwoe_matrix", []) + wahlprogramm_scores = row.get("wahlprogramm_scores", []) + verbesserungen = row.get("verbesserungen", []) + staerken = row.get("staerken", []) + schwaechen = row.get("schwaechen", []) + bundesland = row.get("bundesland", "") + + # gwoe_matrix ist ein Array [{field, rating, symbol, …}] → Dict A1→{rating,symbol} + matrix_dict = {} + for cell in (gwoe_matrix_raw or []): + field = cell.get("field", "") + if field: + rating_raw = cell.get("rating", 0) + # DB speichert rating als 1-5 Skala (1=−−,2=−,3=○,4=+,5=++), + # matrix_mini erwartet -2..+2. Mapping: DB-3=0, DB-4=+1, DB-5=+2, DB-2=−1, DB-1=−2 + rating_normalized = int(rating_raw) - 3 + symbol = cell.get("symbol", "○") + matrix_dict[field] = {"rating": rating_normalized, "symbol": symbol} + + # fraktions_scores: numerische Scores + Begründungen + leere Zitat-Listen (Fix 2+3) + fraktions_scores = [] + for wp in (wahlprogramm_scores or []): + fraktion = wp.get("fraktion", "") + wp_src = wp.get("wahlprogramm") or {} + pp_src = wp.get("parteiprogramm") or {} + fraktions_scores.append({ + "fraktion": fraktion, + "ist_antragsteller": wp.get("istAntragsteller", wp.get("ist_antragsteller")), + "ist_regierung": wp.get("istRegierung", wp.get("ist_regierung")), + "wahlprogramm": { + "score": wp_src.get("score", 0), + "begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")), + "hat_zitate": bool(wp_src.get("zitate")), + }, + "parteiprogramm": { + "score": pp_src.get("score", 0), + "begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")), + "hat_zitate": bool(pp_src.get("zitate")), + }, + }) + + # Zitate aus wahlprogramm_scores extrahieren (flache Liste über alle Fraktionen) + # PDF-Href wird bevorzugt aus url-Feld genommen, sonst aus quelle rekonstruiert + zitate = [] + for wp in (wahlprogramm_scores or []): + fraktion = wp.get("fraktion", "") + for src_key in ("wahlprogramm", "parteiprogramm"): + src = wp.get(src_key) or {} + for zitat in (src.get("zitate") or []): + zitate.append({ + "text": zitat.get("text", ""), + "source": zitat.get("quelle", ""), + "partei": fraktion, + "verified": True, + "contra": False, + "pdf_href": _build_pdf_href(zitat, bundesland), + }) + + # Stärkster/schwächster Wert aus staerken/schwaechen (erste Einträge) + staerkster = {"titel": "Stärken", "text": "; ".join(staerken[:2]) if staerken else ""} + schwaechster = {"titel": "Schwächen", "text": "; ".join(schwaechen[:2]) if schwaechen else ""} + + # Verbesserungen: §INS§/§DEL§- und **/**-/~~Marker parsen (Fix 1) + verbesserungen_parsed = [] + for v in (verbesserungen or []): + vorschlag_raw = v.get("vorschlag", "") + verbesserungen_parsed.append({ + "original": v.get("original", ""), + "vorschlag": vorschlag_raw, + "begruendung": v.get("begruendung", ""), + "segments": _parse_redline_segments(vorschlag_raw), + }) + + # Redline-Fallback (wird nur genutzt wenn verbesserungen leer, für rückwärts-Compat) + redline_data = {"segments": []} + if not verbesserungen_parsed and verbesserungen: + v = verbesserungen[0] + redline_data["segments"] = _parse_redline_segments(v.get("vorschlag", "")) + + # Datum normalisieren: ISO → lesbar + datum_raw = row.get("datum", "") + datum_display = datum_raw + try: + d = datetime.date.fromisoformat(datum_raw[:10]) + datum_display = d.strftime("%d.%m.%Y") + except Exception: + pass + + updated_at = row.get("updated_at", "") + analysiert_display = updated_at + try: + d2 = datetime.datetime.fromisoformat(updated_at[:19]) + analysiert_display = d2.strftime("%d.%m.%Y") + except Exception: + pass + + # gwoe_score als float (DB kann None liefern) + score = float(row.get("gwoe_score") or 0.0) + empfehlung = row.get("empfehlung") or "" + + return { + "drucksache": row.get("drucksache", ""), + "bundesland": row.get("bundesland", ""), + "parlament": "Landtag", + "typ": "Antrag", + "datum": datum_display, + "analysiert": analysiert_display, + "modell": row.get("model", ""), + "parteien": fraktionen, + "zitate_count": len(zitate), + "title": row.get("title", ""), + "score": score, + "verdict_title": empfehlung, + "verdict_body": row.get("gwoe_begruendung", "") or "", + "zusammenfassung": row.get("antrag_zusammenfassung", "") or "", + "staerkster_wert": staerkster, + "schwaechster_wert": schwaechster, + "redline": redline_data, + "matrix": matrix_dict, + "zitate": zitate, + "fraktions_scores": fraktions_scores, + "verbesserungen": verbesserungen_parsed, + "staerken": staerken, + "schwaechen": schwaechen, + "share_threads": row.get("share_threads"), + "share_twitter": row.get("share_twitter"), + "share_mastodon": row.get("share_mastodon"), + # Roher ISO-Zeitstempel für OG-Cache-Key (#141) + "updated_at_raw": row.get("updated_at", ""), + } + + @app.post("/analyze") @limiter.limit("10/minute") async def start_analysis( @@ -386,13 +643,135 @@ async def auth_login_url(request: Request, redirect: str = "/"): """Keycloak-Login-URL für den Browser-Redirect.""" if not _is_auth_enabled(): return {"enabled": False, "url": ""} - # redirect_uri muss auf den Callback-Endpoint zeigen, nicht auf die - # Zielseite — der Callback tauscht den Code gegen ein Token. base = str(request.base_url).rstrip("/").replace("http://", "https://") url = keycloak_login_url(f"{base}/api/auth/callback") return {"enabled": True, "url": url} +@app.post("/api/auth/login") +async def auth_direct_login( + username: str = Form(...), + password: str = Form(...), +): + """Direct Access Grant Login (#129) — kein Redirect zu Keycloak. + + Ruft Keycloak per Resource Owner Password Credentials (Direct Access Grant) an. + Setzt access_token als HttpOnly-Cookie und refresh_token als separates rt-Cookie. + + KEYCLOAK-VORAUSSETZUNG: Im Client muss "Direct Access Grants" aktiviert sein. + Keycloak Admin → Realm → Clients → gwoe-antragspruefer → Capability config → + "Direct access grants enabled" = ON. + + Fehler-Mapping: + - 401 → {"error": "invalid_credentials", "msg": "..."} + - Keycloak nicht erreichbar / sonstiges → {"error": "unknown", "msg": "..."} + """ + from .auth import direct_login, _validate_token + try: + token_data = await direct_login(username, password) + except HTTPException as exc: + if exc.status_code == 401: + return JSONResponse( + status_code=401, + content={"error": "invalid_credentials", "msg": exc.detail}, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": "unknown", "msg": exc.detail}, + ) + + access_token = token_data["access_token"] + expires_in = token_data.get("expires_in", 300) + refresh_token = token_data.get("refresh_token") + refresh_expires_in = token_data.get("refresh_expires_in", 1800) + + # Validiere Token um User-Infos zu extrahieren. + # _validate_token gibt {sub, email, name, roles} zurück, wobei name + # bereits auf preferred_username normalisiert wurde (siehe auth.py L144). + user_payload = await _validate_token(access_token) + user_info = {} + if user_payload: + user_info = { + "sub": user_payload.get("sub"), + "preferred_username": user_payload.get("name", username), + "name": user_payload.get("name", username), + "email": user_payload.get("email", ""), + } + + response = JSONResponse({ + "authenticated": True, + "expires_in": expires_in, + "user": user_info, + }) + response.set_cookie( + "access_token", + access_token, + max_age=expires_in, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + if refresh_token: + response.set_cookie( + "rt", + refresh_token, + max_age=refresh_expires_in, + httponly=True, + secure=True, + samesite="lax", + path="/api/auth/refresh", + ) + return response + + +@app.post("/api/auth/logout") +async def auth_logout(): + """Logout — löscht access_token + rt-Cookies (HttpOnly, daher server-seitig).""" + response = JSONResponse({"authenticated": False}) + response.delete_cookie("access_token", path="/") + response.delete_cookie("rt", path="/api/auth/refresh") + return response + + +@app.post("/api/auth/refresh") +async def auth_refresh(request: Request): + """Refresh-Token-Endpoint (#129) — holt neuen access_token via refresh_token-Cookie. + + Setzt neuen access_token-Cookie bei Erfolg. + """ + from .auth import _keycloak_issuer + refresh_token = request.cookies.get("rt") + if not refresh_token: + raise HTTPException(status_code=401, detail="Kein Refresh-Token") + import httpx as _httpx + async with _httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{_keycloak_issuer()}/protocol/openid-connect/token", + data={ + "grant_type": "refresh_token", + "client_id": settings.keycloak_client_id, + "refresh_token": refresh_token, + }, + ) + if resp.status_code != 200: + raise HTTPException(status_code=401, detail="Refresh fehlgeschlagen") + token_data = resp.json() + access_token = token_data["access_token"] + expires_in = token_data.get("expires_in", 300) + response = JSONResponse({"authenticated": True, "expires_in": expires_in}) + response.set_cookie( + "access_token", + access_token, + max_age=expires_in, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + return response + + # ─── Bookmarks + Comments (#94) ───────────────────────────────────────────── @app.post("/api/bookmark") @@ -413,6 +792,126 @@ async def bookmarks_list(user=Depends(get_current_user)): return await get_bookmarks(user["sub"]) +# ─── Merkliste — serverseitig (#140) ───────────────────────────────────────── + +class _MerklisteAddBody(BaseModel): + antrag_id: str + notiz: Optional[str] = None + + +class _MerklisteBulkBody(BaseModel): + entries: list[dict] + + +@app.post("/api/me/merkliste") +async def merkliste_add_endpoint( + body: _MerklisteAddBody, + user: dict = Depends(require_auth), +): + """Antrag zur Merkliste hinzufügen (Upsert). Erfordert Anmeldung.""" + entry = await merkliste_add(user["sub"], body.antrag_id, body.notiz) + return entry + + +@app.delete("/api/me/merkliste/{antrag_id:path}") +async def merkliste_remove_endpoint( + antrag_id: str, + user: dict = Depends(require_auth), +): + """Antrag aus der Merkliste entfernen. Erfordert Anmeldung.""" + removed = await merkliste_remove(user["sub"], antrag_id) + return {"removed": removed, "antrag_id": antrag_id} + + +@app.get("/api/me/merkliste") +async def merkliste_list_endpoint(user: dict = Depends(require_auth)): + """Alle Merklisten-Einträge des aktuellen Users. Erfordert Anmeldung.""" + return await merkliste_list(user["sub"]) + + +@app.post("/api/me/merkliste/bulk-import") +async def merkliste_bulk_import( + body: _MerklisteBulkBody, + user: dict = Depends(require_auth), +): + """Mehrere Einträge auf einmal importieren (für localStorage-Migration). + + Body: ``{"entries": [{"antrag_id": "18/1234"}, …]}`` + """ + count = await merkliste_bulk_add(user["sub"], body.entries) + return {"imported": count} + + +# ─── E-Mail-Abonnements (#124) ─────────────────────────────────────────────── + +@app.post("/api/subscriptions") +async def subscription_create( + bundesland: Optional[str] = Form(None), + partei: Optional[str] = Form(None), + frequency: str = Form("daily"), + user: dict = Depends(require_auth), +): + """Neues Abo für Benachrichtigungen anlegen.""" + email = user.get("email") + if not email: + raise HTTPException(status_code=400, detail="Nutzer hat keine E-Mail-Adresse im Token") + if frequency not in ("daily",): + raise HTTPException(status_code=400, detail="Unsupported frequency") + sub_id = await create_subscription( + user_id=user["sub"], + email=email, + bundesland=bundesland or None, + partei=partei or None, + frequency=frequency, + ) + return {"id": sub_id, "email": email, "bundesland": bundesland, "partei": partei, "frequency": frequency} + + +@app.get("/api/subscriptions") +async def subscription_list(user=Depends(get_current_user)): + """Liste aller Abos. Admins erhalten alle Abos inkl. user_id-Feld; normale + User sehen nur ihre eigenen Abos.""" + if not user: + return [] + roles = user.get("roles") or [] + if "admin" in roles: + return await list_all_subscriptions() + return await list_subscriptions(user["sub"]) + + +@app.delete("/api/subscriptions/{sub_id}") +async def subscription_delete(sub_id: int, user: dict = Depends(require_auth)): + """Abo löschen (nur eigenes).""" + ok = await delete_subscription(user["sub"], sub_id) + if not ok: + raise HTTPException(status_code=404, detail="Abo nicht gefunden") + return {"deleted": sub_id} + + +@app.get("/unsubscribe/{sub_id}/{token}", response_class=HTMLResponse) +async def unsubscribe(sub_id: int, token: str): + """Unsubscribe-Link aus E-Mails — kein Login nötig (HMAC-Token verifiziert).""" + from .mail import verify_unsubscribe_token + if not verify_unsubscribe_token(sub_id, token): + return HTMLResponse( + "" + "

Ungültiger Unsubscribe-Link

" + "

Der Link ist nicht mehr gültig oder wurde manipuliert.

" + "

Zurück zur Startseite

" + "", + status_code=400, + ) + ok = await delete_subscription_by_id(sub_id) + msg = "Abo wurde abbestellt." if ok else "Abo war bereits gelöscht." + return HTMLResponse( + f"" + f"

{msg}

" + f"

Du bekommst keine weiteren Benachrichtigungen zu diesem Filter.

" + f"

Zurück zur Startseite

" + f"" + ) + + @app.post("/api/comment") async def comment_add( drucksache: str = Form(...), @@ -442,41 +941,64 @@ async def comment_delete(comment_id: int, user: dict = Depends(require_auth)): return {"status": "deleted"} +# ─── Assessment-History (#110) ──────────────────────────────────────────── + +@app.get("/api/assessment/history") +async def assessment_history(drucksache: str): + """Versionshistorie eines Assessments.""" + drucksache = validate_drucksache(drucksache) + return await get_assessment_history(drucksache) + + +# ─── Crowd-Validation / Votes (#112) ───────────────────────────────────── + +@app.post("/api/vote") +async def vote_endpoint( + drucksache: str = Form(...), + target: str = Form("overall"), + vote: str = Form(...), + user: dict = Depends(require_auth), +): + """Bewertung als treffend/fragwürdig markieren. Toggle: gleicher Vote nochmal = entfernen.""" + drucksache = validate_drucksache(drucksache) + if vote not in ("up", "down"): + raise HTTPException(status_code=400, detail="vote muss 'up' oder 'down' sein") + if target not in ("overall",) and not target.startswith("matrix:") and not target.startswith("partei:"): + raise HTTPException(status_code=400, detail="Ungültiges target") + result = await upsert_vote(user["sub"], drucksache, target, vote) + return result + + +@app.get("/api/votes") +async def votes_endpoint(drucksache: str, user: dict = Depends(get_current_user)): + """Aggregierte Votes + eigener Vote für eine Drucksache.""" + drucksache = validate_drucksache(drucksache) + user_id = user["sub"] if user else None + return await get_votes(drucksache, user_id) + + # ─── Registrierung (#103) ──────────────────────────────────────────────── @app.post("/api/auth/register") +@limiter.limit("3/hour") async def auth_register( request: Request, firstName: str = Form(...), lastName: str = Form(...), email: str = Form(...), username: str = Form(...), - password: str = Form(...), ): """Registrierung: erstellt User in Keycloak mit enabled=false. - Admin muss den Account manuell freischalten.""" - if len(password) < 8: - raise HTTPException(status_code=400, detail="Passwort muss mindestens 8 Zeichen haben") + Admin muss den Account manuell freischalten. + Kein Passwort nötig — nach Freischaltung sendet Keycloak eine + E-Mail zum Passwort-Setzen.""" import httpx as _httpx - # Admin-Token holen + admin_token = await keycloak_admin_token() async with _httpx.AsyncClient(timeout=10) as client: - token_resp = await client.post( - "https://sso.toppyr.de/realms/master/protocol/openid-connect/token", - data={ - "grant_type": "password", - "client_id": "admin-cli", - "username": "admin", - "password": "J915vI2Ankf7SdmEqe0BC5Aq", - }, - ) - if token_resp.status_code != 200: - raise HTTPException(status_code=500, detail="Keycloak-Verbindung fehlgeschlagen") - admin_token = token_resp.json().get("access_token") - - # User anlegen (disabled) + # User anlegen (disabled, ohne Passwort, mit required action) create_resp = await client.post( - "https://sso.toppyr.de/admin/realms/collaboration/users", + f"{settings.keycloak_url}/admin/realms/{settings.keycloak_realm}/users", headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"}, json={ "username": username, @@ -484,7 +1006,8 @@ async def auth_register( "firstName": firstName, "lastName": lastName, "enabled": False, - "credentials": [{"type": "password", "value": password, "temporary": False}], + "emailVerified": True, + "requiredActions": ["UPDATE_PASSWORD"], }, ) if create_resp.status_code == 409: @@ -492,22 +1015,17 @@ async def auth_register( if create_resp.status_code != 201: raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen") - return {"status": "pending_approval", "message": "Registrierung eingegangen. Ein Administrator wird Ihren Account freischalten."} + return {"status": "pending_approval", "message": "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen."} @app.get("/api/auth/pending-users") async def auth_pending_users(user: dict = Depends(require_admin)): """Liste nicht-freigeschalteter User (Admin-only).""" import httpx as _httpx + admin_token = await keycloak_admin_token() async with _httpx.AsyncClient(timeout=10) as client: - token_resp = await client.post( - "https://sso.toppyr.de/realms/master/protocol/openid-connect/token", - data={"grant_type": "password", "client_id": "admin-cli", - "username": "admin", "password": "J915vI2Ankf7SdmEqe0BC5Aq"}, - ) - admin_token = token_resp.json().get("access_token") resp = await client.get( - "https://sso.toppyr.de/admin/realms/collaboration/users?enabled=false&max=50", + f"{settings.keycloak_url}/admin/realms/{settings.keycloak_realm}/users?enabled=false&max=50", headers={"Authorization": f"Bearer {admin_token}"}, ) users = resp.json() if resp.status_code == 200 else [] @@ -522,23 +1040,33 @@ async def auth_approve_user( user_id: str = Form(...), user: dict = Depends(require_admin), ): - """User freischalten (Admin-only).""" + """User freischalten (Admin-only). + + Aktiviert den User und triggert eine Keycloak-E-Mail zum Passwort setzen. + Voraussetzung: Keycloak Realm hat SMTP konfiguriert (Realm Settings → Email). + """ import httpx as _httpx + admin_token = await keycloak_admin_token() async with _httpx.AsyncClient(timeout=10) as client: - token_resp = await client.post( - "https://sso.toppyr.de/realms/master/protocol/openid-connect/token", - data={"grant_type": "password", "client_id": "admin-cli", - "username": "admin", "password": "J915vI2Ankf7SdmEqe0BC5Aq"}, + headers = {"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"} + base = f"{settings.keycloak_url}/admin/realms/{settings.keycloak_realm}/users/{user_id}" + + # 1. User aktivieren + resp = await client.put(base, headers=headers, json={"enabled": True}) + if resp.status_code != 204: + raise HTTPException(status_code=500, detail="Freischaltung fehlgeschlagen") + + # 2. Passwort-Setzen-E-Mail senden (Keycloak execute-actions-email) + email_resp = await client.put( + f"{base}/execute-actions-email", headers=headers, json=["UPDATE_PASSWORD"], ) - admin_token = token_resp.json().get("access_token") - resp = await client.put( - f"https://sso.toppyr.de/admin/realms/collaboration/users/{user_id}", - headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"}, - json={"enabled": True}, - ) - if resp.status_code == 204: - return {"status": "approved", "user_id": user_id} - raise HTTPException(status_code=500, detail="Freischaltung fehlgeschlagen") + email_sent = email_resp.status_code == 204 + + return { + "status": "approved", + "user_id": user_id, + "password_email_sent": email_sent, + } # API: Load assessments from database @@ -550,7 +1078,9 @@ async def list_assessments(bundesland: Optional[str] = None): """ rows = await get_all_assessments(bundesland) - # Convert DB format to frontend format + # Lightweight list format — nur Felder die für die Listenansicht nötig sind. + # Detail-Daten (Matrix, Scores, Verbesserungen) werden on-demand via + # GET /api/assessment?drucksache=X geladen (#122 Speicheroptimierung). assessments = [] for row in rows: assessments.append({ @@ -561,22 +1091,11 @@ async def list_assessments(bundesland: Optional[str] = None): "link": row.get("link"), "bundesland": row.get("bundesland"), "gwoeScore": row.get("gwoe_score"), - "gwoeBegründung": row.get("gwoe_begruendung"), - "gwoeMatrix": row.get("gwoe_matrix", []), - "gwoeSchwerpunkt": row.get("gwoe_schwerpunkt", []), - "wahlprogrammScores": row.get("wahlprogramm_scores", []), - "verbesserungen": row.get("verbesserungen", []), - "stärken": row.get("staerken", []), - "schwächen": row.get("schwaechen", []), "empfehlung": row.get("empfehlung"), "empfehlungSymbol": row.get("empfehlung_symbol"), - "verbesserungspotenzial": row.get("verbesserungspotenzial"), "themen": row.get("themen", []), - "antragZusammenfassung": row.get("antrag_zusammenfassung"), - "antragKernpunkte": row.get("antrag_kernpunkte", []), "updatedAt": row.get("updated_at"), - "source": row.get("source"), - "model": row.get("model"), + "konfidenz": row.get("konfidenz"), }) return assessments @@ -615,6 +1134,8 @@ async def get_single_assessment(drucksache: str): "updatedAt": row.get("updated_at"), "source": row.get("source"), "model": row.get("model"), + "konfidenz": row.get("konfidenz"), + "fehlendeProgramme": row.get("fehlende_programme") or [], } @@ -743,7 +1264,7 @@ async def search_landtag( return {"error": f"Bundesland {bundesland} noch nicht unterstützt"} try: - external = await adapter.search(q, limit) + external = adapter._filter_abstimmbar(await adapter.search(q, limit)) results = [] for doc in external: results.append({ @@ -754,6 +1275,7 @@ async def search_landtag( "link": doc.link, "bundesland": bundesland, "typ": doc.typ, + "typ_normiert": doc.typ_normiert, "gwoeScore": None, "status": "unchecked", }) @@ -786,10 +1308,8 @@ async def batch_analyze( if not adapter: raise HTTPException(status_code=400, detail=f"Bundesland {bundesland} nicht unterstützt") - # Neueste Drucksachen vom Landtag holen. Multiplier 10× weil die - # meisten Adapter Anfragen+Gesetzentwürfe+Anträge gemischt liefern - # und der Antrag-Anteil nur ~10-30% ist. - drucksachen = await adapter.search("", limit=limit * 10) + # Neueste Drucksachen vom Landtag holen, gefiltert auf abstimmbare Typen (#127). + drucksachen = adapter._filter_abstimmbar(await adapter.search("", limit=limit * 10)) enqueued = [] skipped = 0 @@ -862,10 +1382,32 @@ async def analyze_drucksache( text = await adapter.download_text(drucksache) if not text: raise HTTPException(status_code=404, detail=f"Dokument {drucksache} nicht gefunden") - + # Get document metadata doc = await adapter.get_document(drucksache) - + + # #127: Typ-Check — nur abstimmbare Drucksachen analysieren. + # Falls der Adapter den Typ nicht richtig setzt (NRW: "Drucksache"), + # versuche den Typ aus dem Dokument-Text zu erkennen. + from .drucksache_typen import ist_abstimmbar_original, normalize_typ + doc_typ = doc.typ if doc else "Drucksache" + if doc_typ in ("Drucksache", ""): + for kw in ["Kleine Anfrage", "Große Anfrage", "Gesetzentwurf", + "Änderungsantrag", "Entschließungsantrag", + "Dringlichkeitsantrag", "Beschlussempfehlung", + "Unterrichtung", "Antrag"]: + if kw in text[:500]: + doc_typ = kw + break + if not ist_abstimmbar_original(doc_typ): + return { + "status": "skipped", + "drucksache": drucksache, + "typ": doc_typ, + "typ_normiert": normalize_typ(doc_typ), + "reason": f"Typ '{doc_typ}' ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll", + } + # Create job and enqueue (#95) from .queue import enqueue, QueueFullError job_id = str(uuid.uuid4()) @@ -935,10 +1477,65 @@ async def run_drucksache_analysis( "themen": assessment.themen, "antragZusammenfassung": assessment.antrag_zusammenfassung, "antragKernpunkte": assessment.antrag_kernpunkte, + "konfidenz": getattr(assessment, 'konfidenz', None), + "share_threads": getattr(assessment, 'share_threads', None), + "share_twitter": getattr(assessment, 'share_twitter', None), + "share_mastodon": getattr(assessment, 'share_mastodon', None), + "fehlendeProgramme": getattr(assessment, 'fehlende_programme', None) or [], "source": "webapp", "model": model, } - + + # #123: Assessment-Embedding für Clustering/Ähnlichkeit erzeugen. + # Fällt bei API-Fehler auf (None, None) zurück — Backfill-Script + # zieht nach. Kein Show-Stopper für das Assessment selbst. + from .embeddings import create_assessment_embedding + emb_blob, emb_model = create_assessment_embedding( + title=assessment_data["title"], + zusammenfassung=assessment.antrag_zusammenfassung, + themen=assessment.themen, + bundesland=bundesland, + ) + assessment_data["summary_embedding"] = emb_blob + assessment_data["embedding_model"] = emb_model + + # #133: Social-Media-Texte separat generieren (schneller, zuverlässiger + # als sie im Haupt-Prompt unterzubringen — Qwen ignoriert sie dort) + try: + from openai import OpenAI + social_client = OpenAI( + api_key=settings.dashscope_api_key, + base_url=settings.dashscope_base_url, + ) + social_prompt = ( + f"Generiere Social-Media-Posts für diesen Parlamentsantrag:\n\n" + f"Titel: {assessment_data['title']}\n" + f"GWÖ-Score: {assessment_data['gwoeScore']}/10\n" + f"Empfehlung: {assessment_data.get('empfehlung', '')}\n" + f"Fraktionen: {', '.join(assessment_data.get('fraktionen', []))}\n" + f"Themen: {', '.join(assessment_data.get('themen', []))}\n" + f"Bundesland: {bundesland}\n" + f"Zusammenfassung: {(assessment.antrag_zusammenfassung or '')[:300]}\n\n" + f"Antworte NUR mit JSON:\n" + f'{{"shareThreads": "Post für Threads/Instagram (max 500 Zeichen, Emojis, konkret auf den Antrag, CTA, Hashtags #Gemeinwohl #GWÖ + 2 thematische)",' + f' "shareTwitter": "Tweet für X/Twitter (max 280 Zeichen, knackig, pointiert, 2 Hashtags)",' + f' "shareMastodon": "Post für Mastodon (max 500 Zeichen, sachlich-informativ, Kontext, Quellenhinweis)"}}' + ) + social_resp = social_client.chat.completions.create( + model="qwen-plus-latest", + messages=[{"role": "user", "content": social_prompt}], + temperature=0.7, + response_format={"type": "json_object"}, + ) + import json as _json + social_data = _json.loads(social_resp.choices[0].message.content) + assessment_data["share_threads"] = social_data.get("shareThreads", "") + assessment_data["share_twitter"] = social_data.get("shareTwitter", "") + assessment_data["share_mastodon"] = social_data.get("shareMastodon", "") + logger.info("Social-Texte generiert für %s", drucksache) + except Exception: + logger.exception("Social-Text-Generierung fehlgeschlagen für %s", drucksache) + # Save to DB await upsert_assessment(assessment_data) @@ -988,6 +1585,24 @@ async def list_bundeslaender(): return out +# === Impressum / Datenschutz === + +@app.get("/impressum", response_class=HTMLResponse) +async def impressum_page(request: Request): + return templates.TemplateResponse("v2/screens/legal.html", { + "request": request, "app_name": settings.app_name, + "title": "Impressum", "section": "impressum", + }) + + +@app.get("/datenschutz", response_class=HTMLResponse) +async def datenschutz_page(request: Request): + return templates.TemplateResponse("v2/screens/legal.html", { + "request": request, "app_name": settings.app_name, + "title": "Datenschutzerklärung", "section": "datenschutz", + }) + + # === Quellen / Programme === @app.get("/methodik", response_class=HTMLResponse) @@ -1006,11 +1621,12 @@ async def methodik_page(request: Request): status = get_indexing_status() - return templates.TemplateResponse("methodik.html", { + return templates.TemplateResponse("v2/screens/methodik.html", { "request": request, "app_name": settings.app_name, "adapter_count": len(ADAPTERS), "model_name": settings.llm_model_default, + "embedding_model": settings.embedding_model_read, "programme_count": status.get("total", 0), "chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), @@ -1038,7 +1654,7 @@ async def quellen_page(request: Request): # Sortieren: alphabetisch nach BL-Name wahlprogramme_grouped = sorted(by_bl.items()) - return templates.TemplateResponse("quellen.html", { + return templates.TemplateResponse("v2/screens/quellen.html", { "request": request, "app_name": settings.app_name, "programmes": programmes, @@ -1235,20 +1851,30 @@ async def index_programme( @app.get("/auswertungen", response_class=HTMLResponse) async def auswertungen_page(request: Request): - """Statische Seite, die die Matrix-Endpoints per fetch() lädt.""" - from .wahlperioden import all_wahlperioden - return templates.TemplateResponse("auswertungen.html", { + """Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic).""" + from .auswertungen import get_wahlperioden + from .bundeslaender import alle_bundeslaender + + wahlperioden = get_wahlperioden() + bl_codes = sorted(bl.code for bl in alle_bundeslaender() if bl.aktiv) + + return templates.TemplateResponse("v2/screens/auswertungen.html", { "request": request, "app_name": settings.app_name, - "wahlperioden": sorted(all_wahlperioden()), + "v2_active_nav": "auswertungen", + "wahlperioden": wahlperioden, + "bl_codes": bl_codes, }) @app.get("/api/auswertungen/matrix") -async def auswertungen_matrix(wahlperiode: Optional[str] = None): +async def auswertungen_matrix( + wahlperiode: Optional[str] = None, + bundesland: Optional[str] = None, +): """2D-Matrix Bundesland × Partei mit Anzahl + Ø-GWÖ-Score.""" from .auswertungen import aggregate_matrix - return aggregate_matrix(filter_wp=wahlperiode) + return aggregate_matrix(filter_wp=wahlperiode, filter_bl=bundesland) @app.get("/api/auswertungen/zeitreihe") @@ -1258,6 +1884,261 @@ async def auswertungen_zeitreihe(bundesland: str, partei: str): return aggregate_zeitreihe(bundesland, partei) +@app.get("/api/auswertungen/themen-matrix") +async def auswertungen_themen_matrix(min_count: int = 3, bundesland: Optional[str] = None): + """Thema × Fraktion Heatmap (#105 Integration in Auswertungen). + + Zeigt die Ø-GWÖ-Scores pro Thema und Fraktion. Nur Themen mit + mindestens `min_count` Assessments werden angezeigt. Optional auf + ein Bundesland einschränken. + """ + import json as _json + from collections import Counter, defaultdict + from .parteien import normalize_partei + + rows = await get_all_assessments(None) + + heatmap: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list)) + thema_counts: Counter = Counter() + + for row in rows: + if bundesland is not None and row.get("bundesland") != bundesland: + continue + fraktionen = row.get("fraktionen") or [] + themen = row.get("themen") or [] + score = row.get("gwoe_score") + if score is None: + continue + for thema in themen[:3]: + thema_counts[thema] += 1 + for frak in fraktionen: + # Normalisiere Fraktion (AfD/AFD, LINKE/DIE LINKE) + norm = normalize_partei(frak) or frak + heatmap[thema][norm].append(score) + + # Top-Themen filtern + top_themen = [t for t, n in thema_counts.most_common(20) if n >= min_count] + + # Alle vorkommenden Fraktionen sammeln + all_fraktionen = sorted({f for t in top_themen for f in heatmap[t]}) + + # Matrix bauen + cells = {} + for thema in top_themen: + cells[thema] = {} + for frak in all_fraktionen: + scores = heatmap[thema].get(frak, []) + if scores: + cells[thema][frak] = { + "avg": round(sum(scores) / len(scores), 1), + "n": len(scores), + } + + return { + "themen": top_themen, + "fraktionen": all_fraktionen, + "cells": cells, + "total": len(rows), + } + + +@app.get("/api/auswertungen/export.json") +async def auswertungen_export_json(): + """Open-Data-JSON-Export aller Assessments (#113). + + Vollständiger Download aller Bewertungen als JSON mit Metadaten. + Lizenz: CC BY 4.0 (öffentliche parlamentarische Dokumente + KI-Analyse). + """ + from datetime import datetime + rows = await get_all_assessments(None) + assessments = [] + for row in rows: + assessments.append({ + "drucksache": row.get("drucksache"), + "title": row.get("title"), + "fraktionen": row.get("fraktionen", []), + "datum": row.get("datum"), + "link": row.get("link"), + "bundesland": row.get("bundesland"), + "gwoe_score": row.get("gwoe_score"), + "gwoe_begruendung": row.get("gwoe_begruendung"), + "gwoe_matrix": row.get("gwoe_matrix", []), + "gwoe_schwerpunkt": row.get("gwoe_schwerpunkt", []), + "wahlprogramm_scores": row.get("wahlprogramm_scores", []), + "verbesserungen": row.get("verbesserungen", []), + "staerken": row.get("staerken", []), + "schwaechen": row.get("schwaechen", []), + "empfehlung": row.get("empfehlung"), + "themen": row.get("themen", []), + "antrag_zusammenfassung": row.get("antrag_zusammenfassung"), + "antrag_kernpunkte": row.get("antrag_kernpunkte", []), + "model": row.get("model"), + "updated_at": row.get("updated_at"), + }) + payload = { + "meta": { + "name": "GWÖ-Antragsprüfer Open Data", + "description": "Automatische Gemeinwohl-Bilanzierung von Parlamentsanträgen nach der GWÖ-Matrix 2.0", + "url": "https://gwoe.toppyr.de", + "license": "CC BY 4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "exported_at": datetime.utcnow().isoformat() + "Z", + "count": len(assessments), + }, + "assessments": assessments, + } + import json as _json + content = _json.dumps(payload, ensure_ascii=False, indent=2) + return Response( + content=content, + media_type="application/json", + headers={ + "Content-Disposition": 'attachment; filename="gwoe-assessments.json"', + "Access-Control-Allow-Origin": "*", + }, + ) + + +@app.get("/api/clusters") +async def clusters_api(bundesland: Optional[str] = None, threshold: Optional[float] = None): + """Antrag-Cluster (#105) per Cosine-Similarity über v4-Embeddings. + + Wenn threshold nicht angegeben, nutze den Default aus clustering.py + (0.55, empirisch kalibriert für die aktuelle Prod-DB). + """ + from .clustering import build_hierarchy, DEFAULT_THRESHOLD + return await build_hierarchy( + bundesland=bundesland, + threshold=threshold if threshold is not None else DEFAULT_THRESHOLD, + ) + + +@app.get("/api/assessment/similar") +async def assessment_similar(drucksache: str, top_k: int = 5): + """Ähnliche Anträge zum gegebenen (#108 Teil B).""" + from .clustering import find_similar_assessments + return await find_similar_assessments(drucksache=drucksache, top_k=min(max(1, top_k), 20)) + + +@app.get("/api/feed.xml") +async def feed_xml(request: Request, bundesland: Optional[str] = None, partei: Optional[str] = None, limit: int = 50): + """Atom 1.0 Feed der neuesten Bewertungen (Issue #125). + + Query-Parameter: + - bundesland: optionaler BL-Code (NRW, MV, BE, …) + - partei: optionaler Partei-Filter (CDU, SPD, GRÜNE, …) — matcht gegen fraktionen-Liste + - limit: Anzahl Einträge (default 50, max 200) + """ + from datetime import datetime, timezone + import hashlib + import html + + limit = min(max(1, limit), 200) + rows = await get_all_assessments(bundesland if bundesland else None) + + if partei: + partei_norm = partei.upper() + rows = [r for r in rows if any(partei_norm in (f or "").upper() for f in (r.get("fraktionen") or []))] + + rows.sort(key=lambda r: r.get("updated_at") or "", reverse=True) + rows = rows[:limit] + + base_url = "https://gwoe.toppyr.de" + feed_id_parts = ["gwoe-feed"] + if bundesland: + feed_id_parts.append(bundesland) + if partei: + feed_id_parts.append(partei) + feed_id = "urn:" + ":".join(feed_id_parts) + + title_parts = ["GWÖ-Antragsprüfer — Neue Bewertungen"] + if bundesland: + title_parts.append(bundesland) + if partei: + title_parts.append(partei) + feed_title = " · ".join(title_parts) + + latest_updated = rows[0].get("updated_at") if rows else datetime.utcnow().isoformat() + "Z" + if latest_updated and not latest_updated.endswith("Z"): + latest_updated = latest_updated + "Z" + + etag_src = f"{len(rows)}:{latest_updated}:{bundesland}:{partei}" + etag = '"' + hashlib.md5(etag_src.encode()).hexdigest() + '"' + + if request.headers.get("if-none-match") == etag: + return Response(status_code=304) + + def _entry(row): + drucksache = row.get("drucksache") or "" + title = html.escape(row.get("title") or drucksache) + score = row.get("gwoe_score") + empfehlung = row.get("empfehlung") or "" + zusammenfassung = row.get("antrag_zusammenfassung") or "" + fraktionen = ", ".join(row.get("fraktionen") or []) + bl = row.get("bundesland") or "" + updated = row.get("updated_at") or "" + if updated and not updated.endswith("Z"): + updated += "Z" + + detail_url = f"{base_url}/?drucksache={drucksache}" + entry_id = f"urn:gwoe:{bl}:{drucksache}" + + summary_parts = [] + if score is not None: + summary_parts.append(f"GWÖ-Score: {score}/10") + if empfehlung: + summary_parts.append(f"Empfehlung: {empfehlung}") + if fraktionen: + summary_parts.append(f"Fraktionen: {fraktionen}") + if zusammenfassung: + summary_parts.append(zusammenfassung) + summary = html.escape(" — ".join(summary_parts)) + + return f""" + {html.escape(entry_id)} + {title} + + {html.escape(updated)} + {summary} + + """ + + entries_xml = "\n".join(_entry(r) for r in rows) + + self_url = f"{base_url}/api/feed.xml" + if bundesland or partei: + params = [] + if bundesland: + params.append(f"bundesland={bundesland}") + if partei: + params.append(f"partei={partei}") + self_url += "?" + "&".join(params) + + xml = f""" + + {html.escape(feed_id)} + {html.escape(feed_title)} + Automatische Gemeinwohl-Bilanzierung von Parlamentsanträgen + + + {html.escape(latest_updated)} + GWÖ-Antragsprüfer + CC BY 4.0 +{entries_xml} + +""" + + return Response( + content=xml, + media_type="application/atom+xml; charset=utf-8", + headers={ + "ETag": etag, + "Cache-Control": "public, max-age=600", + "Access-Control-Allow-Origin": "*", + }, + ) + + @app.get("/api/auswertungen/export.csv") async def auswertungen_export_csv(): """Long-Format-CSV-Export aller Assessments. Deckt #45 mit ab.""" @@ -1270,6 +2151,268 @@ async def auswertungen_export_csv(): ) +# ─── v2 Frontend (#139 Phase 2 + Phase 3) ─────────────────────────────────── +# / ist jetzt Default-v2. /v2 leitet auf / weiter; /v2/antrag/* auf /antrag/*. +# /classic ist die alte Ansicht (index.html unverändert). +# Phase 3: /v2/merkliste, /v2/tags, /v2/cluster, /v2/neu, /v2/batch + +@app.get("/v2", response_class=HTMLResponse) +async def v2_redirect(request: Request): + """Redirect /v2 → / (v2 ist jetzt Default unter /).""" + from fastapi.responses import RedirectResponse + return RedirectResponse("/", status_code=301) + + +@app.get("/v2/antrag/{drucksache:path}", response_class=HTMLResponse) +async def v2_antrag_redirect(request: Request, drucksache: str): + """Redirect /v2/antrag/XX → /antrag/XX (kanonische URL).""" + from fastapi.responses import RedirectResponse + return RedirectResponse(f"/antrag/{drucksache}", status_code=301) + + +@app.get("/v2/merkliste", response_class=HTMLResponse) +async def v2_merkliste(request: Request): + """Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig.""" + return templates.TemplateResponse("v2/screens/merkliste.html", { + "request": request, + "v2_active_nav": "merkliste", + }) + + +@app.get("/v2/tags", response_class=HTMLResponse) +async def v2_tags(request: Request): + """Tag-Cloud-Seite — Themen-Filter über alle Assessments.""" + return templates.TemplateResponse("v2/screens/tags.html", { + "request": request, + "v2_active_nav": "tags", + }) + + +@app.get("/v2/cluster", response_class=HTMLResponse) +async def v2_cluster(request: Request): + """Cluster-Liste — Top-10 Cluster als redaktionelle Liste.""" + rows = await get_all_assessments(None) + assessments = _rows_to_list(rows) + bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")}) + return templates.TemplateResponse("v2/screens/cluster.html", { + "request": request, + "v2_active_nav": "cluster", + "bl_codes": bl_codes, + }) + + +@app.get("/v2/neu", response_class=HTMLResponse) +async def v2_neu(request: Request): + """Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache.""" + from .bundeslaender import alle_bundeslaender + bl_list = [ + {"code": bl.code, "name": bl.name} + for bl in alle_bundeslaender() + if bl.aktiv + ] + return templates.TemplateResponse("v2/screens/neu.html", { + "request": request, + "v2_active_nav": "neu", + "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + "default_model": settings.llm_model_default, + }) + + +@app.get("/v2/landtag-suche", response_class=HTMLResponse) +async def v2_landtag_suche(request: Request): + """Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB).""" + from .bundeslaender import alle_bundeslaender + bl_list = [ + {"code": bl.code, "name": bl.name} + for bl in alle_bundeslaender() + if bl.aktiv + ] + return templates.TemplateResponse("v2/screens/landtag_suche.html", { + "request": request, + "v2_active_nav": "landtag_suche", + "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + }) + + +@app.get("/v2/batch", response_class=HTMLResponse) +async def v2_batch(request: Request): + """Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL.""" + from .bundeslaender import alle_bundeslaender + bl_list = [ + {"code": bl.code, "name": bl.name} + for bl in alle_bundeslaender() + if bl.aktiv + ] + return templates.TemplateResponse("v2/screens/batch.html", { + "request": request, + "v2_active_nav": "batch", + "bundeslaender": sorted(bl_list, key=lambda x: x["name"]), + }) + + +# ─── v2 Admin-Screens ──────────────────────────────────────────────────────── + +@app.get("/v2/admin/freischaltungen", response_class=HTMLResponse) +async def v2_admin_freischaltungen(request: Request, user: dict = Depends(require_admin)): + """Ausstehende User-Freischaltungen (Admin).""" + return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", { + "request": request, + "v2_active_nav": "admin_freischaltungen", + "is_admin": True, + }) + + +@app.get("/v2/admin/queue", response_class=HTMLResponse) +async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)): + """Queue-Status-Übersicht (Admin).""" + return templates.TemplateResponse("v2/screens/admin_queue.html", { + "request": request, + "v2_active_nav": "admin_queue", + "is_admin": True, + }) + + +@app.get("/v2/admin/abos", response_class=HTMLResponse) +async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)): + """Abo-Verwaltung — alle E-Mail-Abonnements (Admin).""" + return templates.TemplateResponse("v2/screens/admin_abos.html", { + "request": request, + "v2_active_nav": "admin_abos", + "is_admin": True, + }) + + +# ─── #141 Open-Graph-Karten ────────────────────────────────────────────────── + +@app.get("/v2/og-template", response_class=HTMLResponse) +async def og_template(request: Request, drucksache: str = ""): + """Internes Render-Template für Playwright (#141). + + Wird von render_og_card() intern aufgerufen — nicht für Endnutzer. + """ + antrag = None + if drucksache: + try: + drucksache = validate_drucksache(drucksache) + row = await get_assessment(drucksache) + if row: + antrag = _row_to_detail(row) + except Exception: + pass + return templates.TemplateResponse("v2/og_template.html", { + "request": request, + "antrag": antrag, + }) + + +@app.get("/api/og/{drucksache_encoded}.png") +async def api_og_card(drucksache_encoded: str, request: Request): + """Liefert die Open-Graph-PNG-Karte für einen Antrag (#141). + + Cache-Hit → direkte Datei-Response. + Cache-Miss → Playwright-Render (synchron, blockiert kurz). + """ + import urllib.parse + from .og_card import render_og_card, get_cached + from .config import settings + + drucksache = urllib.parse.unquote(drucksache_encoded) + try: + drucksache = validate_drucksache(drucksache) + except Exception: + raise HTTPException(status_code=400, detail="Ungültige Drucksachen-ID") + + row = await get_assessment(drucksache) + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + + updated_at = row.get("updated_at", "") + og_cache_dir = settings.data_dir / "og-cache" + + cached_path = get_cached(drucksache, updated_at, og_cache_dir) + if cached_path: + return FileResponse(str(cached_path), media_type="image/png") + + # Interne URL: der laufende Server selbst (Playwright greift loopback an) + base_url = f"http://127.0.0.1:{settings.port}" + png_bytes = render_og_card(drucksache, updated_at, base_url, og_cache_dir) + if not png_bytes: + raise HTTPException(status_code=500, detail="OG-Render fehlgeschlagen") + + return Response(content=png_bytes, media_type="image/png") + + +# ─── #138 Admin: Wahlprogramm-Beschaffung ──────────────────────────────────── + +@app.get("/v2/admin/wahlprogramme", response_class=HTMLResponse) +async def v2_admin_wahlprogramme(request: Request, user: dict = Depends(require_admin)): + """Admin-Übersicht fehlender Wahlprogramme mit Kandidaten-URLs (#138).""" + from .wahlprogramm_fetch import get_missing_programmes + missing = get_missing_programmes() + return templates.TemplateResponse("v2/screens/admin_wahlprogramme.html", { + "request": request, + "v2_active_nav": "admin_wahlprogramme", + "is_admin": True, + "missing": missing, + }) + + +@app.post("/api/admin/wahlprogramm-fetch") +async def api_admin_wahlprogramm_fetch( + request: Request, + user: dict = Depends(require_admin), +): + """Lädt ein Wahlprogramm von einer angegebenen URL (#138). + + Body: JSON { "bl": "NRW", "partei": "BSW", "url": "https://..." } + SHA-Gate: Wenn eine Datei bereits vorhanden ist und der SHA abweicht, + wird sie nicht überschrieben — stattdessen wird ein 409-Fehler zurückgegeben. + """ + from .wahlprogramm_fetch import fetch_and_verify, suggest_candidates + from .wahlprogramme import WAHLPROGRAMME + + body = await request.json() + bl = body.get("bl", "").strip().upper() + partei = body.get("partei", "").strip().upper() + url = body.get("url", "").strip() + expected_sha = body.get("expected_sha", None) + + if not bl or not partei: + raise HTTPException(status_code=400, detail="bl und partei sind Pflichtfelder") + + if not url: + candidates = suggest_candidates(bl, partei) + if not candidates: + raise HTTPException( + status_code=400, + detail=f"Keine URL angegeben und keine Kandidaten für {bl}/{partei} hinterlegt", + ) + url = candidates[0]["url"] + + wp_info = WAHLPROGRAMME.get(bl, {}).get(partei) + if wp_info: + from pathlib import Path as _Path + dest = _Path(__file__).parent / "static" / "referenzen" / wp_info["file"] + else: + from pathlib import Path as _Path + dest = _Path(__file__).parent / "static" / "referenzen" / f"{partei.lower()}-{bl.lower()}-neu.pdf" + + result = fetch_and_verify(url, dest, expected_sha) + + if not result["ok"]: + # SHA-Abweichung oder Download-Fehler → 409 damit kein stilles Überschreiben + status = 409 if "SHA" in (result["error"] or "") else 502 + raise HTTPException(status_code=status, detail=result["error"]) + + return JSONResponse({ + "ok": True, + "sha256": result["sha256"], + "prev_sha256": result["prev_sha256"], + "changed": result["changed"], + "dest": str(dest.name), + }) + + # Health check @app.get("/health") async def health(): diff --git a/app/og_card.py b/app/og_card.py new file mode 100644 index 0000000..71e59a6 --- /dev/null +++ b/app/og_card.py @@ -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 diff --git a/app/queue.py b/app/queue.py index 23cec98..33ea4ff 100644 --- a/app/queue.py +++ b/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. - 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,), + 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() + marked_stale += 1 + continue + + # 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, ) - await db.commit() - re_enqueued += 1 + re_enqueued += 1 + logger.info("Re-enqueued %s (%s) at position %d", drucksache, bundesland, position) - logger.info("Marked %d jobs as stale (re-trigger via UI)", re_enqueued) + 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) diff --git a/app/static/v2/fonts.css b/app/static/v2/fonts.css new file mode 100644 index 0000000..3d96fdf --- /dev/null +++ b/app/static/v2/fonts.css @@ -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%; +} diff --git a/app/static/v2/fonts/nunito-sans-italic-latin.woff2 b/app/static/v2/fonts/nunito-sans-italic-latin.woff2 new file mode 100644 index 0000000..10a8518 Binary files /dev/null and b/app/static/v2/fonts/nunito-sans-italic-latin.woff2 differ diff --git a/app/static/v2/fonts/nunito-sans-latin-variable.woff2 b/app/static/v2/fonts/nunito-sans-latin-variable.woff2 new file mode 100644 index 0000000..7dba503 Binary files /dev/null and b/app/static/v2/fonts/nunito-sans-latin-variable.woff2 differ diff --git a/app/static/v2/icons/phosphor/arrow-square-out.svg b/app/static/v2/icons/phosphor/arrow-square-out.svg new file mode 100644 index 0000000..e46b338 --- /dev/null +++ b/app/static/v2/icons/phosphor/arrow-square-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/book-open.svg b/app/static/v2/icons/phosphor/book-open.svg new file mode 100644 index 0000000..2909321 --- /dev/null +++ b/app/static/v2/icons/phosphor/book-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/bookmark-simple.svg b/app/static/v2/icons/phosphor/bookmark-simple.svg new file mode 100644 index 0000000..8429ae9 --- /dev/null +++ b/app/static/v2/icons/phosphor/bookmark-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/chart-bar.svg b/app/static/v2/icons/phosphor/chart-bar.svg new file mode 100644 index 0000000..02451a0 --- /dev/null +++ b/app/static/v2/icons/phosphor/chart-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/circle-half.svg b/app/static/v2/icons/phosphor/circle-half.svg new file mode 100644 index 0000000..7c8d2d4 --- /dev/null +++ b/app/static/v2/icons/phosphor/circle-half.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/envelope-simple.svg b/app/static/v2/icons/phosphor/envelope-simple.svg new file mode 100644 index 0000000..f77d510 --- /dev/null +++ b/app/static/v2/icons/phosphor/envelope-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/file-csv.svg b/app/static/v2/icons/phosphor/file-csv.svg new file mode 100644 index 0000000..e418f5f --- /dev/null +++ b/app/static/v2/icons/phosphor/file-csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/file-plus.svg b/app/static/v2/icons/phosphor/file-plus.svg new file mode 100644 index 0000000..8e27171 --- /dev/null +++ b/app/static/v2/icons/phosphor/file-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/graph.svg b/app/static/v2/icons/phosphor/graph.svg new file mode 100644 index 0000000..350b0bd --- /dev/null +++ b/app/static/v2/icons/phosphor/graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/info.svg b/app/static/v2/icons/phosphor/info.svg new file mode 100644 index 0000000..96369e2 --- /dev/null +++ b/app/static/v2/icons/phosphor/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/key.svg b/app/static/v2/icons/phosphor/key.svg new file mode 100644 index 0000000..5aaa6be --- /dev/null +++ b/app/static/v2/icons/phosphor/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/list-checks.svg b/app/static/v2/icons/phosphor/list-checks.svg new file mode 100644 index 0000000..9d1c071 --- /dev/null +++ b/app/static/v2/icons/phosphor/list-checks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/magnifying-glass-plus.svg b/app/static/v2/icons/phosphor/magnifying-glass-plus.svg new file mode 100644 index 0000000..73f0bd5 --- /dev/null +++ b/app/static/v2/icons/phosphor/magnifying-glass-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/magnifying-glass.svg b/app/static/v2/icons/phosphor/magnifying-glass.svg new file mode 100644 index 0000000..2bdda58 --- /dev/null +++ b/app/static/v2/icons/phosphor/magnifying-glass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/moon.svg b/app/static/v2/icons/phosphor/moon.svg new file mode 100644 index 0000000..20fca63 --- /dev/null +++ b/app/static/v2/icons/phosphor/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/rss.svg b/app/static/v2/icons/phosphor/rss.svg new file mode 100644 index 0000000..5c70e81 --- /dev/null +++ b/app/static/v2/icons/phosphor/rss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/sign-out.svg b/app/static/v2/icons/phosphor/sign-out.svg new file mode 100644 index 0000000..9890a7d --- /dev/null +++ b/app/static/v2/icons/phosphor/sign-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/stack.svg b/app/static/v2/icons/phosphor/stack.svg new file mode 100644 index 0000000..5325917 --- /dev/null +++ b/app/static/v2/icons/phosphor/stack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/sun.svg b/app/static/v2/icons/phosphor/sun.svg new file mode 100644 index 0000000..2a97d01 --- /dev/null +++ b/app/static/v2/icons/phosphor/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/tag.svg b/app/static/v2/icons/phosphor/tag.svg new file mode 100644 index 0000000..eecf8bd --- /dev/null +++ b/app/static/v2/icons/phosphor/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/user-check.svg b/app/static/v2/icons/phosphor/user-check.svg new file mode 100644 index 0000000..e6b69a5 --- /dev/null +++ b/app/static/v2/icons/phosphor/user-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/icons/phosphor/user.svg b/app/static/v2/icons/phosphor/user.svg new file mode 100644 index 0000000..d560c5d --- /dev/null +++ b/app/static/v2/icons/phosphor/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/v2/tokens.css b/app/static/v2/tokens.css new file mode 100644 index 0000000..fd86ff8 --- /dev/null +++ b/app/static/v2/tokens.css @@ -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; +} diff --git a/app/static/v2/v2.css b/app/static/v2/v2.css new file mode 100644 index 0000000..17be016 --- /dev/null +++ b/app/static/v2/v2.css @@ -0,0 +1,1048 @@ +/* + * v2.css — Base-Styles für GWÖ-Antragsprüfer v2 + * Alle Regeln leben unter dem Scope "body.v2" und kollidieren nicht + * mit dem bestehenden v1-Frontend. + * + * Abhängigkeiten: tokens.css (zuerst laden), fonts.css + */ + +/* ── Phosphor Icons ─────────────────────────────────────────────── */ +.v2-icon { + display: inline-flex; + flex-shrink: 0; + vertical-align: middle; +} +.v2-icon svg { + display: block; + width: 100%; + height: 100%; + fill: currentColor; +} +/* ── Reset & Box Model ──────────────────────────────────────────── */ +body.v2 *, body.v2 *::before, body.v2 *::after { + box-sizing: border-box; +} + +body.v2 { + margin: 0; + padding: 0; + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.55; + color: var(--ecg-dark); + background: var(--paper); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body.v2 a { + color: var(--ecg-blue); + text-decoration: none; + border-bottom: 1px solid rgba(0, 157, 165, 0.35); +} + +body.v2 a:hover { + border-bottom-color: var(--ecg-blue); +} + +body.v2 :focus-visible { + outline: 2px solid var(--ecg-blue); + outline-offset: 2px; +} + +/* ── App-Shell Layout ───────────────────────────────────────────── */ +.v2-shell { + display: grid; + grid-template-rows: auto 1fr auto; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-areas: + "sidebar topbar" + "sidebar main" + "sidebar footer"; + min-height: 100vh; +} + +.v2-topbar { + grid-area: topbar; + background: var(--paper); + border-bottom: 1px solid var(--hairline); + padding: 10px 24px; + display: flex; + align-items: center; + gap: var(--space-4); + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-dark); +} + +.v2-topbar-spacer { + flex: 1; +} + +.v2-topbar a { + color: var(--ecg-dark); + border-bottom: none; + opacity: 0.75; + letter-spacing: 0.06em; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.v2-topbar a:hover { + opacity: 1; + color: var(--ecg-blue); + border-bottom: none; +} + +.v2-topbar .v2-beta-link { + color: var(--ecg-green); + font-weight: 700; + opacity: 1; + border-bottom: 1px solid var(--ecg-green); +} + +/* ── Sidebar ────────────────────────────────────────────────────── */ +.v2-sidebar { + grid-area: sidebar; + width: var(--sidebar-width); + background: var(--paper); + border-right: 1px solid var(--hairline); + padding: 22px 18px; + overflow-y: auto; + position: sticky; + top: 0; + height: 100vh; +} + +.v2-brand { + font-family: var(--font-sans); + font-weight: 900; + font-size: 18px; + text-transform: uppercase; + color: var(--ecg-dark); + letter-spacing: 0; + line-height: 1.05; +} + +.v2-brand .grn { color: var(--ecg-green); } +.v2-brand .blu { color: var(--ecg-blue); } + +.v2-brand-sub { + font-family: var(--font-mono); + font-size: 10px; + color: var(--ecg-dark); + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 6px 0 26px; + opacity: 0.7; +} + +.v2-nav-group { + margin-bottom: 20px; +} + +.v2-nav-label { + font-family: var(--font-sans); + font-weight: 900; + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ecg-green); + margin-bottom: 6px; + padding: 0 4px; +} + +.v2-nav-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 7px 10px; + color: var(--ecg-dark); + font-size: 13px; + font-weight: 400; + position: relative; + cursor: pointer; + text-decoration: none; + border-bottom: none; +} + +.v2-nav-item:hover { + background: var(--surface); + border-bottom: none; +} + +.v2-nav-item.active { + background: var(--ecg-blue); + color: #fff; + font-weight: 900; +} + +.v2-nav-item.active:hover { + background: var(--ecg-blue); +} + +.v2-nav-count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-light); +} + +.v2-nav-item.active .v2-nav-count { + color: #fff; + opacity: 0.85; +} + +/* ── Main Content ───────────────────────────────────────────────── */ +.v2-main { + grid-area: main; + padding: 24px 28px; + background: var(--paper); + min-width: 0; +} + +/* ── Footer ─────────────────────────────────────────────────────── */ +.v2-footer { + grid-area: footer; + padding: 18px 24px; + border-top: 1px solid var(--hairline); + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-dark); + letter-spacing: 0.04em; + display: flex; + gap: 20px; + flex-wrap: wrap; + opacity: 0.75; + background: var(--paper); +} + +.v2-footer a { + color: var(--ecg-dark); + border-bottom: none; + opacity: 0.75; +} + +.v2-footer a:hover { + opacity: 1; + color: var(--ecg-blue); + border-bottom: none; +} + +/* ── Typografie-Skala (Manual) ──────────────────────────────────── */ +body.v2 .v2-title { + font-family: var(--font-sans); + font-weight: 900; + font-size: 52px; + line-height: 1.02; + letter-spacing: -0.005em; + text-transform: uppercase; + color: var(--ecg-dark); + margin: 0; +} + +body.v2 .v2-overtitle { + font-family: var(--font-sans); + font-weight: 300; + font-size: 22px; + text-transform: uppercase; + color: var(--ecg-dark); + letter-spacing: 0.01em; + margin: 4px 0 0; +} + +body.v2 h1, body.v2 .v2-h1 { + font-family: var(--font-sans); + font-weight: 900; + font-size: 26px; + line-height: 1.2; + margin: 0 0 8px; + text-transform: uppercase; + color: var(--ecg-dark); + letter-spacing: 0.005em; +} + +body.v2 h2, body.v2 .v2-h2 { + font-family: var(--font-sans); + font-weight: 900; + font-size: 18px; + line-height: 1.25; + margin: 0 0 8px; + color: var(--ecg-dark); +} + +body.v2 h3, body.v2 .v2-h3 { + font-family: var(--font-sans); + font-weight: 900; + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ecg-dark); + margin: 0 0 6px; +} + +body.v2 p { + margin: 0 0 14px; + max-width: 72ch; + text-wrap: pretty; +} + +body.v2 strong, body.v2 b { + font-weight: 900; + color: var(--ecg-dark); +} + +/* ── Toolbar / Filter-Row ───────────────────────────────────────── */ +.v2-toolbar { + display: flex; + gap: 8px; + align-items: center; + padding-bottom: 14px; + border-bottom: 1px solid var(--hairline); + margin-bottom: 14px; + flex-wrap: wrap; + position: sticky; + top: 0; + background: var(--paper); + z-index: 10; +} + +.v2-toolbar-sep { flex: 1; } + +.v2-search { + flex: 1; + min-width: 180px; + border: 1px solid var(--hairline); + padding: 8px 12px; + font-size: 13px; + color: var(--ecg-dark); + font-family: var(--font-sans); + background: var(--paper); + outline: none; +} + +.v2-search:focus { + border-color: var(--ecg-blue); +} + +/* ── Chip ───────────────────────────────────────────────────────── */ +.v2-chip { + border: 1px solid var(--hairline); + background: var(--paper); + padding: 5px 11px; + font-size: 12px; + color: var(--ecg-dark); + font-family: var(--font-sans); + font-weight: 400; + cursor: pointer; + white-space: nowrap; + display: inline-block; +} + +.v2-chip:hover { + border-color: var(--ecg-light); + background: var(--surface); +} + +.v2-chip.active, +.v2-chip[data-active="true"] { + background: var(--ecg-dark); + color: #fff; + border-color: var(--ecg-dark); + font-weight: 900; +} + +.v2-chip.green.active, +.v2-chip.green[data-active="true"] { + background: var(--ecg-green); + border-color: var(--ecg-green); + color: #fff; +} + +.v2-chip.dark.active, +.v2-chip.dark[data-active="true"] { + background: var(--ecg-dark); + border-color: var(--ecg-dark); + color: #fff; +} + +/* ── Result Row ─────────────────────────────────────────────────── */ +.v2-result-row { + display: grid; + grid-template-columns: 72px 1fr 140px 90px; + gap: 18px; + padding: 14px 4px; + border-bottom: 1px solid var(--hairline); + align-items: center; + cursor: pointer; + text-decoration: none; + color: inherit; +} + +.v2-result-row:hover { + background: var(--surface); +} + +.v2-score-cell { + font-family: var(--font-sans); + font-weight: 900; + font-size: 22px; + text-align: center; + padding: 8px 0; + line-height: 1; + letter-spacing: -0.02em; +} + +.v2-score-cell small { + display: block; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 500; + margin-top: 3px; + opacity: 0.75; +} + +.v2-score-cell.s-high { background: var(--score-high-bg); color: var(--score-high-fg); } +.v2-score-cell.s-mid { background: var(--score-mid-bg); color: var(--score-mid-fg); } +.v2-score-cell.s-low { background: var(--score-low-bg); color: var(--score-low-fg); } + +.v2-r-title { + font-family: var(--font-sans); + font-weight: 900; + font-size: 14.5px; + line-height: 1.3; + color: var(--ecg-dark); +} + +.v2-r-sub { + font-family: var(--font-sans); + font-size: 12px; + color: var(--ecg-dark); + margin-top: 4px; + opacity: 0.85; +} + +.v2-party-chip { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 6px; + border: 1px solid var(--hairline); + margin-right: 4px; + color: var(--ecg-dark); + letter-spacing: 0.04em; +} + +.v2-r-state { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-dark); + opacity: 0.8; +} + +.v2-r-date { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-dark); + text-align: right; + opacity: 0.75; +} + +/* ── Kasten (4 Varianten aus Manual Seite 13) ───────────────────── */ +.v2-kasten { + padding: 18px 22px; + margin: 14px 0; +} + +.v2-kasten.solid-green { background: var(--ecg-green); color: #fff; } +.v2-kasten.solid-blue { background: var(--ecg-blue); color: #fff; } +.v2-kasten.outline-green { border: 2px solid var(--ecg-green); color: var(--ecg-green); } +.v2-kasten.outline-blue { border: 2px solid var(--ecg-blue); color: var(--ecg-blue); } + +.v2-kasten h4 { color: inherit; margin: 0 0 6px; } +.v2-kasten p { color: inherit; margin: 0; max-width: none; } + +/* ── Score Hero ─────────────────────────────────────────────────── */ +.v2-score-hero { + display: flex; + align-items: baseline; + gap: 22px; + padding: 20px 0 22px; + border-top: 2px solid var(--ecg-green); + border-bottom: 1px solid var(--hairline); + margin: 4px 0 22px; +} + +.v2-score-hero.low { + border-top-color: var(--redline-contra); +} + +.v2-score-hero .big-num { + font-family: var(--font-sans); + font-weight: 900; + font-size: 68px; + line-height: 0.9; + color: var(--ecg-green); + letter-spacing: -0.02em; +} + +.v2-score-hero.low .big-num { + color: var(--redline-contra); +} + +.v2-score-hero .slash { + color: var(--ecg-light); + font-weight: 300; +} + +.v2-score-hero .verdict { + font-family: var(--font-sans); + font-size: 15px; + color: var(--ecg-dark); + max-width: 28ch; + line-height: 1.4; +} + +.v2-score-hero .verdict b { + color: var(--ecg-green); + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.02em; + display: block; + font-size: 12px; + margin-bottom: 3px; +} + +.v2-score-hero.low .verdict b { + color: var(--redline-contra); +} + +/* ── Matrix Mini (5×5) ──────────────────────────────────────────── */ +.v2-matrix-mini { + display: grid; + grid-template-columns: 92px repeat(5, 1fr); + gap: 0; + border: 1px solid var(--hairline); + font-size: 11px; + margin: 8px 0 4px; +} + +.v2-matrix-mini > div { + padding: 6px 8px; + border-right: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); + font-family: var(--font-mono); + text-align: center; + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; +} + +.v2-matrix-mini > div:nth-child(6n) { border-right: 0; } +.v2-matrix-mini > div:nth-last-child(-n+6) { border-bottom: 0; } + +.v2-matrix-mini .hdr { background: var(--ecg-blue); color: #fff; font-size: 10px; letter-spacing: 0.04em; font-weight: 700; } +.v2-matrix-mini .rhdr { background: var(--surface); text-align: left; justify-content: flex-start; padding-left: 10px; color: var(--ecg-dark); font-weight: 700; font-family: var(--font-sans); text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; } + +.v2-matrix-mini .m-pp { background: var(--ecg-green); color: #fff; font-weight: 700; } +.v2-matrix-mini .m-p { background: var(--redline-ins-bg); color: var(--ecg-dark); } +.v2-matrix-mini .m-0 { background: var(--paper); color: var(--ecg-light); } +.v2-matrix-mini .m-n { background: var(--redline-del-bg); color: var(--ecg-dark); } +.v2-matrix-mini .m-nn { background: var(--redline-contra); color: #fff; font-weight: 700; } + +.v2-matrix-legend { + font-family: var(--font-mono); + font-size: 10px; + color: var(--ecg-dark); + margin: 6px 0 0; + display: flex; + gap: 14px; + opacity: 0.85; + flex-wrap: wrap; +} + +/* ── Quote Card ─────────────────────────────────────────────────── */ +.v2-quote { + border-left: 3px solid var(--ecg-blue); + padding: 12px 16px; + background: var(--paper); + font-family: var(--font-sans); + font-weight: 400; + font-size: 14px; + color: var(--ecg-dark); + margin: 10px 0; + line-height: 1.5; +} + +.v2-quote.contra { + border-left-color: var(--redline-contra); +} + +.v2-quote .q-body { + font-style: italic; +} + +.v2-quote cite { + display: block; + font-family: var(--font-mono); + font-size: 10px; + font-style: normal; + color: var(--ecg-dark); + margin-top: 8px; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.85; +} + +.v2-quote .verified { + display: inline-block; + color: var(--ecg-green); + font-weight: 700; + margin-right: 8px; +} + +.v2-quote.contra .verified { + color: var(--redline-contra); +} + +/* ── Redline ────────────────────────────────────────────────────── */ +.v2-redline { + font-family: var(--font-mono); + font-size: 12.5px; + background: var(--surface); + padding: 12px 14px; + line-height: 1.6; + border-left: 3px solid var(--ecg-green); + color: var(--ecg-dark); + margin: 8px 0; +} + +.v2-redline .del { + background: var(--redline-del-bg); + text-decoration: line-through; + padding: 0 2px; +} + +.v2-redline .ins { + background: var(--redline-ins-bg); + padding: 0 2px; +} + +/* ── Detail Split Layout ────────────────────────────────────────── */ +.v2-detail { + display: grid; + grid-template-columns: 1.25fr 1fr; + gap: 0; + background: var(--paper); +} + +.v2-detail .left { + padding: 30px 34px 30px 0; + border-right: 1px solid var(--hairline); + min-width: 0; +} + +.v2-detail .right { + padding: 30px 0 30px 34px; + background: var(--surface); + min-width: 0; +} + +.v2-antrag-id { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ecg-dark); + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.85; + margin-bottom: 6px; +} + +.v2-big-title { + font-family: var(--font-sans); + font-weight: 900; + font-size: 24px; + line-height: 1.2; + margin: 6px 0 8px; + color: var(--ecg-dark); + text-transform: uppercase; + letter-spacing: 0.005em; +} + +.v2-byline { + font-family: var(--font-sans); + font-size: 12px; + color: var(--ecg-dark); + margin-bottom: 20px; + opacity: 0.85; +} + +/* ── Skeleton Loading ───────────────────────────────────────────── */ +@keyframes v2-shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} + +.v2-skeleton { + background: linear-gradient( + 90deg, + var(--surface) 25%, + var(--hairline) 50%, + var(--surface) 75% + ); + background-size: 800px 100%; + animation: v2-shimmer 1.4s ease-in-out infinite; + border-radius: 2px; + display: inline-block; +} + +.v2-skeleton-row { + display: grid; + grid-template-columns: 72px 1fr 140px 90px; + gap: 18px; + padding: 14px 4px; + border-bottom: 1px solid var(--hairline); + align-items: center; +} + +/* ── Manual-Aufzählung ──────────────────────────────────────────── */ +body.v2 ul.v2-manual { + list-style: none; + padding: 0; + margin: 10px 0 16px; +} + +body.v2 ul.v2-manual > li { + position: relative; + padding: 5px 0 5px 24px; + line-height: 1.5; +} + +body.v2 ul.v2-manual > li::before { + content: ""; + position: absolute; + left: 4px; + top: 13px; + width: 9px; + height: 9px; + background: var(--ecg-blue); +} + +body.v2 ul.v2-manual > li.green::before { + background: var(--ecg-green); +} + +body.v2 ul.v2-manual ul { + list-style: none; + padding: 0; + margin: 4px 0 4px 4px; +} + +body.v2 ul.v2-manual ul li { + position: relative; + padding: 3px 0 3px 22px; + font-size: 14px; +} + +body.v2 ul.v2-manual ul li::before { + content: "–"; + position: absolute; + left: 4px; + top: 3px; + color: var(--ecg-blue); + font-weight: 900; +} + +/* ── Responsive: Mobile Drawer ──────────────────────────────────── */ +@media (max-width: 900px) { + .v2-shell { + grid-template-columns: 1fr; + grid-template-areas: + "topbar" + "main" + "footer"; + } + + .v2-sidebar { + display: none; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 200; + box-shadow: 4px 0 16px rgba(0,0,0,0.12); + } + + .v2-sidebar.open { + display: block; + } + + .v2-topbar { + position: sticky; + top: 0; + z-index: 100; + } + + .v2-result-row { + grid-template-columns: 64px 1fr; + } + + .v2-result-row .v2-r-date, + .v2-result-row .v2-r-state { + display: none; + } + + .v2-detail { + grid-template-columns: 1fr; + } + + .v2-detail .left { + padding: 20px 0; + border-right: 0; + border-bottom: 1px solid var(--hairline); + } + + .v2-detail .right { + padding: 20px 0; + } + + .v2-matrix-mini { + grid-template-columns: 70px repeat(5, 1fr); + font-size: 10px; + } + + .v2-menu-toggle { + display: inline-flex; + } +} + +@media (min-width: 901px) { + .v2-menu-toggle { + display: none; + } +} + +/* ── Overlay for mobile drawer ──────────────────────────────────── */ +.v2-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.3); + z-index: 150; +} + +.v2-overlay.open { + display: block; +} + +/* ── Button primitives ──────────────────────────────────────────── */ +.v2-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 700; + cursor: pointer; + border: none; + background: var(--ecg-blue); + color: #fff; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.v2-btn:hover { + background: color-mix(in srgb, var(--ecg-blue) 85%, black); +} + +.v2-btn.green { + background: var(--ecg-green); +} + +.v2-btn.green:hover { + background: color-mix(in srgb, var(--ecg-green) 85%, black); +} + +.v2-btn.outline { + background: transparent; + border: 2px solid var(--ecg-blue); + color: var(--ecg-blue); +} + +.v2-btn.outline:hover { + background: var(--ecg-blue); + color: #fff; +} + +/* ── Menu Toggle Button ─────────────────────────────────────────── */ +.v2-menu-toggle { + padding: 6px 10px; + background: none; + border: 1px solid var(--hairline); + color: var(--ecg-dark); + font-size: 18px; + cursor: pointer; + line-height: 1; +} + +/* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */ +.v2-fraktions-scores { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.v2-fraktion-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + background: var(--paper); + border: 1px solid var(--hairline); + border-radius: 4px; + font-size: 13px; +} + +.v2-fraktion-label { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; + min-width: 90px; +} + +.v2-fraktion-scores { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.v2-score-chip { + display: inline-block; + padding: 2px 7px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.v2-score-chip.chip-green { + background: var(--score-chip-green-bg); + color: var(--score-chip-green-fg); +} + +.v2-score-chip.chip-mid { + background: var(--score-chip-mid-bg); + color: var(--score-chip-mid-fg); +} + +.v2-score-chip.chip-red { + background: var(--score-chip-red-bg); + color: var(--score-chip-red-fg); +} + +.v2-badge-antragsteller, +.v2-badge-regierung { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.v2-badge-antragsteller { + background: var(--ecg-blue); + color: #fff; +} + +.v2-badge-regierung { + background: var(--ecg-green); + color: #fff; +} + +/* ── Admin-Screens ──────────────────────────────────────────────────────── */ + +body.v2 .v2-admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +body.v2 .v2-admin-table thead th { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + opacity: 0.5; + padding: 6px 10px; + text-align: left; + border-bottom: 1px solid var(--ecg-teal); +} + +body.v2 .v2-admin-table tbody tr { + border-bottom: 1px solid rgba(0,0,0,.06); + transition: background .15s; +} + +body.v2 .v2-admin-table tbody tr:hover { + background: rgba(0,120,100,.04); +} + +body.v2 .v2-admin-table td { + padding: 8px 10px; + vertical-align: middle; +} + +body.v2 .v2-admin-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 10px; + background: var(--ecg-teal); + color: #fff; + border: none; + border-radius: 3px; + cursor: pointer; + transition: opacity .15s; +} + +body.v2 .v2-admin-btn:hover { opacity: .8; } +body.v2 .v2-admin-btn:disabled { opacity: .4; cursor: default; } + +body.v2 .v2-admin-btn.danger { + background: transparent; + color: var(--ecg-blue); + border: 1px solid var(--ecg-blue); +} + +body.v2 .v2-admin-btn.danger:hover { background: var(--ecg-blue); color: #fff; } + +body.v2 .v2-admin-badge { + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 6px; + border-radius: 2px; + background: rgba(0,120,100,.12); + color: var(--ecg-teal); +} + +body.v2 .v2-admin-badge.running { + background: rgba(0,120,100,.18); + color: var(--ecg-teal); +} diff --git a/app/templates/_header.html b/app/templates/_header.html new file mode 100644 index 0000000..fce6a1a --- /dev/null +++ b/app/templates/_header.html @@ -0,0 +1,19 @@ + +
+ + {{ back_label | default('← ' + app_name) }} + + {% if page_title %} +

{{ page_title }}

+ {% endif %} + {% if header_nav %} + + {% endif %} +
diff --git a/app/templates/auswertungen.html b/app/templates/auswertungen.html index fc56746..0fefbe6 100644 --- a/app/templates/auswertungen.html +++ b/app/templates/auswertungen.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-orange: #F7941D; @@ -160,12 +160,12 @@ -
-

Auswertungen — Bundesland × Partei × Wahlperiode

- + {% set page_title = 'Auswertungen — Bundesland × Partei × Wahlperiode' %} + {% set header_nav = 'Quellen' %} + {% include "_header.html" %} + +
+ Diese Seite ist auch direkt in der Haupt-App verfügbar: zur integrierten Auswertungs-Ansicht →
@@ -198,6 +198,11 @@
+

Thema × Fraktion

+

Ø-GWÖ-Score pro Thema und Fraktion. Klick auf eine Zelle für Details. Grün = GWÖ-freundlich, Rot = GWÖ-kritisch.

+
Lade Themen-Matrix …
+ + diff --git a/app/templates/index.html b/app/templates/index.html index 723423b..2b85f24 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,37 +4,45 @@ {{ app_name }} + + + @@ -741,24 +778,25 @@
- + -
- +
- 📈 Auswertungen 📚 Quellen 🔍 Methodik + 📋 Impressum + 🔒 Datenschutz
- - + +
- - - + +
+
+ ✨ Zum neuen Design →
@@ -780,6 +818,7 @@
@@ -809,11 +848,15 @@ -
- 0 geprüft · 0 vorbildlich · Ø 0 - - | - +
+ 0 geprüft · 0 vorbildlich · Ø 0 + | + + + + + +
@@ -841,29 +884,169 @@
+ + + + + +
@@ -985,18 +1141,41 @@ if (currentUser.roles && currentUser.roles.includes('admin')) { const pendingBtn = document.getElementById('admin-pending-btn'); if (pendingBtn) pendingBtn.style.display = 'block'; + const batchBtn = document.getElementById('batch-menu-btn'); + if (batchBtn) batchBtn.style.display = 'block'; + const batchLink = document.getElementById('admin-batch-link'); + if (batchLink) batchLink.style.display = 'block'; + const usersSection = document.getElementById('admin-users-section'); + if (usersSection) usersSection.style.display = 'block'; } + // Eingeloggte Features einblenden + const subsBtn = document.getElementById('subs-btn'); + if (subsBtn) subsBtn.style.display = 'block'; + const bookmarksBtn = document.getElementById('btn-bookmarks'); + if (bookmarksBtn) bookmarksBtn.style.display = ''; + const auswBtn = document.getElementById('btn-auswertungen'); + if (auswBtn) auswBtn.style.display = 'block'; + const adminBtn = document.getElementById('btn-admin'); + if (adminBtn) adminBtn.style.display = 'block'; // Bestehende Liste neu rendern damit Buttons aktiv werden if (allAssessments.length > 0) renderList(sortAssessments(allAssessments)); } else { - authBtn.textContent = '🔑 Anmelden'; + authBtn.textContent = '🔑 Anmelden / Registrieren'; authBtn.style.color = ''; authBtn.classList.remove('logged-in'); - authBtn.onclick = async () => { - const resp = await fetch(`/api/auth/login-url?redirect=${encodeURIComponent(window.location.pathname)}`); - const data = await resp.json(); - if (data.url) window.location.href = data.url; + authBtn.onclick = () => { + document.getElementById('auth-modal').style.display = 'flex'; }; + // Eingeloggte Features ausblenden + ['btn-bookmarks', 'btn-auswertungen', 'btn-admin', 'subs-btn'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + // Zurück zu Durchsuchen falls in geschütztem Modus + const mode = ['admin-mode','auswertungen-mode','bookmarks-mode'].find( + id => document.getElementById(id)?.style.display === 'flex' + ); + if (mode) showMode('browse'); } } @@ -1083,7 +1262,7 @@ function setSortOrder(order) { currentSort = order; localStorage.setItem('sortOrder', order); - applyFilters(); + renderList(sortAssessments(allAssessments)); } function sortAssessments(items) { const sorted = [...items]; @@ -1140,6 +1319,10 @@ // Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet. const PARLAMENT_NAMES = {{ parlament_names | tojson }}; + // localStorage gwoe.ui-Setter deaktiviert — verursachte Redirect-Loop mit v2 + // Umschalter wird allein über Topbar-Links gesteuert, keine Persistenz nötig + (function () { try { localStorage.removeItem('gwoe.ui'); } catch (e) {} })(); + // Load assessments on page load — localStorage-Auswahl wiederherstellen document.addEventListener('DOMContentLoaded', () => { initAuth(); // #43: Auth-State prüfen @@ -1170,21 +1353,108 @@ btnLandtag.disabled = true; btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich'; } - loadAssessments(); + loadAssessments().then(() => { + // #132: Direkte Verlinkbarkeit — wenn ?drucksache=X in der URL, Detail öffnen + const params = new URLSearchParams(window.location.search); + const ds = params.get('drucksache'); + if (ds) { + setTimeout(() => showDetail(ds), 300); + } + const mode = params.get('mode'); + if (mode && ['auswertungen','admin','tags','bookmarks'].includes(mode)) { + setTimeout(() => showMode(mode), 100); + } + }); }); - + + // #132: Browser-Back schließt Detail-Panel und kehrt zur Liste zurück. + // pushState passiert in showDetail; hier ist der Gegenpart. + window.addEventListener('popstate', (e) => { + const params = new URLSearchParams(window.location.search); + const ds = params.get('drucksache'); + if (ds) { + showDetail(ds); + } else if (document.getElementById('detail-panel').innerHTML.trim()) { + document.getElementById('detail-panel').innerHTML = ''; + document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active')); + if (typeof closeMobileDetail === 'function') closeMobileDetail(); + } + }); + + // Keyboard Shortcuts (#116) + document.addEventListener('keydown', (e) => { + // Nicht in Input-Feldern oder Textareas + if (e.target.matches('input, textarea, select')) return; + // Nicht bei offenen Modals/Panels + if (document.querySelector('[style*="display: block"][style*="position: fixed"], [style*="display:block"][style*="position:fixed"]')) return; + + const items = document.querySelectorAll('#list-content .list-item:not(.scroll-sentinel)'); + if (!items.length) return; + const active = document.querySelector('#list-content .list-item.active'); + let idx = active ? Array.from(items).indexOf(active) : -1; + + switch (e.key) { + case 'j': // nächster + e.preventDefault(); + idx = Math.min(idx + 1, items.length - 1); + items[idx].classList.add('active'); + if (active && active !== items[idx]) active.classList.remove('active'); + items[idx].scrollIntoView({ block: 'nearest' }); + break; + case 'k': // vorheriger + e.preventDefault(); + idx = Math.max(idx - 1, 0); + items[idx].classList.add('active'); + if (active && active !== items[idx]) active.classList.remove('active'); + items[idx].scrollIntoView({ block: 'nearest' }); + break; + case 'Enter': // öffnen + if (active) { + e.preventDefault(); + const ds = active.dataset.drucksache; + if (ds && !active.classList.contains('unchecked')) showDetail(ds); + } + break; + case 'Escape': // Detail schließen → Liste + if (document.getElementById('detail-panel').innerHTML.trim()) { + e.preventDefault(); + document.getElementById('detail-panel').innerHTML = ''; + closeMobileDetail(); + // #132: URL aufräumen wenn Detail via Escape geschlossen wird + if (new URLSearchParams(window.location.search).has('drucksache')) { + history.pushState({}, '', window.location.pathname); + } + } + break; + case '/': // Suche fokussieren + e.preventDefault(); + document.getElementById('search-input').focus(); + break; + case '?': // Shortcuts-Hilfe + e.preventDefault(); + alert('Tastenkürzel:\n\nj / k — nächster / vorheriger Antrag\nEnter — Antrag öffnen\nEsc — Detail schließen\n/ — Suche fokussieren'); + break; + } + }); + + // Bundestags-Fraktionen (21. WP, ab 2025) + // Quelle: bundestag.de/parlament/fraktionen + const BUNDESTAG_FRAKTIONEN = ['CDU/CSU', 'CDU', 'CSU', 'SPD', 'AfD', 'GRÜNE', 'LINKE']; + function buildParteienFilter() { - // Alle Fraktionen sammeln + // Filter-Dropdown: ALLE Parteien aus den Daten (zum Filtern) const parteien = new Set(); allAssessments.forEach(a => { - (a.fraktionen || []).forEach(f => parteien.add(f)); + (a.fraktionen || []).forEach(f => parteien.add(normalizePartei(f))); }); - + const select = document.getElementById('partei-filter'); + const prev = select.value; select.innerHTML = ''; Array.from(parteien).sort().forEach(p => { select.innerHTML += ``; }); + select.value = prev; } function buildTagCloud() { @@ -1292,15 +1562,15 @@ } resultsContainer.innerHTML = ` -

${filtered.length} Anträge gefunden

+

${filtered.length} Anträge

${filtered.map(item => ` -
+
- ${item.drucksache} - ${item.gwoeScore}/10 + ${item.drucksache} + ${item.gwoeScore}/10
-
${item.title || 'Ohne Titel'}
-
${(item.fraktionen || []).join(', ')}
+
${(item.title || 'Ohne Titel').substring(0, 80)}
+
${(item.fraktionen || []).join(', ')}
`).join('')} `; @@ -1331,41 +1601,49 @@ document.getElementById('stat-high').textContent = high; document.getElementById('stat-avg').textContent = avg; - // Pro-Bundesland-Aufschlüsselung — nur im Bundesweit-Modus, und nur - // wenn tatsächlich mehr als ein Bundesland in der Liste vorkommt. - const blContainer = document.getElementById('bundesland-stats'); - if (currentBundesland === 'ALL') { - const blStats = {}; + // BL-Tooltip (hover über Gesamtzahl) + const blTooltip = document.getElementById('bl-tooltip'); + const summaryEl = document.getElementById('stats-summary'); + const blStats = {}; + allAssessments.forEach(a => { + if (a.gwoeScore == null || !a.bundesland) return; + if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 }; + blStats[a.bundesland].sum += a.gwoeScore; + blStats[a.bundesland].count += 1; + }); + const blCodes = Object.keys(blStats); + if (blCodes.length > 0) { + const sortedBl = blCodes + .map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count })) + .sort((a, b) => b.avg - a.avg); + // Vorbildlich-Zählung pro BL + const blVorbildlich = {}; allAssessments.forEach(a => { - if (a.gwoeScore == null || !a.bundesland) return; - if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 }; - blStats[a.bundesland].sum += a.gwoeScore; - blStats[a.bundesland].count += 1; + if (!a.bundesland) return; + if (!blVorbildlich[a.bundesland]) blVorbildlich[a.bundesland] = 0; + if ((a.gwoeScore || 0) >= 8) blVorbildlich[a.bundesland]++; }); - const codes = Object.keys(blStats); - if (codes.length > 1) { - const sortedBl = codes - .map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count })) - .sort((a, b) => b.avg - a.avg); - blContainer.innerHTML = sortedBl.map(b => - `Ø ${b.code} ${b.avg.toFixed(1)} (n=${b.count})` - ).join(' · '); - blContainer.style.display = 'inline-flex'; - } else { - blContainer.style.display = 'none'; - blContainer.innerHTML = ''; - } - } else { - blContainer.style.display = 'none'; - blContainer.innerHTML = ''; + blTooltip.innerHTML = `
Bundesländer — Übersicht
` + + `
${checked} geprüft · ${high} vorbildlich (≥8) · Ø ${avg}/10
` + + '
' + + 'BLØ ScoreAnträge≥8' + + sortedBl.map(b => { + const color = b.avg >= 7 ? '#889e33' : b.avg >= 4 ? '#fd7e14' : '#dc3545'; + const vb = blVorbildlich[b.code] || 0; + return `${b.code}${b.avg.toFixed(1)}${b.count}${vb}`; + }).join('') + '
'; + summaryEl.onmouseenter = () => { document.getElementById('partei-tooltip').style.display = 'none'; blTooltip.style.display = 'block'; }; + summaryEl.onmouseleave = () => { blTooltip.style.display = 'none'; }; } - // Partei-Durchschnitte berechnen (Normalisierung via globaler normalizePartei) + // Partei-Durchschnitte berechnen (Normalisierung via globalem normalizePartei) + // Bei Bundesweit: nur Bundestags-Fraktionen anzeigen const parteiStats = {}; allAssessments.forEach(a => { if (a.gwoeScore == null) return; (a.fraktionen || []).forEach(f => { const norm = normalizePartei(f); + if (currentBundesland === 'ALL' && !BUNDESTAG_FRAKTIONEN.includes(norm)) return; if (!parteiStats[norm]) parteiStats[norm] = { sum: 0, count: 0 }; parteiStats[norm].sum += a.gwoeScore; parteiStats[norm].count += 1; @@ -1377,21 +1655,105 @@ .map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count })) .sort((a, b) => b.avg - a.avg); + // Vollständige Statistik (alle Parteien, ungefiltert) für Tooltip + const allParteiStats = {}; + allAssessments.forEach(a => { + if (a.gwoeScore == null) return; + (a.fraktionen || []).forEach(f => { + const norm = normalizePartei(f); + if (!allParteiStats[norm]) allParteiStats[norm] = { sum: 0, count: 0 }; + allParteiStats[norm].sum += a.gwoeScore; + allParteiStats[norm].count += 1; + }); + }); + const allSorted = Object.entries(allParteiStats) + .map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count })) + .sort((a, b) => b.avg - a.avg); + const container = document.getElementById('partei-stats'); + container.style.cursor = 'help'; + container.style.position = 'relative'; container.innerHTML = sorted.map(p => { const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545'; return `${p.partei} ${p.avg.toFixed(1)}`; }).join(''); + + // Partei-Tooltip (hover über Parteien-Leiste) + const parteiTooltip = document.getElementById('partei-tooltip'); + // Min/Max + Vorbildlich pro Partei + const allParteiDetail = {}; + allAssessments.forEach(a => { + if (a.gwoeScore == null) return; + (a.fraktionen || []).forEach(f => { + const norm = normalizePartei(f); + if (!allParteiDetail[norm]) allParteiDetail[norm] = { scores: [] }; + allParteiDetail[norm].scores.push(a.gwoeScore); + }); + }); + parteiTooltip.innerHTML = '
Alle Parteien — Übersicht
' + + '
' + + 'ParteiØMinMaxn' + + allSorted.map(p => { + const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545'; + const detail = allParteiDetail[p.partei]; + const min = detail ? Math.min(...detail.scores) : '-'; + const max = detail ? Math.max(...detail.scores) : '-'; + return `${p.partei}${p.avg.toFixed(1)}${min}${max}${p.count}`; + }).join('') + '
'; + container.onmouseenter = () => { document.getElementById('bl-tooltip').style.display = 'none'; parteiTooltip.style.display = 'block'; }; + container.onmouseleave = () => { parteiTooltip.style.display = 'none'; }; } + let _allItems = []; + let _shownCount = 0; + const PAGE_SIZE = 30; + + let _scrollObserver = null; + function renderList(items) { const container = document.getElementById('list-content'); + _allItems = items; + _shownCount = 0; + if (_scrollObserver) { _scrollObserver.disconnect(); _scrollObserver = null; } if (items.length === 0) { container.innerHTML = '

Keine Ergebnisse

'; return; } - - container.innerHTML = items.map(item => { + container.innerHTML = ''; + appendItems(PAGE_SIZE); + _setupScrollObserver(container); + } + + function _setupScrollObserver(container) { + if (_allItems.length <= _shownCount) return; + let sentinel = container.querySelector('.scroll-sentinel'); + if (!sentinel) { + sentinel = document.createElement('div'); + sentinel.className = 'scroll-sentinel'; + sentinel.style.cssText = 'padding:1rem;text-align:center;color:#888;font-size:0.85rem;'; + container.appendChild(sentinel); + } + sentinel.textContent = `${_shownCount} von ${_allItems.length} geladen…`; + _scrollObserver = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && _shownCount < _allItems.length) { + appendItems(PAGE_SIZE); + if (_shownCount >= _allItems.length) { + sentinel.remove(); + _scrollObserver.disconnect(); + } else { + sentinel.textContent = `${_shownCount} von ${_allItems.length} geladen…`; + container.appendChild(sentinel); + } + } + }, { rootMargin: '200px' }); + _scrollObserver.observe(sentinel); + } + + function appendItems(count) { + const container = document.getElementById('list-content'); + const batch = _allItems.slice(_shownCount, _shownCount + count); + _shownCount += batch.length; + container.insertAdjacentHTML('beforeend', batch.map(item => { const isUnchecked = item.status === 'unchecked'; // Skala 0-10 const scoreClass = isUnchecked ? 'status-unchecked' : @@ -1426,7 +1788,7 @@ `}
`; - }).join(''); + }).join('')); } function debounceSearch(query) { @@ -1502,89 +1864,6 @@ } } - // ─── Batch-Analyse ────────────────────────────────────────── - async function startBatch() { - const bl = document.getElementById('batch-bundesland').value; - const limit = document.getElementById('batch-limit').value; - const btn = document.getElementById('batch-btn'); - const status = document.getElementById('batch-status'); - - btn.disabled = true; - btn.textContent = '⏳ Wird gestartet...'; - status.innerHTML = ''; - - try { - const resp = await fetch('/api/batch-analyze', { - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: `bundesland=${bl}&limit=${limit}` - }); - if (resp.status === 401) { - status.innerHTML = '🔒 Bitte zuerst anmelden.'; - btn.disabled = false; btn.textContent = '🚀 Batch starten'; - return; - } - const data = await resp.json(); - if (data.status === 'batch_enqueued') { - status.innerHTML = ` - ✓ ${data.enqueued} Anträge in die Queue eingereiht - ${data.skipped_existing > 0 ? `
${data.skipped_existing} bereits bewertet (übersprungen)` : ''} -
Die Analyse läuft im Hintergrund. Ergebnisse erscheinen nach und nach in der Liste. - `; - // Queue-Status pollen - pollBatchQueue(status); - } else { - status.innerHTML = `❌ ${data.detail || 'Fehler'}`; - } - } catch (e) { - status.innerHTML = `❌ ${e.message}`; - } - btn.disabled = false; btn.textContent = '🚀 Batch starten'; - } - - async function pollBatchQueue(statusEl) { - for (let i = 0; i < 200; i++) { - await new Promise(r => setTimeout(r, 3000)); - try { - const qs = await fetch('/api/queue/status').then(r => r.json()); - - // Job-Tabelle rendern - const jobs = qs.jobs || []; - const jobsHtml = jobs.length > 0 ? ` - - - ${jobs.map(j => { - const statusIcon = j.status === 'completed' ? '✅' : j.status === 'processing' ? '⏳' : j.status === 'failed' ? '❌' : '⏸'; - const dur = j.duration ? j.duration + 's' : ''; - const link = j.status === 'completed' ? `${j.drucksache}` : j.drucksache; - return ``; - }).join('')} -
DrucksacheStatusDauer
${link}${statusIcon} ${j.status}${dur}
` : ''; - - // Fortschrittsbalken - const completed = jobs.filter(j => j.status === 'completed').length; - const total = jobs.length; - const pct = total > 0 ? Math.round(completed / total * 100) : 0; - - statusEl.innerHTML = ` -
-
-
-
- ${completed}/${total} fertig · ${qs.concurrency} Worker · ~${Math.round(qs.estimated_wait_seconds/60)} Min. -
- ${jobsHtml} - `; - - if (qs.pending === 0 && completed > 0) { - statusEl.innerHTML += `
✓ Alle Jobs abgeschlossen`; - loadAssessments(); - return; - } - } catch { break; } - } - } - // ─── Merkliste (#94) ──────────────────────────────────────── async function loadBookmarksList() { const container = document.getElementById('bookmarks-content'); @@ -1948,15 +2227,45 @@ } } - function showDetail(drucksache) { - const item = allAssessments.find(a => a.drucksache === drucksache); - if (!item) return; - + function closeMobileDetail() { + document.querySelector('.list-panel')?.classList.remove('mobile-hidden'); + document.querySelector('.detail-panel')?.classList.remove('mobile-fullscreen'); + } + + async function showDetail(drucksache) { + // #132: URL aktualisieren für direkte Verlinkbarkeit + const newUrl = '/?drucksache=' + encodeURIComponent(drucksache); + if (window.location.search !== '?drucksache=' + encodeURIComponent(drucksache)) { + history.pushState({drucksache}, '', newUrl); + } + // Liste-Item für Basis-Daten (Score, Titel etc.) + const listItem_ = allAssessments.find(a => a.drucksache === drucksache); + if (!listItem_) return; + + // Mobile: Liste verstecken, Detail fullscreen (#115) + if (window.innerWidth <= 900) { + document.querySelector('.list-panel')?.classList.add('mobile-hidden'); + document.querySelector('.detail-panel')?.classList.add('mobile-fullscreen'); + } + // Highlight active item document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active')); - // Find and highlight the list item by drucksache const listItem = document.querySelector(`.list-item[data-drucksache="${drucksache}"]`); if (listItem) listItem.classList.add('active'); + + // Lade-Indikator + document.getElementById('detail-panel').innerHTML = '
Lade Detail…
'; + + // Volle Assessment-Daten on-demand laden (#122) + let item; + try { + const resp = await fetch(`/api/assessment?drucksache=${encodeURIComponent(drucksache)}`); + if (!resp.ok) throw new Error('Nicht gefunden'); + item = await resp.json(); + } catch (e) { + document.getElementById('detail-panel').innerHTML = `
Fehler beim Laden: ${e.message}
`; + return; + } // Skala 0-10 const scoreClass = item.gwoeScore >= 8 ? 'score-high' : @@ -2146,7 +2455,7 @@ document.getElementById('detail-panel').innerHTML = `
- +
${item.title || 'Ohne Titel'}
@@ -2156,6 +2465,11 @@
${item.gwoeScore}
GWÖ-Score
+ ${item.konfidenz ? `
${item.konfidenz === 'hoch' ? 'Hohe' : item.konfidenz === 'mittel' ? 'Mittlere' : 'Niedrige'} Konfidenz
` : ''} +
+ + +
@@ -2189,6 +2503,10 @@ ${wahlprogrammHtml ? `

Programmtreue

+ ${(item.fehlendeProgramme && item.fehlendeProgramme.length > 0) ? ` +
+ ⚠ Wahlprogramm-Treue unvollständig: Für folgende Fraktionen liegt kein Wahlprogramm vor: ${item.fehlendeProgramme.join(', ')} +
` : ''} ${wahlprogrammHtml} ` : ''} @@ -2226,13 +2544,27 @@ 🔄 Neu bewerten
-
+
+ + 𝕏 + Threads + + LinkedIn + 📧 E-Mail + 🖼 Bild suchen +
+
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') + ', ' + new Date(item.updatedAt).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}) + ' Uhr' : '–'} ${item.source ? ` · Quelle: ${item.source}` : ''} ${item.model ? ` · Modell: ${item.model}` : ''}
+
+ + +
+
+ + +
+

Lade…

+
`; + window._clusterDataAusw = await fetch('/api/clusters').then(r => r.json()); + renderClusterView('list'); + } + } catch (e) { + detail.innerHTML = `

Fehler: ${e.message}

`; + } + } + function refreshAuswertung() { if (_currentAuswertung) showAuswertung(_currentAuswertung); } + + function onAuswBlChange() { + const blSel = document.getElementById('ausw-bl'); + const wpSel = document.getElementById('ausw-wp'); + const wpContainer = document.getElementById('ausw-wp-container'); + const selectedBl = blSel ? blSel.value : ''; + + // WP-Dropdown zurücksetzen + while (wpSel.options.length > 1) wpSel.remove(1); + wpSel.value = ''; + + if (selectedBl) { + // WPs für dieses BL ermitteln: Drucksachen-Prefix als WP-Ziffer, dann als "-WP" formatieren + const wps = new Set(); + allAssessments.forEach(a => { + if (a.bundesland !== selectedBl) return; + const ds = a.drucksache || ''; + const wpNum = ds.split('/')[0]; + if (wpNum) wps.add(wpNum); + }); + [...wps].sort().forEach(wpNum => { + const opt = document.createElement('option'); + opt.value = selectedBl + '-WP' + wpNum; + opt.textContent = 'WP ' + wpNum; + wpSel.appendChild(opt); + }); + if (wpContainer) wpContainer.style.display = ''; + } else { + if (wpContainer) wpContainer.style.display = 'none'; + } + + refreshAuswertung(); + } + async function reloadAuswCluster() { + const thr = document.getElementById('ausw-cluster-thr')?.value || '0.55'; + const container = document.getElementById('ausw-cluster-content'); + if (container) container.innerHTML = '

Lade…

'; + window._clusterDataAusw = await fetch('/api/clusters?threshold=' + thr).then(r => r.json()); + // Behalte aktuelle Ansicht (Liste oder Bubble) + const isBubble = document.getElementById('cl-btn-bubble')?.style.background?.includes('var'); + renderClusterView(isBubble ? 'bubble' : 'list'); + } + + function renderClusterView(view) { + const data = window._clusterDataAusw; + if (!data) return; + const container = document.getElementById('ausw-cluster-content'); + const meta = data.meta || {}; + // Toggle buttons + const listBtn = document.getElementById('cl-btn-list'); + const bubbleBtn = document.getElementById('cl-btn-bubble'); + if (listBtn) { listBtn.style.background = view === 'list' ? 'var(--color-blue)' : 'none'; listBtn.style.color = view === 'list' ? 'white' : 'var(--color-blue)'; } + if (bubbleBtn) { bubbleBtn.style.background = view === 'bubble' ? 'var(--color-blue)' : 'none'; bubbleBtn.style.color = view === 'bubble' ? 'white' : 'var(--color-blue)'; } + + if (!data.clusters?.length) { container.innerHTML = '

Keine Cluster.

'; return; } + + if (view === 'list') { + let html = `

${meta.num_clusters} Cluster, ${meta.num_singletons} Singletons (${meta.total} Anträge)

`; + for (const c of data.clusters) { + html += `
`; + html += `${c.label} (${c.size} · ${c.dominant_fraktion||'–'} · Ø ${c.avg_gwoe_score}/10)`; + html += `
${(c.drucksachen||[]).map(ds => `${ds}`).join('')}
`; + } + container.innerHTML = html; + } else { + container.innerHTML = '
'; + // Re-use renderBubbleChart from cluster mode + if (typeof renderBubbleChart === 'function') { + // Temporarily set holder + const origHolder = document.getElementById('bubble-svg-holder'); + const newHolder = document.getElementById('ausw-bubble-holder'); + if (newHolder) { + newHolder.id = 'bubble-svg-holder'; + renderBubbleChart(data); + newHolder.id = 'ausw-bubble-holder'; + } + } else { + container.innerHTML = '

Bubble-Chart nicht verfügbar (d3.js nicht geladen).

'; + } + } + } + async function showZeitreiheInline(bl, partei) { + const detail = document.getElementById('auswertungen-detail'); + detail.innerHTML = '
Lade Zeitreihe…
'; + try { + const z = await fetch(`/api/auswertungen/zeitreihe?bundesland=${encodeURIComponent(bl)}&partei=${encodeURIComponent(partei)}`).then(r => r.json()); + let html = `

${bl} × ${partei}

`; + html += ''; + if (z.wahlperioden?.length) { + html += ''; + for (const r of z.wahlperioden) html += ``; + html += '
WPAnträgeØ Score
WP ${r.wp}${r.n}${r.avg.toFixed(1)}
'; + } else html += '

Keine Daten.

'; + html += '
'; + detail.innerHTML = html; + } catch (e) { + detail.innerHTML = `

Fehler: ${e.message}

`; + } + } + + function updateFeedUrl() { + const bl = document.getElementById('feed-bl')?.value || ''; + const partei = document.getElementById('feed-partei')?.value || ''; + const limit = document.getElementById('feed-limit')?.value || '50'; + const params = []; + if (bl) params.push('bundesland=' + encodeURIComponent(bl)); + if (partei) params.push('partei=' + encodeURIComponent(partei)); + if (limit !== '50') params.push('limit=' + limit); + const url = 'https://gwoe.toppyr.de/api/feed.xml' + (params.length ? '?' + params.join('&') : ''); + const urlEl = document.getElementById('feed-url'); + if (urlEl) urlEl.textContent = url; + const openLink = document.getElementById('feed-open-link'); + if (openLink) openLink.href = '/api/feed.xml' + (params.length ? '?' + params.join('&') : ''); + const dlLink = document.getElementById('feed-download-link'); + if (dlLink) dlLink.href = openLink.href; + } + + function showAdminSection(section) { + // Highlight active link + document.querySelectorAll('.admin-link').forEach(l => l.style.borderLeftColor = 'transparent'); + if (event && event.target) event.target.style.borderLeftColor = 'var(--color-blue)'; + + const adminDetail = document.getElementById('admin-detail'); + const expDetail = document.getElementById('experimental-detail'); + const target = adminDetail?.style.display !== 'none' ? adminDetail : expDetail; + if (!target) return; + + // Aktions-Sektionen (wechseln den Mode oder öffnen Overlays) + if (section === 'manual-check') { + target.innerHTML = document.getElementById('upload-mode').innerHTML; + return; + } + if (section === 'batch') { document.getElementById('batch-panel').style.display = 'block'; return; } + if (section === 'pending-users') { showPendingUsers(); return; } + + // Cluster im Experimental-Detail + if (section === 'clusters') { + const el = expDetail || target; + el.innerHTML = '

Lade Cluster…

'; + fetch('/api/clusters').then(r => r.json()).then(data => { + const meta = data.meta || {}; + let html = `

🎯 Antrag-Cluster

`; + html += `

${meta.num_clusters} Cluster, ${meta.num_singletons} Singletons (${meta.total} Anträge)

`; + for (const c of (data.clusters || [])) { + html += `
`; + html += `${c.label} (${c.size} · ${c.dominant_fraktion||'–'} · Ø ${c.avg_gwoe_score})`; + html += `
${(c.drucksachen||[]).map(ds => `${ds}`).join('')}
`; + } + document.getElementById('exp-cluster-content').innerHTML = html; + }); + return; + } + + // HTML-Sektionen + const pages = { + 'export': `
+

📥 Daten exportieren

+

Alle GWÖ-Bewertungen herunterladen. Lizenz: CC BY 4.0.

+ +

Inhalt

+

Pro Antrag: Drucksache, Titel, Bundesland, Fraktionen, GWÖ-Score, 25-Felder-Matrix, Wahlprogramm-Treue mit Zitaten, Verbesserungsvorschläge, Themen, Datum.

+ +

Format wählen

+
+
+ JSON (Open Data) +

Maschinenlesbar, vollständig. Für Entwickler, Datenanalyse, Weiterverarbeitung.

+ 📥 JSON +
+
+ CSV (Tabelle) +

Für Excel, Google Sheets, R, Python. Long-Format, eine Zeile pro Antrag.

+ 📊 CSV +
+
+ +

Einzelner Antrag als PDF

+

In der Durchsuchen-Ansicht → Antrag öffnen → "PDF" Button im Detail-Panel.

+
`, + 'notifications': `
+

🔔 Benachrichtigungen

+

Erhalte automatisch Updates wenn neue Anträge bewertet werden — per Feed-Reader oder E-Mail.

+ +

Filter

+
+ + +
+ +
+
+ 📡 Atom-Feed (RSS) +

Für Feed-Reader (Feedly, Thunderbird, etc.). Kein Account nötig.

+
+ https://gwoe.toppyr.de/api/feed.xml + +
+ Feed öffnen +
+
+ 📧 E-Mail-Digest +

Tägliche Zusammenfassung neuer Bewertungen per E-Mail. Erfordert Anmeldung.

+ ${currentUser + ? '' + : '

Bitte erst anmelden.

'} +
+
+
`, + 'similar': `
+

🔗 Ähnliche Anträge

+

Wähle einen Antrag in der Durchsuchen-Ansicht — die ähnlichen Anträge werden automatisch im Detail-Panel angezeigt (Sektion "🔗 ähnliche Anträge").

+
`, + }; + + if (pages[section]) target.innerHTML = pages[section]; } // Upload Tab Toggle @@ -2400,21 +3073,59 @@ - diff --git a/app/templates/quellen.html b/app/templates/quellen.html index 6a67de8..a6f8dc2 100644 --- a/app/templates/quellen.html +++ b/app/templates/quellen.html @@ -179,10 +179,8 @@ -
-

{{ app_name }}

- → Quellen -
+ {% set page_title = 'Quellen' %} + {% include "_header.html" %}
← Zurück zur Übersicht diff --git a/app/templates/v2/base.html b/app/templates/v2/base.html new file mode 100644 index 0000000..49aa0a0 --- /dev/null +++ b/app/templates/v2/base.html @@ -0,0 +1,282 @@ +{% from "v2/components/icon.html" import icon %} + + + + + + {% block title %}GWÖ-Antragsprüfer{% endblock %} + + + {# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #} + + + + + {% block head_extra %}{% endblock %} + + + +{% block body %} +{# AppShell inline, damit {% block main %} aus Screen-Templates rendert. + include propagiert Blocks nicht (Jinja2-Limitierung), darum direkt hier. #} + +
+ +
+ + + +
+ + + {{ icon("arrow-square-out", 13) }} Klassische Ansicht + {{ icon("info", 13) }} Methodik + {{ icon("book-open", 13) }} Quellen + + {# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #} +
+ {# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #} +
+ + +
+ +
+ {% block main %}{% endblock %} +
+ + + +
+{% endblock %} + + + +{% block body_scripts %}{% endblock %} + +{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #} +{% include "v2/components/auth_modal.html" %} + + + + diff --git a/app/templates/v2/components/appshell.html b/app/templates/v2/components/appshell.html new file mode 100644 index 0000000..5a9c9f6 --- /dev/null +++ b/app/templates/v2/components/appshell.html @@ -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 #} +
+ +
+ + {# ── Sidebar ──────────────────────────────────────────────────── #} + + + {# ── Topbar ───────────────────────────────────────────────────── #} +
+ + + Klassische Ansicht + Methodik + Quellen + +
+ + {# ── Main Content ─────────────────────────────────────────────── #} +
+ {% block main %}{% endblock %} +
+ + {# ── Footer ───────────────────────────────────────────────────── #} + + +
{# .v2-shell #} diff --git a/app/templates/v2/components/auth_modal.html b/app/templates/v2/components/auth_modal.html new file mode 100644 index 0000000..8240d07 --- /dev/null +++ b/app/templates/v2/components/auth_modal.html @@ -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 %} + + + + + diff --git a/app/templates/v2/components/chip.html b/app/templates/v2/components/chip.html new file mode 100644 index 0000000..4e2e375 --- /dev/null +++ b/app/templates/v2/components/chip.html @@ -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 +{% endif %} +{% endmacro %} diff --git a/app/templates/v2/components/icon.html b/app/templates/v2/components/icon.html new file mode 100644 index 0000000..3f1ec60 --- /dev/null +++ b/app/templates/v2/components/icon.html @@ -0,0 +1 @@ +{% macro icon(name, size=16, cls="") %}{% endmacro %} diff --git a/app/templates/v2/components/kasten.html b/app/templates/v2/components/kasten.html new file mode 100644 index 0000000..87a56c7 --- /dev/null +++ b/app/templates/v2/components/kasten.html @@ -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") %} +

Komplexer Inhalt mit Markup.

+ {% endcall %} +#} + +{% macro kasten(variant="outline-green", title="", body="") %} +
+ {% if title %} +

{{ title }}

+ {% endif %} + {% if body %} +

{{ body }}

+ {% endif %} + {{ caller() if caller is defined else "" }} +
+{% endmacro %} diff --git a/app/templates/v2/components/matrix_mini.html b/app/templates/v2/components/matrix_mini.html new file mode 100644 index 0000000..062d6aa --- /dev/null +++ b/app/templates/v2/components/matrix_mini.html @@ -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 %} + +
+ {# Header-Zeile #} +
+ {% for c in cols %} +
{{ col_labels[c] }}
+ {% endfor %} + + {# Daten-Zeilen #} + {% for r in rows %} +
{{ row_labels[r] }}
+ {% 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("○") %} +
+ {{ symbol }} +
+ {% endfor %} + {% endfor %} +
+ + +{% endmacro %} diff --git a/app/templates/v2/components/quote_card.html b/app/templates/v2/components/quote_card.html new file mode 100644 index 0000000..1adc1e5 --- /dev/null +++ b/app/templates/v2/components/quote_card.html @@ -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="") %} +
+
„{{ text }}"
+ + {% if verified %} + {% if contra %}✗{% else %}✓{% endif %} {% if contra %}Programm-Widerspruch{% else %}verifiziert{% endif %} + {% endif %} + {{ source }} + {% if pdf_href %} + · PDF öffnen + {% endif %} + +
+{% endmacro %} diff --git a/app/templates/v2/components/redline.html b/app/templates/v2/components/redline.html new file mode 100644 index 0000000..dd92658 --- /dev/null +++ b/app/templates/v2/components/redline.html @@ -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 / 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) %} +
+{% if segments %} + {# Segment-basiertes Rendering (bevorzugt) #} + {% for seg in segments %} + {% if seg.type == "del" %}{{ seg.text }} + {% elif seg.type == "ins" %}{{ seg.text }} + {% 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 %} +
+{% endmacro %} diff --git a/app/templates/v2/components/result_row.html b/app/templates/v2/components/result_row.html new file mode 100644 index 0000000..2cc15d8 --- /dev/null +++ b/app/templates/v2/components/result_row.html @@ -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 %} + + + +
+ {{ "%.1f" | format(score) }} + Score +
+ +
+
{{ assessment.title }}
+
+ {% for p in (assessment.parteien | default([])) %} + {{ p }} + {% endfor %} + · Drucksache {{ assessment.drucksache }} + {% if assessment.tags is defined and assessment.tags %} + · {{ assessment.tags | join(", ") }} + {% endif %} +
+
+ +
+ {{ assessment.bundesland | default("") }} + {% if assessment.parlament is defined %} · {{ assessment.parlament }}{% endif %} +
+ +
{{ assessment.datum | default("") }}
+ +
+{% endmacro %} diff --git a/app/templates/v2/components/score_hero.html b/app/templates/v2/components/score_hero.html new file mode 100644 index 0000000..a4f8bfc --- /dev/null +++ b/app/templates/v2/components/score_hero.html @@ -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 %} + +
+ +
+ {% if verdict_title %}{{ verdict_title }}{% endif %} + {{ verdict_body }} +
+
+{% endmacro %} diff --git a/app/templates/v2/icons/phosphor/arrow-square-out.svg b/app/templates/v2/icons/phosphor/arrow-square-out.svg new file mode 100644 index 0000000..e46b338 --- /dev/null +++ b/app/templates/v2/icons/phosphor/arrow-square-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/book-open.svg b/app/templates/v2/icons/phosphor/book-open.svg new file mode 100644 index 0000000..2909321 --- /dev/null +++ b/app/templates/v2/icons/phosphor/book-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/bookmark-simple.svg b/app/templates/v2/icons/phosphor/bookmark-simple.svg new file mode 100644 index 0000000..8429ae9 --- /dev/null +++ b/app/templates/v2/icons/phosphor/bookmark-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/chart-bar.svg b/app/templates/v2/icons/phosphor/chart-bar.svg new file mode 100644 index 0000000..02451a0 --- /dev/null +++ b/app/templates/v2/icons/phosphor/chart-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/circle-half.svg b/app/templates/v2/icons/phosphor/circle-half.svg new file mode 100644 index 0000000..7c8d2d4 --- /dev/null +++ b/app/templates/v2/icons/phosphor/circle-half.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/envelope-simple.svg b/app/templates/v2/icons/phosphor/envelope-simple.svg new file mode 100644 index 0000000..f77d510 --- /dev/null +++ b/app/templates/v2/icons/phosphor/envelope-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/file-csv.svg b/app/templates/v2/icons/phosphor/file-csv.svg new file mode 100644 index 0000000..e418f5f --- /dev/null +++ b/app/templates/v2/icons/phosphor/file-csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/file-plus.svg b/app/templates/v2/icons/phosphor/file-plus.svg new file mode 100644 index 0000000..8e27171 --- /dev/null +++ b/app/templates/v2/icons/phosphor/file-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/graph.svg b/app/templates/v2/icons/phosphor/graph.svg new file mode 100644 index 0000000..350b0bd --- /dev/null +++ b/app/templates/v2/icons/phosphor/graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/info.svg b/app/templates/v2/icons/phosphor/info.svg new file mode 100644 index 0000000..96369e2 --- /dev/null +++ b/app/templates/v2/icons/phosphor/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/key.svg b/app/templates/v2/icons/phosphor/key.svg new file mode 100644 index 0000000..5aaa6be --- /dev/null +++ b/app/templates/v2/icons/phosphor/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/list-checks.svg b/app/templates/v2/icons/phosphor/list-checks.svg new file mode 100644 index 0000000..9d1c071 --- /dev/null +++ b/app/templates/v2/icons/phosphor/list-checks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/magnifying-glass-plus.svg b/app/templates/v2/icons/phosphor/magnifying-glass-plus.svg new file mode 100644 index 0000000..73f0bd5 --- /dev/null +++ b/app/templates/v2/icons/phosphor/magnifying-glass-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/magnifying-glass.svg b/app/templates/v2/icons/phosphor/magnifying-glass.svg new file mode 100644 index 0000000..2bdda58 --- /dev/null +++ b/app/templates/v2/icons/phosphor/magnifying-glass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/moon.svg b/app/templates/v2/icons/phosphor/moon.svg new file mode 100644 index 0000000..20fca63 --- /dev/null +++ b/app/templates/v2/icons/phosphor/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/rss.svg b/app/templates/v2/icons/phosphor/rss.svg new file mode 100644 index 0000000..5c70e81 --- /dev/null +++ b/app/templates/v2/icons/phosphor/rss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/sign-out.svg b/app/templates/v2/icons/phosphor/sign-out.svg new file mode 100644 index 0000000..9890a7d --- /dev/null +++ b/app/templates/v2/icons/phosphor/sign-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/stack.svg b/app/templates/v2/icons/phosphor/stack.svg new file mode 100644 index 0000000..5325917 --- /dev/null +++ b/app/templates/v2/icons/phosphor/stack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/sun.svg b/app/templates/v2/icons/phosphor/sun.svg new file mode 100644 index 0000000..2a97d01 --- /dev/null +++ b/app/templates/v2/icons/phosphor/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/tag.svg b/app/templates/v2/icons/phosphor/tag.svg new file mode 100644 index 0000000..eecf8bd --- /dev/null +++ b/app/templates/v2/icons/phosphor/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/user-check.svg b/app/templates/v2/icons/phosphor/user-check.svg new file mode 100644 index 0000000..e6b69a5 --- /dev/null +++ b/app/templates/v2/icons/phosphor/user-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/icons/phosphor/user.svg b/app/templates/v2/icons/phosphor/user.svg new file mode 100644 index 0000000..d560c5d --- /dev/null +++ b/app/templates/v2/icons/phosphor/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/v2/og_template.html b/app/templates/v2/og_template.html new file mode 100644 index 0000000..4d0269b --- /dev/null +++ b/app/templates/v2/og_template.html @@ -0,0 +1,221 @@ + + + + + OG Card — {{ antrag.drucksache if antrag else "GWÖ" }} + + + +
+ +
+ GWÖ-ANTRAGSPRÜFER +
+ +
+ + {% 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 %} + +
+
{{ "%.1f"|format(s) }}
+
/ 10
+ {% if antrag.verdict_title %} +
{{ antrag.verdict_title }}
+ {% endif %} +
+ +
+ +
+
{{ antrag.title }}
+
+ {% if antrag.bundesland %} + {{ antrag.bundesland }} + {% endif %} + {% if antrag.drucksache %} + {{ antrag.drucksache }} + {% 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") %} + {{ partei }} + {% endfor %} +
+
+ {% else %} +
+
GWÖ-Antragsprüfer
+
+ Matrix 2.0 · Gemeinden + gwoe.toppyr.de +
+
+ {% endif %} + +
+ + + +
+ + diff --git a/app/templates/v2/screens/admin_abos.html b/app/templates/v2/screens/admin_abos.html new file mode 100644 index 0000000..58d9c3c --- /dev/null +++ b/app/templates/v2/screens/admin_abos.html @@ -0,0 +1,129 @@ +{% extends "v2/base.html" %} + +{% block title %}Abo-Verwaltung — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "admin_abos" %} + +{% block main %} +
+

Abo-Verwaltung

+

+ E-Mail-Abonnements aller Nutzer*innen · nur für Admins +

+
+ +
+ Lade Abonnements … +
+ + + + + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/admin_freischaltungen.html b/app/templates/v2/screens/admin_freischaltungen.html new file mode 100644 index 0000000..0199c7f --- /dev/null +++ b/app/templates/v2/screens/admin_freischaltungen.html @@ -0,0 +1,118 @@ +{% extends "v2/base.html" %} + +{% block title %}Freischaltungen — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "admin_freischaltungen" %} + +{% block main %} +
+

Freischaltungen

+

+ Ausstehende Registrierungen · nur für Admins +

+
+ +
+ Lade ausstehende Freischaltungen … +
+ + + + + + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/admin_queue.html b/app/templates/v2/screens/admin_queue.html new file mode 100644 index 0000000..18a6dc0 --- /dev/null +++ b/app/templates/v2/screens/admin_queue.html @@ -0,0 +1,175 @@ +{% extends "v2/base.html" %} + +{% block title %}Queue-Status — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "admin_queue" %} + +{% block main %} +
+
+

Queue-Status

+

+ Analyse-Jobs · automatische Aktualisierung alle 5 s +

+
+ +
+ +
+ Lade Queue … +
+ + + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/admin_wahlprogramme.html b/app/templates/v2/screens/admin_wahlprogramme.html new file mode 100644 index 0000000..ab8c477 --- /dev/null +++ b/app/templates/v2/screens/admin_wahlprogramme.html @@ -0,0 +1,122 @@ +{% extends "v2/base.html" %} + +{% block title %}Wahlprogramme — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "admin_wahlprogramme" %} + +{% block main %} +
+

+ Wahlprogramm-Beschaffung +

+

+ Halbautomatisch (#138) · nur Lücken mit Kandidaten-URL aus wahlprogramm-links.yaml +

+
+ + + +{% if not missing %} +
+

Keine Lücken gefunden — alle registrierten Einträge haben eine Datei.

+
+{% else %} + + + + + + + + + + + + {% for entry in missing %} + {% set first_url = entry.kandidaten[0].url if entry.kandidaten else "" %} + + + + + + + + {% endfor %} + +
BLParteiDateinameKandidat-URL
{{ entry.bl }}{{ entry.partei }} + {{ entry.dateiname or "— noch nicht registriert —" }} + + + + +
+{% endif %} + +

+ Nach einem erfolgreichen Download müssen die Embeddings neu indexiert werden: + python -m app.reindex_embeddings +

+ + +{% endblock %} diff --git a/app/templates/v2/screens/antrag_detail.html b/app/templates/v2/screens/antrag_detail.html new file mode 100644 index 0000000..398c580 --- /dev/null +++ b/app/templates/v2/screens/antrag_detail.html @@ -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" %} + + + + + + +{% if antrag.updated_at_raw %} + +{% endif %} + + + + +{% endif %} +{% endblock %} + +{% set v2_active_nav = "durchsuchen" %} + +{% block main %} + +{# ── Fehlerfall ──────────────────────────────────────────────────── #} +{% if error is defined and error %} +
+

Antrag nicht gefunden

+

{{ error }}

+

← Zurück zur Übersicht

+
+ +{% 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 ─────────────────────────────────────────────────── #} +

+ ← Zurück zur Übersicht +

+ +{# ── Split-Layout ────────────────────────────────────────────────── #} +
+ + {# ── Linke Spalte: Redaktionelle Analyse ── #} +
+
+ {{ antrag.bundesland | default("") }} + {% if antrag.drucksache %} · Drs. {{ antrag.drucksache }}{% endif %} + {% if antrag.typ %} · {{ antrag.typ }}{% endif %} + {% if antrag.datum %} · eingebracht {{ antrag.datum }}{% endif %} +
+ +

{{ antrag.title | default("Antrag") }}

+ + {% if antrag.parteien or antrag.analysiert %} + + {% endif %} + + {% if antrag.zusammenfassung %} +

Zusammenfassung

+

{{ antrag.zusammenfassung }}

+ {% endif %} + + {# Stärkster Wert #} + {% if antrag.staerkster_wert and antrag.staerkster_wert.text %} +
+

Stärkster Wert{% if antrag.staerkster_wert.titel %} — {{ antrag.staerkster_wert.titel }}{% endif %}

+

{{ antrag.staerkster_wert.text }}

+
+ {% elif antrag.staerken %} +
+

Stärken

+
    + {% for s in antrag.staerken %} +
  • {{ s }}
  • + {% endfor %} +
+
+ {% endif %} + + {# Schwächster Wert #} + {% if antrag.schwaechster_wert and antrag.schwaechster_wert.text %} +
+

Schwächster Wert{% if antrag.schwaechster_wert.titel %} — {{ antrag.schwaechster_wert.titel }}{% endif %}

+

{{ antrag.schwaechster_wert.text }}

+
+ {% elif antrag.schwaechen %} +
+

Schwächen

+
    + {% for s in antrag.schwaechen %} +
  • {{ s }}
  • + {% endfor %} +
+
+ {% endif %} + + {# Redline-Vorschläge: alle verbesserungen rendern wenn vorhanden #} + {% if antrag.verbesserungen %} +

Redline-Vorschläge

+ {% for v in antrag.verbesserungen %} +
+ {% if antrag.verbesserungen | length > 1 %} +
+ Vorschlag {{ loop.index }} von {{ antrag.verbesserungen | length }} +
+ {% 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 %} +

+ {{ v.begruendung }} +

+ {% endif %} +
+ {% endfor %} + {% elif antrag.redline and antrag.redline.segments %} +

Redline-Vorschlag

+ {% from "v2/components/redline.html" import redline %} + {{ redline(segments=antrag.redline.segments) }} + {% endif %} + +
{# .left #} + + {# ── Rechte Spalte: Bewertungs-Panel ── #} +
+
Bewertung
+ + {{ score_hero(antrag.score | default(0), antrag.verdict_title | default(""), antrag.verdict_body | default("")) }} + + {# ── Merkliste-Stern (#140) ── #} +
+ +
+ + {# ── Namentliche Abstimmung (#106 Phase 1) ── #} + {% if antrag.abstimmungsverhalten %} + {% set aw = antrag.abstimmungsverhalten %} +

Namentliche Abstimmung

+
+ {% if aw.datum %}{{ aw.datum }} · {% endif %} + {% if aw.accepted %}Angenommen{% else %}Abgelehnt{% endif %} +
+ {% for f in aw.fraktionen %} + {% set total = (f.yes + f.no + f.abstain + f.no_show) | int %} + {% if total > 0 %} +
+
+ {{ f.partei }} + {{ f.yes }}✓ {{ f.abstain }}○ {{ f.no }}✗ +
+
+ {% if f.yes > 0 %} +
+ {% endif %} + {% if f.abstain > 0 %} +
+ {% endif %} + {% if f.no > 0 %} +
+ {% endif %} +
+
+ {% endif %} + {% endfor %} + {% endif %}{# abstimmungsverhalten #} + + {% if antrag.matrix %} +

Matrix 2.0 · 25 Felder

+ {{ matrix_mini(antrag.matrix) }} + {% endif %} + + {# Fraktions-Score-Tabelle (Fix 2+3): auch Fraktionen ohne Zitate sichtbar #} + {% if antrag.fraktions_scores %} +

Programm-Treue pro Fraktion

+
+ {% for fs in antrag.fraktions_scores %} +
+
+ {{ fs.fraktion }} + {% if fs.ist_antragsteller %}A{% endif %} + {% if fs.ist_regierung %}R{% endif %} +
+
+ {% set wp_score = fs.wahlprogramm.score | float %} + {% set pp_score = fs.parteiprogramm.score | float %} + + WP {{ "%.0f"|format(wp_score) }}/10 + + + PP {{ "%.0f"|format(pp_score) }}/10 + +
+
+ {% endfor %} +
+ {% 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 %} +

Belege — {{ z.partei }}

+ {% endif %} + {{ quote_card(z.text, z.source, z.verified | default(True), z.contra | default(False), z.pdf_href | default("")) }} + {% endfor %} + {% endif %} + + {# Aktions-Links #} + + + {# ── Voting-Block ─────────────────────────────────────────────── #} +
+
Bewertung treffend?
+
+ + +
+
+ + {# ── Share-Block ──────────────────────────────────────────────── #} +
+
Teilen
+
+ + + +
+
+ + {# ── Re-Analyze-Block ─────────────────────────────────────────── #} +
+ +
+ + {# ── Bewertungs-Historie ───────────────────────────────────────── #} +
+
Bewertungs-Historie
+
+ Lade… +
+
+ +
{# .right #} + +
{# .v2-detail #} + +{# ── Kommentare ───────────────────────────────────────────────────────── #} +
+

Kommentare

+ +
+ Lade… +
+ + {# Kommentar-Formular — wird per JS eingeblendet wenn angemeldet #} + + + +
+ +{# ── Matrix-Feld-Info-Modal ───────────────────────────────────────────── #} + + +{% endif %}{# _render #} + +{% endblock %} + +{% block body_scripts %} + + +{# Matrix-Erklärungen als JSON in den Browser übertragen #} +{% if matrix_explanations is defined %} + +{% endif %} + +{% if antrag is defined and antrag and antrag.drucksache %} + +{% endif %} +{% endblock %} diff --git a/app/templates/v2/screens/auswertungen.html b/app/templates/v2/screens/auswertungen.html new file mode 100644 index 0000000..c33df45 --- /dev/null +++ b/app/templates/v2/screens/auswertungen.html @@ -0,0 +1,430 @@ +{% extends "v2/base.html" %} + +{% block title %}Auswertungen — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "auswertungen" %} + +{% block head_extra %} + + +{% endblock %} + +{% block main %} +
+

Auswertungen

+

+ Bundesland × Partei · Thema × Fraktion · Cluster +

+
+ + +
+ + + +
+ + +
+
+ + + + + + +
+
+
Lade Matrix …
+
+
+
+ + +
+
+ + +
+
+
Wählen Sie den Tab „Thema × Fraktion".
+
+
+ + +
+
+

Cluster-Ansicht

+

+ Die interaktive Cluster-Übersicht finden Sie unter + /v2/cluster. + Sie zeigt thematisch ähnliche Anträge als redaktionelle Liste und verlinkt + zur Force-Graph-Visualisierung. +

+ + Zur Cluster-Ansicht → + +
+
+ + + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/batch.html b/app/templates/v2/screens/batch.html new file mode 100644 index 0000000..9cf3fcb --- /dev/null +++ b/app/templates/v2/screens/batch.html @@ -0,0 +1,151 @@ +{% extends "v2/base.html" %} + +{% block title %}Batch-Analyse — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "batch" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Batch-Analyse

+

+ Mehrere ungeprüfte Anträge eines Bundeslandes auf einmal analysieren +

+
+ +
+

+ 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 + Queue-Ansicht (klassisch) einsehbar. +

+

+ Hinweis: Batch-Analyse erfordert Admin-Rechte. Bei fehlendem Zugriff schlägt der Aufruf mit 403 fehl. +

+
+ +
+ + + + + + + + + +
+ +
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/cluster.html b/app/templates/v2/screens/cluster.html new file mode 100644 index 0000000..f79a278 --- /dev/null +++ b/app/templates/v2/screens/cluster.html @@ -0,0 +1,247 @@ +{% extends "v2/base.html" %} + +{% block title %}Cluster — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "cluster" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Cluster

+

+ Thematisch ähnliche Anträge · Cosine-Similarity über Embeddings +

+
+ + +
+ + + + + + 0.55 + + +
+ + +
+
Lade Cluster …
+
+ + +
+ ← Zurück zur Übersicht +
+
+ + +
+ Vollständige Force-Graph-Visualisierung: + Klassische Ansicht → +
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/durchsuchen.html b/app/templates/v2/screens/durchsuchen.html new file mode 100644 index 0000000..c839cb8 --- /dev/null +++ b/app/templates/v2/screens/durchsuchen.html @@ -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 ─────────────────────────── #} + + +{# ── Score-Filter + Sortierung ───────────────────────────────────── #} + + +{# ── Ergebnisliste ───────────────────────────────────────────────── #} +
+ +{% if assessments %} + {% for a in assessments %} + {{ result_row(a) }} + {% endfor %} +{% else %} +

+ Noch keine Bewertungen in der Datenbank. +

+{% endif %} + +
{# #v2-results #} + +{# ── Empty-State ─────────────────────────────────────────────────── #} + + +{% endblock %} + +{% block body_scripts %} + + +{# ── Keyboard-Hilfe-Modal ───────────────────────────────────────── #} + + +{% endblock %} diff --git a/app/templates/v2/screens/landtag_suche.html b/app/templates/v2/screens/landtag_suche.html new file mode 100644 index 0000000..1220043 --- /dev/null +++ b/app/templates/v2/screens/landtag_suche.html @@ -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 %} + +{% endblock %} + +{% block main %} +
+

+ Landtag-Suche +

+

+ Drucksachen direkt aus dem Landtags-Portal suchen — nicht nur aus der eigenen Datenbank +

+
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/legal.html b/app/templates/v2/screens/legal.html new file mode 100644 index 0000000..558755c --- /dev/null +++ b/app/templates/v2/screens/legal.html @@ -0,0 +1,264 @@ +{% extends "v2/base.html" %} + +{% block title %}{{ title }} — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

{{ title }}

+
+ +{# .legal-body #} +{% endblock %} diff --git a/app/templates/v2/screens/merkliste.html b/app/templates/v2/screens/merkliste.html new file mode 100644 index 0000000..1137c17 --- /dev/null +++ b/app/templates/v2/screens/merkliste.html @@ -0,0 +1,269 @@ +{% extends "v2/base.html" %} + +{% block title %}Merkliste — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "merkliste" %} + +{% block main %} +
+

Merkliste

+

+ Gemerkte Anträge · nur für angemeldete Nutzer:innen +

+
+ +{% from "v2/components/result_row.html" import result_row %} + + + + + +
+ Lade Merkliste … +
+ + + + + +
+ + + + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/methodik.html b/app/templates/v2/screens/methodik.html new file mode 100644 index 0000000..4bc3e8f --- /dev/null +++ b/app/templates/v2/screens/methodik.html @@ -0,0 +1,460 @@ +{% extends "v2/base.html" %} + +{% block title %}Methodik — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Methodik

+

+ GWÖ-Matrix 2.0 · Gemeinden · Transparenz-Dokumentation +

+
+ +
+ + + + + +
+ +
+

Was ist die Gemeinwohl-Ökonomie?

+
+

+ Die Gemeinwohl-Ökonomie (GWÖ) ist ein Wirtschaftsmodell, das den + Erfolg wirtschaftlichen Handelns nicht am Gewinn, sondern am Beitrag zum + Gemeinwohl 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. +

+ +

Das Bewertungsmodell: die Gemeinwohl-Bilanz

+

+ Das Kernstück ist die Gemeinwohl-Bilanz: 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 Unternehmens-Matrix (Version 5.1) ist in über 35 Ländern erprobt. +

+

+ → Arbeitsbuch Unternehmen 5.1 (PDF) · + Matrix-Übersicht +

+ +

Adaption für die öffentliche Hand

+

+ Für Gemeinden gibt es seit 2017 das Arbeitsbuch für Gemeinden Version 2.0. + Es überträgt die Unternehmens-Matrix auf kommunale Handlungsfelder: + statt „Kund:innen" stehen Bürger:innen im Fokus, statt „Lieferkette" + geht es um öffentliche Beschaffung. + Eine aktualisierte Version 2.1.A läuft seit 2023 im Pilotbetrieb. +

+

+ → Arbeitsbuch Gemeinden 2.0 (PDF) · + Version 2.1.A Pilotfassung (PDF) +

+ +

Anwendung auf Parlamentsanträge

+

+ Dieser Antragsprüfer 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. +

+
+
+ +
+

Was macht der GWÖ-Antragsprüfer?

+
+

Jeder Antrag wird automatisch analysiert und erhält:

+
    +
  • GWÖ-Score (0–10) — wie stark fördert oder widerspricht der Antrag den fünf Gemeinwohl-Werten?
  • +
  • 25-Felder-Matrix — detaillierte Bewertung für jede Kombination aus Berührungsgruppe und Wert
  • +
  • Wahlprogramm-Treue — wie gut passt der Antrag zu den Wahl- und Grundsatzprogrammen der Fraktionen, belegt mit verifizierten Zitaten?
  • +
  • Verbesserungsvorschläge — konkrete Textänderungen im Redline-Format
  • +
+

+ Ziel ist Transparenz: 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. +

+
+
+ +
+

Die GWÖ-Matrix 2.0 für Gemeinden

+
+

5 Berührungsgruppen (Zeilen) × 5 Werte (Spalten) = 25 Bewertungsfelder. + Jedes Feld wird von −5 (fundamental widersprechend) bis +5 + (stark fördernd) bewertet. Der GWÖ-Score (0–10) ist ein gewichteter Durchschnitt.

+
+ +

Die fünf Werte (Spalten)

+ + + + + + + +
WertLeitfrage
1. MenschenwürdeWerden Grundrechte geschützt? Rechtliche Gleichstellung? Schutz vor Diskriminierung?
2. SolidaritätWird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft? Kooperation statt Konkurrenz?
3. Ökologische NachhaltigkeitKlimaschutz? Ressourcenschonung? Biodiversität? Kreislaufwirtschaft?
4. Soziale GerechtigkeitGerechte Verteilung? Daseinsvorsorge? Soziale Absicherung? Chancengleichheit?
5. Transparenz & DemokratieBürgerbeteiligung? Offenlegung? Demokratische Prozesse? Rechenschaftspflicht?
+ +

Die fünf Berührungsgruppen (Zeilen)

+ + + + + + + +
GruppeWer ist gemeint?
A · Lieferant:innenExterne Beschaffung, Lieferketten, Dienstleister:innen
B · FinanzenUmgang mit öffentlichen Mitteln, Haushalt, Steuerzahler:innen
C · VerwaltungMandatsträger:innen, Mitarbeitende, Ehrenamtliche
D · Bürger:innenWirkung innerhalb der Grenzen, Daseinsvorsorge
E · Gesellschaft & NaturWirkung über die Grenzen hinaus, Zukunft
+ +

Alle 25 Felder

+

Klick auf ein Feld für Details.

+ +
+
+ + +
+
+
+ +
+
+
Menschen­würde
+
Solidarität
+
Ökol. Nachh.
+
Soz. Gerecht.
+
Transparenz
+ +
A · Lieferant:innen
+
A1
Grundrechte Lieferkette
+
A2
Nutzen Gemeinde
+
A3
Ökol. Verantwortung
+
A4
Soziale Verantwortung
+
A5
Rechenschaft
+ +
B · Finanzen
+
B1
Eth. Finanzgebaren
+
B2
Gemeinnutz
+
B3
Ökol. Finanzpolitik
+
B4
Soz. Finanzpolitik
+
B5
Partizipation
+ +
C · Verwaltung
+
C1
Gleichstellung
+
C2
Gemeinsame Ziele
+
C3
Ökol. Verhalten
+
C4
Gerechte Arbeit
+
C5
Transparenz intern
+ +
D · Bürger:innen
+
D1
Rechtsgleichheit
+
D2
Gesamtwohl
+
D3
Ökol. Leistung
+
D4
Soz. Leistung
+
D5
Demokratie
+ +
E · Gesellschaft
+
E1
Zukunft
+
E2
Beitrag Gesamtwohl
+
E3
Ökol. Auswirkungen
+
E4
Sozialer Ausgleich
+
E5
Demokratie global
+
+ +
+ Bewertungsskala + + + + + + + +
SymbolRatingBedeutung
+++4 bis +5Stark fördernd, vorbildlich
++1 bis +3Fördernd
0Neutral / nicht berührt
−1 bis −3Widersprechend
−−−4 bis −5Stark widersprechend
+
+
+ +
+

Analyse-Pipeline

+
+
+
1
+
+ Antragstext laden
+ 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. +
+
+
+
2
+
+ Wahlprogramm-Passagen suchen
+ Per semantischer Suche ({{ embedding_model }}, 1024 Dimensionen) werden für + jede Fraktion die thematisch relevantesten Passagen aus + Wahl- und Grundsatzprogrammen gefunden. Aktuell {{ programme_count }} Programme + mit {{ chunk_count }} Textabschnitten indexiert. +
+
+
+
3
+
+ KI-Bewertung
+ 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. +
+
+
+
4
+
+ Zitat-Verifikation
+ Jedes Zitat wird server-seitig verifiziert: 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. +
+
+ +
+ Technische Details + + + + + + + +
EigenschaftWert
Sprachmodell{{ model_name }} (DashScope / Alibaba Cloud)
Embedding-Modell{{ embedding_model }} (1024 Dimensionen)
Chunk-Größe400 Wörter, 50 Wörter Overlap
Retry bei Parse-Fehlern3 Versuche mit steigender Temperatur
Zitat-VerifikationSubstring- oder 5-Wort-Anker-Match gegen Original-PDF
+
+
+
+ +
+

Qualitätssicherung

+
+
    +
  • Automatische Zitat-Verifikation — jedes Zitat wird gegen das + Original-PDF geprüft. Nicht-verifizierbare Zitate werden verworfen.
  • +
  • Typ-Filterung — nur abstimmbare Drucksachen (Anträge, + Gesetzentwürfe) werden bewertet.
  • +
  • Automatische Neu-Analyse — wenn ein Zitat nicht auffindbar ist, + wird der Antrag mit der aktuellen Pipeline neu analysiert.
  • +
  • Open Data — alle Bewertungen sind als JSON und CSV exportierbar + (CC BY 4.0).
  • +
+
+
+ +
+

Einschränkungen

+
+
    +
  • Wertebasierte Einordnung, keine Rechtsprüfung
  • +
  • KI-Bias — Sprachmodelle können systematische Verzerrungen aufweisen. Bewertungen sind Orientierung, nicht objektive Wahrheit.
  • +
  • Programmabhängig — Fraktionen ohne hinterlegtes Wahlprogramm erhalten keinen Programm-Vergleich.
  • +
  • Antragstext, nicht Umsetzung — bewertet wird was im Antrag steht, nicht ob es umgesetzt wird.
  • +
+
+
+ +
+

Datenquellen

+
+

{{ adapter_count }} Parlamente angebunden:

+ + + {% for bl in bundeslaender %} + + {% endfor %} +
ParlamentSystem
{{ bl.name }} ({{ bl.code }}){{ bl.doku_system }}
+

+ Programme & Quellen · + Open Data (JSON) · + Atom-Feed · + Quellcode +

+
+
+ +
{# .meth-body #} +
{# .meth-layout #} + +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/neu.html b/app/templates/v2/screens/neu.html new file mode 100644 index 0000000..98c7726 --- /dev/null +++ b/app/templates/v2/screens/neu.html @@ -0,0 +1,176 @@ +{% extends "v2/base.html" %} + +{% block title %}Neuer Antrag — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "neu" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Neuer Antrag prüfen

+

+ Drucksachen-Nr. eingeben · Analyse startet sofort +

+
+ +
+

+ 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. +

+
+ +
+ + + + + + + + + + + + +
+ +
+ + +
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/quellen.html b/app/templates/v2/screens/quellen.html new file mode 100644 index 0000000..3e9be98 --- /dev/null +++ b/app/templates/v2/screens/quellen.html @@ -0,0 +1,219 @@ +{% extends "v2/base.html" %} + +{% block title %}Quellen — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Quellen & Referenzdokumente

+

+ Wahl- und Grundsatzprogramme · semantisch indexiert +

+
+ +
+

+ Der GWÖ-Antragsprüfer vergleicht parlamentarische Anträge mit den Wahl- und Grundsatzprogrammen + der Parteien. Hier finden Sie alle verwendeten Originaldokumente zum Download. +

+

+ Die Programme werden semantisch indexiert, um relevante Passagen für jeden Antrag zu finden. +

+
+ + +
+

Indexierungsstatus

+
+
+
{{ status.indexed }}
+
Indexiert
+
+
+
{{ status.total }}
+
Gesamt
+
+
+
+ + +
+
+ + +{% for bl_name, bl_progs in wahlprogramme_grouped %} +

{{ bl_name }}

+
+ {% for prog in bl_progs %} +
+ + {{ prog.name }} + +
+
+ {{ prog.partei }} + {{ prog.name }} +
+
+ Wahlprogramm + {% if prog.bundesland %}{{ prog.bundesland }}{% endif %} +
+
+ + PDF herunterladen + + {% for s in status.programmes if s.id == prog.id %} + {% if s.indexed %} + ✓ {{ s.chunks }} Chunks + {% else %} + ○ Nicht indexiert + {% endif %} + {% endfor %} +
+
+
+ {% endfor %} +
+{% endfor %} + + +

Grundsatzprogramme (Bundesebene)

+
+ {% for prog in grundsatzprogramme %} +
+ + {{ prog.name }} + +
+
+ {{ prog.partei }} + {{ prog.name }} +
+
+ Grundsatzprogramm +
+
+ + PDF herunterladen + + {% for s in status.programmes if s.id == prog.id %} + {% if s.indexed %} + ✓ {{ s.chunks }} Chunks + {% else %} + ○ Nicht indexiert + {% endif %} + {% endfor %} +
+
+
+ {% endfor %} +
+ +
+

Hinweise zur Methodik

+
    +
  • Die Programme werden in semantische Chunks aufgeteilt (~400 Wörter)
  • +
  • Jeder Chunk wird mit einem Embedding-Modell vektorisiert
  • +
  • Bei der Analyse wird der Antrag ebenfalls vektorisiert
  • +
  • Die ähnlichsten Passagen werden als Kontext an das LLM übergeben
  • +
  • Das LLM zitiert nur, wenn eine Passage wirklich zur Argumentation passt
  • +
+
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/v2/screens/tags.html b/app/templates/v2/screens/tags.html new file mode 100644 index 0000000..dd57e5c --- /dev/null +++ b/app/templates/v2/screens/tags.html @@ -0,0 +1,179 @@ +{% extends "v2/base.html" %} + +{% block title %}Tags — GWÖ-Antragsprüfer{% endblock %} + +{% set v2_active_nav = "tags" %} + +{% block head_extra %} + +{% endblock %} + +{% block main %} +
+

Tags

+

+ Anträge nach Thema filtern +

+
+ + +
+ Lade Tags … +
+ + + + + +
+ Wählen Sie einen Tag aus. +
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/validators.py b/app/validators.py index 530fbb9..9cb206e 100644 --- a/app/validators.py +++ b/app/validators.py @@ -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: diff --git a/app/wahlprogramm-links.yaml b/app/wahlprogramm-links.yaml new file mode 100644 index 0000000..96aed26 --- /dev/null +++ b/app/wahlprogramm-links.yaml @@ -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: "" diff --git a/app/wahlprogramm_fetch.py b/app/wahlprogramm_fetch.py new file mode 100644 index 0000000..203d102 --- /dev/null +++ b/app/wahlprogramm_fetch.py @@ -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()