Enhance Roadmap Slot Matching and Gap Offer Logic
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 45s
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 1m22s

- Introduced `_roadmap_step_passes_post_match_gate` to validate steps after matching, ensuring only relevant steps proceed.
- Enhanced `_enrich_roadmap_unfilled_gap_offers` to generate AI gap offers for unfilled roadmap slots, improving exercise suggestions.
- Updated `suggest_progression_path` to incorporate new gap offer logic and streamline the handling of roadmap steps.
- Refined frontend logic in `applyMatchStepsToSlots` to better manage step assignments and improve clarity in slot handling.
- Bumped version to 0.8.231 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-12 08:05:56 +02:00
parent d448c3191f
commit 63c99b0ec5
5 changed files with 245 additions and 93 deletions

View File

@ -1272,9 +1272,35 @@ def _match_roadmap_slot(
else: else:
step["slot_status"] = "matched" step["slot_status"] = "matched"
step["roadmap_match_source"] = "stage_spec" step["roadmap_match_source"] = "stage_spec"
if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate(
cur,
step,
goal_query=goal_query,
semantic_brief=semantic_brief,
):
return None, stage_spec
return step, None return step, None
def _roadmap_step_passes_post_match_gate(
cur,
step: Dict[str, Any],
*,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> bool:
"""Abgleich mit Pfad-QA — kein Rematch-Treffer, der sofort wieder stage_mismatch wäre."""
if step.get("exercise_id") is None:
return False
issues = detect_off_topic_steps(
cur,
[step],
brief=semantic_brief,
goal_query=goal_query,
)
return not issues
def _normalize_roadmap_steps_coverage( def _normalize_roadmap_steps_coverage(
steps: List[Dict[str, Any]], steps: List[Dict[str, Any]],
*, *,
@ -1394,6 +1420,92 @@ def _purge_stage_mismatch_roadmap_slots(
return out, new_unfilled return out, new_unfilled
def _enrich_roadmap_unfilled_gap_offers(
cur,
*,
steps: List[Dict[str, Any]],
gap_fill_offers: List[Dict[str, Any]],
body: ProgressionPathSuggestRequest,
roadmap_ctx: ProgressionRoadmapContext,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""KI-Lücken-Angebote für alle leeren Roadmap-Slots (nach Rematch/Normalize)."""
if not body.include_ai_gap_fill:
return steps, gap_fill_offers
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")}
out_steps: List[Dict[str, Any]] = []
offers = list(gap_fill_offers)
for raw in steps:
step = dict(raw)
if step.get("exercise_id") is not None:
out_steps.append(step)
continue
try:
major_idx = int(step["roadmap_major_step_index"])
except (TypeError, ValueError, KeyError):
out_steps.append(step)
continue
if step.get("gap_offer") and step.get("proposal_key"):
oid = step["gap_offer"].get("offer_id")
if oid and oid not in seen_offer_ids:
offers.append(dict(step["gap_offer"]))
seen_offer_ids.add(oid)
out_steps.append(step)
continue
stage_spec = next(
(
s
for s in (roadmap_ctx.stage_specs or [])
if int(s.major_step_index) == major_idx
),
None,
)
learning_goal = (
(stage_spec.learning_goal if stage_spec else None)
or step.get("roadmap_learning_goal")
or step.get("title")
or ""
).strip()
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": (learning_goal or f"Slot {major_idx + 1}")[:120],
"sketch": learning_goal,
"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,
),
)
step["gap_offer"] = offer
step["proposal_key"] = offer.get("offer_id")
step["slot_status"] = "unfilled"
if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids:
offers.append(offer)
seen_offer_ids.add(offer.get("offer_id"))
out_steps.append(step)
return out_steps, offers
def _merge_rematch_unfilled( def _merge_rematch_unfilled(
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]], rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]],
@ -2269,7 +2381,7 @@ def suggest_progression_path(
elif gaps and roadmap_first: elif gaps and roadmap_first:
unfilled_gaps = list(gaps) unfilled_gaps = list(gaps)
if body.include_llm_path_qa: if body.include_llm_path_qa and not roadmap_first:
llm_qa, llm_qa_applied = try_llm_qa_progression_path( llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur, cur,
goal_query=goal_query, goal_query=goal_query,
@ -2348,6 +2460,22 @@ def suggest_progression_path(
roadmap_first=roadmap_first, roadmap_first=roadmap_first,
) )
if body.include_llm_path_qa and roadmap_first:
gaps = detect_path_gaps(
cur,
steps,
brief=semantic_brief,
roadmap_first=roadmap_first,
)
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
brief=semantic_brief,
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
)
llm_gap_specs = parse_llm_suggested_new_exercises( llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa, llm_qa,
brief=semantic_brief, brief=semantic_brief,
@ -2397,6 +2525,22 @@ def suggest_progression_path(
if offer.get("offer_id") not in seen_offer_ids: if offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer) gap_fill_offers.append(offer)
if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage(
steps,
roadmap_ctx=roadmap_ctx,
max_steps=max_steps,
)
steps, gap_fill_offers = _enrich_roadmap_unfilled_gap_offers(
cur,
steps=steps,
gap_fill_offers=gap_fill_offers,
body=body,
roadmap_ctx=roadmap_ctx,
goal_query=goal_query,
semantic_brief=semantic_brief,
)
multistage_qa = run_multistage_path_qa( multistage_qa = run_multistage_path_qa(
off_topic_steps=off_topic_steps, off_topic_steps=off_topic_steps,
stripped_off_topic=stripped_off_topic, stripped_off_topic=stripped_off_topic,
@ -2428,71 +2572,6 @@ def suggest_progression_path(
path_qa["refine_log"] = refine_log path_qa["refine_log"] = refine_log
path_qa["refine_count"] = len(refine_log) path_qa["refine_count"] = len(refine_log)
if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage(
steps,
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 if o.get("offer_id")}
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
if step.get("gap_offer") and step.get("proposal_key"):
oid = step["gap_offer"].get("offer_id")
if oid and oid not in seen_offer_ids:
gap_fill_offers.append(dict(step["gap_offer"]))
seen_offer_ids.add(oid)
continue
stage_spec = next(
(
s
for s in (roadmap_ctx.stage_specs or [])
if int(s.major_step_index) == major_idx
),
None,
)
learning_goal = (
(stage_spec.learning_goal if stage_spec else None)
or step.get("roadmap_learning_goal")
or step.get("title")
or ""
).strip()
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": (learning_goal or f"Slot {major_idx + 1}")[:120],
"sketch": learning_goal,
"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,
),
)
step["gap_offer"] = offer
step["proposal_key"] = offer.get("offer_id")
step["slot_status"] = "unfilled"
if offer.get("offer_id") and 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) filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None)
match_summary = { match_summary = {
"roadmap_first": roadmap_first, "roadmap_first": roadmap_first,

View File

@ -207,8 +207,29 @@ def rematch_roadmap_slots(
} }
) )
else: else:
goal = (stage_spec.learning_goal or "").strip()
major = None
if roadmap_ctx.roadmap:
major = next(
(m for m in roadmap_ctx.roadmap.major_steps if int(m.index) == int(major_idx)),
None,
)
steps_by_major[int(major_idx)] = {
"exercise_id": None,
"variant_id": None,
"title": goal or f"Slot {major_idx + 1}",
"is_ai_proposal": False,
"roadmap_major_step_index": int(major_idx),
"roadmap_phase": major.phase if major else None,
"roadmap_learning_goal": goal or None,
"roadmap_match_source": "unfilled",
"slot_status": "unfilled",
"reasons": ["Keine passende Übung für Roadmap-Stufe"],
}
if unfilled_spec is not None: if unfilled_spec is not None:
new_unfilled.append((step_index, unfilled_spec)) new_unfilled.append((step_index, unfilled_spec))
elif stage_spec is not None:
new_unfilled.append((step_index, stage_spec))
rematch_log.append( rematch_log.append(
{ {
"roadmap_major_step_index": int(major_idx), "roadmap_major_step_index": int(major_idx),

View File

@ -183,3 +183,43 @@ def test_rematch_excludes_replaced_exercise_from_used():
match_slot_fn=_fake_match, match_slot_fn=_fake_match,
) )
assert 99 in seen_used[0] assert 99 in seen_used[0]
def test_rematch_unfilled_leaves_placeholder_step():
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mae Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
{"exercise_id": 99, "title": "Falsch", "roadmap_major_step_index": 1},
]
def _no_match(cur, *, stage_spec, **kwargs):
return None, stage_spec
ordered, log, unfilled = rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mae Geri",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "stage_mismatch"},
match_slot_fn=_no_match,
)
assert len(ordered) == 2
slot1 = ordered[1]
assert slot1["exercise_id"] is None
assert slot1["slot_status"] == "unfilled"
assert slot1["roadmap_match_source"] == "unfilled"
assert log[0]["action"] == "rematch_unfilled"
assert len(unfilled) == 1

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.230" APP_VERSION = "0.8.231"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.5", # Roadmap-Match strikt; stage_mismatch → unfilled + KI-Gap "planning_exercise_suggest": "0.23.6", # Gap-Angebote nach Rematch; LLM-QA auf finalem Pfad; Post-Match-Gate
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung

View File

@ -856,21 +856,16 @@ export function slotsToEvaluateSteps(draft) {
export function applyMatchStepsToSlots(draft, apiSteps) { export function applyMatchStepsToSlots(draft, apiSteps) {
const steps = Array.isArray(apiSteps) ? apiSteps : [] const steps = Array.isArray(apiSteps) ? apiSteps : []
const nextSlots = (draft.slots || []).map((slot) => ({ const stepByMajor = new Map()
...slot,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}))
const touchedMajors = new Set()
for (const step of steps) { for (const step of steps) {
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue continue
} }
const idx = Number(step.roadmap_major_step_index) stepByMajor.set(Number(step.roadmap_major_step_index), step)
if (idx < 0 || idx >= nextSlots.length) continue }
touchedMajors.add(idx)
const mapStepToPrimary = (step, slot) => {
const midx = Number(slot.majorStepIndex)
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key) const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
const isUnfilledSlot = const isUnfilledSlot =
@ -880,33 +875,50 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
Boolean(step.gap_offer) Boolean(step.gap_offer)
if (isProposal && !hasAiPayload && isUnfilledSlot) { if (isProposal && !hasAiPayload && isUnfilledSlot) {
const offer = step.gap_offer || {} const offer = step.gap_offer || {}
nextSlots[idx].primary = proposalSlotExercise({ return proposalSlotExercise({
title: title:
offer.title_hint || offer.title_hint ||
step.roadmap_learning_goal || step.roadmap_learning_goal ||
step.title || step.title ||
nextSlots[idx].learning_goal || slot.learning_goal ||
`Slot ${idx + 1}`, `Slot ${midx + 1}`,
proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${idx}`, proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${midx}`,
aiSuggestion: offer.ai_suggestion || null, aiSuggestion: offer.ai_suggestion || null,
}) })
} else if (isProposal && !hasAiPayload) { }
nextSlots[idx].primary = emptySlotExercise() if (isProposal && !hasAiPayload) {
} else if (isProposal) { return emptySlotExercise()
nextSlots[idx].primary = proposalSlotExercise({ }
title: step.title || nextSlots[idx].learning_goal, if (isProposal) {
return proposalSlotExercise({
title: step.title || slot.learning_goal,
proposalKey: step.proposal_key, proposalKey: step.proposal_key,
aiSuggestion: step.ai_suggestion, aiSuggestion: step.ai_suggestion,
}) })
} else {
nextSlots[idx].primary = librarySlotExercise({
exerciseId: step.exercise_id,
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
variantId: step.variant_id,
})
} }
return librarySlotExercise({
exerciseId: step.exercise_id,
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
variantId: step.variant_id,
})
} }
const nextSlots = (draft.slots || []).map((slot) => {
const base = {
...slot,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}
const step = stepByMajor.get(Number(slot.majorStepIndex))
if (!step) {
return base
}
return {
...base,
primary: mapStepToPrimary(step, slot),
}
})
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
} }