progression V2 #57
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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).`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user