""" 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 concurrent.futures import ThreadPoolExecutor, as_completed 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, MAX_PREVIEW_BATCH_EXERCISES, SKILL_MERGE_MODES, SkillMergeMode, apply_exercise_enrichment, estimate_llm_calls, preview_exercise_enrichment, ) _PREVIEW_MAX_WORKERS = 3 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_PREVIEW_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 ), } def _preview_one_exercise( ex_id: int, *, want_skills: bool, want_summary: bool, want_instructions: bool, merge_mode: SkillMergeMode, ) -> Dict[str, Any]: """Einzel-Preview mit eigener DB-Connection (Thread-Pool).""" try: with get_db() as conn: cur = get_cursor(conn) row = preview_exercise_enrichment( cur, ex_id, want_skills=want_skills, want_summary=want_summary, want_instructions=want_instructions, merge_mode=merge_mode, ) return row except HTTPException as he: d = he.detail return {"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)} except Exception as exc: # pragma: no cover return {"exercise_id": ex_id, "ok": False, "error": str(exc)} @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 (max. 3 Übungen/Request, parallel).""" _require_superadmin(session) ids = _normalize_id_list(body.exercise_ids, max_items=MAX_PREVIEW_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]] = [] workers = min(_PREVIEW_MAX_WORKERS, len(ids)) with ThreadPoolExecutor(max_workers=workers) as pool: futures = [ pool.submit( _preview_one_exercise, ex_id, want_skills=modes.skills, want_summary=modes.summary, want_instructions=modes.instructions, merge_mode=body.merge_mode, ) for ex_id in ids ] for fut in as_completed(futures): row = fut.result() if row.get("ok"): results.append(row) else: errors.append(row) results.sort(key=lambda r: int(r.get("exercise_id") or 0)) errors.sort(key=lambda r: int(r.get("exercise_id") or 0)) 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(), }