""" Superadmin-API: Verwaltung von ai_prompts (Templates, Aktivierung, Vorschau). Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext. """ from __future__ import annotations from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from auth import require_auth from club_tenancy import is_superadmin from db import get_cursor, get_db, r2d from exercise_ai import build_exercise_placeholder_variables from prompt_resolver import exercise_placeholder_catalog, render_mustache_template router = APIRouter(tags=["admin_ai_prompts"]) def _require_superadmin(session: dict = Depends(require_auth)) -> dict: role = (session.get("role") or "").strip().lower() if not is_superadmin(role): raise HTTPException(status_code=403, detail="Nur Superadmins") return session def _prompts_table_ready(cur) -> bool: cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_prompts",)) row = cur.fetchone() if not row: return False val = row.get("t") if isinstance(row, dict) else row[0] return val is not None and str(val).strip() != "" def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]: cur.execute( """ SELECT id, slug, display_name, description, template, category, output_format, output_schema, is_system_default, default_template, active, sort_order, created_at, updated_at FROM ai_prompts WHERE id = %s """, (prompt_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Prompt nicht gefunden") return dict(row) class AiPromptUpdateBody(BaseModel): template: Optional[str] = None active: Optional[bool] = None display_name: Optional[str] = Field(None, max_length=200) description: Optional[str] = Field(None, max_length=8000) class AiPromptPreviewFocus(BaseModel): focus_area_id: int = Field(..., ge=1) is_primary: Optional[bool] = False class AiPromptPreviewBody(BaseModel): title: Optional[str] = "" goal: Optional[str] = None execution: Optional[str] = None focus_hint: Optional[str] = None focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None @router.get("/api/admin/ai-prompts/catalog/placeholders") def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)): return exercise_placeholder_catalog() @router.get("/api/admin/ai-prompts") def list_ai_prompts(session: dict = Depends(_require_superadmin)): with get_db() as conn: cur = get_cursor(conn) if not _prompts_table_ready(cur): raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") cur.execute( """ SELECT id, slug, display_name, description, category, output_format, active, sort_order, is_system_default, default_template FROM ai_prompts ORDER BY sort_order ASC NULLS LAST, id ASC """ ) rows = [r2d(r) if not isinstance(r, dict) else r for r in cur.fetchall()] out = [] for r in rows: dt = (r.get("default_template") or "").strip() tmpl = (r.get("template") or "").strip() is_modified = bool(dt and tmpl != dt) rr = {k: v for k, v in r.items() if k != "default_template"} rr["is_modified"] = is_modified if dt else False rr["has_reference_template"] = bool(dt) out.append(rr) return out @router.get("/api/admin/ai-prompts/{prompt_id:int}") def get_ai_prompt(prompt_id: int, session: dict = Depends(_require_superadmin)): with get_db() as conn: cur = get_cursor(conn) if not _prompts_table_ready(cur): raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") row = _fetch_prompt_any(cur, prompt_id) dt = (row.get("default_template") or "").strip() tmpl = (row.get("template") or "").strip() row_out = dict(row) row_out["is_modified"] = bool(dt and tmpl != dt) if dt else False row_out["has_reference_template"] = bool(dt) return row_out @router.put("/api/admin/ai-prompts/{prompt_id:int}") def update_ai_prompt( prompt_id: int, body: AiPromptUpdateBody, session: dict = Depends(_require_superadmin), ): with get_db() as conn: cur = get_cursor(conn) if not _prompts_table_ready(cur): raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") old = _fetch_prompt_any(cur, prompt_id) next_template = old["template"] if body.template is not None: tpl = body.template.strip() if isinstance(body.template, str) else "" if not tpl: raise HTTPException(status_code=400, detail="template darf nicht leer sein.") next_template = tpl next_active = bool(old.get("active", True)) if body.active is not None: next_active = body.active next_name = body.display_name if body.display_name is not None else old.get("display_name") or "" next_name = (next_name or "").strip() if not next_name: raise HTTPException(status_code=400, detail="display_name darf nicht leer sein.") next_desc = body.description if body.description is not None else old.get("description") or "" next_desc = (next_desc or "").strip() cur.execute( """ UPDATE ai_prompts SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW() WHERE id = %s RETURNING id, slug, display_name, description, template, category, output_format, output_schema, is_system_default, default_template, active, sort_order, created_at, updated_at """, (next_template, next_active, next_name, next_desc, prompt_id), ) row = dict(cur.fetchone()) conn.commit() dt = (row.get("default_template") or "").strip() tmpl = (row.get("template") or "").strip() row["is_modified"] = bool(dt and tmpl != dt) if dt else False return row @router.post("/api/admin/ai-prompts/{prompt_id:int}/reset-template") def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_superadmin)): with get_db() as conn: cur = get_cursor(conn) if not _prompts_table_ready(cur): raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") old = _fetch_prompt_any(cur, prompt_id) dt_old = old.get("default_template") if dt_old is None or not str(dt_old).strip(): raise HTTPException( status_code=400, detail="Kein gespeicherter Referenztext (default_template) — Ruecksetzen nicht möglich.", ) cur.execute( """ UPDATE ai_prompts SET template = default_template, updated_at = NOW() WHERE id = %s AND default_template IS NOT NULL RETURNING id, slug, display_name, description, template, category, output_format, output_schema, is_system_default, default_template, active, sort_order, created_at, updated_at """, (prompt_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Prompt nicht gefunden") rr = dict(row) conn.commit() tmpl = (rr.get("template") or "").strip() dt = (rr.get("default_template") or "").strip() rr["is_modified"] = bool(dt and tmpl != dt) if dt else False return rr @router.post("/api/admin/ai-prompts/{prompt_id:int}/preview") def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = Depends(_require_superadmin)): with get_db() as conn: cur = get_cursor(conn) if not _prompts_table_ready(cur): raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") row = _fetch_prompt_any(cur, prompt_id) slug = (row.get("slug") or "").strip().lower() tpl_raw = row.get("template") or "" fctx_list: Optional[List[tuple[int, bool]]] = None if body.focus_areas_context: pairs: List[tuple[int, bool]] = [] for x in body.focus_areas_context: pairs.append((int(x.focus_area_id), bool(x.is_primary))) fctx_list = pairs vars_map: Dict[str, str] warn: Optional[str] = None if slug in ("exercise_summary", "exercise_skill_suggestions"): try: vars_map = build_exercise_placeholder_variables( cur, slug=slug, title=(body.title or "").strip(), goal=body.goal, execution=body.execution, focus_area_hint=body.focus_hint, focus_areas_context=fctx_list, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e elif slug == "pipeline": vars_map = {} warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." else: vars_map = {} warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung." rendered = render_mustache_template(str(tpl_raw), vars_map) return { "slug": slug, "resolved_template": rendered.text, "keys_in_template": rendered.keys_in_template, "keys_missing_variables": rendered.keys_missing_variables, "placeholders_remaining": rendered.placeholders_remaining, "warning": warn, }