export der Fähigkeiten, KI- Admin #47
|
|
@ -1,8 +1,12 @@
|
||||||
# KI-Prompt-System – Universelle Admin-Konfiguration
|
# KI-Prompt-System – Universelle Admin-Konfiguration
|
||||||
|
|
||||||
**Version:** 1.0
|
**Version:** 1.1
|
||||||
**Datum:** 2026-04-24
|
**Datum:** 2026-05-30
|
||||||
**Status:** DRAFT
|
**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
|
**Autor:** Claude Code
|
||||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
**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
|
## 8. Prompt-Kaskaden (geplant — nicht implementiert)
|
||||||
**Datum:** 2026-04-24
|
|
||||||
**Status:** DRAFT
|
**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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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_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.
|
**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.
|
**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)
|
### 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-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||||
|
from prompt_resolver import render_mustache_template
|
||||||
|
|
||||||
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||||
|
|
||||||
|
|
@ -506,12 +507,46 @@ def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _render_template(template: str, ctx: Dict[str, str]) -> str:
|
def build_exercise_placeholder_variables(
|
||||||
out = template or ""
|
cur,
|
||||||
for key, val in ctx.items():
|
*,
|
||||||
placeholder = "{{" + key + "}}"
|
slug: str,
|
||||||
out = out.replace(placeholder, val if val is not None else "")
|
title: Optional[str],
|
||||||
return out
|
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]:
|
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")
|
prow = _load_prompt_row(cur, "exercise_summary")
|
||||||
if not prow:
|
if not prow:
|
||||||
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
||||||
ctx = {
|
try:
|
||||||
"exercise_title": t_title or "-",
|
ctx = build_exercise_placeholder_variables(
|
||||||
"exercise_focus_area": focus or "-",
|
cur,
|
||||||
"exercise_goal": g_plain or "-",
|
slug="exercise_summary",
|
||||||
"exercise_execution": e_plain or "-",
|
title=title,
|
||||||
}
|
goal=goal,
|
||||||
prompt = _render_template(str(prow["template"]), ctx)
|
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():
|
if _ai_debug_on():
|
||||||
_LOGGER.warning(
|
_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),
|
len(prompt),
|
||||||
prompt.count("{{"),
|
len(rendered.placeholders_remaining),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||||
|
|
@ -735,27 +777,25 @@ def run_exercise_ai_suggestion(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||||
)
|
)
|
||||||
catalog = build_contextual_skills_catalog_block(
|
try:
|
||||||
cur,
|
ctx = build_exercise_placeholder_variables(
|
||||||
title=t_title,
|
cur,
|
||||||
goal_plain=g_plain,
|
slug="exercise_skill_suggestions",
|
||||||
execution_plain=e_plain,
|
title=title,
|
||||||
focus_hint=focus or None,
|
goal=goal,
|
||||||
focus_ctx=focus_areas_context,
|
execution=execution,
|
||||||
)
|
focus_area_hint=focus_area_hint,
|
||||||
ctx = {
|
focus_areas_context=focus_areas_context,
|
||||||
"exercise_title": t_title or "-",
|
)
|
||||||
"exercise_focus_area": focus or "-",
|
except ValueError as e:
|
||||||
"exercise_goal": g_plain or "-",
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
"exercise_execution": e_plain or "-",
|
rendered = render_mustache_template(str(srow["template"]), ctx)
|
||||||
"skills_catalog": catalog,
|
prompt = rendered.text
|
||||||
}
|
|
||||||
prompt = _render_template(str(srow["template"]), ctx)
|
|
||||||
if _ai_debug_on():
|
if _ai_debug_on():
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
|
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
|
||||||
"template_has_skills_placeholder=%s",
|
"template_has_skills_placeholder=%s",
|
||||||
len(catalog),
|
len(ctx.get("skills_catalog") or ""),
|
||||||
len(prompt),
|
len(prompt),
|
||||||
"{{skills_catalog}}" in str(srow.get("template") or ""),
|
"{{skills_catalog}}" in str(srow.get("template") or ""),
|
||||||
)
|
)
|
||||||
|
|
@ -808,6 +848,7 @@ def run_exercise_ai_suggestion(
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"build_contextual_skills_catalog_block",
|
"build_contextual_skills_catalog_block",
|
||||||
|
"build_exercise_placeholder_variables",
|
||||||
"run_exercise_ai_suggestion",
|
"run_exercise_ai_suggestion",
|
||||||
"strip_html_to_plain",
|
"strip_html_to_plain",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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(import_wiki_admin.router)
|
||||||
app.include_router(legal_documents.router)
|
app.include_router(legal_documents.router)
|
||||||
app.include_router(content_reports.router)
|
app.include_router(content_reports.router)
|
||||||
|
app.include_router(ai_prompts_admin.router)
|
||||||
app.include_router(ai_skill_retrieval_admin.router)
|
app.include_router(ai_skill_retrieval_admin.router)
|
||||||
|
|
||||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
|
|
|
||||||
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
|
|
@ -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;
|
||||||
128
backend/prompt_resolver.py
Normal file
128
backend/prompt_resolver.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
253
backend/routers/ai_prompts_admin.py
Normal file
253
backend/routers/ai_prompts_admin.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
||||||
"platform_media_storage.py",
|
"platform_media_storage.py",
|
||||||
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
"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_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",
|
"catalogs.py",
|
||||||
"skills.py",
|
"skills.py",
|
||||||
"maturity_models.py",
|
"maturity_models.py",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.157"
|
APP_VERSION = "0.8.158"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-30"
|
||||||
DB_SCHEMA_VERSION = "20260529068"
|
DB_SCHEMA_VERSION = "20260530069"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"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_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
|
"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_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",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"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
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -38,6 +39,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.157",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||||
|
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||||
|
|
||||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||||
|
|
@ -309,6 +310,14 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/ai-prompts',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminAiPromptsPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
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).
|
* 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/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
{ 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 (
|
return (
|
||||||
|
|
|
||||||
346
frontend/src/pages/AdminAiPromptsPage.jsx
Normal file
346
frontend/src/pages/AdminAiPromptsPage.jsx
Normal file
|
|
@ -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('<p>Ziel hier</p>')
|
||||||
|
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
||||||
|
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 <Navigate to="/" replace />
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<AdminPageNav />
|
||||||
|
<div className="card" style={{ marginTop: 16, padding: 24, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px', paddingBottom: 96 }}>
|
||||||
|
<AdminPageNav />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||||
|
<Sparkles size={22} />
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
||||||
|
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
|
||||||
|
aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||||
|
</p>
|
||||||
|
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '260px minmax(0,1fr)', gap: 16 }}>
|
||||||
|
<div className="card" style={{ padding: 12 }}>
|
||||||
|
<strong style={{ fontSize: '13px' }}>Prompts</strong>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0 0', maxHeight: '70vh', overflow: 'auto' }}>
|
||||||
|
{(prompts || []).map((p) => (
|
||||||
|
<li key={p.id} style={{ marginBottom: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${selectedId === p.id ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '8px 10px',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
flexDirection: 'column',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(p.id)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>{p.display_name}</span>
|
||||||
|
<span style={{ opacity: 0.85, marginTop: 2 }}>{p.slug}</span>
|
||||||
|
{!p.active ? (
|
||||||
|
<span style={{ color: 'var(--danger)', fontSize: 11 }}>
|
||||||
|
inaktiv
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!selectedId ? (
|
||||||
|
<p style={{ color: 'var(--text3)' }}>Prompt links wählen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
|
slug: <code>{detail?.slug}</code> · Ausgabe:{' '}
|
||||||
|
<code>{detail?.output_format}</code> · Kategorie: <code>{detail?.category}</code>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: 10 }}>
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input className="form-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={draftDesc}
|
||||||
|
onChange={(e) => setDraftDesc(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
<label className="form-label">Template (Mustache-Zeilen mit {'{{'}}name{'}'}})</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
||||||
|
rows={22}
|
||||||
|
value={draftTemplate}
|
||||||
|
onChange={(e) => setDraftTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => save()}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={saving || !detail?.has_reference_template}
|
||||||
|
title={!detail?.has_reference_template ? 'Nach Migration 069 bzw. manuell default_template gesetzt' : ''}
|
||||||
|
onClick={() => resetTemplate()}
|
||||||
|
>
|
||||||
|
Auf Referenz zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style={{ marginTop: 20 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Platzhalter-Katalog (Übung)</summary>
|
||||||
|
{catalog?.placeholders ? (
|
||||||
|
<ul style={{ paddingLeft: 18, marginTop: 8 }}>
|
||||||
|
{catalog.placeholders.map((ph) => (
|
||||||
|
<li key={ph.key} style={{ marginBottom: 10, fontSize: 13 }}>
|
||||||
|
<code>{ph.placeholder}</code> — {ph.description}{' '}
|
||||||
|
<span style={{ color: 'var(--text3)' }}>
|
||||||
|
[{Array.isArray(ph.used_by_slugs) ? ph.used_by_slugs.join(', ') : ''}]
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>Wird geladen …</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Titel</label>
|
||||||
|
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="numerisch"
|
||||||
|
value={pvFocusId}
|
||||||
|
onChange={(e) => setPvFocusId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Fokus-Hinweistext</label>
|
||||||
|
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Ziel (HTML möglich)</label>
|
||||||
|
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Durchführung (HTML möglich)</label>
|
||||||
|
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
|
||||||
|
Platzhalter auflösen
|
||||||
|
</button>
|
||||||
|
{pvPreview?.warning ? (
|
||||||
|
<p style={{ marginTop: 10, color: 'var(--text3)', fontSize: 13 }}>{pvPreview.warning}</p>
|
||||||
|
) : null}
|
||||||
|
{pvPreview?.placeholders_remaining?.length ? (
|
||||||
|
<p style={{ color: 'var(--danger)', fontSize: 13 }}>
|
||||||
|
Unbekannte Platzhalter im Ergebnis:{' '}
|
||||||
|
{pvPreview.placeholders_remaining.join(', ')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{pvPreview?.resolved_template != null ? (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: 360,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pvPreview.resolved_template}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -388,6 +388,37 @@ export async function deleteAiSkillRetrievalProfile(profileId) {
|
||||||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Superadmin: KI Prompt-Templates (ai_prompts) */
|
||||||
|
export async function listAdminAiPrompts() {
|
||||||
|
return request('/api/admin/ai-prompts')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminAiPrompt(promptId) {
|
||||||
|
return request(`/api/admin/ai-prompts/${promptId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminAiPrompt(promptId, data) {
|
||||||
|
return request(`/api/admin/ai-prompts/${promptId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewAdminAiPrompt(promptId, data) {
|
||||||
|
return request(`/api/admin/ai-prompts/${promptId}/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data || {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAdminAiPromptTemplate(promptId) {
|
||||||
|
return request(`/api/admin/ai-prompts/${promptId}/reset-template`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminAiPromptPlaceholdersCatalog() {
|
||||||
|
return request('/api/admin/ai-prompts/catalog/placeholders')
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Reifegradmodelle / Fähigkeitsmatrix
|
// Reifegradmodelle / Fähigkeitsmatrix
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -796,6 +827,12 @@ export const api = {
|
||||||
createAiSkillRetrievalProfile,
|
createAiSkillRetrievalProfile,
|
||||||
updateAiSkillRetrievalProfile,
|
updateAiSkillRetrievalProfile,
|
||||||
deleteAiSkillRetrievalProfile,
|
deleteAiSkillRetrievalProfile,
|
||||||
|
listAdminAiPrompts,
|
||||||
|
getAdminAiPrompt,
|
||||||
|
updateAdminAiPrompt,
|
||||||
|
previewAdminAiPrompt,
|
||||||
|
resetAdminAiPromptTemplate,
|
||||||
|
getAdminAiPromptPlaceholdersCatalog,
|
||||||
listStyleDirections,
|
listStyleDirections,
|
||||||
listTrainingStyles,
|
listTrainingStyles,
|
||||||
createStyleDirection,
|
createStyleDirection,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user