diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
index ba8b968..b26eef7 100644
--- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
+++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
@@ -1,8 +1,14 @@
# 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)
+
+**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan.
+
+**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 +604,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/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
new file mode 100644
index 0000000..e75de69
--- /dev/null
+++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
@@ -0,0 +1,164 @@
+# KI-Prompt-System — Zielarchitektur (Shinkan Jinkendo)
+
+**Version:** 1.0
+**Datum:** 2026-05-30
+**Status:** VERBINDLICHE ZIELRICHTUNG (Roadmap — nicht alles bereits umgesetzt)
+**Ergänzt:** `AI_PROMPT_SYSTEM_SPEC.md` (aktueller Ist-Stand APIs/DB/UI), Mitai-Anleihen aus gleichnamigen Konzepten (Admin-Prompts, Platzhalter)
+
+---
+
+## 1. Zweck
+
+Dieses Dokument beschreibt das **Zielbild**, damit spätere Arbeiten (**Trainingsplanung**, **mehrstufige Rahmenprogramme**, **Phasen/Streams**, weitere KI-Artefakte) **nicht** zu wiederholten Refaktoren von Übungs-KI oder OpenRouter-Anbindung zwingen.
+
+**Leitkriterien:** wenige stabile Schnittflächen, Kontext pro Domäne, komponierbare Prompts, gültige Ausgaben, Betrieb ohne Code-Deploy für kleine Tweaks.
+
+---
+
+## 2. Leitprinzipien
+
+### 2.1 Eine stabile Ausführungsschicht
+
+Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fassade** laufen:
+
+- **Eingabe:** `slug` (+ optional Kontext-Arten-Enum), **serialisierter Domän-Kontext** (Pydantic pro Kind), Konfiguration (Modell, Temperatur, … aus Env/DB).
+- **Ausgabe:** Text oder validiertes JSON, Metadaten (`model`, `slug`, ggf. `prompt_version`/Hash), strukturierte Fehler.
+
+Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
+
+**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`).
+
+### 2.2 Trennung: Semantik vs. Transport
+
+- **Semantik:** Was soll das Modell liefern? Das hängt an **Prompt-Definition**, **Ausgabeformat** (`text`/`json`) und nachvollziehbarer Validierung — nicht am HTTP-Client.
+- **Transport:** OpenRouter, Modellwahl, Retry, Timeouts bleiben in einem oder wenigen Hilfsmodulen.
+
+### 2.3 Kontext-Namespaces für Platzhalter
+
+Platzhalter und erlaubte Keys sind **pro logischer Kontext-Art** definiert, z. B.:
+
+- `exercise_form_ai` — heute: Übungsformular-Vorschläge.
+- später: `training_unit`, `framework_program_slot`, `import_wiki`, …
+
+Damit kann der Katalog wachsen, ohne dass alle Keys in einen globalen Soup-Namespace müssen (`exercise_*` vs. `framework_*` ohne Kollisionen). Optional später **präfixierte** Keys (`exercise.title`, `slot.index`).
+
+### 2.4 Komposition / Kaskade explizit
+
+**Ziel:** Mehrteilige Prompts („System“–„Nutzer“–Anhänge) und **Einbindung anderer Vorlagen** als **Daten** (Kompositionsmodell), nicht nur als unbearbeiteter Freitext mit `{{include}}`.
+
+Skizzen (noch nicht vollständig umgesetzt):
+
+- Tabelle oder JSON-Spalte `composition`/`ai_prompt_segments`: geordnete Segmente mit `role` (`system` \| `user` \| äquivalent zum jeweiligen API-Shape), Quelle (`inline`, `ref_slug`), optional `ref_slug`, Schema-Version.
+- Einbindungen mit **Maximaltiefe** und **Zykluserkennung** — keine unbegrenzten Makro-Ketten.
+
+Bis dahin bleiben zusammenhängende Anweisungen in **einem** DB-Template pro Slug tragbar (`exercise_skill_suggestions` + `{{skills_catalog}}` bleiben gültig).
+
+---
+
+## 3. Zieldatenmodell (Schichten)
+
+### 3.1 Definition (`ai_prompts` — bereits vorhanden, evolviert)
+
+| Konzept | Bedeutung |
+|--------|-----------|
+| `slug`, `category`, `output_format`, `active` | Adressierung & Schalter |
+| `template` | aktueller Inhalt |
+| `default_template` | Referenz zum Zurücksetzen (Migration **069**) |
+| `output_schema` (JSONB) | optional: JSON-Outputs validieren |
+
+**Ausbaustufen:**
+
+1. Nur `template`-Text (**heute**, plus Mustache über `prompt_resolver`).
+2. Zusätzlich **Versionierung**: Historie oder `template_version`/Audit (wer hat wann geändert).
+3. **Segmentierte Composition** wie in Abschnitt 2.4.
+
+### 3.2 Kontext-Builder pro Domäne
+
+Pro **Kontext-Art** eine klar genannte Routine (Pattern: registrierbare Builder):
+
+| Kontext-Art | Beispiel-Input aus der App | Beispiel-Platzhalter / Daten |
+|-------------|----------------------------|------------------------------|
+| `exercise_form_ai` | Titel, Ziel/Durchführung (HTML→Plain), Fokuskontext, Retrieval-Profil-Influenza | `exercise_*`, `skills_catalog` |
+| `training_unit` (geplant) | Sektionen, Zeiten, Phasen/Streams, verknüpfte Übungs-IDs | `unit_*`, `sections_summary_*` |
+| `framework_program` (geplant) | Ziele pro Woche/Schicht, Slots, bereits geplante Einheiten, Skill-Scores | `framework_*`, `slot_*`, aggregierte KPIs |
+
+**Regel:** Planungs-UI baut keine Prompt-Strings; sie liefert **Domän-DTOs** → Builder erzeugen **Platzhalter-Map + ggf. Anhänge**.
+
+### 3.3 Skill-Retrieval und Prompts
+
+`ai_skill_retrieval_profiles` steuert **Katalog‑Zusammenstellung** vor dem Platzhalter `{{skills_catalog}}` — das bleibt **orthogonal** zur Prompt-Verwaltung: Prompt ändert *Anweisung*, Profil ändert *welche Skills im Kontextfenster sind*.
+
+---
+
+## 4. Trainingsplanung & Rahmen — erwartete Komplexität
+
+Risiken: sehr große Kontexte (viele Slots, Streams, Bibliotheken), wiederholte KI-Anfragen, Token-Limits.
+
+**Vorbereitende Strategien:**
+
+1. **Gestufte Kontexte:** Rohdaten → interne Kurzfassungen (optional zweiter Prompt oder heuristisch) → finale Generator-Prompt nur mit komprimierten Summaries.
+2. **Slug-Pro-Use-Case:** z. B. `training_unit_trainer_notes`, `framework_slot_coach_hint` — jeweils schmaler Vertrag statt „ein Prompt für alles“.
+3. **Output-Verträge:** JSON-Schema + Server-Validierung vor UI; Fehlermeldungen mit Referenz auf Slug/Version.
+4. **Feature-Flags / Modell-Overrides** pro Slug (optional in DB oder Env) für Dev/Prod ohne große Codepfade.
+
+---
+
+## 5. Mitai (Jinkendo)
+
+Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, Preview), **andere** Kontext-Builder und ggf. andere Mandanten/Overlays. Eine gemeinsame **Resolver-/Mustache-Ebene** ist wünschenswert; **Shinkan-spezifische** Planungs- und Rahmenkontexte bleiben in Shinkan gekapselt.
+
+---
+
+## 6. Betrieb, Sicherheit, Observability
+
+- **Audit:** `updated_by` / Änderungshistorie für Templates (Backlog), heute: Timestamps.
+- **Prompt-Injection:** System-/User-Segmente trennen; sensible Regeln in `system`/`developer`-äquivalenten Blöcken (wenn API das hergibt).
+- **Logging:** weiter `SHINKAN_AI_DEBUG`; langfristig Hash/Länge des **aufgelösten** Prompts pro Request (ohne Secrets).
+- **Kosten/Latenz:** Timeouts, max. Token-Hinweise pro Slug-Konfiguration.
+
+---
+
+## 7. Phasenplan (empfohlen, ohne Big-Bang)
+
+```mermaid
+flowchart LR
+ subgraph heute
+ A[ai_prompts DB]
+ B[prompt_resolver Mustache]
+ C[ai_prompt_runtime Loader + ContextKind]
+ D[exercise_ai]
+ end
+ A --> B
+ A --> C
+ C --> D
+ B --> D
+```
+
+| Phase | Inhalt |
+|-------|--------|
+| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
+| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
+| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
+| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
+| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
+
+---
+
+## 8. Was bewusst vermieden werden soll
+
+- Vollständige „Workflow-Engine“ mit beliebigen Graphen, bevor 2–3 konkrete Planungs-Anwendungsfälle live sind.
+- Pro-Verein-Prompt-Kopien vor klar definierter Produkt-Anforderung (sonst Daten- und Pflege-Spirale).
+- Unbegrenzte `include`-rekursive Textmakros ohne Tiefenschutz.
+
+---
+
+## 9. Querverweise
+
+- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
+- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
+- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
+- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
+
+---
+
+**Version:** 1.0 · **Datum:** 2026-05-30
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 1937e1d..e16267c 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -33,19 +33,22 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
+| matrix_editor | `/api/admin/matrix-editor/*` (Export/Import Editor-Bundle) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale Fähigkeitsmatrix ohne Mandantenkontext |
| 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/CLAUDE.md b/CLAUDE.md
index ec43ca3..ebea17c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,7 @@
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
+> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
## Projekt-Übersicht
diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py
new file mode 100644
index 0000000..4ca8560
--- /dev/null
+++ b/backend/ai_prompt_runtime.py
@@ -0,0 +1,74 @@
+"""
+Gemeinsame KI-Prompt-Laufzeit (Shinkan): DB-Lesezugriff ai_prompts + Kontext-Arten.
+
+Bleibt ohne Import von exercise_ai (kein Zirkel). Domänen wie exercise_ai nutzen
+load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilte Builder.
+"""
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, Dict, Optional
+
+_EXERCISE_AI_SLUGS = frozenset(
+ {
+ "exercise_summary",
+ "exercise_skill_suggestions",
+ }
+)
+
+
+class AiPromptContextKind(str, Enum):
+ """
+ Logischer Kontext fuer Platzhalter/Builder — erweiterbar fuer Planung/Rahmen
+ ohne bestehende Slugs zu invalidieren.
+ """
+
+ EXERCISE_FORM_AI = "exercise_form_ai"
+
+
+def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
+ """Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
+ s = (slug or "").strip().lower()
+ if s in _EXERCISE_AI_SLUGS:
+ return AiPromptContextKind.EXERCISE_FORM_AI
+ return None
+
+
+def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[Dict[str, Any]]:
+ """
+ Laedt eine Zeile ai_prompts fuer Laufzeit-Orchestrierung.
+
+ active_only=True: inaktive Prompts werden wie fehlend behandelt (503 im Aufrufer).
+ """
+ if active_only:
+ cur.execute(
+ """
+ SELECT slug, display_name, template, output_format, active
+ FROM ai_prompts
+ WHERE slug = %s AND active = true
+ """,
+ (slug,),
+ )
+ else:
+ cur.execute(
+ """
+ SELECT slug, display_name, template, output_format, active
+ FROM ai_prompts
+ WHERE slug = %s
+ """,
+ (slug,),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ d = dict(row)
+ if active_only and not d.get("active", True):
+ return None
+ return d
+
+
+__all__ = [
+ "AiPromptContextKind",
+ "context_kind_for_slug",
+ "load_ai_prompt_row",
+]
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index bf7d714..6391740 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -18,6 +18,9 @@ from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
+from ai_prompt_runtime import load_ai_prompt_row
+from prompt_resolver import render_mustache_template
+
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@@ -488,30 +491,46 @@ def build_contextual_skills_catalog_block(
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
-def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
- cur.execute(
- """
- SELECT slug, display_name, template, output_format, active
- FROM ai_prompts
- WHERE slug = %s
- """,
- (slug,),
- )
- row = cur.fetchone()
- if not row:
- return None
- d = dict(row)
- if not d.get("active", True):
- return None
- 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]:
@@ -696,21 +715,28 @@ def run_exercise_ai_suggestion(
)
if want_summary:
- prow = _load_prompt_row(cur, "exercise_summary")
+ prow = load_ai_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)
@@ -729,33 +755,31 @@ def run_exercise_ai_suggestion(
result["summary"] = {"text": text, "ai_generated": True, "model": model}
if want_skills:
- srow = _load_prompt_row(cur, "exercise_skill_suggestions")
+ srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
if not srow:
raise HTTPException(
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 +832,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..fbe10cf 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, matrix_editor, 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)
@@ -216,10 +216,12 @@ app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
+app.include_router(matrix_editor.router)
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/routers/matrix_editor.py b/backend/routers/matrix_editor.py
new file mode 100644
index 0000000..40e203f
--- /dev/null
+++ b/backend/routers/matrix_editor.py
@@ -0,0 +1,727 @@
+"""
+Superadmin Export/Import für zentrale Pflege der Fähigkeitsmatrix.
+
+Fokus: Beschreibungen und Gewichtungen (skills.importance, model_skills.relevance,
+skill_level_definitions, model_skill_levels) — flaches, bearbeitbares Format.
+
+Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext.
+# ACCESS_LAYER exempt: Plattform-Superadmin-Tool; globale Fähigkeitsmatrix ohne Mandantenkontext
+"""
+from __future__ import annotations
+
+import csv
+import io
+import re
+import uuid
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Tuple
+
+from fastapi import APIRouter, Body, Depends, HTTPException, Query
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import JSONResponse, PlainTextResponse
+
+from auth import require_auth
+from club_tenancy import is_superadmin
+from db import get_db, get_cursor, r2d
+
+router = APIRouter(prefix="/api/admin/matrix-editor", tags=["admin_matrix_editor"])
+
+KIND_V1 = "shinkan.matrix_editor.v1"
+
+SKILLS_CSV_FIELDS = [
+ "skill_key",
+ "main_category",
+ "subcategory",
+ "skill_name",
+ "description",
+ "importance",
+ "karate_relevance",
+ "relevance_level",
+ "level_1",
+ "level_2",
+ "level_3",
+ "level_4",
+ "level_5",
+]
+
+MATRIX_CSV_FIELDS = [
+ "model_key",
+ "model_name",
+ "skill_key",
+ "skill_name",
+ "relevance",
+ "sort_order",
+ "level_number",
+ "level_label",
+ "description",
+ "observable_criteria",
+]
+
+
+def _require_superadmin(session: dict) -> dict:
+ role = (session.get("role") or "").strip().lower()
+ if not is_superadmin(role):
+ raise HTTPException(status_code=403, detail="Nur Superadmins")
+ return session
+
+
+def _slugify_label(text: str) -> str:
+ t = (text or "").strip().lower()
+ t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
+ t = re.sub(r"_+", "_", t).strip("_")
+ return t[:48] or "gruppe"
+
+
+def _skill_key(main_slug: Optional[str], cat_slug: Optional[str], name: str) -> str:
+ return f"{main_slug or ''}|{cat_slug or ''}|{(name or '').strip()}"
+
+
+def _model_key(import_id: Optional[str], name: str) -> str:
+ iid = (import_id or "").strip()
+ if iid:
+ return iid
+ return _slugify_label(name)
+
+
+def _parse_skill_key(raw: str) -> Tuple[str, str, str]:
+ parts = (raw or "").split("|", 2)
+ while len(parts) < 3:
+ parts.append("")
+ return parts[0], parts[1], parts[2]
+
+
+def _csv_response(text: str, filename: str) -> PlainTextResponse:
+ return PlainTextResponse(
+ content=text,
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+def _rows_to_csv(fieldnames: List[str], rows: List[Dict[str, Any]]) -> str:
+ buf = io.StringIO()
+ writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n")
+ writer.writeheader()
+ for row in rows:
+ writer.writerow({k: row.get(k, "") for k in fieldnames})
+ return buf.getvalue()
+
+
+def _load_editor_payload(cur) -> Dict[str, Any]:
+ cur.execute(
+ """
+ SELECT s.id, s.name, s.description, s.importance, s.karate_relevance, s.relevance_level,
+ mc.slug AS main_category_slug, mc.name AS main_category_name,
+ sc.slug AS category_slug, sc.name AS category_name
+ FROM skills s
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.sort_order NULLS LAST, s.name
+ """
+ )
+ skill_rows = [r2d(r) for r in cur.fetchall()]
+ skill_ids = [int(r["id"]) for r in skill_rows]
+
+ level_defs: Dict[int, Dict[int, str]] = {}
+ if skill_ids:
+ cur.execute(
+ """
+ SELECT skill_id, level, description
+ FROM skill_level_definitions
+ WHERE skill_id = ANY(%s)
+ ORDER BY skill_id, level
+ """,
+ (skill_ids,),
+ )
+ for r in cur.fetchall():
+ sid = int(r["skill_id"])
+ level_defs.setdefault(sid, {})[int(r["level"])] = r.get("description") or ""
+
+ skills_out: List[Dict[str, Any]] = []
+ skills_csv: List[Dict[str, Any]] = []
+ for s in skill_rows:
+ sid = int(s["id"])
+ main_slug = s.get("main_category_slug")
+ cat_slug = s.get("category_slug")
+ name = (s.get("name") or "").strip()
+ key = _skill_key(main_slug, cat_slug, name)
+ defs = level_defs.get(sid, {})
+ level_map = {str(i): defs.get(i, "") for i in range(1, 6)}
+ entry = {
+ "skill_key": key,
+ "main_category": s.get("main_category_name") or "",
+ "subcategory": s.get("category_name") or "",
+ "skill_name": name,
+ "description": s.get("description") or "",
+ "importance": s.get("importance"),
+ "karate_relevance": s.get("karate_relevance"),
+ "relevance_level": s.get("relevance_level"),
+ "level_definitions": level_map,
+ }
+ skills_out.append(entry)
+ csv_row = {
+ "skill_key": key,
+ "main_category": entry["main_category"],
+ "subcategory": entry["subcategory"],
+ "skill_name": name,
+ "description": entry["description"],
+ "importance": entry["importance"] if entry["importance"] is not None else "",
+ "karate_relevance": entry["karate_relevance"] or "",
+ "relevance_level": entry["relevance_level"] if entry["relevance_level"] is not None else "",
+ }
+ for i in range(1, 6):
+ csv_row[f"level_{i}"] = level_map.get(str(i), "")
+ skills_csv.append(csv_row)
+
+ cur.execute("SELECT id, name, import_id, level_count, status FROM maturity_models ORDER BY name")
+ models_raw = [r2d(r) for r in cur.fetchall()]
+
+ models_out: List[Dict[str, Any]] = []
+ matrix_csv: List[Dict[str, Any]] = []
+ for m in models_raw:
+ mid = int(m["id"])
+ mkey = _model_key(m.get("import_id"), m.get("name") or "")
+ mname = (m.get("name") or "").strip()
+
+ cur.execute(
+ """
+ SELECT level_number, name, description
+ FROM model_levels
+ WHERE maturity_model_id = %s
+ ORDER BY sort_order ASC, level_number ASC
+ """,
+ (mid,),
+ )
+ levels = [r2d(r) for r in cur.fetchall()]
+ level_label = {int(lv["level_number"]): lv.get("name") or "" for lv in levels}
+
+ cur.execute(
+ """
+ SELECT ms.skill_id, ms.sort_order, ms.relevance, s.name AS skill_name,
+ mc.slug AS main_category_slug, sc.slug AS category_slug
+ FROM model_skills ms
+ JOIN skills s ON s.id = ms.skill_id
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ WHERE ms.maturity_model_id = %s
+ ORDER BY ms.sort_order ASC, ms.id ASC
+ """,
+ (mid,),
+ )
+ model_skills = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT msl.skill_id, msl.level_number, msl.description, msl.observable_criteria,
+ s.name AS skill_name, mc.slug AS main_category_slug, sc.slug AS category_slug
+ FROM model_skill_levels msl
+ JOIN skills s ON s.id = msl.skill_id
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ WHERE msl.maturity_model_id = %s
+ ORDER BY msl.skill_id, msl.level_number
+ """,
+ (mid,),
+ )
+ skill_levels = [r2d(r) for r in cur.fetchall()]
+
+ ms_by_skill: Dict[int, Dict[str, Any]] = {int(ms["skill_id"]): ms for ms in model_skills}
+ cells_by_skill: Dict[int, List[Dict[str, Any]]] = {}
+ for sl in skill_levels:
+ sid = int(sl["skill_id"])
+ cells_by_skill.setdefault(sid, []).append(
+ {
+ "level_number": int(sl["level_number"]),
+ "description": sl.get("description") or "",
+ "observable_criteria": sl.get("observable_criteria"),
+ }
+ )
+
+ matrix_rows: List[Dict[str, Any]] = []
+ seen_skills = set()
+ for ms in model_skills:
+ sid = int(ms["skill_id"])
+ seen_skills.add(sid)
+ skey = _skill_key(ms.get("main_category_slug"), ms.get("category_slug"), ms.get("skill_name") or "")
+ matrix_rows.append(
+ {
+ "skill_key": skey,
+ "skill_name": ms.get("skill_name") or "",
+ "relevance": ms.get("relevance"),
+ "sort_order": int(ms.get("sort_order") or 0),
+ "cells": cells_by_skill.get(sid, []),
+ }
+ )
+ for cell in cells_by_skill.get(sid, []):
+ ln = int(cell["level_number"])
+ matrix_csv.append(
+ {
+ "model_key": mkey,
+ "model_name": mname,
+ "skill_key": skey,
+ "skill_name": ms.get("skill_name") or "",
+ "relevance": ms.get("relevance") or "",
+ "sort_order": int(ms.get("sort_order") or 0),
+ "level_number": ln,
+ "level_label": level_label.get(ln, ""),
+ "description": cell.get("description") or "",
+ "observable_criteria": cell.get("observable_criteria") or "",
+ }
+ )
+
+ for sid, cells in cells_by_skill.items():
+ if sid in seen_skills:
+ continue
+ sample = cells[0] if cells else {}
+ cur.execute(
+ """
+ SELECT s.name, mc.slug AS main_category_slug, sc.slug AS category_slug
+ FROM skills s
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ WHERE s.id = %s
+ """,
+ (sid,),
+ )
+ sk = r2d(cur.fetchone()) or {}
+ skey = _skill_key(sk.get("main_category_slug"), sk.get("category_slug"), sk.get("name") or "")
+ matrix_rows.append(
+ {
+ "skill_key": skey,
+ "skill_name": sk.get("name") or "",
+ "relevance": None,
+ "sort_order": 0,
+ "cells": cells,
+ }
+ )
+ for cell in cells:
+ ln = int(cell["level_number"])
+ matrix_csv.append(
+ {
+ "model_key": mkey,
+ "model_name": mname,
+ "skill_key": skey,
+ "skill_name": sk.get("name") or "",
+ "relevance": "",
+ "sort_order": 0,
+ "level_number": ln,
+ "level_label": level_label.get(ln, ""),
+ "description": cell.get("description") or "",
+ "observable_criteria": cell.get("observable_criteria") or "",
+ }
+ )
+
+ models_out.append(
+ {
+ "model_key": mkey,
+ "model_name": mname,
+ "level_count": int(m.get("level_count") or 5),
+ "status": m.get("status"),
+ "level_labels": [
+ {
+ "level_number": int(lv["level_number"]),
+ "name": lv.get("name") or "",
+ "description": lv.get("description"),
+ }
+ for lv in levels
+ ],
+ "matrix_rows": matrix_rows,
+ }
+ )
+
+ return {
+ "skills": skills_out,
+ "skills_csv_rows": skills_csv,
+ "maturity_models": models_out,
+ "matrix_csv_rows": matrix_csv,
+ }
+
+
+def _build_skill_lookup(cur) -> Dict[str, int]:
+ cur.execute(
+ """
+ SELECT s.id, s.name, mc.slug AS main_category_slug, sc.slug AS category_slug
+ FROM skills s
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ """
+ )
+ out: Dict[str, int] = {}
+ for r in cur.fetchall():
+ row = r2d(r)
+ key = _skill_key(row.get("main_category_slug"), row.get("category_slug"), row.get("name") or "")
+ out[key] = int(row["id"])
+ return out
+
+
+def _build_model_lookup(cur) -> Dict[str, int]:
+ cur.execute("SELECT id, name, import_id FROM maturity_models")
+ out: Dict[str, int] = {}
+ for r in cur.fetchall():
+ row = r2d(r)
+ key = _model_key(row.get("import_id"), row.get("name") or "")
+ out[key] = int(row["id"])
+ return out
+
+
+def _resolve_skill_id(
+ cur,
+ skill_lookup: Dict[str, int],
+ *,
+ skill_key: str,
+ main_category: str = "",
+ subcategory: str = "",
+ skill_name: str = "",
+) -> Optional[int]:
+ key = (skill_key or "").strip()
+ if key and key in skill_lookup:
+ return skill_lookup[key]
+ if key:
+ main_slug, cat_slug, name = _parse_skill_key(key)
+ if name and _skill_key(main_slug, cat_slug, name) in skill_lookup:
+ return skill_lookup[_skill_key(main_slug, cat_slug, name)]
+ name = (skill_name or "").strip()
+ if not name:
+ return None
+ cur.execute(
+ """
+ SELECT s.id
+ FROM skills s
+ LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
+ LEFT JOIN skill_categories sc ON s.category_id = sc.id
+ WHERE s.name = %s
+ AND COALESCE(mc.name, '') = COALESCE(%s, '')
+ AND COALESCE(sc.name, '') = COALESCE(%s, '')
+ LIMIT 1
+ """,
+ (name, (main_category or "").strip() or None, (subcategory or "").strip() or None),
+ )
+ row = cur.fetchone()
+ if row:
+ return int(row["id"])
+ cur.execute("SELECT id FROM skills WHERE name = %s LIMIT 1", (name,))
+ row = cur.fetchone()
+ return int(row["id"]) if row else None
+
+
+def _optional_int(val: Any) -> Optional[int]:
+ if val is None or val == "":
+ return None
+ try:
+ return int(val)
+ except (TypeError, ValueError):
+ return None
+
+
+def _apply_skills_import(cur, skills: List[Dict[str, Any]], skill_lookup: Dict[str, int], warnings: List[str]) -> int:
+ updated = 0
+ for row in skills:
+ sid = _resolve_skill_id(
+ cur,
+ skill_lookup,
+ skill_key=str(row.get("skill_key") or ""),
+ main_category=str(row.get("main_category") or ""),
+ subcategory=str(row.get("subcategory") or ""),
+ skill_name=str(row.get("skill_name") or ""),
+ )
+ if not sid:
+ warnings.append(f"Skill nicht gefunden: {row.get('skill_key') or row.get('skill_name')}")
+ continue
+
+ importance = row.get("importance")
+ if importance == "":
+ importance = None
+ relevance_level = row.get("relevance_level")
+ if relevance_level == "":
+ relevance_level = None
+
+ cur.execute(
+ """
+ UPDATE skills SET
+ description = COALESCE(%s, description),
+ importance = COALESCE(%s, importance),
+ karate_relevance = COALESCE(%s, karate_relevance),
+ relevance_level = COALESCE(%s, relevance_level),
+ updated_at = NOW()
+ WHERE id = %s
+ """,
+ (
+ row.get("description"),
+ importance,
+ row.get("karate_relevance") if row.get("karate_relevance") != "" else None,
+ relevance_level,
+ sid,
+ ),
+ )
+ updated += 1
+
+ defs = row.get("level_definitions")
+ if isinstance(defs, dict):
+ for lvl_str, desc in defs.items():
+ try:
+ lvl = int(lvl_str)
+ except (TypeError, ValueError):
+ continue
+ if lvl < 1 or lvl > 10:
+ continue
+ text = (desc or "").strip()
+ if not text:
+ continue
+ cur.execute(
+ """
+ INSERT INTO skill_level_definitions (skill_id, level, description)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (skill_id, level) DO UPDATE SET
+ description = EXCLUDED.description,
+ updated_at = NOW()
+ """,
+ (sid, lvl, text),
+ )
+ else:
+ for i in range(1, 6):
+ col = row.get(f"level_{i}")
+ if col is None or str(col).strip() == "":
+ continue
+ cur.execute(
+ """
+ INSERT INTO skill_level_definitions (skill_id, level, description)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (skill_id, level) DO UPDATE SET
+ description = EXCLUDED.description,
+ updated_at = NOW()
+ """,
+ (sid, i, str(col).strip()),
+ )
+ return updated
+
+
+def _apply_matrix_import(
+ cur,
+ models: List[Dict[str, Any]],
+ model_lookup: Dict[str, int],
+ skill_lookup: Dict[str, int],
+ warnings: List[str],
+) -> int:
+ cells_updated = 0
+ for model in models:
+ mkey = str(model.get("model_key") or "").strip()
+ mid = model_lookup.get(mkey)
+ if not mid and model.get("model_name"):
+ mid = model_lookup.get(_model_key(None, str(model.get("model_name"))))
+ if not mid:
+ warnings.append(f"Reifegradmodell nicht gefunden: {mkey or model.get('model_name')}")
+ continue
+
+ rows = model.get("matrix_rows") or []
+ if not rows and model.get("cells"):
+ rows = [model]
+
+ for mrow in rows:
+ sid = _resolve_skill_id(
+ cur,
+ skill_lookup,
+ skill_key=str(mrow.get("skill_key") or ""),
+ skill_name=str(mrow.get("skill_name") or ""),
+ )
+ if not sid:
+ warnings.append(f"Matrix-Zeile: Skill nicht gefunden ({mrow.get('skill_key')})")
+ continue
+
+ relevance = mrow.get("relevance")
+ if relevance is not None and str(relevance).strip() != "":
+ sort_order = _optional_int(mrow.get("sort_order")) or 0
+ cur.execute(
+ """
+ INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance)
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (maturity_model_id, skill_id) DO UPDATE SET
+ sort_order = EXCLUDED.sort_order,
+ relevance = EXCLUDED.relevance
+ """,
+ (mid, sid, sort_order, str(relevance).strip()),
+ )
+
+ cells = mrow.get("cells") or []
+ for cell in cells:
+ ln = _optional_int(cell.get("level_number"))
+ if ln is None:
+ continue
+ desc = (cell.get("description") or "").strip()
+ if not desc:
+ continue
+ cur.execute(
+ """
+ INSERT INTO model_skill_levels (
+ maturity_model_id, skill_id, level_number,
+ description, observable_criteria
+ )
+ VALUES (%s, %s, %s, %s, %s)
+ ON CONFLICT (maturity_model_id, skill_id, level_number)
+ DO UPDATE SET
+ description = EXCLUDED.description,
+ observable_criteria = COALESCE(EXCLUDED.observable_criteria, model_skill_levels.observable_criteria),
+ updated_at = NOW()
+ """,
+ (
+ mid,
+ sid,
+ ln,
+ desc,
+ cell.get("observable_criteria") if cell.get("observable_criteria") else None,
+ ),
+ )
+ cells_updated += 1
+ return cells_updated
+
+
+def _parse_csv_text(text: str) -> List[Dict[str, str]]:
+ reader = csv.DictReader(io.StringIO(text))
+ return [dict(row) for row in reader]
+
+
+def _skills_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]:
+ out: List[Dict[str, Any]] = []
+ for row in rows:
+ if not any(str(v).strip() for v in row.values()):
+ continue
+ entry: Dict[str, Any] = {
+ "skill_key": row.get("skill_key", ""),
+ "main_category": row.get("main_category", ""),
+ "subcategory": row.get("subcategory", ""),
+ "skill_name": row.get("skill_name", ""),
+ "description": row.get("description", ""),
+ "importance": _optional_int(row.get("importance")),
+ "karate_relevance": row.get("karate_relevance", ""),
+ "relevance_level": _optional_int(row.get("relevance_level")),
+ }
+ for i in range(1, 6):
+ entry[f"level_{i}"] = row.get(f"level_{i}", "")
+ out.append(entry)
+ return out
+
+
+def _matrix_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]:
+ by_model: Dict[str, Dict[str, Any]] = {}
+ row_index: Dict[Tuple[str, str], Dict[str, Any]] = {}
+
+ for row in rows:
+ if not any(str(v).strip() for v in row.values()):
+ continue
+ mkey = (row.get("model_key") or "").strip()
+ if not mkey:
+ continue
+ model = by_model.setdefault(
+ mkey,
+ {"model_key": mkey, "model_name": row.get("model_name") or "", "matrix_rows": []},
+ )
+ skey = (row.get("skill_key") or "").strip()
+ row_key = (mkey, skey or (row.get("skill_name") or "").strip())
+ mrow = row_index.get(row_key)
+ if not mrow:
+ mrow = {
+ "skill_key": skey,
+ "skill_name": row.get("skill_name") or "",
+ "relevance": row.get("relevance") or "",
+ "sort_order": _optional_int(row.get("sort_order")) or 0,
+ "cells": [],
+ }
+ row_index[row_key] = mrow
+ model["matrix_rows"].append(mrow)
+
+ ln = _optional_int(row.get("level_number"))
+ if ln is None:
+ continue
+ desc = (row.get("description") or "").strip()
+ if not desc:
+ continue
+ mrow["cells"].append(
+ {
+ "level_number": ln,
+ "description": desc,
+ "observable_criteria": row.get("observable_criteria") or "",
+ }
+ )
+
+ return list(by_model.values())
+
+
+@router.get("/export")
+def export_matrix_editor(
+ format: str = Query("json", pattern="^(json|csv_skills|csv_matrix)$"),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ export_uid = str(uuid.uuid4())
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ payload = _load_editor_payload(cur)
+
+ if format == "csv_skills":
+ csv_text = _rows_to_csv(SKILLS_CSV_FIELDS, payload["skills_csv_rows"])
+ return _csv_response(csv_text, f"faehigkeiten-katalog-{export_uid[:8]}.csv")
+
+ if format == "csv_matrix":
+ csv_text = _rows_to_csv(MATRIX_CSV_FIELDS, payload["matrix_csv_rows"])
+ return _csv_response(csv_text, f"faehigkeitsmatrix-zellen-{export_uid[:8]}.csv")
+
+ bundle = {
+ "kind": KIND_V1,
+ "export_version": 1,
+ "bundle_export_id": export_uid,
+ "exported_at": datetime.now(timezone.utc).isoformat(),
+ "skills": payload["skills"],
+ "maturity_models": payload["maturity_models"],
+ }
+ return JSONResponse(
+ content=jsonable_encoder(bundle),
+ headers={
+ "Content-Disposition": f'attachment; filename="matrix-editor-{export_uid[:8]}.json"'
+ },
+ )
+
+
+@router.post("/import")
+def import_matrix_editor(
+ data: Dict[str, Any] = Body(...),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ warnings: List[str] = []
+ skills: List[Dict[str, Any]] = []
+ models: List[Dict[str, Any]] = []
+
+ kind = data.get("kind")
+ if kind == KIND_V1:
+ skills = list(data.get("skills") or [])
+ models = list(data.get("maturity_models") or [])
+ elif kind == "shinkan.matrix_editor.csv":
+ part = (data.get("part") or "").strip().lower()
+ csv_text = data.get("csv_text") or ""
+ if not csv_text.strip():
+ raise HTTPException(400, "csv_text fehlt")
+ rows = _parse_csv_text(csv_text)
+ if part == "skills":
+ skills = _skills_from_csv_rows(rows)
+ elif part == "matrix":
+ models = _matrix_from_csv_rows(rows)
+ else:
+ raise HTTPException(400, "part muss skills oder matrix sein")
+ else:
+ raise HTTPException(400, f"Unbekanntes Format (kind={kind!r})")
+
+ if not skills and not models:
+ raise HTTPException(400, "Keine importierbaren Daten")
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ skill_lookup = _build_skill_lookup(cur)
+ model_lookup = _build_model_lookup(cur)
+ skills_updated = _apply_skills_import(cur, skills, skill_lookup, warnings) if skills else 0
+ cells_updated = _apply_matrix_import(cur, models, model_lookup, skill_lookup, warnings) if models else 0
+
+ return {
+ "ok": True,
+ "skills_updated": skills_updated,
+ "matrix_cells_updated": cells_updated,
+ "warnings": warnings,
+ }
diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py
index acecdc2..9c40fe4 100644
--- a/backend/scripts/check_access_layer_hints.py
+++ b/backend/scripts/check_access_layer_hints.py
@@ -24,10 +24,12 @@ 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",
"matrix_stack_bundle.py",
+ "matrix_editor.py", # Superadmin Editor-Export/Import Fähigkeitsmatrix; require_auth + is_superadmin — kein Vereinsmandant
"import_wiki.py",
"import_wiki_admin.py",
}
diff --git a/backend/tests/test_matrix_editor.py b/backend/tests/test_matrix_editor.py
new file mode 100644
index 0000000..5a3ea18
--- /dev/null
+++ b/backend/tests/test_matrix_editor.py
@@ -0,0 +1,146 @@
+"""GET/POST /api/admin/matrix-editor — Superadmin Export/Import."""
+from __future__ import annotations
+
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from auth import require_auth
+from main import app
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def _clear_overrides():
+ yield
+ app.dependency_overrides.pop(require_auth, None)
+
+
+def test_matrix_editor_export_requires_superadmin(client: TestClient) -> None:
+ def _admin():
+ return {"profile_id": 1, "role": "admin"}
+
+ app.dependency_overrides[require_auth] = _admin
+ r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"})
+ assert r.status_code == 403
+
+
+@patch("routers.matrix_editor._load_editor_payload")
+def test_matrix_editor_export_json_ok(mock_load, client: TestClient) -> None:
+ def _superadmin():
+ return {"profile_id": 1, "role": "superadmin"}
+
+ app.dependency_overrides[require_auth] = _superadmin
+ mock_load.return_value = {
+ "skills": [{"skill_key": "a|b|Test", "skill_name": "Test", "description": "x", "level_definitions": {}}],
+ "skills_csv_rows": [],
+ "maturity_models": [],
+ "matrix_csv_rows": [],
+ }
+
+ mock_cm = MagicMock()
+ mock_cm.__enter__.return_value = MagicMock()
+ mock_cm.__exit__.return_value = False
+
+ with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
+ "routers.matrix_editor.get_cursor", return_value=MagicMock()
+ ):
+ r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"})
+
+ assert r.status_code == 200
+ body = r.json()
+ assert body["kind"] == "shinkan.matrix_editor.v1"
+ assert len(body["skills"]) == 1
+
+
+@patch("routers.matrix_editor._load_editor_payload")
+def test_matrix_editor_export_csv_skills(mock_load, client: TestClient) -> None:
+ def _superadmin():
+ return {"profile_id": 1, "role": "superadmin"}
+
+ app.dependency_overrides[require_auth] = _superadmin
+ mock_load.return_value = {
+ "skills": [],
+ "skills_csv_rows": [
+ {
+ "skill_key": "main|sub|Skill A",
+ "main_category": "Main",
+ "subcategory": "Sub",
+ "skill_name": "Skill A",
+ "description": "Desc",
+ "importance": 3,
+ "karate_relevance": "",
+ "relevance_level": "",
+ "level_1": "L1",
+ "level_2": "",
+ "level_3": "",
+ "level_4": "",
+ "level_5": "",
+ }
+ ],
+ "maturity_models": [],
+ "matrix_csv_rows": [],
+ }
+
+ mock_cm = MagicMock()
+ mock_cm.__enter__.return_value = MagicMock()
+ mock_cm.__exit__.return_value = False
+
+ with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
+ "routers.matrix_editor.get_cursor", return_value=MagicMock()
+ ):
+ r = client.get(
+ "/api/admin/matrix-editor/export?format=csv_skills",
+ headers={"X-Auth-Token": "t"},
+ )
+
+ assert r.status_code == 200
+ assert "skill_key" in r.text
+ assert "Skill A" in r.text
+
+
+@patch("routers.matrix_editor._apply_matrix_import", return_value=0)
+@patch("routers.matrix_editor._apply_skills_import", return_value=1)
+@patch("routers.matrix_editor._build_model_lookup", return_value={})
+@patch("routers.matrix_editor._build_skill_lookup", return_value={"main|sub|Skill A": 42})
+def test_matrix_editor_import_json_ok(
+ mock_skill_lookup,
+ mock_model_lookup,
+ mock_apply_skills,
+ mock_apply_matrix,
+ client: TestClient,
+) -> None:
+ def _superadmin():
+ return {"profile_id": 1, "role": "superadmin"}
+
+ app.dependency_overrides[require_auth] = _superadmin
+
+ mock_cm = MagicMock()
+ mock_conn = MagicMock()
+ mock_cm.__enter__.return_value = mock_conn
+ mock_cm.__exit__.return_value = False
+
+ with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
+ "routers.matrix_editor.get_cursor", return_value=MagicMock()
+ ):
+ r = client.post(
+ "/api/admin/matrix-editor/import",
+ headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
+ json={
+ "kind": "shinkan.matrix_editor.v1",
+ "skills": [{"skill_key": "main|sub|Skill A", "description": "Neu"}],
+ },
+ )
+
+ assert r.status_code == 200
+ body = r.json()
+ assert body["ok"] is True
+ assert body["skills_updated"] == 1
diff --git a/backend/version.py b/backend/version.py
index deb6ea3..3a1f6d6 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.159"
+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,13 @@ 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.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
+ "ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
"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.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
"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 +40,22 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.159",
+ "date": "2026-05-30",
+ "changes": [
+ "Doku: AI_PROMPT_TARGET_ARCHITECTURE.md (Zielbild Kontext-Arten, Composition, Planung/Rahmen, Phasenplan); HANDOVER & docs/architecture/README verlinkt;",
+ "Backend ai_prompt_runtime: AiPromptContextKind, load_ai_prompt_row — exercise_ai ohne duplizierte Prompt-SQL;",
+ ],
+ },
+ {
+ "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/docs/HANDOVER.md b/docs/HANDOVER.md
index 4eb3217..48abe41 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
-**Stand:** 2026-05-29
-**App-Version / DB-Schema:** App **`0.8.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
+**Stand:** 2026-05-30
+**App-Version / DB-Schema:** App **`0.8.159`** u. a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -29,6 +29,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Media / Upload-Limits / Embed | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
+| KI-Prompt-System — Zielarchitektur (Roadmap) | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
| Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` |
| **Trainingsmodule & Kombinationsübungen (Fachspez, Drift-Schutz)** | `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (§ 10.2.1 Archetyp-IDs, § 10.4 Coaching-Stufen, **Anhang A** Code-Abgleich) |
@@ -88,14 +89,16 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
-### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.157**)
+### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**)
-- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
-- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
+- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
+- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
+- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
+- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
-- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
+- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`**
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
-- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`**
+- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`)
---
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 91bed80..9a96ddf 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -13,7 +13,7 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen |
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
-| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |
+| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
## Tests (E2E / Refaktor-Budget)
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: (
+
- Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON - exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '} - Komplett-Stack mit Fähigkeitskatalog und allen Reifegradmodellen für Test → Prod). + Zentral Beschreibungen und Gewichtungen pflegen (Superadmin), Matrix nach Kontext anzeigen, sowie + vollständige JSON-Stacks für Test → Prod.
{error ? ( @@ -246,6 +345,76 @@ export default function MaturityMatrixToolsAdmin() { ) : null} {message ?{message}
: null} +
+ Flaches Format für Excel oder JSON-Editor: Fähigkeits-Beschreibungen,{' '}
+ importance (Gewichtung 1–5), globale Stufen-Texte (
+ level_1 … level_5
+ ), Matrix-Zelltexte und Zeilen-Relevanz pro Reifegradmodell. Import aktualisiert bestehende Einträge —
+ es werden keine neuen Fähigkeiten oder Modelle angelegt.
+
+ Nur geänderte Datei hochladen — Katalog-CSV oder Matrix-CSV getrennt. +
+ + handleImportEditorCsv(e, 'skills')} + /> + + handleImportEditorCsv(e, 'matrix')} + /> +Import läuft…
: null} +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}
+