Merge pull request 'export der Fähigkeiten, KI- Admin' (#47) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s

Reviewed-on: #47
This commit is contained in:
Lars 2026-05-22 11:56:54 +02:00
commit 3bf012a8f4
22 changed files with 2256 additions and 84 deletions

View File

@ -1,8 +1,14 @@
# 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)
**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 **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 +604,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

View File

@ -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 **KatalogZusammenstellung** 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 23 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

View File

@ -33,19 +33,22 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | | 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 | | 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_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 | | 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.

View File

@ -17,6 +17,7 @@
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** | > | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** | > | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** | > | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
## Projekt-Übersicht ## Projekt-Übersicht

View File

@ -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",
]

View File

@ -18,6 +18,9 @@ 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 ai_prompt_runtime import load_ai_prompt_row
from prompt_resolver import render_mustache_template
_LOGGER = logging.getLogger("shinkan.exercise_ai") _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)" return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: def build_exercise_placeholder_variables(
cur.execute( cur,
""" *,
SELECT slug, display_name, template, output_format, active slug: str,
FROM ai_prompts title: Optional[str],
WHERE slug = %s goal: Optional[str],
""", execution: Optional[str],
(slug,), focus_area_hint: Optional[str],
) focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
row = cur.fetchone() ) -> Dict[str, str]:
if not row: """
return None Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
d = dict(row) """
if not d.get("active", True): s = (slug or "").strip().lower()
return None if s == "pipeline":
return d return {}
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
def _render_template(template: str, ctx: Dict[str, str]) -> str: t_title = (title or "").strip()
out = template or "" focus = (focus_area_hint or "").strip()
for key, val in ctx.items(): ctx: Dict[str, str] = {
placeholder = "{{" + key + "}}" "exercise_title": t_title or "-",
out = out.replace(placeholder, val if val is not None else "") "exercise_focus_area": focus or "-",
return out "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]:
@ -696,21 +715,28 @@ def run_exercise_ai_suggestion(
) )
if want_summary: if want_summary:
prow = _load_prompt_row(cur, "exercise_summary") prow = load_ai_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)
@ -729,33 +755,31 @@ def run_exercise_ai_suggestion(
result["summary"] = {"text": text, "ai_generated": True, "model": model} result["summary"] = {"text": text, "ai_generated": True, "model": model}
if want_skills: if want_skills:
srow = _load_prompt_row(cur, "exercise_skill_suggestions") srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
if not srow: if not srow:
raise HTTPException( raise HTTPException(
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 +832,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",
] ]

View File

@ -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, 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(auth.router)
app.include_router(profiles.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(catalogs.router)
app.include_router(maturity_models.router) app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.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.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

View 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
View 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",
]

View 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,
}

View File

@ -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,
}

View File

@ -24,10 +24,12 @@ 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",
"matrix_stack_bundle.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.py",
"import_wiki_admin.py", "import_wiki_admin.py",
} }

View File

@ -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

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.157" APP_VERSION = "0.8.159"
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,13 @@ 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.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", "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.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_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 +40,22 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.157",
"date": "2026-05-22", "date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-29 **Stand:** 2026-05-30
**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` **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**. 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` | | Media / Upload-Limits / Embed | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` | | MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.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` | | Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.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) | | **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`) - **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 - **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 - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **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) - **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, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff) - **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~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 - **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`)
--- ---

View File

@ -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/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) | | [`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) | | [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) ## Tests (E2E / Refaktor-Budget)

View File

@ -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 /> },
], ],
}, },

View File

@ -13,13 +13,10 @@ export function mergeActiveClubHeader(headers = {}) {
if (cid && /^\d+$/.test(String(cid).trim())) { if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() } return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
} }
return { ...headers } return headers
} }
/** async function _fetchWithAuth(endpoint, options = {}) {
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
*/
export async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken') const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase() const method = (options.method || 'GET').toUpperCase()
@ -65,7 +62,7 @@ export async function request(endpoint, options = {}) {
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`) throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
} }
return response.json() return response
} catch (e) { } catch (e) {
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) { if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
const hint = const hint =
@ -77,3 +74,22 @@ export async function request(endpoint, options = {}) {
throw e throw e
} }
} }
/**
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
*/
export async function request(endpoint, options = {}) {
const response = await _fetchWithAuth(endpoint, options)
return response.json()
}
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */
export async function requestText(endpoint, options = {}) {
const response = await _fetchWithAuth(endpoint, options)
const disposition = response.headers.get('Content-Disposition') || ''
const match = disposition.match(/filename="([^"]+)"/)
return {
text: await response.text(),
filename: match ? match[1] : null,
}
}

View File

