Enhance Progression Path Suggestion Logic and UI Feedback
All checks were successful
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
Deploy Development / deploy (push) Successful in 44s
All checks were successful
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
Deploy Development / deploy (push) Successful in 44s
- 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.
This commit is contained in:
parent
044ce2ee60
commit
8f1dad53ab
|
|
@ -1414,7 +1414,10 @@ def suggest_progression_path(
|
||||||
anchor_id = eid
|
anchor_id = eid
|
||||||
anchor_variant_id = step.get("variant_id")
|
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(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.",
|
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,
|
roadmap_ctx=roadmap_ctx,
|
||||||
max_steps=max_steps,
|
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)
|
target_profile_summary = path_target_profile.to_summary_dict(cur)
|
||||||
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
|
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
|
||||||
|
|
@ -1667,6 +1724,7 @@ def suggest_progression_path(
|
||||||
"roadmap_edited": roadmap_edited,
|
"roadmap_edited": roadmap_edited,
|
||||||
"roadmap_unfilled_count": len(roadmap_unfilled),
|
"roadmap_unfilled_count": len(roadmap_unfilled),
|
||||||
"path_skill_expectations": path_skill_expectations,
|
"path_skill_expectations": path_skill_expectations,
|
||||||
|
"match_summary": match_summary,
|
||||||
"retrieval_phase": "+".join(retrieval_parts),
|
"retrieval_phase": "+".join(retrieval_parts),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1286,7 +1286,10 @@ def pick_best_path_hit(
|
||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
if roadmap_stage_match:
|
if roadmap_stage_match:
|
||||||
|
if (path_primary_topic or "").strip():
|
||||||
return None
|
return None
|
||||||
|
chosen = _scan(strict=False)
|
||||||
|
return chosen
|
||||||
|
|
||||||
chosen = _scan(strict=False)
|
chosen = _scan(strict=False)
|
||||||
if chosen:
|
if chosen:
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,32 @@ def test_resolve_path_primary_topic_from_stage_learning_goal():
|
||||||
assert primary and "mawashi" in primary
|
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():
|
def test_pick_rejects_kumite_when_primary_only_in_stage_goal():
|
||||||
brief = build_semantic_brief("Trainingsprogression")
|
brief = build_semantic_brief("Trainingsprogression")
|
||||||
stage_goal = "Perfektionierung der statischen Mawashi Geri Technik"
|
stage_goal = "Perfektionierung der statischen Mawashi Geri Technik"
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [loadErr, setLoadErr] = useState('')
|
const [loadErr, setLoadErr] = useState('')
|
||||||
const [actionErr, setActionErr] = useState('')
|
const [actionErr, setActionErr] = useState('')
|
||||||
|
const [matchNotice, setMatchNotice] = useState('')
|
||||||
const [pickContext, setPickContext] = useState(null)
|
const [pickContext, setPickContext] = useState(null)
|
||||||
const [pathQa, setPathQa] = useState(null)
|
const [pathQa, setPathQa] = useState(null)
|
||||||
const [gapFillOffers, setGapFillOffers] = useState([])
|
const [gapFillOffers, setGapFillOffers] = useState([])
|
||||||
|
|
@ -396,6 +397,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
setMatching(true)
|
setMatching(true)
|
||||||
setActionErr('')
|
setActionErr('')
|
||||||
|
setMatchNotice('')
|
||||||
try {
|
try {
|
||||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const override = majorStepsToOverridePayload(synced.slots)
|
const override = majorStepsToOverridePayload(synced.slots)
|
||||||
|
|
@ -427,6 +429,21 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setTargetSummary(res?.target_profile_summary || null)
|
setTargetSummary(res?.target_profile_summary || null)
|
||||||
setPathQa(res?.path_qa || null)
|
setPathQa(res?.path_qa || null)
|
||||||
setGapFillOffers(remainingOffers)
|
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) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -868,6 +885,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
{busy ? 'Speichern…' : 'Graph speichern'}
|
{busy ? 'Speichern…' : 'Graph speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{matchNotice ? (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>{matchNotice}</p>
|
||||||
|
) : null}
|
||||||
{draft.dirty ? (
|
{draft.dirty ? (
|
||||||
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
|
||||||
Ungespeicherte Änderungen
|
Ungespeicherte Änderungen
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ export function filterGapOffersForUnfilledSlots(draft, offers) {
|
||||||
if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true
|
if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true
|
||||||
const p = draft.slots[idx]?.primary
|
const p = draft.slots[idx]?.primary
|
||||||
if (p?.kind === 'library' && p.exerciseId != null) return false
|
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
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -407,13 +407,13 @@ export function majorStepsToOverridePayload(rows) {
|
||||||
major_steps: indexed.map((row) => ({
|
major_steps: indexed.map((row) => ({
|
||||||
index: row.index,
|
index: row.index,
|
||||||
phase: row.phase || 'vertiefung',
|
phase: row.phase || 'vertiefung',
|
||||||
learning_goal: row.learning_goal.trim(),
|
learning_goal: (row.learning_goal || '').trim(),
|
||||||
consolidates: row.consolidates || [],
|
consolidates: row.consolidates || [],
|
||||||
rationale: row.rationale || '',
|
rationale: row.rationale || '',
|
||||||
})),
|
})),
|
||||||
stage_specs: indexed.map((row, i) => ({
|
stage_specs: indexed.map((row, i) => ({
|
||||||
major_step_index: 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 : [],
|
load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
|
||||||
exercise_type: (row.exercise_type || '').trim(),
|
exercise_type: (row.exercise_type || '').trim(),
|
||||||
success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
|
success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
|
||||||
|
|
@ -521,8 +521,11 @@ function buildSlotsFromSources({ majorSteps, slotContents, primaryChain, sibling
|
||||||
? slotContents.find((s) => Number(s.major_step_index) === i)
|
? slotContents.find((s) => Number(s.major_step_index) === i)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const hasSavedSlotContents = Array.isArray(slotContents) && slotContents.length > 0
|
||||||
let primary = saved?.primary
|
let primary = saved?.primary
|
||||||
? slotExerciseFromApi(saved.primary)
|
? slotExerciseFromApi(saved.primary)
|
||||||
|
: hasSavedSlotContents
|
||||||
|
? emptySlotExercise()
|
||||||
: chainNodeToLibrary(primaryChain?.nodes?.[i])
|
: chainNodeToLibrary(primaryChain?.nodes?.[i])
|
||||||
|
|
||||||
if (primary.kind === 'empty' && saved?.primary) {
|
if (primary.kind === 'empty' && saved?.primary) {
|
||||||
|
|
@ -760,7 +763,10 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||||
touchedMajors.add(idx)
|
touchedMajors.add(idx)
|
||||||
|
|
||||||
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
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({
|
nextSlots[idx].primary = proposalSlotExercise({
|
||||||
title: step.title || nextSlots[idx].learning_goal,
|
title: step.title || nextSlots[idx].learning_goal,
|
||||||
proposalKey: step.proposal_key,
|
proposalKey: step.proposal_key,
|
||||||
|
|
@ -804,7 +810,7 @@ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots =
|
||||||
offer?.replace_step_index != null
|
offer?.replace_step_index != null
|
||||||
if (!isOffTopicReplace) {
|
if (!isOffTopicReplace) {
|
||||||
if (primary?.kind === 'library' && primary.exerciseId != null) continue
|
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)
|
next = applyGapOfferToSlot(next, idx, offer)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user