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

- 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:
Lars 2026-06-11 11:17:53 +02:00
parent 044ce2ee60
commit 8f1dad53ab
5 changed files with 121 additions and 8 deletions

View File

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

View File

@ -1286,7 +1286,10 @@ def pick_best_path_hit(
return chosen return chosen
if roadmap_stage_match: if roadmap_stage_match:
return None if (path_primary_topic or "").strip():
return None
chosen = _scan(strict=False)
return chosen
chosen = _scan(strict=False) chosen = _scan(strict=False)
if chosen: if chosen:

View File

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

View File

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

View File

@ -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,9 +521,12 @@ 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)
: chainNodeToLibrary(primaryChain?.nodes?.[i]) : hasSavedSlotContents
? emptySlotExercise()
: chainNodeToLibrary(primaryChain?.nodes?.[i])
if (primary.kind === 'empty' && saved?.primary) { if (primary.kind === 'empty' && saved?.primary) {
primary = slotExerciseFromApi(saved.primary) primary = slotExerciseFromApi(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)