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
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:
parent
d1d8539b42
commit
f4196c3580
|
|
@ -37,17 +37,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
| 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.
|
**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-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)
|
### 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-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
|
||||||
66
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
66
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal 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.
|
||||||
534
backend/exercise_enrichment.py
Normal file
534
backend/exercise_enrichment.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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, 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(auth.router)
|
||||||
app.include_router(profiles.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(content_reports.router)
|
||||||
app.include_router(ai_prompts_admin.router)
|
app.include_router(ai_prompts_admin.router)
|
||||||
app.include_router(ai_skill_retrieval_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
|
# 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>).
|
||||||
|
|
|
||||||
415
backend/routers/exercise_enrichment_admin.py
Normal file
415
backend/routers/exercise_enrichment_admin.py
Normal 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(),
|
||||||
|
}
|
||||||
|
|
@ -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()
|
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
||||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
"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",
|
"catalogs.py",
|
||||||
"skills.py",
|
"skills.py",
|
||||||
"maturity_models.py",
|
"maturity_models.py",
|
||||||
|
|
|
||||||
282
backend/tests/test_exercise_enrichment_admin.py
Normal file
282
backend/tests/test_exercise_enrichment_admin.py
Normal 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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.177"
|
APP_VERSION = "0.8.179"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-23"
|
||||||
DB_SCHEMA_VERSION = "20260531074"
|
DB_SCHEMA_VERSION = "20260531074"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
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_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||||
|
"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
|
"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_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||||
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||||
|
|
@ -43,6 +44,22 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.177",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||||
|
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
||||||
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. */
|
||||||
|
|
@ -318,6 +319,14 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/exercise-enrichment',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminExerciseEnrichmentPage />
|
||||||
|
</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, 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).
|
* 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/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-prompts', label: 'KI Prompts', icon: Sparkles },
|
{ to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles },
|
||||||
|
{ to: '/admin/exercise-enrichment', label: 'Übungs-Anreicherung', icon: Wand2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
881
frontend/src/pages/AdminExerciseEnrichmentPage.jsx
Normal file
881
frontend/src/pages/AdminExerciseEnrichmentPage.jsx
Normal 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 & 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 & 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}" → ` : ''}
|
||||||
|
"{row.suggested_summary}"
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -388,6 +388,50 @@ export async function deleteAiSkillRetrievalProfile(profileId) {
|
||||||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Ü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) */
|
/** Superadmin: KI Prompt-Templates (ai_prompts) */
|
||||||
export async function listAdminAiPrompts() {
|
export async function listAdminAiPrompts() {
|
||||||
return request('/api/admin/ai-prompts')
|
return request('/api/admin/ai-prompts')
|
||||||
|
|
@ -844,6 +888,11 @@ export const api = {
|
||||||
createAiSkillRetrievalProfile,
|
createAiSkillRetrievalProfile,
|
||||||
updateAiSkillRetrievalProfile,
|
updateAiSkillRetrievalProfile,
|
||||||
deleteAiSkillRetrievalProfile,
|
deleteAiSkillRetrievalProfile,
|
||||||
|
listExerciseEnrichmentCandidates,
|
||||||
|
previewExerciseEnrichment,
|
||||||
|
applyExerciseEnrichment,
|
||||||
|
listExerciseEnrichmentCandidateIds,
|
||||||
|
analyzeExerciseEnrichment,
|
||||||
listAdminAiPrompts,
|
listAdminAiPrompts,
|
||||||
getAdminAiPrompt,
|
getAdminAiPrompt,
|
||||||
updateAdminAiPrompt,
|
updateAdminAiPrompt,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user