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 |
|
||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile.
|
||||
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
|
||||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
|
||||
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||
|
|
|
|||
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
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -224,6 +224,7 @@ app.include_router(legal_documents.router)
|
|||
app.include_router(content_reports.router)
|
||||
app.include_router(ai_prompts_admin.router)
|
||||
app.include_router(ai_skill_retrieval_admin.router)
|
||||
app.include_router(exercise_enrichment_admin.router)
|
||||
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
|
|
|
|||
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()
|
||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
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
|
||||
|
||||
APP_VERSION = "0.8.177"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
APP_VERSION = "0.8.179"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260531074"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
|
|
@ -19,6 +19,7 @@ MODULE_VERSIONS = {
|
|||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"exercise_enrichment_admin": "1.1.0", # Analyze, candidate-ids, instructions/summary apply; unbegrenzte Batch-Auswahl
|
||||
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||
|
|
@ -43,6 +44,22 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.179",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Übungs-Anreicherung: Dialog mit Merge-Modus, Skills/Summary/Anleitung, Kosten-Analyse + Bestätigung.",
|
||||
"Alle aus Filter auswählen (300+), chunked Preview/Apply; Default Sichtbarkeit privat, replace_all empfohlen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.178",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Superadmin-Werkzeug Übungs-Anreicherung (KI): Kandidaten filtern, Vorschau, Batch-Apply Skills + Status in_review.",
|
||||
"API /api/admin/exercise-enrichment/* — wiederverwendet exercise_ai / run_exercise_form_ai_suggestion.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.177",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const LegalPage = lazy(() => import('./pages/LegalPage'))
|
|||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||
|
||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||
|
|
@ -318,6 +319,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/exercise-enrichment',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminExerciseEnrichmentPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -13,6 +13,7 @@ export default function AdminPageNav() {
|
|||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||
{ to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles },
|
||||
{ to: '/admin/exercise-enrichment', label: 'Übungs-Anreicherung', icon: Wand2 },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
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' })
|
||||
}
|
||||
|
||||
/** Superadmin: Übungs-Anreicherung per KI (Batch Skills) */
|
||||
export async function listExerciseEnrichmentCandidates(params = {}) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
|
||||
else q.set(k, String(v))
|
||||
})
|
||||
const qs = q.toString()
|
||||
return request(`/api/admin/exercise-enrichment/candidates${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function previewExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listExerciseEnrichmentCandidateIds(params = {}) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
|
||||
else q.set(k, String(v))
|
||||
})
|
||||
const qs = q.toString()
|
||||
return request(`/api/admin/exercise-enrichment/candidate-ids${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function analyzeExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Superadmin: KI Prompt-Templates (ai_prompts) */
|
||||
export async function listAdminAiPrompts() {
|
||||
return request('/api/admin/ai-prompts')
|
||||
|
|
@ -844,6 +888,11 @@ export const api = {
|
|||
createAiSkillRetrievalProfile,
|
||||
updateAiSkillRetrievalProfile,
|
||||
deleteAiSkillRetrievalProfile,
|
||||
listExerciseEnrichmentCandidates,
|
||||
previewExerciseEnrichment,
|
||||
applyExerciseEnrichment,
|
||||
listExerciseEnrichmentCandidateIds,
|
||||
analyzeExerciseEnrichment,
|
||||
listAdminAiPrompts,
|
||||
getAdminAiPrompt,
|
||||
updateAdminAiPrompt,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user