From 8f1dad53ab3227cd0170cbda6ed01f32ff69ed0b Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 11:17:53 +0200 Subject: [PATCH] Enhance Progression Path Suggestion Logic and UI Feedback - Updated `suggest_progression_path` to include AI-generated gap fill offers when exercises are missing, improving the relevance of suggested paths. - Introduced a match summary to provide insights on library matches and gap fill offers, enhancing user feedback in the `ProgressionGraphEditor`. - Refined the `pick_best_path_hit` function to ensure proper handling of roadmap stage matches based on primary topics. - Added tests to validate the new gap fill offer logic and match summary functionality, ensuring robustness in path suggestion features. --- backend/planning_exercise_path_builder.py | 60 ++++++++++++++++++- backend/planning_exercise_semantics.py | 5 +- .../test_planning_roadmap_stage_match.py | 26 ++++++++ .../src/components/ProgressionGraphEditor.jsx | 20 +++++++ frontend/src/utils/progressionGraphDraft.js | 18 ++++-- 5 files changed, 121 insertions(+), 8 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index d017fc0..0855cf2 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -1414,7 +1414,10 @@ def suggest_progression_path( anchor_id = eid anchor_variant_id = step.get("variant_id") - if len(steps) < 2: + stage_spec_count = len(roadmap_ctx.stage_specs or []) if roadmap_ctx else 0 + if roadmap_first and stage_spec_count >= 2: + pass + elif len(steps) < 2: raise HTTPException( status_code=422, detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.", @@ -1623,6 +1626,60 @@ def suggest_progression_path( roadmap_ctx=roadmap_ctx, max_steps=max_steps, ) + if body.include_ai_gap_fill: + seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers} + for step in steps: + if step.get("exercise_id") is not None: + continue + try: + major_idx = int(step["roadmap_major_step_index"]) + except (TypeError, ValueError, KeyError): + continue + stage_spec = next( + ( + s + for s in (roadmap_ctx.stage_specs or []) + if int(s.major_step_index) == major_idx + ), + None, + ) + if stage_spec is None: + continue + spec = { + "source": "roadmap_unfilled", + "insert_after_index": max(major_idx - 1, -1), + "roadmap_major_step_index": major_idx, + "phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(), + "title_hint": (stage_spec.learning_goal or step.get("title") or f"Slot {major_idx + 1}")[:120], + "sketch": (stage_spec.learning_goal or "").strip(), + "rationale": f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; KI-Entwurf für diese Stufe.", + } + offer = build_gap_fill_offer( + spec=spec, + steps=steps, + goal_query=goal_query, + brief=semantic_brief, + proposal=None, + roadmap_snapshot=_roadmap_gap_snapshot_for_spec( + cur, + roadmap_ctx, + spec, + goal_query=goal_query, + semantic_brief=semantic_brief, + ), + ) + if offer.get("offer_id") not in seen_offer_ids: + gap_fill_offers.append(offer) + seen_offer_ids.add(offer.get("offer_id")) + + filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None) + match_summary = { + "roadmap_first": roadmap_first, + "library_matches": filled_library_steps, + "slot_count": len(steps), + "gap_fill_offer_count": len(gap_fill_offers), + "roadmap_unfilled_count": len(roadmap_unfilled), + } target_profile_summary = path_target_profile.to_summary_dict(cur) retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"] @@ -1667,6 +1724,7 @@ def suggest_progression_path( "roadmap_edited": roadmap_edited, "roadmap_unfilled_count": len(roadmap_unfilled), "path_skill_expectations": path_skill_expectations, + "match_summary": match_summary, "retrieval_phase": "+".join(retrieval_parts), } diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 9d630bb..701e41c 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -1286,7 +1286,10 @@ def pick_best_path_hit( return chosen if roadmap_stage_match: - return None + if (path_primary_topic or "").strip(): + return None + chosen = _scan(strict=False) + return chosen chosen = _scan(strict=False) if chosen: diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index d7cc943..9519c60 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -263,6 +263,32 @@ def test_resolve_path_primary_topic_from_stage_learning_goal(): assert primary and "mawashi" in primary +def test_pick_roadmap_relaxed_for_non_technique_stage(): + stage_goal = "Progression Hüftflexibilität und Adduktoren dehnen" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + hits = [ + { + "id": 11, + "title": "Adduktoren Dehnung am Boden", + "summary": "Flexibilität Hüfte", + "goal": "Mobilität", + "score": 0.68, + "semantic_score": 0.22, + "stage_semantic_score": 0.22, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=None, + ) + assert chosen is not None + assert int(chosen["id"]) == 11 + + def test_pick_rejects_kumite_when_primary_only_in_stage_goal(): brief = build_semantic_brief("Trainingsprogression") stage_goal = "Perfektionierung der statischen Mawashi Geri Technik" diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 3a02888..78bee4c 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -71,6 +71,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const [busy, setBusy] = useState(false) const [loadErr, setLoadErr] = useState('') const [actionErr, setActionErr] = useState('') + const [matchNotice, setMatchNotice] = useState('') const [pickContext, setPickContext] = useState(null) const [pathQa, setPathQa] = useState(null) const [gapFillOffers, setGapFillOffers] = useState([]) @@ -396,6 +397,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } setMatching(true) setActionErr('') + setMatchNotice('') try { const synced = syncProgressionRoadmapFromSlots(draft) const override = majorStepsToOverridePayload(synced.slots) @@ -427,6 +429,21 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setTargetSummary(res?.target_profile_summary || null) setPathQa(res?.path_qa || null) setGapFillOffers(remainingOffers) + const ms = res?.match_summary + if (ms) { + setMatchNotice( + `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`, + ) + } + try { + await saveProgressionGraphDraft(api, graphId, { + ...matched, + lastFindings: res?.path_qa || null, + }) + setDraft((prev) => (prev ? { ...prev, dirty: false } : prev)) + } catch (saveErr) { + console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr) + } } catch (e) { setActionErr(e.message || 'Übungs-Match fehlgeschlagen') } finally { @@ -868,6 +885,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa {busy ? 'Speichern…' : 'Graph speichern'} + {matchNotice ? ( +

{matchNotice}

+ ) : null} {draft.dirty ? (

Ungespeicherte Änderungen diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 62d8659..dd7bf38 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -245,7 +245,7 @@ export function filterGapOffersForUnfilledSlots(draft, offers) { if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true const p = draft.slots[idx]?.primary if (p?.kind === 'library' && p.exerciseId != null) return false - if (p?.kind === 'proposal') return false + if (p?.kind === 'proposal' && p.aiSuggestion) return false return true }) } @@ -407,13 +407,13 @@ export function majorStepsToOverridePayload(rows) { major_steps: indexed.map((row) => ({ index: row.index, phase: row.phase || 'vertiefung', - learning_goal: row.learning_goal.trim(), + learning_goal: (row.learning_goal || '').trim(), consolidates: row.consolidates || [], rationale: row.rationale || '', })), stage_specs: indexed.map((row, i) => ({ major_step_index: i, - learning_goal: row.learning_goal.trim(), + learning_goal: (row.learning_goal || '').trim(), load_profile: Array.isArray(row.load_profile) ? row.load_profile : [], exercise_type: (row.exercise_type || '').trim(), success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [], @@ -521,9 +521,12 @@ function buildSlotsFromSources({ majorSteps, slotContents, primaryChain, sibling ? slotContents.find((s) => Number(s.major_step_index) === i) : null + const hasSavedSlotContents = Array.isArray(slotContents) && slotContents.length > 0 let primary = saved?.primary ? slotExerciseFromApi(saved.primary) - : chainNodeToLibrary(primaryChain?.nodes?.[i]) + : hasSavedSlotContents + ? emptySlotExercise() + : chainNodeToLibrary(primaryChain?.nodes?.[i]) if (primary.kind === 'empty' && saved?.primary) { primary = slotExerciseFromApi(saved.primary) @@ -760,7 +763,10 @@ export function applyMatchStepsToSlots(draft, apiSteps) { touchedMajors.add(idx) const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null - if (isProposal) { + const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key) + if (isProposal && !hasAiPayload) { + nextSlots[idx].primary = emptySlotExercise() + } else if (isProposal) { nextSlots[idx].primary = proposalSlotExercise({ title: step.title || nextSlots[idx].learning_goal, proposalKey: step.proposal_key, @@ -804,7 +810,7 @@ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = offer?.replace_step_index != null if (!isOffTopicReplace) { if (primary?.kind === 'library' && primary.exerciseId != null) continue - if (primary?.kind === 'proposal') continue + if (primary?.kind === 'proposal' && primary.aiSuggestion) continue } next = applyGapOfferToSlot(next, idx, offer)