progression V2 #57

Merged
Lars merged 20 commits from develop into main 2026-06-13 16:34:09 +02:00
5 changed files with 1098 additions and 282 deletions
Showing only changes of commit 85fccdd093 - Show all commits

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