""" 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 ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_job import resolve_exercise_form_variables from ai_prompt_planning_preview import ( PlanningPromptPreviewInput, is_planning_prompt_slug, resolve_planning_prompt_preview_variables, ) from ai_prompt_runtime import render_ai_prompt_template_for_row from db import get_cursor, get_db, r2d from prompt_resolver import exercise_placeholder_catalog 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, openrouter_model, 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) openrouter_model: Optional[str] = Field(None, max_length=200) class AiPromptPreviewBody(ExerciseFormAiPromptContext): """Preview-POST: Übungs-KI und Planungs-Prompts.""" goal_query: Optional[str] = Field(default=None, max_length=2000) user_notes: Optional[str] = Field(default=None, max_length=2000) max_steps: Optional[int] = Field(default=None, ge=2, le=10) search_query: Optional[str] = Field(default=None, max_length=2000) @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, openrouter_model 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() next_openrouter = old.get("openrouter_model") if body.openrouter_model is not None: cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else "" if any(c in cand for c in ("\r", "\n", "\t")): raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.") next_openrouter = cand or None cur.execute( """ UPDATE ai_prompts SET template = %s, active = %s, display_name = %s, description = %s, openrouter_model = %s, updated_at = NOW() WHERE id = %s RETURNING id, slug, display_name, description, template, category, output_format, output_schema, is_system_default, default_template, openrouter_model, active, sort_order, created_at, updated_at """, (next_template, next_active, next_name, next_desc, next_openrouter, 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, openrouter_model, 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() vars_map: Dict[str, str] warn: Optional[str] = None if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"): try: vars_map = resolve_exercise_form_variables(cur, slug, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e elif is_planning_prompt_slug(slug): planning_in = PlanningPromptPreviewInput( goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(), user_notes=(body.user_notes or "").strip(), max_steps=body.max_steps if body.max_steps is not None else 5, search_query=(body.search_query or body.goal_query or "").strip() or None, ) try: vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in) 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_ai_prompt_template_for_row(row, 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, }