From 2148d0aa7fc702fd71d9f159eb8479deebc7eeee Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 11:02:02 +0200 Subject: [PATCH 1/4] 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. --- .../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 29 +- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +- backend/exercise_ai.py | 105 ++++-- backend/main.py | 3 +- .../069_ai_prompts_default_template.sql | 10 + backend/prompt_resolver.py | 128 +++++++ backend/routers/ai_prompts_admin.py | 253 +++++++++++++ backend/scripts/check_access_layer_hints.py | 1 + backend/version.py | 17 +- frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 4 +- frontend/src/pages/AdminAiPromptsPage.jsx | 346 ++++++++++++++++++ frontend/src/utils/api.js | 37 ++ 13 files changed, 900 insertions(+), 46 deletions(-) create mode 100644 backend/migrations/069_ai_prompts_default_template.sql create mode 100644 backend/prompt_resolver.py create mode 100644 backend/routers/ai_prompts_admin.py create mode 100644 frontend/src/pages/AdminAiPromptsPage.jsx 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: ( + + + + ), + }, { path: 'trainer-contexts', element: }, ], }, diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 8681130..7fc5c75 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant). @@ -12,7 +12,7 @@ export default function AdminPageNav() { { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }, { to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale }, - { to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain }, + { to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles }, ] return ( diff --git a/frontend/src/pages/AdminAiPromptsPage.jsx b/frontend/src/pages/AdminAiPromptsPage.jsx new file mode 100644 index 0000000..de45cd1 --- /dev/null +++ b/frontend/src/pages/AdminAiPromptsPage.jsx @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { Sparkles } from 'lucide-react' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' +import AdminPageNav from '../components/AdminPageNav' + +/** + * Pflege von ai_prompts (OpenRouter-Vorlagen) — nur Superadmin. + */ +export default function AdminAiPromptsPage() { + const { user } = useAuth() + const isSuperadmin = user?.role === 'superadmin' + + const [prompts, setPrompts] = useState([]) + const [catalog, setCatalog] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const [draftName, setDraftName] = useState('') + const [draftDesc, setDraftDesc] = useState('') + const [draftTemplate, setDraftTemplate] = useState('') + const [draftActive, setDraftActive] = useState(true) + + const [pvTitle, setPvTitle] = useState('Testübung') + const [pvGoal, setPvGoal] = useState('

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 + + if (loading) { + return ( +
+ +
+
+
+
+ ) + } + + return ( +
+ +
+ +

KI Prompts

+
+

+ Datenbankvorlagen (ai_prompts) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig + aufgelöst — die Vorschau unten ruft kein externes Modell auf. +

+ {error ?

{error}

: null} + +
+
+ Prompts +
    + {(prompts || []).map((p) => ( +
  • + +
  • + ))} +
+
+ +
+ {!selectedId ? ( +

Prompt links wählen.

+ ) : ( +
+
+
+ slug: {detail?.slug} · Ausgabe:{' '} + {detail?.output_format} · Kategorie: {detail?.category} +
+
+ + setDraftName(e.target.value)} /> +
+
+ +