Update version to 0.8.216 and enhance Exercise Progression Path Builder
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
- Incremented application version to 0.8.216 to reflect recent changes. - Added skill expectations handling in the Exercise Progression Path Builder, improving the integration of expected skills into the roadmap steps. - Enhanced the mapping of major steps to include load profiles, success criteria, anti-patterns, and exercise types, enriching the user experience and functionality.
This commit is contained in:
parent
1e7941f57b
commit
98b279fa89
|
|
@ -42,3 +42,21 @@ def test_annotate_roadmap_step_adds_metadata():
|
|||
assert step["roadmap_phase"] == "grundlage"
|
||||
assert step["roadmap_match_source"] == "stage_spec"
|
||||
assert any("Roadmap:" in r for r in step["reasons"])
|
||||
|
||||
|
||||
def test_annotate_roadmap_step_adds_skill_expectations():
|
||||
spec = StageSpecArtifact(major_step_index=0, learning_goal="Timing und Distanz")
|
||||
step = _annotate_roadmap_step(
|
||||
{"exercise_id": 5, "title": "Test", "reasons": []},
|
||||
stage_spec=spec,
|
||||
major_step=None,
|
||||
skill_expectations={
|
||||
"scope": "progression_stage",
|
||||
"expected_skills": [
|
||||
{"skill_id": 2, "skill_name": "Timing", "weight": 0.9},
|
||||
{"skill_id": 3, "skill_name": "Distanz", "weight": 0.8},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert step["skill_expectations"]["expected_skills"][0]["skill_name"] == "Timing"
|
||||
assert any("Fähigkeiten:" in r for r in step["reasons"])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.215"
|
||||
APP_VERSION = "0.8.216"
|
||||
BUILD_DATE = "2026-06-07"
|
||||
DB_SCHEMA_VERSION = "20260607087"
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ function mapApiStepToRow(step) {
|
|||
step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null,
|
||||
roadmapPhase: step?.roadmap_phase || null,
|
||||
roadmapLearningGoal: step?.roadmap_learning_goal || null,
|
||||
skillExpectations: step?.skill_expectations || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,16 +138,65 @@ const PATH_STEPS_HARD_MAX = 10
|
|||
|
||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||
|
||||
const LOAD_PROFILE_OPTIONS = [
|
||||
'koordination',
|
||||
'präzision',
|
||||
'kraft',
|
||||
'geschwindigkeit',
|
||||
'timing',
|
||||
'reaktion',
|
||||
'distanz',
|
||||
'gleichgewicht',
|
||||
'kime',
|
||||
'ausdauer',
|
||||
'beweglichkeit',
|
||||
]
|
||||
|
||||
function mapMajorStepsFromApi(apiRoadmap) {
|
||||
const raw = apiRoadmap?.roadmap?.major_steps
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((s, i) => ({
|
||||
const rows = raw.map((s, i) => ({
|
||||
index: i,
|
||||
phase: s.phase || 'vertiefung',
|
||||
learning_goal: (s.learning_goal || '').trim(),
|
||||
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
|
||||
rationale: s.rationale || '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
}))
|
||||
return mergeStageSpecsIntoMajorSteps(rows, apiRoadmap)
|
||||
}
|
||||
|
||||
function mergeStageSpecsIntoMajorSteps(rows, apiRoadmap) {
|
||||
const specs = apiRoadmap?.stage_specs
|
||||
if (!Array.isArray(specs) || !rows.length) return rows
|
||||
return rows.map((row, i) => {
|
||||
const spec =
|
||||
specs.find((s) => Number(s.major_step_index) === i) ||
|
||||
specs.find((s) => Number(s.major_step_index) === row.index) ||
|
||||
specs[i]
|
||||
if (!spec) return row
|
||||
return {
|
||||
...row,
|
||||
load_profile: Array.isArray(spec.load_profile) ? [...spec.load_profile] : [],
|
||||
success_criteria: Array.isArray(spec.success_criteria) ? [...spec.success_criteria] : [],
|
||||
anti_patterns: Array.isArray(spec.anti_patterns) ? [...spec.anti_patterns] : [],
|
||||
exercise_type: (spec.exercise_type || '').trim(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function linesToStringList(text) {
|
||||
return String(text || '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function listToMultiline(arr) {
|
||||
return Array.isArray(arr) ? arr.join('\n') : ''
|
||||
}
|
||||
|
||||
function reindexMajorSteps(rows) {
|
||||
|
|
@ -154,17 +204,35 @@ function reindexMajorSteps(rows) {
|
|||
}
|
||||
|
||||
function majorStepsToOverridePayload(rows) {
|
||||
const indexed = reindexMajorSteps(rows)
|
||||
return {
|
||||
major_steps: reindexMajorSteps(rows).map((row) => ({
|
||||
major_steps: indexed.map((row) => ({
|
||||
index: row.index,
|
||||
phase: row.phase || 'vertiefung',
|
||||
learning_goal: row.learning_goal.trim(),
|
||||
consolidates: row.consolidates || [],
|
||||
rationale: row.rationale || '',
|
||||
})),
|
||||
stage_specs: indexed.map((row, i) => ({
|
||||
major_step_index: i,
|
||||
learning_goal: row.learning_goal.trim(),
|
||||
load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
|
||||
exercise_type: (row.exercise_type || '').trim(),
|
||||
success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
|
||||
anti_patterns: Array.isArray(row.anti_patterns) ? row.anti_patterns : [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function formatExpectedSkillNames(skillExpectations, limit = 4) {
|
||||
const items = skillExpectations?.expected_skills
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((s) => String(s?.skill_name || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
|
||||
function offerGrowsPath(offer) {
|
||||
const replaceIdx = offer?.replace_step_index
|
||||
|
|
@ -234,6 +302,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
const [pathSteps, setPathSteps] = useState([])
|
||||
const [gapFillOffers, setGapFillOffers] = useState([])
|
||||
const [progressionRoadmap, setProgressionRoadmap] = useState(null)
|
||||
const [pathSkillExpectations, setPathSkillExpectations] = useState(null)
|
||||
const [editableMajorSteps, setEditableMajorSteps] = useState([])
|
||||
const [roadmapDirty, setRoadmapDirty] = useState(false)
|
||||
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
|
||||
|
|
@ -331,6 +400,10 @@ export default function ExerciseProgressionPathBuilder({
|
|||
learning_goal: '',
|
||||
consolidates: [],
|
||||
rationale: '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
|
@ -629,8 +702,14 @@ export default function ExerciseProgressionPathBuilder({
|
|||
: [],
|
||||
)
|
||||
setProgressionRoadmap(res?.progression_roadmap || null)
|
||||
setPathSkillExpectations(res?.path_skill_expectations || null)
|
||||
setRoadmapDirty(false)
|
||||
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
|
||||
if (res?.progression_roadmap?.stage_specs?.length) {
|
||||
setEditableMajorSteps((prev) =>
|
||||
prev.length ? mergeStageSpecsIntoMajorSteps(prev, res.progression_roadmap) : prev,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const applyStartTargetResponse = (res) => {
|
||||
|
|
@ -737,12 +816,14 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setTargetSummary(null)
|
||||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setPathSkillExpectations(null)
|
||||
setRoadmapDirty(false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
|
||||
setEditableMajorSteps([])
|
||||
setProgressionRoadmap(null)
|
||||
setPathSkillExpectations(null)
|
||||
} finally {
|
||||
setLoadingRoadmap(false)
|
||||
}
|
||||
|
|
@ -831,6 +912,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setProgressionRoadmap(null)
|
||||
setPathSkillExpectations(null)
|
||||
setEditableMajorSteps([])
|
||||
setRoadmapDirty(false)
|
||||
if (typeof onSaved === 'function') await onSaved()
|
||||
|
|
@ -1048,7 +1130,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
|
||||
{(semanticBrief || targetSummary || pathSkillExpectations) && pathSteps.length > 0 ? (
|
||||
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{semanticBrief?.primary_topic ? (
|
||||
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||
|
|
@ -1067,6 +1149,16 @@ export default function ExerciseProgressionPathBuilder({
|
|||
Fokus: {fa}
|
||||
</span>
|
||||
))}
|
||||
{formatExpectedSkillNames(pathSkillExpectations, 5).map((name) => (
|
||||
<span
|
||||
key={`path-skill-${name}`}
|
||||
className="exercise-tag"
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--accent) 55%, var(--border))' }}
|
||||
title="Pfadweite Fähigkeiten-Erwartung (Scoring)"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1102,7 +1194,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
: progressionRoadmap
|
||||
? ' (heuristisch/KI)'
|
||||
: ''}
|
||||
. Phasen und Lernziele anpassen, dann „Übungen matchen“.
|
||||
. Phasen und Lernziele anpassen; optional Stufen-Details für Match und KI-Lücken, dann „Übungen matchen“.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{editableMajorSteps.map((step, idx) => (
|
||||
|
|
@ -1113,66 +1205,141 @@ export default function ExerciseProgressionPathBuilder({
|
|||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: '10px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<div className="form-row" style={{ marginBottom: 0, flex: '0 0 120px' }}>
|
||||
<label className="form-label">Stufe {idx + 1} · Phase</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={step.phase}
|
||||
onChange={(e) => patchMajorStep(idx, { phase: e.target.value })}
|
||||
disabled={disabled || loading || saving}
|
||||
>
|
||||
{ROADMAP_PHASES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}>
|
||||
<label className="form-label">Lernziel</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={step.learning_goal}
|
||||
onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })}
|
||||
placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
|
||||
disabled={disabled || loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => moveMajorStep(idx, -1)}
|
||||
disabled={disabled || loading || saving || idx === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => moveMajorStep(idx, 1)}
|
||||
disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => removeMajorStep(idx)}
|
||||
disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: '10px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<div className="form-row" style={{ marginBottom: 0, flex: '0 0 120px' }}>
|
||||
<label className="form-label">Stufe {idx + 1} · Phase</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={step.phase}
|
||||
onChange={(e) => patchMajorStep(idx, { phase: e.target.value })}
|
||||
disabled={disabled || loading || saving}
|
||||
>
|
||||
{ROADMAP_PHASES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}>
|
||||
<label className="form-label">Lernziel (Major Step)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={step.learning_goal}
|
||||
onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })}
|
||||
placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
|
||||
disabled={disabled || loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => moveMajorStep(idx, -1)}
|
||||
disabled={disabled || loading || saving || idx === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => moveMajorStep(idx, 1)}
|
||||
disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
onClick={() => removeMajorStep(idx)}
|
||||
disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details style={{ marginTop: '10px', fontSize: '12px' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||
Stufen-Details (Match & KI)
|
||||
{step.load_profile?.length
|
||||
? ` · ${step.load_profile.slice(0, 2).join(', ')}`
|
||||
: ''}
|
||||
</summary>
|
||||
<div style={{ marginTop: '10px', display: 'grid', gap: '10px' }}>
|
||||
<div>
|
||||
<label className="form-label">Belastungsschwerpunkte</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{LOAD_PROFILE_OPTIONS.map((opt) => {
|
||||
const active = (step.load_profile || []).includes(opt)
|
||||
return (
|
||||
<label
|
||||
key={opt}
|
||||
className="exercise-tag"
|
||||
style={{
|
||||
cursor: disabled || loading || saving ? 'default' : 'pointer',
|
||||
borderColor: active ? 'var(--accent)' : 'var(--border)',
|
||||
opacity: disabled || loading || saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
disabled={disabled || loading || saving}
|
||||
style={{ marginRight: '4px' }}
|
||||
onChange={() => {
|
||||
const current = step.load_profile || []
|
||||
const next = active
|
||||
? current.filter((x) => x !== opt)
|
||||
: [...current, opt]
|
||||
patchMajorStep(idx, { load_profile: next })
|
||||
}}
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Erfolgskriterien (je Zeile)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={listToMultiline(step.success_criteria)}
|
||||
onChange={(e) =>
|
||||
patchMajorStep(idx, { success_criteria: linesToStringList(e.target.value) })
|
||||
}
|
||||
placeholder={'z. B. Bezug zur Technik erkennbar\nPhase vertiefung im Übungsziel'}
|
||||
disabled={disabled || loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Vermeiden (optional, je Zeile)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={listToMultiline(step.anti_patterns)}
|
||||
onChange={(e) =>
|
||||
patchMajorStep(idx, { anti_patterns: linesToStringList(e.target.value) })
|
||||
}
|
||||
placeholder="z. B. reine Kraftübung ohne Technikbezug"
|
||||
disabled={disabled || loading || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1361,6 +1528,30 @@ export default function ExerciseProgressionPathBuilder({
|
|||
Ziel: {step.roadmapLearningGoal}
|
||||
</p>
|
||||
) : null}
|
||||
{formatExpectedSkillNames(step.skillExpectations).length ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
>
|
||||
{formatExpectedSkillNames(step.skillExpectations).map((name) => (
|
||||
<span
|
||||
key={`${idx}-${name}`}
|
||||
className="exercise-tag"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
borderColor: 'color-mix(in srgb, var(--accent) 50%, var(--border))',
|
||||
}}
|
||||
title="Erwartete Fähigkeiten dieser Roadmap-Stufe"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<strong>{step.exerciseTitle}</strong>
|
||||
{step.exerciseId ? (
|
||||
|
|
@ -1454,6 +1645,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setProgressionRoadmap(null)
|
||||
setPathSkillExpectations(null)
|
||||
}}
|
||||
>
|
||||
Vorschlag verwerfen
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user