Add Exercise Enrichment Admin API and Update Documentation
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m17s

- Introduced the `exercise_enrichment_admin` API for batch exercise enrichment, allowing superadmins to filter candidates, preview, and apply skills.
- Updated the access layer documentation to include the new endpoint and its exempt status.
- Enhanced the frontend with a new admin page for exercise enrichment and updated navigation to include this feature.
- Incremented version to 0.8.179 and updated changelog to reflect these additions and improvements.
This commit is contained in:
Lars 2026-05-23 07:35:45 +02:00
parent d1d8539b42
commit f4196c3580
12 changed files with 2263 additions and 5 deletions

View File

@ -37,17 +37,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 |
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
**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-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile.
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
---
### Changelog (Fortführung)
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
- **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.

View File

@ -0,0 +1,66 @@
# Superadmin: Übungs-Anreicherung per KI
Stand: 2026-05-23 · App 0.8.178
## Zweck
Plattform-weites Werkzeug für Superadmins, um Übungen (typisch `draft`, ohne Skills) **batchweise** per KI mit Fähigkeiten anzureichern und kontrolliert auf `in_review` zu setzen.
Verbessert indirekt die Planungs-KI (`POST /api/planning/exercise-suggest`), die gegen Skill-Profile rankt — unvollständige `exercise_skills` führen dort zu Volltext-dominiertem Ranking.
## UI
- Route: `/admin/exercise-enrichment` (nur Superadmin)
- Admin-Menü: „Übungs-Anreicherung“
## API
Prefix: `/api/admin/exercise-enrichment`
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/candidates` | Paginierte Kandidaten (Filter: status, visibility, focus_area, without_skills, with_ai_suggested_skills, include_club, search) |
| POST | `/preview` | Dry-Run — `{ exercise_ids[], modes: { skills, summary }, merge_mode }` |
| POST | `/apply` | `{ items: [{ exercise_id, merged_skills }], merge_mode, set_status }` |
Auth: `require_auth` + `is_superadmin`**kein** `TenantContext` (EXEMPT, siehe ACCESS_LAYER_ENDPOINT_AUDIT.md).
## KI
Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_suggestions` (MVP Pflicht), optional `exercise_summary`. Skill-Katalog via `build_contextual_skills_catalog_block` / `ai_skill_retrieval_profiles`.
## Merge-Modi (Skills)
- `additive` (Default): manuelle Skills bleiben; KI ergänzt neue; bestehende `ai_suggested`-Links werden aktualisiert
- `replace_ai_only`: nur `ai_suggested=true` entfernen, dann KI-Set anwenden
- `replace_all`: alle Skills ersetzen (explizit)
## Defaults
- Kandidaten: **Status** primär (Default `draft`); Sichtbarkeit Default **`private`**, wählbar bis „Alle“
- Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell)
- Nach Apply: `set_status=in_review` (nie automatisch `approved`)
- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung; HTTP-Chunks à 25/100
## Inhalte (modular)
| Modus | Prompt | Apply-Felder |
|-------|--------|--------------|
| Skills | `exercise_skill_suggestions` | `exercise_skills` inkl. Intensität, required/target_level, `ai_suggested` |
| Summary | `exercise_summary` | `summary`, `summary_ai_generated=true` |
| Anleitung | `exercise_instruction_rewrite` | `goal`, `execution`, `preparation`, `trainer_notes` |
## API (ergänzt)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/candidate-ids` | Alle IDs zum Filter (Select-all) |
| POST | `/analyze` | `{ exercise_ids[], modes }` → Kosten-Schätzung vor Start |
## Keine Migration
Bestehende Spalte `exercise_skills.ai_suggested` reicht; kein Enrichment-Log in MVP.
## Tests
`backend/tests/test_exercise_enrichment_admin.py` — 403, Merge-Logik, Status draft→in_review.

View File

