diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 87db549..868e706 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -35,6 +35,7 @@ from planning_path_rematch import ( from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target from planning_exercise_path_qa import ( + _load_exercise_text_bundle, apply_llm_path_reorder, build_path_qa_summary, compute_deterministic_path_quality_score, @@ -66,6 +67,7 @@ from planning_exercise_semantics import ( exercise_passes_stage_fit, exercise_title_matches_peer_stage_goal, pick_best_path_hit, + score_exercise_stage_fit, resolve_semantic_skill_weights, step_phase_for_index, step_retrieval_query, @@ -3019,6 +3021,92 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]: } +_SLOT_FIT_POOR_THRESHOLD = 0.30 + + +def _off_topic_semantic_scores_by_slot( + off_topic_steps: Sequence[Mapping[str, Any]], +) -> Dict[int, float]: + scores: Dict[int, float] = {} + 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) + raw = item.get("semantic_score") + if raw is not None: + scores[key] = round(float(raw), 4) + except (TypeError, ValueError): + continue + return scores + + +def _score_exercise_stage_fit_for_spec( + cur, + *, + exercise_id: int, + step: Mapping[str, Any], + stage_spec: StageSpecArtifact, + semantic_brief: PlanningSemanticBrief, + step_index: int, + stage_count: int, +) -> Optional[float]: + try: + eid = int(exercise_id) + except (TypeError, ValueError): + return None + if eid < 1: + return None + bundle = _load_exercise_text_bundle(cur, eid) + stage_goal = (stage_spec.learning_goal or step.get("roadmap_learning_goal") or "").strip() + phase = ( + (step.get("roadmap_phase") or "").strip().lower() + or step_phase_for_index(semantic_brief, step_index, stage_count) + ) + stage_anti = list(stage_spec.anti_patterns or step.get("roadmap_anti_patterns") or []) + stage_match_brief = ( + build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=stage_anti or None, + phase=phase or None, + ) + if stage_goal + else None + ) + if not stage_match_brief: + return None + score, _ = score_exercise_stage_fit( + title=bundle["title"], + summary=bundle["summary"], + goal=bundle["goal"], + variant_names=bundle["variant_names"], + stage_brief=stage_match_brief, + step_phase=phase, + ) + return round(float(score), 4) + + +def _slot_auto_select_library( + *, + baseline_slot_score: Optional[float], + proposed_slot_score: Optional[float], + baseline_exercise_id: Optional[int], + proposed_exercise_id: Optional[int], +) -> bool: + if proposed_exercise_id is None: + return False + if baseline_exercise_id is not None and int(baseline_exercise_id) == int(proposed_exercise_id): + return False + if proposed_slot_score is None: + return False + if baseline_slot_score is None: + return True + return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 + + def _run_unified_slot_improvement_review( cur, *, @@ -3109,6 +3197,10 @@ def _run_unified_slot_improvement_review( spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs} stage_count = len(roadmap_ctx.stage_specs) + off_topic_scores = _off_topic_semantic_scores_by_slot( + baseline_qa.get("off_topic_steps") or [], + ) + slot_reviews: List[Dict[str, Any]] = [] suggestions: List[Dict[str, Any]] = [] rejected: List[Dict[str, Any]] = [] @@ -3116,6 +3208,7 @@ def _run_unified_slot_improvement_review( 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( @@ -3123,6 +3216,18 @@ def _run_unified_slot_improvement_review( ) 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()) @@ -3141,19 +3246,6 @@ def _run_unified_slot_improvement_review( 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 or slot_problem): - try: - exclude_id = int(current_id) - except (TypeError, ValueError): - exclude_id = None - elif current_id is not None and (off_topic or slot_problem): - try: - exclude_id = int(current_id) - except (TypeError, ValueError): - exclude_id = None - - relax_match_gate = bool(off_topic or slot_problem) candidates = _roadmap_slot_library_candidates( cur, tenant=tenant, @@ -3171,208 +3263,199 @@ def _run_unified_slot_improvement_review( 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 exclude_id, - max_candidates=5 if relax_match_gate else 3, - skip_post_match_gate=relax_match_gate, + exclude_exercise_id=int(current_id) if current_id is not None else None, + max_candidates=5, + skip_post_match_gate=True, ) - accepted_for_slot = False + 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 not (off_topic or slot_problem) - and int(current_id) == cand_id - ): + if current_id is not None and int(current_id) == cand_id: continue - diff_stub = { + 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( + { "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, - "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 = _quick_evaluate_steps_qa( - cur, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=merged_steps, - roadmap_ctx=roadmap_ctx, - ) - projected_qa = eval_res if isinstance(eval_res, dict) else None - projected_score = _path_qa_quality_score(projected_qa) - delta = _quality_delta(baseline_score, projected_score) - improves = _slot_suggestion_accepted( - baseline_qa=baseline_qa, - projected_qa=projected_qa, - baseline_score=baseline_score, - projected_score=projected_score, - diff=diff_stub, - off_topic=off_topic, - major_idx=major_idx, - slot_problem=slot_problem, - stage_specs=roadmap_ctx.stage_specs, - baseline_steps=baseline_steps, - projected_steps=merged_steps, - ) - 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") - ) - entry = { - **diff_stub, + "baseline_slot_score": baseline_slot_score, "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, "slot_problem": slot_problem, + "off_topic": off_topic, "problem_reasons": off_reasons[:6], - "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 [], - ), + "library_alternative": library_alt, + "ai_alternative": ai_alt, } - 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 slot_problem - 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 = _quick_evaluate_steps_qa( - cur, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=merged_steps, - roadmap_ctx=roadmap_ctx, - ) - projected_qa = eval_res if isinstance(eval_res, dict) else None - projected_score = _path_qa_quality_score(projected_qa) - delta = _quality_delta(baseline_score, projected_score) - improves = _slot_suggestion_accepted( - baseline_qa=baseline_qa, - projected_qa=projected_qa, - baseline_score=baseline_score, - projected_score=projected_score, - diff=diff_stub, - off_topic=off_topic, - major_idx=major_idx, - slot_problem=slot_problem, - stage_specs=roadmap_ctx.stage_specs, - baseline_steps=baseline_steps, - projected_steps=merged_steps, - ) - 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 or slot_problem, - "off_topic": off_topic, - "slot_problem": slot_problem, - "problem_reasons": off_reasons[:6], - "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 or slot_problem: - entry["improves_path"] = True - suggestions.append(entry) - else: - rejected.append(entry) improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] problem_slot_payload = { @@ -3412,9 +3495,11 @@ def _run_unified_slot_improvement_review( "suggestion_count": len(suggestions), "rejected_count": len(rejected), "problem_slot_count": len(problem_slots), + "slot_review_count": len(slot_reviews), }, "retrieval_phase": "unified_slot_review", "unified_slot_review": True, + "slot_reviews": slot_reviews, "problem_slots": problem_slot_payload, "slot_suggestions": suggestions, "slot_diff_scoring": slot_diff_scoring, diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 4052139..b4fa3d9 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -2,6 +2,7 @@ from planning_exercise_path_builder import ( _parse_slot_refs_from_text, _problematic_slots_from_path_qa, + _slot_auto_select_library, _slot_suggestion_accepted, ) from planning_progression_roadmap import StageSpecArtifact @@ -95,3 +96,18 @@ def test_problematic_slots_from_llm_schritt_text(): specs = [_spec(7)] problems = _problematic_slots_from_path_qa(qa, steps, specs) assert 7 in problems + + +def test_slot_auto_select_requires_higher_score(): + assert _slot_auto_select_library( + baseline_slot_score=0.5, + proposed_slot_score=0.51, + baseline_exercise_id=1, + proposed_exercise_id=2, + ) + assert not _slot_auto_select_library( + baseline_slot_score=0.5, + proposed_slot_score=0.5, + baseline_exercise_id=1, + proposed_exercise_id=2, + ) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 80a4187..b980d2d 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -501,53 +501,75 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) - setMatchNotice('Schritt 2/2: Verbesserungsvorschläge für gemeldete Schachstellen…') - const reviewRes = await api.suggestProgressionPath({ - ...buildEvaluateRequest(synced), - evaluate_only: false, - unified_slot_review: true, - baseline_evaluate_steps: slotsToEvaluateSteps(synced), - baseline_path_qa_snapshot: baselineRes?.path_qa || null, - baseline_quality_score: - baselineRes?.path_qa?.quality_score != null - ? Number(baselineRes.path_qa.quality_score) - : null, - include_llm_intent: false, - auto_rematch_after_qa: false, - }) - - if (!reviewRes?.unified_slot_review) { - throw new Error( - 'Match-Review nicht verfügbar — Backend-Stand prüfen (unified_slot_review fehlt in der Antwort).', - ) + setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…') + let compareRes + let reviewError = null + try { + const reviewRes = await api.suggestProgressionPath({ + ...buildEvaluateRequest(synced), + evaluate_only: false, + unified_slot_review: true, + baseline_evaluate_steps: slotsToEvaluateSteps(synced), + baseline_path_qa_snapshot: baselineRes?.path_qa || null, + baseline_quality_score: + baselineRes?.path_qa?.quality_score != null + ? Number(baselineRes.path_qa.quality_score) + : null, + include_llm_intent: false, + auto_rematch_after_qa: false, + }) + if (!reviewRes?.unified_slot_review) { + reviewError = + 'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).' + compareRes = buildProgressionComparePayload(baselineRes, { + ...reviewRes, + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + }) + } else { + compareRes = buildProgressionComparePayload(baselineRes, reviewRes) + } + setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) + } catch (e) { + reviewError = e.message || 'Slot-Review fehlgeschlagen' + compareRes = buildProgressionComparePayload(baselineRes, { + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + path_qa: baselineRes?.path_qa, + }) } - const compareRes = buildProgressionComparePayload(baselineRes, reviewRes) - setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) - presentMatchCompare(compareRes, { source }) + presentMatchCompare(compareRes, { source, reviewError }) return compareRes } - const presentMatchCompare = (res, { source = 'manual' } = {}) => { + const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => { setSemanticBrief(res?.semantic_brief_summary || null) setTargetSummary(res?.target_profile_summary || null) - setComparePayload(res) + setComparePayload(reviewError ? { ...res, review_error: reviewError } : res) setCompareSource(source) setProposedPathQa(res?.proposed_path_qa_pipeline || null) setCompareOpen(true) const baselineQa = res?.baseline_path_qa || null - const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length + const slotReviews = res?.slot_reviews || [] + const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length + const diffCount = autoCount || res?.slot_diff_count || 0 const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length const problemCount = res?.match_summary?.problem_slot_count ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) const bPct = pathQaQualityPercent(baselineQa) - let notice = - diffCount > 0 - ? `Match: ${diffCount} Verbesserung(en) für gemeldete Schachstellen.` - : problemCount > 0 - ? `Match: ${problemCount} Schachstelle(n) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.` - : 'Match: Keine Schachstellen — Pfad wirkt konsistent.' + let notice = reviewError + ? `Match: Dialog geöffnet — ${reviewError}` + : slotReviews.length > 0 + ? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.` + : diffCount > 0 + ? `Match: ${diffCount} Verbesserung(en).` + : problemCount > 0 + ? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.` + : 'Match: Pfad geprüft — siehe Dialog.' if (rejectedCount > 0) { notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` } diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 8e4147d..098e58c 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -1,13 +1,16 @@ /** - * Gegenüberstellung: Verbesserungsvorschläge mit Slot-Bewertung (Pro/Contra). + * Slot-Match-Dialog: je Slot Bewertung, Bibliotheks-Alternative, optional KI. */ import React, { useMemo, useState } from 'react' +import FormModalOverlay from './FormModalOverlay' import { - compareDiffsForDialog, + compareSlotReviews, defaultSelectedCompareDiffs, pathQaQualityPercent, qualityDeltaPercent, rejectedCompareDiffs, + slotFitScorePercent, + slotReviewSelectionKey, } from '../utils/progressionGraphDraft' function qaLabel(pathQa) { @@ -17,12 +20,10 @@ function qaLabel(pathQa) { return ok ? 'OK' : 'Hinweise' } -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 slotScoreLabel(score) { + const pct = slotFitScorePercent(score) + if (pct == null) return '—' + return `${pct} % Stufen-Fit` } function ProContraList({ title, items, tone = 'neutral' }) { @@ -41,89 +42,157 @@ function ProContraList({ title, items, tone = 'neutral' }) { ) } -function DiffRow({ diff, checked, onToggle, applying }) { - const midx = Number(diff.roadmap_major_step_index) - 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 +function SlotReviewRow({ review, selected, onToggle, applying }) { + const midx = Number(review.roadmap_major_step_index) + const lib = review.library_alternative + const ai = review.ai_alternative + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + const pc = lib?.pro_contra || {} + const pathDelta = qualityDeltaPercent({ quality_delta: lib?.quality_delta }) + const slotDelta = lib?.slot_score_delta return (
  • -
  • ) } @@ -136,10 +205,10 @@ export default function ProgressionOptimizeCompareModal({ onApplySelected, applying = false, }) { - const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) + const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison]) const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison]) const defaultSelected = useMemo( - () => defaultSelectedCompareDiffs(comparison), + () => new Set(defaultSelectedCompareDiffs(comparison)), [comparison], ) @@ -155,48 +224,59 @@ export default function ProgressionOptimizeCompareModal({ const baselineQa = comparison.baseline_path_qa const baselinePct = pathQaQualityPercent(baselineQa) const rejectedCount = rejected.length + const reviewError = comparison.review_error || null - const toggle = (midx) => { + const toggle = (key, kind) => { setSelected((prev) => { const next = new Set(prev) - if (next.has(midx)) next.delete(midx) - else next.add(midx) + const parsed = String(key) + const midx = Number(parsed.split(':')[0]) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (next.has(parsed)) { + next.delete(parsed) + return next + } + if (kind === 'library') next.delete(aiKey) + if (kind === 'ai') next.delete(libKey) + next.add(parsed) 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 — Verbesserungen' : 'Optimierung vergleichen' + mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen' return ( -
    { - if (e.target === e.currentTarget && !applying) onClose() - }} - > +
    e.stopPropagation()} >

    {title}

    - 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. + Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur + vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.

    + {reviewError ? ( +

    + {reviewError} +

    + ) : null} +
    0 ? (

    - {rejectedCount} Alternative(n) verworfen — kein QS-Gewinn gegenüber deinem Pfad - {baselinePct != null ? ` (${baselinePct} %)` : ''}. + {rejectedCount} Alternative(n) ohne Pfad-Gewinn + {baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.

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

    - Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten. - KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen. -

    - {comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? ( -
      - {Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => { - const midx = Number(midxRaw) - const reasonList = Array.isArray(reasons) ? reasons : [reasons] - return ( -
    • - - Schachstelle Slot {midx + 1} - -
        - {reasonList.filter(Boolean).map((text, i) => ( -
      • {text}
      • - ))} -
      -
    • - ) - })} -
    - ) : null} - + {slotReviews.length === 0 ? ( +

    + Keine Slot-Daten — Backend-Stand prüfen oder erneut „Graph bewerten“ und „Übungen + matchen“. +

    ) : ( - <> -
    - - -
    -
      - {dialogDiffs.map((diff) => ( - - ))} -
    - +
      + {slotReviews.map((review) => ( + + ))} +
    )}
    onApplySelected([...selected])} > {applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
    -
    +
    ) } diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index c25c295..7360c89 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -1010,8 +1010,39 @@ export function annotateCompareDiffKinds(diffs) { })) } +export function slotFitScorePercent(score) { + if (score == null || !Number.isFinite(Number(score))) return null + return Math.round(Number(score) * 100) +} + +export function slotReviewSelectionKey(midx, kind = 'library') { + return `${Number(midx)}:${kind}` +} + +export function parseSlotReviewSelection(raw) { + if (raw == null) return null + const text = String(raw) + if (text.includes(':')) { + const [midxRaw, kind] = text.split(':') + const midx = Number(midxRaw) + if (!Number.isFinite(midx)) return null + return { midx, kind: kind === 'ai' ? 'ai' : 'library' } + } + const midx = Number(text) + if (!Number.isFinite(midx)) return null + return { midx, kind: 'library' } +} + +/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */ +export function compareSlotReviews(comparison) { + return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : [] +} + /** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ export function compareDiffsForDialog(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) return reviews + const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) if (fromSuggestions.length > 0) { return fromSuggestions @@ -1037,6 +1068,16 @@ export function compareDiffsForDialog(comparison) { ) } +export function defaultSelectedCompareDiffs(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + return reviews + .filter((review) => review?.library_alternative?.auto_select) + .map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library')) + } + return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) +} + export function suggestionDiffKind(suggestion) { if (!suggestion) return 'skip' if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' @@ -1069,10 +1110,6 @@ export function gapOnlyCompareDiffs(comparison) { ).filter((d) => d.diff_kind === 'gap_only') } -export function defaultSelectedCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) -} - function mergeGapFillOffersFromSteps(steps, offers) { const merged = (offers || []).map((o) => ({ ...o })) const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) @@ -1160,11 +1197,13 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])) const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null const scoring = res?.slot_diff_scoring + const slotReviews = compareSlotReviews(res) const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] - const improving = suggestions.filter((s) => s?.improves_path) + const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select) 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 : [] + const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length return { ...res, @@ -1177,14 +1216,15 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { proposed_path_qa: baselineQa, proposed_path_qa_pipeline: null, gap_fill_offers: gapFillOffers, + slot_reviews: slotReviews, slot_suggestions: suggestions, slot_diffs: improving, slot_diffs_improving: improving, slot_diffs_rejected: rejected, - slot_diffs_dialog: improving, + slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving, slot_diffs_recommended: improving, - slot_diff_count: improving.length, - slot_diff_count_recommended: improving.length, + slot_diff_count: autoSelectCount || improving.length, + slot_diff_count_recommended: autoSelectCount || improving.length, slot_diff_count_rejected: rejected.length, slot_diffs_source: 'unified_slot_review', slot_diff_scoring: scoring, @@ -1220,25 +1260,59 @@ function suggestionToApplyStep(suggestion) { } /** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ -export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) { +export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + const selected = new Set((selectedKeys || []).map((x) => String(x))) + const steps = [] + for (const review of reviews) { + const midx = Number(review.roadmap_major_step_index) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (selected.has(aiKey) && review.ai_alternative?.gap_offer) { + const offer = review.ai_alternative.gap_offer + steps.push({ + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + }) + continue + } + if (selected.has(libKey) && review.library_alternative?.exercise_id != null) { + steps.push({ + roadmap_major_step_index: midx, + exercise_id: review.library_alternative.exercise_id, + title: review.library_alternative.title, + slot_status: 'matched', + is_ai_proposal: false, + }) + } + } + if (steps.length) return applyMatchStepsToSlots(draft, steps) + } + const selected = new Set( - (selectedMajorIndices || []) - .map((x) => Number(x)) + (selectedKeys || []) + .map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x)) .filter((x) => Number.isFinite(x)), ) if (!selected.size) return draft - const steps = (comparison?.slot_suggestions || []) + const legacySteps = (comparison?.slot_suggestions || []) .filter((s) => selected.has(Number(s.roadmap_major_step_index))) .map(suggestionToApplyStep) .filter(Boolean) - if (!steps.length) { + if (!legacySteps.length) { return applySelectedCompareSteps( draft, comparison?.proposed_steps || comparison?.steps, - selectedMajorIndices, + selectedKeys, ) } - return applyMatchStepsToSlots(draft, steps) + return applyMatchStepsToSlots(draft, legacySteps) } /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */