Enhance Exercise Enrichment Admin Functionality and Update Documentation
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Test Suite / pytest-backend (pull_request) Successful in 36s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m23s

- Implemented a maximum of 3 exercises per preview request to prevent Gateway-504 errors, improving the stability of the exercise enrichment process.
- Adjusted batch sizes for applying exercises and previewing to optimize performance and resource management.
- Updated the frontend to reflect changes in preview handling, including user notifications about chunk sizes and potential timeouts.
- Incremented version to 0.8.180 and updated changelog to document these enhancements and fixes.
This commit is contained in:
Lars 2026-05-23 07:46:35 +02:00
parent f4196c3580
commit 46fae3da33
5 changed files with 114 additions and 41 deletions

View File

@ -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“ - 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) - Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell)
- Nach Apply: `set_status=in_review` (nie automatisch `approved`) - 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) ## Inhalte (modular)

View File

@ -22,8 +22,10 @@ SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"}) SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
DEFAULT_SET_STATUS = "in_review" DEFAULT_SET_STATUS = "in_review"
# Max. IDs pro einzelner HTTP-Anfrage (Frontend chunked darüber hinaus). # Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
MAX_BATCH_EXERCISES = 100 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") _INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary") _SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")

View File

@ -6,6 +6,7 @@ Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
""" """
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -17,6 +18,7 @@ from db import get_cursor, get_db, r2d
from exercise_enrichment import ( from exercise_enrichment import (
DEFAULT_SET_STATUS, DEFAULT_SET_STATUS,
MAX_BATCH_EXERCISES, MAX_BATCH_EXERCISES,
MAX_PREVIEW_BATCH_EXERCISES,
SKILL_MERGE_MODES, SKILL_MERGE_MODES,
SkillMergeMode, SkillMergeMode,
apply_exercise_enrichment, apply_exercise_enrichment,
@ -24,6 +26,8 @@ from exercise_enrichment import (
preview_exercise_enrichment, preview_exercise_enrichment,
) )
_PREVIEW_MAX_WORKERS = 3
router = APIRouter(tags=["admin_exercise_enrichment"]) router = APIRouter(tags=["admin_exercise_enrichment"])
_VALID_STATUS_FILTER = frozenset({"draft", "in_review", "approved", "archived"}) _VALID_STATUS_FILTER = frozenset({"draft", "in_review", "approved", "archived"})
@ -108,7 +112,7 @@ class EnrichmentModes(BaseModel):
class EnrichmentPreviewBody(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) modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
merge_mode: SkillMergeMode = "replace_all" 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") @router.post("/api/admin/exercise-enrichment/preview")
def preview_enrichment(body: EnrichmentPreviewBody, session: dict = Depends(require_auth)): 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) _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: if not ids:
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-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]] = [] results: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = [] errors: List[Dict[str, Any]] = []
with get_db() as conn: workers = min(_PREVIEW_MAX_WORKERS, len(ids))
cur = get_cursor(conn) with ThreadPoolExecutor(max_workers=workers) as pool:
for ex_id in ids: futures = [
try: pool.submit(
row = preview_exercise_enrichment( _preview_one_exercise,
cur, ex_id,
ex_id, want_skills=modes.skills,
want_skills=modes.skills, want_summary=modes.summary,
want_summary=modes.summary, want_instructions=modes.instructions,
want_instructions=modes.instructions, merge_mode=body.merge_mode,
merge_mode=body.merge_mode, )
) for ex_id in ids
if row.get("ok"): ]
results.append(row) for fut in as_completed(futures):
else: row = fut.result()
errors.append(row) if row.get("ok"):
except HTTPException as he: results.append(row)
d = he.detail else:
errors.append({"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)}) errors.append(row)
except Exception as exc: # pragma: no cover
errors.append({"exercise_id": ex_id, "ok": False, "error": str(exc)}) 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( est = estimate_llm_calls(
exercise_count=len(ids), exercise_count=len(ids),

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.179" APP_VERSION = "0.8.180"
BUILD_DATE = "2026-05-23" BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074" 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_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068) "admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"exercise_enrichment_admin": "1.1.0", # Analyze, candidate-ids, instructions/summary apply; unbegrenzte Batch-Auswahl "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 "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion "ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text "ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
@ -44,6 +44,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.179",
"date": "2026-05-23", "date": "2026-05-23",

View File

@ -43,7 +43,9 @@ const INSTRUCTION_LABELS = {
trainer_notes: 'Trainer-Hinweise', 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) { function skillLabel(sk) {
if (!sk) return '—' if (!sk) return '—'
@ -177,9 +179,15 @@ function RunDialog({
)} )}
</div> </div>
)} )}
{exerciseCount >= 10 && (
<p style={{ margin: '10px 0 0', color: 'var(--text2)', fontSize: '0.88rem' }}>
Die Vorschau läuft in Paketen à 3 Übungen (Gateway-Timeout vermeiden). Fenster offen lassen
bei {exerciseCount} Übungen ca. {Math.ceil(exerciseCount / 3)} Pakete.
</p>
)}
{exerciseCount >= 25 && ( {exerciseCount >= 25 && (
<p style={{ margin: '10px 0 0', color: 'var(--danger)', fontSize: '0.88rem' }}> <p style={{ margin: '6px 0 0', color: 'var(--danger)', fontSize: '0.88rem' }}>
Großer Batch OpenRouter-Kosten und Laufzeit beachten. Der Lauf kann mehrere Minuten dauern. Großer Batch OpenRouter-Kosten und Gesamtlaufzeit (mehrere Minuten) beachten.
</p> </p>
)} )}
</div> </div>
@ -432,6 +440,24 @@ export default function AdminExerciseEnrichmentPage() {
setDialogOpen(true) 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() { async function runPreviewFromDialog() {
setDialogOpen(false) setDialogOpen(false)
setError('') setError('')
@ -440,18 +466,21 @@ export default function AdminExerciseEnrichmentPage() {
const allResults = [] const allResults = []
const allErrors = [] const allErrors = []
let estTotal = 0 let estTotal = 0
const totalChunks = Math.ceil(selectedIds.length / PREVIEW_CHUNK_SIZE)
setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' }) setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' })
try { 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 if (abortRef.current) break
const chunk = selectedIds.slice(i, i + CHUNK_SIZE) const chunk = selectedIds.slice(i, i + PREVIEW_CHUNK_SIZE)
const resp = await api.previewExerciseEnrichment({ const chunkNo = Math.floor(i / PREVIEW_CHUNK_SIZE) + 1
exercise_ids: chunk, setJobProgress({
modes, done: i,
merge_mode: mergeMode, total: selectedIds.length,
phase: `Vorschau Paket ${chunkNo}/${totalChunks}`,
}) })
const resp = await previewChunkWithRetry(chunk)
allResults.push(...(resp.results || [])) allResults.push(...(resp.results || []))
allErrors.push(...(resp.errors || [])) allErrors.push(...(resp.errors || []))
const est = resp.estimated_llm_calls const est = resp.estimated_llm_calls
@ -459,7 +488,7 @@ export default function AdminExerciseEnrichmentPage() {
setJobProgress({ setJobProgress({
done: Math.min(i + chunk.length, selectedIds.length), done: Math.min(i + chunk.length, selectedIds.length),
total: selectedIds.length, total: selectedIds.length,
phase: 'Vorschau', phase: `Vorschau Paket ${chunkNo}/${totalChunks}`,
}) })
} }
setPreviewRows(allResults) setPreviewRows(allResults)
@ -517,9 +546,9 @@ export default function AdminExerciseEnrichmentPage() {
setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' }) setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' })
try { 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 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({ const resp = await api.applyExerciseEnrichment({
items: chunk, items: chunk,
modes: previewMeta?.modes || modes, modes: previewMeta?.modes || modes,