Implement Off-Topic Slot Gap Specification and Unified Slot Review Enhancements
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
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 1m34s

- Introduced `_build_off_topic_slot_gap_spec` to generate specifications for off-topic slots, improving the handling of filled but thematically inappropriate slots.
- Added `_build_unified_slot_review_entry` to streamline the review process for slots, incorporating various parameters for better evaluation and suggestions.
- Enhanced existing logic in slot management to improve the robustness of path evaluations and user feedback.
- Added tests for the new off-topic slot gap specification to ensure functionality and correctness.
This commit is contained in:
Lars 2026-06-13 12:43:59 +02:00
parent cd457e3ea0
commit f0e581a9f5
3 changed files with 411 additions and 244 deletions

View File

@ -2024,6 +2024,56 @@ def _build_evaluate_empty_slot_gap_specs(
return specs[:8] return specs[:8]
def _build_off_topic_slot_gap_spec(
step: Mapping[str, Any],
*,
goal_query: str = "",
) -> Optional[Dict[str, Any]]:
"""KI-Angebot für belegten, aber themenfremden Slot (Ersatz statt Leerstelle)."""
del goal_query
major_idx = step.get("roadmap_major_step_index")
if major_idx is None:
return None
try:
roadmap_idx = int(major_idx)
except (TypeError, ValueError):
return None
phase = (step.get("roadmap_phase") or "vertiefung").strip().lower()
learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip()
rejected_title = (step.get("title") or "").strip()
title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}"
rationale = (
f"Slot {roadmap_idx + 1}: Ersatz für „{rejected_title}“ — passende Übung per KI."
if rejected_title
else f"Slot {roadmap_idx + 1} — KI-Entwurf für diese Roadmap-Stufe."
)
return {
"source": "off_topic",
"insert_after_index": max(roadmap_idx - 1, -1),
"replace_step_index": roadmap_idx,
"gap": {
"expected_phase": phase,
"roadmap_major_step_index": roadmap_idx,
"learning_goal": learning_goal,
},
"phase": phase,
"title_hint": title_hint,
"sketch": learning_goal or title_hint,
"rationale": rationale[:400],
"roadmap_major_step_index": roadmap_idx,
}
def _gap_offer_major_index(offer: Mapping[str, Any]) -> Optional[int]:
raw = offer.get("roadmap_major_step_index")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _run_evaluate_only_path_qa( def _run_evaluate_only_path_qa(
cur, cur,
*, *,
@ -3107,6 +3157,261 @@ def _slot_auto_select_library(
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
def _build_unified_slot_review_entry(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
stage_spec: StageSpecArtifact,
step_index: int,
stage_count: int,
major_idx: int,
current: Mapping[str, Any],
baseline_steps: Sequence[Mapping[str, Any]],
baseline_qa: Mapping[str, Any],
baseline_score: Optional[float],
steps_by_major: Mapping[int, Mapping[str, Any]],
problem_slots: Mapping[int, Sequence[str]],
off_topic_map: Mapping[int, Sequence[str]],
off_topic_scores: Mapping[int, float],
gap_fill_offers: List[Dict[str, Any]],
suggestions: List[Dict[str, Any]],
rejected: List[Dict[str, Any]],
) -> Dict[str, Any]:
current = dict(current or {})
current.setdefault("roadmap_major_step_index", major_idx)
current.setdefault("roadmap_learning_goal", stage_spec.learning_goal)
current_id = current.get("exercise_id")
slot_problem = major_idx in problem_slots
off_topic = slot_problem or major_idx in off_topic_map or bool(
current.get("slot_status") in {"off_topic", "stripped"}
)
off_reasons = list(problem_slots.get(major_idx, [])) + list(off_topic_map.get(major_idx, []))
baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx)
if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"):
baseline_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=int(current_id),
step=current,
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
planned_ids = [
int(s["exercise_id"])
for midx, s in sorted(steps_by_major.items())
if midx != major_idx and s.get("exercise_id") is not None
]
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
used_other: Set[int] = set(planned_ids)
for midx in sorted(steps_by_major):
if midx >= major_idx:
break
step = steps_by_major[midx]
eid = step.get("exercise_id")
if eid is not None:
anchor_id = int(eid)
vid = step.get("variant_id")
anchor_variant_id = int(vid) if vid is not None else None
candidates = _roadmap_slot_library_candidates(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
stage_spec=stage_spec,
step_index=step_index,
stage_count=stage_count,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used_other,
exclude_exercise_id=int(current_id) if current_id is not None else None,
max_candidates=5,
skip_post_match_gate=True,
)
best_candidate: Optional[Dict[str, Any]] = None
for candidate in candidates:
try:
cand_id = int(candidate.get("exercise_id"))
except (TypeError, ValueError):
continue
if current_id is not None and int(current_id) == cand_id:
continue
best_candidate = candidate
break
library_alt: Optional[Dict[str, Any]] = None
if best_candidate is not None:
try:
cand_id = int(best_candidate.get("exercise_id"))
except (TypeError, ValueError):
cand_id = None
if cand_id is not None:
proposed_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=cand_id,
step={**current, **best_candidate, "roadmap_major_step_index": major_idx},
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
suggestion_type = (
"remove_and_replace"
if (off_topic or slot_problem) and current_id is not None
else ("library_fill" if current_id is None else "library_improvement")
)
auto_select = _slot_auto_select_library(
baseline_slot_score=baseline_slot_score,
proposed_slot_score=proposed_slot_score,
baseline_exercise_id=int(current_id) if current_id is not None else None,
proposed_exercise_id=cand_id,
)
slot_score_delta = (
round(float(proposed_slot_score) - float(baseline_slot_score), 4)
if proposed_slot_score is not None and baseline_slot_score is not None
else None
)
pro_contra = _build_slot_pro_contra(
current_step=current,
proposed_step=best_candidate,
suggestion_type=suggestion_type,
baseline_qa=baseline_qa,
projected_qa=None,
quality_delta=None,
off_topic_reasons=off_reasons,
candidate_reasons=best_candidate.get("reasons") or [],
)
if slot_score_delta is not None and slot_score_delta > 0:
fit_msg = f"Stufen-Fit +{round(slot_score_delta * 100)} Prozentpunkte"
if fit_msg not in pro_contra["proposed_pro"]:
pro_contra["proposed_pro"].insert(0, fit_msg)
library_alt = {
"exercise_id": cand_id,
"title": (best_candidate.get("title") or "").strip() or None,
"slot_score": proposed_slot_score,
"slot_score_delta": slot_score_delta,
"quality_delta": None,
"auto_select": auto_select,
"suggestion_type": suggestion_type,
"reasons": list(best_candidate.get("reasons") or [])[:4],
"pro_contra": pro_contra,
}
lib_entry = {
"roadmap_major_step_index": major_idx,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": cand_id,
"proposed_title": library_alt["title"],
"baseline_slot_status": current.get("slot_status"),
"proposed_slot_status": best_candidate.get("slot_status") or "matched",
"suggestion_type": suggestion_type,
"quality_delta": None,
"baseline_slot_score": baseline_slot_score,
"proposed_slot_score": proposed_slot_score,
"slot_score_delta": slot_score_delta,
"auto_select": auto_select,
"baseline_quality_score": baseline_score,
"improves_path": auto_select,
"off_topic": off_topic,
"slot_problem": slot_problem,
"problem_reasons": off_reasons[:6],
"proposed_is_ai_proposal": False,
"pro_contra": pro_contra,
}
if auto_select:
suggestions.append(lib_entry)
else:
rejected.append(lib_entry)
show_ai_option = bool(
body.include_ai_gap_fill
and (
current_id is None
or off_topic
or slot_problem
or bool(current.get("is_ai_proposal"))
or (
baseline_slot_score is not None
and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD
)
)
)
ai_alt: Optional[Dict[str, Any]] = None
if show_ai_option:
slot_offer = next(
(
o
for o in gap_fill_offers
if isinstance(o, dict) and _gap_offer_major_index(o) == major_idx
),
None,
)
if not slot_offer:
gap_spec: Optional[Dict[str, Any]] = None
if current_id is None:
empty_specs = _build_evaluate_empty_slot_gap_specs(
[current],
goal_query=goal_query,
)
gap_spec = empty_specs[0] if empty_specs else None
elif off_topic or slot_problem:
gap_spec = _build_off_topic_slot_gap_spec(current, goal_query=goal_query)
if gap_spec:
slot_offer = build_gap_fill_offer(
spec=gap_spec,
steps=baseline_steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
cur,
roadmap_ctx,
gap_spec,
goal_query=goal_query,
semantic_brief=semantic_brief,
),
)
gap_fill_offers.append(slot_offer)
if slot_offer:
ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer,
"auto_select": False,
}
return {
"roadmap_major_step_index": major_idx,
"roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"baseline_slot_score": baseline_slot_score,
"baseline_slot_status": current.get("slot_status"),
"slot_problem": slot_problem,
"off_topic": off_topic,
"problem_reasons": off_reasons[:6],
"library_alternative": library_alt,
"ai_alternative": ai_alt,
}
def _run_unified_slot_improvement_review( def _run_unified_slot_improvement_review(
cur, cur,
*, *,
@ -3124,7 +3429,7 @@ def _run_unified_slot_improvement_review(
roadmap_edited: bool, roadmap_edited: bool,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Ein Workflow: Pfad bewerten je Slot Alternativen suchen Einzel-QS nur Verbesserungen. Ein Workflow: Pfad bewerten je Slot Alternativen suchen Stufen-Fit vergleichen.
""" """
if not body.baseline_evaluate_steps: if not body.baseline_evaluate_steps:
raise HTTPException( raise HTTPException(
@ -3137,6 +3442,52 @@ def _run_unified_slot_improvement_review(
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
) )
try:
return _run_unified_slot_improvement_review_core(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
semantic_llm_applied=semantic_llm_applied,
path_target_profile=path_target_profile,
path_intent=path_intent,
first_intent_summary=first_intent_summary,
roadmap_ctx=roadmap_ctx,
progression_roadmap=progression_roadmap,
roadmap_edited=roadmap_edited,
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"unified_slot_review fehlgeschlagen: {exc}",
) from exc
def _run_unified_slot_improvement_review_core(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
semantic_llm_applied: bool,
path_target_profile: PlanningTargetProfile,
path_intent: str,
first_intent_summary: Mapping[str, Any],
roadmap_ctx: ProgressionRoadmapContext,
progression_roadmap: Optional[Dict[str, Any]],
roadmap_edited: bool,
) -> Dict[str, Any]:
if not body.baseline_evaluate_steps:
raise HTTPException(status_code=400, detail="baseline_evaluate_steps fehlt")
if not roadmap_ctx.stage_specs:
raise HTTPException(status_code=400, detail="roadmap stage_specs fehlt")
baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
snapshot = ( snapshot = (
dict(body.baseline_path_qa_snapshot) dict(body.baseline_path_qa_snapshot)
@ -3206,47 +3557,8 @@ def _run_unified_slot_improvement_review(
for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs): for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs):
major_idx = int(stage_spec.major_step_index) major_idx = int(stage_spec.major_step_index)
current = dict(steps_by_major.get(major_idx, {})) try:
current.setdefault("roadmap_major_step_index", major_idx) slot_review = _build_unified_slot_review_entry(
current.setdefault("roadmap_learning_goal", stage_spec.learning_goal)
current_id = current.get("exercise_id")
slot_problem = major_idx in problem_slots
off_topic = slot_problem or major_idx in off_topic_map or bool(
current.get("slot_status") in {"off_topic", "stripped"}
)
off_reasons = list(problem_slots.get(major_idx, [])) + off_topic_map.get(major_idx, [])
baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx)
if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"):
baseline_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=int(current_id),
step=current,
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
planned_ids = [
int(s["exercise_id"])
for midx, s in sorted(steps_by_major.items())
if midx != major_idx and s.get("exercise_id") is not None
]
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
used_other: Set[int] = set(planned_ids)
for midx in sorted(steps_by_major):
if midx >= major_idx:
break
step = steps_by_major[midx]
eid = step.get("exercise_id")
if eid is not None:
anchor_id = int(eid)
vid = step.get("variant_id")
anchor_variant_id = int(vid) if vid is not None else None
candidates = _roadmap_slot_library_candidates(
cur, cur,
tenant=tenant, tenant=tenant,
body=body, body=body,
@ -3259,203 +3571,35 @@ def _run_unified_slot_improvement_review(
stage_spec=stage_spec, stage_spec=stage_spec,
step_index=step_index, step_index=step_index,
stage_count=stage_count, stage_count=stage_count,
planned_ids=planned_ids, major_idx=major_idx,
anchor_id=anchor_id, current=steps_by_major.get(major_idx, {}),
anchor_variant_id=anchor_variant_id, baseline_steps=baseline_steps,
used=used_other,
exclude_exercise_id=int(current_id) if current_id is not None else None,
max_candidates=5,
skip_post_match_gate=True,
)
best_candidate: Optional[Dict[str, Any]] = None
for candidate in candidates:
try:
cand_id = int(candidate.get("exercise_id"))
except (TypeError, ValueError):
continue
if current_id is not None and int(current_id) == cand_id:
continue
best_candidate = candidate
break
proposed_slot_score: Optional[float] = None
quality_delta: Optional[float] = None
projected_qa: Optional[Dict[str, Any]] = None
library_alt: Optional[Dict[str, Any]] = None
if best_candidate is not None:
try:
cand_id = int(best_candidate.get("exercise_id"))
except (TypeError, ValueError):
cand_id = None
if cand_id is not None:
proposed_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=cand_id,
step={**current, **best_candidate, "roadmap_major_step_index": major_idx},
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
diff_stub = {
"roadmap_major_step_index": major_idx,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": cand_id,
"proposed_title": (best_candidate.get("title") or "").strip() or None,
}
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps)
for i, raw in enumerate(merged_steps):
if int(raw.get("roadmap_major_step_index", -1)) == major_idx:
merged_steps[i] = {
**raw,
**best_candidate,
"roadmap_major_step_index": major_idx,
}
break
projected_qa = _quick_evaluate_steps_qa(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=merged_steps,
roadmap_ctx=roadmap_ctx,
)
quality_delta = _quality_delta(
baseline_score,
_path_qa_quality_score(projected_qa),
)
suggestion_type = (
"remove_and_replace"
if (off_topic or slot_problem) and current_id is not None
else ("library_fill" if current_id is None else "library_improvement")
)
auto_select = _slot_auto_select_library(
baseline_slot_score=baseline_slot_score,
proposed_slot_score=proposed_slot_score,
baseline_exercise_id=int(current_id) if current_id is not None else None,
proposed_exercise_id=cand_id,
)
library_alt = {
"exercise_id": cand_id,
"title": (best_candidate.get("title") or "").strip() or None,
"slot_score": proposed_slot_score,
"slot_score_delta": (
round(float(proposed_slot_score) - float(baseline_slot_score), 4)
if proposed_slot_score is not None and baseline_slot_score is not None
else None
),
"quality_delta": quality_delta,
"auto_select": auto_select,
"suggestion_type": suggestion_type,
"reasons": list(best_candidate.get("reasons") or [])[:4],
"pro_contra": _build_slot_pro_contra(
current_step=current,
proposed_step=best_candidate,
suggestion_type=suggestion_type,
baseline_qa=baseline_qa, baseline_qa=baseline_qa,
projected_qa=projected_qa, baseline_score=baseline_score,
quality_delta=quality_delta, steps_by_major=steps_by_major,
off_topic_reasons=off_reasons, problem_slots=problem_slots,
candidate_reasons=best_candidate.get("reasons") or [], off_topic_map=off_topic_map,
), off_topic_scores=off_topic_scores,
} gap_fill_offers=gap_fill_offers,
lib_entry = { suggestions=suggestions,
"roadmap_major_step_index": major_idx, rejected=rejected,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": cand_id,
"proposed_title": library_alt["title"],
"baseline_slot_status": current.get("slot_status"),
"proposed_slot_status": best_candidate.get("slot_status") or "matched",
"suggestion_type": suggestion_type,
"quality_delta": quality_delta,
"baseline_slot_score": baseline_slot_score,
"proposed_slot_score": proposed_slot_score,
"slot_score_delta": library_alt["slot_score_delta"],
"auto_select": auto_select,
"baseline_quality_score": baseline_score,
"projected_quality_score": _path_qa_quality_score(projected_qa),
"projected_path_qa": projected_qa,
"improves_path": auto_select,
"off_topic": off_topic,
"slot_problem": slot_problem,
"problem_reasons": off_reasons[:6],
"proposed_is_ai_proposal": False,
"pro_contra": library_alt["pro_contra"],
}
if auto_select:
suggestions.append(lib_entry)
elif cand_id is not None:
rejected.append(lib_entry)
show_ai_option = bool(
body.include_ai_gap_fill
and (
current_id is None
or off_topic
or slot_problem
or bool(current.get("is_ai_proposal"))
or (
baseline_slot_score is not None
and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD
) )
) except Exception as exc:
) slot_review = {
ai_alt: Optional[Dict[str, Any]] = None
if show_ai_option:
slot_offer = next(
(
o
for o in gap_fill_offers
if isinstance(o, dict)
and int(o.get("roadmap_major_step_index", -1)) == major_idx
),
None,
)
if not slot_offer:
empty_specs = _build_evaluate_empty_slot_gap_specs(
[current],
goal_query=goal_query,
)
if empty_specs:
slot_offer = build_gap_fill_offer(
spec=empty_specs[0],
steps=baseline_steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
cur,
roadmap_ctx,
empty_specs[0],
goal_query=goal_query,
semantic_brief=semantic_brief,
),
)
gap_fill_offers.append(slot_offer)
if slot_offer:
ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer,
"auto_select": False,
}
slot_reviews.append(
{
"roadmap_major_step_index": major_idx, "roadmap_major_step_index": major_idx,
"roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None, "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None,
"baseline_exercise_id": int(current_id) if current_id is not None else None, "baseline_exercise_id": None,
"baseline_title": (current.get("title") or "").strip() or None, "baseline_title": None,
"baseline_slot_score": baseline_slot_score, "baseline_slot_score": None,
"baseline_slot_status": current.get("slot_status"), "baseline_slot_status": None,
"slot_problem": slot_problem, "slot_problem": major_idx in problem_slots,
"off_topic": off_topic, "off_topic": major_idx in off_topic_map,
"problem_reasons": off_reasons[:6], "problem_reasons": [f"Slot-Review fehlgeschlagen: {exc}"[:300]],
"library_alternative": library_alt, "library_alternative": None,
"ai_alternative": ai_alt, "ai_alternative": None,
"review_error": str(exc)[:300],
} }
) slot_reviews.append(slot_review)
improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
problem_slot_payload = { problem_slot_payload = {
@ -3470,12 +3614,17 @@ def _run_unified_slot_improvement_review(
"rejected_count": len(rejected), "rejected_count": len(rejected),
} }
try:
target_summary = path_target_profile.to_summary_dict(cur)
except Exception:
target_summary = {}
return { return {
"goal_query": goal_query, "goal_query": goal_query,
"max_steps_requested": max_steps, "max_steps_requested": max_steps,
"steps": baseline_steps, "steps": baseline_steps,
"step_count": len(baseline_steps), "step_count": len(baseline_steps),
"target_profile_summary": path_target_profile.to_summary_dict(cur), "target_profile_summary": target_summary,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief), "semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied, "semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": first_intent_summary, "query_intent_summary": first_intent_summary,

View File

@ -111,3 +111,20 @@ def test_slot_auto_select_requires_higher_score():
baseline_exercise_id=1, baseline_exercise_id=1,
proposed_exercise_id=2, proposed_exercise_id=2,
) )
def test_off_topic_slot_gap_spec_for_filled_slot():
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
spec = _build_off_topic_slot_gap_spec(
{
"roadmap_major_step_index": 7,
"exercise_id": 99,
"title": "Ukemi Vorwärts",
"roadmap_learning_goal": "Integration Täuschung",
}
)
assert spec is not None
assert spec["source"] == "off_topic"
assert spec["roadmap_major_step_index"] == 7
assert "Ukemi" in spec["rationale"]

View File

@ -515,6 +515,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
baselineRes?.path_qa?.quality_score != null baselineRes?.path_qa?.quality_score != null
? Number(baselineRes.path_qa.quality_score) ? Number(baselineRes.path_qa.quality_score)
: null, : null,
include_llm_path_qa: false,
include_llm_intent: false, include_llm_intent: false,
auto_rematch_after_qa: false, auto_rematch_after_qa: false,
}) })