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
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:
commit
3bf012a8f4
|
|
@ -1,8 +1,14 @@
|
|||
# KI-Prompt-System – Universelle Admin-Konfiguration
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-05-30
|
||||
**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8)
|
||||
|
||||
**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan.
|
||||
|
||||
**Ist-Stand API (Superadmin):**
|
||||
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
||||
|
||||
**Autor:** Claude Code
|
||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||
|
||||
|
|
@ -598,6 +604,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
|
|||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
## 8. Prompt-Kaskaden (geplant — nicht implementiert)
|
||||
|
||||
**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern.
|
||||
|
||||
**Konzeptskizze:**
|
||||
- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`).
|
||||
- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung.
|
||||
- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen.
|
||||
|
||||
Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`).
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-05-30
|
||||
**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen
|
||||
|
|
|
|||
164
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal file
164
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal 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 **Katalog‑Zusammenstellung** vor dem Platzhalter `{{skills_catalog}}` — das bleibt **orthogonal** zur Prompt-Verwaltung: Prompt ändert *Anweisung*, Profil ändert *welche Skills im Kontextfenster sind*.
|
||||
|
||||
---
|
||||
|
||||
## 4. Trainingsplanung & Rahmen — erwartete Komplexität
|
||||
|
||||
Risiken: sehr große Kontexte (viele Slots, Streams, Bibliotheken), wiederholte KI-Anfragen, Token-Limits.
|
||||
|
||||
**Vorbereitende Strategien:**
|
||||
|
||||
1. **Gestufte Kontexte:** Rohdaten → interne Kurzfassungen (optional zweiter Prompt oder heuristisch) → finale Generator-Prompt nur mit komprimierten Summaries.
|
||||
2. **Slug-Pro-Use-Case:** z. B. `training_unit_trainer_notes`, `framework_slot_coach_hint` — jeweils schmaler Vertrag statt „ein Prompt für alles“.
|
||||
3. **Output-Verträge:** JSON-Schema + Server-Validierung vor UI; Fehlermeldungen mit Referenz auf Slug/Version.
|
||||
4. **Feature-Flags / Modell-Overrides** pro Slug (optional in DB oder Env) für Dev/Prod ohne große Codepfade.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mitai (Jinkendo)
|
||||
|
||||
Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, Preview), **andere** Kontext-Builder und ggf. andere Mandanten/Overlays. Eine gemeinsame **Resolver-/Mustache-Ebene** ist wünschenswert; **Shinkan-spezifische** Planungs- und Rahmenkontexte bleiben in Shinkan gekapselt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Betrieb, Sicherheit, Observability
|
||||
|
||||
- **Audit:** `updated_by` / Änderungshistorie für Templates (Backlog), heute: Timestamps.
|
||||
- **Prompt-Injection:** System-/User-Segmente trennen; sensible Regeln in `system`/`developer`-äquivalenten Blöcken (wenn API das hergibt).
|
||||
- **Logging:** weiter `SHINKAN_AI_DEBUG`; langfristig Hash/Länge des **aufgelösten** Prompts pro Request (ohne Secrets).
|
||||
- **Kosten/Latenz:** Timeouts, max. Token-Hinweise pro Slug-Konfiguration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phasenplan (empfohlen, ohne Big-Bang)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph heute
|
||||
A[ai_prompts DB]
|
||||
B[prompt_resolver Mustache]
|
||||
C[ai_prompt_runtime Loader + ContextKind]
|
||||
D[exercise_ai]
|
||||
end
|
||||
A --> B
|
||||
A --> C
|
||||
C --> D
|
||||
B --> D
|
||||
```
|
||||
|
||||
| Phase | Inhalt |
|
||||
|-------|--------|
|
||||
| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
|
||||
| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
|
||||
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
|
||||
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
||||
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Was bewusst vermieden werden soll
|
||||
|
||||
- Vollständige „Workflow-Engine“ mit beliebigen Graphen, bevor 2–3 konkrete Planungs-Anwendungsfälle live sind.
|
||||
- Pro-Verein-Prompt-Kopien vor klar definierter Produkt-Anforderung (sonst Daten- und Pflege-Spirale).
|
||||
- Unbegrenzte `include`-rekursive Textmakros ohne Tiefenschutz.
|
||||
|
||||
---
|
||||
|
||||
## 9. Querverweise
|
||||
|
||||
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
|
||||
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
|
||||
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0 · **Datum:** 2026-05-30
|
||||
|
|
@ -33,19 +33,22 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
||||
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||
| matrix_editor | `/api/admin/matrix-editor/*` (Export/Import Editor-Bundle) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale Fähigkeitsmatrix ohne Mandantenkontext |
|
||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068).
|
||||
Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile.
|
||||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
||||
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
|
|||
74
backend/ai_prompt_runtime.py
Normal file
74
backend/ai_prompt_runtime.py
Normal 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",
|
||||
]
|
||||
|
|
@ -18,6 +18,9 @@ from fastapi import HTTPException
|
|||
|
||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||
|
||||
from ai_prompt_runtime import load_ai_prompt_row
|
||||
from prompt_resolver import render_mustache_template
|
||||
|
||||
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||
|
||||
|
||||
|
|
@ -488,30 +491,46 @@ def build_contextual_skills_catalog_block(
|
|||
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
||||
|
||||
|
||||
def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s
|
||||
""",
|
||||
(slug,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if not d.get("active", True):
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
def _render_template(template: str, ctx: Dict[str, str]) -> str:
|
||||
out = template or ""
|
||||
for key, val in ctx.items():
|
||||
placeholder = "{{" + key + "}}"
|
||||
out = out.replace(placeholder, val if val is not None else "")
|
||||
return out
|
||||
def build_exercise_placeholder_variables(
|
||||
cur,
|
||||
*,
|
||||
slug: str,
|
||||
title: Optional[str],
|
||||
goal: Optional[str],
|
||||
execution: Optional[str],
|
||||
focus_area_hint: Optional[str],
|
||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
|
||||
"""
|
||||
s = (slug or "").strip().lower()
|
||||
if s == "pipeline":
|
||||
return {}
|
||||
g_plain = strip_html_to_plain(goal)
|
||||
e_plain = strip_html_to_plain(execution)
|
||||
t_title = (title or "").strip()
|
||||
focus = (focus_area_hint or "").strip()
|
||||
ctx: Dict[str, str] = {
|
||||
"exercise_title": t_title or "-",
|
||||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
}
|
||||
if s == "exercise_summary":
|
||||
return ctx
|
||||
if s == "exercise_skill_suggestions":
|
||||
catalog = build_contextual_skills_catalog_block(
|
||||
cur,
|
||||
title=t_title,
|
||||
goal_plain=g_plain,
|
||||
execution_plain=e_plain,
|
||||
focus_hint=focus or None,
|
||||
focus_ctx=focus_areas_context,
|
||||
)
|
||||
ctx["skills_catalog"] = catalog
|
||||
return ctx
|
||||
raise ValueError(f"Kein Platzhalter-Kontext fuer slug={slug!r} definiert.")
|
||||
|
||||
|
||||
def _first_balanced_json_array(text: str) -> Optional[str]:
|
||||
|
|
@ -696,21 +715,28 @@ def run_exercise_ai_suggestion(
|
|||
)
|
||||
|
||||
if want_summary:
|
||||
prow = _load_prompt_row(cur, "exercise_summary")
|
||||
prow = load_ai_prompt_row(cur, "exercise_summary")
|
||||
if not prow:
|
||||
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
||||
ctx = {
|
||||
"exercise_title": t_title or "-",
|
||||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
}
|
||||
prompt = _render_template(str(prow["template"]), ctx)
|
||||
try:
|
||||
ctx = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
slug="exercise_summary",
|
||||
title=title,
|
||||
goal=goal,
|
||||
execution=execution,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
rendered = render_mustache_template(str(prow["template"]), ctx)
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s",
|
||||
"AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s placeholders_remaining=%s",
|
||||
len(prompt),
|
||||
prompt.count("{{"),
|
||||
len(rendered.placeholders_remaining),
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||
|
|
@ -729,33 +755,31 @@ def run_exercise_ai_suggestion(
|
|||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
||||
|
||||
if want_skills:
|
||||
srow = _load_prompt_row(cur, "exercise_skill_suggestions")
|
||||
srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
|
||||
if not srow:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||
)
|
||||
catalog = build_contextual_skills_catalog_block(
|
||||
cur,
|
||||
title=t_title,
|
||||
goal_plain=g_plain,
|
||||
execution_plain=e_plain,
|
||||
focus_hint=focus or None,
|
||||
focus_ctx=focus_areas_context,
|
||||
)
|
||||
ctx = {
|
||||
"exercise_title": t_title or "-",
|
||||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
"skills_catalog": catalog,
|
||||
}
|
||||
prompt = _render_template(str(srow["template"]), ctx)
|
||||
try:
|
||||
ctx = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
slug="exercise_skill_suggestions",
|
||||
title=title,
|
||||
goal=goal,
|
||||
execution=execution,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
rendered = render_mustache_template(str(srow["template"]), ctx)
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
|
||||
"template_has_skills_placeholder=%s",
|
||||
len(catalog),
|
||||
len(ctx.get("skills_catalog") or ""),
|
||||
len(prompt),
|
||||
"{{skills_catalog}}" in str(srow.get("template") or ""),
|
||||
)
|
||||
|
|
@ -808,6 +832,7 @@ def run_exercise_ai_suggestion(
|
|||
|
||||
__all__ = [
|
||||
"build_contextual_skills_catalog_block",
|
||||
"build_exercise_placeholder_variables",
|
||||
"run_exercise_ai_suggestion",
|
||||
"strip_html_to_plain",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -216,10 +216,12 @@ app.include_router(training_framework_programs.router)
|
|||
app.include_router(catalogs.router)
|
||||
app.include_router(maturity_models.router)
|
||||
app.include_router(matrix_stack_bundle.router)
|
||||
app.include_router(matrix_editor.router)
|
||||
app.include_router(import_wiki.router)
|
||||
app.include_router(import_wiki_admin.router)
|
||||
app.include_router(legal_documents.router)
|
||||
app.include_router(content_reports.router)
|
||||
app.include_router(ai_prompts_admin.router)
|
||||
app.include_router(ai_skill_retrieval_admin.router)
|
||||
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
|
|
|
|||
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- Migration 069: ai_prompts default_template fuer Ruecksetzen & Transparenz
|
||||
-- Setzt fuer bestehende System-Prompt-Zeilen default_template aus dem aktuellen template,
|
||||
-- sofern noch kein Referenzinhalt gespeichert war (Migration 067 hatte NULL fuer exercise_*).
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET default_template = template,
|
||||
updated_at = NOW()
|
||||
WHERE default_template IS NULL
|
||||
AND template IS NOT NULL
|
||||
AND LENGTH(TRIM(template)) > 0;
|
||||
128
backend/prompt_resolver.py
Normal file
128
backend/prompt_resolver.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Mustache-aehnliche Platzhalter {{schluessel}} fuer KI-Templates aus ai_prompts.
|
||||
|
||||
Kein Vereinsbezug — reine Textersetzung; Aufrufe aus exercise_ai und Admin-Vorschau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Mapping
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
||||
|
||||
|
||||
def _placeholder_pattern_for_key(key: str) -> re.Pattern[str]:
|
||||
return re.compile(r"\{\{\s*" + re.escape(str(key).strip()) + r"\s*\}\}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MustacheRenderResult:
|
||||
"""Ergebnis von render_mustache_template."""
|
||||
|
||||
text: str
|
||||
keys_in_template: List[str]
|
||||
keys_substituted: List[str]
|
||||
keys_missing_variables: List[str]
|
||||
placeholders_remaining: List[str]
|
||||
|
||||
|
||||
def extract_mustache_keys(template: str) -> List[str]:
|
||||
"""Platzhalter-Namen in Vorkommensreihenfolge, ohne erstes Duplikat."""
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for m in _PLACEHOLDER_RE.finditer(template or ""):
|
||||
k = str(m.group(1) or "").strip()
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
ordered.append(k)
|
||||
return ordered
|
||||
|
||||
|
||||
def render_mustache_template(template: str, variables: Mapping[str, str]) -> MustacheRenderResult:
|
||||
"""
|
||||
Ersetzt {{keys}} durch die passenden Strings.
|
||||
Variablen, die fuer einen im Template genutzten Key fehlen, werden als Leerstring ersetzt.
|
||||
|
||||
Rueckgabe-liste keys_missing_variables: Keys, die im Template vorkommen, aber nicht als Map-Keys
|
||||
uebergeben wurden (oder None-Wert entsprachen Leerung).
|
||||
"""
|
||||
tpl_in = template or ""
|
||||
vars_norm: Dict[str, str] = {}
|
||||
for k, v in variables.items():
|
||||
vars_norm[str(k)] = "" if v is None else str(v)
|
||||
keys_in = extract_mustache_keys(tpl_in)
|
||||
missing_known: List[str] = []
|
||||
out = tpl_in
|
||||
substituted: List[str] = []
|
||||
|
||||
for key in keys_in:
|
||||
pat = _placeholder_pattern_for_key(key)
|
||||
repl = vars_norm.get(key)
|
||||
if key not in vars_norm:
|
||||
missing_known.append(key)
|
||||
repl = ""
|
||||
substituted.append(key)
|
||||
out = pat.sub(repl, out)
|
||||
|
||||
still = extract_mustache_keys(out)
|
||||
|
||||
return MustacheRenderResult(
|
||||
text=out,
|
||||
keys_in_template=keys_in,
|
||||
keys_substituted=substituted,
|
||||
keys_missing_variables=missing_known,
|
||||
placeholders_remaining=still,
|
||||
)
|
||||
|
||||
|
||||
def exercise_placeholder_catalog() -> dict:
|
||||
"""
|
||||
Statischer Platzhalter-Katalog fuer Uebungs-KI-Templates — deckt aktuelle Seeds ab.
|
||||
(Erweiterung andere Kontexte: matrix/import folgen separat.)
|
||||
"""
|
||||
defs = [
|
||||
{
|
||||
"key": "exercise_title",
|
||||
"placeholder": "{{exercise_title}}",
|
||||
"description": "Titel der Uebung (oder Platzhalter, wenn leer).",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_focus_area",
|
||||
"placeholder": "{{exercise_focus_area}}",
|
||||
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_goal",
|
||||
"placeholder": "{{exercise_goal}}",
|
||||
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_execution",
|
||||
"placeholder": "{{exercise_execution}}",
|
||||
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
||||
},
|
||||
{
|
||||
"key": "skills_catalog",
|
||||
"placeholder": "{{skills_catalog}}",
|
||||
"description": (
|
||||
"Gewichtete, kontextbezogene Liste aus dem Skill-Katalog (retrieval_profiles). "
|
||||
"Nur fuer exercise_skill_suggestions."
|
||||
),
|
||||
"used_by_slugs": ["exercise_skill_suggestions"],
|
||||
},
|
||||
]
|
||||
return {"context": "exercise", "placeholders": defs}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MustacheRenderResult",
|
||||
"exercise_placeholder_catalog",
|
||||
"extract_mustache_keys",
|
||||
"render_mustache_template",
|
||||
]
|
||||
253
backend/routers/ai_prompts_admin.py
Normal file
253
backend/routers/ai_prompts_admin.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""
|
||||
Superadmin-API: Verwaltung von ai_prompts (Templates, Aktivierung, Vorschau).
|
||||
|
||||
Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_superadmin
|
||||
from db import get_cursor, get_db, r2d
|
||||
from exercise_ai import build_exercise_placeholder_variables
|
||||
from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
|
||||
|
||||
router = APIRouter(tags=["admin_ai_prompts"])
|
||||
|
||||
|
||||
def _require_superadmin(session: dict = Depends(require_auth)) -> dict:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmins")
|
||||
return session
|
||||
|
||||
|
||||
def _prompts_table_ready(cur) -> bool:
|
||||
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_prompts",))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
val = row.get("t") if isinstance(row, dict) else row[0]
|
||||
return val is not None and str(val).strip() != ""
|
||||
|
||||
|
||||
def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template,
|
||||
active, sort_order, created_at, updated_at
|
||||
FROM ai_prompts WHERE id = %s
|
||||
""",
|
||||
(prompt_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Prompt nicht gefunden")
|
||||
return dict(row)
|
||||
|
||||
|
||||
class AiPromptUpdateBody(BaseModel):
|
||||
template: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
display_name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=8000)
|
||||
|
||||
|
||||
class AiPromptPreviewFocus(BaseModel):
|
||||
focus_area_id: int = Field(..., ge=1)
|
||||
is_primary: Optional[bool] = False
|
||||
|
||||
|
||||
class AiPromptPreviewBody(BaseModel):
|
||||
title: Optional[str] = ""
|
||||
goal: Optional[str] = None
|
||||
execution: Optional[str] = None
|
||||
focus_hint: Optional[str] = None
|
||||
focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
||||
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
|
||||
return exercise_placeholder_catalog()
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-prompts")
|
||||
def list_ai_prompts(session: dict = Depends(_require_superadmin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _prompts_table_ready(cur):
|
||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, slug, display_name, description, category, output_format, active,
|
||||
sort_order, is_system_default, default_template
|
||||
FROM ai_prompts
|
||||
ORDER BY sort_order ASC NULLS LAST, id ASC
|
||||
"""
|
||||
)
|
||||
rows = [r2d(r) if not isinstance(r, dict) else r for r in cur.fetchall()]
|
||||
out = []
|
||||
for r in rows:
|
||||
dt = (r.get("default_template") or "").strip()
|
||||
tmpl = (r.get("template") or "").strip()
|
||||
is_modified = bool(dt and tmpl != dt)
|
||||
rr = {k: v for k, v in r.items() if k != "default_template"}
|
||||
rr["is_modified"] = is_modified if dt else False
|
||||
rr["has_reference_template"] = bool(dt)
|
||||
out.append(rr)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-prompts/{prompt_id:int}")
|
||||
def get_ai_prompt(prompt_id: int, session: dict = Depends(_require_superadmin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _prompts_table_ready(cur):
|
||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||
row = _fetch_prompt_any(cur, prompt_id)
|
||||
dt = (row.get("default_template") or "").strip()
|
||||
tmpl = (row.get("template") or "").strip()
|
||||
row_out = dict(row)
|
||||
row_out["is_modified"] = bool(dt and tmpl != dt) if dt else False
|
||||
row_out["has_reference_template"] = bool(dt)
|
||||
return row_out
|
||||
|
||||
|
||||
@router.put("/api/admin/ai-prompts/{prompt_id:int}")
|
||||
def update_ai_prompt(
|
||||
prompt_id: int,
|
||||
body: AiPromptUpdateBody,
|
||||
session: dict = Depends(_require_superadmin),
|
||||
):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _prompts_table_ready(cur):
|
||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||
old = _fetch_prompt_any(cur, prompt_id)
|
||||
|
||||
next_template = old["template"]
|
||||
if body.template is not None:
|
||||
tpl = body.template.strip() if isinstance(body.template, str) else ""
|
||||
if not tpl:
|
||||
raise HTTPException(status_code=400, detail="template darf nicht leer sein.")
|
||||
next_template = tpl
|
||||
|
||||
next_active = bool(old.get("active", True))
|
||||
if body.active is not None:
|
||||
next_active = body.active
|
||||
|
||||
next_name = body.display_name if body.display_name is not None else old.get("display_name") or ""
|
||||
next_name = (next_name or "").strip()
|
||||
if not next_name:
|
||||
raise HTTPException(status_code=400, detail="display_name darf nicht leer sein.")
|
||||
|
||||
next_desc = body.description if body.description is not None else old.get("description") or ""
|
||||
next_desc = (next_desc or "").strip()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE ai_prompts
|
||||
SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template, active, sort_order,
|
||||
created_at, updated_at
|
||||
""",
|
||||
(next_template, next_active, next_name, next_desc, prompt_id),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
dt = (row.get("default_template") or "").strip()
|
||||
tmpl = (row.get("template") or "").strip()
|
||||
row["is_modified"] = bool(dt and tmpl != dt) if dt else False
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/api/admin/ai-prompts/{prompt_id:int}/reset-template")
|
||||
def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_superadmin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _prompts_table_ready(cur):
|
||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||
old = _fetch_prompt_any(cur, prompt_id)
|
||||
dt_old = old.get("default_template")
|
||||
if dt_old is None or not str(dt_old).strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Kein gespeicherter Referenztext (default_template) — Ruecksetzen nicht möglich.",
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE ai_prompts
|
||||
SET template = default_template, updated_at = NOW()
|
||||
WHERE id = %s AND default_template IS NOT NULL
|
||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template, active, sort_order,
|
||||
created_at, updated_at
|
||||
""",
|
||||
(prompt_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Prompt nicht gefunden")
|
||||
rr = dict(row)
|
||||
conn.commit()
|
||||
tmpl = (rr.get("template") or "").strip()
|
||||
dt = (rr.get("default_template") or "").strip()
|
||||
rr["is_modified"] = bool(dt and tmpl != dt) if dt else False
|
||||
return rr
|
||||
|
||||
|
||||
@router.post("/api/admin/ai-prompts/{prompt_id:int}/preview")
|
||||
def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = Depends(_require_superadmin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _prompts_table_ready(cur):
|
||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||
row = _fetch_prompt_any(cur, prompt_id)
|
||||
slug = (row.get("slug") or "").strip().lower()
|
||||
tpl_raw = row.get("template") or ""
|
||||
|
||||
fctx_list: Optional[List[tuple[int, bool]]] = None
|
||||
if body.focus_areas_context:
|
||||
pairs: List[tuple[int, bool]] = []
|
||||
for x in body.focus_areas_context:
|
||||
pairs.append((int(x.focus_area_id), bool(x.is_primary)))
|
||||
fctx_list = pairs
|
||||
|
||||
vars_map: Dict[str, str]
|
||||
warn: Optional[str] = None
|
||||
if slug in ("exercise_summary", "exercise_skill_suggestions"):
|
||||
try:
|
||||
vars_map = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
slug=slug,
|
||||
title=(body.title or "").strip(),
|
||||
goal=body.goal,
|
||||
execution=body.execution,
|
||||
focus_area_hint=body.focus_hint,
|
||||
focus_areas_context=fctx_list,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
elif slug == "pipeline":
|
||||
vars_map = {}
|
||||
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."
|
||||
else:
|
||||
vars_map = {}
|
||||
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
|
||||
|
||||
rendered = render_mustache_template(str(tpl_raw), vars_map)
|
||||
return {
|
||||
"slug": slug,
|
||||
"resolved_template": rendered.text,
|
||||
"keys_in_template": rendered.keys_in_template,
|
||||
"keys_missing_variables": rendered.keys_missing_variables,
|
||||
"placeholders_remaining": rendered.placeholders_remaining,
|
||||
"warning": warn,
|
||||
}
|
||||
727
backend/routers/matrix_editor.py
Normal file
727
backend/routers/matrix_editor.py
Normal 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,
|
||||
}
|
||||
|
|
@ -24,10 +24,12 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"platform_media_storage.py",
|
||||
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
"matrix_stack_bundle.py",
|
||||
"matrix_editor.py", # Superadmin Editor-Export/Import Fähigkeitsmatrix; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"import_wiki.py",
|
||||
"import_wiki_admin.py",
|
||||
}
|
||||
|
|
|
|||
146
backend/tests/test_matrix_editor.py
Normal file
146
backend/tests/test_matrix_editor.py
Normal 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
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.157"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260529068"
|
||||
APP_VERSION = "0.8.159"
|
||||
BUILD_DATE = "2026-05-30"
|
||||
DB_SCHEMA_VERSION = "20260530069"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -19,11 +19,13 @@ MODULE_VERSIONS = {
|
|||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"admin_ai_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
|
||||
"ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter
|
||||
"exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -38,6 +40,22 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.159",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
"Doku: AI_PROMPT_TARGET_ARCHITECTURE.md (Zielbild Kontext-Arten, Composition, Planung/Rahmen, Phasenplan); HANDOVER & docs/architecture/README verlinkt;",
|
||||
"Backend ai_prompt_runtime: AiPromptContextKind, load_ai_prompt_row — exercise_ai ohne duplizierte Prompt-SQL;",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.158",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
"KI-Prompts: Backend prompt_resolver ({{platzhalter}} robust), Admin-API /api/admin/ai-prompts* (Liste, Detail, PUT, Preview, Reset), Migration 069 default_template;",
|
||||
"Superadmin-Web: Admin KI Prompts (/admin/ai-prompts) mit Platzhalter-Katalog und Vorschau ohne OpenRouter.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.157",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-29
|
||||
**App-Version / DB-Schema:** App **`0.8.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||
**Stand:** 2026-05-30
|
||||
**App-Version / DB-Schema:** App **`0.8.159`** u. a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| Media / Upload-Limits / Embed | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
|
||||
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
|
||||
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
|
||||
| KI-Prompt-System — Zielarchitektur (Roadmap) | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||
| Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` |
|
||||
| **Trainingsmodule & Kombinationsübungen (Fachspez, Drift-Schutz)** | `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (§ 10.2.1 Archetyp-IDs, § 10.4 Coaching-Stufen, **Anhang A** Code-Abgleich) |
|
||||
|
|
@ -88,14 +89,16 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||
|
||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.157**)
|
||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**)
|
||||
|
||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
|
||||
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||
- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
|
||||
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
|
||||
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
||||
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
|
||||
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`**
|
||||
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
|
||||
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`**
|
||||
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
|
|||
| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen |
|
||||
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
|
||||
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
|
||||
| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |
|
||||
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
|
||||
|
||||
## Tests (E2E / Refaktor-Budget)
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
|||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||
|
||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||
|
|
@ -309,6 +310,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/ai-prompts',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminAiPromptsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,13 +13,10 @@ export function mergeActiveClubHeader(headers = {}) {
|
|||
if (cid && /^\d+$/.test(String(cid).trim())) {
|
||||
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
|
||||
}
|
||||
return { ...headers }
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
|
||||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
async function _fetchWithAuth(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
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}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
|
||||
const hint =
|
||||
|
|
@ -77,3 +74,22 @@ export async function request(endpoint, options = {}) {
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -12,7 +12,7 @@ export default function AdminPageNav() {
|
|||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||
{ to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain },
|
||||
{ to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@ function downloadJson(obj, filename) {
|
|||
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) {
|
||||
if (!model?.model_skills?.length) return []
|
||||
const groups = new Map()
|
||||
|
|
@ -56,6 +66,8 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
const [stackConfirmText, setStackConfirmText] = useState('')
|
||||
const [stackLoading, setStackLoading] = useState(false)
|
||||
const [stackImportLoading, setStackImportLoading] = useState(false)
|
||||
const [editorLoading, setEditorLoading] = useState(false)
|
||||
const [editorImportLoading, setEditorImportLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
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() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
|
@ -234,9 +334,8 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
return (
|
||||
<div className="admin-matrix-tools">
|
||||
<p className="admin-matrix-tools__intro muted">
|
||||
Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON
|
||||
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '}
|
||||
<strong>Komplett-Stack</strong> mit Fähigkeitskatalog und allen Reifegradmodellen für Test → Prod).
|
||||
Zentral Beschreibungen und Gewichtungen pflegen (Superadmin), Matrix nach Kontext anzeigen, sowie
|
||||
vollständige JSON-Stacks für Test → Prod.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -246,6 +345,76 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
) : 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 1–5), 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">
|
||||
<h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2>
|
||||
<div className="admin-matrix-tools__filters">
|
||||
|
|
|
|||
346
frontend/src/pages/AdminAiPromptsPage.jsx
Normal file
346
frontend/src/pages/AdminAiPromptsPage.jsx
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
/**
|
||||
* Pflege von ai_prompts (OpenRouter-Vorlagen) — nur Superadmin.
|
||||
*/
|
||||
export default function AdminAiPromptsPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftDesc, setDraftDesc] = useState('')
|
||||
const [draftTemplate, setDraftTemplate] = useState('')
|
||||
const [draftActive, setDraftActive] = useState(true)
|
||||
|
||||
const [pvTitle, setPvTitle] = useState('Testübung')
|
||||
const [pvGoal, setPvGoal] = useState('<p>Ziel hier</p>')
|
||||
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
||||
const [pvHint, setPvHint] = useState('')
|
||||
const [pvFocusId, setPvFocusId] = useState('')
|
||||
const [pvPreview, setPvPreview] = useState(null)
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
const [pList, cat] = await Promise.all([
|
||||
api.listAdminAiPrompts(),
|
||||
api.getAdminAiPromptPlaceholdersCatalog(),
|
||||
])
|
||||
setPrompts(Array.isArray(pList) ? pList : [])
|
||||
setCatalog(cat || null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, loadList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin || !selectedId) {
|
||||
setDetail(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const d = await api.getAdminAiPrompt(selectedId)
|
||||
if (!cancelled) {
|
||||
setDetail(d)
|
||||
setDraftName(d.display_name || '')
|
||||
setDraftDesc(d.description || '')
|
||||
setDraftTemplate(d.template || '')
|
||||
setDraftActive(!!d.active)
|
||||
setPvPreview(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, selectedId])
|
||||
|
||||
const save = async () => {
|
||||
if (!detail?.id) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.updateAdminAiPrompt(detail.id, {
|
||||
template: draftTemplate,
|
||||
display_name: draftName,
|
||||
description: draftDesc,
|
||||
active: draftActive,
|
||||
})
|
||||
await loadList()
|
||||
const nd = await api.getAdminAiPrompt(detail.id)
|
||||
setDetail(nd)
|
||||
setPvPreview(null)
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTemplate = async () => {
|
||||
if (!detail?.id || !detail.has_reference_template) return
|
||||
if (!confirm('Template auf gespeicherten Referenztext zurücksetzen?')) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const nd = await api.resetAdminAiPromptTemplate(detail.id)
|
||||
setDetail(nd)
|
||||
setDraftTemplate(nd.template || '')
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!detail?.id) return
|
||||
setError('')
|
||||
try {
|
||||
const body = {
|
||||
title: pvTitle,
|
||||
goal: pvGoal,
|
||||
execution: pvExec,
|
||||
focus_hint: pvHint || undefined,
|
||||
}
|
||||
const fid = parseInt(String(pvFocusId).trim(), 10)
|
||||
if (Number.isFinite(fid) && fid >= 1) {
|
||||
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
|
||||
}
|
||||
const r = await api.previewAdminAiPrompt(detail.id, body)
|
||||
setPvPreview(r)
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<AdminPageNav />
|
||||
<div className="card" style={{ marginTop: 16, padding: 24, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px', paddingBottom: 96 }}>
|
||||
<AdminPageNav />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<Sparkles size={22} />
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
|
||||
aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||
</p>
|
||||
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '260px minmax(0,1fr)', gap: 16 }}>
|
||||
<div className="card" style={{ padding: 12 }}>
|
||||
<strong style={{ fontSize: '13px' }}>Prompts</strong>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0 0', maxHeight: '70vh', overflow: 'auto' }}>
|
||||
{(prompts || []).map((p) => (
|
||||
<li key={p.id} style={{ marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedId === p.id ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
fontSize: 12,
|
||||
padding: '8px 10px',
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedId(p.id)
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{p.display_name}</span>
|
||||
<span style={{ opacity: 0.85, marginTop: 2 }}>{p.slug}</span>
|
||||
{!p.active ? (
|
||||
<span style={{ color: 'var(--danger)', fontSize: 11 }}>
|
||||
inaktiv
|
||||
</span>
|
||||
) : null}
|
||||
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!selectedId ? (
|
||||
<p style={{ color: 'var(--text3)' }}>Prompt links wählen.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
slug: <code>{detail?.slug}</code> · Ausgabe:{' '}
|
||||
<code>{detail?.output_format}</code> · Kategorie: <code>{detail?.category}</code>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginTop: 10 }}>
|
||||
<label className="form-label">Name</label>
|
||||
<input className="form-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={draftDesc}
|
||||
onChange={(e) => setDraftDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
||||
Aktiv
|
||||
</label>
|
||||
<label className="form-label">Template (Mustache-Zeilen mit {'{{'}}name{'}'}})</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
||||
rows={22}
|
||||
value={draftTemplate}
|
||||
onChange={(e) => setDraftTemplate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => save()}>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={saving || !detail?.has_reference_template}
|
||||
title={!detail?.has_reference_template ? 'Nach Migration 069 bzw. manuell default_template gesetzt' : ''}
|
||||
onClick={() => resetTemplate()}
|
||||
>
|
||||
Auf Referenz zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details style={{ marginTop: 20 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Platzhalter-Katalog (Übung)</summary>
|
||||
{catalog?.placeholders ? (
|
||||
<ul style={{ paddingLeft: 18, marginTop: 8 }}>
|
||||
{catalog.placeholders.map((ph) => (
|
||||
<li key={ph.key} style={{ marginBottom: 10, fontSize: 13 }}>
|
||||
<code>{ph.placeholder}</code> — {ph.description}{' '}
|
||||
<span style={{ color: 'var(--text3)' }}>
|
||||
[{Array.isArray(ph.used_by_slugs) ? ph.used_by_slugs.join(', ') : ''}]
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Wird geladen …</p>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Titel</label>
|
||||
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="numerisch"
|
||||
value={pvFocusId}
|
||||
onChange={(e) => setPvFocusId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokus-Hinweistext</label>
|
||||
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ziel (HTML möglich)</label>
|
||||
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Durchführung (HTML möglich)</label>
|
||||
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
|
||||
Platzhalter auflösen
|
||||
</button>
|
||||
{pvPreview?.warning ? (
|
||||
<p style={{ marginTop: 10, color: 'var(--text3)', fontSize: 13 }}>{pvPreview.warning}</p>
|
||||
) : null}
|
||||
{pvPreview?.placeholders_remaining?.length ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>
|
||||
Unbekannte Platzhalter im Ergebnis:{' '}
|
||||
{pvPreview.placeholders_remaining.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{pvPreview?.resolved_template != null ? (
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 360,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{pvPreview.resolved_template}
|
||||
</pre>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* 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 planning from '../api/planning.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' })
|
||||
}
|
||||
|
||||
/** 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
|
||||
// ============================================================================
|
||||
|
|
@ -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)
|
||||
export async function listStyleDirections(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
|
|
@ -796,6 +844,12 @@ export const api = {
|
|||
createAiSkillRetrievalProfile,
|
||||
updateAiSkillRetrievalProfile,
|
||||
deleteAiSkillRetrievalProfile,
|
||||
listAdminAiPrompts,
|
||||
getAdminAiPrompt,
|
||||
updateAdminAiPrompt,
|
||||
previewAdminAiPrompt,
|
||||
resetAdminAiPromptTemplate,
|
||||
getAdminAiPromptPlaceholdersCatalog,
|
||||
listStyleDirections,
|
||||
listTrainingStyles,
|
||||
createStyleDirection,
|
||||
|
|
@ -837,6 +891,9 @@ export const api = {
|
|||
exportResolvedMaturityBundle,
|
||||
exportMatrixStackBundle,
|
||||
importMatrixStackBundle,
|
||||
exportMatrixEditorBundle,
|
||||
exportMatrixEditorCsv,
|
||||
importMatrixEditorBundle,
|
||||
resolveMaturityModel,
|
||||
getMaturityModel,
|
||||
createMaturityModel,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user