Enhance Slot Evaluation and Scoring Mechanisms
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s

- Introduced new functions `_off_topic_semantic_scores_by_slot` and `_score_exercise_stage_fit_for_spec` to improve the evaluation of off-topic steps and exercise stage fit, enhancing the quality assessment process.
- Updated `_run_unified_slot_improvement_review` to incorporate off-topic scores and exercise stage fit scoring, refining the decision-making process for slot suggestions.
- Enhanced existing logic to streamline the handling of slot scores and improve the overall robustness of slot management in path evaluations.
This commit is contained in:
Lars 2026-06-13 12:33:16 +02:00
parent e9bf5bd1a5
commit cd457e3ea0
5 changed files with 654 additions and 437 deletions

View File

@ -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_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_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import ( from planning_exercise_path_qa import (
_load_exercise_text_bundle,
apply_llm_path_reorder, apply_llm_path_reorder,
build_path_qa_summary, build_path_qa_summary,
compute_deterministic_path_quality_score, compute_deterministic_path_quality_score,
@ -66,6 +67,7 @@ from planning_exercise_semantics import (
exercise_passes_stage_fit, exercise_passes_stage_fit,
exercise_title_matches_peer_stage_goal, exercise_title_matches_peer_stage_goal,
pick_best_path_hit, pick_best_path_hit,
score_exercise_stage_fit,
resolve_semantic_skill_weights, resolve_semantic_skill_weights,
step_phase_for_index, step_phase_for_index,
step_retrieval_query, 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( def _run_unified_slot_improvement_review(
cur, 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} spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs}
stage_count = len(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]] = [] suggestions: List[Dict[str, Any]] = []
rejected: 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) major_idx = int(stage_spec.major_step_index)
current = dict(steps_by_major.get(major_idx, {})) current = dict(steps_by_major.get(major_idx, {}))
current.setdefault("roadmap_major_step_index", 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") current_id = current.get("exercise_id")
slot_problem = major_idx in problem_slots slot_problem = major_idx in problem_slots
off_topic = slot_problem or major_idx in off_topic_map or bool( 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, []) 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 = [ planned_ids = [
int(s["exercise_id"]) int(s["exercise_id"])
for midx, s in sorted(steps_by_major.items()) for midx, s in sorted(steps_by_major.items())
@ -3141,19 +3246,6 @@ def _run_unified_slot_improvement_review(
vid = step.get("variant_id") vid = step.get("variant_id")
anchor_variant_id = int(vid) if vid is not None else None 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( candidates = _roadmap_slot_library_candidates(
cur, cur,
tenant=tenant, tenant=tenant,
@ -3171,208 +3263,199 @@ def _run_unified_slot_improvement_review(
anchor_id=anchor_id, anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id, anchor_variant_id=anchor_variant_id,
used=used_other, used=used_other,
exclude_exercise_id=int(current_id) if current_id is not None else exclude_id, exclude_exercise_id=int(current_id) if current_id is not None else None,
max_candidates=5 if relax_match_gate else 3, max_candidates=5,
skip_post_match_gate=relax_match_gate, skip_post_match_gate=True,
) )
accepted_for_slot = False best_candidate: Optional[Dict[str, Any]] = None
for candidate in candidates: for candidate in candidates:
try: try:
cand_id = int(candidate.get("exercise_id")) cand_id = int(candidate.get("exercise_id"))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if ( if current_id is not None and int(current_id) == cand_id:
current_id is not None
and not (off_topic or slot_problem)
and int(current_id) == cand_id
):
continue 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_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_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None, "baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": cand_id, "baseline_slot_score": baseline_slot_score,
"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_status": current.get("slot_status"), "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, "slot_problem": slot_problem,
"off_topic": off_topic,
"problem_reasons": off_reasons[:6], "problem_reasons": off_reasons[:6],
"proposed_is_ai_proposal": False, "library_alternative": library_alt,
"pro_contra": _build_slot_pro_contra( "ai_alternative": ai_alt,
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 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] improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
problem_slot_payload = { problem_slot_payload = {
@ -3412,9 +3495,11 @@ def _run_unified_slot_improvement_review(
"suggestion_count": len(suggestions), "suggestion_count": len(suggestions),
"rejected_count": len(rejected), "rejected_count": len(rejected),
"problem_slot_count": len(problem_slots), "problem_slot_count": len(problem_slots),
"slot_review_count": len(slot_reviews),
}, },
"retrieval_phase": "unified_slot_review", "retrieval_phase": "unified_slot_review",
"unified_slot_review": True, "unified_slot_review": True,
"slot_reviews": slot_reviews,
"problem_slots": problem_slot_payload, "problem_slots": problem_slot_payload,
"slot_suggestions": suggestions, "slot_suggestions": suggestions,
"slot_diff_scoring": slot_diff_scoring, "slot_diff_scoring": slot_diff_scoring,

View File

@ -2,6 +2,7 @@
from planning_exercise_path_builder import ( from planning_exercise_path_builder import (
_parse_slot_refs_from_text, _parse_slot_refs_from_text,
_problematic_slots_from_path_qa, _problematic_slots_from_path_qa,
_slot_auto_select_library,
_slot_suggestion_accepted, _slot_suggestion_accepted,
) )
from planning_progression_roadmap import StageSpecArtifact from planning_progression_roadmap import StageSpecArtifact
@ -95,3 +96,18 @@ def test_problematic_slots_from_llm_schritt_text():
specs = [_spec(7)] specs = [_spec(7)]
problems = _problematic_slots_from_path_qa(qa, steps, specs) problems = _problematic_slots_from_path_qa(qa, steps, specs)
assert 7 in problems 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,
)

View File

@ -501,53 +501,75 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
setMatchNotice('Schritt 2/2: Verbesserungsvorschläge für gemeldete Schachstellen…') setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
const reviewRes = await api.suggestProgressionPath({ let compareRes
...buildEvaluateRequest(synced), let reviewError = null
evaluate_only: false, try {
unified_slot_review: true, const reviewRes = await api.suggestProgressionPath({
baseline_evaluate_steps: slotsToEvaluateSteps(synced), ...buildEvaluateRequest(synced),
baseline_path_qa_snapshot: baselineRes?.path_qa || null, evaluate_only: false,
baseline_quality_score: unified_slot_review: true,
baselineRes?.path_qa?.quality_score != null baseline_evaluate_steps: slotsToEvaluateSteps(synced),
? Number(baselineRes.path_qa.quality_score) baseline_path_qa_snapshot: baselineRes?.path_qa || null,
: null, baseline_quality_score:
include_llm_intent: false, baselineRes?.path_qa?.quality_score != null
auto_rematch_after_qa: false, ? Number(baselineRes.path_qa.quality_score)
}) : null,
include_llm_intent: false,
if (!reviewRes?.unified_slot_review) { auto_rematch_after_qa: false,
throw new Error( })
'Match-Review nicht verfügbar — Backend-Stand prüfen (unified_slot_review fehlt in der Antwort).', 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) presentMatchCompare(compareRes, { source, reviewError })
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
presentMatchCompare(compareRes, { source })
return compareRes return compareRes
} }
const presentMatchCompare = (res, { source = 'manual' } = {}) => { const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null) setTargetSummary(res?.target_profile_summary || null)
setComparePayload(res) setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
setCompareSource(source) setCompareSource(source)
setProposedPathQa(res?.proposed_path_qa_pipeline || null) setProposedPathQa(res?.proposed_path_qa_pipeline || null)
setCompareOpen(true) setCompareOpen(true)
const baselineQa = res?.baseline_path_qa || null 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 rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
const problemCount = res?.match_summary?.problem_slot_count const problemCount = res?.match_summary?.problem_slot_count
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
const bPct = pathQaQualityPercent(baselineQa) const bPct = pathQaQualityPercent(baselineQa)
let notice = let notice = reviewError
diffCount > 0 ? `Match: Dialog geöffnet — ${reviewError}`
? `Match: ${diffCount} Verbesserung(en) für gemeldete Schachstellen.` : slotReviews.length > 0
: problemCount > 0 ? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
? `Match: ${problemCount} Schachstelle(n) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.` : diffCount > 0
: 'Match: Keine Schachstellen — Pfad wirkt konsistent.' ? `Match: ${diffCount} Verbesserung(en).`
: problemCount > 0
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
: 'Match: Pfad geprüft — siehe Dialog.'
if (rejectedCount > 0) { if (rejectedCount > 0) {
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
} }

View File

@ -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 React, { useMemo, useState } from 'react'
import FormModalOverlay from './FormModalOverlay'
import { import {
compareDiffsForDialog, compareSlotReviews,
defaultSelectedCompareDiffs, defaultSelectedCompareDiffs,
pathQaQualityPercent, pathQaQualityPercent,
qualityDeltaPercent, qualityDeltaPercent,
rejectedCompareDiffs, rejectedCompareDiffs,
slotFitScorePercent,
slotReviewSelectionKey,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
function qaLabel(pathQa) { function qaLabel(pathQa) {
@ -17,12 +20,10 @@ function qaLabel(pathQa) {
return ok ? 'OK' : 'Hinweise' return ok ? 'OK' : 'Hinweise'
} }
function deltaLabel(diff) { function slotScoreLabel(score) {
const pct = qualityDeltaPercent(diff) const pct = slotFitScorePercent(score)
if (pct == null) return null if (pct == null) return '—'
if (pct > 0) return `+${pct} % Pfad-QS` return `${pct} % Stufen-Fit`
if (pct === 0) return '±0 % Pfad-QS'
return `${pct} % Pfad-QS`
} }
function ProContraList({ title, items, tone = 'neutral' }) { function ProContraList({ title, items, tone = 'neutral' }) {
@ -41,89 +42,157 @@ function ProContraList({ title, items, tone = 'neutral' }) {
) )
} }
function DiffRow({ diff, checked, onToggle, applying }) { function SlotReviewRow({ review, selected, onToggle, applying }) {
const midx = Number(diff.roadmap_major_step_index) const midx = Number(review.roadmap_major_step_index)
const delta = deltaLabel(diff) const lib = review.library_alternative
const pc = diff.pro_contra || {} const ai = review.ai_alternative
const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal const libKey = slotReviewSelectionKey(midx, 'library')
const isFill = diff.baseline_exercise_id == null && !isAi 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 ( return (
<li <li
style={{ style={{
padding: '10px 12px', padding: '12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid var(--border)', border: `1px solid ${review.slot_problem ? 'var(--danger)' : 'var(--border)'}`,
background: checked ? 'var(--surface2)' : 'var(--surface)', background: 'var(--surface)',
fontSize: '12px', fontSize: '12px',
}} }}
> >
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginBottom: '8px' }}>
<input <strong>Slot {midx + 1}</strong>
type="checkbox" {review.slot_problem ? (
checked={checked} <span style={{ fontSize: '10px', color: 'var(--danger)' }}>Schachstelle</span>
onChange={() => onToggle(midx)} ) : review.off_topic ? (
disabled={applying} <span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht</span>
style={{ marginTop: '3px' }} ) : (
/> <span style={{ fontSize: '10px', color: 'var(--text2)' }}>OK</span>
<span style={{ flex: 1 }}> )}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}> {review.roadmap_learning_goal ? (
<strong>Slot {midx + 1}</strong> <span style={{ fontSize: '10px', color: 'var(--text3)', flex: '1 1 100%' }}>
{isFill ? ( {review.roadmap_learning_goal}
<span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>Lücke füllen</span> </span>
) : isAi ? ( ) : null}
<span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>KI-Alternative</span> </div>
) : diff.off_topic ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht Ersatz</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>Bessere Übung</span>
)}
{delta ? (
<span
style={{
fontSize: '10px',
fontWeight: 600,
color: qualityDeltaPercent(diff) > 0 ? 'var(--accent-dark)' : 'var(--text2)',
}}
>
{delta}
</span>
) : null}
</div>
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
gap: '10px', gap: '10px',
marginTop: '8px', marginBottom: lib || ai ? '10px' : 0,
}} }}
> >
<div> <div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}> <div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell Aktuell
</div> </div>
<div style={{ color: 'var(--text2)' }}> <div style={{ color: 'var(--text2)' }}>
{diff.baseline_title || '— leer —'} {review.baseline_title || '— leer —'}
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} {review.baseline_exercise_id != null ? ` (#${review.baseline_exercise_id})` : ''}
</div> </div>
<ProContraList title="Pro" items={pc.current_pro} tone="pro" /> <div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
<ProContraList title="Contra" items={pc.current_contra} tone="contra" /> {slotScoreLabel(review.baseline_slot_score)}
</div> </div>
<div> {(review.problem_reasons || []).slice(0, 3).map((text, i) => (
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}> <p key={`pr-${i}`} style={{ margin: '4px 0 0', color: 'var(--danger)', fontSize: '11px' }}>
Vorschlag {text}
</div> </p>
))}
</div>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Beste Bibliotheks-Alternative
</div>
{lib ? (
<>
<div style={{ color: 'var(--accent-dark)' }}> <div style={{ color: 'var(--accent-dark)' }}>
{diff.proposed_title || '—'} {lib.title || '—'}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''} {lib.exercise_id != null ? ` (#${lib.exercise_id})` : ''}
</div>
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
{slotScoreLabel(lib.slot_score)}
{slotDelta != null && Number(slotDelta) !== 0 ? (
<span style={{ marginLeft: '6px', color: Number(slotDelta) > 0 ? 'var(--accent-dark)' : 'var(--text2)' }}>
({Number(slotDelta) > 0 ? '+' : ''}{Math.round(Number(slotDelta) * 100)} PP)
</span>
) : null}
{pathDelta != null ? (
<span style={{ marginLeft: '6px' }}>· Pfad {pathDelta > 0 ? `+${pathDelta}` : pathDelta} %</span>
) : null}
</div> </div>
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" /> <ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" /> <ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
</div> </>
</div> ) : (
</span> <div style={{ color: 'var(--text3)' }}>Kein passender Bibliotheks-Treffer</div>
</label> )}
</div>
</div>
{lib ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(libKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
marginBottom: ai ? '8px' : 0,
}}
>
<input
type="checkbox"
checked={selected.has(libKey)}
onChange={() => onToggle(libKey, 'library')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Bibliothek übernehmen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{lib.auto_select
? 'Empfohlen — Stufen-Fit besser als aktuell'
: 'Optional — nicht besser als aktuell bewertet'}
</span>
</span>
</label>
) : null}
{ai ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(aiKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={selected.has(aiKey)}
onChange={() => onToggle(aiKey, 'ai')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'}
</span>
</span>
</label>
) : null}
</li> </li>
) )
} }
@ -136,10 +205,10 @@ export default function ProgressionOptimizeCompareModal({
onApplySelected, onApplySelected,
applying = false, applying = false,
}) { }) {
const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison])
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison]) const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
const defaultSelected = useMemo( const defaultSelected = useMemo(
() => defaultSelectedCompareDiffs(comparison), () => new Set(defaultSelectedCompareDiffs(comparison)),
[comparison], [comparison],
) )
@ -155,48 +224,59 @@ export default function ProgressionOptimizeCompareModal({
const baselineQa = comparison.baseline_path_qa const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa) const baselinePct = pathQaQualityPercent(baselineQa)
const rejectedCount = rejected.length const rejectedCount = rejected.length
const reviewError = comparison.review_error || null
const toggle = (midx) => { const toggle = (key, kind) => {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(midx)) next.delete(midx) const parsed = String(key)
else next.add(midx) 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 return next
}) })
} }
const toggleAll = (on) => {
setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set())
}
const title = const title =
mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen' mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen'
return ( return (
<div <FormModalOverlay open={open} raised onBackdropClick={applying ? undefined : onClose}>
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="optimize-compare-title"
style={{ zIndex: 2200 }}
onClick={(e) => {
if (e.target === e.currentTarget && !applying) onClose()
}}
>
<div <div
className="card modal-content" className="card modal-panel--form modal-panel--narrow"
style={{ maxWidth: '760px', width: '100%', maxHeight: '90vh', overflow: 'auto' }} style={{ maxHeight: '92vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}> <h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
{title} {title}
</h3> </h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
hier mit Pro- und Contra-Punkten auf Slot-Ebene.
</p> </p>
{reviewError ? (
<p
style={{
fontSize: '12px',
color: 'var(--danger)',
padding: '8px 10px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
}}
>
{reviewError}
</p>
) : null}
<div <div
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
@ -216,97 +296,37 @@ export default function ProgressionOptimizeCompareModal({
{rejectedCount > 0 ? ( {rejectedCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}> <p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
{rejectedCount} Alternative(n) verworfen kein QS-Gewinn gegenüber deinem Pfad {rejectedCount} Alternative(n) ohne Pfad-Gewinn
{baselinePct != null ? ` (${baselinePct} %)` : ''}. {baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.
</p> </p>
) : null} ) : null}
{dialogDiffs.length === 0 ? ( {slotReviews.length === 0 ? (
<> <p style={{ fontSize: '12px', color: 'var(--text2)' }}>
<p style={{ fontSize: '12px', color: 'var(--text2)' }}> Keine Slot-Daten Backend-Stand prüfen oder erneut Graph bewerten und Übungen
Keine Bibliotheks-Verbesserung mit messbarem Gewinn Schachstellen siehe unten. matchen.
KI-Angebote im Bewertungs-Panel oder Brücke / KI-Angebot nutzen. </p>
</p>
{comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '12px 0 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => {
const midx = Number(midxRaw)
const reasonList = Array.isArray(reasons) ? reasons : [reasons]
return (
<li
key={`problem-${midxRaw}`}
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ color: 'var(--danger)' }}>
Schachstelle Slot {midx + 1}
</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{reasonList.filter(Boolean).map((text, i) => (
<li key={`${midxRaw}-r-${i}`}>{text}</li>
))}
</ul>
</li>
)
})}
</ul>
) : null}
</>
) : ( ) : (
<> <ul
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}> style={{
<button listStyle: 'none',
type="button" padding: 0,
className="btn btn-secondary" margin: 0,
style={{ fontSize: '11px' }} display: 'flex',
onClick={() => toggleAll(true)} flexDirection: 'column',
> gap: '10px',
Alle wählen }}
</button> >
<button {slotReviews.map((review) => (
type="button" <SlotReviewRow
className="btn btn-secondary" key={`slot-review-${review.roadmap_major_step_index}`}
style={{ fontSize: '11px' }} review={review}
onClick={() => toggleAll(false)} selected={selected}
> onToggle={toggle}
Keine applying={applying}
</button> />
</div> ))}
<ul </ul>
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{dialogDiffs.map((diff) => (
<DiffRow
key={`improve-${diff.roadmap_major_step_index}-${diff.suggestion_type || 'lib'}`}
diff={diff}
checked={selected.has(Number(diff.roadmap_major_step_index))}
onToggle={toggle}
applying={applying}
/>
))}
</ul>
</>
)} )}
<div <div
@ -324,13 +344,13 @@ export default function ProgressionOptimizeCompareModal({
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={applying || selected.size === 0 || dialogDiffs.length === 0} disabled={applying || selected.size === 0}
onClick={() => onApplySelected([...selected])} onClick={() => onApplySelected([...selected])}
> >
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`} {applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
</button> </button>
</div> </div>
</div> </div>
</div> </FormModalOverlay>
) )
} }

View File

@ -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). */ /** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
export function compareDiffsForDialog(comparison) { export function compareDiffsForDialog(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) return reviews
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
if (fromSuggestions.length > 0) { if (fromSuggestions.length > 0) {
return fromSuggestions 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) { export function suggestionDiffKind(suggestion) {
if (!suggestion) return 'skip' if (!suggestion) return 'skip'
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
@ -1069,10 +1110,6 @@ export function gapOnlyCompareDiffs(comparison) {
).filter((d) => d.diff_kind === 'gap_only') ).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) { function mergeGapFillOffersFromSteps(steps, offers) {
const merged = (offers || []).map((o) => ({ ...o })) const merged = (offers || []).map((o) => ({ ...o }))
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) 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 || [])) : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []))
const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null
const scoring = res?.slot_diff_scoring const scoring = res?.slot_diff_scoring
const slotReviews = compareSlotReviews(res)
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] 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 rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : []
const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean)
const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
return { return {
...res, ...res,
@ -1177,14 +1216,15 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) {
proposed_path_qa: baselineQa, proposed_path_qa: baselineQa,
proposed_path_qa_pipeline: null, proposed_path_qa_pipeline: null,
gap_fill_offers: gapFillOffers, gap_fill_offers: gapFillOffers,
slot_reviews: slotReviews,
slot_suggestions: suggestions, slot_suggestions: suggestions,
slot_diffs: improving, slot_diffs: improving,
slot_diffs_improving: improving, slot_diffs_improving: improving,
slot_diffs_rejected: rejected, slot_diffs_rejected: rejected,
slot_diffs_dialog: improving, slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving,
slot_diffs_recommended: improving, slot_diffs_recommended: improving,
slot_diff_count: improving.length, slot_diff_count: autoSelectCount || improving.length,
slot_diff_count_recommended: improving.length, slot_diff_count_recommended: autoSelectCount || improving.length,
slot_diff_count_rejected: rejected.length, slot_diff_count_rejected: rejected.length,
slot_diffs_source: 'unified_slot_review', slot_diffs_source: 'unified_slot_review',
slot_diff_scoring: scoring, slot_diff_scoring: scoring,
@ -1220,25 +1260,59 @@ function suggestionToApplyStep(suggestion) {
} }
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ /** 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( const selected = new Set(
(selectedMajorIndices || []) (selectedKeys || [])
.map((x) => Number(x)) .map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x))
.filter((x) => Number.isFinite(x)), .filter((x) => Number.isFinite(x)),
) )
if (!selected.size) return draft 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))) .filter((s) => selected.has(Number(s.roadmap_major_step_index)))
.map(suggestionToApplyStep) .map(suggestionToApplyStep)
.filter(Boolean) .filter(Boolean)
if (!steps.length) { if (!legacySteps.length) {
return applySelectedCompareSteps( return applySelectedCompareSteps(
draft, draft,
comparison?.proposed_steps || comparison?.steps, comparison?.proposed_steps || comparison?.steps,
selectedMajorIndices, selectedKeys,
) )
} }
return applyMatchStepsToSlots(draft, steps) return applyMatchStepsToSlots(draft, legacySteps)
} }
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */