Enhance Progression Path Comparison and Slot Evaluation Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s

- Introduced new fields in `ProgressionPathSuggestRequest` for baseline evaluation and incremental scoring, improving the assessment of proposed paths.
- Implemented `_apply_slot_diff_to_steps` and `_score_incremental_slot_diffs` functions to manage slot differences and evaluate their impact on quality scores.
- Updated `ProgressionGraphEditor` to streamline the match comparison flow, integrating new evaluation parameters and improving user notifications.
- Enhanced `ProgressionOptimizeCompareModal` to better display proposed path suggestions, including pro/con evaluations and quality delta metrics.
- Refactored utility functions for clearer handling of slot differences and improved overall data management in the progression graph editor.
This commit is contained in:
Lars 2026-06-13 10:11:10 +02:00
parent 19bbcdaf50
commit 85fccdd093
5 changed files with 1098 additions and 282 deletions

View File

@ -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,
}

View 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"

View File

@ -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)

View File

@ -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',

View File

@ -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)) {