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

- 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:
Lars 2026-06-10 07:14:18 +02:00
parent 1e7941f57b
commit 98b279fa89
3 changed files with 272 additions and 62 deletions

View File

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

View File

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

View File

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