Document Superadmin API for AI Skill Retrieval Profiles and Update Access Layer
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 43s
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) Has been cancelled
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 43s
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) Has been cancelled
- Added documentation for the new Superadmin CRUD endpoints for managing AI Skill Retrieval Profiles (`/api/admin/ai-skill-retrieval-profiles*`). - Updated the ACCESS_LAYER_ENDPOINT_AUDIT.md to include the new Superadmin API and its exempt status. - Registered the ai_skill_retrieval_admin router in the backend and updated versioning to reflect the changes. - Enhanced the frontend with a new Admin page for AI Skill Retrieval, including navigation and API integration for profile management.
This commit is contained in:
parent
294b09a5d9
commit
286c36e9d7
|
|
@ -34,17 +34,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
||||||
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||||
|
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||||
|
|
||||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||||
|
|
||||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||||
|
|
||||||
Letzte Änderung: 2026-05-29 — 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)
|
### 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-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
||||||
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
`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
|
## 6. Changelog
|
||||||
|
|
||||||
|
- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`).
|
||||||
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
|
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
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(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -220,6 +220,7 @@ app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.router)
|
app.include_router(import_wiki_admin.router)
|
||||||
app.include_router(legal_documents.router)
|
app.include_router(legal_documents.router)
|
||||||
app.include_router(content_reports.router)
|
app.include_router(content_reports.router)
|
||||||
|
app.include_router(ai_skill_retrieval_admin.router)
|
||||||
|
|
||||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||||
|
|
|
||||||
370
backend/routers/ai_skill_retrieval_admin.py
Normal file
370
backend/routers/ai_skill_retrieval_admin.py
Normal 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}
|
||||||
|
|
@ -23,6 +23,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
||||||
"admin_users.py",
|
"admin_users.py",
|
||||||
"platform_media_storage.py",
|
"platform_media_storage.py",
|
||||||
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
||||||
|
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"catalogs.py",
|
"catalogs.py",
|
||||||
"skills.py",
|
"skills.py",
|
||||||
"maturity_models.py",
|
"maturity_models.py",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.153"
|
APP_VERSION = "0.8.154"
|
||||||
BUILD_DATE = "2026-05-29"
|
BUILD_DATE = "2026-05-29"
|
||||||
DB_SCHEMA_VERSION = "20260529068"
|
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_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_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||||
|
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
|
|
@ -37,6 +38,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.153",
|
||||||
"date": "2026-05-29",
|
"date": "2026-05-29",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-29
|
**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**.
|
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`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.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
|
- **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)
|
- **DB:** Migration **`068`** – Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert)
|
||||||
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
- **`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`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik
|
- **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
|
- **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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
||||||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||||
|
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||||
|
|
||||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||||
|
|
@ -300,6 +301,14 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/ai-skill-retrieval',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminAiSkillRetrievalPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } 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).
|
* 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/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||||
|
{ to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
435
frontend/src/pages/AdminAiSkillRetrievalPage.jsx
Normal file
435
frontend/src/pages/AdminAiSkillRetrievalPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -361,6 +361,33 @@ export async function getAdminHierarchy() {
|
||||||
return request('/api/admin/hierarchy')
|
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
|
// Reifegradmodelle / Fähigkeitsmatrix
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -764,6 +791,11 @@ export const api = {
|
||||||
updateFocusArea,
|
updateFocusArea,
|
||||||
deleteFocusArea,
|
deleteFocusArea,
|
||||||
getAdminHierarchy,
|
getAdminHierarchy,
|
||||||
|
listAiSkillRetrievalProfiles,
|
||||||
|
getAiSkillRetrievalProfile,
|
||||||
|
createAiSkillRetrievalProfile,
|
||||||
|
updateAiSkillRetrievalProfile,
|
||||||
|
deleteAiSkillRetrievalProfile,
|
||||||
listStyleDirections,
|
listStyleDirections,
|
||||||
listTrainingStyles,
|
listTrainingStyles,
|
||||||
createStyleDirection,
|
createStyleDirection,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user