progression V2 #57
|
|
@ -143,6 +143,11 @@ class ProgressionPathSuggestRequest(BaseModel):
|
||||||
exercise_kind_any: Optional[List[str]] = None
|
exercise_kind_any: Optional[List[str]] = None
|
||||||
compare_with_assignments: bool = False
|
compare_with_assignments: bool = False
|
||||||
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
|
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(
|
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)
|
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(
|
def _merge_gap_fill_offers_from_steps(
|
||||||
steps: Sequence[Mapping[str, Any]],
|
steps: Sequence[Mapping[str, Any]],
|
||||||
offers: 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_target_profile = apply_expectations_to_target(path_target_profile, path_exp)
|
||||||
path_skill_expectations = path_exp.to_api_dict()
|
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_unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||||
roadmap_gap_offers: List[Dict[str, Any]] = []
|
roadmap_gap_offers: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
@ -3081,6 +3785,28 @@ def suggest_progression_path(
|
||||||
if refine_log:
|
if refine_log:
|
||||||
retrieval_parts.append("stage_spec_refine")
|
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 {
|
return {
|
||||||
"goal_query": goal_query,
|
"goal_query": goal_query,
|
||||||
"max_steps_requested": max_steps,
|
"max_steps_requested": max_steps,
|
||||||
|
|
@ -3101,6 +3827,7 @@ def suggest_progression_path(
|
||||||
"path_skill_expectations": path_skill_expectations,
|
"path_skill_expectations": path_skill_expectations,
|
||||||
"match_summary": match_summary,
|
"match_summary": match_summary,
|
||||||
"retrieval_phase": "+".join(retrieval_parts),
|
"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,
|
applyEvaluateResponseToDraft,
|
||||||
applyGapOfferToDraft,
|
applyGapOfferToDraft,
|
||||||
applySelectedCompareSteps,
|
applySelectedCompareSteps,
|
||||||
|
applySelectedSlotSuggestions,
|
||||||
applyResolvedStructuredToDraft,
|
applyResolvedStructuredToDraft,
|
||||||
buildPlanningArtifactFromDraft,
|
buildPlanningArtifactFromDraft,
|
||||||
buildProgressionComparePayload,
|
buildProgressionComparePayload,
|
||||||
collectGapOffersFromApiResponse,
|
collectGapOffersFromApiResponse,
|
||||||
compareSlotDiffs,
|
compareSlotDiffs,
|
||||||
|
compareDiffsForDialog,
|
||||||
dedupeGapOffersBySlot,
|
dedupeGapOffersBySlot,
|
||||||
draftHasLibrarySlotAssignments,
|
draftHasLibrarySlotAssignments,
|
||||||
draftRetrievalBoostExerciseIds,
|
draftRetrievalBoostExerciseIds,
|
||||||
|
|
@ -47,7 +49,7 @@ import {
|
||||||
patchSlotInDraft,
|
patchSlotInDraft,
|
||||||
pathQaQualityPercent,
|
pathQaQualityPercent,
|
||||||
planningCatalogContextToApi,
|
planningCatalogContextToApi,
|
||||||
recommendedCompareDiffs,
|
rejectedCompareDiffs,
|
||||||
removeSlotFromDraft,
|
removeSlotFromDraft,
|
||||||
saveProgressionGraphDraft,
|
saveProgressionGraphDraft,
|
||||||
setCatalogSelectItems,
|
setCatalogSelectItems,
|
||||||
|
|
@ -491,24 +493,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFullMatch = async (synced) =>
|
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
||||||
api.suggestProgressionPath({
|
setMatchNotice('Pfad bewerten und je Slot passende Verbesserungen prüfen…')
|
||||||
|
const reviewRes = await api.suggestProgressionPath({
|
||||||
...buildMatchRequestBase(synced),
|
...buildMatchRequestBase(synced),
|
||||||
preserve_slot_assignments: false,
|
unified_slot_review: true,
|
||||||
|
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
||||||
include_llm_intent: false,
|
include_llm_intent: false,
|
||||||
include_llm_path_qa: false,
|
include_llm_path_qa: false,
|
||||||
|
auto_rematch_after_qa: false,
|
||||||
})
|
})
|
||||||
|
setPathQa(reviewRes?.path_qa || null)
|
||||||
|
|
||||||
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
const compareRes = buildProgressionComparePayload(null, reviewRes)
|
||||||
setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…')
|
setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes))
|
||||||
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))
|
|
||||||
presentMatchCompare(compareRes, { source })
|
presentMatchCompare(compareRes, { source })
|
||||||
return compareRes
|
return compareRes
|
||||||
}
|
}
|
||||||
|
|
@ -522,31 +520,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setCompareOpen(true)
|
setCompareOpen(true)
|
||||||
|
|
||||||
const baselineQa = res?.baseline_path_qa || null
|
const baselineQa = res?.baseline_path_qa || null
|
||||||
const proposedQa = res?.proposed_path_qa || res?.path_qa || null
|
const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length
|
||||||
const diffCount =
|
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
|
||||||
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 bPct = pathQaQualityPercent(baselineQa)
|
const bPct = pathQaQualityPercent(baselineQa)
|
||||||
const pPct = pathQaQualityPercent(proposedQa)
|
|
||||||
let notice =
|
let notice =
|
||||||
diffCount > 0
|
diffCount > 0
|
||||||
? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.`
|
? `Match: ${diffCount} Verbesserung(en) — je Slot gegen deinen Pfad (${bPct != null ? `${bPct} %` : 'QS'}) geprüft.`
|
||||||
: 'Match: Keine Bibliotheks-Lückenfüllungen — Dialog zur Kontrolle geöffnet.'
|
: 'Match: Keine messbare Verbesserung gegenüber deinem Pfad.'
|
||||||
if (replaceCount > 0) {
|
if (rejectedCount > 0) {
|
||||||
notice += ` ${replaceCount} optionale Ersetzung(en) bestehender Slots — standardmäßig abgewählt.`
|
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
|
||||||
}
|
}
|
||||||
const gapCount = collectGapOffersFromApiResponse(res).length
|
const gapCount = collectGapOffersFromApiResponse(res).length
|
||||||
if (gapCount > 0) {
|
if (gapCount > 0) {
|
||||||
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
|
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)
|
setMatchNotice(notice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,11 +590,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setCompareApplying(true)
|
setCompareApplying(true)
|
||||||
try {
|
try {
|
||||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const nextDraft = applySelectedCompareSteps(
|
const nextDraft = comparePayload?.unified_slot_review
|
||||||
synced,
|
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
|
||||||
comparePayload.proposed_steps || comparePayload.steps,
|
: applySelectedCompareSteps(
|
||||||
selectedMajorIndices,
|
synced,
|
||||||
)
|
comparePayload.proposed_steps || comparePayload.steps,
|
||||||
|
selectedMajorIndices,
|
||||||
|
)
|
||||||
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
||||||
const evalRes = await fetchPathEvaluate(syncedNext)
|
const evalRes = await fetchPathEvaluate(syncedNext)
|
||||||
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)
|
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 React, { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
compareDiffsForDialog,
|
compareDiffsForDialog,
|
||||||
defaultSelectedCompareDiffs,
|
defaultSelectedCompareDiffs,
|
||||||
gapOnlyCompareDiffs,
|
|
||||||
optionalReplaceCompareDiffs,
|
|
||||||
pathQaQualityPercent,
|
pathQaQualityPercent,
|
||||||
recommendedCompareDiffs,
|
qualityDeltaPercent,
|
||||||
|
rejectedCompareDiffs,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
function qaLabel(pathQa) {
|
function qaLabel(pathQa) {
|
||||||
|
|
@ -18,25 +17,44 @@ function qaLabel(pathQa) {
|
||||||
return ok ? 'OK' : 'Hinweise'
|
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 midx = Number(diff.roadmap_major_step_index)
|
||||||
const border =
|
const delta = deltaLabel(diff)
|
||||||
tone === 'warn'
|
const pc = diff.pro_contra || {}
|
||||||
? '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))'
|
const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal
|
||||||
: '1px solid var(--border)'
|
const isFill = diff.baseline_exercise_id == null && !isAi
|
||||||
const bg = checked
|
|
||||||
? tone === 'warn'
|
|
||||||
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
|
|
||||||
: 'var(--surface2)'
|
|
||||||
: 'var(--surface)'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border,
|
border: '1px solid var(--border)',
|
||||||
background: bg,
|
background: checked ? 'var(--surface2)' : 'var(--surface)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -49,28 +67,60 @@ function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) {
|
||||||
style={{ marginTop: '3px' }}
|
style={{ marginTop: '3px' }}
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1 }}>
|
<span style={{ flex: 1 }}>
|
||||||
<strong>Slot {midx + 1}</strong>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||||
{tone === 'warn' ? (
|
<strong>Slot {midx + 1}</strong>
|
||||||
<span style={{ marginLeft: '6px', fontSize: '10px', color: 'var(--danger)' }}>
|
{isFill ? (
|
||||||
Ersetzt deine Zuordnung
|
<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>
|
||||||
|
) : 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: '8px',
|
gap: '10px',
|
||||||
marginTop: '6px',
|
marginTop: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--text2)' }}>
|
<div>
|
||||||
Bisher: {diff.baseline_title || '— leer —'}
|
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
|
||||||
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
|
Aktuell
|
||||||
</span>
|
</div>
|
||||||
<span style={{ color: tone === 'warn' ? 'var(--danger)' : 'var(--accent-dark)' }}>
|
<div style={{ color: 'var(--text2)' }}>
|
||||||
Neu: {diff.proposed_title || '— leer —'}
|
{diff.baseline_title || '— leer —'}
|
||||||
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''}
|
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -86,16 +136,8 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
onApplySelected,
|
onApplySelected,
|
||||||
applying = false,
|
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 dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison])
|
||||||
|
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
|
||||||
const defaultSelected = useMemo(
|
const defaultSelected = useMemo(
|
||||||
() => defaultSelectedCompareDiffs(comparison),
|
() => defaultSelectedCompareDiffs(comparison),
|
||||||
[comparison],
|
[comparison],
|
||||||
|
|
@ -111,18 +153,8 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
if (!open || !comparison) return null
|
if (!open || !comparison) return null
|
||||||
|
|
||||||
const baselineQa = comparison.baseline_path_qa
|
const baselineQa = comparison.baseline_path_qa
|
||||||
const pipelineQa = comparison.proposed_path_qa_pipeline
|
|
||||||
const baselinePct = pathQaQualityPercent(baselineQa)
|
const baselinePct = pathQaQualityPercent(baselineQa)
|
||||||
const pipelinePct = pathQaQualityPercent(pipelineQa)
|
const rejectedCount = rejected.length
|
||||||
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 toggle = (midx) => {
|
const toggle = (midx) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
|
|
@ -133,20 +165,12 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleGroup = (diffs, on) => {
|
const toggleAll = (on) => {
|
||||||
setSelected((prev) => {
|
setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set())
|
||||||
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 title =
|
const title =
|
||||||
mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen'
|
mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -161,201 +185,90 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="card modal-content"
|
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()}
|
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 }}>
|
||||||
Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind
|
Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere
|
||||||
vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter.
|
Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen
|
||||||
KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel „KI-Angebote“, nicht hierher.
|
hier — mit Pro- und Contra-Punkten auf Slot-Ebene.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: '12px',
|
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
|
border: '1px solid var(--border)',
|
||||||
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
|
background: 'var(--surface2)',
|
||||||
fontSize: '12px',
|
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',
|
marginBottom: '14px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<strong>Dein Pfad</strong>
|
||||||
style={{
|
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||||
padding: '10px 12px',
|
{baselineQa?.topic_coverage ? (
|
||||||
borderRadius: '8px',
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||||
border: '1px solid var(--border)',
|
) : null}
|
||||||
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>
|
|
||||||
</div>
|
</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 }}>
|
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
||||||
Rematch-Protokoll
|
{rejectedCount} Alternative(n) verworfen — kein QS-Gewinn gegenüber deinem Pfad
|
||||||
{tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''}
|
{baselinePct != null ? ` (${baselinePct} %)` : ''}.
|
||||||
{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).
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dialogDiffs.length === 0 ? (
|
{dialogDiffs.length === 0 ? (
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||||
Keine übernehmbaren Bibliotheks-Änderungen. Leere Slots ggf. über KI-Angebote im Panel
|
Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet
|
||||||
befüllen — nichts am Pfad ändern ist oft die richtige Wahl.
|
oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{recommended.length > 0 ? (
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||||
<>
|
<button
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
|
type="button"
|
||||||
Lücken füllen (empfohlen)
|
className="btn btn-secondary"
|
||||||
</h4>
|
style={{ fontSize: '11px' }}
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
onClick={() => toggleAll(true)}
|
||||||
<button
|
>
|
||||||
type="button"
|
Alle wählen
|
||||||
className="btn btn-secondary"
|
</button>
|
||||||
style={{ fontSize: '11px' }}
|
<button
|
||||||
onClick={() => toggleGroup(recommended, true)}
|
type="button"
|
||||||
>
|
className="btn btn-secondary"
|
||||||
Alle Lücken wählen
|
style={{ fontSize: '11px' }}
|
||||||
</button>
|
onClick={() => toggleAll(false)}
|
||||||
<button
|
>
|
||||||
type="button"
|
Keine
|
||||||
className="btn btn-secondary"
|
</button>
|
||||||
style={{ fontSize: '11px' }}
|
</div>
|
||||||
onClick={() => toggleGroup(recommended, false)}
|
<ul
|
||||||
>
|
style={{
|
||||||
Keine
|
listStyle: 'none',
|
||||||
</button>
|
padding: 0,
|
||||||
</div>
|
margin: 0,
|
||||||
<ul
|
display: 'flex',
|
||||||
style={{
|
flexDirection: 'column',
|
||||||
listStyle: 'none',
|
gap: '8px',
|
||||||
padding: 0,
|
}}
|
||||||
margin: '0 0 16px',
|
>
|
||||||
display: 'flex',
|
{dialogDiffs.map((diff) => (
|
||||||
flexDirection: 'column',
|
<DiffRow
|
||||||
gap: '8px',
|
key={`improve-${diff.roadmap_major_step_index}-${diff.suggestion_type || 'lib'}`}
|
||||||
}}
|
diff={diff}
|
||||||
>
|
checked={selected.has(Number(diff.roadmap_major_step_index))}
|
||||||
{recommended.map((diff) => (
|
onToggle={toggle}
|
||||||
<DiffRow
|
applying={applying}
|
||||||
key={`fill-${diff.roadmap_major_step_index}`}
|
/>
|
||||||
diff={diff}
|
))}
|
||||||
checked={selected.has(Number(diff.roadmap_major_step_index))}
|
</ul>
|
||||||
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}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -997,6 +997,12 @@ export function compareDiffKind(diff) {
|
||||||
return 'skip'
|
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) {
|
export function annotateCompareDiffKinds(diffs) {
|
||||||
return (diffs || []).map((d) => ({
|
return (diffs || []).map((d) => ({
|
||||||
...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) {
|
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(
|
const diffs = annotateCompareDiffKinds(
|
||||||
compareSlotDiffs(comparison, { actionableOnly: true }),
|
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) {
|
export function recommendedCompareDiffs(comparison) {
|
||||||
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill')
|
return compareDiffsForDialog(comparison)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionalReplaceCompareDiffs(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) {
|
export function gapOnlyCompareDiffs(comparison) {
|
||||||
|
|
@ -1027,7 +1070,7 @@ export function gapOnlyCompareDiffs(comparison) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultSelectedCompareDiffs(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) {
|
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) {
|
export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
||||||
|
if (proposedRes?.unified_slot_review) {
|
||||||
|
return buildUnifiedSlotReviewComparePayload(proposedRes)
|
||||||
|
}
|
||||||
|
|
||||||
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
|
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
|
||||||
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
|
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
|
||||||
const baselineQa = baselineRes?.path_qa || null
|
const baselineQa = baselineRes?.path_qa || null
|
||||||
const pipelineQa = proposedRes?.path_qa || null
|
const pipelineQa = proposedRes?.path_qa || null
|
||||||
const slotDiffs = annotateCompareDiffKinds(
|
const scoring = proposedRes?.slot_diff_scoring
|
||||||
|
const rawDiffs = annotateCompareDiffKinds(
|
||||||
annotateCompareSlotDiffs(
|
annotateCompareSlotDiffs(
|
||||||
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const actionableDiffs = actionableCompareSlotDiffs(slotDiffs)
|
const improvingDiffs = annotateCompareDiffKinds(
|
||||||
const dialogDiffs = actionableDiffs.filter(
|
(scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null),
|
||||||
(d) => d.diff_kind === 'fill' || d.diff_kind === 'replace',
|
|
||||||
)
|
)
|
||||||
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(
|
const gapFillOffers = mergeGapFillOffersFromSteps(
|
||||||
proposedSteps,
|
proposedSteps,
|
||||||
proposedRes?.gap_fill_offers || [],
|
proposedRes?.gap_fill_offers || [],
|
||||||
)
|
)
|
||||||
const proposedQa =
|
const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score
|
||||||
actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa
|
const proposedQa = baselineQa
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...proposedRes,
|
...proposedRes,
|
||||||
|
|
@ -1078,19 +1135,110 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
||||||
proposed_path_qa: proposedQa,
|
proposed_path_qa: proposedQa,
|
||||||
proposed_path_qa_pipeline: pipelineQa,
|
proposed_path_qa_pipeline: pipelineQa,
|
||||||
gap_fill_offers: gapFillOffers,
|
gap_fill_offers: gapFillOffers,
|
||||||
slot_diffs: slotDiffs,
|
slot_diffs: rawDiffs,
|
||||||
slot_diffs_actionable: actionableDiffs,
|
slot_diffs_actionable: actionableDiffs,
|
||||||
|
slot_diffs_improving: improvingDiffs,
|
||||||
|
slot_diffs_rejected: rejectedDiffs,
|
||||||
slot_diffs_dialog: dialogDiffs,
|
slot_diffs_dialog: dialogDiffs,
|
||||||
slot_diffs_recommended: recommendedDiffs,
|
slot_diffs_recommended: dialogDiffs,
|
||||||
slot_diff_count: dialogDiffs.length,
|
slot_diff_count: dialogDiffs.length,
|
||||||
slot_diff_count_recommended: recommendedDiffs.length,
|
slot_diff_count_recommended: dialogDiffs.length,
|
||||||
slot_diff_count_including_trivial: slotDiffs.length,
|
slot_diff_count_rejected: rejectedDiffs.length,
|
||||||
slot_diffs_source: 'steps',
|
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,
|
path_qa: proposedQa,
|
||||||
steps: proposedSteps,
|
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). */
|
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
|
||||||
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
|
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
|
||||||
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {
|
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user