progression V2 #57
|
|
@ -143,6 +143,11 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
exercise_kind_any: Optional[List[str]] = None
|
||||
compare_with_assignments: bool = False
|
||||
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
|
||||
# Für Match-Vergleich: Baseline aus evaluate_only (Schritt 1) — inkrementelles QS-Scoring je Diff
|
||||
baseline_evaluate_steps: Optional[List[EvaluateStepPayload]] = None
|
||||
baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||
include_incremental_diff_scoring: bool = False
|
||||
unified_slot_review: bool = False
|
||||
|
||||
|
||||
def _resolve_planning_catalog_context(
|
||||
|
|
@ -2394,6 +2399,688 @@ def _evaluate_steps_for_compare_qa(
|
|||
return suggest_progression_path(cur, tenant=tenant, body=eval_body)
|
||||
|
||||
|
||||
def _apply_slot_diff_to_steps(
|
||||
baseline_steps: Sequence[Mapping[str, Any]],
|
||||
diff: Mapping[str, Any],
|
||||
proposed_steps: Sequence[Mapping[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Einzeländerung auf Baseline-Pfad legen (für faire QS pro Vorschlag)."""
|
||||
base_by = _steps_by_major_index(baseline_steps)
|
||||
prop_by = _steps_by_major_index(proposed_steps)
|
||||
try:
|
||||
midx = int(diff.get("roadmap_major_step_index"))
|
||||
except (TypeError, ValueError):
|
||||
return [dict(s) for s in baseline_steps or []]
|
||||
out_by: Dict[int, Dict[str, Any]] = {i: dict(s) for i, s in base_by.items()}
|
||||
prop_step = prop_by.get(midx)
|
||||
if isinstance(prop_step, dict):
|
||||
merged = dict(out_by.get(midx, {}))
|
||||
merged.update(prop_step)
|
||||
merged["roadmap_major_step_index"] = midx
|
||||
out_by[midx] = merged
|
||||
elif diff.get("proposed_exercise_id") is not None:
|
||||
merged = dict(out_by.get(midx, {}))
|
||||
merged["exercise_id"] = int(diff["proposed_exercise_id"])
|
||||
if diff.get("proposed_title"):
|
||||
merged["title"] = diff.get("proposed_title")
|
||||
merged["roadmap_major_step_index"] = midx
|
||||
merged["slot_status"] = diff.get("proposed_slot_status") or "matched"
|
||||
out_by[midx] = merged
|
||||
elif diff.get("baseline_exercise_id") is not None and diff.get("proposed_exercise_id") is None:
|
||||
merged = dict(out_by.get(midx, {}))
|
||||
merged["exercise_id"] = None
|
||||
merged["roadmap_major_step_index"] = midx
|
||||
out_by[midx] = merged
|
||||
return [out_by[i] for i in sorted(out_by.keys())]
|
||||
|
||||
|
||||
def _slot_diff_improves_path(
|
||||
diff: Mapping[str, Any],
|
||||
quality_delta: Optional[float],
|
||||
*,
|
||||
off_topic: bool = False,
|
||||
) -> bool:
|
||||
"""Nur Vorschläge mit messbarer Pfad-Verbesserung (Lücken/off-topic: neutral oder besser)."""
|
||||
if quality_delta is None:
|
||||
return False
|
||||
try:
|
||||
delta = float(quality_delta)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
base_id = diff.get("baseline_exercise_id")
|
||||
prop_id = diff.get("proposed_exercise_id")
|
||||
if off_topic and base_id is not None:
|
||||
return delta >= -0.001
|
||||
if base_id is None and prop_id is not None:
|
||||
return delta >= -0.001
|
||||
if base_id is not None and prop_id is not None:
|
||||
return delta > 0.005
|
||||
return False
|
||||
|
||||
|
||||
def _score_incremental_slot_diffs(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: ProgressionPathSuggestRequest,
|
||||
baseline_steps: Sequence[Mapping[str, Any]],
|
||||
proposed_steps: Sequence[Mapping[str, Any]],
|
||||
baseline_path_qa: Optional[Mapping[str, Any]],
|
||||
raw_diffs: Sequence[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Bewertet jeden Slot-Diff isoliert gegen die Baseline-QS — filtert Verschlechterungen."""
|
||||
baseline_score = _path_qa_quality_score(baseline_path_qa)
|
||||
if baseline_score is None and baseline_steps:
|
||||
baseline_eval = _evaluate_steps_for_compare_qa(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
steps=baseline_steps,
|
||||
)
|
||||
if isinstance(baseline_eval, dict):
|
||||
baseline_score = _path_qa_quality_score(baseline_eval.get("path_qa"))
|
||||
|
||||
annotated = _annotate_slot_diffs(list(raw_diffs or []))
|
||||
candidates = _actionable_slot_diffs(annotated)
|
||||
# Lücken zuerst, dann Ersetzungen — harte Obergrenze gegen Timeouts
|
||||
candidates.sort(
|
||||
key=lambda d: (
|
||||
0 if d.get("baseline_exercise_id") is None else 1,
|
||||
int(d.get("roadmap_major_step_index") or 0),
|
||||
)
|
||||
)
|
||||
candidates = candidates[:10]
|
||||
|
||||
scored: List[Dict[str, Any]] = []
|
||||
improving: List[Dict[str, Any]] = []
|
||||
rejected: List[Dict[str, Any]] = []
|
||||
|
||||
for diff in candidates:
|
||||
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff, proposed_steps)
|
||||
eval_res = _evaluate_steps_for_compare_qa(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
steps=merged_steps,
|
||||
)
|
||||
projected_qa = (
|
||||
eval_res.get("path_qa")
|
||||
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
|
||||
else None
|
||||
)
|
||||
projected_score = _path_qa_quality_score(projected_qa)
|
||||
delta: Optional[float] = None
|
||||
if baseline_score is not None and projected_score is not None:
|
||||
delta = round(projected_score - baseline_score, 4)
|
||||
entry = {
|
||||
**diff,
|
||||
"projected_path_qa": projected_qa,
|
||||
"projected_quality_score": projected_score,
|
||||
"baseline_quality_score": baseline_score,
|
||||
"quality_delta": delta,
|
||||
"improves_path": _slot_diff_improves_path(diff, delta),
|
||||
}
|
||||
scored.append(entry)
|
||||
if entry["improves_path"]:
|
||||
improving.append(entry)
|
||||
else:
|
||||
rejected.append(entry)
|
||||
|
||||
return {
|
||||
"baseline_quality_score": baseline_score,
|
||||
"scored_diffs": scored,
|
||||
"improvement_diffs": improving,
|
||||
"rejected_diffs": rejected,
|
||||
"improvement_count": len(improving),
|
||||
"rejected_count": len(rejected),
|
||||
}
|
||||
|
||||
|
||||
def _off_topic_reasons_by_slot(
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
) -> Dict[int, List[str]]:
|
||||
out: Dict[int, List[str]] = {}
|
||||
for item in off_topic_steps or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
midx = item.get("roadmap_major_step_index")
|
||||
if midx is None:
|
||||
continue
|
||||
try:
|
||||
key = int(midx)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
issue = str(item.get("issue") or "off_topic")
|
||||
reasons = item.get("reasons") or [issue]
|
||||
for raw in reasons:
|
||||
text = str(raw or "").strip()
|
||||
if text and text not in out.setdefault(key, []):
|
||||
out[key].append(text[:400])
|
||||
return out
|
||||
|
||||
|
||||
def _slot_issues_from_path_qa(
|
||||
path_qa: Optional[Mapping[str, Any]],
|
||||
major_idx: int,
|
||||
) -> List[str]:
|
||||
texts: List[str] = []
|
||||
if not isinstance(path_qa, dict):
|
||||
return texts
|
||||
for key in ("issues", "recommendations"):
|
||||
for raw in path_qa.get(key) or []:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if f"slot {major_idx + 1}" in text.lower() or f"stufe {major_idx + 1}" in text.lower():
|
||||
if text not in texts:
|
||||
texts.append(text[:400])
|
||||
for hint in path_qa.get("optimization_hints") or []:
|
||||
if not isinstance(hint, dict):
|
||||
continue
|
||||
hint_idx = hint.get("roadmap_major_step_index")
|
||||
if hint_idx is None:
|
||||
continue
|
||||
try:
|
||||
if int(hint_idx) != int(major_idx):
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
text = str(hint.get("reason") or hint.get("issue") or "").strip()
|
||||
if text and text not in texts:
|
||||
texts.append(text[:400])
|
||||
return texts
|
||||
|
||||
|
||||
def _build_slot_pro_contra(
|
||||
*,
|
||||
current_step: Mapping[str, Any],
|
||||
proposed_step: Optional[Mapping[str, Any]],
|
||||
suggestion_type: str,
|
||||
baseline_qa: Optional[Mapping[str, Any]],
|
||||
projected_qa: Optional[Mapping[str, Any]],
|
||||
quality_delta: Optional[float],
|
||||
off_topic_reasons: Sequence[str],
|
||||
candidate_reasons: Sequence[str],
|
||||
gap_offer: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
current_pro: List[str] = []
|
||||
current_contra: List[str] = list(off_topic_reasons or [])[:4]
|
||||
proposed_pro: List[str] = [str(r) for r in (candidate_reasons or []) if str(r or "").strip()][:4]
|
||||
proposed_contra: List[str] = []
|
||||
|
||||
if current_step.get("exercise_id") is not None and not current_contra:
|
||||
current_pro.append("Bestehende Zuordnung im Graph")
|
||||
if current_step.get("is_ai_proposal"):
|
||||
sketch = (current_step.get("title") or "KI-Entwurf").strip()
|
||||
current_pro.append(f"KI-Entwurf: {sketch[:120]}")
|
||||
|
||||
major_idx = current_step.get("roadmap_major_step_index")
|
||||
if major_idx is not None:
|
||||
for text in _slot_issues_from_path_qa(baseline_qa, int(major_idx)):
|
||||
if text not in current_contra:
|
||||
current_contra.append(text)
|
||||
|
||||
if quality_delta is not None and quality_delta > 0:
|
||||
proposed_pro.append(f"Pfad-QS +{round(float(quality_delta) * 100)} Prozentpunkte")
|
||||
elif suggestion_type in {"library_fill", "remove_and_replace", "ai_gap"} and not current_contra:
|
||||
proposed_pro.append("Schließt Lücke bzw. passt besser zur Stufe")
|
||||
|
||||
if isinstance(gap_offer, dict):
|
||||
sketch = str(gap_offer.get("sketch") or gap_offer.get("title_hint") or "").strip()
|
||||
if sketch:
|
||||
proposed_pro.append(f"KI-Entwurf: {sketch[:160]}")
|
||||
rationale = str(gap_offer.get("rationale") or "").strip()
|
||||
if rationale:
|
||||
proposed_pro.append(rationale[:200])
|
||||
|
||||
if isinstance(projected_qa, dict):
|
||||
for text in _slot_issues_from_path_qa(projected_qa, int(major_idx or 0)):
|
||||
if text not in proposed_contra:
|
||||
proposed_contra.append(text)
|
||||
|
||||
if proposed_step and proposed_step.get("exercise_id") is not None and not proposed_pro:
|
||||
proposed_pro.append("Bibliotheks-Treffer für Stufen-Lernziel")
|
||||
|
||||
return {
|
||||
"current_pro": current_pro[:6],
|
||||
"current_contra": current_contra[:6],
|
||||
"proposed_pro": proposed_pro[:6],
|
||||
"proposed_contra": proposed_contra[:6],
|
||||
}
|
||||
|
||||
|
||||
def _roadmap_slot_library_candidates(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: ProgressionPathSuggestRequest,
|
||||
goal_query: str,
|
||||
max_steps: int,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
path_target_profile: PlanningTargetProfile,
|
||||
path_intent: str,
|
||||
roadmap_ctx: ProgressionRoadmapContext,
|
||||
stage_spec: StageSpecArtifact,
|
||||
step_index: int,
|
||||
stage_count: int,
|
||||
planned_ids: List[int],
|
||||
anchor_id: Optional[int],
|
||||
anchor_variant_id: Optional[int],
|
||||
used: Set[int],
|
||||
exclude_exercise_id: Optional[int] = None,
|
||||
max_candidates: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
|
||||
pick_used = set(used)
|
||||
if exclude_exercise_id is not None:
|
||||
try:
|
||||
pick_used.add(int(exclude_exercise_id))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
seen_ids: Set[int] = set()
|
||||
for _ in range(max(1, max_candidates)):
|
||||
step, _unfilled = _match_roadmap_slot(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
stage_spec=stage_spec,
|
||||
step_index=step_index,
|
||||
stage_count=stage_count,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
used=pick_used,
|
||||
slot_priority_exercise_id=None,
|
||||
)
|
||||
if not step or step.get("exercise_id") is None:
|
||||
break
|
||||
try:
|
||||
eid = int(step["exercise_id"])
|
||||
except (TypeError, ValueError):
|
||||
break
|
||||
if eid in seen_ids:
|
||||
break
|
||||
seen_ids.add(eid)
|
||||
candidates.append(step)
|
||||
pick_used.add(eid)
|
||||
return candidates
|
||||
|
||||
|
||||
def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"roadmap_major_step_index": entry.get("roadmap_major_step_index"),
|
||||
"baseline_exercise_id": entry.get("baseline_exercise_id"),
|
||||
"baseline_title": entry.get("baseline_title"),
|
||||
"proposed_exercise_id": entry.get("proposed_exercise_id"),
|
||||
"proposed_title": entry.get("proposed_title"),
|
||||
"baseline_slot_status": entry.get("baseline_slot_status"),
|
||||
"proposed_slot_status": entry.get("proposed_slot_status"),
|
||||
"changed": True,
|
||||
"suggestion_type": entry.get("suggestion_type"),
|
||||
"quality_delta": entry.get("quality_delta"),
|
||||
"projected_quality_score": entry.get("projected_quality_score"),
|
||||
"baseline_quality_score": entry.get("baseline_quality_score"),
|
||||
"projected_path_qa": entry.get("projected_path_qa"),
|
||||
"pro_contra": entry.get("pro_contra"),
|
||||
"improves_path": entry.get("improves_path"),
|
||||
"off_topic": entry.get("off_topic"),
|
||||
"gap_offer": entry.get("gap_offer"),
|
||||
"proposed_is_ai_proposal": entry.get("proposed_is_ai_proposal"),
|
||||
}
|
||||
|
||||
|
||||
def _run_unified_slot_improvement_review(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: ProgressionPathSuggestRequest,
|
||||
goal_query: str,
|
||||
max_steps: int,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
semantic_llm_applied: bool,
|
||||
path_target_profile: PlanningTargetProfile,
|
||||
path_intent: str,
|
||||
first_intent_summary: Mapping[str, Any],
|
||||
roadmap_ctx: ProgressionRoadmapContext,
|
||||
progression_roadmap: Optional[Dict[str, Any]],
|
||||
roadmap_edited: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ein Workflow: Pfad bewerten → je Slot Alternativen suchen → Einzel-QS → nur Verbesserungen.
|
||||
"""
|
||||
if not body.baseline_evaluate_steps:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="unified_slot_review erfordert baseline_evaluate_steps",
|
||||
)
|
||||
if roadmap_ctx is None or not roadmap_ctx.stage_specs:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
|
||||
)
|
||||
|
||||
eval_body = body.model_copy(
|
||||
update={
|
||||
"include_llm_path_qa": body.include_llm_path_qa,
|
||||
"include_ai_gap_fill": body.include_ai_gap_fill,
|
||||
"auto_rematch_after_qa": False,
|
||||
}
|
||||
)
|
||||
baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
|
||||
qa_pack = _run_evaluate_only_path_qa(
|
||||
cur,
|
||||
body=eval_body,
|
||||
goal_query=goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
steps=list(baseline_steps),
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
)
|
||||
baseline_steps = list(qa_pack.get("steps") or baseline_steps)
|
||||
baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {}
|
||||
baseline_score = _path_qa_quality_score(baseline_qa)
|
||||
gap_fill_offers = list(qa_pack.get("gap_fill_offers") or [])
|
||||
off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
|
||||
|
||||
steps_by_major = _steps_by_major_index(baseline_steps)
|
||||
spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs}
|
||||
stage_count = len(roadmap_ctx.stage_specs)
|
||||
|
||||
suggestions: List[Dict[str, Any]] = []
|
||||
rejected: List[Dict[str, Any]] = []
|
||||
scored_eval_body = body.model_copy(
|
||||
update={
|
||||
"include_llm_path_qa": False,
|
||||
"include_ai_gap_fill": False,
|
||||
"auto_rematch_after_qa": False,
|
||||
"include_roadmap_preview": False,
|
||||
}
|
||||
)
|
||||
|
||||
for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs):
|
||||
major_idx = int(stage_spec.major_step_index)
|
||||
current = dict(steps_by_major.get(major_idx, {}))
|
||||
current.setdefault("roadmap_major_step_index", major_idx)
|
||||
current_id = current.get("exercise_id")
|
||||
off_topic = major_idx in off_topic_map or bool(
|
||||
current.get("slot_status") in {"off_topic", "stripped"}
|
||||
)
|
||||
off_reasons = off_topic_map.get(major_idx, [])
|
||||
|
||||
planned_ids = [
|
||||
int(s["exercise_id"])
|
||||
for midx, s in sorted(steps_by_major.items())
|
||||
if midx != major_idx and s.get("exercise_id") is not None
|
||||
]
|
||||
anchor_id: Optional[int] = None
|
||||
anchor_variant_id: Optional[int] = None
|
||||
used_other: Set[int] = set(planned_ids)
|
||||
for midx in sorted(steps_by_major):
|
||||
if midx >= major_idx:
|
||||
break
|
||||
step = steps_by_major[midx]
|
||||
eid = step.get("exercise_id")
|
||||
if eid is not None:
|
||||
anchor_id = int(eid)
|
||||
vid = step.get("variant_id")
|
||||
anchor_variant_id = int(vid) if vid is not None else None
|
||||
|
||||
exclude_id: Optional[int] = None
|
||||
if current_id is not None and not off_topic:
|
||||
try:
|
||||
exclude_id = int(current_id)
|
||||
except (TypeError, ValueError):
|
||||
exclude_id = None
|
||||
|
||||
candidates = _roadmap_slot_library_candidates(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
stage_spec=stage_spec,
|
||||
step_index=step_index,
|
||||
stage_count=stage_count,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
used=used_other,
|
||||
exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None,
|
||||
)
|
||||
|
||||
accepted_for_slot = False
|
||||
for candidate in candidates:
|
||||
try:
|
||||
cand_id = int(candidate.get("exercise_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
current_id is not None
|
||||
and not off_topic
|
||||
and int(current_id) == cand_id
|
||||
):
|
||||
continue
|
||||
diff_stub = {
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"baseline_exercise_id": int(current_id) if current_id is not None else None,
|
||||
"baseline_title": (current.get("title") or "").strip() or None,
|
||||
"proposed_exercise_id": cand_id,
|
||||
"proposed_title": (candidate.get("title") or "").strip() or None,
|
||||
}
|
||||
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps)
|
||||
for i, raw in enumerate(merged_steps):
|
||||
if int(raw.get("roadmap_major_step_index", -1)) == major_idx:
|
||||
merged_steps[i] = {**raw, **candidate, "roadmap_major_step_index": major_idx}
|
||||
break
|
||||
eval_res = _evaluate_steps_for_compare_qa(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=scored_eval_body,
|
||||
steps=merged_steps,
|
||||
)
|
||||
projected_qa = (
|
||||
eval_res.get("path_qa")
|
||||
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
|
||||
else None
|
||||
)
|
||||
projected_score = _path_qa_quality_score(projected_qa)
|
||||
delta: Optional[float] = None
|
||||
if baseline_score is not None and projected_score is not None:
|
||||
delta = round(projected_score - baseline_score, 4)
|
||||
improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic)
|
||||
suggestion_type = (
|
||||
"remove_and_replace"
|
||||
if off_topic and current_id is not None
|
||||
else ("library_fill" if current_id is None else "library_improvement")
|
||||
)
|
||||
entry = {
|
||||
**diff_stub,
|
||||
"baseline_slot_status": current.get("slot_status"),
|
||||
"proposed_slot_status": candidate.get("slot_status") or "matched",
|
||||
"suggestion_type": suggestion_type,
|
||||
"quality_delta": delta,
|
||||
"projected_quality_score": projected_score,
|
||||
"baseline_quality_score": baseline_score,
|
||||
"projected_path_qa": projected_qa,
|
||||
"improves_path": improves,
|
||||
"off_topic": off_topic,
|
||||
"proposed_is_ai_proposal": False,
|
||||
"pro_contra": _build_slot_pro_contra(
|
||||
current_step=current,
|
||||
proposed_step=candidate,
|
||||
suggestion_type=suggestion_type,
|
||||
baseline_qa=baseline_qa,
|
||||
projected_qa=projected_qa,
|
||||
quality_delta=delta,
|
||||
off_topic_reasons=off_reasons,
|
||||
candidate_reasons=candidate.get("reasons") or [],
|
||||
),
|
||||
}
|
||||
if improves:
|
||||
suggestions.append(entry)
|
||||
accepted_for_slot = True
|
||||
break
|
||||
rejected.append(entry)
|
||||
|
||||
if accepted_for_slot:
|
||||
continue
|
||||
|
||||
# Kein Bibliotheks-Treffer oder keine Verbesserung → KI-Angebot wenn Slot leer/off-topic/KI
|
||||
needs_ai = (
|
||||
current_id is None
|
||||
or off_topic
|
||||
or bool(current.get("is_ai_proposal"))
|
||||
)
|
||||
if not needs_ai or not body.include_ai_gap_fill:
|
||||
continue
|
||||
slot_offer = next(
|
||||
(
|
||||
o
|
||||
for o in gap_fill_offers
|
||||
if isinstance(o, dict)
|
||||
and int(o.get("roadmap_major_step_index", -1)) == major_idx
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not slot_offer:
|
||||
empty_specs = _build_evaluate_empty_slot_gap_specs(
|
||||
[current],
|
||||
goal_query=goal_query,
|
||||
)
|
||||
if empty_specs:
|
||||
slot_offer = build_gap_fill_offer(
|
||||
spec=empty_specs[0],
|
||||
steps=baseline_steps,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
proposal=None,
|
||||
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
|
||||
cur,
|
||||
roadmap_ctx,
|
||||
empty_specs[0],
|
||||
goal_query=goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
),
|
||||
)
|
||||
gap_fill_offers.append(slot_offer)
|
||||
|
||||
ai_step = {
|
||||
**current,
|
||||
"exercise_id": None,
|
||||
"is_ai_proposal": True,
|
||||
"title": slot_offer.get("title_hint") or current.get("title") or f"Slot {major_idx + 1}",
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"gap_offer": slot_offer,
|
||||
}
|
||||
diff_stub = {
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"baseline_exercise_id": int(current_id) if current_id is not None else None,
|
||||
"baseline_title": (current.get("title") or "").strip() or None,
|
||||
"proposed_exercise_id": None,
|
||||
"proposed_title": ai_step.get("title"),
|
||||
}
|
||||
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, [ai_step])
|
||||
eval_res = _evaluate_steps_for_compare_qa(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=scored_eval_body,
|
||||
steps=merged_steps,
|
||||
)
|
||||
projected_qa = (
|
||||
eval_res.get("path_qa")
|
||||
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
|
||||
else None
|
||||
)
|
||||
projected_score = _path_qa_quality_score(projected_qa)
|
||||
delta = (
|
||||
round(projected_score - baseline_score, 4)
|
||||
if baseline_score is not None and projected_score is not None
|
||||
else None
|
||||
)
|
||||
improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic or current_id is None)
|
||||
entry = {
|
||||
**diff_stub,
|
||||
"baseline_slot_status": current.get("slot_status"),
|
||||
"proposed_slot_status": "ai_proposal",
|
||||
"suggestion_type": "ai_gap",
|
||||
"quality_delta": delta,
|
||||
"projected_quality_score": projected_score,
|
||||
"baseline_quality_score": baseline_score,
|
||||
"projected_path_qa": projected_qa,
|
||||
"improves_path": improves,
|
||||
"off_topic": off_topic,
|
||||
"proposed_is_ai_proposal": True,
|
||||
"gap_offer": slot_offer,
|
||||
"pro_contra": _build_slot_pro_contra(
|
||||
current_step=current,
|
||||
proposed_step=None,
|
||||
suggestion_type="ai_gap",
|
||||
baseline_qa=baseline_qa,
|
||||
projected_qa=projected_qa,
|
||||
quality_delta=delta,
|
||||
off_topic_reasons=off_reasons,
|
||||
candidate_reasons=[],
|
||||
gap_offer=slot_offer,
|
||||
),
|
||||
}
|
||||
if improves:
|
||||
suggestions.append(entry)
|
||||
else:
|
||||
rejected.append(entry)
|
||||
|
||||
improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
|
||||
slot_diff_scoring = {
|
||||
"baseline_quality_score": baseline_score,
|
||||
"scored_diffs": improvement_diffs + [_suggestion_as_slot_diff(r) for r in rejected],
|
||||
"improvement_diffs": improvement_diffs,
|
||||
"rejected_diffs": [_suggestion_as_slot_diff(r) for r in rejected],
|
||||
"improvement_count": len(improvement_diffs),
|
||||
"rejected_count": len(rejected),
|
||||
}
|
||||
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
"steps": baseline_steps,
|
||||
"step_count": len(baseline_steps),
|
||||
"target_profile_summary": path_target_profile.to_summary_dict(cur),
|
||||
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
|
||||
"semantic_llm_applied": semantic_llm_applied,
|
||||
"query_intent_summary": first_intent_summary,
|
||||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": baseline_qa,
|
||||
"baseline_path_qa": baseline_qa,
|
||||
"baseline_steps": baseline_steps,
|
||||
"gap_fill_offers": gap_fill_offers,
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": True,
|
||||
"roadmap_only": False,
|
||||
"roadmap_edited": roadmap_edited,
|
||||
"roadmap_unfilled_count": 0,
|
||||
"path_skill_expectations": None,
|
||||
"match_summary": {
|
||||
"unified_slot_review": True,
|
||||
"suggestion_count": len(suggestions),
|
||||
"rejected_count": len(rejected),
|
||||
},
|
||||
"retrieval_phase": "unified_slot_review",
|
||||
"unified_slot_review": True,
|
||||
"slot_suggestions": suggestions,
|
||||
"slot_diff_scoring": slot_diff_scoring,
|
||||
"comparison_mode": True,
|
||||
}
|
||||
|
||||
|
||||
def _merge_gap_fill_offers_from_steps(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
offers: Sequence[Mapping[str, Any]],
|
||||
|
|
@ -2698,6 +3385,23 @@ def suggest_progression_path(
|
|||
path_target_profile = apply_expectations_to_target(path_target_profile, path_exp)
|
||||
path_skill_expectations = path_exp.to_api_dict()
|
||||
|
||||
if body.unified_slot_review:
|
||||
return _run_unified_slot_improvement_review(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
semantic_llm_applied=semantic_llm_applied,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
first_intent_summary=first_intent_summary,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
progression_roadmap=progression_roadmap,
|
||||
roadmap_edited=roadmap_edited,
|
||||
)
|
||||
|
||||
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||
roadmap_gap_offers: List[Dict[str, Any]] = []
|
||||
|
||||
|
|
@ -3081,6 +3785,28 @@ def suggest_progression_path(
|
|||
if refine_log:
|
||||
retrieval_parts.append("stage_spec_refine")
|
||||
|
||||
slot_diff_scoring: Optional[Dict[str, Any]] = None
|
||||
if (
|
||||
body.include_incremental_diff_scoring
|
||||
and body.baseline_evaluate_steps
|
||||
and not evaluate_only
|
||||
and not body.compare_with_assignments
|
||||
):
|
||||
baseline_steps_for_scoring = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
|
||||
raw_diffs = _build_progression_slot_diffs(baseline_steps_for_scoring, steps)
|
||||
baseline_qa_for_scoring: Optional[Dict[str, Any]] = None
|
||||
if body.baseline_quality_score is not None:
|
||||
baseline_qa_for_scoring = {"quality_score": float(body.baseline_quality_score)}
|
||||
slot_diff_scoring = _score_incremental_slot_diffs(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
baseline_steps=baseline_steps_for_scoring,
|
||||
proposed_steps=steps,
|
||||
baseline_path_qa=baseline_qa_for_scoring,
|
||||
raw_diffs=raw_diffs,
|
||||
)
|
||||
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
|
|
@ -3101,6 +3827,7 @@ def suggest_progression_path(
|
|||
"path_skill_expectations": path_skill_expectations,
|
||||
"match_summary": match_summary,
|
||||
"retrieval_phase": "+".join(retrieval_parts),
|
||||
"slot_diff_scoring": slot_diff_scoring,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
39
backend/tests/test_planning_incremental_diff_scoring.py
Normal file
39
backend/tests/test_planning_incremental_diff_scoring.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Tests inkrementelles Slot-Diff-Scoring (nur messbare Verbesserungen)."""
|
||||
from planning_exercise_path_builder import (
|
||||
_apply_slot_diff_to_steps,
|
||||
_slot_diff_improves_path,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_diff_improves_path_fill_neutral_or_positive():
|
||||
fill = {"baseline_exercise_id": None, "proposed_exercise_id": 101}
|
||||
assert _slot_diff_improves_path(fill, 0.0) is True
|
||||
assert _slot_diff_improves_path(fill, 0.04) is True
|
||||
assert _slot_diff_improves_path(fill, -0.01) is False
|
||||
|
||||
|
||||
def test_slot_diff_improves_path_off_topic_allows_neutral_replace():
|
||||
repl = {"baseline_exercise_id": 10, "proposed_exercise_id": 99}
|
||||
assert _slot_diff_improves_path(repl, 0.0, off_topic=True) is True
|
||||
assert _slot_diff_improves_path(repl, -0.02, off_topic=True) is False
|
||||
|
||||
|
||||
def test_apply_slot_diff_merges_proposed_step():
|
||||
baseline = [
|
||||
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
|
||||
{"roadmap_major_step_index": 1, "exercise_id": None, "title": "Leer"},
|
||||
]
|
||||
proposed = [
|
||||
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
|
||||
{"roadmap_major_step_index": 1, "exercise_id": 55, "title": "Neu", "slot_status": "matched"},
|
||||
]
|
||||
diff = {
|
||||
"roadmap_major_step_index": 1,
|
||||
"baseline_exercise_id": None,
|
||||
"proposed_exercise_id": 55,
|
||||
"proposed_title": "Neu",
|
||||
}
|
||||
merged = _apply_slot_diff_to_steps(baseline, diff, proposed)
|
||||
assert merged[0]["exercise_id"] == 1
|
||||
assert merged[1]["exercise_id"] == 55
|
||||
assert merged[1]["title"] == "Neu"
|
||||
|
|
@ -28,11 +28,13 @@ import {
|
|||
applyEvaluateResponseToDraft,
|
||||
applyGapOfferToDraft,
|
||||
applySelectedCompareSteps,
|
||||
applySelectedSlotSuggestions,
|
||||
applyResolvedStructuredToDraft,
|
||||
buildPlanningArtifactFromDraft,
|
||||
buildProgressionComparePayload,
|
||||
collectGapOffersFromApiResponse,
|
||||
compareSlotDiffs,
|
||||
compareDiffsForDialog,
|
||||
dedupeGapOffersBySlot,
|
||||
draftHasLibrarySlotAssignments,
|
||||
draftRetrievalBoostExerciseIds,
|
||||
|
|
@ -47,7 +49,7 @@ import {
|
|||
patchSlotInDraft,
|
||||
pathQaQualityPercent,
|
||||
planningCatalogContextToApi,
|
||||
recommendedCompareDiffs,
|
||||
rejectedCompareDiffs,
|
||||
removeSlotFromDraft,
|
||||
saveProgressionGraphDraft,
|
||||
setCatalogSelectItems,
|
||||
|
|
@ -491,24 +493,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
}
|
||||
}
|
||||
|
||||
const fetchFullMatch = async (synced) =>
|
||||
api.suggestProgressionPath({
|
||||
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
||||
setMatchNotice('Pfad bewerten und je Slot passende Verbesserungen prüfen…')
|
||||
const reviewRes = await api.suggestProgressionPath({
|
||||
...buildMatchRequestBase(synced),
|
||||
preserve_slot_assignments: false,
|
||||
unified_slot_review: true,
|
||||
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
||||
include_llm_intent: false,
|
||||
include_llm_path_qa: false,
|
||||
auto_rematch_after_qa: false,
|
||||
})
|
||||
setPathQa(reviewRes?.path_qa || null)
|
||||
|
||||
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
||||
setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…')
|
||||
const baselineRes = await fetchPathEvaluate(synced)
|
||||
setPathQa(baselineRes?.path_qa || null)
|
||||
|
||||
setMatchNotice('Schritt 2/2: Match für alle Slots (Bibliothek + Lücken)…')
|
||||
const matchRes = await fetchFullMatch(synced)
|
||||
|
||||
const compareRes = buildProgressionComparePayload(baselineRes, matchRes)
|
||||
setGapFillOffers(mergeGapOffersForDraft(synced, baselineRes, matchRes))
|
||||
const compareRes = buildProgressionComparePayload(null, reviewRes)
|
||||
setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes))
|
||||
presentMatchCompare(compareRes, { source })
|
||||
return compareRes
|
||||
}
|
||||
|
|
@ -522,31 +520,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setCompareOpen(true)
|
||||
|
||||
const baselineQa = res?.baseline_path_qa || null
|
||||
const proposedQa = res?.proposed_path_qa || res?.path_qa || null
|
||||
const diffCount =
|
||||
res?.slot_diff_count_recommended
|
||||
?? recommendedCompareDiffs(res).length
|
||||
?? res?.slot_diff_count
|
||||
?? compareSlotDiffs(res, { actionableOnly: true }).length
|
||||
const replaceCount = (res?.slot_diffs || []).filter(
|
||||
(d) => d?.diff_kind === 'replace',
|
||||
).length
|
||||
const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length
|
||||
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
|
||||
const bPct = pathQaQualityPercent(baselineQa)
|
||||
const pPct = pathQaQualityPercent(proposedQa)
|
||||
let notice =
|
||||
diffCount > 0
|
||||
? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.`
|
||||
: 'Match: Keine Bibliotheks-Lückenfüllungen — Dialog zur Kontrolle geöffnet.'
|
||||
if (replaceCount > 0) {
|
||||
notice += ` ${replaceCount} optionale Ersetzung(en) bestehender Slots — standardmäßig abgewählt.`
|
||||
? `Match: ${diffCount} Verbesserung(en) — je Slot gegen deinen Pfad (${bPct != null ? `${bPct} %` : 'QS'}) geprüft.`
|
||||
: 'Match: Keine messbare Verbesserung gegenüber deinem Pfad.'
|
||||
if (rejectedCount > 0) {
|
||||
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
|
||||
}
|
||||
const gapCount = collectGapOffersFromApiResponse(res).length
|
||||
if (gapCount > 0) {
|
||||
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
|
||||
}
|
||||
if (bPct != null && pPct != null && pPct !== bPct) {
|
||||
notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
|
||||
}
|
||||
setMatchNotice(notice)
|
||||
}
|
||||
|
||||
|
|
@ -603,11 +590,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setCompareApplying(true)
|
||||
try {
|
||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||
const nextDraft = applySelectedCompareSteps(
|
||||
synced,
|
||||
comparePayload.proposed_steps || comparePayload.steps,
|
||||
selectedMajorIndices,
|
||||
)
|
||||
const nextDraft = comparePayload?.unified_slot_review
|
||||
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
|
||||
: applySelectedCompareSteps(
|
||||
synced,
|
||||
comparePayload.proposed_steps || comparePayload.steps,
|
||||
selectedMajorIndices,
|
||||
)
|
||||
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
||||
const evalRes = await fetchPathEvaluate(syncedNext)
|
||||
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
/**
|
||||
* Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag.
|
||||
* Gegenüberstellung: Verbesserungsvorschläge mit Slot-Bewertung (Pro/Contra).
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
compareDiffsForDialog,
|
||||
defaultSelectedCompareDiffs,
|
||||
gapOnlyCompareDiffs,
|
||||
optionalReplaceCompareDiffs,
|
||||
pathQaQualityPercent,
|
||||
recommendedCompareDiffs,
|
||||
qualityDeltaPercent,
|
||||
rejectedCompareDiffs,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
function qaLabel(pathQa) {
|
||||
|
|
@ -18,25 +17,44 @@ function qaLabel(pathQa) {
|
|||
return ok ? 'OK' : 'Hinweise'
|
||||
}
|
||||
|
||||
function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) {
|
||||
function deltaLabel(diff) {
|
||||
const pct = qualityDeltaPercent(diff)
|
||||
if (pct == null) return null
|
||||
if (pct > 0) return `+${pct} % Pfad-QS`
|
||||
if (pct === 0) return '±0 % Pfad-QS'
|
||||
return `${pct} % Pfad-QS`
|
||||
}
|
||||
|
||||
function ProContraList({ title, items, tone = 'neutral' }) {
|
||||
if (!items?.length) return null
|
||||
const color =
|
||||
tone === 'pro' ? 'var(--accent-dark)' : tone === 'contra' ? 'var(--danger)' : 'var(--text2)'
|
||||
return (
|
||||
<div style={{ marginTop: '6px' }}>
|
||||
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)' }}>{title}</div>
|
||||
<ul style={{ margin: '4px 0 0', paddingLeft: '16px', color, fontSize: '11px' }}>
|
||||
{items.map((text, i) => (
|
||||
<li key={`${title}-${i}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffRow({ diff, checked, onToggle, applying }) {
|
||||
const midx = Number(diff.roadmap_major_step_index)
|
||||
const border =
|
||||
tone === 'warn'
|
||||
? '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))'
|
||||
: '1px solid var(--border)'
|
||||
const bg = checked
|
||||
? tone === 'warn'
|
||||
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
|
||||
: 'var(--surface2)'
|
||||
: 'var(--surface)'
|
||||
const delta = deltaLabel(diff)
|
||||
const pc = diff.pro_contra || {}
|
||||
const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal
|
||||
const isFill = diff.baseline_exercise_id == null && !isAi
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border,
|
||||
background: bg,
|
||||
border: '1px solid var(--border)',
|
||||
background: checked ? 'var(--surface2)' : 'var(--surface)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
|
|
@ -49,28 +67,60 @@ function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) {
|
|||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>Slot {midx + 1}</strong>
|
||||
{tone === 'warn' ? (
|
||||
<span style={{ marginLeft: '6px', fontSize: '10px', color: 'var(--danger)' }}>
|
||||
Ersetzt deine Zuordnung
|
||||
</span>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<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>
|
||||
) : (
|
||||
<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
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '8px',
|
||||
marginTop: '6px',
|
||||
gap: '10px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text2)' }}>
|
||||
Bisher: {diff.baseline_title || '— leer —'}
|
||||
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
|
||||
</span>
|
||||
<span style={{ color: tone === 'warn' ? 'var(--danger)' : 'var(--accent-dark)' }}>
|
||||
Neu: {diff.proposed_title || '— leer —'}
|
||||
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Aktuell
|
||||
</div>
|
||||
<div style={{ color: 'var(--text2)' }}>
|
||||
{diff.baseline_title || '— leer —'}
|
||||
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
|
||||
</div>
|
||||
<ProContraList title="Pro" items={pc.current_pro} tone="pro" />
|
||||
<ProContraList title="Contra" items={pc.current_contra} tone="contra" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Vorschlag
|
||||
</div>
|
||||
<div style={{ color: 'var(--accent-dark)' }}>
|
||||
{diff.proposed_title || '—'}
|
||||
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''}
|
||||
</div>
|
||||
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
|
||||
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -86,16 +136,8 @@ export default function ProgressionOptimizeCompareModal({
|
|||
onApplySelected,
|
||||
applying = false,
|
||||
}) {
|
||||
const recommended = useMemo(
|
||||
() => recommendedCompareDiffs(comparison),
|
||||
[comparison],
|
||||
)
|
||||
const optionalReplace = useMemo(
|
||||
() => optionalReplaceCompareDiffs(comparison),
|
||||
[comparison],
|
||||
)
|
||||
const gapOnly = useMemo(() => gapOnlyCompareDiffs(comparison), [comparison])
|
||||
const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison])
|
||||
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
|
||||
const defaultSelected = useMemo(
|
||||
() => defaultSelectedCompareDiffs(comparison),
|
||||
[comparison],
|
||||
|
|
@ -111,18 +153,8 @@ export default function ProgressionOptimizeCompareModal({
|
|||
if (!open || !comparison) return null
|
||||
|
||||
const baselineQa = comparison.baseline_path_qa
|
||||
const pipelineQa = comparison.proposed_path_qa_pipeline
|
||||
const baselinePct = pathQaQualityPercent(baselineQa)
|
||||
const pipelinePct = pathQaQualityPercent(pipelineQa)
|
||||
const rematchRounds = pipelineQa?.rematch_rounds
|
||||
const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0
|
||||
const refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0
|
||||
const hintCount = Number(pipelineQa?.optimization_hint_count || 0)
|
||||
const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0
|
||||
|
||||
const selectedReplaceCount = optionalReplace.filter((d) =>
|
||||
selected.has(Number(d.roadmap_major_step_index)),
|
||||
).length
|
||||
const rejectedCount = rejected.length
|
||||
|
||||
const toggle = (midx) => {
|
||||
setSelected((prev) => {
|
||||
|
|
@ -133,20 +165,12 @@ export default function ProgressionOptimizeCompareModal({
|
|||
})
|
||||
}
|
||||
|
||||
const toggleGroup = (diffs, on) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const d of diffs) {
|
||||
const midx = Number(d.roadmap_major_step_index)
|
||||
if (on) next.add(midx)
|
||||
else next.delete(midx)
|
||||
}
|
||||
return next
|
||||
})
|
||||
const toggleAll = (on) => {
|
||||
setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set())
|
||||
}
|
||||
|
||||
const title =
|
||||
mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen'
|
||||
mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen'
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -161,201 +185,90 @@ export default function ProgressionOptimizeCompareModal({
|
|||
>
|
||||
<div
|
||||
className="card modal-content"
|
||||
style={{ maxWidth: '720px', width: '100%', maxHeight: '90vh', overflow: 'auto' }}
|
||||
style={{ maxWidth: '760px', width: '100%', maxHeight: '90vh', 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 }}>
|
||||
Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind
|
||||
vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter.
|
||||
KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel „KI-Angebote“, nicht hierher.
|
||||
Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere
|
||||
Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen
|
||||
hier — mit Pro- und Contra-Punkten auf Slot-Ebene.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
|
||||
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong>Warum nicht einfach alles übernehmen?</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
Der Match-Lauf optimiert den <em>gesamten</em> Pfad neu (inkl. Rematch). Das kann
|
||||
bereits gute Slots verschlechtern. Die Prozentzahl rechts bezieht sich auf diesen
|
||||
Ganzpfad — nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig
|
||||
nur Lückenfüllungen an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
marginBottom: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Dein Pfad (bewertet)</strong>
|
||||
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||
{baselineQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--text3) 35%, var(--border))',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Match-Ganzpfad (nur Info)</strong>
|
||||
<div style={{ marginTop: '6px', color: 'var(--text2)' }}>
|
||||
{pipelineQa ? qaLabel(pipelineQa) : '—'}
|
||||
</div>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Rematch-Prozess — kein Versprechen für deine Checkbox-Auswahl.
|
||||
{pipelinePct != null && baselinePct != null && pipelinePct < baselinePct
|
||||
? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<strong>Dein Pfad</strong>
|
||||
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||
{baselineQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? (
|
||||
{rejectedCount > 0 ? (
|
||||
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
||||
Rematch-Protokoll
|
||||
{tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''}
|
||||
{rematchCount > 0
|
||||
? ` · ${rematchRounds != null ? `${rematchRounds} Runde(n)` : ''}: ${rematchCount} Anpassung(en)`
|
||||
: ''}
|
||||
{refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''}
|
||||
{hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{gapOnly.length > 0 ? (
|
||||
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
||||
Slot{gapOnly.length > 1 ? 's' : ''}{' '}
|
||||
{gapOnly.map((d) => Number(d.roadmap_major_step_index) + 1).join(', ')}: kein
|
||||
Bibliotheks-Treffer — bitte „KI-Angebote“ im Panel nutzen (eigenständig pro Slot).
|
||||
{rejectedCount} Alternative(n) verworfen — kein QS-Gewinn gegenüber deinem Pfad
|
||||
{baselinePct != null ? ` (${baselinePct} %)` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dialogDiffs.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||
Keine übernehmbaren Bibliotheks-Änderungen. Leere Slots ggf. über KI-Angebote im Panel
|
||||
befüllen — nichts am Pfad ändern ist oft die richtige Wahl.
|
||||
Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet
|
||||
oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{recommended.length > 0 ? (
|
||||
<>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
|
||||
Lücken füllen (empfohlen)
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleGroup(recommended, true)}
|
||||
>
|
||||
Alle Lücken wählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleGroup(recommended, false)}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{recommended.map((diff) => (
|
||||
<DiffRow
|
||||
key={`fill-${diff.roadmap_major_step_index}`}
|
||||
diff={diff}
|
||||
checked={selected.has(Number(diff.roadmap_major_step_index))}
|
||||
onToggle={toggle}
|
||||
applying={applying}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{optionalReplace.length > 0 ? (
|
||||
<>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem', color: 'var(--danger)' }}>
|
||||
Bestehende Slots ersetzen (optional — oft Verschlechterung)
|
||||
</h4>
|
||||
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 8px' }}>
|
||||
Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen
|
||||
willst.
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{optionalReplace.map((diff) => (
|
||||
<DiffRow
|
||||
key={`replace-${diff.roadmap_major_step_index}`}
|
||||
diff={diff}
|
||||
checked={selected.has(Number(diff.roadmap_major_step_index))}
|
||||
onToggle={toggle}
|
||||
applying={applying}
|
||||
tone="warn"
|
||||
/>
|
||||
))}
|
||||
</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',
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedReplaceCount > 0 ? (
|
||||
<p
|
||||
className="form-error"
|
||||
style={{ marginTop: '12px', fontSize: '11px' }}
|
||||
>
|
||||
{selectedReplaceCount} Ersetzung(en) gewählt — kann Pfad-QS senken. Lückenfüllungen
|
||||
sind unkritischer.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -997,6 +997,12 @@ export function compareDiffKind(diff) {
|
|||
return 'skip'
|
||||
}
|
||||
|
||||
export function qualityDeltaPercent(diff) {
|
||||
const delta = diff?.quality_delta
|
||||
if (delta == null || !Number.isFinite(Number(delta))) return null
|
||||
return Math.round(Number(delta) * 100)
|
||||
}
|
||||
|
||||
export function annotateCompareDiffKinds(diffs) {
|
||||
return (diffs || []).map((d) => ({
|
||||
...d,
|
||||
|
|
@ -1004,20 +1010,57 @@ export function annotateCompareDiffKinds(diffs) {
|
|||
}))
|
||||
}
|
||||
|
||||
/** Nur übernehmbare Bibliotheks-Diffs (kein reines Titel-/Gap-Geplänkel). */
|
||||
/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
|
||||
export function compareDiffsForDialog(comparison) {
|
||||
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
|
||||
if (fromSuggestions.length > 0) {
|
||||
return fromSuggestions
|
||||
.map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) }))
|
||||
.filter(
|
||||
(d) =>
|
||||
d.proposed_exercise_id != null
|
||||
|| (d.suggestion_type === 'ai_gap' && d.gap_offer),
|
||||
)
|
||||
}
|
||||
if (Array.isArray(comparison?.slot_diffs_improving)) {
|
||||
return comparison.slot_diffs_improving.filter(
|
||||
(d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap,
|
||||
)
|
||||
}
|
||||
const diffs = annotateCompareDiffKinds(
|
||||
compareSlotDiffs(comparison, { actionableOnly: true }),
|
||||
)
|
||||
return diffs.filter((d) => d.diff_kind === 'fill' || d.diff_kind === 'replace')
|
||||
return diffs.filter(
|
||||
(d) =>
|
||||
(d.diff_kind === 'fill' || d.diff_kind === 'replace')
|
||||
&& d.proposed_exercise_id != null,
|
||||
)
|
||||
}
|
||||
|
||||
export function suggestionDiffKind(suggestion) {
|
||||
if (!suggestion) return 'skip'
|
||||
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
|
||||
if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) {
|
||||
return 'fill'
|
||||
}
|
||||
if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) {
|
||||
return 'replace'
|
||||
}
|
||||
return 'skip'
|
||||
}
|
||||
|
||||
export function recommendedCompareDiffs(comparison) {
|
||||
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill')
|
||||
return compareDiffsForDialog(comparison)
|
||||
}
|
||||
|
||||
export function optionalReplaceCompareDiffs(comparison) {
|
||||
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'replace')
|
||||
return []
|
||||
}
|
||||
|
||||
export function rejectedCompareDiffs(comparison) {
|
||||
return Array.isArray(comparison?.slot_diffs_rejected)
|
||||
? comparison.slot_diffs_rejected
|
||||
: []
|
||||
}
|
||||
|
||||
export function gapOnlyCompareDiffs(comparison) {
|
||||
|
|
@ -1027,7 +1070,7 @@ export function gapOnlyCompareDiffs(comparison) {
|
|||
}
|
||||
|
||||
export function defaultSelectedCompareDiffs(comparison) {
|
||||
return recommendedCompareDiffs(comparison).map((d) => Number(d.roadmap_major_step_index))
|
||||
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
|
||||
}
|
||||
|
||||
function mergeGapFillOffersFromSteps(steps, offers) {
|
||||
|
|
@ -1044,29 +1087,43 @@ function mergeGapFillOffersFromSteps(steps, offers) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Vergleich aus zwei kaskadierten Antworten (Evaluate → Match) — spiegelt Backend-Compare.
|
||||
* Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match).
|
||||
*/
|
||||
export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
||||
if (proposedRes?.unified_slot_review) {
|
||||
return buildUnifiedSlotReviewComparePayload(proposedRes)
|
||||
}
|
||||
|
||||
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
|
||||
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
|
||||
const baselineQa = baselineRes?.path_qa || null
|
||||
const pipelineQa = proposedRes?.path_qa || null
|
||||
const slotDiffs = annotateCompareDiffKinds(
|
||||
const scoring = proposedRes?.slot_diff_scoring
|
||||
const rawDiffs = annotateCompareDiffKinds(
|
||||
annotateCompareSlotDiffs(
|
||||
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
||||
),
|
||||
)
|
||||
const actionableDiffs = actionableCompareSlotDiffs(slotDiffs)
|
||||
const dialogDiffs = actionableDiffs.filter(
|
||||
(d) => d.diff_kind === 'fill' || d.diff_kind === 'replace',
|
||||
const improvingDiffs = annotateCompareDiffKinds(
|
||||
(scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null),
|
||||
)
|
||||
const recommendedDiffs = dialogDiffs.filter((d) => d.diff_kind === 'fill')
|
||||
const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || [])
|
||||
const dialogDiffs = improvingDiffs.length > 0
|
||||
? improvingDiffs
|
||||
: rawDiffs.filter(
|
||||
(d) =>
|
||||
!d.trivial_id_swap
|
||||
&& (d.diff_kind === 'fill' || d.diff_kind === 'replace')
|
||||
&& d.proposed_exercise_id != null
|
||||
&& d.improves_path !== false,
|
||||
)
|
||||
const actionableDiffs = dialogDiffs
|
||||
const gapFillOffers = mergeGapFillOffersFromSteps(
|
||||
proposedSteps,
|
||||
proposedRes?.gap_fill_offers || [],
|
||||
)
|
||||
const proposedQa =
|
||||
actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa
|
||||
const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score
|
||||
const proposedQa = baselineQa
|
||||
|
||||
return {
|
||||
...proposedRes,
|
||||
|
|
@ -1078,19 +1135,110 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
|||
proposed_path_qa: proposedQa,
|
||||
proposed_path_qa_pipeline: pipelineQa,
|
||||
gap_fill_offers: gapFillOffers,
|
||||
slot_diffs: slotDiffs,
|
||||
slot_diffs: rawDiffs,
|
||||
slot_diffs_actionable: actionableDiffs,
|
||||
slot_diffs_improving: improvingDiffs,
|
||||
slot_diffs_rejected: rejectedDiffs,
|
||||
slot_diffs_dialog: dialogDiffs,
|
||||
slot_diffs_recommended: recommendedDiffs,
|
||||
slot_diffs_recommended: dialogDiffs,
|
||||
slot_diff_count: dialogDiffs.length,
|
||||
slot_diff_count_recommended: recommendedDiffs.length,
|
||||
slot_diff_count_including_trivial: slotDiffs.length,
|
||||
slot_diffs_source: 'steps',
|
||||
slot_diff_count_recommended: dialogDiffs.length,
|
||||
slot_diff_count_rejected: rejectedDiffs.length,
|
||||
slot_diff_count_including_trivial: rawDiffs.length,
|
||||
slot_diffs_source: scoring ? 'incremental_scoring' : 'steps',
|
||||
slot_diff_scoring: scoring,
|
||||
baseline_quality_score: baselineScore,
|
||||
path_qa: proposedQa,
|
||||
steps: proposedSteps,
|
||||
}
|
||||
}
|
||||
|
||||
/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */
|
||||
export function buildUnifiedSlotReviewComparePayload(res) {
|
||||
const baselineSteps = Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])
|
||||
const baselineQa = res?.baseline_path_qa || res?.path_qa || null
|
||||
const scoring = res?.slot_diff_scoring
|
||||
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : []
|
||||
const improving = suggestions.filter((s) => s?.improves_path)
|
||||
const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : []
|
||||
const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean)
|
||||
const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
|
||||
|
||||
return {
|
||||
...res,
|
||||
comparison_mode: true,
|
||||
unified_slot_review: true,
|
||||
baseline_steps: baselineSteps,
|
||||
baseline_path_qa: baselineQa,
|
||||
proposed_steps: proposedSteps,
|
||||
proposed_steps_pipeline: proposedSteps,
|
||||
proposed_path_qa: baselineQa,
|
||||
proposed_path_qa_pipeline: null,
|
||||
gap_fill_offers: gapFillOffers,
|
||||
slot_suggestions: suggestions,
|
||||
slot_diffs: improving,
|
||||
slot_diffs_improving: improving,
|
||||
slot_diffs_rejected: rejected,
|
||||
slot_diffs_dialog: improving,
|
||||
slot_diffs_recommended: improving,
|
||||
slot_diff_count: improving.length,
|
||||
slot_diff_count_recommended: improving.length,
|
||||
slot_diff_count_rejected: rejected.length,
|
||||
slot_diffs_source: 'unified_slot_review',
|
||||
slot_diff_scoring: scoring,
|
||||
baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score,
|
||||
path_qa: baselineQa,
|
||||
steps: baselineSteps,
|
||||
}
|
||||
}
|
||||
|
||||
function suggestionToApplyStep(suggestion) {
|
||||
if (!suggestion || suggestion.roadmap_major_step_index == null) return null
|
||||
const midx = Number(suggestion.roadmap_major_step_index)
|
||||
if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) {
|
||||
const offer = suggestion.gap_offer
|
||||
return {
|
||||
roadmap_major_step_index: midx,
|
||||
exercise_id: null,
|
||||
title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`,
|
||||
is_ai_proposal: true,
|
||||
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
|
||||
gap_offer: offer,
|
||||
slot_status: 'ai_proposal',
|
||||
}
|
||||
}
|
||||
if (suggestion.proposed_exercise_id == null) return null
|
||||
return {
|
||||
roadmap_major_step_index: midx,
|
||||
exercise_id: suggestion.proposed_exercise_id,
|
||||
title: suggestion.proposed_title,
|
||||
slot_status: suggestion.proposed_slot_status || 'matched',
|
||||
is_ai_proposal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */
|
||||
export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) {
|
||||
const selected = new Set(
|
||||
(selectedMajorIndices || [])
|
||||
.map((x) => Number(x))
|
||||
.filter((x) => Number.isFinite(x)),
|
||||
)
|
||||
if (!selected.size) return draft
|
||||
const steps = (comparison?.slot_suggestions || [])
|
||||
.filter((s) => selected.has(Number(s.roadmap_major_step_index)))
|
||||
.map(suggestionToApplyStep)
|
||||
.filter(Boolean)
|
||||
if (!steps.length) {
|
||||
return applySelectedCompareSteps(
|
||||
draft,
|
||||
comparison?.proposed_steps || comparison?.steps,
|
||||
selectedMajorIndices,
|
||||
)
|
||||
}
|
||||
return applyMatchStepsToSlots(draft, steps)
|
||||
}
|
||||
|
||||
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
|
||||
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
|
||||
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user