Implement Roadmap Review Features and Enhance Progression Path Management
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Added support for editable major steps in the roadmap, allowing users to modify phase, learning goals, and order before exercise matching.
- Introduced a new `roadmap_override` feature to facilitate customized retrieval without re-invoking the roadmap AI.
- Updated the `ExerciseProgressionPathBuilder` component to incorporate these new features, enhancing user interaction and flexibility.
- Incremented application version to 0.8.207 to reflect these changes.
This commit is contained in:
Lars 2026-06-08 14:59:24 +02:00
parent 0677663268
commit f074a8bef0
9 changed files with 514 additions and 76 deletions

View File

@ -188,7 +188,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
| **F4** | UI Roadmap-Review | 🔲 |
| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
---

View File

@ -53,10 +53,12 @@ from planning_exercise_suggest import (
from planning_progression_roadmap import (
MajorStep,
ProgressionRoadmapContext,
RoadmapOverridePayload,
StageSpecArtifact,
build_roadmap_unfilled_gap_specs,
progression_roadmap_to_api_dict,
resolve_step_exercise_kind_filter,
roadmap_context_from_override,
run_progression_roadmap_pipeline,
stage_spec_retrieval_query,
)
@ -74,6 +76,8 @@ class ProgressionPathSuggestRequest(BaseModel):
include_roadmap_preview: bool = False
include_llm_roadmap: bool = True
roadmap_first: bool = False
roadmap_only: bool = False
roadmap_override: Optional[RoadmapOverridePayload] = None
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
@ -492,21 +496,29 @@ def suggest_progression_path(
cur, goal_query, semantic_brief
)
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
roadmap_first = bool(body.roadmap_first)
include_roadmap = roadmap_first or body.include_roadmap_preview
roadmap_only = bool(body.roadmap_only)
include_roadmap = roadmap_first or body.include_roadmap_preview or roadmap_only
progression_roadmap: Optional[Dict[str, Any]] = None
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
roadmap_edited = False
if include_roadmap:
if body.roadmap_override is not None:
try:
roadmap_ctx = roadmap_context_from_override(
goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
override=body.roadmap_override,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
progression_roadmap["roadmap_edited"] = True
roadmap_edited = True
max_steps = int(roadmap_ctx.max_steps)
roadmap_first = True
elif include_roadmap:
roadmap_ctx = run_progression_roadmap_pipeline(
goal_query,
max_steps=max_steps,
@ -516,6 +528,37 @@ def suggest_progression_path(
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
if roadmap_only:
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": [],
"step_count": 0,
"target_profile_summary": None,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": {},
"progression_graph_id": body.progression_graph_id,
"path_qa": None,
"gap_fill_offers": [],
"progression_roadmap": progression_roadmap,
"roadmap_first": False,
"roadmap_only": True,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": 0,
"retrieval_phase": "roadmap_only",
}
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
@ -721,6 +764,8 @@ def suggest_progression_path(
retrieval_parts.append("gap_fill_offers")
if include_roadmap:
retrieval_parts.append("roadmap_preview")
if roadmap_edited:
retrieval_parts.append("roadmap_edited")
if roadmap_unfilled:
retrieval_parts.append("roadmap_unfilled")
@ -738,6 +783,8 @@ def suggest_progression_path(
"gap_fill_offers": gap_fill_offers,
"progression_roadmap": progression_roadmap,
"roadmap_first": roadmap_first,
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": len(roadmap_unfilled),
"retrieval_phase": "+".join(retrieval_parts),
}

View File

@ -109,6 +109,13 @@ class StageSpecArtifact(BaseModel):
anti_patterns: List[str] = Field(default_factory=list)
class RoadmapOverridePayload(BaseModel):
"""Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI."""
major_steps: List[MajorStep] = Field(..., min_length=2, max_length=10)
stage_specs: Optional[List[StageSpecArtifact]] = None
class ProgressionRoadmapContext(BaseModel):
goal_query: str
max_steps: int = Field(ge=2, le=10, default=5)
@ -551,6 +558,80 @@ def build_stage_specs(
return specs
def normalize_major_steps_for_override(
major_steps: Sequence[MajorStep],
*,
max_steps: int,
) -> List[MajorStep]:
"""Indizes 0…n-1, mindestens 2, höchstens max_steps Major Steps."""
cleaned: List[MajorStep] = []
for raw in list(major_steps)[:max_steps]:
goal = (raw.learning_goal or "").strip()
phase = (raw.phase or "vertiefung").strip().lower()
if not goal:
continue
cleaned.append(
MajorStep(
index=len(cleaned),
phase=phase,
learning_goal=goal,
consolidates=list(raw.consolidates or []),
rationale=(raw.rationale or "").strip(),
)
)
if len(cleaned) < 2:
raise ValueError("Mindestens zwei Major Steps mit Lernziel nötig")
for i, step in enumerate(cleaned):
step.index = i
return cleaned
def roadmap_context_from_override(
goal_query: str,
*,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
override: RoadmapOverridePayload,
) -> ProgressionRoadmapContext:
"""Phase F4: bearbeitete Roadmap → stage_specs → Retrieval (ohne LLM-Roadmap)."""
majors = normalize_major_steps_for_override(override.major_steps, max_steps=max_steps)
effective_max = len(majors)
goal_analysis = build_goal_analysis(goal_query, semantic_brief)
stage_specs: List[StageSpecArtifact]
if override.stage_specs and len(override.stage_specs) >= effective_max:
stage_specs = []
for i, spec in enumerate(override.stage_specs[:effective_max]):
stage_specs.append(
StageSpecArtifact(
major_step_index=i,
learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(),
load_profile=list(spec.load_profile or []),
exercise_type=(spec.exercise_type or "").strip(),
success_criteria=list(spec.success_criteria or []),
anti_patterns=list(spec.anti_patterns or []),
)
)
if not all(s.exercise_type for s in stage_specs):
rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis)
for i, spec in enumerate(stage_specs):
if not spec.exercise_type:
spec.exercise_type = rebuilt[i].exercise_type
if not spec.load_profile:
spec.load_profile = list(rebuilt[i].load_profile)
else:
stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis)
return ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief),
goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs,
pipeline_phase="roadmap_v1_edited",
)
def run_progression_roadmap_pipeline(
goal_query: str,
*,
@ -652,6 +733,9 @@ __all__ = [
"MicroObjective",
"ProgressionRoadmapContext",
"RoadmapArtifact",
"RoadmapOverridePayload",
"normalize_major_steps_for_override",
"roadmap_context_from_override",
"StageSpecArtifact",
"build_goal_analysis",
"build_roadmap_unfilled_gap_specs",

View File

@ -71,6 +71,7 @@ def post_progression_path_suggest(
body.include_llm_intent
or body.include_llm_path_qa
or body.include_ai_gap_fill
or body.include_llm_roadmap
)
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
if uses_ai:

View File

@ -14,6 +14,9 @@ from planning_progression_roadmap import (
run_progression_roadmap_pipeline,
stage_spec_exercise_kind_filter,
stage_spec_retrieval_query,
normalize_major_steps_for_override,
roadmap_context_from_override,
RoadmapOverridePayload,
)
from planning_exercise_semantics import build_semantic_brief
@ -90,6 +93,40 @@ def test_build_roadmap_unfilled_gap_specs():
assert specs[0]["phase"] == "anwendung"
def test_normalize_major_steps_reindexes():
majors = normalize_major_steps_for_override(
[
MajorStep(index=9, phase="einstieg", learning_goal="Einstieg", consolidates=[]),
MajorStep(index=8, phase="perfektion", learning_goal="Ziel", consolidates=[]),
],
max_steps=5,
)
assert len(majors) == 2
assert majors[0].index == 0
assert majors[1].index == 1
def test_roadmap_context_from_override():
brief = build_semantic_brief("Mae Geri Perfektion")
override = RoadmapOverridePayload(
major_steps=[
MajorStep(index=0, phase="einstieg", learning_goal="Mae Geri Einstieg", consolidates=[]),
MajorStep(index=1, phase="grundlage", learning_goal="Stand und Hüfte", consolidates=[]),
MajorStep(index=2, phase="perfektion", learning_goal="Präzision unter Belastung", consolidates=[]),
]
)
ctx = roadmap_context_from_override(
"Mae Geri Perfektion",
max_steps=5,
semantic_brief=brief,
override=override,
)
assert ctx.pipeline_phase == "roadmap_v1_edited"
assert len(ctx.roadmap.major_steps) == 3
assert len(ctx.stage_specs) == 3
assert ctx.stage_specs[1].learning_goal == "Stand und Hüfte"
def test_api_dict_exposes_prompt_slug_catalog():
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
api = progression_roadmap_to_api_dict(ctx)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.206"
APP_VERSION = "0.8.207"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606086"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.18.0", # F3: roadmap_first Retrieval pro stage_spec
"planning_exercise_suggest": "0.19.0", # F4: roadmap_only + roadmap_override, editierbare Roadmap-UI
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -53,6 +53,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.207",
"date": "2026-06-07",
"changes": [
"Phase F4: Roadmap-Review — roadmap_only, roadmap_override auf progression-path-suggest.",
"UI: Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match.",
"Zwei-Schritt-Flow: Roadmap vorschlagen → Übungen matchen.",
],
},
{
"version": "0.8.206",
"date": "2026-06-07",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-06-07
**App-Version / DB-Schema:** App **`0.8.206`** (Planungs-KI Phase F3); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
**App-Version / DB-Schema:** App **`0.8.207`** (Planungs-KI Phase F4); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -109,6 +109,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
| **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**

View File

@ -64,10 +64,11 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
- [ ] QA/Lücken vollständig an Roadmap koppeln (Brücken optional reduzieren)
### F4 — UI
### F4 — UI (0.8.207)
- [ ] Roadmap-Review im `ExerciseProgressionPathBuilder`
- [ ] Major Steps editierbar vor Übungs-Match
- [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
---

View File

@ -35,6 +35,10 @@ function mapApiStepToRow(step) {
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
isOffTopic: false,
roadmapMajorStepIndex:
step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null,
roadmapPhase: step?.roadmap_phase || null,
roadmapLearningGoal: step?.roadmap_learning_goal || null,
}
}
@ -63,6 +67,36 @@ const OFFER_SOURCE_LABELS = {
const PATH_STEPS_HARD_MAX = 10
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return []
return 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 || '',
}))
}
function reindexMajorSteps(rows) {
return rows.map((row, i) => ({ ...row, index: i }))
}
function majorStepsToOverridePayload(rows) {
return {
major_steps: reindexMajorSteps(rows).map((row) => ({
index: row.index,
phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [],
rationale: row.rationale || '',
})),
}
}
/** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath(offer) {
const replaceIdx = offer?.replace_step_index
@ -121,7 +155,6 @@ export default function ExerciseProgressionPathBuilder({
const [goalQuery, setGoalQuery] = useState('')
const [maxSteps, setMaxSteps] = useState(5)
const [segmentNotes, setSegmentNotes] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [targetSummary, setTargetSummary] = useState(null)
@ -130,6 +163,11 @@ export default function ExerciseProgressionPathBuilder({
const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([])
const [progressionRoadmap, setProgressionRoadmap] = useState(null)
const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
const [loadingMatch, setLoadingMatch] = useState(false)
const loading = loadingRoadmap || loadingMatch
const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null)
@ -173,6 +211,52 @@ export default function ExerciseProgressionPathBuilder({
setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)))
}, [])
const patchMajorStep = useCallback((idx, patch) => {
setEditableMajorSteps((prev) =>
reindexMajorSteps(prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))),
)
setRoadmapDirty(true)
}, [])
const moveMajorStep = useCallback((idx, dir) => {
setEditableMajorSteps((prev) => {
const j = idx + dir
if (j < 0 || j >= prev.length) return prev
const next = [...prev]
const t = next[idx]
next[idx] = next[j]
next[j] = t
return reindexMajorSteps(next)
})
setRoadmapDirty(true)
}, [])
const removeMajorStep = useCallback((idx) => {
setEditableMajorSteps((prev) => {
if (prev.length <= 2) return prev
return reindexMajorSteps(prev.filter((_, i) => i !== idx))
})
setRoadmapDirty(true)
}, [])
const addMajorStep = useCallback(() => {
setEditableMajorSteps((prev) => {
if (prev.length >= PATH_STEPS_HARD_MAX) return prev
const phase = ROADMAP_PHASES[Math.min(prev.length, ROADMAP_PHASES.length - 1)]
return reindexMajorSteps([
...prev,
{
index: prev.length,
phase,
learning_goal: '',
consolidates: [],
rationale: '',
},
])
})
setRoadmapDirty(true)
}, [])
const moveStep = useCallback((idx, dir) => {
setPathSteps((prev) => {
const j = idx + dir
@ -376,7 +460,33 @@ export default function ExerciseProgressionPathBuilder({
}
}
const suggestPath = async () => {
const applyPathMatchResponse = (res, q) => {
const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const rows =
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
? rawRows
: applyOffTopicFlags(rawRows, qa)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
setProgressionRoadmap(res?.progression_roadmap || null)
setRoadmapDirty(false)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
}
const suggestRoadmap = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
@ -386,55 +496,85 @@ export default function ExerciseProgressionPathBuilder({
alert('Zuerst einen Graphen wählen.')
return
}
setLoading(true)
setLoadingRoadmap(true)
setError('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: Number(maxSteps),
include_llm_intent: false,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
roadmap_only: true,
progression_graph_id: Number(graphId),
})
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) {
throw new Error('Roadmap hat zu wenig Major Steps.')
}
setEditableMajorSteps(majors)
setMaxSteps(majors.length)
setProgressionRoadmap(res?.progression_roadmap || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathSteps([])
setTargetSummary(null)
setPathQa(null)
setGapFillOffers([])
setRoadmapDirty(false)
} catch (e) {
console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
setEditableMajorSteps([])
setProgressionRoadmap(null)
} finally {
setLoadingRoadmap(false)
}
}
const matchExercisesFromRoadmap = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (!graphId) {
alert('Zuerst einen Graphen wählen.')
return
}
const validSteps = editableMajorSteps.filter((s) => (s.learning_goal || '').trim().length >= 3)
if (validSteps.length < 2) {
alert('Mindestens zwei Major Steps mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setLoadingMatch(true)
setError('')
try {
const override = majorStepsToOverridePayload(validSteps)
const res = await api.suggestProgressionPath({
query: q,
max_steps: validSteps.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: true,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
progression_graph_id: Number(graphId),
})
const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const rows =
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
? rawRows
: applyOffTopicFlags(rawRows, qa)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
setProgressionRoadmap(res?.progression_roadmap || null)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
} catch (e) {
console.error(e)
setError(e.message || 'Pfad-Vorschlag fehlgeschlagen')
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
setError(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
setLoading(false)
setLoadingMatch(false)
}
}
@ -477,6 +617,8 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@ -501,8 +643,8 @@ export default function ExerciseProgressionPathBuilder({
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Ziel in Freitext formulieren die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden.
Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
Lücken können mit KI als Übung angelegt werden.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
@ -531,9 +673,30 @@ export default function ExerciseProgressionPathBuilder({
type="button"
className="btn btn-primary"
disabled={disabled || loading || saving || !graphId}
onClick={suggestPath}
onClick={suggestRoadmap}
>
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'}
{loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={
disabled ||
loading ||
saving ||
!graphId ||
editableMajorSteps.length < 2
}
onClick={matchExercisesFromRoadmap}
title={
editableMajorSteps.length < 2
? 'Zuerst Roadmap vorschlagen'
: roadmapDirty
? 'Roadmap wurde bearbeitet — erneut matchen'
: 'Bibliothek je Major Step durchsuchen'
}
>
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
</button>
</div>
@ -565,7 +728,7 @@ export default function ExerciseProgressionPathBuilder({
</div>
) : null}
{progressionRoadmap?.roadmap?.major_steps?.length > 0 ? (
{editableMajorSteps.length > 0 ? (
<div
style={{
marginTop: '12px',
@ -575,25 +738,113 @@ export default function ExerciseProgressionPathBuilder({
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
}}
>
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap (Phase F)</strong>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
Ziel-zuerst-Planung: {progressionRoadmap.micro_objective_count ?? '?'} Zwischenziele {' '}
{progressionRoadmap.major_step_count ?? progressionRoadmap.roadmap.major_steps.length} Major Steps.
{progressionRoadmap.llm_roadmap_applied
? ' (KI-Prompts aus Admin-Konfiguration)'
: ' (heuristischer Fallback — KI-Prompts in ai_prompts)'}
. Übungen unten: je Major Step aus der Bibliothek (roadmap-first).
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap bearbeiten</strong>
{roadmapDirty ? (
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
Geändert bitte erneut matchen
</span>
) : pathSteps.length > 0 ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Gematcht
</span>
) : null}
</div>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
{progressionRoadmap?.micro_objective_count != null
? `${progressionRoadmap.micro_objective_count} Zwischenziele → `
: ''}
{editableMajorSteps.length} Major Steps
{progressionRoadmap?.llm_roadmap_applied
? ' (KI-Roadmap)'
: progressionRoadmap
? ' (heuristisch/KI)'
: ''}
. Phasen und Lernziele anpassen, dann Übungen matchen.
</p>
<ol style={{ margin: 0, paddingLeft: '18px', fontSize: '13px', lineHeight: 1.5 }}>
{progressionRoadmap.roadmap.major_steps.map((step) => (
<li key={step.index} style={{ marginBottom: '6px' }}>
<span className="exercise-tag" style={{ marginRight: '6px' }}>
{step.phase}
</span>
{step.learning_goal}
</li>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{editableMajorSteps.map((step, idx) => (
<div
key={`major-${idx}-${step.index}`}
style={{
padding: '10px 12px',
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>
</div>
))}
</ol>
</div>
{editableMajorSteps.length < PATH_STEPS_HARD_MAX ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '10px', fontSize: '12px' }}
onClick={addMajorStep}
disabled={disabled || loading || saving}
>
Major Step hinzufügen
</button>
) : null}
</div>
) : null}
@ -748,11 +999,18 @@ export default function ExerciseProgressionPathBuilder({
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.roadmapMajorStepIndex != null
? ` · Roadmap ${step.roadmapMajorStepIndex + 1}`
: ''}
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
{step.roadmapLearningGoal ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Ziel: {step.roadmapLearningGoal}
</p>
) : null}
<div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong>
{step.exerciseId ? (