From 85fccdd0936b7347fb8bde5bed3023109be718d2 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 10:11:10 +0200 Subject: [PATCH] Enhance Progression Path Comparison and Slot Evaluation Features - Introduced new fields in `ProgressionPathSuggestRequest` for baseline evaluation and incremental scoring, improving the assessment of proposed paths. - Implemented `_apply_slot_diff_to_steps` and `_score_incremental_slot_diffs` functions to manage slot differences and evaluate their impact on quality scores. - Updated `ProgressionGraphEditor` to streamline the match comparison flow, integrating new evaluation parameters and improving user notifications. - Enhanced `ProgressionOptimizeCompareModal` to better display proposed path suggestions, including pro/con evaluations and quality delta metrics. - Refactored utility functions for clearer handling of slot differences and improved overall data management in the progression graph editor. --- backend/planning_exercise_path_builder.py | 727 ++++++++++++++++++ .../test_planning_incremental_diff_scoring.py | 39 + .../src/components/ProgressionGraphEditor.jsx | 61 +- .../ProgressionOptimizeCompareModal.jsx | 369 ++++----- frontend/src/utils/progressionGraphDraft.js | 184 ++++- 5 files changed, 1098 insertions(+), 282 deletions(-) create mode 100644 backend/tests/test_planning_incremental_diff_scoring.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 4fdad02..7114ced 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -143,6 +143,11 @@ class ProgressionPathSuggestRequest(BaseModel): exercise_kind_any: Optional[List[str]] = None compare_with_assignments: bool = False planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None + # Für Match-Vergleich: Baseline aus evaluate_only (Schritt 1) — inkrementelles QS-Scoring je Diff + baseline_evaluate_steps: Optional[List[EvaluateStepPayload]] = None + baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) + include_incremental_diff_scoring: bool = False + unified_slot_review: bool = False def _resolve_planning_catalog_context( @@ -2394,6 +2399,688 @@ def _evaluate_steps_for_compare_qa( return suggest_progression_path(cur, tenant=tenant, body=eval_body) +def _apply_slot_diff_to_steps( + baseline_steps: Sequence[Mapping[str, Any]], + diff: Mapping[str, Any], + proposed_steps: Sequence[Mapping[str, Any]], +) -> List[Dict[str, Any]]: + """Einzeländerung auf Baseline-Pfad legen (für faire QS pro Vorschlag).""" + base_by = _steps_by_major_index(baseline_steps) + prop_by = _steps_by_major_index(proposed_steps) + try: + midx = int(diff.get("roadmap_major_step_index")) + except (TypeError, ValueError): + return [dict(s) for s in baseline_steps or []] + out_by: Dict[int, Dict[str, Any]] = {i: dict(s) for i, s in base_by.items()} + prop_step = prop_by.get(midx) + if isinstance(prop_step, dict): + merged = dict(out_by.get(midx, {})) + merged.update(prop_step) + merged["roadmap_major_step_index"] = midx + out_by[midx] = merged + elif diff.get("proposed_exercise_id") is not None: + merged = dict(out_by.get(midx, {})) + merged["exercise_id"] = int(diff["proposed_exercise_id"]) + if diff.get("proposed_title"): + merged["title"] = diff.get("proposed_title") + merged["roadmap_major_step_index"] = midx + merged["slot_status"] = diff.get("proposed_slot_status") or "matched" + out_by[midx] = merged + elif diff.get("baseline_exercise_id") is not None and diff.get("proposed_exercise_id") is None: + merged = dict(out_by.get(midx, {})) + merged["exercise_id"] = None + merged["roadmap_major_step_index"] = midx + out_by[midx] = merged + return [out_by[i] for i in sorted(out_by.keys())] + + +def _slot_diff_improves_path( + diff: Mapping[str, Any], + quality_delta: Optional[float], + *, + off_topic: bool = False, +) -> bool: + """Nur Vorschläge mit messbarer Pfad-Verbesserung (Lücken/off-topic: neutral oder besser).""" + if quality_delta is None: + return False + try: + delta = float(quality_delta) + except (TypeError, ValueError): + return False + base_id = diff.get("baseline_exercise_id") + prop_id = diff.get("proposed_exercise_id") + if off_topic and base_id is not None: + return delta >= -0.001 + if base_id is None and prop_id is not None: + return delta >= -0.001 + if base_id is not None and prop_id is not None: + return delta > 0.005 + return False + + +def _score_incremental_slot_diffs( + cur, + *, + tenant: TenantContext, + body: ProgressionPathSuggestRequest, + baseline_steps: Sequence[Mapping[str, Any]], + proposed_steps: Sequence[Mapping[str, Any]], + baseline_path_qa: Optional[Mapping[str, Any]], + raw_diffs: Sequence[Mapping[str, Any]], +) -> Dict[str, Any]: + """Bewertet jeden Slot-Diff isoliert gegen die Baseline-QS — filtert Verschlechterungen.""" + baseline_score = _path_qa_quality_score(baseline_path_qa) + if baseline_score is None and baseline_steps: + baseline_eval = _evaluate_steps_for_compare_qa( + cur, + tenant=tenant, + body=body, + steps=baseline_steps, + ) + if isinstance(baseline_eval, dict): + baseline_score = _path_qa_quality_score(baseline_eval.get("path_qa")) + + annotated = _annotate_slot_diffs(list(raw_diffs or [])) + candidates = _actionable_slot_diffs(annotated) + # Lücken zuerst, dann Ersetzungen — harte Obergrenze gegen Timeouts + candidates.sort( + key=lambda d: ( + 0 if d.get("baseline_exercise_id") is None else 1, + int(d.get("roadmap_major_step_index") or 0), + ) + ) + candidates = candidates[:10] + + scored: List[Dict[str, Any]] = [] + improving: List[Dict[str, Any]] = [] + rejected: List[Dict[str, Any]] = [] + + for diff in candidates: + merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff, proposed_steps) + eval_res = _evaluate_steps_for_compare_qa( + cur, + tenant=tenant, + body=body, + steps=merged_steps, + ) + projected_qa = ( + eval_res.get("path_qa") + if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict) + else None + ) + projected_score = _path_qa_quality_score(projected_qa) + delta: Optional[float] = None + if baseline_score is not None and projected_score is not None: + delta = round(projected_score - baseline_score, 4) + entry = { + **diff, + "projected_path_qa": projected_qa, + "projected_quality_score": projected_score, + "baseline_quality_score": baseline_score, + "quality_delta": delta, + "improves_path": _slot_diff_improves_path(diff, delta), + } + scored.append(entry) + if entry["improves_path"]: + improving.append(entry) + else: + rejected.append(entry) + + return { + "baseline_quality_score": baseline_score, + "scored_diffs": scored, + "improvement_diffs": improving, + "rejected_diffs": rejected, + "improvement_count": len(improving), + "rejected_count": len(rejected), + } + + +def _off_topic_reasons_by_slot( + off_topic_steps: Sequence[Mapping[str, Any]], +) -> Dict[int, List[str]]: + out: Dict[int, List[str]] = {} + for item in off_topic_steps or []: + if not isinstance(item, dict): + continue + midx = item.get("roadmap_major_step_index") + if midx is None: + continue + try: + key = int(midx) + except (TypeError, ValueError): + continue + issue = str(item.get("issue") or "off_topic") + reasons = item.get("reasons") or [issue] + for raw in reasons: + text = str(raw or "").strip() + if text and text not in out.setdefault(key, []): + out[key].append(text[:400]) + return out + + +def _slot_issues_from_path_qa( + path_qa: Optional[Mapping[str, Any]], + major_idx: int, +) -> List[str]: + texts: List[str] = [] + if not isinstance(path_qa, dict): + return texts + for key in ("issues", "recommendations"): + for raw in path_qa.get(key) or []: + text = str(raw or "").strip() + if not text: + continue + if f"slot {major_idx + 1}" in text.lower() or f"stufe {major_idx + 1}" in text.lower(): + if text not in texts: + texts.append(text[:400]) + for hint in path_qa.get("optimization_hints") or []: + if not isinstance(hint, dict): + continue + hint_idx = hint.get("roadmap_major_step_index") + if hint_idx is None: + continue + try: + if int(hint_idx) != int(major_idx): + continue + except (TypeError, ValueError): + continue + text = str(hint.get("reason") or hint.get("issue") or "").strip() + if text and text not in texts: + texts.append(text[:400]) + return texts + + +def _build_slot_pro_contra( + *, + current_step: Mapping[str, Any], + proposed_step: Optional[Mapping[str, Any]], + suggestion_type: str, + baseline_qa: Optional[Mapping[str, Any]], + projected_qa: Optional[Mapping[str, Any]], + quality_delta: Optional[float], + off_topic_reasons: Sequence[str], + candidate_reasons: Sequence[str], + gap_offer: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + current_pro: List[str] = [] + current_contra: List[str] = list(off_topic_reasons or [])[:4] + proposed_pro: List[str] = [str(r) for r in (candidate_reasons or []) if str(r or "").strip()][:4] + proposed_contra: List[str] = [] + + if current_step.get("exercise_id") is not None and not current_contra: + current_pro.append("Bestehende Zuordnung im Graph") + if current_step.get("is_ai_proposal"): + sketch = (current_step.get("title") or "KI-Entwurf").strip() + current_pro.append(f"KI-Entwurf: {sketch[:120]}") + + major_idx = current_step.get("roadmap_major_step_index") + if major_idx is not None: + for text in _slot_issues_from_path_qa(baseline_qa, int(major_idx)): + if text not in current_contra: + current_contra.append(text) + + if quality_delta is not None and quality_delta > 0: + proposed_pro.append(f"Pfad-QS +{round(float(quality_delta) * 100)} Prozentpunkte") + elif suggestion_type in {"library_fill", "remove_and_replace", "ai_gap"} and not current_contra: + proposed_pro.append("Schließt Lücke bzw. passt besser zur Stufe") + + if isinstance(gap_offer, dict): + sketch = str(gap_offer.get("sketch") or gap_offer.get("title_hint") or "").strip() + if sketch: + proposed_pro.append(f"KI-Entwurf: {sketch[:160]}") + rationale = str(gap_offer.get("rationale") or "").strip() + if rationale: + proposed_pro.append(rationale[:200]) + + if isinstance(projected_qa, dict): + for text in _slot_issues_from_path_qa(projected_qa, int(major_idx or 0)): + if text not in proposed_contra: + proposed_contra.append(text) + + if proposed_step and proposed_step.get("exercise_id") is not None and not proposed_pro: + proposed_pro.append("Bibliotheks-Treffer für Stufen-Lernziel") + + return { + "current_pro": current_pro[:6], + "current_contra": current_contra[:6], + "proposed_pro": proposed_pro[:6], + "proposed_contra": proposed_contra[:6], + } + + +def _roadmap_slot_library_candidates( + 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, + planned_ids: List[int], + anchor_id: Optional[int], + anchor_variant_id: Optional[int], + used: Set[int], + exclude_exercise_id: Optional[int] = None, + max_candidates: int = 5, +) -> List[Dict[str, Any]]: + """Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen).""" + pick_used = set(used) + if exclude_exercise_id is not None: + try: + pick_used.add(int(exclude_exercise_id)) + except (TypeError, ValueError): + pass + candidates: List[Dict[str, Any]] = [] + seen_ids: Set[int] = set() + for _ in range(max(1, max_candidates)): + step, _unfilled = _match_roadmap_slot( + 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=pick_used, + slot_priority_exercise_id=None, + ) + if not step or step.get("exercise_id") is None: + break + try: + eid = int(step["exercise_id"]) + except (TypeError, ValueError): + break + if eid in seen_ids: + break + seen_ids.add(eid) + candidates.append(step) + pick_used.add(eid) + return candidates + + +def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]: + return { + "roadmap_major_step_index": entry.get("roadmap_major_step_index"), + "baseline_exercise_id": entry.get("baseline_exercise_id"), + "baseline_title": entry.get("baseline_title"), + "proposed_exercise_id": entry.get("proposed_exercise_id"), + "proposed_title": entry.get("proposed_title"), + "baseline_slot_status": entry.get("baseline_slot_status"), + "proposed_slot_status": entry.get("proposed_slot_status"), + "changed": True, + "suggestion_type": entry.get("suggestion_type"), + "quality_delta": entry.get("quality_delta"), + "projected_quality_score": entry.get("projected_quality_score"), + "baseline_quality_score": entry.get("baseline_quality_score"), + "projected_path_qa": entry.get("projected_path_qa"), + "pro_contra": entry.get("pro_contra"), + "improves_path": entry.get("improves_path"), + "off_topic": entry.get("off_topic"), + "gap_offer": entry.get("gap_offer"), + "proposed_is_ai_proposal": entry.get("proposed_is_ai_proposal"), + } + + +def _run_unified_slot_improvement_review( + 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]: + """ + Ein Workflow: Pfad bewerten → je Slot Alternativen suchen → Einzel-QS → nur Verbesserungen. + """ + if not body.baseline_evaluate_steps: + raise HTTPException( + status_code=400, + detail="unified_slot_review erfordert baseline_evaluate_steps", + ) + if roadmap_ctx is None or not roadmap_ctx.stage_specs: + raise HTTPException( + status_code=400, + detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", + ) + + eval_body = body.model_copy( + update={ + "include_llm_path_qa": body.include_llm_path_qa, + "include_ai_gap_fill": body.include_ai_gap_fill, + "auto_rematch_after_qa": False, + } + ) + baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) + qa_pack = _run_evaluate_only_path_qa( + cur, + body=eval_body, + goal_query=goal_query, + semantic_brief=semantic_brief, + steps=list(baseline_steps), + roadmap_ctx=roadmap_ctx, + ) + baseline_steps = list(qa_pack.get("steps") or baseline_steps) + baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} + baseline_score = _path_qa_quality_score(baseline_qa) + gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) + off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or []) + + steps_by_major = _steps_by_major_index(baseline_steps) + spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs} + stage_count = len(roadmap_ctx.stage_specs) + + suggestions: List[Dict[str, Any]] = [] + rejected: List[Dict[str, Any]] = [] + scored_eval_body = body.model_copy( + update={ + "include_llm_path_qa": False, + "include_ai_gap_fill": False, + "auto_rematch_after_qa": False, + "include_roadmap_preview": False, + } + ) + + 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_id = current.get("exercise_id") + off_topic = major_idx in off_topic_map or bool( + current.get("slot_status") in {"off_topic", "stripped"} + ) + off_reasons = off_topic_map.get(major_idx, []) + + 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 + + exclude_id: Optional[int] = None + if current_id is not None and not off_topic: + try: + exclude_id = int(current_id) + except (TypeError, ValueError): + exclude_id = 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=exclude_id if not off_topic else int(current_id) if current_id else None, + ) + + accepted_for_slot = False + for candidate in candidates: + try: + cand_id = int(candidate.get("exercise_id")) + except (TypeError, ValueError): + continue + if ( + current_id is not None + and not off_topic + and int(current_id) == cand_id + ): + continue + 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": (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, **candidate, "roadmap_major_step_index": major_idx} + break + eval_res = _evaluate_steps_for_compare_qa( + cur, + tenant=tenant, + body=scored_eval_body, + steps=merged_steps, + ) + projected_qa = ( + eval_res.get("path_qa") + if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict) + else None + ) + projected_score = _path_qa_quality_score(projected_qa) + delta: Optional[float] = None + if baseline_score is not None and projected_score is not None: + delta = round(projected_score - baseline_score, 4) + improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic) + suggestion_type = ( + "remove_and_replace" + if off_topic and current_id is not None + else ("library_fill" if current_id is None else "library_improvement") + ) + entry = { + **diff_stub, + "baseline_slot_status": current.get("slot_status"), + "proposed_slot_status": candidate.get("slot_status") or "matched", + "suggestion_type": suggestion_type, + "quality_delta": delta, + "projected_quality_score": projected_score, + "baseline_quality_score": baseline_score, + "projected_path_qa": projected_qa, + "improves_path": improves, + "off_topic": off_topic, + "proposed_is_ai_proposal": False, + "pro_contra": _build_slot_pro_contra( + current_step=current, + proposed_step=candidate, + suggestion_type=suggestion_type, + baseline_qa=baseline_qa, + projected_qa=projected_qa, + quality_delta=delta, + off_topic_reasons=off_reasons, + candidate_reasons=candidate.get("reasons") or [], + ), + } + if improves: + suggestions.append(entry) + accepted_for_slot = True + break + rejected.append(entry) + + if accepted_for_slot: + continue + + # Kein Bibliotheks-Treffer oder keine Verbesserung → KI-Angebot wenn Slot leer/off-topic/KI + needs_ai = ( + current_id is None + or off_topic + or bool(current.get("is_ai_proposal")) + ) + if not needs_ai or not body.include_ai_gap_fill: + continue + 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) + + ai_step = { + **current, + "exercise_id": None, + "is_ai_proposal": True, + "title": slot_offer.get("title_hint") or current.get("title") or f"Slot {major_idx + 1}", + "roadmap_major_step_index": major_idx, + "gap_offer": slot_offer, + } + 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": None, + "proposed_title": ai_step.get("title"), + } + merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, [ai_step]) + eval_res = _evaluate_steps_for_compare_qa( + cur, + tenant=tenant, + body=scored_eval_body, + steps=merged_steps, + ) + projected_qa = ( + eval_res.get("path_qa") + if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict) + else None + ) + projected_score = _path_qa_quality_score(projected_qa) + delta = ( + round(projected_score - baseline_score, 4) + if baseline_score is not None and projected_score is not None + else None + ) + improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic or current_id is None) + entry = { + **diff_stub, + "baseline_slot_status": current.get("slot_status"), + "proposed_slot_status": "ai_proposal", + "suggestion_type": "ai_gap", + "quality_delta": delta, + "projected_quality_score": projected_score, + "baseline_quality_score": baseline_score, + "projected_path_qa": projected_qa, + "improves_path": improves, + "off_topic": off_topic, + "proposed_is_ai_proposal": True, + "gap_offer": slot_offer, + "pro_contra": _build_slot_pro_contra( + current_step=current, + proposed_step=None, + suggestion_type="ai_gap", + baseline_qa=baseline_qa, + projected_qa=projected_qa, + quality_delta=delta, + off_topic_reasons=off_reasons, + candidate_reasons=[], + gap_offer=slot_offer, + ), + } + if improves: + suggestions.append(entry) + else: + rejected.append(entry) + + improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] + slot_diff_scoring = { + "baseline_quality_score": baseline_score, + "scored_diffs": improvement_diffs + [_suggestion_as_slot_diff(r) for r in rejected], + "improvement_diffs": improvement_diffs, + "rejected_diffs": [_suggestion_as_slot_diff(r) for r in rejected], + "improvement_count": len(improvement_diffs), + "rejected_count": len(rejected), + } + + 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), + "semantic_brief_summary": brief_to_summary_dict(semantic_brief), + "semantic_llm_applied": semantic_llm_applied, + "query_intent_summary": first_intent_summary, + "progression_graph_id": body.progression_graph_id, + "path_qa": baseline_qa, + "baseline_path_qa": baseline_qa, + "baseline_steps": baseline_steps, + "gap_fill_offers": gap_fill_offers, + "progression_roadmap": progression_roadmap, + "roadmap_first": True, + "roadmap_only": False, + "roadmap_edited": roadmap_edited, + "roadmap_unfilled_count": 0, + "path_skill_expectations": None, + "match_summary": { + "unified_slot_review": True, + "suggestion_count": len(suggestions), + "rejected_count": len(rejected), + }, + "retrieval_phase": "unified_slot_review", + "unified_slot_review": True, + "slot_suggestions": suggestions, + "slot_diff_scoring": slot_diff_scoring, + "comparison_mode": True, + } + + def _merge_gap_fill_offers_from_steps( steps: Sequence[Mapping[str, Any]], offers: Sequence[Mapping[str, Any]], @@ -2698,6 +3385,23 @@ def suggest_progression_path( path_target_profile = apply_expectations_to_target(path_target_profile, path_exp) path_skill_expectations = path_exp.to_api_dict() + if body.unified_slot_review: + return _run_unified_slot_improvement_review( + 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, + ) + roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = [] roadmap_gap_offers: List[Dict[str, Any]] = [] @@ -3081,6 +3785,28 @@ def suggest_progression_path( if refine_log: retrieval_parts.append("stage_spec_refine") + slot_diff_scoring: Optional[Dict[str, Any]] = None + if ( + body.include_incremental_diff_scoring + and body.baseline_evaluate_steps + and not evaluate_only + and not body.compare_with_assignments + ): + baseline_steps_for_scoring = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) + raw_diffs = _build_progression_slot_diffs(baseline_steps_for_scoring, steps) + baseline_qa_for_scoring: Optional[Dict[str, Any]] = None + if body.baseline_quality_score is not None: + baseline_qa_for_scoring = {"quality_score": float(body.baseline_quality_score)} + slot_diff_scoring = _score_incremental_slot_diffs( + cur, + tenant=tenant, + body=body, + baseline_steps=baseline_steps_for_scoring, + proposed_steps=steps, + baseline_path_qa=baseline_qa_for_scoring, + raw_diffs=raw_diffs, + ) + return { "goal_query": goal_query, "max_steps_requested": max_steps, @@ -3101,6 +3827,7 @@ def suggest_progression_path( "path_skill_expectations": path_skill_expectations, "match_summary": match_summary, "retrieval_phase": "+".join(retrieval_parts), + "slot_diff_scoring": slot_diff_scoring, } diff --git a/backend/tests/test_planning_incremental_diff_scoring.py b/backend/tests/test_planning_incremental_diff_scoring.py new file mode 100644 index 0000000..7e5eb2d --- /dev/null +++ b/backend/tests/test_planning_incremental_diff_scoring.py @@ -0,0 +1,39 @@ +"""Tests inkrementelles Slot-Diff-Scoring (nur messbare Verbesserungen).""" +from planning_exercise_path_builder import ( + _apply_slot_diff_to_steps, + _slot_diff_improves_path, +) + + +def test_slot_diff_improves_path_fill_neutral_or_positive(): + fill = {"baseline_exercise_id": None, "proposed_exercise_id": 101} + assert _slot_diff_improves_path(fill, 0.0) is True + assert _slot_diff_improves_path(fill, 0.04) is True + assert _slot_diff_improves_path(fill, -0.01) is False + + +def test_slot_diff_improves_path_off_topic_allows_neutral_replace(): + repl = {"baseline_exercise_id": 10, "proposed_exercise_id": 99} + assert _slot_diff_improves_path(repl, 0.0, off_topic=True) is True + assert _slot_diff_improves_path(repl, -0.02, off_topic=True) is False + + +def test_apply_slot_diff_merges_proposed_step(): + baseline = [ + {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}, + {"roadmap_major_step_index": 1, "exercise_id": None, "title": "Leer"}, + ] + proposed = [ + {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}, + {"roadmap_major_step_index": 1, "exercise_id": 55, "title": "Neu", "slot_status": "matched"}, + ] + diff = { + "roadmap_major_step_index": 1, + "baseline_exercise_id": None, + "proposed_exercise_id": 55, + "proposed_title": "Neu", + } + merged = _apply_slot_diff_to_steps(baseline, diff, proposed) + assert merged[0]["exercise_id"] == 1 + assert merged[1]["exercise_id"] == 55 + assert merged[1]["title"] == "Neu" diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 0fbdcd2..4d87c5b 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -28,11 +28,13 @@ import { applyEvaluateResponseToDraft, applyGapOfferToDraft, applySelectedCompareSteps, + applySelectedSlotSuggestions, applyResolvedStructuredToDraft, buildPlanningArtifactFromDraft, buildProgressionComparePayload, collectGapOffersFromApiResponse, compareSlotDiffs, + compareDiffsForDialog, dedupeGapOffersBySlot, draftHasLibrarySlotAssignments, draftRetrievalBoostExerciseIds, @@ -47,7 +49,7 @@ import { patchSlotInDraft, pathQaQualityPercent, planningCatalogContextToApi, - recommendedCompareDiffs, + rejectedCompareDiffs, removeSlotFromDraft, saveProgressionGraphDraft, setCatalogSelectItems, @@ -491,24 +493,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } - const fetchFullMatch = async (synced) => - api.suggestProgressionPath({ + const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { + setMatchNotice('Pfad bewerten und je Slot passende Verbesserungen prüfen…') + const reviewRes = await api.suggestProgressionPath({ ...buildMatchRequestBase(synced), - preserve_slot_assignments: false, + unified_slot_review: true, + baseline_evaluate_steps: slotsToEvaluateSteps(synced), include_llm_intent: false, include_llm_path_qa: false, + auto_rematch_after_qa: false, }) + setPathQa(reviewRes?.path_qa || null) - const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { - setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…') - const baselineRes = await fetchPathEvaluate(synced) - setPathQa(baselineRes?.path_qa || null) - - setMatchNotice('Schritt 2/2: Match für alle Slots (Bibliothek + Lücken)…') - const matchRes = await fetchFullMatch(synced) - - const compareRes = buildProgressionComparePayload(baselineRes, matchRes) - setGapFillOffers(mergeGapOffersForDraft(synced, baselineRes, matchRes)) + const compareRes = buildProgressionComparePayload(null, reviewRes) + setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes)) presentMatchCompare(compareRes, { source }) return compareRes } @@ -522,31 +520,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setCompareOpen(true) const baselineQa = res?.baseline_path_qa || null - const proposedQa = res?.proposed_path_qa || res?.path_qa || null - const diffCount = - res?.slot_diff_count_recommended - ?? recommendedCompareDiffs(res).length - ?? res?.slot_diff_count - ?? compareSlotDiffs(res, { actionableOnly: true }).length - const replaceCount = (res?.slot_diffs || []).filter( - (d) => d?.diff_kind === 'replace', - ).length + const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length + const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length const bPct = pathQaQualityPercent(baselineQa) - const pPct = pathQaQualityPercent(proposedQa) let notice = diffCount > 0 - ? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.` - : 'Match: Keine Bibliotheks-Lückenfüllungen — Dialog zur Kontrolle geöffnet.' - if (replaceCount > 0) { - notice += ` ${replaceCount} optionale Ersetzung(en) bestehender Slots — standardmäßig abgewählt.` + ? `Match: ${diffCount} Verbesserung(en) — je Slot gegen deinen Pfad (${bPct != null ? `${bPct} %` : 'QS'}) geprüft.` + : 'Match: Keine messbare Verbesserung gegenüber deinem Pfad.' + if (rejectedCount > 0) { + notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` } const gapCount = collectGapOffersFromApiResponse(res).length if (gapCount > 0) { notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` } - if (bPct != null && pPct != null && pPct !== bPct) { - notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.` - } setMatchNotice(notice) } @@ -603,11 +590,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setCompareApplying(true) try { const synced = syncProgressionRoadmapFromSlots(draft) - const nextDraft = applySelectedCompareSteps( - synced, - comparePayload.proposed_steps || comparePayload.steps, - selectedMajorIndices, - ) + const nextDraft = comparePayload?.unified_slot_review + ? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices) + : applySelectedCompareSteps( + synced, + comparePayload.proposed_steps || comparePayload.steps, + selectedMajorIndices, + ) const syncedNext = syncProgressionRoadmapFromSlots(nextDraft) const evalRes = await fetchPathEvaluate(syncedNext) const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes) diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index a00faa1..2e28f81 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -1,14 +1,13 @@ /** - * Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag. + * Gegenüberstellung: Verbesserungsvorschläge mit Slot-Bewertung (Pro/Contra). */ import React, { useMemo, useState } from 'react' import { compareDiffsForDialog, defaultSelectedCompareDiffs, - gapOnlyCompareDiffs, - optionalReplaceCompareDiffs, pathQaQualityPercent, - recommendedCompareDiffs, + qualityDeltaPercent, + rejectedCompareDiffs, } from '../utils/progressionGraphDraft' function qaLabel(pathQa) { @@ -18,25 +17,44 @@ function qaLabel(pathQa) { return ok ? 'OK' : 'Hinweise' } -function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) { +function deltaLabel(diff) { + const pct = qualityDeltaPercent(diff) + if (pct == null) return null + if (pct > 0) return `+${pct} % Pfad-QS` + if (pct === 0) return '±0 % Pfad-QS' + return `${pct} % Pfad-QS` +} + +function ProContraList({ title, items, tone = 'neutral' }) { + if (!items?.length) return null + const color = + tone === 'pro' ? 'var(--accent-dark)' : tone === 'contra' ? 'var(--danger)' : 'var(--text2)' + return ( +
+
{title}
+ +
+ ) +} + +function DiffRow({ diff, checked, onToggle, applying }) { const midx = Number(diff.roadmap_major_step_index) - const border = - tone === 'warn' - ? '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))' - : '1px solid var(--border)' - const bg = checked - ? tone === 'warn' - ? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))' - : 'var(--surface2)' - : 'var(--surface)' + const delta = deltaLabel(diff) + const pc = diff.pro_contra || {} + const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal + const isFill = diff.baseline_exercise_id == null && !isAi return (
  • @@ -49,28 +67,60 @@ function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) { style={{ marginTop: '3px' }} /> - Slot {midx + 1} - {tone === 'warn' ? ( - - Ersetzt deine Zuordnung - - ) : null} +
    + Slot {midx + 1} + {isFill ? ( + Lücke füllen + ) : isAi ? ( + KI-Alternative + ) : diff.off_topic ? ( + Passt nicht — Ersatz + ) : ( + Bessere Übung + )} + {delta ? ( + 0 ? 'var(--accent-dark)' : 'var(--text2)', + }} + > + {delta} + + ) : null} +
    +
    - - Bisher: {diff.baseline_title || '— leer —'} - {diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} - - - Neu: {diff.proposed_title || '— leer —'} - {diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''} - +
    +
    + Aktuell +
    +
    + {diff.baseline_title || '— leer —'} + {diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} +
    + + +
    +
    +
    + Vorschlag +
    +
    + {diff.proposed_title || '—'} + {diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''} +
    + + +
    @@ -86,16 +136,8 @@ export default function ProgressionOptimizeCompareModal({ onApplySelected, applying = false, }) { - const recommended = useMemo( - () => recommendedCompareDiffs(comparison), - [comparison], - ) - const optionalReplace = useMemo( - () => optionalReplaceCompareDiffs(comparison), - [comparison], - ) - const gapOnly = useMemo(() => gapOnlyCompareDiffs(comparison), [comparison]) const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) + const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison]) const defaultSelected = useMemo( () => defaultSelectedCompareDiffs(comparison), [comparison], @@ -111,18 +153,8 @@ export default function ProgressionOptimizeCompareModal({ if (!open || !comparison) return null const baselineQa = comparison.baseline_path_qa - const pipelineQa = comparison.proposed_path_qa_pipeline const baselinePct = pathQaQualityPercent(baselineQa) - const pipelinePct = pathQaQualityPercent(pipelineQa) - const rematchRounds = pipelineQa?.rematch_rounds - const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0 - const refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0 - const hintCount = Number(pipelineQa?.optimization_hint_count || 0) - const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0 - - const selectedReplaceCount = optionalReplace.filter((d) => - selected.has(Number(d.roadmap_major_step_index)), - ).length + const rejectedCount = rejected.length const toggle = (midx) => { setSelected((prev) => { @@ -133,20 +165,12 @@ export default function ProgressionOptimizeCompareModal({ }) } - const toggleGroup = (diffs, on) => { - setSelected((prev) => { - const next = new Set(prev) - for (const d of diffs) { - const midx = Number(d.roadmap_major_step_index) - if (on) next.add(midx) - else next.delete(midx) - } - return next - }) + const toggleAll = (on) => { + setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set()) } const title = - mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen' + mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen' return (
    e.stopPropagation()} >

    {title}

    - Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind - vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter. - KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel „KI-Angebote“, nicht hierher. + Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere + Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen + hier — mit Pro- und Contra-Punkten auf Slot-Ebene.

    - Warum nicht einfach alles übernehmen? -

    - Der Match-Lauf optimiert den gesamten Pfad neu (inkl. Rematch). Das kann - bereits gute Slots verschlechtern. Die Prozentzahl rechts bezieht sich auf diesen - Ganzpfad — nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig - nur Lückenfüllungen an. -

    -
    - -
    -
    - Dein Pfad (bewertet) -
    {qaLabel(baselineQa)}
    - {baselineQa?.topic_coverage ? ( -

    {baselineQa.topic_coverage}

    - ) : null} -
    -
    - Match-Ganzpfad (nur Info) -
    - {pipelineQa ? qaLabel(pipelineQa) : '—'} -
    -

    - Rematch-Prozess — kein Versprechen für deine Checkbox-Auswahl. - {pipelinePct != null && baselinePct != null && pipelinePct < baselinePct - ? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).` - : ''} -

    -
    + Dein Pfad +
    {qaLabel(baselineQa)}
    + {baselineQa?.topic_coverage ? ( +

    {baselineQa.topic_coverage}

    + ) : null}
    - {tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? ( + {rejectedCount > 0 ? (

    - Rematch-Protokoll - {tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''} - {rematchCount > 0 - ? ` · ${rematchRounds != null ? `${rematchRounds} Runde(n)` : ''}: ${rematchCount} Anpassung(en)` - : ''} - {refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''} - {hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''} -

    - ) : null} - - {gapOnly.length > 0 ? ( -

    - Slot{gapOnly.length > 1 ? 's' : ''}{' '} - {gapOnly.map((d) => Number(d.roadmap_major_step_index) + 1).join(', ')}: kein - Bibliotheks-Treffer — bitte „KI-Angebote“ im Panel nutzen (eigenständig pro Slot). + {rejectedCount} Alternative(n) verworfen — kein QS-Gewinn gegenüber deinem Pfad + {baselinePct != null ? ` (${baselinePct} %)` : ''}.

    ) : null} {dialogDiffs.length === 0 ? (

    - Keine übernehmbaren Bibliotheks-Änderungen. Leere Slots ggf. über KI-Angebote im Panel - befüllen — nichts am Pfad ändern ist oft die richtige Wahl. + Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet + oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).

    ) : ( <> - {recommended.length > 0 ? ( - <> -

    - Lücken füllen (empfohlen) -

    -
    - - -
    -
      - {recommended.map((diff) => ( - - ))} -
    - - ) : null} - - {optionalReplace.length > 0 ? ( - <> -

    - Bestehende Slots ersetzen (optional — oft Verschlechterung) -

    -

    - Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen - willst. -

    -
      - {optionalReplace.map((diff) => ( - - ))} -
    - - ) : null} +
    + + +
    +
      + {dialogDiffs.map((diff) => ( + + ))} +
    )} - {selectedReplaceCount > 0 ? ( -

    - {selectedReplaceCount} Ersetzung(en) gewählt — kann Pfad-QS senken. Lückenfüllungen - sind unkritischer. -

    - ) : null} -
    ({ ...d, @@ -1004,20 +1010,57 @@ export function annotateCompareDiffKinds(diffs) { })) } -/** Nur übernehmbare Bibliotheks-Diffs (kein reines Titel-/Gap-Geplänkel). */ +/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ export function compareDiffsForDialog(comparison) { + const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) + if (fromSuggestions.length > 0) { + return fromSuggestions + .map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) })) + .filter( + (d) => + d.proposed_exercise_id != null + || (d.suggestion_type === 'ai_gap' && d.gap_offer), + ) + } + if (Array.isArray(comparison?.slot_diffs_improving)) { + return comparison.slot_diffs_improving.filter( + (d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap, + ) + } const diffs = annotateCompareDiffKinds( compareSlotDiffs(comparison, { actionableOnly: true }), ) - return diffs.filter((d) => d.diff_kind === 'fill' || d.diff_kind === 'replace') + return diffs.filter( + (d) => + (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null, + ) +} + +export function suggestionDiffKind(suggestion) { + if (!suggestion) return 'skip' + if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' + if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) { + return 'fill' + } + if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) { + return 'replace' + } + return 'skip' } export function recommendedCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill') + return compareDiffsForDialog(comparison) } export function optionalReplaceCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'replace') + return [] +} + +export function rejectedCompareDiffs(comparison) { + return Array.isArray(comparison?.slot_diffs_rejected) + ? comparison.slot_diffs_rejected + : [] } export function gapOnlyCompareDiffs(comparison) { @@ -1027,7 +1070,7 @@ export function gapOnlyCompareDiffs(comparison) { } export function defaultSelectedCompareDiffs(comparison) { - return recommendedCompareDiffs(comparison).map((d) => Number(d.roadmap_major_step_index)) + return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) } function mergeGapFillOffersFromSteps(steps, offers) { @@ -1044,29 +1087,43 @@ function mergeGapFillOffersFromSteps(steps, offers) { } /** - * Vergleich aus zwei kaskadierten Antworten (Evaluate → Match) — spiegelt Backend-Compare. + * Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match). */ export function buildProgressionComparePayload(baselineRes, proposedRes) { + if (proposedRes?.unified_slot_review) { + return buildUnifiedSlotReviewComparePayload(proposedRes) + } + const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] const baselineQa = baselineRes?.path_qa || null const pipelineQa = proposedRes?.path_qa || null - const slotDiffs = annotateCompareDiffKinds( + const scoring = proposedRes?.slot_diff_scoring + const rawDiffs = annotateCompareDiffKinds( annotateCompareSlotDiffs( buildProgressionSlotDiffs(baselineSteps, proposedSteps), ), ) - const actionableDiffs = actionableCompareSlotDiffs(slotDiffs) - const dialogDiffs = actionableDiffs.filter( - (d) => d.diff_kind === 'fill' || d.diff_kind === 'replace', + const improvingDiffs = annotateCompareDiffKinds( + (scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null), ) - const recommendedDiffs = dialogDiffs.filter((d) => d.diff_kind === 'fill') + const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || []) + const dialogDiffs = improvingDiffs.length > 0 + ? improvingDiffs + : rawDiffs.filter( + (d) => + !d.trivial_id_swap + && (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null + && d.improves_path !== false, + ) + const actionableDiffs = dialogDiffs const gapFillOffers = mergeGapFillOffersFromSteps( proposedSteps, proposedRes?.gap_fill_offers || [], ) - const proposedQa = - actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa + const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score + const proposedQa = baselineQa return { ...proposedRes, @@ -1078,19 +1135,110 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) { proposed_path_qa: proposedQa, proposed_path_qa_pipeline: pipelineQa, gap_fill_offers: gapFillOffers, - slot_diffs: slotDiffs, + slot_diffs: rawDiffs, slot_diffs_actionable: actionableDiffs, + slot_diffs_improving: improvingDiffs, + slot_diffs_rejected: rejectedDiffs, slot_diffs_dialog: dialogDiffs, - slot_diffs_recommended: recommendedDiffs, + slot_diffs_recommended: dialogDiffs, slot_diff_count: dialogDiffs.length, - slot_diff_count_recommended: recommendedDiffs.length, - slot_diff_count_including_trivial: slotDiffs.length, - slot_diffs_source: 'steps', + slot_diff_count_recommended: dialogDiffs.length, + slot_diff_count_rejected: rejectedDiffs.length, + slot_diff_count_including_trivial: rawDiffs.length, + slot_diffs_source: scoring ? 'incremental_scoring' : 'steps', + slot_diff_scoring: scoring, + baseline_quality_score: baselineScore, path_qa: proposedQa, steps: proposedSteps, } } +/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */ +export function buildUnifiedSlotReviewComparePayload(res) { + const baselineSteps = Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []) + const baselineQa = res?.baseline_path_qa || res?.path_qa || null + const scoring = res?.slot_diff_scoring + const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] + const improving = suggestions.filter((s) => s?.improves_path) + const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : [] + const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) + const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] + + return { + ...res, + comparison_mode: true, + unified_slot_review: true, + baseline_steps: baselineSteps, + baseline_path_qa: baselineQa, + proposed_steps: proposedSteps, + proposed_steps_pipeline: proposedSteps, + proposed_path_qa: baselineQa, + proposed_path_qa_pipeline: null, + gap_fill_offers: gapFillOffers, + slot_suggestions: suggestions, + slot_diffs: improving, + slot_diffs_improving: improving, + slot_diffs_rejected: rejected, + slot_diffs_dialog: improving, + slot_diffs_recommended: improving, + slot_diff_count: improving.length, + slot_diff_count_recommended: improving.length, + slot_diff_count_rejected: rejected.length, + slot_diffs_source: 'unified_slot_review', + slot_diff_scoring: scoring, + baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score, + path_qa: baselineQa, + steps: baselineSteps, + } +} + +function suggestionToApplyStep(suggestion) { + if (!suggestion || suggestion.roadmap_major_step_index == null) return null + const midx = Number(suggestion.roadmap_major_step_index) + if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) { + const offer = suggestion.gap_offer + return { + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + } + } + if (suggestion.proposed_exercise_id == null) return null + return { + roadmap_major_step_index: midx, + exercise_id: suggestion.proposed_exercise_id, + title: suggestion.proposed_title, + slot_status: suggestion.proposed_slot_status || 'matched', + is_ai_proposal: false, + } +} + +/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ +export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) { + const selected = new Set( + (selectedMajorIndices || []) + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x)), + ) + if (!selected.size) return draft + const steps = (comparison?.slot_suggestions || []) + .filter((s) => selected.has(Number(s.roadmap_major_step_index))) + .map(suggestionToApplyStep) + .filter(Boolean) + if (!steps.length) { + return applySelectedCompareSteps( + draft, + comparison?.proposed_steps || comparison?.steps, + selectedMajorIndices, + ) + } + return applyMatchStepsToSlots(draft, steps) +} + /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {