diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 868e706..618a467 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -2024,6 +2024,56 @@ def _build_evaluate_empty_slot_gap_specs( 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( cur, *, @@ -3107,6 +3157,261 @@ def _slot_auto_select_library( 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( cur, *, @@ -3124,7 +3429,7 @@ def _run_unified_slot_improvement_review( roadmap_edited: bool, ) -> 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: raise HTTPException( @@ -3137,6 +3442,52 @@ def _run_unified_slot_improvement_review( 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) snapshot = ( dict(body.baseline_path_qa_snapshot) @@ -3206,256 +3557,49 @@ def _run_unified_slot_improvement_review( for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs): major_idx = int(stage_spec.major_step_index) - current = dict(steps_by_major.get(major_idx, {})) - 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, [])) + 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( + try: + slot_review = _build_unified_slot_review_entry( cur, - exercise_id=int(current_id), - step=current, - stage_spec=stage_spec, + 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, + major_idx=major_idx, + current=steps_by_major.get(major_idx, {}), + baseline_steps=baseline_steps, + baseline_qa=baseline_qa, + baseline_score=baseline_score, + steps_by_major=steps_by_major, + problem_slots=problem_slots, + off_topic_map=off_topic_map, + off_topic_scores=off_topic_scores, + gap_fill_offers=gap_fill_offers, + suggestions=suggestions, + rejected=rejected, ) - - 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 - - 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, - projected_qa=projected_qa, - quality_delta=quality_delta, - off_topic_reasons=off_reasons, - candidate_reasons=best_candidate.get("reasons") or [], - ), - } - 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": 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 - ) - ) - ) - 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( - { + except Exception as exc: + slot_review = { "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, + "baseline_exercise_id": None, + "baseline_title": None, + "baseline_slot_score": None, + "baseline_slot_status": None, + "slot_problem": major_idx in problem_slots, + "off_topic": major_idx in off_topic_map, + "problem_reasons": [f"Slot-Review fehlgeschlagen: {exc}"[:300]], + "library_alternative": None, + "ai_alternative": None, + "review_error": str(exc)[:300], } - ) + slot_reviews.append(slot_review) improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] problem_slot_payload = { @@ -3470,12 +3614,17 @@ def _run_unified_slot_improvement_review( "rejected_count": len(rejected), } + try: + target_summary = path_target_profile.to_summary_dict(cur) + except Exception: + target_summary = {} + return { "goal_query": goal_query, "max_steps_requested": max_steps, "steps": 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_llm_applied": semantic_llm_applied, "query_intent_summary": first_intent_summary, diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index b4fa3d9..8276d70 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -111,3 +111,20 @@ def test_slot_auto_select_requires_higher_score(): baseline_exercise_id=1, 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"] diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index b980d2d..59ce505 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -515,6 +515,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa baselineRes?.path_qa?.quality_score != null ? Number(baselineRes.path_qa.quality_score) : null, + include_llm_path_qa: false, include_llm_intent: false, auto_rematch_after_qa: false, })