From 9dd44ce3ca90bf692871a8e3b50067f6df53b609 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 9 Jun 2026 11:10:46 +0200 Subject: [PATCH] Add Structured Roadmap Inputs and Enhance Goal Analysis Features - Introduced `RoadmapStructuredInput` to encapsulate structured inputs for start situation, target state, and roadmap notes. - Updated `ProgressionPathSuggestRequest` to include new fields for structured roadmap inputs. - Implemented parsing logic for goal queries to extract start and target states, enhancing the goal analysis process. - Enhanced `build_goal_analysis` to utilize structured inputs, improving the clarity and relevance of generated goals. - Updated the `ExerciseProgressionPathBuilder` component to support new structured input fields, enhancing user experience. - Incremented application version to 0.8.210 to reflect these changes. --- backend/planning_exercise_path_builder.py | 20 ++ backend/planning_progression_roadmap.py | 230 ++++++++++++++++-- .../test_planning_progression_roadmap.py | 58 +++++ backend/version.py | 4 +- .../ExerciseProgressionPathBuilder.jsx | 134 ++++++++++ 5 files changed, 428 insertions(+), 18 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index d2ca3f8..eb76e66 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -54,6 +54,7 @@ from planning_progression_roadmap import ( MajorStep, ProgressionRoadmapContext, RoadmapOverridePayload, + RoadmapStructuredInput, StageSpecArtifact, build_roadmap_unfilled_gap_specs, progression_roadmap_to_api_dict, @@ -78,10 +79,26 @@ class ProgressionPathSuggestRequest(BaseModel): roadmap_first: bool = False roadmap_only: bool = False roadmap_override: Optional[RoadmapOverridePayload] = None + start_situation: Optional[str] = Field(default=None, max_length=2000) + target_state: Optional[str] = Field(default=None, max_length=2000) + roadmap_notes: Optional[str] = Field(default=None, max_length=2000) progression_graph_id: Optional[int] = Field(default=None, ge=1) exercise_kind_any: Optional[List[str]] = None +def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]: + start = (body.start_situation or "").strip() or None + target = (body.target_state or "").strip() or None + notes = (body.roadmap_notes or "").strip() or None + if not any([start, target, notes]): + return None + return RoadmapStructuredInput( + start_situation=start, + target_state=target, + roadmap_notes=notes, + ) + + def _pick_best_path_hit( hits: List[Dict[str, Any]], used_exercise_ids: Set[int], @@ -502,6 +519,7 @@ def suggest_progression_path( progression_roadmap: Optional[Dict[str, Any]] = None roadmap_ctx: Optional[ProgressionRoadmapContext] = None roadmap_edited = False + roadmap_structured = _roadmap_structured_from_body(body) if body.roadmap_override is not None: try: @@ -510,6 +528,7 @@ def suggest_progression_path( max_steps=max_steps, semantic_brief=semantic_brief, override=body.roadmap_override, + structured=roadmap_structured, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -525,6 +544,7 @@ def suggest_progression_path( semantic_brief=semantic_brief, cur=cur, include_llm_roadmap=body.include_llm_roadmap, + structured=roadmap_structured, ) progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx) diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index 90276c3..a4463d6 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -109,6 +109,14 @@ class StageSpecArtifact(BaseModel): anti_patterns: List[str] = Field(default_factory=list) +class RoadmapStructuredInput(BaseModel): + """Optionale Strukturierung: Start, Ziel, Ergänzungen (Progressionsgraph, kein Gruppen-Tracking).""" + + start_situation: Optional[str] = Field(default=None, max_length=2000) + target_state: Optional[str] = Field(default=None, max_length=2000) + roadmap_notes: Optional[str] = Field(default=None, max_length=2000) + + class RoadmapOverridePayload(BaseModel): """Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI.""" @@ -116,6 +124,9 @@ class RoadmapOverridePayload(BaseModel): stage_specs: Optional[List[StageSpecArtifact]] = None +_GENERIC_START_MARKER = "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert" + + class ProgressionRoadmapContext(BaseModel): goal_query: str max_steps: int = Field(ge=2, le=10, default=5) @@ -268,34 +279,177 @@ def _topic_label(brief: PlanningSemanticBrief) -> str: return (brief.primary_topic or brief.retrieval_query or "Technik").strip() +_PHASE_TOPIC_WORDS = frozenset( + {"einstieg", "grundlage", "vertiefung", "anwendung", "perfektion", "technik"} +) + + +def _extract_topic_from_goal_query(goal_query: str, brief: PlanningSemanticBrief) -> str: + q = (goal_query or "").strip() + m = re.match(r"^(.+?)\s+von\s+(?:der|die|dem|das|einer?|einem)\s+", q, flags=re.IGNORECASE) + if m: + cand = m.group(1).strip().rstrip(".,;") + if len(cand) >= 3: + return cand + topic = _topic_label(brief) + if topic and topic.lower() not in _PHASE_TOPIC_WORDS and len(topic) >= 4: + return topic + return topic or "Technik" + + +def parse_start_target_from_goal_query(goal_query: str) -> Tuple[Optional[str], Optional[str]]: + """„von … bis …“ aus Freitext (z. B. Kumite Beinarbeit von X bis Y).""" + q = (goal_query or "").strip() + if not q: + return None, None + m = re.search( + r"\bvon\s+((?:(?:der|die|dem|das|einer?|einem)\s+)?.+?)\s+bis\s+" + r"(?:zur?|zum|zu der|zu einem)?\s*(.+?)\s*$", + q, + flags=re.IGNORECASE | re.DOTALL, + ) + if not m: + return None, None + start = m.group(1).strip().rstrip(".,;") + target = m.group(2).strip().rstrip(".,;") + if len(start) < 4 or len(target) < 4: + return None, None + return start[:800], target[:800] + + +def _roadmap_llm_goal_block( + goal_query: str, + *, + structured: Optional[RoadmapStructuredInput] = None, + parsed_start: Optional[str] = None, + parsed_target: Optional[str] = None, +) -> str: + """Reicher Kontext für Roadmap-LLM ohne zwingend neue Prompt-Migration.""" + lines = [f"Gesamtanfrage: {(goal_query or '').strip()}"] + start = (structured.start_situation if structured else None) or parsed_start + target = (structured.target_state if structured else None) or parsed_target + notes = structured.roadmap_notes if structured else None + if start: + lines.append(f"Ausgangslage/Startpunkt: {start.strip()}") + if target: + lines.append(f"Zielzustand: {target.strip()}") + if notes and notes.strip(): + lines.append(f"Ergänzungen (Fokus, Gruppe, Besonderheiten): {notes.strip()}") + return "\n".join(lines) + + def build_goal_analysis( goal_query: str, brief: PlanningSemanticBrief, + *, + structured: Optional[RoadmapStructuredInput] = None, ) -> GoalAnalysisArtifact: - """Phase A — deterministisch aus Anfrage + Semantic Brief.""" - topic = _topic_label(brief) - target = goal_query.strip() or f"Entwicklung {topic}" + """Phase A — aus Anfrage, optionalen Feldern und Semantic Brief.""" + topic = _extract_topic_from_goal_query(goal_query, brief) + parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query) + + start = (structured.start_situation if structured else None) or parsed_start + target = (structured.target_state if structured else None) or parsed_target + notes = (structured.roadmap_notes if structured else None) or "" + + if not target: + target = goal_query.strip() or f"Entwicklung {topic}" + arc = list(brief.development_arc or []) start_phase = arc[0] if arc else "grundlage" target_phase = arc[-1] if arc else "perfektion" + + if start: + start_assumption = start.strip() + else: + start_assumption = ( + f"Einstieg auf Niveau „{start_phase}“ — {_GENERIC_START_MARKER} " + "(erst Trainingsplanung)." + ) + criteria: List[str] = [] if brief.must_phrases: criteria.extend(brief.must_phrases[:3]) if topic: criteria.append(f"klarer Bezug zu {topic}") + if start and target: + criteria.append(f"nachvollziehbarer Übergang von „{start[:80]}“ zu „{target[:80]}“") + if notes.strip(): + criteria.append(f"Berücksichtigung: {notes.strip()[:200]}") + + constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False} + if notes.strip(): + constraints["trainer_notes"] = notes.strip()[:500] return GoalAnalysisArtifact( primary_topic=topic, - start_assumption=( - f"Einstieg auf Niveau „{start_phase}“ — Voraussetzungen der Zielgruppe werden im " - "Progressionsgraphen nicht analysiert (erst Trainingsplanung)." - ), - target_state=target, + start_assumption=start_assumption, + target_state=target.strip(), success_criteria=criteria or [f"sichere Entwicklung Richtung {target_phase}"], - constraints={"partner_required": False, "group_analysis": False}, + constraints=constraints, ) +def _has_specific_start_target(goal_analysis: GoalAnalysisArtifact) -> bool: + start = (goal_analysis.start_assumption or "").strip() + target = (goal_analysis.target_state or "").strip() + if _GENERIC_START_MARKER in start: + return False + return len(start) >= 6 and len(target) >= 6 and start != target + + +def _target_facets(target: str) -> List[str]: + parts = re.split(r"\s+und\s+|\s+mit\s+|,\s*", target, flags=re.IGNORECASE) + out: List[str] = [] + for p in parts: + s = p.strip().rstrip(".,;") + if len(s) >= 5 and s.lower() not in {x.lower() for x in out}: + out.append(s[:200]) + return out[:6] + + +def develop_micro_objectives_from_start_target( + goal_analysis: GoalAnalysisArtifact, + *, + min_count: int, +) -> List[MicroObjective]: + """Zwischenziele entlang Start → Ziel (heuristisch, themenspezifisch).""" + topic = goal_analysis.primary_topic or "Technik" + start = goal_analysis.start_assumption.strip() + target = goal_analysis.target_state.strip() + facets = _target_facets(target) + phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"] + + titles: List[str] = [f"{topic}: Ausgang — {start}"] + n_middle = max(0, min_count - 2) + for i in range(n_middle): + if facets and i < len(facets): + titles.append(f"{topic}: {facets[i]} — schrittweise einführen") + else: + titles.append( + f"{topic}: Übergangsschritt {i + 1} — Annäherung vom Ausgang zum Ziel" + ) + titles.append(f"{topic}: Ziel — {target}") + + while len(titles) < min_count: + titles.insert(max(1, len(titles) - 1), f"{topic}: Vertiefung vor Zielerreichung") + + titles = titles[:max(min_count, 2)] + micro: List[MicroObjective] = [] + for i, title in enumerate(titles): + phase = phases[min(i, len(phases) - 1)] + micro.append( + MicroObjective( + id=f"m{i + 1}", + phase=phase, + title=title, + weight=0.9 if i in (0, len(titles) - 1) else 0.85, + depends_on=[f"m{i}"] if i > 0 else [], + ) + ) + return micro + + def _micro_title_for_phase(phase: str, topic: str) -> str: p = (phase or "vertiefung").lower() labels = { @@ -314,7 +468,10 @@ def develop_micro_objectives( goal_analysis: GoalAnalysisArtifact, min_count: int = 6, ) -> List[MicroObjective]: - """Phase B1 — Zwischenziele (heuristisch aus development_arc).""" + """Phase B1 — Zwischenziele (Start→Ziel oder development_arc-Fallback).""" + if _has_specific_start_target(goal_analysis): + return develop_micro_objectives_from_start_target(goal_analysis, min_count=min_count) + topic = goal_analysis.primary_topic or _topic_label(brief) arc = [str(p).lower() for p in (brief.development_arc or []) if str(p).strip()] seen_phases: set = set() @@ -592,11 +749,12 @@ def roadmap_context_from_override( max_steps: int, semantic_brief: PlanningSemanticBrief, override: RoadmapOverridePayload, + structured: Optional[RoadmapStructuredInput] = None, ) -> 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) + goal_analysis = build_goal_analysis(goal_query, semantic_brief, structured=structured) stage_specs: List[StageSpecArtifact] if override.stage_specs and len(override.stage_specs) >= effective_max: stage_specs = [] @@ -632,6 +790,31 @@ def roadmap_context_from_override( ) +def _merge_structured_into_goal_analysis( + llm_ga: GoalAnalysisArtifact, + *, + goal_query: str, + brief: PlanningSemanticBrief, + structured: Optional[RoadmapStructuredInput], +) -> GoalAnalysisArtifact: + ga_struct = build_goal_analysis(goal_query, brief, structured=structured) + if not _has_specific_start_target(ga_struct): + return llm_ga + merged_criteria = list( + dict.fromkeys((llm_ga.success_criteria or []) + (ga_struct.success_criteria or [])) + )[:6] + merged_constraints = {**(llm_ga.constraints or {}), **(ga_struct.constraints or {})} + return llm_ga.model_copy( + update={ + "primary_topic": ga_struct.primary_topic or llm_ga.primary_topic, + "start_assumption": ga_struct.start_assumption, + "target_state": ga_struct.target_state, + "success_criteria": merged_criteria, + "constraints": merged_constraints, + } + ) + + def run_progression_roadmap_pipeline( goal_query: str, *, @@ -639,20 +822,33 @@ def run_progression_roadmap_pipeline( semantic_brief: Optional[PlanningSemanticBrief] = None, cur=None, include_llm_roadmap: bool = False, + structured: Optional[RoadmapStructuredInput] = None, ) -> ProgressionRoadmapContext: """Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback.""" brief = semantic_brief or build_semantic_brief(goal_query) + parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query) + llm_goal_query = _roadmap_llm_goal_block( + goal_query, + structured=structured, + parsed_start=parsed_start, + parsed_target=parsed_target, + ) ctx = ProgressionRoadmapContext( goal_query=goal_query.strip(), max_steps=max_steps, semantic_brief=brief_to_summary_dict(brief), ) - goal_analysis = build_goal_analysis(goal_query, brief) + goal_analysis = build_goal_analysis(goal_query, brief, structured=structured) if include_llm_roadmap and cur is not None: - llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=goal_query, brief=brief) + llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief) if ga_ok and llm_ga: - goal_analysis = llm_ga + goal_analysis = _merge_structured_into_goal_analysis( + llm_ga, + goal_query=goal_query, + brief=brief, + structured=structured, + ) ctx.llm_goal_analysis_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_GOAL_ANALYSIS) ctx.goal_analysis = goal_analysis @@ -661,7 +857,7 @@ def run_progression_roadmap_pipeline( if include_llm_roadmap and cur is not None: llm_rm, rm_ok = try_llm_roadmap( cur, - goal_query=goal_query, + goal_query=llm_goal_query, brief=brief, goal_analysis=goal_analysis, max_steps=max_steps, @@ -689,7 +885,7 @@ def run_progression_roadmap_pipeline( if include_llm_roadmap and cur is not None: llm_specs, spec_ok = try_llm_stage_specs( cur, - goal_query=goal_query, + goal_query=llm_goal_query, goal_analysis=goal_analysis, major_steps=roadmap.major_steps, ) @@ -734,7 +930,9 @@ __all__ = [ "ProgressionRoadmapContext", "RoadmapArtifact", "RoadmapOverridePayload", + "RoadmapStructuredInput", "normalize_major_steps_for_override", + "parse_start_target_from_goal_query", "roadmap_context_from_override", "StageSpecArtifact", "build_goal_analysis", diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py index dfbf144..a6cb3de 100644 --- a/backend/tests/test_planning_progression_roadmap.py +++ b/backend/tests/test_planning_progression_roadmap.py @@ -4,11 +4,13 @@ from planning_progression_roadmap import ( PROMPT_SLUG_ROADMAP, PROMPT_SLUG_STAGE_SPEC, MajorStep, + RoadmapStructuredInput, StageSpecArtifact, build_goal_analysis, build_roadmap_unfilled_gap_specs, consolidate_micro_to_major, develop_micro_objectives, + parse_start_target_from_goal_query, progression_roadmap_to_api_dict, resolve_step_exercise_kind_filter, run_progression_roadmap_pipeline, @@ -20,6 +22,11 @@ from planning_progression_roadmap import ( ) from planning_exercise_semantics import build_semantic_brief +KUMITE_GOAL = ( + "Kumite Beinarbeit von einer gleichartigen Steppbewegung bis zur dynamischen " + "unvorhersehbaren Bewegung mit explosivartigem Angriff und ausweichen" +) + def test_run_progression_roadmap_pipeline_major_step_count(): ctx = run_progression_roadmap_pipeline( @@ -134,3 +141,54 @@ def test_api_dict_exposes_prompt_slug_catalog(): assert api["prompt_slug_catalog"]["roadmap"] == PROMPT_SLUG_ROADMAP assert api["prompt_slug_catalog"]["stage_spec"] == PROMPT_SLUG_STAGE_SPEC assert api["prompt_slugs"] == [] + + +def test_parse_start_target_kumite_beinarbeit(): + start, target = parse_start_target_from_goal_query(KUMITE_GOAL) + assert start is not None + assert "Steppbewegung" in start + assert target is not None + assert "dynamischen" in target + assert "Angriff" in target + + +def test_build_goal_analysis_uses_parsed_start_target(): + brief = build_semantic_brief(KUMITE_GOAL) + ga = build_goal_analysis(KUMITE_GOAL, brief) + assert "Kumite Beinarbeit" in ga.primary_topic + assert "Steppbewegung" in ga.start_assumption + assert "dynamischen" in ga.target_state + assert "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert" not in ga.start_assumption + + +def test_build_goal_analysis_structured_fields_override(): + brief = build_semantic_brief("Kumite Beinarbeit") + structured = RoadmapStructuredInput( + start_situation="statische Vorwärtsbewegung im Partnerdrill", + target_state="explosiver Gegenangriff nach unvorhersehbarer Beinarbeit", + roadmap_notes="Kindergruppe 10–12 Jahre", + ) + ga = build_goal_analysis("Kumite Beinarbeit", brief, structured=structured) + assert ga.start_assumption == structured.start_situation + assert ga.target_state == structured.target_state + assert any("Kindergruppe" in c for c in ga.success_criteria) + + +def test_develop_micro_objectives_start_target_kumite(): + brief = build_semantic_brief(KUMITE_GOAL) + ga = build_goal_analysis(KUMITE_GOAL, brief) + micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=6) + titles = [m.title for m in micro] + assert any("Ausgang" in t for t in titles) + assert any("Ziel" in t for t in titles) + assert not any("Einstieg und Orientierung zum Thema" in t for t in titles) + + +def test_pipeline_kumite_major_steps_not_generic_templates(): + ctx = run_progression_roadmap_pipeline(KUMITE_GOAL, max_steps=5, include_llm_roadmap=False) + goals = [s.learning_goal for s in ctx.roadmap.major_steps] + joined = " ".join(goals).lower() + assert "kumite beinarbeit" in joined + assert "steppbewegung" in joined or "ausgang" in joined + assert "dynamisch" in joined or "ziel" in joined + assert not any(g == "Grundstellung und Basisbewegung" for g in goals) diff --git a/backend/version.py b/backend/version.py index f0d45b9..1b65436 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.209" +APP_VERSION = "0.8.210" 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.20.1", # F3-Polish: roadmap_first QA lite (keine Brücken/Reorder) + "planning_exercise_suggest": "0.20.2", # Strukturierte Roadmap-Eingaben Start/Ziel + von-bis-Parsing "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 diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 57c5bae..38d4103 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -12,6 +12,31 @@ import { } from '../utils/exerciseAiQuickCreate' import { buildPathGapPlanningContextForAi } from '../utils/planningContextForExerciseAi' +/** „von … bis …“ aus Freitext (z. B. Kumite Beinarbeit von X bis Y). */ +function parseStartTargetFromGoalQuery(q) { + const text = String(q || '').trim() + if (!text) return { start: '', target: '' } + const m = text.match( + /\bvon\s+((?:(?:der|die|dem|das|einer?|einem)\s+)?.+?)\s+bis\s+(?:zur?|zum|zu der|zu einem)?\s*(.+)$/is, + ) + if (!m) return { start: '', target: '' } + const start = m[1].trim().replace(/[.,;]+$/, '') + const target = m[2].trim().replace(/[.,;]+$/, '') + if (start.length < 4 || target.length < 4) return { start: '', target: '' } + return { start, target } +} + +function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { + const start = (startSituation || '').trim() + const target = (targetState || '').trim() + const notes = (roadmapNotes || '').trim() + const body = {} + if (start) body.start_situation = start + if (target) body.target_state = target + if (notes) body.roadmap_notes = notes + return body +} + function emptyPathStep() { return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] } } @@ -154,6 +179,9 @@ export default function ExerciseProgressionPathBuilder({ onSaved, }) { const [goalQuery, setGoalQuery] = useState('') + const [startSituation, setStartSituation] = useState('') + const [targetState, setTargetState] = useState('') + const [roadmapNotes, setRoadmapNotes] = useState('') const [maxSteps, setMaxSteps] = useState(5) const [segmentNotes, setSegmentNotes] = useState('') const [saving, setSaving] = useState(false) @@ -507,6 +535,19 @@ export default function ExerciseProgressionPathBuilder({ alert('Zuerst einen Graphen wählen.') return } + let start = startSituation.trim() + let target = targetState.trim() + if (!start || !target) { + const parsed = parseStartTargetFromGoalQuery(q) + if (parsed.start && !start) { + start = parsed.start + setStartSituation(parsed.start) + } + if (parsed.target && !target) { + target = parsed.target + setTargetState(parsed.target) + } + } setLoadingRoadmap(true) setError('') try { @@ -522,6 +563,7 @@ export default function ExerciseProgressionPathBuilder({ include_llm_roadmap: true, roadmap_only: true, progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(start, target, roadmapNotes), }) const majors = mapMajorStepsFromApi(res?.progression_roadmap) if (majors.length < 2) { @@ -578,6 +620,7 @@ export default function ExerciseProgressionPathBuilder({ roadmap_first: true, roadmap_override: override, progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), }) applyPathMatchResponse(res, q) setMaxSteps(validSteps.length) @@ -680,6 +723,53 @@ export default function ExerciseProgressionPathBuilder({ disabled={disabled || loading || saving} /> + +
+
+ +