KI Implementierung (MVP) auf Übungen #46

Merged
Lars merged 10 commits from develop into main 2026-05-22 10:38:39 +02:00
11 changed files with 871 additions and 10 deletions
Showing only changes of commit 286c36e9d7 - Show all commits

View File

@ -34,17 +34,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 |
| 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 |
**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 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068).
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).
---
### Changelog (Fortführung)
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.

View File

@ -111,10 +111,11 @@ Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts).
`POST …/ai/regenerate` nutzt später dieselbe Retrieval-Logik aus den Detail-Daten der Übung (**To-do:** dort `focus_areas_context` aus `exercise_focus_areas` ableiten).
`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest.
---
**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**.
## 6. Changelog
- **2026-05-29:** Superadmin-Pflege-Endpoints + UIRoute dokumentiert (`/admin/ai-skill-retrieval`).
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.

View File

@ -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
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
app.include_router(auth.router)
app.include_router(profiles.router)
@ -220,6 +220,7 @@ app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
app.include_router(ai_skill_retrieval_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).

View File

@ -0,0 +1,370 @@
"""
Superadmin API: Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog / exercise_ai).
Kein Vereinsbezug require_auth + is_superadmin; kein TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_cursor, get_db, r2d
router = APIRouter(tags=["admin_ai_skill_retrieval"])
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 _table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
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 _assert_table(cur) -> None:
if not _table_ready(cur):
raise HTTPException(
status_code=503,
detail="Tabelle ai_skill_retrieval_profiles fehlt — Migration 068 ausführen.",
)
def _normalize_config(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"config: ungültiges JSON — {e}") from e
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="config muss ein JSON-Objekt sein.")
return parsed
raise HTTPException(status_code=400, detail="config muss ein Objekt sein.")
class AiRetrievalProfileCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=200)
description: Optional[str] = Field("", max_length=4000)
active: bool = True
is_default: bool = False
focus_area_id: Optional[int] = Field(None, ge=1)
config: Dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def default_vs_focus(self):
if self.is_default and self.focus_area_id is not None:
raise ValueError("Standardprofil darf keinen Fokusbereich (focus_area_id) haben.")
if not self.is_default and self.focus_area_id is None:
raise ValueError("Profil ohne Standard-Flag benötigt eine focus_area_id.")
return self
class AiRetrievalProfileUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=200)
description: Optional[str] = Field(None, max_length=4000)
active: Optional[bool] = None
is_default: Optional[bool] = None
focus_area_id: Optional[int] = Field(None, ge=1)
config: Optional[Dict[str, Any]] = None
def _row_to_out(row: dict) -> dict:
cfg = row.get("config")
if isinstance(cfg, str):
try:
cfg = json.loads(cfg)
except json.JSONDecodeError:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
out = {
"id": row["id"],
"focus_area_id": row.get("focus_area_id"),
"focus_area_name": row.get("focus_area_name"),
"is_default": bool(row.get("is_default")),
"name": row.get("name") or "",
"description": row.get("description") or "",
"active": bool(row.get("active", True)),
"config": cfg,
"updated_at": row.get("updated_at"),
}
return out
def _active_focus_conflict(cur, focus_area_id: int, exclude_id: Optional[int] = None) -> bool:
if exclude_id is not None:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s AND id != %s
LIMIT 1
""",
(focus_area_id, exclude_id),
)
else:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s
LIMIT 1
""",
(focus_area_id,),
)
return cur.fetchone() is not None
def _focus_area_exists(cur, focus_area_id: int) -> bool:
cur.execute(
"SELECT 1 FROM focus_areas WHERE id = %s AND (status IS NULL OR status = 'active') LIMIT 1",
(focus_area_id,),
)
return cur.fetchone() is not None
@router.get("/api/admin/ai-skill-retrieval-profiles")
def list_ai_skill_retrieval_profiles(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
ORDER BY p.is_default DESC NULLS LAST, fa.name NULLS LAST, p.name
"""
)
rows = [r2d(r) for r in cur.fetchall()]
return [_row_to_out(r) for r in rows]
@router.get("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def get_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
return _row_to_out(r2d(row))
@router.post("/api/admin/ai-skill-retrieval-profiles", status_code=201)
def create_ai_skill_retrieval_profile(
body: AiRetrievalProfileCreate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
cfg = _normalize_config(body.config)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
if body.is_default:
cur.execute(
"UPDATE ai_skill_retrieval_profiles SET is_default = false, updated_at = NOW() WHERE is_default = true"
)
else:
if not _focus_area_exists(cur, int(body.focus_area_id)):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if body.active and _active_focus_conflict(cur, int(body.focus_area_id)):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein aktives Profil.",
)
cur.execute(
"""
INSERT INTO ai_skill_retrieval_profiles
(focus_area_id, is_default, name, description, active, config, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
None if body.is_default else body.focus_area_id,
body.is_default,
body.name.strip(),
(body.description or "").strip(),
body.active,
Json(cfg),
),
)
new_id_row = cur.fetchone()
new_id = int(new_id_row["id"] if isinstance(new_id_row, dict) else new_id_row[0])
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(new_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.put("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def update_ai_skill_retrieval_profile(
profile_id: int,
body: AiRetrievalProfileUpdate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT id, focus_area_id, is_default, name, description, active, config, updated_at
FROM ai_skill_retrieval_profiles WHERE id = %s
""",
(profile_id,),
)
old = cur.fetchone()
if not old:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
old_d = dict(old)
if body.is_default is True and body.focus_area_id is not None:
raise HTTPException(
status_code=400,
detail="Standardprofil und Fokusbereich schließen sich aus.",
)
nm = body.name.strip() if body.name is not None else (old_d["name"] or "")
nm = nm.strip()
desc = (
body.description.strip() if body.description is not None else (old_d.get("description") or "")
).strip()
active = body.active if body.active is not None else bool(old_d.get("active", True))
next_is_default = body.is_default if body.is_default is not None else bool(old_d.get("is_default"))
next_focus_raw = (
body.focus_area_id if body.focus_area_id is not None else old_d.get("focus_area_id")
)
cfg = (
_normalize_config(body.config) if body.config is not None else _normalize_config(old_d.get("config"))
)
if next_is_default:
focus_id_sql: Optional[int] = None
else:
if next_focus_raw is None:
raise HTTPException(status_code=400, detail="Nicht-Standard-Profil benötigt focus_area_id.")
focus_id_sql = int(next_focus_raw)
if not _focus_area_exists(cur, focus_id_sql):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if active and _active_focus_conflict(cur, focus_id_sql, exclude_id=profile_id):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein anderes aktives Profil.",
)
old_default = bool(old_d.get("is_default"))
if old_default and not next_is_default:
raise HTTPException(
status_code=400,
detail="Das Standardprofil kann hier nicht zum Fokusbereichsprofil geändert werden — zuerst ein anderes Profil als Standard aktivieren.",
)
if old_default and active is False:
raise HTTPException(status_code=400, detail="Standardprofil kann nicht deaktiviert werden.")
if next_is_default:
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET is_default = false, updated_at = NOW()
WHERE is_default = true AND id != %s
""",
(profile_id,),
)
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET focus_area_id = %s,
is_default = %s,
name = %s,
description = %s,
active = %s,
config = %s,
updated_at = NOW()
WHERE id = %s
""",
(
focus_id_sql,
next_is_default,
nm,
desc,
active,
Json(cfg),
profile_id,
),
)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.delete("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def delete_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"SELECT id, is_default FROM ai_skill_retrieval_profiles WHERE id = %s",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
if bool(row.get("is_default") if isinstance(row, dict) else row[1]):
raise HTTPException(status_code=400, detail="Standardprofil kann nicht gelöscht werden.")
cur.execute("DELETE FROM ai_skill_retrieval_profiles WHERE id = %s", (profile_id,))
return {"deleted": True, "id": profile_id}

View File

@ -23,6 +23,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"admin_users.py",
"platform_media_storage.py",
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.153"
APP_VERSION = "0.8.154"
BUILD_DATE = "2026-05-29"
DB_SCHEMA_VERSION = "20260529068"
@ -18,6 +18,7 @@ MODULE_VERSIONS = {
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
"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)
"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
@ -37,6 +38,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.154",
"date": "2026-05-29",
"changes": [
"Superadmin-Web: KI Skill-Retrieval-Profile unter /admin/ai-skill-retrieval (Liste, JSON config, CRUD gegen /api/admin/ai-skill-retrieval-profiles*)",
"Backend: Router ai_skill_retrieval_admin registriert; ACCESS_HINTS EXEMPT dokumentiert",
],
},
{
"version": "0.8.153",
"date": "2026-05-29",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-29
**App-Version / DB-Schema:** App **`0.8.153`** (KI Skill-Retrieval-Profile), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
**App-Version / DB-Schema:** App **`0.8.154`** (KI Retrieval-Profil-Pflege im Admin), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
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**.
@ -88,13 +88,13 @@ 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.153**)
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.154**)
- **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 **`068`** Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert)
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff)
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik
- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik **Pflege:** Superadmin **`GET/POST/PUT/DELETE /api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort**Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`** (Nav „KI Retrieval“, nur Superadmin)
---

View File

@ -54,6 +54,7 @@ const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
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 SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@ -300,6 +301,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/ai-skill-retrieval',
element: (
<PlatformAdminRoute>
<AdminAiSkillRetrievalPage />
</PlatformAdminRoute>
),
},
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
],
},

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -12,6 +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 },
]
return (

View File

@ -0,0 +1,435 @@
import { useCallback, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
/** Startvorlage — alle Schlüssel optional; Fehlwerte nutzen Fallbacks in exercise_ai (siehe Spec). */
const DEFAULT_CONFIG_JSON = `{
"version": 1,
"importance_multiplier": 1,
"text_overlap_bonus": 2,
"main_slug_weights": { "karate": 1, "allgemeine": 1 },
"category_slug_weights": {},
"category_max_share": {},
"main_min_share": {},
"description_plain_max_len": 160,
"karate_relevance_max_len": 72,
"keyword_overrides": []
}`
function parseConfigObject(text) {
const t = text.trim()
if (!t) return {}
const parsed = JSON.parse(t)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Config muss ein JSON-Objekt sein.')
}
return parsed
}
function formatDt(iso) {
if (!iso) return ''
try {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
} catch {
return String(iso)
}
}
/**
* Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog Gewichte/Quoten).
* Nur Superadmin Routen-Spiegel: PlatformAdminRoute.
*/
export default function AdminAiSkillRetrievalPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [profiles, setProfiles] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null)
const loadAll = useCallback(async () => {
const [p, faRaw] = await Promise.all([
api.listAiSkillRetrievalProfiles(),
api.listFocusAreas(),
])
setProfiles(Array.isArray(p) ? p : [])
const fa = Array.isArray(faRaw) ? faRaw : []
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
}, [])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
await loadAll()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadAll])
const openNew = () => {
setEditor({
mode: 'new',
isDefault: false,
focusAreaId: '',
name: '',
description: '',
active: true,
configJson: DEFAULT_CONFIG_JSON,
})
}
const openEdit = (row) => {
setEditor({
mode: 'edit',
id: row.id,
originalIsDefault: !!row.is_default,
isDefault: !!row.is_default,
promoteToDefault: false,
focusAreaId: row.focus_area_id != null ? String(row.focus_area_id) : '',
name: row.name || '',
description: row.description || '',
active: !!row.active,
configJson: JSON.stringify(row.config && typeof row.config === 'object' ? row.config : {}, null, 2),
})
}
const closeEditor = () => setEditor(null)
const save = async () => {
if (!editor) return
let cfg
try {
cfg = parseConfigObject(editor.configJson)
} catch (e) {
alert(e.message || String(e))
return
}
const name = editor.name.trim()
if (name.length < 2) {
alert('Name mindestens 2 Zeichen.')
return
}
try {
if (editor.mode === 'new') {
const body = {
name,
description: (editor.description || '').trim(),
active: editor.active,
is_default: editor.isDefault,
config: cfg,
}
if (!editor.isDefault) {
const fid = parseInt(editor.focusAreaId, 10)
if (!fid) {
alert('Fokusbereich wählen (oder „Standardprofil“ aktivieren).')
return
}
body.focus_area_id = fid
}
await api.createAiSkillRetrievalProfile(body)
} else {
const body = {
name,
description: (editor.description || '').trim(),
active: editor.active,
config: cfg,
}
if (editor.originalIsDefault) {
await api.updateAiSkillRetrievalProfile(editor.id, body)
} else {
if (editor.promoteToDefault) {
body.is_default = true
} else {
const fid = parseInt(editor.focusAreaId, 10)
if (!fid) {
alert('Fokusbereich wählen oder „Als Standardprofil setzen“ aktivieren.')
return
}
body.focus_area_id = fid
}
await api.updateAiSkillRetrievalProfile(editor.id, body)
}
}
closeEditor()
await loadAll()
} catch (e) {
alert(e.message || String(e))
}
}
const remove = async (row) => {
if (row.is_default) return
if (!confirm(`Profil „${row.name}“ wirklich löschen?`)) return
try {
await api.deleteAiSkillRetrievalProfile(row.id)
await loadAll()
} catch (e) {
alert(e.message || String(e))
}
}
if (!isSuperadmin) return <Navigate to="/" replace />
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1rem' }}>
Konfiguration für den Skill-Katalog-Kontext bei <strong>Übungs-KI</strong> (OpenRouter).
Standardprofil ohne Fokusbereich; weitere Zeilen je <strong>aktivem</strong> Fokusbereich.
JSON-Feld <code>config</code> siehe{' '}
<code style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</code>.
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={openNew}>
Neues Profil
</button>
<button type="button" className="btn btn-secondary" onClick={() => loadAll().catch((e) => setError(e.message))}>
Aktualisieren
</button>
</div>
{error ? (
<div className="card" style={{ padding: '12px 16px', marginBottom: '1rem', borderColor: 'var(--danger)' }}>
<strong style={{ color: 'var(--danger)' }}>Fehler</strong>
<div style={{ marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>{error}</div>
</div>
) : null}
{loading ? (
<div className="spinner" style={{ margin: '2rem auto' }} />
) : (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.92rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '10px 12px' }}>Name</th>
<th style={{ padding: '10px 12px' }}>Typ</th>
<th style={{ padding: '10px 12px' }}>Fokusbereich</th>
<th style={{ padding: '10px 12px' }}>Aktiv</th>
<th style={{ padding: '10px 12px' }}>Geändert</th>
<th style={{ padding: '10px 12px', width: '1%' }} />
</tr>
</thead>
<tbody>
{profiles.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 12px', fontWeight: 600 }}>{row.name}</td>
<td style={{ padding: '10px 12px' }}>{row.is_default ? 'Standard' : 'Fokus'}</td>
<td style={{ padding: '10px 12px', color: 'var(--text2)' }}>
{row.is_default ? '—' : row.focus_area_name || `(ID ${row.focus_area_id})`}
</td>
<td style={{ padding: '10px 12px' }}>{row.active ? 'ja' : 'nein'}</td>
<td style={{ padding: '10px 12px', color: 'var(--text3)' }}>{formatDt(row.updated_at)}</td>
<td style={{ padding: '10px 12px', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-secondary" style={{ marginRight: 6 }} onClick={() => openEdit(row)}>
Bearbeiten
</button>
{!row.is_default ? (
<button type="button" className="btn btn-secondary" onClick={() => remove(row)}>
Löschen
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
{profiles.length === 0 ? (
<div style={{ padding: '1.25rem', color: 'var(--text3)' }}>Keine Einträge (oder Tabelle/Migration 068 fehlt).</div>
) : null}
</div>
)}
{editor && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
className="card"
style={{
maxWidth: '640px',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
padding: '1.25rem 1.5rem',
}}
>
<h2 style={{ marginTop: 0 }}>{editor.mode === 'new' ? 'Neues Profil' : 'Profil bearbeiten'}</h2>
<div className="form-row">
<label className="form-label" htmlFor="arp-name">
Name
</label>
<input
id="arp-name"
className="form-input"
value={editor.name}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, name: e.target.value } : ed))}
/>
</div>
<div className="form-row">
<label className="form-label" htmlFor="arp-desc">
Beschreibung
</label>
<textarea
id="arp-desc"
className="form-input"
rows={3}
value={editor.description}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, description: e.target.value } : ed))}
/>
</div>
{editor.mode === 'new' ? (
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editor.isDefault}
onChange={(e) =>
setEditor((ed) =>
ed ? { ...ed, isDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
)
}
/>
Standardprofil (ohne Fokusbereich)
</label>
</div>
) : null}
{editor.mode === 'edit' && editor.originalIsDefault ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Standardprofil: Fokusbereich ist immer leer; Deaktivieren ist nicht erlaubt.
</p>
) : null}
{editor.mode === 'edit' && !editor.originalIsDefault ? (
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!editor.promoteToDefault}
onChange={(e) =>
setEditor((ed) =>
ed ? { ...ed, promoteToDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
)
}
/>
Als Standardprofil setzen (andere Standard-Markierung wird entfernt)
</label>
</div>
) : null}
{!editor.isDefault && editor.mode === 'new' ? (
<div className="form-row">
<label className="form-label" htmlFor="arp-focus">
Fokusbereich
</label>
<select
id="arp-focus"
className="form-input"
value={editor.focusAreaId}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
>
<option value=""> wählen </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name || `Fokus ${fa.id}`}
</option>
))}
</select>
</div>
) : null}
{editor.mode === 'edit' && !editor.originalIsDefault && !editor.promoteToDefault ? (
<div className="form-row">
<label className="form-label" htmlFor="arp-focus-e">
Fokusbereich
</label>
<select
id="arp-focus-e"
className="form-input"
value={editor.focusAreaId}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
>
<option value=""> wählen </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name || `Fokus ${fa.id}`}
</option>
))}
</select>
</div>
) : null}
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editor.active}
disabled={editor.mode === 'edit' && editor.originalIsDefault}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, active: e.target.checked } : ed))}
/>
Aktiv
</label>
</div>
<div className="form-row">
<label className="form-label" htmlFor="arp-config">
Config (JSON)
</label>
<textarea
id="arp-config"
className="form-input"
style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.82rem', minHeight: '220px' }}
value={editor.configJson}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, configJson: e.target.value } : ed))}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" style={{ flex: '1 1 120px' }} onClick={save}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={closeEditor}>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -361,6 +361,33 @@ export async function getAdminHierarchy() {
return request('/api/admin/hierarchy')
}
// Superadmin: KI Skill-Retrieval-Profile (Migration 068, exercise_ai)
export async function listAiSkillRetrievalProfiles() {
return request('/api/admin/ai-skill-retrieval-profiles')
}
export async function getAiSkillRetrievalProfile(profileId) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`)
}
export async function createAiSkillRetrievalProfile(data) {
return request('/api/admin/ai-skill-retrieval-profiles', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateAiSkillRetrievalProfile(profileId, data) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteAiSkillRetrievalProfile(profileId) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
}
// ============================================================================
// Reifegradmodelle / Fähigkeitsmatrix
// ============================================================================
@ -764,6 +791,11 @@ export const api = {
updateFocusArea,
deleteFocusArea,
getAdminHierarchy,
listAiSkillRetrievalProfiles,
getAiSkillRetrievalProfile,
createAiSkillRetrievalProfile,
updateAiSkillRetrievalProfile,
deleteAiSkillRetrievalProfile,
listStyleDirections,
listTrainingStyles,
createStyleDirection,