diff --git a/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md index 16cf56a..8d00e20 100644 --- a/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md +++ b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md @@ -40,7 +40,9 @@ Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_su - 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 +- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung +- **Preview:** max. **3 Übungen/HTTP-Request** (parallel LLM), Frontend chunked — vermeidet Gateway-504 (~60s Fritz!Box) +- **Apply:** HTTP-Chunks à 25 (nur DB, kein LLM) ## Inhalte (modular) diff --git a/backend/exercise_enrichment.py b/backend/exercise_enrichment.py index f9da6c6..659226a 100644 --- a/backend/exercise_enrichment.py +++ b/backend/exercise_enrichment.py @@ -22,8 +22,10 @@ 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 +# Max. IDs pro Apply-HTTP-Anfrage (kein LLM). +MAX_BATCH_EXERCISES = 50 +# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s). +MAX_PREVIEW_BATCH_EXERCISES = 3 _INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes") _SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary") diff --git a/backend/routers/exercise_enrichment_admin.py b/backend/routers/exercise_enrichment_admin.py index 2f6a248..5b370f4 100644 --- a/backend/routers/exercise_enrichment_admin.py +++ b/backend/routers/exercise_enrichment_admin.py @@ -6,6 +6,7 @@ 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 @@ -17,6 +18,7 @@ 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, @@ -24,6 +26,8 @@ from exercise_enrichment import ( preview_exercise_enrichment, ) +_PREVIEW_MAX_WORKERS = 3 + router = APIRouter(tags=["admin_exercise_enrichment"]) _VALID_STATUS_FILTER = frozenset({"draft", "in_review", "approved", "archived"}) @@ -108,7 +112,7 @@ class EnrichmentModes(BaseModel): class EnrichmentPreviewBody(BaseModel): - exercise_ids: List[int] = Field(..., min_length=1, max_length=MAX_BATCH_EXERCISES) + 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" @@ -301,12 +305,40 @@ def analyze_enrichment(body: EnrichmentAnalyzeBody, session: dict = Depends(requ } +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.""" + """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_BATCH_EXERCISES) + 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") @@ -320,27 +352,28 @@ def preview_enrichment(body: EnrichmentPreviewBody, session: dict = Depends(requ 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)}) + 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), diff --git a/backend/version.py b/backend/version.py index a1a705d..5130641 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.179" +APP_VERSION = "0.8.180" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -19,7 +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 + "exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden) "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 @@ -44,6 +44,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.180", + "date": "2026-05-23", + "changes": [ + "Fix: Übungs-Anreicherung Vorschau — max. 3 Übungen/Request, parallel LLM; Frontend-Pakete + Retry bei 504.", + ], + }, { "version": "0.8.179", "date": "2026-05-23", diff --git a/frontend/src/pages/AdminExerciseEnrichmentPage.jsx b/frontend/src/pages/AdminExerciseEnrichmentPage.jsx index a31b431..e9cd6c7 100644 --- a/frontend/src/pages/AdminExerciseEnrichmentPage.jsx +++ b/frontend/src/pages/AdminExerciseEnrichmentPage.jsx @@ -43,7 +43,9 @@ const INSTRUCTION_LABELS = { trainer_notes: 'Trainer-Hinweise', } -const CHUNK_SIZE = 25 +/** Preview: kleine Pakete — Gateway (Fritz!Box o.ä.) timeout oft ~60s bei LLM-Ketten. */ +const PREVIEW_CHUNK_SIZE = 3 +const APPLY_CHUNK_SIZE = 25 function skillLabel(sk) { if (!sk) return '—' @@ -177,9 +179,15 @@ function RunDialog({ )} )} + {exerciseCount >= 10 && ( +
+ Die Vorschau läuft in Paketen à 3 Übungen (Gateway-Timeout vermeiden). Fenster offen lassen — + bei {exerciseCount} Übungen ca. {Math.ceil(exerciseCount / 3)} Pakete. +
+ )} {exerciseCount >= 25 && ( -- Großer Batch — OpenRouter-Kosten und Laufzeit beachten. Der Lauf kann mehrere Minuten dauern. +
+ Großer Batch — OpenRouter-Kosten und Gesamtlaufzeit (mehrere Minuten) beachten.
)} @@ -432,6 +440,24 @@ export default function AdminExerciseEnrichmentPage() { setDialogOpen(true) } + async function previewChunkWithRetry(chunk, attempt = 0) { + try { + return await api.previewExerciseEnrichment({ + exercise_ids: chunk, + modes, + merge_mode: mergeMode, + }) + } catch (e) { + const msg = e.message || String(e) + const isTimeout = /504|502|timeout/i.test(msg) + if (isTimeout && attempt < 2) { + await new Promise((r) => setTimeout(r, 2500)) + return previewChunkWithRetry(chunk, attempt + 1) + } + throw e + } + } + async function runPreviewFromDialog() { setDialogOpen(false) setError('') @@ -440,18 +466,21 @@ export default function AdminExerciseEnrichmentPage() { const allResults = [] const allErrors = [] let estTotal = 0 + const totalChunks = Math.ceil(selectedIds.length / PREVIEW_CHUNK_SIZE) setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' }) try { - for (let i = 0; i < selectedIds.length; i += CHUNK_SIZE) { + for (let i = 0; i < selectedIds.length; i += PREVIEW_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, + const chunk = selectedIds.slice(i, i + PREVIEW_CHUNK_SIZE) + const chunkNo = Math.floor(i / PREVIEW_CHUNK_SIZE) + 1 + setJobProgress({ + done: i, + total: selectedIds.length, + phase: `Vorschau Paket ${chunkNo}/${totalChunks}`, }) + const resp = await previewChunkWithRetry(chunk) allResults.push(...(resp.results || [])) allErrors.push(...(resp.errors || [])) const est = resp.estimated_llm_calls @@ -459,7 +488,7 @@ export default function AdminExerciseEnrichmentPage() { setJobProgress({ done: Math.min(i + chunk.length, selectedIds.length), total: selectedIds.length, - phase: 'Vorschau', + phase: `Vorschau Paket ${chunkNo}/${totalChunks}`, }) } setPreviewRows(allResults) @@ -517,9 +546,9 @@ export default function AdminExerciseEnrichmentPage() { setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' }) try { - for (let i = 0; i < applyItems.length; i += CHUNK_SIZE) { + for (let i = 0; i < applyItems.length; i += APPLY_CHUNK_SIZE) { if (abortRef.current) break - const chunk = applyItems.slice(i, i + CHUNK_SIZE) + const chunk = applyItems.slice(i, i + APPLY_CHUNK_SIZE) const resp = await api.applyExerciseEnrichment({ items: chunk, modes: previewMeta?.modes || modes,