From 04cc77d5011dc2deaef4a98d57cb4629a7329b06 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 23:00:31 +0200 Subject: [PATCH] Enhance Planning Exercise Profiles and Context Handling - Introduced new functions to generate skill profiles from exercise IDs, improving the ability to summarize skills for both units and sections. - Updated the planning target profile to incorporate section-specific exercise IDs, allowing for more granular skill tracking and context. - Enhanced the ExercisePickerModal and related pages to support section context, including titles, guidance notes, and exercise counts. - Implemented expectation mode handling in the planning target pipeline to differentiate between planning references and query-only scenarios. - Incremented version to 0.8.174 and updated changelog to reflect these enhancements in planning AI capabilities. --- backend/planning_exercise_profiles.py | 46 +++++ backend/planning_exercise_suggest.py | 158 +++++++++++++++++- backend/planning_exercise_target_pipeline.py | 37 +++- .../tests/test_planning_exercise_suggest.py | 24 +++ backend/version.py | 12 +- .../src/components/ExercisePickerModal.jsx | 52 +++++- .../TrainingFrameworkProgramEditPage.jsx | 25 +++ frontend/src/pages/TrainingUnitEditPage.jsx | 25 +++ 8 files changed, 361 insertions(+), 18 deletions(-) diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py index b346cba..26df100 100644 --- a/backend/planning_exercise_profiles.py +++ b/backend/planning_exercise_profiles.py @@ -293,11 +293,50 @@ def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]: return _skill_weights_from_profile(prof.get("skills") or []) +def _profile_from_exercise_ids(cur, exercise_ids: Sequence[int]) -> Dict[int, float]: + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {} + occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + return _skill_weights_from_profile(prof.get("skills") or []) + + +def skill_profile_summary_from_exercise_ids( + cur, + exercise_ids: Sequence[int], + *, + limit_skills: int = 8, +) -> Dict[str, Any]: + """Kompaktes Fähigkeitenprofil für LLM-Kontext und UI.""" + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {"exercise_count": 0, "skills": []} + occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + skills_out = prof.get("skills") or [] + top = sorted(skills_out, key=lambda s: -float(s.get("weight") or s.get("score") or 0))[:limit_skills] + names = _load_skill_names(cur, [int(s["skill_id"]) for s in top if s.get("skill_id") is not None]) + return { + "exercise_count": len(ids), + "skills": [ + { + "skill_id": int(s["skill_id"]), + "name": names.get(int(s["skill_id"]), f"#{s['skill_id']}"), + "weight": round(float(s.get("weight") or s.get("score") or 0), 3), + } + for s in top + if s.get("skill_id") is not None + ], + } + + def build_planning_target_profile( cur, *, unit: Dict[str, Any], planned_exercise_ids: Sequence[int], + section_planned_exercise_ids: Optional[Sequence[int]] = None, anchor_exercise_id: Optional[int], intent: str, ) -> PlanningTargetProfile: @@ -356,6 +395,13 @@ def build_planning_target_profile( if skill_plan: sources.append("current_unit_plan") + section_ids = [int(x) for x in (section_planned_exercise_ids or []) if int(x) > 0] + if section_ids: + section_skills = _profile_from_exercise_ids(cur, section_ids) + if section_skills: + skill_target = _merge_weight_maps(skill_target, section_skills, scale=1.0) + sources.append("current_section_plan") + if anchor_exercise_id: anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)]) ap = anchor_profiles.get(int(anchor_exercise_id)) diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index c9f0277..ffb53a0 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from pydantic import BaseModel, Field from tenant_context import TenantContext, library_content_visibility_sql +from planning_exercise_profiles import skill_profile_summary_from_exercise_ids from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_llm_rank import try_llm_rerank_planning_hits from planning_exercise_target_pipeline import ( @@ -56,6 +57,9 @@ class PlanningExerciseSuggestRequest(BaseModel): query: Optional[str] = "" intent_hint: Optional[str] = None planned_exercise_ids: Optional[List[int]] = None + section_title: Optional[str] = None + section_guidance_notes: Optional[str] = None + section_planned_exercise_ids: Optional[List[int]] = None include_llm_intent: bool = True include_llm_rank: bool = False limit: int = Field(default=20, ge=1, le=50) @@ -241,6 +245,131 @@ def _load_group_recent_exercise_ids( return out +def _section_for_context( + sections: Sequence[Dict[str, Any]], + section_order_index: Optional[int], +) -> Optional[Dict[str, Any]]: + if section_order_index is None: + return None + target = int(section_order_index) + for sec in sections: + if int(sec.get("order_index") or -1) == target: + return sec + if 0 <= target < len(sections): + return sections[target] + return None + + +def _collect_exercise_ids_from_section(sec: Optional[Dict[str, Any]]) -> List[int]: + if not sec: + return [] + out: List[int] = [] + seen: Set[int] = set() + for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)): + if str(it.get("item_type") or "").strip().lower() == "note": + continue + raw = it.get("exercise_id") + if raw is None: + continue + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid in seen: + continue + seen.add(eid) + out.append(eid) + return out + + +def _resolve_last_exercise_in_section(sec: Optional[Dict[str, Any]]) -> Tuple[Optional[int], Optional[str]]: + if not sec: + return None, None + last_id: Optional[int] = None + last_title: Optional[str] = None + for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)): + if str(it.get("item_type") or "").strip().lower() == "note": + continue + raw = it.get("exercise_id") + if raw is None: + continue + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1: + continue + last_id = eid + t = (it.get("exercise_title") or "").strip() + last_title = t or None + return last_id, last_title + + +def _attach_planning_context_details( + cur, + pack: Dict[str, Any], + *, + sections: Optional[Sequence[Dict[str, Any]]] = None, + body: Optional[PlanningExerciseSuggestRequest] = None, +) -> Dict[str, Any]: + """Abschnitt, Fähigkeitenprofile und letzte Übung anreichern.""" + sec: Optional[Dict[str, Any]] = None + section_idx = pack.get("section_order_index") + if sections is not None and section_idx is not None: + sec = _section_for_context(sections, section_idx) + + section_ids = _collect_exercise_ids_from_section(sec) + if body and body.section_planned_exercise_ids: + section_ids = [] + seen: Set[int] = set() + for raw in body.section_planned_exercise_ids: + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid in seen: + continue + seen.add(eid) + section_ids.append(eid) + elif pack.get("section_planned_exercise_ids"): + section_ids = list(pack.get("section_planned_exercise_ids") or []) + + section_title = pack.get("section_title") + if body and (body.section_title or "").strip(): + section_title = (body.section_title or "").strip() + elif sec and (sec.get("title") or "").strip(): + section_title = (sec.get("title") or "").strip() + + guidance = None + if body and (body.section_guidance_notes or "").strip(): + guidance = (body.section_guidance_notes or "").strip() + elif sec and (sec.get("guidance_notes") or "").strip(): + guidance = (sec.get("guidance_notes") or "").strip() + + last_in_section_id, last_in_section_title = _resolve_last_exercise_in_section(sec) + if body and not last_in_section_id and pack.get("anchor_exercise_id"): + last_in_section_id = pack.get("anchor_exercise_id") + last_in_section_title = pack.get("anchor_title") + + unit_ids = list(pack.get("planned_exercise_ids") or []) + pack["section_title"] = section_title + pack["section_guidance_notes"] = guidance + pack["section_planned_exercise_ids"] = section_ids + pack["section_exercise_count"] = len(section_ids) + pack["last_section_exercise_id"] = last_in_section_id + pack["last_section_exercise_title"] = last_in_section_title + pack["unit_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, unit_ids) + pack["section_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, section_ids) + pack["has_planning_reference"] = bool( + unit_ids + or section_ids + or pack.get("anchor_exercise_id") + or (pack.get("unit") or {}).get("framework_slot_id") + or (pack.get("unit") or {}).get("origin_framework_slot_id") + ) + return pack + + def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]: if section_order_index is None: return None @@ -348,7 +477,7 @@ def build_planning_exercise_context_pack( titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x]) anchor_title = titles.get(anchor_id) if anchor_id else None - return { + pack = { "unit_id": int(body.unit_id), "unit": { "id": int(body.unit_id), @@ -369,6 +498,7 @@ def build_planning_exercise_context_pack( "progression_edge_notes": progression_notes, "group_recent_exercise_ids": sorted(group_recent), } + return _attach_planning_context_details(cur, pack, sections=sections, body=body) def build_client_planning_context_pack( @@ -413,7 +543,7 @@ def build_client_planning_context_pack( titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x]) anchor_title = titles.get(anchor_id) if anchor_id else None - return { + pack = { "unit_id": None, "unit": { "id": None, @@ -424,7 +554,7 @@ def build_client_planning_context_pack( "group_id": group_id, "group_name": group_name, "section_order_index": body.section_order_index, - "section_title": None, + "section_title": (body.section_title or "").strip() or None, "planned_exercise_ids": planned_ids, "anchor_exercise_id": anchor_id, "anchor_title": anchor_title, @@ -435,6 +565,7 @@ def build_client_planning_context_pack( "group_recent_exercise_ids": sorted(group_recent), "context_mode": "client_free", } + return _attach_planning_context_details(cur, pack, sections=None, body=body) def suggest_planning_exercises( @@ -448,27 +579,40 @@ def suggest_planning_exercises( else: pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) + pack = _attach_planning_context_details(cur, pack, body=body) query = _normalize_query(body.query) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) + has_plan_ref = bool(pack.get("has_planning_reference")) + expectation_mode = "planning_hybrid" if has_plan_ref else "query_only" + pipeline_context = { "unit_title": pack.get("unit_title"), "group_name": pack.get("group_name"), "section_title": pack.get("section_title"), + "section_guidance_notes": pack.get("section_guidance_notes"), + "section_exercise_count": pack.get("section_exercise_count"), "planned_count": len(pack.get("planned_exercise_ids") or []), "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_id"), + "last_section_exercise_title": pack.get("last_section_exercise_title"), "progression_graph_id": pack.get("progression_graph_id"), + "unit_skill_profile": pack.get("unit_skill_profile_summary"), + "section_skill_profile": pack.get("section_skill_profile_summary"), + "has_planning_reference": has_plan_ref, + "expectation_mode": expectation_mode, } target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline( cur, unit=pack["unit"], planned_exercise_ids=pack["planned_exercise_ids"], + section_planned_exercise_ids=pack.get("section_planned_exercise_ids") or [], anchor_exercise_id=pack.get("anchor_exercise_id"), query=query, heuristic_intent=heuristic_intent, include_llm_intent=body.include_llm_intent, context_summary=pipeline_context, + has_planning_reference=has_plan_ref, ) weights = _intent_weights(intent) target_profile_summary = target_profile.to_summary_dict(cur) @@ -549,11 +693,18 @@ def suggest_planning_exercises( "unit_title": pack.get("unit_title"), "group_name": pack.get("group_name"), "section_title": pack.get("section_title"), + "section_guidance_notes": pack.get("section_guidance_notes"), + "section_exercise_count": pack.get("section_exercise_count"), "planned_count": len(planned_set), "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_id"), + "last_section_exercise_title": pack.get("last_section_exercise_title"), "progression_graph_id": pack.get("progression_graph_id"), "context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"), + "unit_skill_profile": pack.get("unit_skill_profile_summary"), + "section_skill_profile": pack.get("section_skill_profile_summary"), + "has_planning_reference": pack.get("has_planning_reference"), + "expectation_mode": expectation_mode, } return { @@ -568,5 +719,6 @@ def suggest_planning_exercises( "intent_resolved": intent, "intent_heuristic": heuristic_intent, "query_normalized": query or None, + "expectation_mode": expectation_mode, "hits": hits, } diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 6abce68..182c90d 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -224,14 +224,19 @@ def build_planning_target_with_query_pipeline( *, unit: Dict[str, Any], planned_exercise_ids: List[int], + section_planned_exercise_ids: Optional[List[int]] = None, anchor_exercise_id: Optional[int], query: Optional[str], heuristic_intent: str, include_llm_intent: bool, context_summary: Mapping[str, Any], + has_planning_reference: bool = True, ) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]: """ Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict + + Ohne Planungsbezug (keine Übungen/Anker/Rahmen): Erwartungsprofil primär aus Suchtext (query_only). + Mit Planungsbezug: hybrid aus Plan + optional Query-Overlay. """ scenario = classify_planning_scenario(query, heuristic_intent) resolved_intent = heuristic_intent @@ -239,13 +244,18 @@ def build_planning_target_with_query_pipeline( parsed: Optional[PlanningQueryIntentParsed] = None resolved_skills: List[Dict[str, Any]] = [] - base = build_planning_target_profile( - cur, - unit=unit, - planned_exercise_ids=planned_exercise_ids, - anchor_exercise_id=anchor_exercise_id, - intent=heuristic_intent, - ) + if has_planning_reference: + base = build_planning_target_profile( + cur, + unit=unit, + planned_exercise_ids=planned_exercise_ids, + section_planned_exercise_ids=section_planned_exercise_ids or [], + anchor_exercise_id=anchor_exercise_id, + intent=heuristic_intent, + ) + else: + base = PlanningTargetProfile(sources=["query_only"]) + base_summary = base.to_summary_dict(cur) if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent): @@ -273,6 +283,11 @@ def build_planning_target_with_query_pipeline( focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed) if focus or style or tt or tg or skills: + overlay_scenario = scenario + overlay_emphasis = parsed.emphasis + if not has_planning_reference: + overlay_scenario = SCENARIO_FREE_SEARCH + overlay_emphasis = "replace" target = merge_query_overlay_into_target( base, focus=focus, @@ -280,9 +295,12 @@ def build_planning_target_with_query_pipeline( tt=tt, tg=tg, skills=skills, - emphasis=parsed.emphasis, - scenario=scenario, + emphasis=overlay_emphasis, + scenario=overlay_scenario, ) + elif not has_planning_reference and _normalize_query(query): + # Kein LLM, aber Freitext: leichtes Profil bleibt leer — Retrieval nutzt Volltext + target = PlanningTargetProfile(sources=["query_only"]) query_intent_summary: Dict[str, Any] = { "scenario": scenario, @@ -293,6 +311,7 @@ def build_planning_target_with_query_pipeline( "rationale": (parsed.rationale if parsed else None), "skill_hints_resolved": resolved_skills, "requires_partner": parsed.requires_partner if parsed else None, + "expectation_mode": "planning_hybrid" if has_planning_reference else "query_only", } return target, resolved_intent, scenario, query_intent_summary diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 6daf854..93c6a5d 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -78,6 +78,30 @@ def test_compose_retrieval_phase(): ) +def test_query_only_expectation_without_planning_reference(): + from planning_exercise_profiles import PlanningTargetProfile + from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline + + class _Cur: + pass + + target, intent, scenario, summary = build_planning_target_with_query_pipeline( + _Cur(), + unit={"id": None, "framework_slot_id": None, "origin_framework_slot_id": None}, + planned_exercise_ids=[], + section_planned_exercise_ids=[], + anchor_exercise_id=None, + query="Partnerübung Reaktion", + heuristic_intent="free_search", + include_llm_intent=False, + context_summary={"expectation_mode": "query_only"}, + has_planning_reference=False, + ) + assert intent == "free_search" + assert summary.get("expectation_mode") == "query_only" + assert target.sources == ["query_only"] or "query_only" in target.sources + + def test_parse_planning_query_intent_response(): parsed = parse_planning_query_intent_response( '{"intent":"continue_plan_goal","scenario":"additive_constraint",' diff --git a/backend/version.py b/backend/version.py index 410f26a..f4beeaa 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.173" +APP_VERSION = "0.8.174" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531073" @@ -28,7 +28,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.5.0", # Mehrstufiges Profil-Retrieval; LLM-Gates (max 1 Call) + "planning_exercise_suggest": "0.6.0", # Abschnitts-/Skill-Kontext; expectation_mode hybrid|query_only "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 @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.174", + "date": "2026-05-22", + "changes": [ + "Planungs-KI: Abschnitts-Kontext (guidance_notes, Übungszahl, letzte Übung), Fähigkeitenprofil Einheit/Abschnitt an LLM.", + "Erwartungsprofil hybrid (Planungsbezug) vs. query_only (nur Suchtext); current_section_plan im Target-Profil.", + ], + }, { "version": "0.8.173", "date": "2026-05-22", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 333737c..7770f56 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -104,6 +104,13 @@ export default function ExercisePickerModal({ const base = { groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null, sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0, + sectionTitle: planningContext?.sectionTitle ?? null, + sectionGuidanceNotes: planningContext?.sectionGuidanceNotes ?? null, + sectionPlannedExerciseIds: Array.isArray(planningContext?.sectionPlannedExerciseIds) + ? planningContext.sectionPlannedExerciseIds + : [], + sectionExerciseCount: planningContext?.sectionExerciseCount ?? null, + lastExerciseTitle: planningContext?.lastExerciseTitle ?? null, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, anchorExerciseId: planningContext?.anchorExerciseId ?? null, @@ -424,10 +431,24 @@ export default function ExercisePickerModal({ if (resolvedPlanningUnitId) { requestBody.unit_id = Number(resolvedPlanningUnitId) } - if (activePlanningContext.groupId) { - requestBody.group_id = Number(activePlanningContext.groupId) - } - const res = await api.suggestPlanningExercises(requestBody) + if (activePlanningContext.groupId) { + requestBody.group_id = Number(activePlanningContext.groupId) + } + if (activePlanningContext.sectionTitle) { + requestBody.section_title = String(activePlanningContext.sectionTitle) + } + if (activePlanningContext.sectionGuidanceNotes) { + requestBody.section_guidance_notes = String(activePlanningContext.sectionGuidanceNotes) + } + if ( + Array.isArray(activePlanningContext.sectionPlannedExerciseIds) && + activePlanningContext.sectionPlannedExerciseIds.length > 0 + ) { + requestBody.section_planned_exercise_ids = activePlanningContext.sectionPlannedExerciseIds + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x) && x > 0) + } + const res = await api.suggestPlanningExercises(requestBody) setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) @@ -662,6 +683,16 @@ export default function ExercisePickerModal({ {planningContextSummary.section_title ? ( {planningContextSummary.section_title} ) : null} + {planningContextSummary.section_exercise_count != null ? ( + + {planningContextSummary.section_exercise_count} Übungen im Abschnitt + + ) : null} + {planningContextSummary.last_section_exercise_title ? ( + + Letzte: {planningContextSummary.last_section_exercise_title} + + ) : null} {planningContextSummary.planned_count != null ? ( {planningContextSummary.planned_count} Übungen im Plan ) : null} @@ -687,6 +718,19 @@ export default function ExercisePickerModal({ )) : null} + {planningContextSummary.section_guidance_notes ? ( +

+ Abschnitt: {planningContextSummary.section_guidance_notes} +

+ ) : null} + {planningContextSummary.expectation_mode ? ( +

+ Erwartungsprofil:{' '} + {planningContextSummary.expectation_mode === 'query_only' + ? 'nur Suchtext' + : 'Planung + optional Suchtext'} +

+ ) : null} {planningTargetProfileSummary?.has_skill_gap ? (

Skill-Lücke zum bisherigen Plan berücksichtigt diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index bc6f29c..7d75f82 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -286,10 +286,35 @@ export default function TrainingFrameworkProgramEditPage() { plannedExerciseIds.push(eid) } } + const sectionPlannedExerciseIds = [] + const seenSec = new Set() + for (const it of sec?.items || []) { + if (String(it?.item_type || '').toLowerCase() === 'note') continue + const eid = Number(it?.exercise_id) + if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue + seenSec.add(eid) + sectionPlannedExerciseIds.push(eid) + } + let lastExerciseTitle = null + if (sec?.items?.length) { + for (let i = sec.items.length - 1; i >= 0; i -= 1) { + const it = sec.items[i] + if (String(it?.item_type || '').toLowerCase() === 'note') continue + if (it?.exercise_id) { + lastExerciseTitle = (it.exercise_title || '').trim() || null + break + } + } + } return { unitId: null, groupId: null, sectionOrderIndex: sIdx, + sectionTitle: (sec?.title || '').trim() || null, + sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null, + sectionPlannedExerciseIds, + sectionExerciseCount: sectionPlannedExerciseIds.length, + lastExerciseTitle, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, plannedExerciseIds, diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 84ce9a7..22453a4 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -173,11 +173,36 @@ export default function TrainingUnitEditPage() { plannedExerciseIds.push(eid) } } + const sectionPlannedExerciseIds = [] + const seenSec = new Set() + for (const it of sec?.items || []) { + if (String(it?.item_type || '').toLowerCase() === 'note') continue + const eid = Number(it?.exercise_id) + if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue + seenSec.add(eid) + sectionPlannedExerciseIds.push(eid) + } + let lastExerciseTitle = null + if (sec?.items?.length) { + for (let i = sec.items.length - 1; i >= 0; i -= 1) { + const it = sec.items[i] + if (String(it?.item_type || '').toLowerCase() === 'note') continue + if (it?.exercise_id) { + lastExerciseTitle = (it.exercise_title || '').trim() || null + break + } + } + } const groupIdRaw = Number(formData.group_id) return { unitId: resolvedUnitId ? Number(resolvedUnitId) : null, groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null, sectionOrderIndex: sIdx, + sectionTitle: (sec?.title || '').trim() || null, + sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null, + sectionPlannedExerciseIds, + sectionExerciseCount: sectionPlannedExerciseIds.length, + lastExerciseTitle, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, plannedExerciseIds,