@ -0,0 +1,534 @@
"""
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai keine neue OpenRouter-Pipeline.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from exercise_rich_text import normalize_inline_exercise_media_markup
from routers.exercises import (
enrich_exercise_detail,
normalize_exercise_skill_intensity,
normalize_exercise_skill_level,
)
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
DEFAULT_SET_STATUS = "in_review"
# Max. IDs pro einzelner HTTP-Anfrage (Frontend chunked darüber hinaus).
MAX_BATCH_EXERCISES = 100
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
rows: list[tuple[int, bool]] = []
for row in exercise.get("focus_areas") or []:
if not isinstance(row, dict):
continue
try:
fid = int(row.get("focus_area_id"))
except (TypeError, ValueError):
continue
if fid < 1:
continue
rows.append((fid, bool(row.get("is_primary"))))
rows.sort(key=lambda x: (not x[1], x[0]))
return rows
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
parts: List[str] = []
for row in exercise.get("focus_areas") or []:
if isinstance(row, dict):
nm = (row.get("name") or "").strip()
if nm:
parts.append(nm)
txt = ", ".join(parts).strip()
if len(txt) > 900:
return txt[:899] + ""
return txt
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise)
return ExerciseFormAiPromptContext.from_focus_tuples(
title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"),
execution=exercise.get("execution"),
preparation=exercise.get("preparation"),
trainer_notes=exercise.get("trainer_notes"),
focus_hint=focus or None,
focus_tuples=fctx or None,
)
def validate_exercise_for_enrichment(
exercise: Dict[str, Any],
*,
want_skills: bool = False,
want_summary: bool = False,
want_instructions: bool = False,
) -> Optional[str]:
title = str(exercise.get("title") or "").strip()
if not title:
return "Titel fehlt"
ctx = build_form_context_from_exercise(exercise)
g_plain = strip_html_to_plain(exercise.get("goal"))
e_plain = strip_html_to_plain(exercise.get("execution"))
if want_skills or want_summary:
if not (g_plain.strip() or e_plain.strip()):
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
if want_instructions and not ctx.has_instruction_source_text():
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
if not (want_skills or want_summary or want_instructions):
return "Kein Anreicherungsmodus aktiv"
return None
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
return {
"skill_id": int(raw["skill_id"]),
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
"skill_category": raw.get("skill_category"),
"is_primary": bool(raw.get("is_primary")),
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
"ai_suggested": ai_suggested,
}
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
for k in _SKILL_COMPARE_KEYS:
av = a.get(k)
bv = b.get(k)
if k in ("required_level", "target_level"):
av = normalize_exercise_skill_level(av)
bv = normalize_exercise_skill_level(bv)
elif k == "intensity":
av = normalize_exercise_skill_intensity(av)
bv = normalize_exercise_skill_intensity(bv)
elif k == "is_primary":
av = bool(av)
bv = bool(bv)
if av != bv:
return True
return False
def merge_skills(
existing: List[Dict[str, Any]],
suggested: List[Dict[str, Any]],
mode: SkillMergeMode,
) -> List[Dict[str, Any]]:
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
if mode == "replace_all":
return list(suggested_norm)
if mode == "replace_ai_only":
manual = [s for s in existing_norm if not s.get("ai_suggested")]
manual_ids = {int(s["skill_id"]) for s in manual}
result = list(manual)
for s in suggested_norm:
sid = int(s["skill_id"])
if sid in manual_ids:
continue
result.append(s)
return result
# additive
result: List[Dict[str, Any]] = []
seen: set[int] = set()
for s in existing_norm:
sid = int(s["skill_id"])
seen.add(sid)
if sid in suggested_by_id and s.get("ai_suggested"):
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
result.append(merged)
else:
result.append(dict(s))
for s in suggested_norm:
sid = int(s["skill_id"])
if sid not in seen:
result.append(s)
seen.add(sid)
return result
def compute_skill_diff(
before: List[Dict[str, Any]],
after: List[Dict[str, Any]],
) -> Dict[str, Any]:
before_ids = {int(s["skill_id"]): s for s in before}
after_ids = {int(s["skill_id"]): s for s in after}
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
changed: List[Dict[str, Any]] = []
for sid in before_ids:
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
changed.append(
{
"skill_id": sid,
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
"before": before_ids[sid],
"after": after_ids[sid],
}
)
kept = [
before_ids[i]
for i in sorted(before_ids)
if i in after_ids and i not in {c["skill_id"] for c in changed}
]
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
rows = payload.get("skills")
if not isinstance(rows, list):
return []
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
block = payload.get("summary")
if isinstance(block, dict):
text = (block.get("text") or "").strip()
return text or None
if isinstance(block, str) and block.strip():
return block.strip()
return None
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
block = payload.get("instructions")
if not isinstance(block, dict):
return {}
fields = block.get("fields")
if not isinstance(fields, dict):
return {}
out: Dict[str, str] = {}
for key in _INSTRUCTION_FIELDS:
val = fields.get(key)
if val is not None and str(val).strip():
out[key] = str(val).strip()
return out
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
out: Dict[str, str] = {}
for key in _INSTRUCTION_FIELDS:
raw = exercise.get(key)
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
if plain.strip():
out[key] = plain.strip()
return out
def compute_instruction_diff(
before: Dict[str, str],
after: Dict[str, str],
) -> Dict[str, Any]:
changed: List[Dict[str, Any]] = []
added: List[str] = []
for key in _INSTRUCTION_FIELDS:
b = (before.get(key) or "").strip()
a = (after.get(key) or "").strip()
if not a:
continue
if not b:
added.append(key)
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
changed.append({"field": key, "before_plain": b, "after_html": a})
return {"changed_fields": changed, "added_fields": added}
def preview_exercise_enrichment(
cur,
exercise_id: int,
*,
want_skills: bool = True,
want_summary: bool = False,
want_instructions: bool = False,
merge_mode: SkillMergeMode = "additive",
) -> Dict[str, Any]:
exercise = enrich_exercise_detail(exercise_id, cur)
if not exercise:
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
skip_reason = validate_exercise_for_enrichment(
exercise,
want_skills=want_skills,
want_summary=want_summary,
want_instructions=want_instructions,
)
if skip_reason:
return {
"exercise_id": exercise_id,
"ok": False,
"skipped": True,
"error": skip_reason,
"title": exercise.get("title"),
"status": exercise.get("status"),
}
existing = exercise.get("skills") or []
suggested: List[Dict[str, Any]] = []
ai_meta: Dict[str, Any] = {}
payload: Dict[str, Any] = {}
suggested_summary: Optional[str] = None
suggested_instructions: Dict[str, str] = {}
if want_skills or want_summary or want_instructions:
ctx = build_form_context_from_exercise(exercise)
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=want_summary,
want_skills=want_skills,
want_instructions=want_instructions,
)
if want_skills:
suggested = _skills_from_ai_payload(payload)
if want_summary:
suggested_summary = _summary_from_ai_payload(payload)
if want_instructions:
suggested_instructions = _instructions_from_ai_payload(payload)
ai_meta = {
"models": payload.get("models_by_slug") or {},
"llm_calls": sum([want_skills, want_summary, want_instructions]),
}
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
diff = compute_skill_diff(existing, merged) if want_skills else None
existing_summary = (exercise.get("summary") or "").strip() or None
instr_before = _instruction_snapshot(exercise)
instr_after_plain = {
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
}
instruction_diff = (
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
)
return {
"exercise_id": exercise_id,
"ok": True,
"title": exercise.get("title"),
"status": exercise.get("status"),
"visibility": exercise.get("visibility"),
"primary_focus_name": _primary_focus_from_exercise(exercise),
"existing_skills": existing,
"suggested_skills": suggested,
"merged_skills": merged,
"diff": diff,
"existing_summary": existing_summary,
"suggested_summary": suggested_summary,
"existing_instructions": instr_before,
"suggested_instructions": suggested_instructions,
"instruction_diff": instruction_diff,
"ai_meta": ai_meta,
}
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
for row in exercise.get("focus_areas") or []:
if isinstance(row, dict) and row.get("is_primary"):
return (row.get("name") or "").strip() or None
for row in exercise.get("focus_areas") or []:
if isinstance(row, dict):
nm = (row.get("name") or "").strip()
if nm:
return nm
return None
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
if merge_mode == "replace_all":
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
elif merge_mode == "replace_ai_only":
cur.execute(
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
(exercise_id,),
)
for sk in merged:
cur.execute(
"""
INSERT INTO exercise_skills
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
intensity = CASE
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
required_level = CASE
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
target_level = CASE
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
is_primary = CASE
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
ai_suggested = CASE
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
""",
(
exercise_id,
int(sk["skill_id"]),
bool(sk.get("is_primary")),
normalize_exercise_skill_intensity(sk.get("intensity")),
normalize_exercise_skill_level(sk.get("required_level")),
normalize_exercise_skill_level(sk.get("target_level")),
bool(sk.get("ai_suggested")),
merge_mode,
merge_mode,
merge_mode,
merge_mode,
merge_mode,
),
)
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
if not fields:
return {}
out: Dict[str, str] = {}
for key in _INSTRUCTION_FIELDS:
if key not in fields:
continue
raw = fields.get(key)
if raw is None or not str(raw).strip():
continue
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
return out
def apply_exercise_enrichment(
cur,
exercise_id: int,
*,
merged_skills: Optional[List[Dict[str, Any]]] = None,
merge_mode: SkillMergeMode = "additive",
set_status: Optional[str] = DEFAULT_SET_STATUS,
apply_skills: bool = False,
summary_text: Optional[str] = None,
apply_summary: bool = False,
instruction_fields: Optional[Dict[str, Any]] = None,
apply_instructions: bool = False,
) -> Dict[str, Any]:
exercise = enrich_exercise_detail(exercise_id, cur)
if not exercise:
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
skip_reason = validate_exercise_for_enrichment(
exercise,
want_skills=apply_skills,
want_summary=apply_summary,
want_instructions=apply_instructions,
)
if skip_reason:
return {
"exercise_id": exercise_id,
"ok": False,
"skipped": True,
"error": skip_reason,
}
skills_list = merged_skills or []
if apply_skills:
if not skills_list and merge_mode != "replace_all":
return {
"exercise_id": exercise_id,
"ok": False,
"error": "Keine Skills zum Anwenden",
}
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
sets: List[str] = []
vals: List[Any] = []
if apply_summary and summary_text is not None:
text = str(summary_text).strip()
if text:
sets.extend(["summary = %s", "summary_ai_generated = true"])
vals.append(text[:220])
if apply_instructions:
norm = _normalize_instruction_fields(instruction_fields)
for key, val in norm.items():
sets.append(f"{key} = %s")
vals.append(val)
new_status = (set_status or "").strip().lower() or None
if new_status:
if new_status == "approved":
return {
"exercise_id": exercise_id,
"ok": False,
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
}
if new_status not in ("draft", "in_review", "archived"):
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
sets.append("status = %s")
vals.append(new_status)
if sets:
sets.append("updated_at = NOW()")
vals.append(exercise_id)
cur.execute(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
elif not apply_skills:
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
return {
"exercise_id": exercise_id,
"ok": True,
"status": new_status or exercise.get("status"),
"skills_applied": len(skills_list) if apply_skills else 0,
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
}
def estimate_llm_calls(
*,
exercise_count: int,
want_skills: bool,
want_summary: bool,
want_instructions: bool = False,
) -> Dict[str, Any]:
per_skills = exercise_count if want_skills else 0
per_summary = exercise_count if want_summary else 0
per_instructions = exercise_count if want_instructions else 0
total = per_skills + per_summary + per_instructions
return {
"total": total,
"per_exercise": sum([want_skills, want_summary, want_instructions]),
"skills": per_skills,
"summary": per_summary,
"instructions": per_instructions,
}

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, planning_exercise_suggest, 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
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, planning_exercise_suggest, 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, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -224,6 +224,7 @@ 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)
app.include_router(exercise_enrichment_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,415 @@
"""
Superadmin API: Batch-Anreicherung von Übungen per KI (Skills, Kurzfassung, Anleitung).
# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_cursor, get_db, r2d
from exercise_enrichment import (
DEFAULT_SET_STATUS,
MAX_BATCH_EXERCISES,
SKILL_MERGE_MODES,
SkillMergeMode,
apply_exercise_enrichment,
estimate_llm_calls,
preview_exercise_enrichment,
)
router = APIRouter(tags=["admin_exercise_enrichment"])
_VALID_STATUS_FILTER = frozenset({"draft", "in_review", "approved", "archived"})
_VALID_VISIBILITY = frozenset({"private", "club", "official", "all"})
_MAX_CANDIDATES_LIMIT = 100
_MAX_ANALYZE_IDS = 10_000
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 _normalize_id_list(raw: Optional[List[int]], *, max_items: Optional[int] = None) -> List[int]:
if not raw:
return []
seen: set[int] = set()
out: List[int] = []
for x in raw:
try:
xi = int(x)
except (TypeError, ValueError):
continue
if xi < 1 or xi in seen:
continue
seen.add(xi)
out.append(xi)
if max_items is not None and len(out) >= max_items:
break
return out
def _build_candidates_where(
*,
status: str,
visibility: Optional[str],
focus_area_id: Optional[int],
without_skills: bool,
with_ai_suggested_skills: bool,
) -> tuple[list[str], list[Any]]:
st = (status or "draft").strip().lower()
if st not in _VALID_STATUS_FILTER:
raise HTTPException(status_code=400, detail="Ungültiger Status-Filter")
where: List[str] = ["e.status = %s"]
params: List[Any] = [st]
vis_raw = (visibility or "private").strip().lower()
if vis_raw and vis_raw != "all":
if vis_raw not in _VALID_VISIBILITY - {"all"}:
raise HTTPException(status_code=400, detail="Ungültiger Visibility-Filter")
where.append("e.visibility = %s")
params.append(vis_raw)
if focus_area_id is not None:
where.append(
"EXISTS (SELECT 1 FROM exercise_focus_areas efa "
"WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
)
params.append(int(focus_area_id))
if without_skills:
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id)"
)
if with_ai_suggested_skills:
where.append(
"EXISTS (SELECT 1 FROM exercise_skills es "
"WHERE es.exercise_id = e.id AND es.ai_suggested = true)"
)
return where, params
class EnrichmentModes(BaseModel):
skills: bool = True
summary: bool = False
instructions: bool = False
class EnrichmentPreviewBody(BaseModel):
exercise_ids: List[int] = Field(..., min_length=1, max_length=MAX_BATCH_EXERCISES)
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
merge_mode: SkillMergeMode = "replace_all"
@field_validator("merge_mode")
@classmethod
def _merge_mode_ok(cls, v: str) -> str:
if v not in SKILL_MERGE_MODES:
raise ValueError("merge_mode: additive, replace_ai_only oder replace_all")
return v
class EnrichmentAnalyzeBody(BaseModel):
exercise_ids: List[int] = Field(..., min_length=1, max_length=_MAX_ANALYZE_IDS)
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
class EnrichmentApplyItem(BaseModel):
exercise_id: int = Field(..., ge=1)
merged_skills: List[Dict[str, Any]] = Field(default_factory=list)
summary: Optional[str] = None
instruction_fields: Optional[Dict[str, Any]] = None
class EnrichmentApplyBody(BaseModel):
items: List[EnrichmentApplyItem] = Field(..., min_length=1, max_length=MAX_BATCH_EXERCISES)
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
merge_mode: SkillMergeMode = "replace_all"
set_status: Optional[str] = DEFAULT_SET_STATUS
@field_validator("merge_mode")
@classmethod
def _merge_mode_ok(cls, v: str) -> str:
if v not in SKILL_MERGE_MODES:
raise ValueError("merge_mode: additive, replace_ai_only oder replace_all")
return v
@field_validator("set_status")
@classmethod
def _status_ok(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
s = str(v).strip().lower()
if s == "approved":
raise ValueError("Automatisches Freigeben (approved) ist nicht erlaubt")
if s not in _VALID_STATUS_FILTER:
raise ValueError("Ungültiger Ziel-Status")
return s
@router.get("/api/admin/exercise-enrichment/candidates")
def list_enrichment_candidates(
session: dict = Depends(require_auth),
status: str = Query(default="draft"),
visibility: Optional[str] = Query(default="private"),
focus_area_id: Optional[int] = Query(default=None, ge=1),
without_skills: bool = Query(default=False),
with_ai_suggested_skills: bool = Query(default=False),
search: Optional[str] = Query(default=None, max_length=200),
limit: int = Query(default=25, ge=1, le=_MAX_CANDIDATES_LIMIT),
offset: int = Query(default=0, ge=0),
):
"""Paginierte Kandidatenliste für Superadmin-Anreicherung."""
_require_superadmin(session)
where, params = _build_candidates_where(
status=status,
visibility=visibility,
focus_area_id=focus_area_id,
without_skills=without_skills,
with_ai_suggested_skills=with_ai_suggested_skills,
)
qtext = (search or "").strip()
if qtext:
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext)
where_sql = " AND ".join(where)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(f"SELECT COUNT(*) AS c FROM exercises e WHERE {where_sql}", tuple(params))
count_row = cur.fetchone()
total = int(count_row["c"] if isinstance(count_row, dict) else count_row[0])
cur.execute(
f"""
SELECT e.id, e.title, e.status, e.visibility, e.summary, e.updated_at,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
(
SELECT COUNT(*)::int FROM exercise_skills es WHERE es.exercise_id = e.id
) AS skill_count,
(
SELECT COUNT(*)::int FROM exercise_skills es
WHERE es.exercise_id = e.id AND es.ai_suggested = true
) AS ai_suggested_skill_count
FROM exercises e
WHERE {where_sql}
ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s OFFSET %s
""",
tuple(params + [limit, offset]),
)
rows = [r2d(r) for r in cur.fetchall()]
return {
"items": rows,
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/api/admin/exercise-enrichment/candidate-ids")
def list_enrichment_candidate_ids(
session: dict = Depends(require_auth),
status: str = Query(default="draft"),
visibility: Optional[str] = Query(default="private"),
focus_area_id: Optional[int] = Query(default=None, ge=1),
without_skills: bool = Query(default=False),
with_ai_suggested_skills: bool = Query(default=False),
search: Optional[str] = Query(default=None, max_length=200),
):
"""Alle IDs zum aktuellen Filter (für „Alle auswählen“) — ohne Pagination-Obergrenze."""
_require_superadmin(session)
where, params = _build_candidates_where(
status=status,
visibility=visibility,
focus_area_id=focus_area_id,
without_skills=without_skills,
with_ai_suggested_skills=with_ai_suggested_skills,
)
qtext = (search or "").strip()
if qtext:
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext)
where_sql = " AND ".join(where)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"SELECT e.id FROM exercises e WHERE {where_sql} ORDER BY e.updated_at DESC, e.id DESC",
tuple(params),
)
ids = [int(r["id"] if isinstance(r, dict) else r[0]) for r in cur.fetchall()]
return {"ids": ids, "total": len(ids)}
@router.post("/api/admin/exercise-enrichment/analyze")
def analyze_enrichment(body: EnrichmentAnalyzeBody, session: dict = Depends(require_auth)):
"""Kosten-/Umfangsanalyse vor dem Batch-Lauf (explizite Nutzerbestätigung)."""
_require_superadmin(session)
ids = _normalize_id_list(body.exercise_ids, max_items=_MAX_ANALYZE_IDS)
if not ids:
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
if not body.modes.skills and not body.modes.summary and not body.modes.instructions:
raise HTTPException(
status_code=400,
detail="Mindestens ein Modus (skills, summary oder instructions) aktivieren",
)
est = estimate_llm_calls(
exercise_count=len(ids),
want_skills=body.modes.skills,
want_summary=body.modes.summary,
want_instructions=body.modes.instructions,
)
return {
"exercise_count": len(ids),
"estimated_llm_calls": est,
"modes": body.modes.model_dump(),
"warning": (
f"Batch mit {len(ids)} Übungen und ca. {est['total']} LLM-Aufrufen — Kosten beachten."
if len(ids) >= 25 or est["total"] >= 50
else None
),
}
@router.post("/api/admin/exercise-enrichment/preview")
def preview_enrichment(body: EnrichmentPreviewBody, session: dict = Depends(require_auth)):
"""Dry-Run: KI-Vorschläge laden, nichts speichern."""
_require_superadmin(session)
ids = _normalize_id_list(body.exercise_ids, max_items=MAX_BATCH_EXERCISES)
if not ids:
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
modes = body.modes
if not modes.skills and not modes.summary and not modes.instructions:
raise HTTPException(
status_code=400,
detail="Mindestens ein Modus (skills, summary oder instructions) aktivieren",
)
results: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
with get_db() as conn:
cur = get_cursor(conn)
for ex_id in ids:
try:
row = preview_exercise_enrichment(
cur,
ex_id,
want_skills=modes.skills,
want_summary=modes.summary,
want_instructions=modes.instructions,
merge_mode=body.merge_mode,
)
if row.get("ok"):
results.append(row)
else:
errors.append(row)
except HTTPException as he:
d = he.detail
errors.append({"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)})
except Exception as exc: # pragma: no cover
errors.append({"exercise_id": ex_id, "ok": False, "error": str(exc)})
est = estimate_llm_calls(
exercise_count=len(ids),
want_skills=modes.skills,
want_summary=modes.summary,
want_instructions=modes.instructions,
)
return {
"results": results,
"errors": errors,
"processed": len(ids),
"ok_count": len(results),
"error_count": len(errors),
"estimated_llm_calls": est,
"merge_mode": body.merge_mode,
"modes": modes.model_dump(),
}
@router.post("/api/admin/exercise-enrichment/apply")
def apply_enrichment(body: EnrichmentApplyBody, session: dict = Depends(require_auth)):
"""Vorschläge anwenden und optional Status setzen (Default: in_review)."""
_require_superadmin(session)
if not body.items:
raise HTTPException(status_code=400, detail="Keine Items")
modes = body.modes
if not modes.skills and not modes.summary and not modes.instructions:
raise HTTPException(status_code=400, detail="Kein Anwendungsmodus aktiv")
applied: List[Dict[str, Any]] = []
failed: List[Dict[str, Any]] = []
with get_db() as conn:
cur = get_cursor(conn)
for item in body.items:
ex_id = int(item.exercise_id)
try:
row = apply_exercise_enrichment(
cur,
ex_id,
merged_skills=item.merged_skills,
merge_mode=body.merge_mode,
set_status=body.set_status,
apply_skills=modes.skills,
summary_text=item.summary,
apply_summary=modes.summary,
instruction_fields=item.instruction_fields,
apply_instructions=modes.instructions,
)
if row.get("ok"):
applied.append(row)
else:
failed.append(row)
except HTTPException as he:
d = he.detail
failed.append({"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)})
except Exception as exc: # pragma: no cover
failed.append({"exercise_id": ex_id, "ok": False, "error": str(exc)})
conn.commit()
return {
"applied": applied,
"failed": failed,
"applied_count": len(applied),
"failed_count": len(failed),
"set_status": body.set_status,
"merge_mode": body.merge_mode,
"modes": modes.model_dump(),
}

View File

@ -25,6 +25,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"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
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -0,0 +1,282 @@
"""Superadmin Übungs-Anreicherung — Auth, Merge-Logik, Status, Analyze."""
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 exercise_enrichment import (
apply_exercise_enrichment,
compute_skill_diff,
estimate_llm_calls,
merge_skills,
persist_merged_skills,
validate_exercise_for_enrichment,
)
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_candidates_requires_superadmin(client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
r = client.get("/api/admin/exercise-enrichment/candidates", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_preview_requires_superadmin(client: TestClient) -> None:
def _trainer():
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _trainer
r = client.post(
"/api/admin/exercise-enrichment/preview",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"exercise_ids": [1], "modes": {"skills": True}},
)
assert r.status_code == 403
@patch("routers.exercise_enrichment_admin.get_db")
def test_candidates_ok_for_superadmin(mock_get_db, client: TestClient) -> None:
def _super():
return {"profile_id": 1, "role": "superadmin"}
app.dependency_overrides[require_auth] = _super
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [{"c": 2}, None]
mock_cur.fetchall.return_value = [
{
"id": 10,
"title": "Kata Basics",
"status": "draft",
"visibility": "private",
"summary": "",
"updated_at": "2026-05-23T10:00:00",
"primary_focus_name": "Karate",
"skill_count": 0,
"ai_suggested_skill_count": 0,
}
]
mock_get_db.return_value = mock_cm
with patch("routers.exercise_enrichment_admin.get_cursor", return_value=mock_cur):
r = client.get(
"/api/admin/exercise-enrichment/candidates?status=draft&without_skills=true",
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert len(body["items"]) == 1
assert body["items"][0]["id"] == 10
def test_analyze_returns_llm_estimate(client: TestClient) -> None:
def _super():
return {"profile_id": 1, "role": "superadmin"}
app.dependency_overrides[require_auth] = _super
r = client.post(
"/api/admin/exercise-enrichment/analyze",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"exercise_ids": [1, 2, 3],
"modes": {"skills": True, "summary": True, "instructions": False},
},
)
assert r.status_code == 200
body = r.json()
assert body["exercise_count"] == 3
est = body["estimated_llm_calls"]
assert est["total"] == 6
assert est["skills"] == 3
assert est["summary"] == 3
def test_validate_exercise_requires_title_and_content() -> None:
assert (
validate_exercise_for_enrichment({"title": "", "goal": "<p>x</p>"}, want_skills=True)
== "Titel fehlt"
)
assert (
validate_exercise_for_enrichment({"title": "Foo", "goal": "", "execution": ""}, want_skills=True)
== "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
)
assert validate_exercise_for_enrichment({"title": "Foo", "goal": "<p>Ziel</p>"}, want_skills=True) is None
def test_merge_skills_additive_keeps_manual() -> None:
existing = [
{
"skill_id": 1,
"skill_name": "Manual",
"intensity": "hoch",
"required_level": "aufbau",
"target_level": "fortgeschritten",
"is_primary": True,
"ai_suggested": False,
}
]
suggested = [
{
"skill_id": 1,
"skill_name": "Manual AI",
"intensity": "niedrig",
"required_level": "basis",
"target_level": "grundlagen",
"is_primary": False,
},
{
"skill_id": 2,
"skill_name": "New AI",
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": False,
},
]
merged = merge_skills(existing, suggested, "additive")
assert len(merged) == 2
manual = next(s for s in merged if s["skill_id"] == 1)
assert manual["intensity"] == "hoch"
assert manual["ai_suggested"] is False
ai_new = next(s for s in merged if s["skill_id"] == 2)
assert ai_new["ai_suggested"] is True
assert ai_new["intensity"] == "mittel"
def test_merge_skills_replace_all_marks_ai() -> None:
existing = [
{"skill_id": 1, "skill_name": "M", "intensity": "hoch", "ai_suggested": False},
]
suggested = [
{"skill_id": 3, "skill_name": "New AI", "intensity": "niedrig", "required_level": "basis", "target_level": "aufbau"},
]
merged = merge_skills(existing, suggested, "replace_all")
assert len(merged) == 1
assert merged[0]["skill_id"] == 3
assert merged[0]["ai_suggested"] is True
assert merged[0]["intensity"] == "niedrig"
def test_compute_skill_diff_added_and_removed() -> None:
before = [{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False}]
after = [
{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False},
{"skill_id": 2, "skill_name": "B", "intensity": "hoch", "ai_suggested": True},
]
diff = compute_skill_diff(before, after)
assert len(diff["added"]) == 1
assert diff["added"][0]["skill_id"] == 2
@patch("exercise_enrichment.enrich_exercise_detail")
def test_apply_sets_in_review(mock_enrich) -> None:
mock_enrich.return_value = {
"id": 5,
"title": "Test",
"goal": "<p>G</p>",
"execution": "",
"status": "draft",
"skills": [],
}
mock_cur = MagicMock()
row = apply_exercise_enrichment(
mock_cur,
5,
merged_skills=[
{
"skill_id": 9,
"skill_name": "Kick",
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": True,
"ai_suggested": True,
}
],
merge_mode="replace_all",
set_status="in_review",
apply_skills=True,
)
assert row["ok"] is True
assert row["status"] == "in_review"
assert mock_cur.execute.call_count >= 2
def test_apply_rejects_approved_status() -> None:
mock_cur = MagicMock()
with patch(
"exercise_enrichment.enrich_exercise_detail",
return_value={
"title": "T",
"goal": "<p>G</p>",
"status": "draft",
"skills": [],
},
):
row = apply_exercise_enrichment(
mock_cur,
1,
merged_skills=[{"skill_id": 1, "intensity": "mittel", "ai_suggested": True}],
set_status="approved",
apply_skills=True,
)
assert row["ok"] is False
assert "approved" in row["error"]
def test_persist_merged_skills_uses_upsert() -> None:
mock_cur = MagicMock()
persist_merged_skills(
mock_cur,
7,
[
{
"skill_id": 3,
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": False,
"ai_suggested": True,
}
],
"replace_all",
)
sql = mock_cur.execute.call_args_list[-1][0][0]
assert "INSERT INTO exercise_skills" in sql
def test_estimate_llm_calls_breakdown() -> None:
est = estimate_llm_calls(
exercise_count=100,
want_skills=True,
want_summary=False,
want_instructions=True,
)
assert est["total"] == 200
assert est["per_exercise"] == 2

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.177"
BUILD_DATE = "2026-05-22"
APP_VERSION = "0.8.179"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074"
MODULE_VERSIONS = {
@ -19,6 +19,7 @@ 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)
"exercise_enrichment_admin": "1.1.0", # Analyze, candidate-ids, instructions/summary apply; unbegrenzte Batch-Auswahl
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
@ -43,6 +44,22 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.179",
"date": "2026-05-23",
"changes": [
"Übungs-Anreicherung: Dialog mit Merge-Modus, Skills/Summary/Anleitung, Kosten-Analyse + Bestätigung.",
"Alle aus Filter auswählen (300+), chunked Preview/Apply; Default Sichtbarkeit privat, replace_all empfohlen.",
],
},
{
"version": "0.8.178",
"date": "2026-05-23",
"changes": [
"Superadmin-Werkzeug Übungs-Anreicherung (KI): Kandidaten filtern, Vorschau, Batch-Apply Skills + Status in_review.",
"API /api/admin/exercise-enrichment/* — wiederverwendet exercise_ai / run_exercise_form_ai_suggestion.",
],
},
{
"version": "0.8.177",
"date": "2026-05-22",

View File

@ -56,6 +56,7 @@ 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 AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@ -318,6 +319,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/exercise-enrichment',
element: (
<PlatformAdminRoute>
<AdminExerciseEnrichmentPage />
</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, Brain, Sparkles } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -13,6 +13,7 @@ export default function AdminPageNav() {
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
{ to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles },
{ to: '/admin/exercise-enrichment', label: 'Übungs-Anreicherung', icon: Wand2 },
]
return (

View File

@ -0,0 +1,881 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
const STATUS_OPTIONS = [
{ value: 'draft', label: 'Entwurf' },
{ value: 'in_review', label: 'In Prüfung' },
{ value: 'approved', label: 'Freigegeben' },
{ value: 'archived', label: 'Archiviert' },
]
const VISIBILITY_OPTIONS = [
{ value: 'private', label: 'Privat (Standard)' },
{ value: 'all', label: 'Alle Sichtbarkeiten' },
{ value: 'official', label: 'Offiziell' },
{ value: 'club', label: 'Verein' },
]
const MERGE_OPTIONS = [
{
value: 'replace_all',
label: 'Alle Skills ersetzen (empfohlen)',
hint: 'Bestehende Zuordnungen werden entfernt. Alle neuen Skills erhalten ai_suggested=true — klar als KI erkennbar.',
},
{
value: 'replace_ai_only',
label: 'Nur bisherige KI-Skills ersetzen',
hint: 'Manuelle Skills bleiben; nur ai_suggested=true wird neu gesetzt.',
},
{
value: 'additive',
label: 'Ergänzen (manuell behalten)',
hint: 'Manuelle Skills bleiben unverändert; KI ergänzt neue. Intensität/Level nur bei KI-Skills.',
},
]
const INSTRUCTION_LABELS = {
goal: 'Ziel',
execution: 'Durchführung',
preparation: 'Vorbereitung',
trainer_notes: 'Trainer-Hinweise',
}
const CHUNK_SIZE = 25
function skillLabel(sk) {
if (!sk) return '—'
const name = sk.skill_name || `Skill #${sk.skill_id}`
const inten = sk.intensity ? ` · Int. ${sk.intensity}` : ''
const from = sk.required_level || '?'
const to = sk.target_level || from
const lvl = ` · ${from}${to}`
const ai = sk.ai_suggested ? ' · KI' : ' · manuell'
return `${name}${inten}${lvl}${ai}`
}
function buildFilterParams(filters) {
const params = { status: filters.status, visibility: filters.visibility || 'private' }
if (filters.focusAreaId) params.focus_area_id = Number(filters.focusAreaId)
if (filters.withoutSkills) params.without_skills = true
if (filters.withAiSuggested) params.with_ai_suggested_skills = true
if (filters.search.trim()) params.search = filters.search.trim()
return params
}
function DiffBlock({ diff }) {
if (!diff) return null
const hasAny =
(diff.added?.length || 0) + (diff.changed?.length || 0) + (diff.removed?.length || 0) > 0
if (!hasAny) {
return (
<p className="text-muted" style={{ margin: '8px 0 0', fontSize: '0.9rem' }}>
Keine Skill-Änderungen.
</p>
)
}
return (
<div style={{ fontSize: '0.88rem', marginTop: 8 }}>
{diff.added?.length > 0 && (
<div>
<strong style={{ color: 'var(--accent)' }}>Neu:</strong>{' '}
{diff.added.map((s) => skillLabel(s)).join('; ')}
</div>
)}
{diff.changed?.length > 0 && (
<div style={{ marginTop: 4 }}>
<strong>Geändert:</strong>{' '}
{diff.changed.map((c) => (
<span key={c.skill_id}>
{c.skill_name}: {skillLabel(c.before)} {skillLabel(c.after)}
</span>
))}
</div>
)}
{diff.removed?.length > 0 && (
<div style={{ marginTop: 4 }}>
<strong style={{ color: 'var(--danger)' }}>Entfernt:</strong>{' '}
{diff.removed.map((s) => skillLabel(s)).join('; ')}
</div>
)}
</div>
)
}
function RunDialog({
open,
onClose,
exerciseCount,
analysis,
modes,
setModes,
mergeMode,
setMergeMode,
setStatusAfterApply,
statusAfterApply,
costConfirmed,
setCostConfirmed,
onStartPreview,
jobRunning,
}) {
if (!open) return null
const est = analysis?.estimated_llm_calls
const totalCalls = est?.total ?? 0
const mergeHint = MERGE_OPTIONS.find((o) => o.value === mergeMode)?.hint || ''
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="enrichment-run-title"
onClick={(e) => {
if (e.target === e.currentTarget && !jobRunning) onClose()
}}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
>
<div className="card" style={{ maxWidth: 520, width: '100%', padding: 20, maxHeight: '90vh', overflow: 'auto' }}>
<h2 id="enrichment-run-title" style={{ margin: '0 0 12px', fontSize: '1.15rem' }}>
Batch-Anreicherung konfigurieren
</h2>
<div
style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: 12,
marginBottom: 16,
fontSize: '0.92rem',
}}
>
<div>
<strong>{exerciseCount}</strong> Übung(en) ausgewählt
</div>
{est && (
<div style={{ marginTop: 8, color: 'var(--text2)' }}>
Geschätzte LLM-Aufrufe: <strong>{totalCalls}</strong>
{modes.skills && est.skills > 0 && (
<span> · Skills: {est.skills}</span>
)}
{modes.summary && est.summary > 0 && (
<span> · Kurzfassung: {est.summary}</span>
)}
{modes.instructions && est.instructions > 0 && (
<span> · Anleitung: {est.instructions}</span>
)}
</div>
)}
{exerciseCount >= 25 && (
<p style={{ margin: '10px 0 0', color: 'var(--danger)', fontSize: '0.88rem' }}>
Großer Batch OpenRouter-Kosten und Laufzeit beachten. Der Lauf kann mehrere Minuten dauern.
</p>
)}
</div>
<fieldset style={{ border: 'none', padding: 0, margin: '0 0 16px' }}>
<legend style={{ fontWeight: 600, marginBottom: 8 }}>Inhalte per KI</legend>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={modes.skills}
onChange={(e) => setModes((m) => ({ ...m, skills: e.target.checked }))}
/>
Fähigkeiten (inkl. Intensität &amp; Levelbereich)
</label>
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={modes.summary}
onChange={(e) => setModes((m) => ({ ...m, summary: e.target.checked }))}
/>
Kurzfassung (Summary)
</label>
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={modes.instructions}
onChange={(e) => setModes((m) => ({ ...m, instructions: e.target.checked }))}
/>
Anleitung (Ziel, Durchführung, Vorbereitung, Trainer-Hinweise)
</label>
</div>
</fieldset>
{modes.skills && (
<div style={{ marginBottom: 16 }}>
<label className="form-label">
Skill-Merge-Modus
<select className="form-input" value={mergeMode} onChange={(e) => setMergeMode(e.target.value)}>
{MERGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
{mergeHint && (
<p style={{ margin: '6px 0 0', fontSize: '0.85rem', color: 'var(--text2)' }}>{mergeHint}</p>
)}
</div>
)}
<label className="form-label" style={{ marginBottom: 16 }}>
Nach erfolgreichem Apply Status
<select
className="form-input"
value={statusAfterApply}
onChange={(e) => setStatusAfterApply(e.target.value)}
>
<option value="in_review">In Prüfung</option>
<option value="draft">Entwurf (unverändert)</option>
<option value="">Status nicht ändern</option>
</select>
</label>
<label
style={{
display: 'flex',
gap: 8,
alignItems: 'flex-start',
marginBottom: 16,
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
<input
type="checkbox"
checked={costConfirmed}
onChange={(e) => setCostConfirmed(e.target.checked)}
style={{ marginTop: 3 }}
/>
<span>
Ich bestätige <strong>{exerciseCount}</strong> Übung(en) und ca.{' '}
<strong>{totalCalls}</strong> LLM-Aufruf(e) mir sind die Kosten bewusst.
</span>
</label>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={jobRunning} onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={
jobRunning ||
!costConfirmed ||
exerciseCount === 0 ||
(!modes.skills && !modes.summary && !modes.instructions)
}
onClick={onStartPreview}
>
Vorschau starten
</button>
</div>
</div>
</div>
)
}
/**
* Superadmin: Batch-Anreicherung von Übungen per KI.
*/
export default function AdminExerciseEnrichmentPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [filters, setFilters] = useState({
status: 'draft',
visibility: 'private',
focusAreaId: '',
withoutSkills: true,
withAiSuggested: false,
search: '',
})
const [focusAreas, setFocusAreas] = useState([])
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const limit = 25
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [dialogOpen, setDialogOpen] = useState(false)
const [analysis, setAnalysis] = useState(null)
const [modes, setModes] = useState({ skills: true, summary: false, instructions: false })
const [mergeMode, setMergeMode] = useState('replace_all')
const [setStatusAfterApply, setSetStatusAfterApply] = useState('in_review')
const [costConfirmed, setCostConfirmed] = useState(false)
const [previewRows, setPreviewRows] = useState([])
const [previewErrors, setPreviewErrors] = useState([])
const [previewMeta, setPreviewMeta] = useState(null)
const [showPreview, setShowPreview] = useState(false)
const [jobRunning, setJobRunning] = useState(false)
const [jobProgress, setJobProgress] = useState({ done: 0, total: 0, phase: '' })
const abortRef = useRef(false)
const selectedIds = useMemo(() => Array.from(selected), [selected])
const filterParams = useMemo(() => buildFilterParams(filters), [filters])
const loadCandidates = useCallback(async () => {
const data = await api.listExerciseEnrichmentCandidates({ ...filterParams, limit, offset })
setItems(Array.isArray(data.items) ? data.items : [])
setTotal(Number(data.total) || 0)
}, [filterParams, offset])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
const faRaw = await api.listFocusAreas()
if (!cancelled) {
const fa = Array.isArray(faRaw) ? faRaw : []
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
}
await loadCandidates()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadCandidates])
useEffect(() => {
if (!dialogOpen || selectedIds.length === 0) return
let cancelled = false
;(async () => {
try {
const data = await api.analyzeExerciseEnrichment({
exercise_ids: selectedIds,
modes,
})
if (!cancelled) {
setAnalysis(data)
setCostConfirmed(false)
}
} catch (e) {
if (!cancelled) setError(e.message || String(e))
}
})()
return () => {
cancelled = true
}
}, [dialogOpen, modes, selectedIds])
if (!isSuperadmin) return <Navigate to="/" replace />
const pageIds = items.map((r) => r.id)
const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id))
function toggleRow(id) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function togglePageAll() {
setSelected((prev) => {
const next = new Set(prev)
if (allPageSelected) pageIds.forEach((id) => next.delete(id))
else pageIds.forEach((id) => next.add(id))
return next
})
}
async function selectAllMatchingFilter() {
setError('')
try {
const data = await api.listExerciseEnrichmentCandidateIds(filterParams)
const ids = Array.isArray(data.ids) ? data.ids : []
setSelected(new Set(ids))
} catch (e) {
setError(e.message || String(e))
}
}
async function openRunDialog() {
if (selectedIds.length === 0) {
setError('Bitte mindestens eine Übung auswählen.')
return
}
setError('')
setCostConfirmed(false)
setModes({ skills: true, summary: false, instructions: false })
setMergeMode('replace_all')
setDialogOpen(true)
}
async function runPreviewFromDialog() {
setDialogOpen(false)
setError('')
setJobRunning(true)
abortRef.current = false
const allResults = []
const allErrors = []
let estTotal = 0
setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' })
try {
for (let i = 0; i < selectedIds.length; i += CHUNK_SIZE) {
if (abortRef.current) break
const chunk = selectedIds.slice(i, i + CHUNK_SIZE)
const resp = await api.previewExerciseEnrichment({
exercise_ids: chunk,
modes,
merge_mode: mergeMode,
})
allResults.push(...(resp.results || []))
allErrors.push(...(resp.errors || []))
const est = resp.estimated_llm_calls
estTotal += typeof est === 'object' ? est.total || 0 : est || 0
setJobProgress({
done: Math.min(i + chunk.length, selectedIds.length),
total: selectedIds.length,
phase: 'Vorschau',
})
}
setPreviewRows(allResults)
setPreviewErrors(allErrors)
setPreviewMeta({
estimated_llm_calls: estTotal,
merge_mode: mergeMode,
modes: { ...modes },
})
setShowPreview(true)
} catch (e) {
setError(e.message || String(e))
} finally {
setJobRunning(false)
setJobProgress({ done: 0, total: 0, phase: '' })
}
}
function cancelJob() {
abortRef.current = true
}
async function runApply() {
const applyItems = previewRows
.filter((r) => r.ok)
.map((r) => ({
exercise_id: r.exercise_id,
merged_skills: r.merged_skills || [],
summary: r.suggested_summary || null,
instruction_fields: r.suggested_instructions || null,
}))
if (applyItems.length === 0) {
setError('Keine anwendbaren Vorschau-Ergebnisse.')
return
}
const statusLabel =
setStatusAfterApply === 'in_review'
? 'In Prüfung'
: setStatusAfterApply || 'unverändert'
if (
!window.confirm(
`${applyItems.length} Übung(en) speichern und Status „${statusLabel}“ setzen?`,
)
) {
return
}
setError('')
setJobRunning(true)
abortRef.current = false
const appliedAll = []
const failedAll = []
setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' })
try {
for (let i = 0; i < applyItems.length; i += CHUNK_SIZE) {
if (abortRef.current) break
const chunk = applyItems.slice(i, i + CHUNK_SIZE)
const resp = await api.applyExerciseEnrichment({
items: chunk,
modes: previewMeta?.modes || modes,
merge_mode: mergeMode,
set_status: setStatusAfterApply || null,
})
appliedAll.push(...(resp.applied || []))
failedAll.push(...(resp.failed || []))
setJobProgress({
done: Math.min(i + chunk.length, applyItems.length),
total: applyItems.length,
phase: 'Anwenden',
})
}
setPreviewRows([])
setShowPreview(false)
setSelected(new Set())
await loadCandidates()
const msg = `${appliedAll.length} OK, ${failedAll.length} Fehler`
if (failedAll.length) setError(msg)
else setError('')
window.alert(msg)
} catch (e) {
setError(e.message || String(e))
} finally {
setJobRunning(false)
setJobProgress({ done: 0, total: 0, phase: '' })
}
}
return (
<div className="page" style={{ paddingBottom: 80 }}>
<AdminPageNav />
<header style={{ marginBottom: 16 }}>
<h1 style={{ margin: '0 0 4px', fontSize: '1.35rem' }}>Übungs-Anreicherung (KI)</h1>
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.95rem' }}>
Batchweise Anreicherung nach Status Vorschau ohne Speichern, danach kontrolliert in Prüfung
überführen. Für große Bestände (300+) alle passenden Übungen auswählen und Kosten bestätigen.
</p>
</header>
{error && (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 12, padding: 12 }}>
{error}
</div>
)}
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-row" style={{ flexWrap: 'wrap', gap: 12 }}>
<label className="form-label" style={{ minWidth: 120 }}>
Status
<select
className="form-input"
value={filters.status}
onChange={(e) => {
setOffset(0)
setFilters((f) => ({ ...f, status: e.target.value }))
}}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="form-label" style={{ minWidth: 160 }}>
Sichtbarkeit
<select
className="form-input"
value={filters.visibility}
onChange={(e) => {
setOffset(0)
setFilters((f) => ({ ...f, visibility: e.target.value }))
}}
>
{VISIBILITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="form-label" style={{ minWidth: 140 }}>
Fokusbereich
<select
className="form-input"
value={filters.focusAreaId}
onChange={(e) => {
setOffset(0)
setFilters((f) => ({ ...f, focusAreaId: e.target.value }))
}}
>
<option value=""></option>
{focusAreas.map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.name}
</option>
))}
</select>
</label>
<label className="form-label" style={{ flex: '1 1 180px' }}>
Suche
<input
className="form-input"
value={filters.search}
placeholder="Titel, Stichworte…"
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setOffset(0)
loadCandidates().catch((err) => setError(err.message))
}
}}
/>
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginTop: 12, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters.withoutSkills}
onChange={(e) => {
setOffset(0)
setFilters((f) => ({ ...f, withoutSkills: e.target.checked }))
}}
/>
Ohne Skills
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters.withAiSuggested}
onChange={(e) => {
setOffset(0)
setFilters((f) => ({ ...f, withAiSuggested: e.target.checked }))
}}
/>
Mit KI-Skills
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setOffset(0)
loadCandidates().catch((err) => setError(err.message))
}}
>
Filtern
</button>
</div>
</div>
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
disabled={jobRunning || total === 0}
onClick={selectAllMatchingFilter}
>
Alle {total} aus Filter auswählen
</button>
<button
type="button"
className="btn btn-primary"
disabled={jobRunning || selectedIds.length === 0}
onClick={openRunDialog}
>
Anreicherung konfigurieren ({selectedIds.length})
</button>
{showPreview && previewRows.length > 0 && (
<button type="button" className="btn btn-primary" disabled={jobRunning} onClick={runApply}>
Anwenden &amp; Status setzen
</button>
)}
{jobRunning && (
<button type="button" className="btn btn-secondary" onClick={cancelJob}>
Abbrechen
</button>
)}
</div>
{jobRunning && jobProgress.total > 0 && (
<p style={{ margin: '10px 0 0', fontSize: '0.9rem' }}>
{jobProgress.phase}: {jobProgress.done}/{jobProgress.total}
</p>
)}
</div>
<RunDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
exerciseCount={selectedIds.length}
analysis={analysis}
modes={modes}
setModes={setModes}
mergeMode={mergeMode}
setMergeMode={setMergeMode}
setStatusAfterApply={setStatusAfterApply}
statusAfterApply={setStatusAfterApply}
costConfirmed={costConfirmed}
setCostConfirmed={setCostConfirmed}
onStartPreview={runPreviewFromDialog}
jobRunning={jobRunning}
/>
{showPreview && (
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<h2 style={{ margin: '0 0 12px', fontSize: '1.1rem' }}>Vorschau</h2>
{previewMeta && (
<p style={{ margin: '0 0 12px', color: 'var(--text2)', fontSize: '0.9rem' }}>
{previewRows.length} OK · {previewErrors.length} Fehler/übersprungen · ca.{' '}
{previewMeta.estimated_llm_calls} LLM-Call(s)
</p>
)}
{previewRows.map((row) => (
<div
key={row.exercise_id}
style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginTop: 12 }}
>
<strong>{row.title || `#${row.exercise_id}`}</strong>
<span style={{ color: 'var(--text2)', marginLeft: 8, fontSize: '0.85rem' }}>
{row.status} · {row.visibility} · {row.primary_focus_name || '—'}
</span>
{(previewMeta?.modes?.skills || modes.skills) && (
<div style={{ marginTop: 6, fontSize: '0.88rem' }}>
<div>
<span style={{ color: 'var(--text2)' }}>Vorhanden:</span>{' '}
{(row.existing_skills || []).map((s) => skillLabel(s)).join('; ') || '—'}
</div>
<div style={{ marginTop: 4 }}>
<span style={{ color: 'var(--text2)' }}>Vorgeschlagen:</span>{' '}
{(row.suggested_skills || []).map((s) => skillLabel(s)).join('; ') || '—'}
</div>
<DiffBlock diff={row.diff} />
</div>
)}
{(previewMeta?.modes?.summary || modes.summary) && row.suggested_summary && (
<div style={{ marginTop: 8, fontSize: '0.88rem' }}>
<span style={{ color: 'var(--text2)' }}>Kurzfassung:</span>{' '}
{row.existing_summary ? `"${row.existing_summary}" → ` : ''}
&quot;{row.suggested_summary}&quot;
</div>
)}
{(previewMeta?.modes?.instructions || modes.instructions) &&
row.suggested_instructions &&
Object.keys(row.suggested_instructions).length > 0 && (
<div style={{ marginTop: 8, fontSize: '0.88rem' }}>
<span style={{ color: 'var(--text2)' }}>Anleitung geändert:</span>{' '}
{Object.keys(row.suggested_instructions)
.map((k) => INSTRUCTION_LABELS[k] || k)
.join(', ')}
</div>
)}
</div>
))}
{previewErrors.map((err) => (
<div
key={`err-${err.exercise_id}`}
style={{ color: 'var(--danger)', marginTop: 8, fontSize: '0.88rem' }}
>
#{err.exercise_id}: {err.error || 'Fehler'}
</div>
))}
</div>
)}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{loading ? (
<p style={{ padding: 16 }}>Lade</p>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.92rem' }}>
<thead>
<tr style={{ background: 'var(--surface2)', textAlign: 'left' }}>
<th style={{ padding: '10px 12px', width: 40 }}>
<input
type="checkbox"
checked={allPageSelected}
onChange={togglePageAll}
aria-label="Alle auf Seite"
/>
</th>
<th style={{ padding: '10px 12px' }}>Titel</th>
<th style={{ padding: '10px 12px' }}>Status</th>
<th style={{ padding: '10px 12px' }}>Sichtbarkeit</th>
<th style={{ padding: '10px 12px' }}>Fokus</th>
<th style={{ padding: '10px 12px' }}>Skills</th>
<th style={{ padding: '10px 12px' }}>KI-Skills</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={7} style={{ padding: 16, color: 'var(--text2)' }}>
Keine Kandidaten für die Filter.
</td>
</tr>
) : (
items.map((row) => (
<tr key={row.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '10px 12px' }}>
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => toggleRow(row.id)}
aria-label={`Auswahl ${row.title}`}
/>
</td>
<td style={{ padding: '10px 12px' }}>{row.title}</td>
<td style={{ padding: '10px 12px' }}>{row.status}</td>
<td style={{ padding: '10px 12px' }}>{row.visibility}</td>
<td style={{ padding: '10px 12px' }}>{row.primary_focus_name || '—'}</td>
<td style={{ padding: '10px 12px' }}>{row.skill_count ?? 0}</td>
<td style={{ padding: '10px 12px' }}>{row.ai_suggested_skill_count ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
borderTop: '1px solid var(--border)',
}}
>
<span style={{ color: 'var(--text2)', fontSize: '0.88rem' }}>
{total} gesamt · {selectedIds.length} ausgewählt
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-secondary"
disabled={offset <= 0}
onClick={() => setOffset((o) => Math.max(0, o - limit))}
>
Zurück
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + limit >= total}
onClick={() => setOffset((o) => o + limit)}
>
Weiter
</button>
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@ -388,6 +388,50 @@ export async function deleteAiSkillRetrievalProfile(profileId) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
}
/** Superadmin: Übungs-Anreicherung per KI (Batch Skills) */
export async function listExerciseEnrichmentCandidates(params = {}) {
const q = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
else q.set(k, String(v))
})
const qs = q.toString()
return request(`/api/admin/exercise-enrichment/candidates${qs ? `?${qs}` : ''}`)
}
export async function previewExerciseEnrichment(body) {
return request('/api/admin/exercise-enrichment/preview', {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function applyExerciseEnrichment(body) {
return request('/api/admin/exercise-enrichment/apply', {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function listExerciseEnrichmentCandidateIds(params = {}) {
const q = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
else q.set(k, String(v))
})
const qs = q.toString()
return request(`/api/admin/exercise-enrichment/candidate-ids${qs ? `?${qs}` : ''}`)
}
export async function analyzeExerciseEnrichment(body) {
return request('/api/admin/exercise-enrichment/analyze', {
method: 'POST',
body: JSON.stringify(body),
})
}
/** Superadmin: KI Prompt-Templates (ai_prompts) */
export async function listAdminAiPrompts() {
return request('/api/admin/ai-prompts')
@ -844,6 +888,11 @@ export const api = {
createAiSkillRetrievalProfile,
updateAiSkillRetrievalProfile,
deleteAiSkillRetrievalProfile,
listExerciseEnrichmentCandidates,
previewExerciseEnrichment,
applyExerciseEnrichment,
listExerciseEnrichmentCandidateIds,
analyzeExerciseEnrichment,
listAdminAiPrompts,
getAdminAiPrompt,
updateAdminAiPrompt,