diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
index ba8b968..7290c87 100644
--- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
+++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
@@ -1,8 +1,12 @@
# KI-Prompt-System – Universelle Admin-Konfiguration
-**Version:** 1.0
-**Datum:** 2026-04-24
-**Status:** DRAFT
+**Version:** 1.1
+**Datum:** 2026-05-30
+**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8)
+
+**Ist-Stand API (Superadmin):**
+- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
+
**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
@@ -598,6 +602,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
---
-**Version:** 1.0
-**Datum:** 2026-04-24
-**Status:** DRAFT
+## 8. Prompt-Kaskaden (geplant — nicht implementiert)
+
+**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern.
+
+**Konzeptskizze:**
+- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`).
+- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung.
+- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen.
+
+Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`).
+
+---
+
+**Version:** 1.1
+**Datum:** 2026-05-30
+**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 1937e1d..4434184 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -35,17 +35,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
+| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
-Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068).
+Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile.
---
### Changelog (Fortführung)
+- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index bf7d714..4c8d19e 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
+from prompt_resolver import render_mustache_template
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@@ -506,12 +507,46 @@ def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
return d
-def _render_template(template: str, ctx: Dict[str, str]) -> str:
- out = template or ""
- for key, val in ctx.items():
- placeholder = "{{" + key + "}}"
- out = out.replace(placeholder, val if val is not None else "")
- return out
+def build_exercise_placeholder_variables(
+ cur,
+ *,
+ slug: str,
+ title: Optional[str],
+ goal: Optional[str],
+ execution: Optional[str],
+ focus_area_hint: Optional[str],
+ focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
+) -> Dict[str, str]:
+ """
+ Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
+ """
+ s = (slug or "").strip().lower()
+ if s == "pipeline":
+ return {}
+ g_plain = strip_html_to_plain(goal)
+ e_plain = strip_html_to_plain(execution)
+ t_title = (title or "").strip()
+ focus = (focus_area_hint or "").strip()
+ ctx: Dict[str, str] = {
+ "exercise_title": t_title or "-",
+ "exercise_focus_area": focus or "-",
+ "exercise_goal": g_plain or "-",
+ "exercise_execution": e_plain or "-",
+ }
+ if s == "exercise_summary":
+ return ctx
+ if s == "exercise_skill_suggestions":
+ catalog = build_contextual_skills_catalog_block(
+ cur,
+ title=t_title,
+ goal_plain=g_plain,
+ execution_plain=e_plain,
+ focus_hint=focus or None,
+ focus_ctx=focus_areas_context,
+ )
+ ctx["skills_catalog"] = catalog
+ return ctx
+ raise ValueError(f"Kein Platzhalter-Kontext fuer slug={slug!r} definiert.")
def _first_balanced_json_array(text: str) -> Optional[str]:
@@ -699,18 +734,25 @@ def run_exercise_ai_suggestion(
prow = _load_prompt_row(cur, "exercise_summary")
if not prow:
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
- ctx = {
- "exercise_title": t_title or "-",
- "exercise_focus_area": focus or "-",
- "exercise_goal": g_plain or "-",
- "exercise_execution": e_plain or "-",
- }
- prompt = _render_template(str(prow["template"]), ctx)
+ try:
+ ctx = build_exercise_placeholder_variables(
+ cur,
+ slug="exercise_summary",
+ title=title,
+ goal=goal,
+ execution=execution,
+ focus_area_hint=focus_area_hint,
+ focus_areas_context=focus_areas_context,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=500, detail=str(e)) from e
+ rendered = render_mustache_template(str(prow["template"]), ctx)
+ prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
- "AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s",
+ "AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s placeholders_remaining=%s",
len(prompt),
- prompt.count("{{"),
+ len(rendered.placeholders_remaining),
)
try:
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
@@ -735,27 +777,25 @@ def run_exercise_ai_suggestion(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
)
- catalog = build_contextual_skills_catalog_block(
- cur,
- title=t_title,
- goal_plain=g_plain,
- execution_plain=e_plain,
- focus_hint=focus or None,
- focus_ctx=focus_areas_context,
- )
- ctx = {
- "exercise_title": t_title or "-",
- "exercise_focus_area": focus or "-",
- "exercise_goal": g_plain or "-",
- "exercise_execution": e_plain or "-",
- "skills_catalog": catalog,
- }
- prompt = _render_template(str(srow["template"]), ctx)
+ try:
+ ctx = build_exercise_placeholder_variables(
+ cur,
+ slug="exercise_skill_suggestions",
+ title=title,
+ goal=goal,
+ execution=execution,
+ focus_area_hint=focus_area_hint,
+ focus_areas_context=focus_areas_context,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=500, detail=str(e)) from e
+ rendered = render_mustache_template(str(srow["template"]), ctx)
+ prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
"template_has_skills_placeholder=%s",
- len(catalog),
+ len(ctx.get("skills_catalog") or ""),
len(prompt),
"{{skills_catalog}}" in str(srow.get("template") or ""),
)
@@ -808,6 +848,7 @@ def run_exercise_ai_suggestion(
__all__ = [
"build_contextual_skills_catalog_block",
+ "build_exercise_placeholder_variables",
"run_exercise_ai_suggestion",
"strip_html_to_plain",
]
diff --git a/backend/main.py b/backend/main.py
index 32e5519..9cdaeea 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -220,6 +220,7 @@ app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
+app.include_router(ai_prompts_admin.router)
app.include_router(ai_skill_retrieval_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
diff --git a/backend/migrations/069_ai_prompts_default_template.sql b/backend/migrations/069_ai_prompts_default_template.sql
new file mode 100644
index 0000000..bb91511
--- /dev/null
+++ b/backend/migrations/069_ai_prompts_default_template.sql
@@ -0,0 +1,10 @@
+-- Migration 069: ai_prompts default_template fuer Ruecksetzen & Transparenz
+-- Setzt fuer bestehende System-Prompt-Zeilen default_template aus dem aktuellen template,
+-- sofern noch kein Referenzinhalt gespeichert war (Migration 067 hatte NULL fuer exercise_*).
+
+UPDATE ai_prompts
+SET default_template = template,
+ updated_at = NOW()
+WHERE default_template IS NULL
+ AND template IS NOT NULL
+ AND LENGTH(TRIM(template)) > 0;
diff --git a/backend/prompt_resolver.py b/backend/prompt_resolver.py
new file mode 100644
index 0000000..191d16c
--- /dev/null
+++ b/backend/prompt_resolver.py
@@ -0,0 +1,128 @@
+"""
+Mustache-aehnliche Platzhalter {{schluessel}} fuer KI-Templates aus ai_prompts.
+
+Kein Vereinsbezug — reine Textersetzung; Aufrufe aus exercise_ai und Admin-Vorschau.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Dict, List, Mapping
+
+_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
+
+
+def _placeholder_pattern_for_key(key: str) -> re.Pattern[str]:
+ return re.compile(r"\{\{\s*" + re.escape(str(key).strip()) + r"\s*\}\}")
+
+
+@dataclass(frozen=True)
+class MustacheRenderResult:
+ """Ergebnis von render_mustache_template."""
+
+ text: str
+ keys_in_template: List[str]
+ keys_substituted: List[str]
+ keys_missing_variables: List[str]
+ placeholders_remaining: List[str]
+
+
+def extract_mustache_keys(template: str) -> List[str]:
+ """Platzhalter-Namen in Vorkommensreihenfolge, ohne erstes Duplikat."""
+ seen: set[str] = set()
+ ordered: List[str] = []
+ for m in _PLACEHOLDER_RE.finditer(template or ""):
+ k = str(m.group(1) or "").strip()
+ if not k or k in seen:
+ continue
+ seen.add(k)
+ ordered.append(k)
+ return ordered
+
+
+def render_mustache_template(template: str, variables: Mapping[str, str]) -> MustacheRenderResult:
+ """
+ Ersetzt {{keys}} durch die passenden Strings.
+ Variablen, die fuer einen im Template genutzten Key fehlen, werden als Leerstring ersetzt.
+
+ Rueckgabe-liste keys_missing_variables: Keys, die im Template vorkommen, aber nicht als Map-Keys
+ uebergeben wurden (oder None-Wert entsprachen Leerung).
+ """
+ tpl_in = template or ""
+ vars_norm: Dict[str, str] = {}
+ for k, v in variables.items():
+ vars_norm[str(k)] = "" if v is None else str(v)
+ keys_in = extract_mustache_keys(tpl_in)
+ missing_known: List[str] = []
+ out = tpl_in
+ substituted: List[str] = []
+
+ for key in keys_in:
+ pat = _placeholder_pattern_for_key(key)
+ repl = vars_norm.get(key)
+ if key not in vars_norm:
+ missing_known.append(key)
+ repl = ""
+ substituted.append(key)
+ out = pat.sub(repl, out)
+
+ still = extract_mustache_keys(out)
+
+ return MustacheRenderResult(
+ text=out,
+ keys_in_template=keys_in,
+ keys_substituted=substituted,
+ keys_missing_variables=missing_known,
+ placeholders_remaining=still,
+ )
+
+
+def exercise_placeholder_catalog() -> dict:
+ """
+ Statischer Platzhalter-Katalog fuer Uebungs-KI-Templates — deckt aktuelle Seeds ab.
+ (Erweiterung andere Kontexte: matrix/import folgen separat.)
+ """
+ defs = [
+ {
+ "key": "exercise_title",
+ "placeholder": "{{exercise_title}}",
+ "description": "Titel der Uebung (oder Platzhalter, wenn leer).",
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ },
+ {
+ "key": "exercise_focus_area",
+ "placeholder": "{{exercise_focus_area}}",
+ "description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ },
+ {
+ "key": "exercise_goal",
+ "placeholder": "{{exercise_goal}}",
+ "description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ },
+ {
+ "key": "exercise_execution",
+ "placeholder": "{{exercise_execution}}",
+ "description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ },
+ {
+ "key": "skills_catalog",
+ "placeholder": "{{skills_catalog}}",
+ "description": (
+ "Gewichtete, kontextbezogene Liste aus dem Skill-Katalog (retrieval_profiles). "
+ "Nur fuer exercise_skill_suggestions."
+ ),
+ "used_by_slugs": ["exercise_skill_suggestions"],
+ },
+ ]
+ return {"context": "exercise", "placeholders": defs}
+
+
+__all__ = [
+ "MustacheRenderResult",
+ "exercise_placeholder_catalog",
+ "extract_mustache_keys",
+ "render_mustache_template",
+]
diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py
new file mode 100644
index 0000000..b0bb55d
--- /dev/null
+++ b/backend/routers/ai_prompts_admin.py
@@ -0,0 +1,253 @@
+"""
+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,
+ }
diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py
index acecdc2..27c5015 100644
--- a/backend/scripts/check_access_layer_hints.py
+++ b/backend/scripts/check_access_layer_hints.py
@@ -24,6 +24,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"platform_media_storage.py",
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
+ "ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",
diff --git a/backend/version.py b/backend/version.py
index deb6ea3..27d5c61 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.157"
-BUILD_DATE = "2026-05-22"
-DB_SCHEMA_VERSION = "20260529068"
+APP_VERSION = "0.8.158"
+BUILD_DATE = "2026-05-30"
+DB_SCHEMA_VERSION = "20260530069"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -19,11 +19,12 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
+ "admin_ai_prompts": "1.0.0", # Superadmin Prompt-Pflege /api/admin/ai-prompts* (067/069 ai_prompts)
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
- "exercises": "2.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter
+ "exercises": "2.31.0", # AI: build_exercise_placeholder_variables + prompt_resolver-Mustache (Leerstring bei fehlenden Keys)
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@@ -38,6 +39,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.158",
+ "date": "2026-05-30",
+ "changes": [
+ "KI-Prompts: Backend prompt_resolver ({{platzhalter}} robust), Admin-API /api/admin/ai-prompts* (Liste, Detail, PUT, Preview, Reset), Migration 069 default_template;",
+ "Superadmin-Web: Admin KI Prompts (/admin/ai-prompts) mit Platzhalter-Katalog und Vorschau ohne OpenRouter.",
+ ],
+ },
{
"version": "0.8.157",
"date": "2026-05-22",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a5bbff3..43d6c1b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -55,6 +55,7 @@ const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
+const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@@ -309,6 +310,14 @@ const appRouter = createBrowserRouter([
),
},
+ {
+ path: 'admin/ai-prompts',
+ element: (
+
Ziel hier
') + const [pvExec, setPvExec] = useState('Ablauf hier
') + const [pvHint, setPvHint] = useState('') + const [pvFocusId, setPvFocusId] = useState('') + const [pvPreview, setPvPreview] = useState(null) + + const loadList = useCallback(async () => { + const [pList, cat] = await Promise.all([ + api.listAdminAiPrompts(), + api.getAdminAiPromptPlaceholdersCatalog(), + ]) + setPrompts(Array.isArray(pList) ? pList : []) + setCatalog(cat || null) + }, []) + + useEffect(() => { + if (!isSuperadmin) return + let cancelled = false + ;(async () => { + setLoading(true) + setError('') + try { + await loadList() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, loadList]) + + useEffect(() => { + if (!isSuperadmin || !selectedId) { + setDetail(null) + return + } + let cancelled = false + ;(async () => { + try { + const d = await api.getAdminAiPrompt(selectedId) + if (!cancelled) { + setDetail(d) + setDraftName(d.display_name || '') + setDraftDesc(d.description || '') + setDraftTemplate(d.template || '') + setDraftActive(!!d.active) + setPvPreview(null) + } + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, selectedId]) + + const save = async () => { + if (!detail?.id) return + setSaving(true) + setError('') + try { + await api.updateAdminAiPrompt(detail.id, { + template: draftTemplate, + display_name: draftName, + description: draftDesc, + active: draftActive, + }) + await loadList() + const nd = await api.getAdminAiPrompt(detail.id) + setDetail(nd) + setPvPreview(null) + } catch (e) { + setError(e.message || String(e)) + } finally { + setSaving(false) + } + } + + const resetTemplate = async () => { + if (!detail?.id || !detail.has_reference_template) return + if (!confirm('Template auf gespeicherten Referenztext zurücksetzen?')) return + setSaving(true) + try { + const nd = await api.resetAdminAiPromptTemplate(detail.id) + setDetail(nd) + setDraftTemplate(nd.template || '') + await loadList() + } catch (e) { + setError(e.message || String(e)) + } finally { + setSaving(false) + } + } + + const runPreview = async () => { + if (!detail?.id) return + setError('') + try { + const body = { + title: pvTitle, + goal: pvGoal, + execution: pvExec, + focus_hint: pvHint || undefined, + } + const fid = parseInt(String(pvFocusId).trim(), 10) + if (Number.isFinite(fid) && fid >= 1) { + body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + } + const r = await api.previewAdminAiPrompt(detail.id, body) + setPvPreview(r) + } catch (e) { + setError(e.message || String(e)) + } + } + + if (!isSuperadmin) return
+ Datenbankvorlagen (ai_prompts) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
+ aufgelöst — die Vorschau unten ruft kein externes Modell auf.
+
{error}
: null} + +Prompt links wählen.
+ ) : ( +{detail?.slug} · Ausgabe:{' '}
+ {detail?.output_format} · Kategorie: {detail?.category}
+ {ph.placeholder} — {ph.description}{' '}
+
+ [{Array.isArray(ph.used_by_slugs) ? ph.used_by_slugs.join(', ') : ''}]
+
+ Wird geladen …
+ )} +{pvPreview.warning}
+ ) : null} + {pvPreview?.placeholders_remaining?.length ? ( ++ Unbekannte Platzhalter im Ergebnis:{' '} + {pvPreview.placeholders_remaining.join(', ')} +
+ ) : null} + {pvPreview?.resolved_template != null ? ( +
+ {pvPreview.resolved_template}
+
+ ) : null}
+