From f074a8bef02999710213d95280a6a7b589fdeb92 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 8 Jun 2026 14:59:24 +0200 Subject: [PATCH] Implement Roadmap Review Features and Enhance Progression Path Management - 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. --- .../PLANNING_PROGRESSION_ROADMAP_SPEC.md | 2 +- backend/planning_exercise_path_builder.py | 69 +++- backend/planning_progression_roadmap.py | 84 ++++ backend/routers/planning_exercise_suggest.py | 1 + .../test_planning_progression_roadmap.py | 37 ++ backend/version.py | 13 +- docs/HANDOVER.md | 3 +- docs/architecture/PLANNING_KI_ROADMAP.md | 7 +- .../ExerciseProgressionPathBuilder.jsx | 374 +++++++++++++++--- 9 files changed, 514 insertions(+), 76 deletions(-) diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md index 785373e..6e0eadb 100644 --- a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md +++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md @@ -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 | 🔲 | --- diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 799c25a..2aaad49 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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), } diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index 2db9735..90276c3 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -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", diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index cede94a..cd0e6a2 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -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: diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py index d2ea1d5..dfbf144 100644 --- a/backend/tests/test_planning_progression_roadmap.py +++ b/backend/tests/test_planning_progression_roadmap.py @@ -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) diff --git a/backend/version.py b/backend/version.py index 68803c1..9ae3136 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 1e1e110..84593e1 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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** | | **F0–F2** | 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`** diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md index 64c2fd3..5e68101 100644 --- a/docs/architecture/PLANNING_KI_ROADMAP.md +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -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` --- diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 89bd936..68ddc0d 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -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({ >

KI: Pfad zum Ziel

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

@@ -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'} + +
@@ -565,7 +728,7 @@ export default function ExerciseProgressionPathBuilder({
) : null} - {progressionRoadmap?.roadmap?.major_steps?.length > 0 ? ( + {editableMajorSteps.length > 0 ? (
- Didaktische Roadmap (Phase F) -

- 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). +

+ Didaktische Roadmap — bearbeiten + {roadmapDirty ? ( + + Geändert — bitte erneut matchen + + ) : pathSteps.length > 0 ? ( + + Gematcht + + ) : null} +
+

+ {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“.

-
    - {progressionRoadmap.roadmap.major_steps.map((step) => ( -
  1. - - {step.phase} - - {step.learning_goal} -
  2. +
    + {editableMajorSteps.map((step, idx) => ( +
    +
    + + +
    +
    + + patchMajorStep(idx, { learning_goal: e.target.value })} + placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri" + disabled={disabled || loading || saving} + /> +
    +
    + + + +
    +
    ))} -
+
+ {editableMajorSteps.length < PATH_STEPS_HARD_MAX ? ( + + ) : null} ) : null} @@ -748,11 +999,18 @@ export default function ExerciseProgressionPathBuilder({
+ {step.roadmapLearningGoal ? ( +

+ Ziel: {step.roadmapLearningGoal} +

+ ) : null}
{step.exerciseTitle} {step.exerciseId ? (