All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `openrouter_model` field to the `ai_prompts` table, allowing for optional model overrides per prompt. - Updated the `exercise_ai` module to utilize the effective OpenRouter model based on prompt-specific settings, enhancing flexibility in AI interactions. - Enhanced the admin interface to support OpenRouter model configuration for prompts, improving usability for Superadmins. - Incremented application version to 0.8.161 and updated changelog to reflect these changes, including migration details and new functionality.
251 lines
9.8 KiB
Python
251 lines
9.8 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 ai_prompt_job import ExerciseFormAiPromptContext, resolve_exercise_form_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 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, 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"):
|
|
try:
|
|
pf_ctx = ExerciseFormAiPromptContext.model_validate(body.model_dump())
|
|
vars_map = resolve_exercise_form_variables(cur, slug, pf_ctx)
|
|
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,
|
|
}
|