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
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:
parent
e9bf5bd1a5
commit
cd457e3ea0
|
|
@ -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,106 +3263,147 @@ 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
|
||||
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": (candidate.get("title") or "").strip() or None,
|
||||
"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, **candidate, "roadmap_major_step_index": major_idx}
|
||||
merged_steps[i] = {
|
||||
**raw,
|
||||
**best_candidate,
|
||||
"roadmap_major_step_index": major_idx,
|
||||
}
|
||||
break
|
||||
eval_res = _quick_evaluate_steps_qa(
|
||||
projected_qa = _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,
|
||||
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")
|
||||
)
|
||||
entry = {
|
||||
**diff_stub,
|
||||
"baseline_slot_status": current.get("slot_status"),
|
||||
"proposed_slot_status": candidate.get("slot_status") or "matched",
|
||||
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,
|
||||
"quality_delta": delta,
|
||||
"projected_quality_score": projected_score,
|
||||
"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": improves,
|
||||
"improves_path": auto_select,
|
||||
"off_topic": off_topic,
|
||||
"slot_problem": slot_problem,
|
||||
"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 [],
|
||||
),
|
||||
"pro_contra": library_alt["pro_contra"],
|
||||
}
|
||||
if improves:
|
||||
suggestions.append(entry)
|
||||
accepted_for_slot = True
|
||||
break
|
||||
rejected.append(entry)
|
||||
if auto_select:
|
||||
suggestions.append(lib_entry)
|
||||
elif cand_id is not None:
|
||||
rejected.append(lib_entry)
|
||||
|
||||
if accepted_for_slot:
|
||||
continue
|
||||
|
||||
# Kein Bibliotheks-Treffer oder keine Verbesserung → KI-Angebot wenn Slot leer/off-topic/KI
|
||||
needs_ai = (
|
||||
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
|
||||
)
|
||||
if not needs_ai or not body.include_ai_gap_fill:
|
||||
continue
|
||||
)
|
||||
)
|
||||
ai_alt: Optional[Dict[str, Any]] = None
|
||||
if show_ai_option:
|
||||
slot_offer = next(
|
||||
(
|
||||
o
|
||||
|
|
@ -3301,78 +3434,28 @@ def _run_unified_slot_improvement_review(
|
|||
),
|
||||
)
|
||||
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,
|
||||
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,
|
||||
}
|
||||
diff_stub = {
|
||||
|
||||
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": 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_score": baseline_slot_score,
|
||||
"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,
|
||||
"off_topic": off_topic,
|
||||
"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,
|
||||
),
|
||||
"library_alternative": library_alt,
|
||||
"ai_alternative": ai_alt,
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -501,7 +501,10 @@ 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…')
|
||||
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
|
||||
let compareRes
|
||||
let reviewError = null
|
||||
try {
|
||||
const reviewRes = await api.suggestProgressionPath({
|
||||
...buildEvaluateRequest(synced),
|
||||
evaluate_only: false,
|
||||
|
|
@ -515,39 +518,58 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
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).',
|
||||
)
|
||||
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.`
|
||||
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) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.`
|
||||
: 'Match: Keine Schachstellen — Pfad wirkt konsistent.'
|
||||
? `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).`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,52 +42,38 @@ 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 (
|
||||
<li
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: checked ? 'var(--surface2)' : 'var(--surface)',
|
||||
border: `1px solid ${review.slot_problem ? 'var(--danger)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(midx)}
|
||||
disabled={applying}
|
||||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<strong>Slot {midx + 1}</strong>
|
||||
{isFill ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>Lücke füllen</span>
|
||||
) : isAi ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>KI-Alternative</span>
|
||||
) : diff.off_topic ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht — Ersatz</span>
|
||||
{review.slot_problem ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Schachstelle</span>
|
||||
) : review.off_topic ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>Bessere Übung</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>OK</span>
|
||||
)}
|
||||
{delta ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: qualityDeltaPercent(diff) > 0 ? 'var(--accent-dark)' : 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
{delta}
|
||||
{review.roadmap_learning_goal ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--text3)', flex: '1 1 100%' }}>
|
||||
{review.roadmap_learning_goal}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -96,7 +83,7 @@ function DiffRow({ diff, checked, onToggle, applying }) {
|
|||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '10px',
|
||||
marginTop: '8px',
|
||||
marginBottom: lib || ai ? '10px' : 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
|
|
@ -104,26 +91,108 @@ function DiffRow({ diff, checked, onToggle, applying }) {
|
|||
Aktuell
|
||||
</div>
|
||||
<div style={{ color: 'var(--text2)' }}>
|
||||
{diff.baseline_title || '— leer —'}
|
||||
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
|
||||
{review.baseline_title || '— leer —'}
|
||||
{review.baseline_exercise_id != null ? ` (#${review.baseline_exercise_id})` : ''}
|
||||
</div>
|
||||
<ProContraList title="Pro" items={pc.current_pro} tone="pro" />
|
||||
<ProContraList title="Contra" items={pc.current_contra} tone="contra" />
|
||||
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
|
||||
{slotScoreLabel(review.baseline_slot_score)}
|
||||
</div>
|
||||
{(review.problem_reasons || []).slice(0, 3).map((text, i) => (
|
||||
<p key={`pr-${i}`} style={{ margin: '4px 0 0', color: 'var(--danger)', fontSize: '11px' }}>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Vorschlag
|
||||
Beste Bibliotheks-Alternative
|
||||
</div>
|
||||
{lib ? (
|
||||
<>
|
||||
<div style={{ color: 'var(--accent-dark)' }}>
|
||||
{diff.proposed_title || '—'}
|
||||
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''}
|
||||
{lib.title || '—'}
|
||||
{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>
|
||||
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
|
||||
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text3)' }}>Kein passender Bibliotheks-Treffer</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<FormModalOverlay open={open} raised onBackdropClick={applying ? undefined : onClose}>
|
||||
<div
|
||||
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
|
||||
className="card modal-content"
|
||||
style={{ maxWidth: '760px', width: '100%', maxHeight: '90vh', overflow: 'auto' }}
|
||||
className="card modal-panel--form modal-panel--narrow"
|
||||
style={{ maxHeight: '92vh', overflow: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
|
||||
{title}
|
||||
</h3>
|
||||
<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
|
||||
Ü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.
|
||||
</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
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
|
|
@ -216,76 +296,17 @@ export default function ProgressionOptimizeCompareModal({
|
|||
|
||||
{rejectedCount > 0 ? (
|
||||
<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
|
||||
{baselinePct != null ? ` (${baselinePct} %)` : ''}.
|
||||
{rejectedCount} Alternative(n) ohne Pfad-Gewinn
|
||||
{baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dialogDiffs.length === 0 ? (
|
||||
<>
|
||||
{slotReviews.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||
Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten.
|
||||
KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen.
|
||||
Keine Slot-Daten — Backend-Stand prüfen oder erneut „Graph bewerten“ und „Übungen
|
||||
matchen“.
|
||||
</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}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleAll(true)}
|
||||
>
|
||||
Alle wählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleAll(false)}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
|
|
@ -293,20 +314,19 @@ export default function ProgressionOptimizeCompareModal({
|
|||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{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))}
|
||||
{slotReviews.map((review) => (
|
||||
<SlotReviewRow
|
||||
key={`slot-review-${review.roadmap_major_step_index}`}
|
||||
review={review}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
applying={applying}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
|
@ -324,13 +344,13 @@ export default function ProgressionOptimizeCompareModal({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={applying || selected.size === 0 || dialogDiffs.length === 0}
|
||||
disabled={applying || selected.size === 0}
|
||||
onClick={() => onApplySelected([...selected])}
|
||||
>
|
||||
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModalOverlay>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user