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“
|
- 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user