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_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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
</button>
|
||||
</div>
|
||||
{matchNotice ? (
|
||||
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>{matchNotice}</p>
|
||||
) : null}
|
||||
{draft.dirty ? (
|
||||
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
|
||||
Ungespeicherte Änderungen
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user