shinkan-jinkendo/backend/routers/ai_prompts_admin.py
Lars 9cee862c32
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 49s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m26s
Implement Planning Prompt Enhancements and LLM Usage Tracking
- Added new fields for goal query, user notes, max steps, and search query in the AiPromptPreviewBody to support planning prompts.
- Integrated planning prompt handling in the preview_ai_prompt function, allowing for distinct processing of planning and exercise prompts.
- Introduced LLM usage tracking in openrouter_chat_completion and planning_exercise_suggest functions to monitor AI call metrics.
- Updated frontend components to accommodate new input fields for planning prompts, enhancing user experience and functionality.
2026-06-15 07:50:49 +02:00

263 lines
11 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_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,
}