Progression optimiert Phase A #55

Merged
Lars merged 33 commits from develop into main 2026-06-11 21:26:54 +02:00
3 changed files with 272 additions and 62 deletions
Showing only changes of commit 98b279fa89 - Show all commits

View File

@ -42,3 +42,21 @@ def test_annotate_roadmap_step_adds_metadata():
assert step["roadmap_phase"] == "grundlage" assert step["roadmap_phase"] == "grundlage"
assert step["roadmap_match_source"] == "stage_spec" assert step["roadmap_match_source"] == "stage_spec"
assert any("Roadmap:" in r for r in step["reasons"]) 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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.215" APP_VERSION = "0.8.216"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087" 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, step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null,
roadmapPhase: step?.roadmap_phase || null, roadmapPhase: step?.roadmap_phase || null,
roadmapLearningGoal: step?.roadmap_learning_goal || 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 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) { function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
return raw.map((s, i) => ({ const rows = raw.map((s, i) => ({
index: i, index: i,
phase: s.phase || 'vertiefung', phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(), learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [], consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '', 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) { function reindexMajorSteps(rows) {
@ -154,17 +204,35 @@ function reindexMajorSteps(rows) {
} }
function majorStepsToOverridePayload(rows) { function majorStepsToOverridePayload(rows) {
const indexed = reindexMajorSteps(rows)
return { return {
major_steps: reindexMajorSteps(rows).map((row) => ({ major_steps: indexed.map((row) => ({
index: row.index, index: row.index,
phase: row.phase || 'vertiefung', phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(), learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [], consolidates: row.consolidates || [],
rationale: row.rationale || '', 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. */ /** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath(offer) { function offerGrowsPath(offer) {
const replaceIdx = offer?.replace_step_index const replaceIdx = offer?.replace_step_index
@ -234,6 +302,7 @@ export default function ExerciseProgressionPathBuilder({
const [pathSteps, setPathSteps] = useState([]) const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([]) const [gapFillOffers, setGapFillOffers] = useState([])
const [progressionRoadmap, setProgressionRoadmap] = useState(null) const [progressionRoadmap, setProgressionRoadmap] = useState(null)
const [pathSkillExpectations, setPathSkillExpectations] = useState(null)
const [editableMajorSteps, setEditableMajorSteps] = useState([]) const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false) const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false) const [loadingRoadmap, setLoadingRoadmap] = useState(false)
@ -331,6 +400,10 @@ export default function ExerciseProgressionPathBuilder({
learning_goal: '', learning_goal: '',
consolidates: [], consolidates: [],
rationale: '', rationale: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}, },
]) ])
}) })
@ -629,8 +702,14 @@ export default function ExerciseProgressionPathBuilder({
: [], : [],
) )
setProgressionRoadmap(res?.progression_roadmap || null) setProgressionRoadmap(res?.progression_roadmap || null)
setPathSkillExpectations(res?.path_skill_expectations || null)
setRoadmapDirty(false) setRoadmapDirty(false)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400)) 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) => { const applyStartTargetResponse = (res) => {
@ -737,12 +816,14 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null) setTargetSummary(null)
setPathQa(null) setPathQa(null)
setGapFillOffers([]) setGapFillOffers([])
setPathSkillExpectations(null)
setRoadmapDirty(false) setRoadmapDirty(false)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen') setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
setEditableMajorSteps([]) setEditableMajorSteps([])
setProgressionRoadmap(null) setProgressionRoadmap(null)
setPathSkillExpectations(null)
} finally { } finally {
setLoadingRoadmap(false) setLoadingRoadmap(false)
} }
@ -831,6 +912,7 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null) setPathQa(null)
setGapFillOffers([]) setGapFillOffers([])
setProgressionRoadmap(null) setProgressionRoadmap(null)
setPathSkillExpectations(null)
setEditableMajorSteps([]) setEditableMajorSteps([])
setRoadmapDirty(false) setRoadmapDirty(false)
if (typeof onSaved === 'function') await onSaved() if (typeof onSaved === 'function') await onSaved()
@ -1048,7 +1130,7 @@ export default function ExerciseProgressionPathBuilder({
</div> </div>
) : null} ) : null}
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? ( {(semanticBrief || targetSummary || pathSkillExpectations) && pathSteps.length > 0 ? (
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}> <div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{semanticBrief?.primary_topic ? ( {semanticBrief?.primary_topic ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}> <span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
@ -1067,6 +1149,16 @@ export default function ExerciseProgressionPathBuilder({
Fokus: {fa} Fokus: {fa}
</span> </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> </div>
) : null} ) : null}
@ -1102,7 +1194,7 @@ export default function ExerciseProgressionPathBuilder({
: progressionRoadmap : progressionRoadmap
? ' (heuristisch/KI)' ? ' (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> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{editableMajorSteps.map((step, idx) => ( {editableMajorSteps.map((step, idx) => (
@ -1113,66 +1205,141 @@ export default function ExerciseProgressionPathBuilder({
borderRadius: '8px', borderRadius: '8px',
background: 'var(--surface2)', background: 'var(--surface2)',
border: '1px solid var(--border)', 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' }}> <div
<label className="form-label">Stufe {idx + 1} · Phase</label> style={{
<select display: 'grid',
className="form-input" gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
value={step.phase} gap: '10px',
onChange={(e) => patchMajorStep(idx, { phase: e.target.value })} alignItems: 'end',
disabled={disabled || loading || saving} }}
> >
{ROADMAP_PHASES.map((p) => ( <div className="form-row" style={{ marginBottom: 0, flex: '0 0 120px' }}>
<option key={p} value={p}> <label className="form-label">Stufe {idx + 1} · Phase</label>
{p} <select
</option> className="form-input"
))} value={step.phase}
</select> onChange={(e) => patchMajorStep(idx, { phase: e.target.value })}
</div> disabled={disabled || loading || saving}
<div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}> >
<label className="form-label">Lernziel</label> {ROADMAP_PHASES.map((p) => (
<input <option key={p} value={p}>
className="form-input" {p}
value={step.learning_goal} </option>
onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })} ))}
placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri" </select>
disabled={disabled || loading || saving} </div>
/> <div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}>
</div> <label className="form-label">Lernziel (Major Step)</label>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}> <input
<button className="form-input"
type="button" value={step.learning_goal}
className="btn" onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })}
style={{ fontSize: '12px', padding: '4px 8px' }} placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
onClick={() => moveMajorStep(idx, -1)} disabled={disabled || loading || saving}
disabled={disabled || loading || saving || idx === 0} />
> </div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
</button> <button
<button type="button"
type="button" className="btn"
className="btn" style={{ fontSize: '12px', padding: '4px 8px' }}
style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveMajorStep(idx, -1)}
onClick={() => moveMajorStep(idx, 1)} disabled={disabled || loading || saving || idx === 0}
disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1} >
>
</button>
</button> <button
<button type="button"
type="button" className="btn"
className="btn" style={{ fontSize: '12px', padding: '4px 8px' }}
style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveMajorStep(idx, 1)}
onClick={() => removeMajorStep(idx)} disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
disabled={disabled || loading || saving || editableMajorSteps.length <= 2} >
>
Entfernen </button>
</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> </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>
))} ))}
</div> </div>
@ -1361,6 +1528,30 @@ export default function ExerciseProgressionPathBuilder({
Ziel: {step.roadmapLearningGoal} Ziel: {step.roadmapLearningGoal}
</p> </p>
) : null} ) : 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' }}> <div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong> <strong>{step.exerciseTitle}</strong>
{step.exerciseId ? ( {step.exerciseId ? (
@ -1454,6 +1645,7 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null) setPathQa(null)
setGapFillOffers([]) setGapFillOffers([])
setProgressionRoadmap(null) setProgressionRoadmap(null)
setPathSkillExpectations(null)
}} }}
> >
Vorschlag verwerfen Vorschlag verwerfen