Enhance Path Quality Assessment and Slot Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added new functions for computing assignment quality scores and counting step assignment statistics, improving the evaluation of steps in path quality assessments. - Updated existing methods to incorporate the new scoring logic, enhancing the robustness of path evaluations. - Introduced UI components in the frontend to display detailed quality assessment results, including handling of split dimensions in path evaluations. - Enhanced tests to cover new functionalities and ensure accuracy in quality scoring and slot management processes.
This commit is contained in:
parent
7265cd5a01
commit
313d613b7c
|
|
@ -2208,6 +2208,7 @@ def _run_evaluate_only_path_qa(
|
|||
reorder_notes=[],
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps,
|
||||
)
|
||||
return {
|
||||
"path_qa": path_qa,
|
||||
|
|
@ -2500,6 +2501,7 @@ def _quick_evaluate_steps_qa(
|
|||
llm_applied=False,
|
||||
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps_list,
|
||||
)
|
||||
if path_qa.get("quality_score") is None:
|
||||
path_qa["quality_score"] = compute_deterministic_path_quality_score(
|
||||
|
|
@ -3072,6 +3074,7 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
_SLOT_FIT_POOR_THRESHOLD = 0.30
|
||||
_SLOT_FIT_GOOD_THRESHOLD = 0.50
|
||||
|
||||
|
||||
def _off_topic_semantic_scores_by_slot(
|
||||
|
|
@ -3152,9 +3155,18 @@ def _slot_auto_select_library(
|
|||
return False
|
||||
if proposed_slot_score is None:
|
||||
return False
|
||||
if baseline_slot_score is None:
|
||||
effective_baseline = float(baseline_slot_score) if baseline_slot_score is not None else 0.0
|
||||
if float(proposed_slot_score) <= effective_baseline + 0.001:
|
||||
return False
|
||||
# Leerer Slot: Bibliothek nur vorauswählen, wenn Stufen-Fit klar ausreicht.
|
||||
if baseline_exercise_id is None:
|
||||
return float(proposed_slot_score) >= _SLOT_FIT_GOOD_THRESHOLD
|
||||
return True
|
||||
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
|
||||
|
||||
|
||||
def _slot_auto_select_ai(*, library_auto_select: bool, has_ai: bool) -> bool:
|
||||
"""KI-Vorschlag vorauswählen, wenn angeboten und Bibliothek nicht klar besser."""
|
||||
return bool(has_ai and not library_auto_select)
|
||||
|
||||
|
||||
def _build_unified_slot_review_entry(
|
||||
|
|
@ -3391,10 +3403,14 @@ def _build_unified_slot_review_entry(
|
|||
)
|
||||
gap_fill_offers.append(slot_offer)
|
||||
if slot_offer:
|
||||
ai_auto = _slot_auto_select_ai(
|
||||
library_auto_select=bool(library_alt and library_alt.get("auto_select")),
|
||||
has_ai=True,
|
||||
)
|
||||
ai_alt = {
|
||||
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
|
||||
"gap_offer": slot_offer,
|
||||
"auto_select": False,
|
||||
"auto_select": ai_auto,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -4312,6 +4328,7 @@ def suggest_progression_path(
|
|||
reorder_notes=reorder_notes,
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps,
|
||||
)
|
||||
if rematch_log:
|
||||
path_qa["rematch_applied"] = True
|
||||
|
|
|
|||
|
|
@ -688,6 +688,160 @@ def find_step_pair_index(
|
|||
return None
|
||||
|
||||
|
||||
def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]:
|
||||
stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0}
|
||||
for raw in steps or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
stats["total"] += 1
|
||||
if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"):
|
||||
stats["library_filled"] += 1
|
||||
elif raw.get("is_ai_proposal"):
|
||||
stats["ai_proposal"] += 1
|
||||
else:
|
||||
stats["empty"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def compute_assignment_quality_score(
|
||||
*,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> float:
|
||||
"""QS der Übungsbesetzung — leere Slots stark abwerten."""
|
||||
stats = count_step_assignment_stats(steps)
|
||||
total = stats["total"]
|
||||
if total <= 0:
|
||||
return 0.45
|
||||
empty = stats["empty"]
|
||||
library = stats["library_filled"]
|
||||
ai = stats["ai_proposal"]
|
||||
fill_credit = (library + 0.55 * ai) / total
|
||||
score = 0.1 + 0.84 * fill_credit
|
||||
if empty > 0:
|
||||
score -= 0.22 * (empty / total)
|
||||
score -= 0.08 * len(off_topic_steps or [])
|
||||
score -= 0.03 * len(gaps or [])
|
||||
return max(0.08, min(0.98, round(score, 4)))
|
||||
|
||||
|
||||
def compute_roadmap_quality_score(
|
||||
*,
|
||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
||||
llm_applied: bool = False,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
) -> float:
|
||||
"""QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung."""
|
||||
if llm_applied and llm_qa and llm_qa.get("quality_score") is not None:
|
||||
try:
|
||||
return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
score = 0.9
|
||||
score -= 0.05 * len(gaps or [])
|
||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||
score -= min(0.12, 0.015 * hint_count)
|
||||
return max(0.35, min(0.98, round(score, 4)))
|
||||
|
||||
|
||||
def build_assignment_qa_snapshot(
|
||||
*,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
off_topic = list(off_topic_steps or [])
|
||||
stats = count_step_assignment_stats(steps)
|
||||
score = compute_assignment_quality_score(
|
||||
steps=steps,
|
||||
off_topic_steps=off_topic,
|
||||
gaps=gaps,
|
||||
)
|
||||
issues: List[str] = []
|
||||
if stats["empty"] > 0:
|
||||
issues.append(
|
||||
f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen",
|
||||
)
|
||||
if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0:
|
||||
issues.append(
|
||||
f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)",
|
||||
)
|
||||
for item in off_topic[:5]:
|
||||
title = (item.get("title") or "Schritt").strip()
|
||||
issues.append(f"„{title}“ passt nicht zum Stufen-Ziel")
|
||||
overall_ok = stats["empty"] == 0 and len(off_topic) == 0
|
||||
return {
|
||||
"overall_ok": overall_ok,
|
||||
"quality_score": score,
|
||||
"slot_count": stats["total"],
|
||||
"empty_slot_count": stats["empty"],
|
||||
"library_filled_count": stats["library_filled"],
|
||||
"ai_proposal_count": stats["ai_proposal"],
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
def build_roadmap_qa_snapshot(
|
||||
*,
|
||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
||||
llm_applied: bool = False,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_qa_mode: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
score = compute_roadmap_quality_score(
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_applied,
|
||||
gaps=gaps,
|
||||
multistage_qa=multistage_qa,
|
||||
)
|
||||
issues: List[str] = []
|
||||
if not llm_applied:
|
||||
for gap in gaps or []:
|
||||
issues.append(
|
||||
f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})",
|
||||
)
|
||||
if llm_applied and llm_qa:
|
||||
issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip())
|
||||
overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0
|
||||
snapshot: Dict[str, Any] = {
|
||||
"overall_ok": overall_ok,
|
||||
"quality_score": score,
|
||||
"issues": issues[:8],
|
||||
"llm_applied": bool(llm_applied),
|
||||
"roadmap_qa_mode": roadmap_qa_mode,
|
||||
}
|
||||
if llm_applied and llm_qa:
|
||||
snapshot["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||
snapshot["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||
snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||
return snapshot
|
||||
|
||||
|
||||
def merge_path_quality_scores(
|
||||
roadmap_qa: Mapping[str, Any],
|
||||
assignment_qa: Mapping[str, Any],
|
||||
) -> float:
|
||||
"""Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich."""
|
||||
try:
|
||||
roadmap_score = float(roadmap_qa.get("quality_score"))
|
||||
except (TypeError, ValueError):
|
||||
roadmap_score = None
|
||||
try:
|
||||
assignment_score = float(assignment_qa.get("quality_score"))
|
||||
except (TypeError, ValueError):
|
||||
assignment_score = None
|
||||
if roadmap_score is not None and assignment_score is not None:
|
||||
return round(min(roadmap_score, assignment_score), 4)
|
||||
if assignment_score is not None:
|
||||
return assignment_score
|
||||
if roadmap_score is not None:
|
||||
return roadmap_score
|
||||
return 0.5
|
||||
|
||||
|
||||
def build_path_qa_summary(
|
||||
*,
|
||||
gaps: Sequence[Mapping[str, Any]],
|
||||
|
|
@ -702,6 +856,7 @@ def build_path_qa_summary(
|
|||
reorder_notes: Optional[Sequence[str]] = None,
|
||||
roadmap_qa_mode: Optional[str] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
offers = list(gap_fill_offers or [])
|
||||
off_topic = list(off_topic_steps or [])
|
||||
|
|
@ -726,31 +881,32 @@ def build_path_qa_summary(
|
|||
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
|
||||
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
|
||||
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
|
||||
if llm_qa:
|
||||
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
|
||||
summary["quality_score"] = llm_qa.get("quality_score")
|
||||
summary["issues"] = list(llm_qa.get("issues") or [])
|
||||
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||
summary["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||
else:
|
||||
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
|
||||
summary["issues"] = [
|
||||
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
||||
for g in gaps
|
||||
] if gaps else []
|
||||
if off_topic:
|
||||
summary["issues"] = list(summary["issues"]) + [
|
||||
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||
for o in off_topic
|
||||
]
|
||||
summary["quality_score"] = compute_deterministic_path_quality_score(
|
||||
gaps=gaps,
|
||||
off_topic_steps=off_topic,
|
||||
|
||||
assignment_qa = build_assignment_qa_snapshot(
|
||||
steps=steps,
|
||||
multistage_qa=multistage_qa,
|
||||
off_topic_steps=off_topic,
|
||||
gaps=gaps,
|
||||
)
|
||||
roadmap_qa = build_roadmap_qa_snapshot(
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_applied,
|
||||
gaps=gaps,
|
||||
multistage_qa=multistage_qa,
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
)
|
||||
summary["assignment_qa"] = assignment_qa
|
||||
summary["roadmap_qa"] = roadmap_qa
|
||||
summary["quality_score"] = merge_path_quality_scores(roadmap_qa, assignment_qa)
|
||||
summary["overall_ok"] = bool(
|
||||
assignment_qa.get("overall_ok")
|
||||
and roadmap_qa.get("overall_ok", True),
|
||||
)
|
||||
summary["topic_coverage"] = roadmap_qa.get("topic_coverage")
|
||||
summary["recommendations"] = list(roadmap_qa.get("recommendations") or [])
|
||||
summary["sequence_notes"] = list(roadmap_qa.get("sequence_notes") or [])
|
||||
summary["issues"] = list(assignment_qa.get("issues") or []) + list(roadmap_qa.get("issues") or [])[:6]
|
||||
if llm_qa:
|
||||
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||
return summary
|
||||
|
||||
|
||||
|
|
@ -761,31 +917,34 @@ def compute_deterministic_path_quality_score(
|
|||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
) -> float:
|
||||
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
|
||||
score = 0.92
|
||||
score -= 0.08 * len(off_topic_steps or [])
|
||||
score -= 0.05 * len(gaps or [])
|
||||
if steps:
|
||||
empty = sum(
|
||||
1
|
||||
for s in steps
|
||||
if isinstance(s, dict)
|
||||
and s.get("exercise_id") is None
|
||||
and not s.get("is_ai_proposal")
|
||||
"""Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
|
||||
roadmap_qa = build_roadmap_qa_snapshot(
|
||||
llm_qa=None,
|
||||
llm_applied=False,
|
||||
gaps=gaps,
|
||||
multistage_qa=multistage_qa,
|
||||
)
|
||||
score -= 0.06 * empty
|
||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||
score -= min(0.14, 0.02 * hint_count)
|
||||
return max(0.35, min(0.98, round(score, 4)))
|
||||
assignment_qa = build_assignment_qa_snapshot(
|
||||
steps=steps,
|
||||
off_topic_steps=off_topic_steps,
|
||||
gaps=gaps,
|
||||
)
|
||||
return merge_path_quality_scores(roadmap_qa, assignment_qa)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_llm_path_reorder",
|
||||
"build_assignment_qa_snapshot",
|
||||
"build_path_qa_summary",
|
||||
"build_roadmap_qa_snapshot",
|
||||
"compute_assignment_quality_score",
|
||||
"compute_deterministic_path_quality_score",
|
||||
"compute_roadmap_quality_score",
|
||||
"count_step_assignment_stats",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"is_roadmap_planned_neighbor_pair",
|
||||
"merge_path_quality_scores",
|
||||
"strip_off_topic_steps_from_path",
|
||||
"find_step_pair_index",
|
||||
"insert_bridge_exercises",
|
||||
|
|
|
|||
|
|
@ -3,19 +3,28 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
|
|||
|
||||
|
||||
def test_deterministic_quality_score_penalizes_off_topic():
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
|
||||
steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps)
|
||||
with_off = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[{"roadmap_major_step_index": 1}],
|
||||
steps=steps,
|
||||
)
|
||||
assert with_off < base
|
||||
|
||||
|
||||
def test_deterministic_quality_score_penalizes_empty_slots():
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
|
||||
filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled)
|
||||
with_empty = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[],
|
||||
steps=[{"exercise_id": None}, {"exercise_id": 1}],
|
||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}],
|
||||
)
|
||||
all_empty = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[],
|
||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4,
|
||||
)
|
||||
assert with_empty < base
|
||||
assert all_empty <= 0.15
|
||||
|
|
|
|||
62
backend/tests/test_planning_path_qa_split.py
Normal file
62
backend/tests/test_planning_path_qa_split.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Getrennte Roadmap- vs. Besetzungs-QS."""
|
||||
from planning_exercise_path_qa import (
|
||||
build_assignment_qa_snapshot,
|
||||
build_path_qa_summary,
|
||||
compute_assignment_quality_score,
|
||||
merge_path_quality_scores,
|
||||
)
|
||||
|
||||
|
||||
def _empty_steps(n: int):
|
||||
return [{"roadmap_major_step_index": i, "exercise_id": None} for i in range(n)]
|
||||
|
||||
|
||||
def test_assignment_quality_all_empty_slots_is_low():
|
||||
steps = _empty_steps(5)
|
||||
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
|
||||
assert score <= 0.15
|
||||
|
||||
|
||||
def test_assignment_quality_all_filled_is_high():
|
||||
steps = [{"roadmap_major_step_index": i, "exercise_id": i + 1} for i in range(5)]
|
||||
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
|
||||
assert score >= 0.9
|
||||
|
||||
|
||||
def test_build_path_qa_summary_caps_llm_score_when_slots_empty():
|
||||
steps = _empty_steps(4)
|
||||
summary = build_path_qa_summary(
|
||||
gaps=[],
|
||||
bridge_inserts=[],
|
||||
ai_proposals=[],
|
||||
off_topic_steps=[],
|
||||
stripped_off_topic=[],
|
||||
llm_qa={
|
||||
"overall_ok": True,
|
||||
"quality_score": 0.88,
|
||||
"topic_coverage": "Roadmap deckt Ziel gut ab",
|
||||
"issues": [],
|
||||
"recommendations": ["Feinschliff Stufe 3"],
|
||||
},
|
||||
llm_applied=True,
|
||||
steps=steps,
|
||||
)
|
||||
assert summary["roadmap_qa"]["quality_score"] == 0.88
|
||||
assert summary["assignment_qa"]["empty_slot_count"] == 4
|
||||
assert summary["assignment_qa"]["quality_score"] <= 0.15
|
||||
assert summary["quality_score"] <= 0.15
|
||||
assert summary["overall_ok"] is False
|
||||
|
||||
|
||||
def test_merge_path_quality_uses_minimum():
|
||||
assert merge_path_quality_scores(
|
||||
{"quality_score": 0.88},
|
||||
{"quality_score": 0.12},
|
||||
) == 0.12
|
||||
|
||||
|
||||
def test_assignment_snapshot_reports_empty_slots():
|
||||
snap = build_assignment_qa_snapshot(steps=_empty_steps(3), off_topic_steps=[], gaps=[])
|
||||
assert snap["empty_slot_count"] == 3
|
||||
assert snap["overall_ok"] is False
|
||||
assert any("ohne Übung" in issue for issue in snap["issues"])
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
from planning_exercise_path_builder import (
|
||||
_parse_slot_refs_from_text,
|
||||
_problematic_slots_from_path_qa,
|
||||
_slot_auto_select_ai,
|
||||
_slot_auto_select_library,
|
||||
_slot_suggestion_accepted,
|
||||
)
|
||||
|
|
@ -113,6 +114,27 @@ def test_slot_auto_select_requires_higher_score():
|
|||
)
|
||||
|
||||
|
||||
def test_slot_auto_select_empty_slot_requires_good_fit():
|
||||
assert not _slot_auto_select_library(
|
||||
baseline_slot_score=None,
|
||||
proposed_slot_score=0.35,
|
||||
baseline_exercise_id=None,
|
||||
proposed_exercise_id=2,
|
||||
)
|
||||
assert _slot_auto_select_library(
|
||||
baseline_slot_score=None,
|
||||
proposed_slot_score=0.55,
|
||||
baseline_exercise_id=None,
|
||||
proposed_exercise_id=2,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_auto_select_ai_when_library_not_selected():
|
||||
assert _slot_auto_select_ai(library_auto_select=False, has_ai=True)
|
||||
assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True)
|
||||
assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False)
|
||||
|
||||
|
||||
def test_off_topic_slot_gap_spec_for_filled_slot():
|
||||
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
formatRefineLogEntry,
|
||||
hasRematchSlotHints,
|
||||
pathQaQualityPercent,
|
||||
pathQaHasSplitDimensions,
|
||||
pathQaSubsectionPercent,
|
||||
pathQaShowsStrongResult,
|
||||
resolveHintSlotIndex,
|
||||
resolveOfferSlotIndex,
|
||||
|
|
@ -26,6 +28,46 @@ function severityStyle(pathQa) {
|
|||
}
|
||||
}
|
||||
|
||||
function subsectionSeverityStyle(subsection) {
|
||||
if (!subsection) return {}
|
||||
return {
|
||||
background: subsection.overall_ok
|
||||
? 'color-mix(in srgb, var(--accent) 6%, var(--surface))'
|
||||
: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
border: `1px solid ${subsection.overall_ok ? 'var(--border)' : 'color-mix(in srgb, var(--danger) 35%, var(--border))'}`,
|
||||
}
|
||||
}
|
||||
|
||||
function PathQaDimensionBlock({ title, subsection, children = null }) {
|
||||
if (!subsection) return null
|
||||
const pct = pathQaSubsectionPercent(subsection)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1.45,
|
||||
...subsectionSeverityStyle(subsection),
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{title}: {subsection.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{pct != null ? ` (${pct} %)` : ''}
|
||||
</strong>
|
||||
{Array.isArray(subsection.issues) && subsection.issues.length > 0 ? (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{subsection.issues.slice(0, 5).map((issue) => (
|
||||
<li key={`${title}-${issue}`}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
|
||||
const { fixHints: optimizationHints } = useMemo(
|
||||
() => splitPathQaHints(pathQa),
|
||||
|
|
@ -310,6 +352,9 @@ export default function ProgressionFindingsPanel({
|
|||
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
|
||||
const qualityPct = pathQaQualityPercent(pathQa)
|
||||
const strongResult = pathQaShowsStrongResult(pathQa)
|
||||
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
|
||||
const roadmapQa = pathQa?.roadmap_qa || null
|
||||
const assignmentQa = pathQa?.assignment_qa || null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||
|
|
@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({
|
|||
}}
|
||||
>
|
||||
<strong>
|
||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
||||
</strong>
|
||||
{hasSplitQa ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
||||
Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung).
|
||||
</p>
|
||||
) : null}
|
||||
{pathQa.assignments_preserved ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
||||
Bewertung des aktuellen Pfads. „Übungen matchen“ öffnet einen Dialog mit Vorschlägen für
|
||||
|
|
@ -378,7 +428,23 @@ export default function ProgressionFindingsPanel({
|
|||
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
||||
</p>
|
||||
) : null}
|
||||
{pathQa.topic_coverage ? (
|
||||
{hasSplitQa ? (
|
||||
<>
|
||||
<PathQaDimensionBlock title="Roadmap & Stufen" subsection={roadmapQa}>
|
||||
{roadmapQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{roadmapQa.topic_coverage}</p>
|
||||
) : null}
|
||||
</PathQaDimensionBlock>
|
||||
<PathQaDimensionBlock title="Übungsbesetzung" subsection={assignmentQa}>
|
||||
{assignmentQa?.empty_slot_count > 0 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
||||
{assignmentQa.empty_slot_count} leere Slot(s) — „Übungen matchen“ oder manuell befüllen.
|
||||
</p>
|
||||
) : null}
|
||||
</PathQaDimensionBlock>
|
||||
</>
|
||||
) : null}
|
||||
{!hasSplitQa && pathQa.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||
) : null}
|
||||
{highlightTexts.length > 0 ? (
|
||||
|
|
@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({
|
|||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{pathQa.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import FormModalOverlay from './FormModalOverlay'
|
|||
import {
|
||||
compareSlotReviews,
|
||||
defaultSelectedCompareDiffs,
|
||||
pathQaHasSplitDimensions,
|
||||
pathQaQualityPercent,
|
||||
pathQaSubsectionPercent,
|
||||
qualityDeltaPercent,
|
||||
rejectedCompareDiffs,
|
||||
slotFitScorePercent,
|
||||
|
|
@ -189,6 +191,9 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
|
|||
<strong>KI-Vorschlag nutzen</strong>
|
||||
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
||||
{ai.title_hint || 'Neue Übung per KI entwerfen'}
|
||||
{ai.auto_select
|
||||
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -223,6 +228,9 @@ export default function ProgressionOptimizeCompareModal({
|
|||
|
||||
const baselineQa = comparison.baseline_path_qa
|
||||
const baselinePct = pathQaQualityPercent(baselineQa)
|
||||
const hasSplitQa = pathQaHasSplitDimensions(baselineQa)
|
||||
const roadmapPct = pathQaSubsectionPercent(baselineQa?.roadmap_qa)
|
||||
const assignmentPct = pathQaSubsectionPercent(baselineQa?.assignment_qa)
|
||||
const rejectedCount = rejected.length
|
||||
const reviewError = comparison.review_error || null
|
||||
|
||||
|
|
@ -258,8 +266,9 @@ export default function ProgressionOptimizeCompareModal({
|
|||
{title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
|
||||
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
|
||||
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
|
||||
Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
|
||||
KI-Vorschlag.
|
||||
</p>
|
||||
|
||||
{reviewError ? (
|
||||
|
|
@ -289,9 +298,19 @@ export default function ProgressionOptimizeCompareModal({
|
|||
>
|
||||
<strong>Dein Pfad</strong>
|
||||
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||
{baselineQa?.topic_coverage ? (
|
||||
{hasSplitQa ? (
|
||||
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Roadmap {roadmapPct != null ? `${roadmapPct} %` : '—'}
|
||||
{' · '}
|
||||
Besetzung {assignmentPct != null ? `${assignmentPct} %` : '—'}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasSplitQa && baselineQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||
) : null}
|
||||
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rejectedCount > 0 ? (
|
||||
|
|
|
|||
|
|
@ -173,8 +173,19 @@ export function pathQaQualityPercent(pathQa) {
|
|||
return Math.round(Number(pathQa.quality_score) * 100)
|
||||
}
|
||||
|
||||
export function pathQaSubsectionPercent(subsection) {
|
||||
if (subsection?.quality_score == null || !Number.isFinite(Number(subsection.quality_score))) return null
|
||||
return Math.round(Number(subsection.quality_score) * 100)
|
||||
}
|
||||
|
||||
export function pathQaHasSplitDimensions(pathQa) {
|
||||
return Boolean(pathQa?.roadmap_qa || pathQa?.assignment_qa)
|
||||
}
|
||||
|
||||
export function pathQaShowsStrongResult(pathQa) {
|
||||
const pct = pathQaQualityPercent(pathQa)
|
||||
const assignmentOk = pathQa?.assignment_qa ? pathQa.assignment_qa.overall_ok !== false : true
|
||||
if (!assignmentOk) return false
|
||||
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
|
||||
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
|
||||
}
|
||||
|
|
@ -1073,9 +1084,16 @@ export function compareDiffsForDialog(comparison) {
|
|||
export function defaultSelectedCompareDiffs(comparison) {
|
||||
const reviews = compareSlotReviews(comparison)
|
||||
if (reviews.length > 0) {
|
||||
return reviews
|
||||
.filter((review) => review?.library_alternative?.auto_select)
|
||||
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
|
||||
const keys = []
|
||||
for (const review of reviews) {
|
||||
const midx = review.roadmap_major_step_index
|
||||
if (review?.ai_alternative?.auto_select) {
|
||||
keys.push(slotReviewSelectionKey(midx, 'ai'))
|
||||
} else if (review?.library_alternative?.auto_select) {
|
||||
keys.push(slotReviewSelectionKey(midx, 'library'))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user