@ -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 (

View File

@ -13,6 +13,16 @@ function downloadJson(obj, filename) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function downloadText(text, filename, mime = 'text/csv;charset=utf-8') {
const blob = new Blob([text], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function groupModelSkills(model) { function groupModelSkills(model) {
if (!model?.model_skills?.length) return [] if (!model?.model_skills?.length) return []
const groups = new Map() const groups = new Map()
@ -56,6 +66,8 @@ export default function MaturityMatrixToolsAdmin() {
const [stackConfirmText, setStackConfirmText] = useState('') const [stackConfirmText, setStackConfirmText] = useState('')
const [stackLoading, setStackLoading] = useState(false) const [stackLoading, setStackLoading] = useState(false)
const [stackImportLoading, setStackImportLoading] = useState(false) const [stackImportLoading, setStackImportLoading] = useState(false)
const [editorLoading, setEditorLoading] = useState(false)
const [editorImportLoading, setEditorImportLoading] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -144,6 +156,94 @@ export default function MaturityMatrixToolsAdmin() {
} }
} }
async function handleExportEditorJson() {
setError('')
setMessage('')
setEditorLoading(true)
try {
const bundle = await api.exportMatrixEditorBundle()
const name = `matrix-editor-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json`
downloadJson(bundle, name)
setMessage('Editor-Export (JSON) heruntergeladen.')
} catch (err) {
setError(err.message || String(err))
} finally {
setEditorLoading(false)
}
}
async function handleExportEditorCsv(part) {
setError('')
setMessage('')
setEditorLoading(true)
try {
const { text, filename } = await api.exportMatrixEditorCsv(part)
downloadText(
text,
filename || (part === 'skills' ? 'faehigkeiten-katalog.csv' : 'faehigkeitsmatrix-zellen.csv')
)
setMessage(part === 'skills' ? 'Katalog-CSV heruntergeladen.' : 'Matrix-CSV heruntergeladen.')
} catch (err) {
setError(err.message || String(err))
} finally {
setEditorLoading(false)
}
}
async function handleImportEditorJson(e) {
const file = e.target.files?.[0]
if (!file) return
setError('')
setMessage('')
setEditorImportLoading(true)
try {
const data = JSON.parse(await file.text())
if (data.kind !== 'shinkan.matrix_editor.v1') {
setError('Erwartet wird kind: shinkan.matrix_editor.v1')
return
}
const res = await api.importMatrixEditorBundle(data)
const w = res.warnings || []
setMessage(
`Import OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` +
(w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '')
)
if (w.length) console.warn('matrix_editor import warnings', w)
} catch (err) {
setError(err.message || String(err))
} finally {
setEditorImportLoading(false)
e.target.value = ''
}
}
async function handleImportEditorCsv(e, part) {
const file = e.target.files?.[0]
if (!file) return
setError('')
setMessage('')
setEditorImportLoading(true)
try {
const csv_text = await file.text()
const res = await api.importMatrixEditorBundle({
kind: 'shinkan.matrix_editor.csv',
part,
csv_text
})
const w = res.warnings || []
setMessage(
`CSV-Import (${part}) OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` +
(w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '')
)
if (w.length) console.warn('matrix_editor csv import warnings', w)
} catch (err) {
setError(err.message || String(err))
} finally {
setEditorImportLoading(false)
e.target.value = ''
}
}
async function handleExportStack() { async function handleExportStack() {
setError('') setError('')
setMessage('') setMessage('')
@ -234,9 +334,8 @@ export default function MaturityMatrixToolsAdmin() {
return ( return (
<div className="admin-matrix-tools"> <div className="admin-matrix-tools">
<p className="admin-matrix-tools__intro muted"> <p className="admin-matrix-tools__intro muted">
Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON Zentral Beschreibungen und Gewichtungen pflegen (Superadmin), Matrix nach Kontext anzeigen, sowie
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '} vollständige JSON-Stacks für Test Prod.
<strong>Komplett-Stack</strong> mit Fähigkeitskatalog und allen Reifegradmodellen für Test Prod).
</p> </p>
{error ? ( {error ? (
@ -246,6 +345,76 @@ export default function MaturityMatrixToolsAdmin() {
) : null} ) : null}
{message ? <p className="muted admin-matrix-tools__msg">{message}</p> : null} {message ? <p className="muted admin-matrix-tools__msg">{message}</p> : null}
<section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Zentral bearbeiten (Export / Import)</h2>
<p className="muted admin-matrix-tools__hint">
Flaches Format für Excel oder JSON-Editor: Fähigkeits-Beschreibungen,{' '}
<code className="admin-bindings__code">importance</code> (Gewichtung 15), globale Stufen-Texte (
<code className="admin-bindings__code">level_1</code> <code className="admin-bindings__code">level_5</code>
), Matrix-Zelltexte und Zeilen-Relevanz pro Reifegradmodell. Import aktualisiert bestehende Einträge
es werden keine neuen Fähigkeiten oder Modelle angelegt.
</p>
<div className="admin-matrix-tools__actions">
<button
type="button"
className="btn btn-primary"
disabled={editorLoading || editorImportLoading}
onClick={handleExportEditorJson}
>
{editorLoading ? 'Export…' : 'JSON exportieren'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={editorLoading || editorImportLoading}
onClick={() => handleExportEditorCsv('skills')}
>
Katalog als CSV
</button>
<button
type="button"
className="btn btn-secondary"
disabled={editorLoading || editorImportLoading}
onClick={() => handleExportEditorCsv('matrix')}
>
Matrix-Zellen als CSV
</button>
</div>
<div className="admin-matrix-tools__io-grid admin-matrix-tools__io-grid--editor">
<div>
<h3 className="admin-matrix-tools__h3">JSON importieren</h3>
<label className="form-label">Datei (<code className="admin-bindings__code">shinkan.matrix_editor.v1</code>)</label>
<input
type="file"
accept="application/json,.json"
disabled={editorImportLoading}
onChange={handleImportEditorJson}
/>
</div>
<div>
<h3 className="admin-matrix-tools__h3">CSV importieren</h3>
<p className="muted admin-matrix-tools__hint">
Nur geänderte Datei hochladen Katalog-CSV oder Matrix-CSV getrennt.
</p>
<label className="form-label">Katalog-CSV</label>
<input
type="file"
accept="text/csv,.csv"
disabled={editorImportLoading}
onChange={(e) => handleImportEditorCsv(e, 'skills')}
/>
<label className="form-label admin-matrix-tools__btn-mt">Matrix-CSV</label>
<input
type="file"
accept="text/csv,.csv"
disabled={editorImportLoading}
onChange={(e) => handleImportEditorCsv(e, 'matrix')}
/>
</div>
</div>
{editorImportLoading ? <p className="muted admin-matrix-tools__msg">Import läuft</p> : null}
</section>
<section className="card admin-matrix-tools__section"> <section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2> <h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2>
<div className="admin-matrix-tools__filters"> <div className="admin-matrix-tools__filters">

View 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, RetrievalRaster)</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>
)
}

View File

@ -4,7 +4,7 @@
* Zentrale API-Kommunikation mit automatischer Token-Injektion * Zentrale API-Kommunikation mit automatischer Token-Injektion
*/ */
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js' import { request, ACTIVE_CLUB_STORAGE_KEY, requestText } from '../api/client.js'
import * as exercises from '../api/exercises.js' import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js' import * as planning from '../api/planning.js'
import * as skillProfiles from '../api/skillProfiles.js' import * as skillProfiles from '../api/skillProfiles.js'
@ -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
// ============================================================================ // ============================================================================
@ -492,6 +523,23 @@ export async function importMatrixStackBundle(payload) {
}) })
} }
/** Superadmin: flacher Export für zentrale Pflege (Beschreibungen, Gewichtungen) */
export async function exportMatrixEditorBundle() {
return request('/api/admin/matrix-editor/export')
}
export async function exportMatrixEditorCsv(part) {
const format = part === 'skills' ? 'csv_skills' : 'csv_matrix'
return requestText(`/api/admin/matrix-editor/export?format=${format}`)
}
export async function importMatrixEditorBundle(payload) {
return request('/api/admin/matrix-editor/import', {
method: 'POST',
body: JSON.stringify(payload)
})
}
// Style Directions (formerly Training Styles) // Style Directions (formerly Training Styles)
export async function listStyleDirections(filters = {}) { export async function listStyleDirections(filters = {}) {
const query = new URLSearchParams(filters).toString() const query = new URLSearchParams(filters).toString()
@ -796,6 +844,12 @@ export const api = {
createAiSkillRetrievalProfile, createAiSkillRetrievalProfile,
updateAiSkillRetrievalProfile, updateAiSkillRetrievalProfile,
deleteAiSkillRetrievalProfile, deleteAiSkillRetrievalProfile,
listAdminAiPrompts,
getAdminAiPrompt,
updateAdminAiPrompt,
previewAdminAiPrompt,
resetAdminAiPromptTemplate,
getAdminAiPromptPlaceholdersCatalog,
listStyleDirections, listStyleDirections,
listTrainingStyles, listTrainingStyles,
createStyleDirection, createStyleDirection,
@ -837,6 +891,9 @@ export const api = {
exportResolvedMaturityBundle, exportResolvedMaturityBundle,
exportMatrixStackBundle, exportMatrixStackBundle,
importMatrixStackBundle, importMatrixStackBundle,
exportMatrixEditorBundle,
exportMatrixEditorCsv,
importMatrixEditorBundle,
resolveMaturityModel, resolveMaturityModel,
getMaturityModel, getMaturityModel,
createMaturityModel, createMaturityModel,