shinkan-jinkendo/backend/routers/ai_prompts_admin.py
Lars 2148d0aa7f
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Update AI Prompt System and Admin API
- Incremented version to 1.1 and updated the status to reflect the implementation of core features including `ai_prompts`, `prompt_resolver`, and the Superadmin HTTP API.
- Documented the current API endpoints for managing AI prompts, including CRUD operations and preview functionality.
- Introduced a new placeholder catalog and preview capabilities for the Superadmin interface.
- Enhanced the backend with new functions for handling AI prompt templates and integrated them into the API.
- Updated frontend components to include navigation and routing for the new Admin AI Prompts page.
- Incremented application version to 0.8.158 and updated changelog to reflect these changes.
2026-05-22 11:02:02 +02:00

254 lines
9.6 KiB
Python

"""
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,
}