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 exercise_kind_any: Optional[List[str]] = None
compare_with_assignments: bool = False compare_with_assignments: bool = False
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
# Für Match-Vergleich: Baseline aus evaluate_only (Schritt 1) — inkrementelles QS-Scoring je Diff
baseline_evaluate_steps: Optional[List[EvaluateStepPayload]] = None
baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
include_incremental_diff_scoring: bool = False
unified_slot_review: bool = False
def _resolve_planning_catalog_context( def _resolve_planning_catalog_context(
@ -2394,6 +2399,688 @@ def _evaluate_steps_for_compare_qa(
return suggest_progression_path(cur, tenant=tenant, body=eval_body) return suggest_progression_path(cur, tenant=tenant, body=eval_body)
def _apply_slot_diff_to_steps(
baseline_steps: Sequence[Mapping[str, Any]],
diff: Mapping[str, Any],
proposed_steps: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
"""Einzeländerung auf Baseline-Pfad legen (für faire QS pro Vorschlag)."""
base_by = _steps_by_major_index(baseline_steps)
prop_by = _steps_by_major_index(proposed_steps)
try:
midx = int(diff.get("roadmap_major_step_index"))
except (TypeError, ValueError):
return [dict(s) for s in baseline_steps or []]
out_by: Dict[int, Dict[str, Any]] = {i: dict(s) for i, s in base_by.items()}
prop_step = prop_by.get(midx)
if isinstance(prop_step, dict):
merged = dict(out_by.get(midx, {}))
merged.update(prop_step)
merged["roadmap_major_step_index"] = midx
out_by[midx] = merged
elif diff.get("proposed_exercise_id") is not None:
merged = dict(out_by.get(midx, {}))
merged["exercise_id"] = int(diff["proposed_exercise_id"])
if diff.get("proposed_title"):
merged["title"] = diff.get("proposed_title")
merged["roadmap_major_step_index"] = midx
merged["slot_status"] = diff.get("proposed_slot_status") or "matched"
out_by[midx] = merged
elif diff.get("baseline_exercise_id") is not None and diff.get("proposed_exercise_id") is None:
merged = dict(out_by.get(midx, {}))
merged["exercise_id"] = None
merged["roadmap_major_step_index"] = midx
out_by[midx] = merged
return [out_by[i] for i in sorted(out_by.keys())]
def _slot_diff_improves_path(
diff: Mapping[str, Any],
quality_delta: Optional[float],
*,
off_topic: bool = False,
) -> bool:
"""Nur Vorschläge mit messbarer Pfad-Verbesserung (Lücken/off-topic: neutral oder besser)."""
if quality_delta is None:
return False
try:
delta = float(quality_delta)
except (TypeError, ValueError):
return False
base_id = diff.get("baseline_exercise_id")
prop_id = diff.get("proposed_exercise_id")
if off_topic and base_id is not None:
return delta >= -0.001
if base_id is None and prop_id is not None:
return delta >= -0.001
if base_id is not None and prop_id is not None:
return delta > 0.005
return False
def _score_incremental_slot_diffs(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
baseline_steps: Sequence[Mapping[str, Any]],
proposed_steps: Sequence[Mapping[str, Any]],
baseline_path_qa: Optional[Mapping[str, Any]],
raw_diffs: Sequence[Mapping[str, Any]],
) -> Dict[str, Any]:
"""Bewertet jeden Slot-Diff isoliert gegen die Baseline-QS — filtert Verschlechterungen."""
baseline_score = _path_qa_quality_score(baseline_path_qa)
if baseline_score is None and baseline_steps:
baseline_eval = _evaluate_steps_for_compare_qa(
cur,
tenant=tenant,
body=body,
steps=baseline_steps,
)
if isinstance(baseline_eval, dict):
baseline_score = _path_qa_quality_score(baseline_eval.get("path_qa"))
annotated = _annotate_slot_diffs(list(raw_diffs or []))
candidates = _actionable_slot_diffs(annotated)
# Lücken zuerst, dann Ersetzungen — harte Obergrenze gegen Timeouts
candidates.sort(
key=lambda d: (
0 if d.get("baseline_exercise_id") is None else 1,
int(d.get("roadmap_major_step_index") or 0),
)
)
candidates = candidates[:10]
scored: List[Dict[str, Any]] = []
improving: List[Dict[str, Any]] = []
rejected: List[Dict[str, Any]] = []
for diff in candidates:
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff, proposed_steps)
eval_res = _evaluate_steps_for_compare_qa(
cur,
tenant=tenant,
body=body,
steps=merged_steps,
)
projected_qa = (
eval_res.get("path_qa")
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
else None
)
projected_score = _path_qa_quality_score(projected_qa)
delta: Optional[float] = None
if baseline_score is not None and projected_score is not None:
delta = round(projected_score - baseline_score, 4)
entry = {
**diff,
"projected_path_qa": projected_qa,
"projected_quality_score": projected_score,
"baseline_quality_score": baseline_score,
"quality_delta": delta,
"improves_path": _slot_diff_improves_path(diff, delta),
}
scored.append(entry)
if entry["improves_path"]:
improving.append(entry)
else:
rejected.append(entry)
return {
"baseline_quality_score": baseline_score,
"scored_diffs": scored,
"improvement_diffs": improving,
"rejected_diffs": rejected,
"improvement_count": len(improving),
"rejected_count": len(rejected),
}
def _off_topic_reasons_by_slot(
off_topic_steps: Sequence[Mapping[str, Any]],
) -> Dict[int, List[str]]:
out: Dict[int, List[str]] = {}
for item in off_topic_steps or []:
if not isinstance(item, dict):
continue
midx = item.get("roadmap_major_step_index")
if midx is None:
continue
try:
key = int(midx)
except (TypeError, ValueError):
continue
issue = str(item.get("issue") or "off_topic")
reasons = item.get("reasons") or [issue]
for raw in reasons:
text = str(raw or "").strip()
if text and text not in out.setdefault(key, []):
out[key].append(text[:400])
return out
def _slot_issues_from_path_qa(
path_qa: Optional[Mapping[str, Any]],
major_idx: int,
) -> List[str]:
texts: List[str] = []
if not isinstance(path_qa, dict):
return texts
for key in ("issues", "recommendations"):
for raw in path_qa.get(key) or []:
text = str(raw or "").strip()
if not text:
continue
if f"slot {major_idx + 1}" in text.lower() or f"stufe {major_idx + 1}" in text.lower():
if text not in texts:
texts.append(text[:400])
for hint in path_qa.get("optimization_hints") or []:
if not isinstance(hint, dict):
continue
hint_idx = hint.get("roadmap_major_step_index")
if hint_idx is None:
continue
try:
if int(hint_idx) != int(major_idx):
continue
except (TypeError, ValueError):
continue
text = str(hint.get("reason") or hint.get("issue") or "").strip()
if text and text not in texts:
texts.append(text[:400])
return texts
def _build_slot_pro_contra(
*,
current_step: Mapping[str, Any],
proposed_step: Optional[Mapping[str, Any]],
suggestion_type: str,
baseline_qa: Optional[Mapping[str, Any]],
projected_qa: Optional[Mapping[str, Any]],
quality_delta: Optional[float],
off_topic_reasons: Sequence[str],
candidate_reasons: Sequence[str],
gap_offer: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
current_pro: List[str] = []
current_contra: List[str] = list(off_topic_reasons or [])[:4]
proposed_pro: List[str] = [str(r) for r in (candidate_reasons or []) if str(r or "").strip()][:4]
proposed_contra: List[str] = []
if current_step.get("exercise_id") is not None and not current_contra:
current_pro.append("Bestehende Zuordnung im Graph")
if current_step.get("is_ai_proposal"):
sketch = (current_step.get("title") or "KI-Entwurf").strip()
current_pro.append(f"KI-Entwurf: {sketch[:120]}")
major_idx = current_step.get("roadmap_major_step_index")
if major_idx is not None:
for text in _slot_issues_from_path_qa(baseline_qa, int(major_idx)):
if text not in current_contra:
current_contra.append(text)
if quality_delta is not None and quality_delta > 0:
proposed_pro.append(f"Pfad-QS +{round(float(quality_delta) * 100)} Prozentpunkte")
elif suggestion_type in {"library_fill", "remove_and_replace", "ai_gap"} and not current_contra:
proposed_pro.append("Schließt Lücke bzw. passt besser zur Stufe")
if isinstance(gap_offer, dict):
sketch = str(gap_offer.get("sketch") or gap_offer.get("title_hint") or "").strip()
if sketch:
proposed_pro.append(f"KI-Entwurf: {sketch[:160]}")
rationale = str(gap_offer.get("rationale") or "").strip()
if rationale:
proposed_pro.append(rationale[:200])
if isinstance(projected_qa, dict):
for text in _slot_issues_from_path_qa(projected_qa, int(major_idx or 0)):
if text not in proposed_contra:
proposed_contra.append(text)
if proposed_step and proposed_step.get("exercise_id") is not None and not proposed_pro:
proposed_pro.append("Bibliotheks-Treffer für Stufen-Lernziel")
return {
"current_pro": current_pro[:6],
"current_contra": current_contra[:6],
"proposed_pro": proposed_pro[:6],
"proposed_contra": proposed_contra[:6],
}
def _roadmap_slot_library_candidates(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
stage_spec: StageSpecArtifact,
step_index: int,
stage_count: int,
planned_ids: List[int],
anchor_id: Optional[int],
anchor_variant_id: Optional[int],
used: Set[int],
exclude_exercise_id: Optional[int] = None,
max_candidates: int = 5,
) -> List[Dict[str, Any]]:
"""Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
pick_used = set(used)
if exclude_exercise_id is not None:
try:
pick_used.add(int(exclude_exercise_id))
except (TypeError, ValueError):
pass
candidates: List[Dict[str, Any]] = []
seen_ids: Set[int] = set()
for _ in range(max(1, max_candidates)):
step, _unfilled = _match_roadmap_slot(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
stage_spec=stage_spec,
step_index=step_index,
stage_count=stage_count,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=pick_used,
slot_priority_exercise_id=None,
)
if not step or step.get("exercise_id") is None:
break
try:
eid = int(step["exercise_id"])
except (TypeError, ValueError):
break
if eid in seen_ids:
break
seen_ids.add(eid)
candidates.append(step)
pick_used.add(eid)
return candidates
def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
return {
"roadmap_major_step_index": entry.get("roadmap_major_step_index"),
"baseline_exercise_id": entry.get("baseline_exercise_id"),
"baseline_title": entry.get("baseline_title"),
"proposed_exercise_id": entry.get("proposed_exercise_id"),
"proposed_title": entry.get("proposed_title"),
"baseline_slot_status": entry.get("baseline_slot_status"),
"proposed_slot_status": entry.get("proposed_slot_status"),
"changed": True,
"suggestion_type": entry.get("suggestion_type"),
"quality_delta": entry.get("quality_delta"),
"projected_quality_score": entry.get("projected_quality_score"),
"baseline_quality_score": entry.get("baseline_quality_score"),
"projected_path_qa": entry.get("projected_path_qa"),
"pro_contra": entry.get("pro_contra"),
"improves_path": entry.get("improves_path"),
"off_topic": entry.get("off_topic"),
"gap_offer": entry.get("gap_offer"),
"proposed_is_ai_proposal": entry.get("proposed_is_ai_proposal"),
}
def _run_unified_slot_improvement_review(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
semantic_llm_applied: bool,
path_target_profile: PlanningTargetProfile,
path_intent: str,
first_intent_summary: Mapping[str, Any],
roadmap_ctx: ProgressionRoadmapContext,
progression_roadmap: Optional[Dict[str, Any]],
roadmap_edited: bool,
) -> Dict[str, Any]:
"""
Ein Workflow: Pfad bewerten je Slot Alternativen suchen Einzel-QS nur Verbesserungen.
"""
if not body.baseline_evaluate_steps:
raise HTTPException(
status_code=400,
detail="unified_slot_review erfordert baseline_evaluate_steps",
)
if roadmap_ctx is None or not roadmap_ctx.stage_specs:
raise HTTPException(
status_code=400,
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
)
eval_body = body.model_copy(
update={
"include_llm_path_qa": body.include_llm_path_qa,
"include_ai_gap_fill": body.include_ai_gap_fill,
"auto_rematch_after_qa": False,
}
)
baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
qa_pack = _run_evaluate_only_path_qa(
cur,
body=eval_body,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=list(baseline_steps),
roadmap_ctx=roadmap_ctx,
)
baseline_steps = list(qa_pack.get("steps") or baseline_steps)
baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {}
baseline_score = _path_qa_quality_score(baseline_qa)
gap_fill_offers = list(qa_pack.get("gap_fill_offers") or [])
off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
steps_by_major = _steps_by_major_index(baseline_steps)
spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs}
stage_count = len(roadmap_ctx.stage_specs)
suggestions: List[Dict[str, Any]] = []
rejected: List[Dict[str, Any]] = []
scored_eval_body = body.model_copy(
update={
"include_llm_path_qa": False,
"include_ai_gap_fill": False,
"auto_rematch_after_qa": False,
"include_roadmap_preview": False,
}
)
for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs):
major_idx = int(stage_spec.major_step_index)
current = dict(steps_by_major.get(major_idx, {}))
current.setdefault("roadmap_major_step_index", major_idx)
current_id = current.get("exercise_id")
off_topic = major_idx in off_topic_map or bool(
current.get("slot_status") in {"off_topic", "stripped"}
)
off_reasons = off_topic_map.get(major_idx, [])
planned_ids = [
int(s["exercise_id"])
for midx, s in sorted(steps_by_major.items())
if midx != major_idx and s.get("exercise_id") is not None
]
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
used_other: Set[int] = set(planned_ids)
for midx in sorted(steps_by_major):
if midx >= major_idx:
break
step = steps_by_major[midx]
eid = step.get("exercise_id")
if eid is not None:
anchor_id = int(eid)
vid = step.get("variant_id")
anchor_variant_id = int(vid) if vid is not None else None
exclude_id: Optional[int] = None
if current_id is not None and not off_topic:
try:
exclude_id = int(current_id)
except (TypeError, ValueError):
exclude_id = None
candidates = _roadmap_slot_library_candidates(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
stage_spec=stage_spec,
step_index=step_index,
stage_count=stage_count,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used_other,
exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None,
)
accepted_for_slot = False
for candidate in candidates:
try:
cand_id = int(candidate.get("exercise_id"))
except (TypeError, ValueError):
continue
if (
current_id is not None
and not off_topic
and int(current_id) == cand_id
):
continue
diff_stub = {
"roadmap_major_step_index": major_idx,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": cand_id,
"proposed_title": (candidate.get("title") or "").strip() or None,
}
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps)
for i, raw in enumerate(merged_steps):
if int(raw.get("roadmap_major_step_index", -1)) == major_idx:
merged_steps[i] = {**raw, **candidate, "roadmap_major_step_index": major_idx}
break
eval_res = _evaluate_steps_for_compare_qa(
cur,
tenant=tenant,
body=scored_eval_body,
steps=merged_steps,
)
projected_qa = (
eval_res.get("path_qa")
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
else None
)
projected_score = _path_qa_quality_score(projected_qa)
delta: Optional[float] = None
if baseline_score is not None and projected_score is not None:
delta = round(projected_score - baseline_score, 4)
improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic)
suggestion_type = (
"remove_and_replace"
if off_topic and current_id is not None
else ("library_fill" if current_id is None else "library_improvement")
)
entry = {
**diff_stub,
"baseline_slot_status": current.get("slot_status"),
"proposed_slot_status": candidate.get("slot_status") or "matched",
"suggestion_type": suggestion_type,
"quality_delta": delta,
"projected_quality_score": projected_score,
"baseline_quality_score": baseline_score,
"projected_path_qa": projected_qa,
"improves_path": improves,
"off_topic": off_topic,
"proposed_is_ai_proposal": False,
"pro_contra": _build_slot_pro_contra(
current_step=current,
proposed_step=candidate,
suggestion_type=suggestion_type,
baseline_qa=baseline_qa,
projected_qa=projected_qa,
quality_delta=delta,
off_topic_reasons=off_reasons,
candidate_reasons=candidate.get("reasons") or [],
),
}
if improves:
suggestions.append(entry)
accepted_for_slot = True
break
rejected.append(entry)
if accepted_for_slot:
continue
# Kein Bibliotheks-Treffer oder keine Verbesserung → KI-Angebot wenn Slot leer/off-topic/KI
needs_ai = (
current_id is None
or off_topic
or bool(current.get("is_ai_proposal"))
)
if not needs_ai or not body.include_ai_gap_fill:
continue
slot_offer = next(
(
o
for o in gap_fill_offers
if isinstance(o, dict)
and int(o.get("roadmap_major_step_index", -1)) == major_idx
),
None,
)
if not slot_offer:
empty_specs = _build_evaluate_empty_slot_gap_specs(
[current],
goal_query=goal_query,
)
if empty_specs:
slot_offer = build_gap_fill_offer(
spec=empty_specs[0],
steps=baseline_steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
cur,
roadmap_ctx,
empty_specs[0],
goal_query=goal_query,
semantic_brief=semantic_brief,
),
)
gap_fill_offers.append(slot_offer)
ai_step = {
**current,
"exercise_id": None,
"is_ai_proposal": True,
"title": slot_offer.get("title_hint") or current.get("title") or f"Slot {major_idx + 1}",
"roadmap_major_step_index": major_idx,
"gap_offer": slot_offer,
}
diff_stub = {
"roadmap_major_step_index": major_idx,
"baseline_exercise_id": int(current_id) if current_id is not None else None,
"baseline_title": (current.get("title") or "").strip() or None,
"proposed_exercise_id": None,
"proposed_title": ai_step.get("title"),
}
merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, [ai_step])
eval_res = _evaluate_steps_for_compare_qa(
cur,
tenant=tenant,
body=scored_eval_body,
steps=merged_steps,
)
projected_qa = (
eval_res.get("path_qa")
if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
else None
)
projected_score = _path_qa_quality_score(projected_qa)
delta = (
round(projected_score - baseline_score, 4)
if baseline_score is not None and projected_score is not None
else None
)
improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic or current_id is None)
entry = {
**diff_stub,
"baseline_slot_status": current.get("slot_status"),
"proposed_slot_status": "ai_proposal",
"suggestion_type": "ai_gap",
"quality_delta": delta,
"projected_quality_score": projected_score,
"baseline_quality_score": baseline_score,
"projected_path_qa": projected_qa,
"improves_path": improves,
"off_topic": off_topic,
"proposed_is_ai_proposal": True,
"gap_offer": slot_offer,
"pro_contra": _build_slot_pro_contra(
current_step=current,
proposed_step=None,
suggestion_type="ai_gap",
baseline_qa=baseline_qa,
projected_qa=projected_qa,
quality_delta=delta,
off_topic_reasons=off_reasons,
candidate_reasons=[],
gap_offer=slot_offer,
),
}
if improves:
suggestions.append(entry)
else:
rejected.append(entry)
improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
slot_diff_scoring = {
"baseline_quality_score": baseline_score,
"scored_diffs": improvement_diffs + [_suggestion_as_slot_diff(r) for r in rejected],
"improvement_diffs": improvement_diffs,
"rejected_diffs": [_suggestion_as_slot_diff(r) for r in rejected],
"improvement_count": len(improvement_diffs),
"rejected_count": len(rejected),
}
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": baseline_steps,
"step_count": len(baseline_steps),
"target_profile_summary": path_target_profile.to_summary_dict(cur),
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": first_intent_summary,
"progression_graph_id": body.progression_graph_id,
"path_qa": baseline_qa,
"baseline_path_qa": baseline_qa,
"baseline_steps": baseline_steps,
"gap_fill_offers": gap_fill_offers,
"progression_roadmap": progression_roadmap,
"roadmap_first": True,
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": 0,
"path_skill_expectations": None,
"match_summary": {
"unified_slot_review": True,
"suggestion_count": len(suggestions),
"rejected_count": len(rejected),
},
"retrieval_phase": "unified_slot_review",
"unified_slot_review": True,
"slot_suggestions": suggestions,
"slot_diff_scoring": slot_diff_scoring,
"comparison_mode": True,
}
def _merge_gap_fill_offers_from_steps( def _merge_gap_fill_offers_from_steps(
steps: Sequence[Mapping[str, Any]], steps: Sequence[Mapping[str, Any]],
offers: Sequence[Mapping[str, Any]], offers: Sequence[Mapping[str, Any]],
@ -2698,6 +3385,23 @@ def suggest_progression_path(
path_target_profile = apply_expectations_to_target(path_target_profile, path_exp) path_target_profile = apply_expectations_to_target(path_target_profile, path_exp)
path_skill_expectations = path_exp.to_api_dict() path_skill_expectations = path_exp.to_api_dict()
if body.unified_slot_review:
return _run_unified_slot_improvement_review(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
semantic_llm_applied=semantic_llm_applied,
path_target_profile=path_target_profile,
path_intent=path_intent,
first_intent_summary=first_intent_summary,
roadmap_ctx=roadmap_ctx,
progression_roadmap=progression_roadmap,
roadmap_edited=roadmap_edited,
)
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = [] roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = [] roadmap_gap_offers: List[Dict[str, Any]] = []
@ -3081,6 +3785,28 @@ def suggest_progression_path(
if refine_log: if refine_log:
retrieval_parts.append("stage_spec_refine") retrieval_parts.append("stage_spec_refine")
slot_diff_scoring: Optional[Dict[str, Any]] = None
if (
body.include_incremental_diff_scoring
and body.baseline_evaluate_steps
and not evaluate_only
and not body.compare_with_assignments
):
baseline_steps_for_scoring = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
raw_diffs = _build_progression_slot_diffs(baseline_steps_for_scoring, steps)
baseline_qa_for_scoring: Optional[Dict[str, Any]] = None
if body.baseline_quality_score is not None:
baseline_qa_for_scoring = {"quality_score": float(body.baseline_quality_score)}
slot_diff_scoring = _score_incremental_slot_diffs(
cur,
tenant=tenant,
body=body,
baseline_steps=baseline_steps_for_scoring,
proposed_steps=steps,
baseline_path_qa=baseline_qa_for_scoring,
raw_diffs=raw_diffs,
)
return { return {
"goal_query": goal_query, "goal_query": goal_query,
"max_steps_requested": max_steps, "max_steps_requested": max_steps,
@ -3101,6 +3827,7 @@ def suggest_progression_path(
"path_skill_expectations": path_skill_expectations, "path_skill_expectations": path_skill_expectations,
"match_summary": match_summary, "match_summary": match_summary,
"retrieval_phase": "+".join(retrieval_parts), "retrieval_phase": "+".join(retrieval_parts),
"slot_diff_scoring": slot_diff_scoring,
} }

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, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applySelectedCompareSteps, applySelectedCompareSteps,
applySelectedSlotSuggestions,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
buildProgressionComparePayload, buildProgressionComparePayload,
collectGapOffersFromApiResponse, collectGapOffersFromApiResponse,
compareSlotDiffs, compareSlotDiffs,
compareDiffsForDialog,
dedupeGapOffersBySlot, dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments, draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds, draftRetrievalBoostExerciseIds,
@ -47,7 +49,7 @@ import {
patchSlotInDraft, patchSlotInDraft,
pathQaQualityPercent, pathQaQualityPercent,
planningCatalogContextToApi, planningCatalogContextToApi,
recommendedCompareDiffs, rejectedCompareDiffs,
removeSlotFromDraft, removeSlotFromDraft,
saveProgressionGraphDraft, saveProgressionGraphDraft,
setCatalogSelectItems, setCatalogSelectItems,
@ -491,24 +493,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const fetchFullMatch = async (synced) => const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
api.suggestProgressionPath({ setMatchNotice('Pfad bewerten und je Slot passende Verbesserungen prüfen…')
const reviewRes = await api.suggestProgressionPath({
...buildMatchRequestBase(synced), ...buildMatchRequestBase(synced),
preserve_slot_assignments: false, unified_slot_review: true,
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
include_llm_intent: false, include_llm_intent: false,
include_llm_path_qa: false, include_llm_path_qa: false,
auto_rematch_after_qa: false,
}) })
setPathQa(reviewRes?.path_qa || null)
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { const compareRes = buildProgressionComparePayload(null, reviewRes)
setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…') setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes))
const baselineRes = await fetchPathEvaluate(synced)
setPathQa(baselineRes?.path_qa || null)
setMatchNotice('Schritt 2/2: Match für alle Slots (Bibliothek + Lücken)…')
const matchRes = await fetchFullMatch(synced)
const compareRes = buildProgressionComparePayload(baselineRes, matchRes)
setGapFillOffers(mergeGapOffersForDraft(synced, baselineRes, matchRes))
presentMatchCompare(compareRes, { source }) presentMatchCompare(compareRes, { source })
return compareRes return compareRes
} }
@ -522,31 +520,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setCompareOpen(true) setCompareOpen(true)
const baselineQa = res?.baseline_path_qa || null const baselineQa = res?.baseline_path_qa || null
const proposedQa = res?.proposed_path_qa || res?.path_qa || null const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length
const diffCount = const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
res?.slot_diff_count_recommended
?? recommendedCompareDiffs(res).length
?? res?.slot_diff_count
?? compareSlotDiffs(res, { actionableOnly: true }).length
const replaceCount = (res?.slot_diffs || []).filter(
(d) => d?.diff_kind === 'replace',
).length
const bPct = pathQaQualityPercent(baselineQa) const bPct = pathQaQualityPercent(baselineQa)
const pPct = pathQaQualityPercent(proposedQa)
let notice = let notice =
diffCount > 0 diffCount > 0
? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.` ? `Match: ${diffCount} Verbesserung(en) — je Slot gegen deinen Pfad (${bPct != null ? `${bPct} %` : 'QS'}) geprüft.`
: 'Match: Keine Bibliotheks-Lückenfüllungen — Dialog zur Kontrolle geöffnet.' : 'Match: Keine messbare Verbesserung gegenüber deinem Pfad.'
if (replaceCount > 0) { if (rejectedCount > 0) {
notice += ` ${replaceCount} optionale Ersetzung(en) bestehender Slots — standardmäßig abgewählt.` notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
} }
const gapCount = collectGapOffersFromApiResponse(res).length const gapCount = collectGapOffersFromApiResponse(res).length
if (gapCount > 0) { if (gapCount > 0) {
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
} }
if (bPct != null && pPct != null && pPct !== bPct) {
notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
}
setMatchNotice(notice) setMatchNotice(notice)
} }
@ -603,11 +590,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setCompareApplying(true) setCompareApplying(true)
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const nextDraft = applySelectedCompareSteps( const nextDraft = comparePayload?.unified_slot_review
synced, ? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
comparePayload.proposed_steps || comparePayload.steps, : applySelectedCompareSteps(
selectedMajorIndices, synced,
) comparePayload.proposed_steps || comparePayload.steps,
selectedMajorIndices,
)
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft) const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
const evalRes = await fetchPathEvaluate(syncedNext) const evalRes = await fetchPathEvaluate(syncedNext)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes) const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)

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 React, { useMemo, useState } from 'react'
import { import {
compareDiffsForDialog, compareDiffsForDialog,
defaultSelectedCompareDiffs, defaultSelectedCompareDiffs,
gapOnlyCompareDiffs,
optionalReplaceCompareDiffs,
pathQaQualityPercent, pathQaQualityPercent,
recommendedCompareDiffs, qualityDeltaPercent,
rejectedCompareDiffs,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
function qaLabel(pathQa) { function qaLabel(pathQa) {
@ -18,25 +17,44 @@ function qaLabel(pathQa) {
return ok ? 'OK' : 'Hinweise' return ok ? 'OK' : 'Hinweise'
} }
function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) { function deltaLabel(diff) {
const pct = qualityDeltaPercent(diff)
if (pct == null) return null
if (pct > 0) return `+${pct} % Pfad-QS`
if (pct === 0) return '±0 % Pfad-QS'
return `${pct} % Pfad-QS`
}
function ProContraList({ title, items, tone = 'neutral' }) {
if (!items?.length) return null
const color =
tone === 'pro' ? 'var(--accent-dark)' : tone === 'contra' ? 'var(--danger)' : 'var(--text2)'
return (
<div style={{ marginTop: '6px' }}>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)' }}>{title}</div>
<ul style={{ margin: '4px 0 0', paddingLeft: '16px', color, fontSize: '11px' }}>
{items.map((text, i) => (
<li key={`${title}-${i}`}>{text}</li>
))}
</ul>
</div>
)
}
function DiffRow({ diff, checked, onToggle, applying }) {
const midx = Number(diff.roadmap_major_step_index) const midx = Number(diff.roadmap_major_step_index)
const border = const delta = deltaLabel(diff)
tone === 'warn' const pc = diff.pro_contra || {}
? '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))' const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal
: '1px solid var(--border)' const isFill = diff.baseline_exercise_id == null && !isAi
const bg = checked
? tone === 'warn'
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
: 'var(--surface2)'
: 'var(--surface)'
return ( return (
<li <li
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border, border: '1px solid var(--border)',
background: bg, background: checked ? 'var(--surface2)' : 'var(--surface)',
fontSize: '12px', fontSize: '12px',
}} }}
> >
@ -49,28 +67,60 @@ function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) {
style={{ marginTop: '3px' }} style={{ marginTop: '3px' }}
/> />
<span style={{ flex: 1 }}> <span style={{ flex: 1 }}>
<strong>Slot {midx + 1}</strong> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
{tone === 'warn' ? ( <strong>Slot {midx + 1}</strong>
<span style={{ marginLeft: '6px', fontSize: '10px', color: 'var(--danger)' }}> {isFill ? (
Ersetzt deine Zuordnung <span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>Lücke füllen</span>
</span> ) : isAi ? (
) : null} <span style={{ fontSize: '10px', color: 'var(--accent-dark)' }}>KI-Alternative</span>
) : diff.off_topic ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht Ersatz</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>Bessere Übung</span>
)}
{delta ? (
<span
style={{
fontSize: '10px',
fontWeight: 600,
color: qualityDeltaPercent(diff) > 0 ? 'var(--accent-dark)' : 'var(--text2)',
}}
>
{delta}
</span>
) : null}
</div>
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
gap: '8px', gap: '10px',
marginTop: '6px', marginTop: '8px',
}} }}
> >
<span style={{ color: 'var(--text2)' }}> <div>
Bisher: {diff.baseline_title || '— leer —'} <div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} Aktuell
</span> </div>
<span style={{ color: tone === 'warn' ? 'var(--danger)' : 'var(--accent-dark)' }}> <div style={{ color: 'var(--text2)' }}>
Neu: {diff.proposed_title || '— leer —'} {diff.baseline_title || '— leer —'}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''} {diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
</span> </div>
<ProContraList title="Pro" items={pc.current_pro} tone="pro" />
<ProContraList title="Contra" items={pc.current_contra} tone="contra" />
</div>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Vorschlag
</div>
<div style={{ color: 'var(--accent-dark)' }}>
{diff.proposed_title || '—'}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''}
</div>
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
</div>
</div> </div>
</span> </span>
</label> </label>
@ -86,16 +136,8 @@ export default function ProgressionOptimizeCompareModal({
onApplySelected, onApplySelected,
applying = false, applying = false,
}) { }) {
const recommended = useMemo(
() => recommendedCompareDiffs(comparison),
[comparison],
)
const optionalReplace = useMemo(
() => optionalReplaceCompareDiffs(comparison),
[comparison],
)
const gapOnly = useMemo(() => gapOnlyCompareDiffs(comparison), [comparison])
const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison])
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
const defaultSelected = useMemo( const defaultSelected = useMemo(
() => defaultSelectedCompareDiffs(comparison), () => defaultSelectedCompareDiffs(comparison),
[comparison], [comparison],
@ -111,18 +153,8 @@ export default function ProgressionOptimizeCompareModal({
if (!open || !comparison) return null if (!open || !comparison) return null
const baselineQa = comparison.baseline_path_qa const baselineQa = comparison.baseline_path_qa
const pipelineQa = comparison.proposed_path_qa_pipeline
const baselinePct = pathQaQualityPercent(baselineQa) const baselinePct = pathQaQualityPercent(baselineQa)
const pipelinePct = pathQaQualityPercent(pipelineQa) const rejectedCount = rejected.length
const rematchRounds = pipelineQa?.rematch_rounds
const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0
const refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0
const hintCount = Number(pipelineQa?.optimization_hint_count || 0)
const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0
const selectedReplaceCount = optionalReplace.filter((d) =>
selected.has(Number(d.roadmap_major_step_index)),
).length
const toggle = (midx) => { const toggle = (midx) => {
setSelected((prev) => { setSelected((prev) => {
@ -133,20 +165,12 @@ export default function ProgressionOptimizeCompareModal({
}) })
} }
const toggleGroup = (diffs, on) => { const toggleAll = (on) => {
setSelected((prev) => { setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set())
const next = new Set(prev)
for (const d of diffs) {
const midx = Number(d.roadmap_major_step_index)
if (on) next.add(midx)
else next.delete(midx)
}
return next
})
} }
const title = const title =
mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen' mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen'
return ( return (
<div <div
@ -161,201 +185,90 @@ export default function ProgressionOptimizeCompareModal({
> >
<div <div
className="card modal-content" className="card modal-content"
style={{ maxWidth: '720px', width: '100%', maxHeight: '90vh', overflow: 'auto' }} style={{ maxWidth: '760px', width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}> <h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
{title} {title}
</h3> </h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere
vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter. Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen
KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel KI-Angebote, nicht hierher. hier mit Pro- und Contra-Punkten auf Slot-Ebene.
</p> </p>
<div <div
style={{ style={{
marginBottom: '12px',
padding: '10px 12px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))', border: '1px solid var(--border)',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))', background: 'var(--surface2)',
fontSize: '12px', fontSize: '12px',
lineHeight: 1.45,
}}
>
<strong>Warum nicht einfach alles übernehmen?</strong>
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Der Match-Lauf optimiert den <em>gesamten</em> Pfad neu (inkl. Rematch). Das kann
bereits gute Slots verschlechtern. Die Prozentzahl rechts bezieht sich auf diesen
Ganzpfad nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig
nur Lückenfüllungen an.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginBottom: '14px', marginBottom: '14px',
}} }}
> >
<div <strong>Dein Pfad</strong>
style={{ <div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
padding: '10px 12px', {baselineQa?.topic_coverage ? (
borderRadius: '8px', <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
border: '1px solid var(--border)', ) : null}
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong>Dein Pfad (bewertet)</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null}
</div>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--text3) 35%, var(--border))',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong>Match-Ganzpfad (nur Info)</strong>
<div style={{ marginTop: '6px', color: 'var(--text2)' }}>
{pipelineQa ? qaLabel(pipelineQa) : '—'}
</div>
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Rematch-Prozess kein Versprechen für deine Checkbox-Auswahl.
{pipelinePct != null && baselinePct != null && pipelinePct < baselinePct
? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).`
: ''}
</p>
</div>
</div> </div>
{tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? ( {rejectedCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}> <p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
Rematch-Protokoll {rejectedCount} Alternative(n) verworfen kein QS-Gewinn gegenüber deinem Pfad
{tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''} {baselinePct != null ? ` (${baselinePct} %)` : ''}.
{rematchCount > 0
? ` · ${rematchRounds != null ? `${rematchRounds} Runde(n)` : ''}: ${rematchCount} Anpassung(en)`
: ''}
{refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''}
{hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''}
</p>
) : null}
{gapOnly.length > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
Slot{gapOnly.length > 1 ? 's' : ''}{' '}
{gapOnly.map((d) => Number(d.roadmap_major_step_index) + 1).join(', ')}: kein
Bibliotheks-Treffer bitte KI-Angebote im Panel nutzen (eigenständig pro Slot).
</p> </p>
) : null} ) : null}
{dialogDiffs.length === 0 ? ( {dialogDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}> <p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine übernehmbaren Bibliotheks-Änderungen. Leere Slots ggf. über KI-Angebote im Panel Keine Verbesserung gefunden dein Pfad ist für alle Slots bereits optimal bewertet
befüllen nichts am Pfad ändern ist oft die richtige Wahl. oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).
</p> </p>
) : ( ) : (
<> <>
{recommended.length > 0 ? ( <div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
<> <button
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}> type="button"
Lücken füllen (empfohlen) className="btn btn-secondary"
</h4> style={{ fontSize: '11px' }}
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}> onClick={() => toggleAll(true)}
<button >
type="button" Alle wählen
className="btn btn-secondary" </button>
style={{ fontSize: '11px' }} <button
onClick={() => toggleGroup(recommended, true)} type="button"
> className="btn btn-secondary"
Alle Lücken wählen style={{ fontSize: '11px' }}
</button> onClick={() => toggleAll(false)}
<button >
type="button" Keine
className="btn btn-secondary" </button>
style={{ fontSize: '11px' }} </div>
onClick={() => toggleGroup(recommended, false)} <ul
> style={{
Keine listStyle: 'none',
</button> padding: 0,
</div> margin: 0,
<ul display: 'flex',
style={{ flexDirection: 'column',
listStyle: 'none', gap: '8px',
padding: 0, }}
margin: '0 0 16px', >
display: 'flex', {dialogDiffs.map((diff) => (
flexDirection: 'column', <DiffRow
gap: '8px', key={`improve-${diff.roadmap_major_step_index}-${diff.suggestion_type || 'lib'}`}
}} diff={diff}
> checked={selected.has(Number(diff.roadmap_major_step_index))}
{recommended.map((diff) => ( onToggle={toggle}
<DiffRow applying={applying}
key={`fill-${diff.roadmap_major_step_index}`} />
diff={diff} ))}
checked={selected.has(Number(diff.roadmap_major_step_index))} </ul>
onToggle={toggle}
applying={applying}
/>
))}
</ul>
</>
) : null}
{optionalReplace.length > 0 ? (
<>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem', color: 'var(--danger)' }}>
Bestehende Slots ersetzen (optional oft Verschlechterung)
</h4>
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 8px' }}>
Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen
willst.
</p>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{optionalReplace.map((diff) => (
<DiffRow
key={`replace-${diff.roadmap_major_step_index}`}
diff={diff}
checked={selected.has(Number(diff.roadmap_major_step_index))}
onToggle={toggle}
applying={applying}
tone="warn"
/>
))}
</ul>
</>
) : null}
</> </>
)} )}
{selectedReplaceCount > 0 ? (
<p
className="form-error"
style={{ marginTop: '12px', fontSize: '11px' }}
>
{selectedReplaceCount} Ersetzung(en) gewählt kann Pfad-QS senken. Lückenfüllungen
sind unkritischer.
</p>
) : null}
<div <div
style={{ style={{
display: 'flex', display: 'flex',

View File

@ -997,6 +997,12 @@ export function compareDiffKind(diff) {
return 'skip' return 'skip'
} }
export function qualityDeltaPercent(diff) {
const delta = diff?.quality_delta
if (delta == null || !Number.isFinite(Number(delta))) return null
return Math.round(Number(delta) * 100)
}
export function annotateCompareDiffKinds(diffs) { export function annotateCompareDiffKinds(diffs) {
return (diffs || []).map((d) => ({ return (diffs || []).map((d) => ({
...d, ...d,
@ -1004,20 +1010,57 @@ export function annotateCompareDiffKinds(diffs) {
})) }))
} }
/** Nur übernehmbare Bibliotheks-Diffs (kein reines Titel-/Gap-Geplänkel). */ /** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
export function compareDiffsForDialog(comparison) { export function compareDiffsForDialog(comparison) {
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
if (fromSuggestions.length > 0) {
return fromSuggestions
.map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) }))
.filter(
(d) =>
d.proposed_exercise_id != null
|| (d.suggestion_type === 'ai_gap' && d.gap_offer),
)
}
if (Array.isArray(comparison?.slot_diffs_improving)) {
return comparison.slot_diffs_improving.filter(
(d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap,
)
}
const diffs = annotateCompareDiffKinds( const diffs = annotateCompareDiffKinds(
compareSlotDiffs(comparison, { actionableOnly: true }), compareSlotDiffs(comparison, { actionableOnly: true }),
) )
return diffs.filter((d) => d.diff_kind === 'fill' || d.diff_kind === 'replace') return diffs.filter(
(d) =>
(d.diff_kind === 'fill' || d.diff_kind === 'replace')
&& d.proposed_exercise_id != null,
)
}
export function suggestionDiffKind(suggestion) {
if (!suggestion) return 'skip'
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) {
return 'fill'
}
if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) {
return 'replace'
}
return 'skip'
} }
export function recommendedCompareDiffs(comparison) { export function recommendedCompareDiffs(comparison) {
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill') return compareDiffsForDialog(comparison)
} }
export function optionalReplaceCompareDiffs(comparison) { export function optionalReplaceCompareDiffs(comparison) {
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'replace') return []
}
export function rejectedCompareDiffs(comparison) {
return Array.isArray(comparison?.slot_diffs_rejected)
? comparison.slot_diffs_rejected
: []
} }
export function gapOnlyCompareDiffs(comparison) { export function gapOnlyCompareDiffs(comparison) {
@ -1027,7 +1070,7 @@ export function gapOnlyCompareDiffs(comparison) {
} }
export function defaultSelectedCompareDiffs(comparison) { export function defaultSelectedCompareDiffs(comparison) {
return recommendedCompareDiffs(comparison).map((d) => Number(d.roadmap_major_step_index)) return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
} }
function mergeGapFillOffersFromSteps(steps, offers) { function mergeGapFillOffersFromSteps(steps, offers) {
@ -1044,29 +1087,43 @@ function mergeGapFillOffersFromSteps(steps, offers) {
} }
/** /**
* Vergleich aus zwei kaskadierten Antworten (Evaluate Match) spiegelt Backend-Compare. * Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate Match).
*/ */
export function buildProgressionComparePayload(baselineRes, proposedRes) { export function buildProgressionComparePayload(baselineRes, proposedRes) {
if (proposedRes?.unified_slot_review) {
return buildUnifiedSlotReviewComparePayload(proposedRes)
}
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
const baselineQa = baselineRes?.path_qa || null const baselineQa = baselineRes?.path_qa || null
const pipelineQa = proposedRes?.path_qa || null const pipelineQa = proposedRes?.path_qa || null
const slotDiffs = annotateCompareDiffKinds( const scoring = proposedRes?.slot_diff_scoring
const rawDiffs = annotateCompareDiffKinds(
annotateCompareSlotDiffs( annotateCompareSlotDiffs(
buildProgressionSlotDiffs(baselineSteps, proposedSteps), buildProgressionSlotDiffs(baselineSteps, proposedSteps),
), ),
) )
const actionableDiffs = actionableCompareSlotDiffs(slotDiffs) const improvingDiffs = annotateCompareDiffKinds(
const dialogDiffs = actionableDiffs.filter( (scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null),
(d) => d.diff_kind === 'fill' || d.diff_kind === 'replace',
) )
const recommendedDiffs = dialogDiffs.filter((d) => d.diff_kind === 'fill') const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || [])
const dialogDiffs = improvingDiffs.length > 0
? improvingDiffs
: rawDiffs.filter(
(d) =>
!d.trivial_id_swap
&& (d.diff_kind === 'fill' || d.diff_kind === 'replace')
&& d.proposed_exercise_id != null
&& d.improves_path !== false,
)
const actionableDiffs = dialogDiffs
const gapFillOffers = mergeGapFillOffersFromSteps( const gapFillOffers = mergeGapFillOffersFromSteps(
proposedSteps, proposedSteps,
proposedRes?.gap_fill_offers || [], proposedRes?.gap_fill_offers || [],
) )
const proposedQa = const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score
actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa const proposedQa = baselineQa
return { return {
...proposedRes, ...proposedRes,
@ -1078,19 +1135,110 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
proposed_path_qa: proposedQa, proposed_path_qa: proposedQa,
proposed_path_qa_pipeline: pipelineQa, proposed_path_qa_pipeline: pipelineQa,
gap_fill_offers: gapFillOffers, gap_fill_offers: gapFillOffers,
slot_diffs: slotDiffs, slot_diffs: rawDiffs,
slot_diffs_actionable: actionableDiffs, slot_diffs_actionable: actionableDiffs,
slot_diffs_improving: improvingDiffs,
slot_diffs_rejected: rejectedDiffs,
slot_diffs_dialog: dialogDiffs, slot_diffs_dialog: dialogDiffs,
slot_diffs_recommended: recommendedDiffs, slot_diffs_recommended: dialogDiffs,
slot_diff_count: dialogDiffs.length, slot_diff_count: dialogDiffs.length,
slot_diff_count_recommended: recommendedDiffs.length, slot_diff_count_recommended: dialogDiffs.length,
slot_diff_count_including_trivial: slotDiffs.length, slot_diff_count_rejected: rejectedDiffs.length,
slot_diffs_source: 'steps', slot_diff_count_including_trivial: rawDiffs.length,
slot_diffs_source: scoring ? 'incremental_scoring' : 'steps',
slot_diff_scoring: scoring,
baseline_quality_score: baselineScore,
path_qa: proposedQa, path_qa: proposedQa,
steps: proposedSteps, steps: proposedSteps,
} }
} }
/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */
export function buildUnifiedSlotReviewComparePayload(res) {
const baselineSteps = Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])
const baselineQa = res?.baseline_path_qa || res?.path_qa || null
const scoring = res?.slot_diff_scoring
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : []
const improving = suggestions.filter((s) => s?.improves_path)
const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : []
const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean)
const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
return {
...res,
comparison_mode: true,
unified_slot_review: true,
baseline_steps: baselineSteps,
baseline_path_qa: baselineQa,
proposed_steps: proposedSteps,
proposed_steps_pipeline: proposedSteps,
proposed_path_qa: baselineQa,
proposed_path_qa_pipeline: null,
gap_fill_offers: gapFillOffers,
slot_suggestions: suggestions,
slot_diffs: improving,
slot_diffs_improving: improving,
slot_diffs_rejected: rejected,
slot_diffs_dialog: improving,
slot_diffs_recommended: improving,
slot_diff_count: improving.length,
slot_diff_count_recommended: improving.length,
slot_diff_count_rejected: rejected.length,
slot_diffs_source: 'unified_slot_review',
slot_diff_scoring: scoring,
baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score,
path_qa: baselineQa,
steps: baselineSteps,
}
}
function suggestionToApplyStep(suggestion) {
if (!suggestion || suggestion.roadmap_major_step_index == null) return null
const midx = Number(suggestion.roadmap_major_step_index)
if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) {
const offer = suggestion.gap_offer
return {
roadmap_major_step_index: midx,
exercise_id: null,
title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`,
is_ai_proposal: true,
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
gap_offer: offer,
slot_status: 'ai_proposal',
}
}
if (suggestion.proposed_exercise_id == null) return null
return {
roadmap_major_step_index: midx,
exercise_id: suggestion.proposed_exercise_id,
title: suggestion.proposed_title,
slot_status: suggestion.proposed_slot_status || 'matched',
is_ai_proposal: false,
}
}
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */
export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) {
const selected = new Set(
(selectedMajorIndices || [])
.map((x) => Number(x))
.filter((x) => Number.isFinite(x)),
)
if (!selected.size) return draft
const steps = (comparison?.slot_suggestions || [])
.filter((s) => selected.has(Number(s.roadmap_major_step_index)))
.map(suggestionToApplyStep)
.filter(Boolean)
if (!steps.length) {
return applySelectedCompareSteps(
draft,
comparison?.proposed_steps || comparison?.steps,
selectedMajorIndices,
)
}
return applyMatchStepsToSlots(draft, steps)
}
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) { if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {