Enhance Planning Exercise Profiles and Context Handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- 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.
This commit is contained in:
Lars 2026-05-22 23:00:31 +02:00
parent 8e68261bc1
commit 04cc77d501
8 changed files with 361 additions and 18 deletions

View File

@ -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))

View File

@ -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,
}

View File

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

View File

@ -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",'

View File

@ -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",

View File

@ -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 ? (
<span className="exercise-tag">{planningContextSummary.section_title}</span>
) : null}
{planningContextSummary.section_exercise_count != null ? (
<span className="exercise-tag">
{planningContextSummary.section_exercise_count} Übungen im Abschnitt
</span>
) : null}
{planningContextSummary.last_section_exercise_title ? (
<span className="exercise-tag">
Letzte: {planningContextSummary.last_section_exercise_title}
</span>
) : null}
{planningContextSummary.planned_count != null ? (
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
) : null}
@ -687,6 +718,19 @@ export default function ExercisePickerModal({
))
: null}
</div>
{planningContextSummary.section_guidance_notes ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
Abschnitt: {planningContextSummary.section_guidance_notes}
</p>
) : null}
{planningContextSummary.expectation_mode ? (
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Erwartungsprofil:{' '}
{planningContextSummary.expectation_mode === 'query_only'
? 'nur Suchtext'
: 'Planung + optional Suchtext'}
</p>
) : null}
{planningTargetProfileSummary?.has_skill_gap ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Skill-Lücke zum bisherigen Plan berücksichtigt

View File

@ -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,

View File

@ -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,