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
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:
parent
f4196c3580
commit
46fae3da33
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user