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“
- 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)

View File

@ -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")

View File

@ -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),

View File

@ -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",

View File

@ -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({
)}
</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 && (
<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 style={{ margin: '6px 0 0', color: 'var(--danger)', fontSize: '0.88rem' }}>
Großer Batch OpenRouter-Kosten und Gesamtlaufzeit (mehrere Minuten) beachten.
</p>
)}
</div>
@ -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,