Enhance Slot Evaluation and Scoring Mechanisms
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s

- Introduced new functions `_off_topic_semantic_scores_by_slot` and `_score_exercise_stage_fit_for_spec` to improve the evaluation of off-topic steps and exercise stage fit, enhancing the quality assessment process.
- Updated `_run_unified_slot_improvement_review` to incorporate off-topic scores and exercise stage fit scoring, refining the decision-making process for slot suggestions.
- Enhanced existing logic to streamline the handling of slot scores and improve the overall robustness of slot management in path evaluations.
This commit is contained in:
Lars 2026-06-13 12:33:16 +02:00
parent e9bf5bd1a5
commit cd457e3ea0
5 changed files with 654 additions and 437 deletions

View File

@ -35,6 +35,7 @@ from planning_path_rematch import (
from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets
from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import (
_load_exercise_text_bundle,
apply_llm_path_reorder,
build_path_qa_summary,
compute_deterministic_path_quality_score,
@ -66,6 +67,7 @@ from planning_exercise_semantics import (
exercise_passes_stage_fit,
exercise_title_matches_peer_stage_goal,
pick_best_path_hit,
score_exercise_stage_fit,
resolve_semantic_skill_weights,
step_phase_for_index,
step_retrieval_query,
@ -3019,6 +3021,92 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
}
_SLOT_FIT_POOR_THRESHOLD = 0.30
def _off_topic_semantic_scores_by_slot(
off_topic_steps: Sequence[Mapping[str, Any]],
) -> Dict[int, float]:
scores: Dict[int, float] = {}
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)
raw = item.get("semantic_score")
if raw is not None:
scores[key] = round(float(raw), 4)
except (TypeError, ValueError):
continue
return scores
def _score_exercise_stage_fit_for_spec(
cur,
*,
exercise_id: int,
step: Mapping[str, Any],
stage_spec: StageSpecArtifact,
semantic_brief: PlanningSemanticBrief,
step_index: int,
stage_count: int,
) -> Optional[float]:
try:
eid = int(exercise_id)
except (TypeError, ValueError):
return None
if eid < 1:
return None
bundle = _load_exercise_text_bundle(cur, eid)
stage_goal = (stage_spec.learning_goal or step.get("roadmap_learning_goal") or "").strip()
phase = (
(step.get("roadmap_phase") or "").strip().lower()
or step_phase_for_index(semantic_brief, step_index, stage_count)
)
stage_anti = list(stage_spec.anti_patterns or step.get("roadmap_anti_patterns") or [])
stage_match_brief = (
build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti or None,
phase=phase or None,
)
if stage_goal
else None
)
if not stage_match_brief:
return None
score, _ = score_exercise_stage_fit(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
stage_brief=stage_match_brief,
step_phase=phase,
)
return round(float(score), 4)
def _slot_auto_select_library(
*,
baseline_slot_score: Optional[float],
proposed_slot_score: Optional[float],
baseline_exercise_id: Optional[int],
proposed_exercise_id: Optional[int],
) -> bool:
if proposed_exercise_id is None:
return False
if baseline_exercise_id is not None and int(baseline_exercise_id) == int(proposed_exercise_id):
return False
if proposed_slot_score is None:
return False
if baseline_slot_score is None:
return True
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
def _run_unified_slot_improvement_review(
cur,
*,
@ -3109,6 +3197,10 @@ def _run_unified_slot_improvement_review(
spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs}
stage_count = len(roadmap_ctx.stage_specs)
off_topic_scores = _off_topic_semantic_scores_by_slot(
baseline_qa.get("off_topic_steps") or [],
)
slot_reviews: List[Dict[str, Any]] = []
suggestions: List[Dict[str, Any]] = []
rejected: List[Dict[str, Any]] = []
@ -3116,6 +3208,7 @@ def _run_unified_slot_improvement_review(
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.setdefault("roadmap_learning_goal", stage_spec.learning_goal)
current_id = current.get("exercise_id")
slot_problem = major_idx in problem_slots
off_topic = slot_problem or major_idx in off_topic_map or bool(
@ -3123,6 +3216,18 @@ def _run_unified_slot_improvement_review(
)
off_reasons = list(problem_slots.get(major_idx, [])) + off_topic_map.get(major_idx, [])
baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx)
if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"):
baseline_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=int(current_id),
step=current,
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
planned_ids = [
int(s["exercise_id"])
for midx, s in sorted(steps_by_major.items())
@ -3141,19 +3246,6 @@ def _run_unified_slot_improvement_review(
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 or slot_problem):
try:
exclude_id = int(current_id)
except (TypeError, ValueError):
exclude_id = None
elif current_id is not None and (off_topic or slot_problem):
try:
exclude_id = int(current_id)
except (TypeError, ValueError):
exclude_id = None
relax_match_gate = bool(off_topic or slot_problem)
candidates = _roadmap_slot_library_candidates(
cur,
tenant=tenant,
@ -3171,208 +3263,199 @@ def _run_unified_slot_improvement_review(
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used_other,
exclude_exercise_id=int(current_id) if current_id is not None else exclude_id,
max_candidates=5 if relax_match_gate else 3,
skip_post_match_gate=relax_match_gate,
exclude_exercise_id=int(current_id) if current_id is not None else None,
max_candidates=5,
skip_post_match_gate=True,
)
accepted_for_slot = False
best_candidate: Optional[Dict[str, Any]] = None
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 or slot_problem)
and int(current_id) == cand_id
):
if current_id is not None and int(current_id) == cand_id:
continue
diff_stub = {
best_candidate = candidate
break
proposed_slot_score: Optional[float] = None
quality_delta: Optional[float] = None
projected_qa: Optional[Dict[str, Any]] = None
library_alt: Optional[Dict[str, Any]] = None
if best_candidate is not None:
try:
cand_id = int(best_candidate.get("exercise_id"))
except (TypeError, ValueError):
cand_id = None
if cand_id is not None:
proposed_slot_score = _score_exercise_stage_fit_for_spec(
cur,
exercise_id=cand_id,
step={**current, **best_candidate, "roadmap_major_step_index": major_idx},
stage_spec=stage_spec,
semantic_brief=semantic_brief,
step_index=step_index,
stage_count=stage_count,
)
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": (best_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,
**best_candidate,
"roadmap_major_step_index": major_idx,
}
break
projected_qa = _quick_evaluate_steps_qa(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=merged_steps,
roadmap_ctx=roadmap_ctx,
)
quality_delta = _quality_delta(
baseline_score,
_path_qa_quality_score(projected_qa),
)
suggestion_type = (
"remove_and_replace"
if (off_topic or slot_problem) and current_id is not None
else ("library_fill" if current_id is None else "library_improvement")
)
auto_select = _slot_auto_select_library(
baseline_slot_score=baseline_slot_score,
proposed_slot_score=proposed_slot_score,
baseline_exercise_id=int(current_id) if current_id is not None else None,
proposed_exercise_id=cand_id,
)
library_alt = {
"exercise_id": cand_id,
"title": (best_candidate.get("title") or "").strip() or None,
"slot_score": proposed_slot_score,
"slot_score_delta": (
round(float(proposed_slot_score) - float(baseline_slot_score), 4)
if proposed_slot_score is not None and baseline_slot_score is not None
else None
),
"quality_delta": quality_delta,
"auto_select": auto_select,
"suggestion_type": suggestion_type,
"reasons": list(best_candidate.get("reasons") or [])[:4],
"pro_contra": _build_slot_pro_contra(
current_step=current,
proposed_step=best_candidate,
suggestion_type=suggestion_type,
baseline_qa=baseline_qa,
projected_qa=projected_qa,
quality_delta=quality_delta,
off_topic_reasons=off_reasons,
candidate_reasons=best_candidate.get("reasons") or [],
),
}
lib_entry = {
"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": library_alt["title"],
"baseline_slot_status": current.get("slot_status"),
"proposed_slot_status": best_candidate.get("slot_status") or "matched",
"suggestion_type": suggestion_type,
"quality_delta": quality_delta,
"baseline_slot_score": baseline_slot_score,
"proposed_slot_score": proposed_slot_score,
"slot_score_delta": library_alt["slot_score_delta"],
"auto_select": auto_select,
"baseline_quality_score": baseline_score,
"projected_quality_score": _path_qa_quality_score(projected_qa),
"projected_path_qa": projected_qa,
"improves_path": auto_select,
"off_topic": off_topic,
"slot_problem": slot_problem,
"problem_reasons": off_reasons[:6],
"proposed_is_ai_proposal": False,
"pro_contra": library_alt["pro_contra"],
}
if auto_select:
suggestions.append(lib_entry)
elif cand_id is not None:
rejected.append(lib_entry)
show_ai_option = bool(
body.include_ai_gap_fill
and (
current_id is None
or off_topic
or slot_problem
or bool(current.get("is_ai_proposal"))
or (
baseline_slot_score is not None
and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD
)
)
)
ai_alt: Optional[Dict[str, Any]] = None
if show_ai_option:
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)
if slot_offer:
ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer,
"auto_select": False,
}
slot_reviews.append(
{
"roadmap_major_step_index": major_idx,
"roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None,
"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 = _quick_evaluate_steps_qa(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=merged_steps,
roadmap_ctx=roadmap_ctx,
)
projected_qa = eval_res if isinstance(eval_res, dict) else None
projected_score = _path_qa_quality_score(projected_qa)
delta = _quality_delta(baseline_score, projected_score)
improves = _slot_suggestion_accepted(
baseline_qa=baseline_qa,
projected_qa=projected_qa,
baseline_score=baseline_score,
projected_score=projected_score,
diff=diff_stub,
off_topic=off_topic,
major_idx=major_idx,
slot_problem=slot_problem,
stage_specs=roadmap_ctx.stage_specs,
baseline_steps=baseline_steps,
projected_steps=merged_steps,
)
suggestion_type = (
"remove_and_replace"
if (off_topic or slot_problem) and current_id is not None
else ("library_fill" if current_id is None else "library_improvement")
)
entry = {
**diff_stub,
"baseline_slot_score": baseline_slot_score,
"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,
"slot_problem": slot_problem,
"off_topic": off_topic,
"problem_reasons": off_reasons[:6],
"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 [],
),
"library_alternative": library_alt,
"ai_alternative": ai_alt,
}
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 slot_problem
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 = _quick_evaluate_steps_qa(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=merged_steps,
roadmap_ctx=roadmap_ctx,
)
projected_qa = eval_res if isinstance(eval_res, dict) else None
projected_score = _path_qa_quality_score(projected_qa)
delta = _quality_delta(baseline_score, projected_score)
improves = _slot_suggestion_accepted(
baseline_qa=baseline_qa,
projected_qa=projected_qa,
baseline_score=baseline_score,
projected_score=projected_score,
diff=diff_stub,
off_topic=off_topic,
major_idx=major_idx,
slot_problem=slot_problem,
stage_specs=roadmap_ctx.stage_specs,
baseline_steps=baseline_steps,
projected_steps=merged_steps,
)
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 or slot_problem,
"off_topic": off_topic,
"slot_problem": slot_problem,
"problem_reasons": off_reasons[:6],
"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 or slot_problem:
entry["improves_path"] = True
suggestions.append(entry)
else:
rejected.append(entry)
improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
problem_slot_payload = {
@ -3412,9 +3495,11 @@ def _run_unified_slot_improvement_review(
"suggestion_count": len(suggestions),
"rejected_count": len(rejected),
"problem_slot_count": len(problem_slots),
"slot_review_count": len(slot_reviews),
},
"retrieval_phase": "unified_slot_review",
"unified_slot_review": True,
"slot_reviews": slot_reviews,
"problem_slots": problem_slot_payload,
"slot_suggestions": suggestions,
"slot_diff_scoring": slot_diff_scoring,

View File

@ -2,6 +2,7 @@
from planning_exercise_path_builder import (
_parse_slot_refs_from_text,
_problematic_slots_from_path_qa,
_slot_auto_select_library,
_slot_suggestion_accepted,
)
from planning_progression_roadmap import StageSpecArtifact
@ -95,3 +96,18 @@ def test_problematic_slots_from_llm_schritt_text():
specs = [_spec(7)]
problems = _problematic_slots_from_path_qa(qa, steps, specs)
assert 7 in problems
def test_slot_auto_select_requires_higher_score():
assert _slot_auto_select_library(
baseline_slot_score=0.5,
proposed_slot_score=0.51,
baseline_exercise_id=1,
proposed_exercise_id=2,
)
assert not _slot_auto_select_library(
baseline_slot_score=0.5,
proposed_slot_score=0.5,
baseline_exercise_id=1,
proposed_exercise_id=2,
)

View File

@ -501,53 +501,75 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
setMatchNotice('Schritt 2/2: Verbesserungsvorschläge für gemeldete Schachstellen…')
const reviewRes = await api.suggestProgressionPath({
...buildEvaluateRequest(synced),
evaluate_only: false,
unified_slot_review: true,
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
baseline_quality_score:
baselineRes?.path_qa?.quality_score != null
? Number(baselineRes.path_qa.quality_score)
: null,
include_llm_intent: false,
auto_rematch_after_qa: false,
})
if (!reviewRes?.unified_slot_review) {
throw new Error(
'Match-Review nicht verfügbar — Backend-Stand prüfen (unified_slot_review fehlt in der Antwort).',
)
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
let compareRes
let reviewError = null
try {
const reviewRes = await api.suggestProgressionPath({
...buildEvaluateRequest(synced),
evaluate_only: false,
unified_slot_review: true,
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
baseline_quality_score:
baselineRes?.path_qa?.quality_score != null
? Number(baselineRes.path_qa.quality_score)
: null,
include_llm_intent: false,
auto_rematch_after_qa: false,
})
if (!reviewRes?.unified_slot_review) {
reviewError =
'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).'
compareRes = buildProgressionComparePayload(baselineRes, {
...reviewRes,
unified_slot_review: true,
slot_reviews: [],
review_error: reviewError,
})
} else {
compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
}
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
} catch (e) {
reviewError = e.message || 'Slot-Review fehlgeschlagen'
compareRes = buildProgressionComparePayload(baselineRes, {
unified_slot_review: true,
slot_reviews: [],
review_error: reviewError,
path_qa: baselineRes?.path_qa,
})
}
const compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
presentMatchCompare(compareRes, { source })
presentMatchCompare(compareRes, { source, reviewError })
return compareRes
}
const presentMatchCompare = (res, { source = 'manual' } = {}) => {
const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setComparePayload(res)
setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
setCompareSource(source)
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
setCompareOpen(true)
const baselineQa = res?.baseline_path_qa || null
const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length
const slotReviews = res?.slot_reviews || []
const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
const diffCount = autoCount || res?.slot_diff_count || 0
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
const problemCount = res?.match_summary?.problem_slot_count
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
const bPct = pathQaQualityPercent(baselineQa)
let notice =
diffCount > 0
? `Match: ${diffCount} Verbesserung(en) für gemeldete Schachstellen.`
: problemCount > 0
? `Match: ${problemCount} Schachstelle(n) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.`
: 'Match: Keine Schachstellen — Pfad wirkt konsistent.'
let notice = reviewError
? `Match: Dialog geöffnet — ${reviewError}`
: slotReviews.length > 0
? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
: diffCount > 0
? `Match: ${diffCount} Verbesserung(en).`
: problemCount > 0
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
: 'Match: Pfad geprüft — siehe Dialog.'
if (rejectedCount > 0) {
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
}

View File

@ -1,13 +1,16 @@
/**
* Gegenüberstellung: Verbesserungsvorschläge mit Slot-Bewertung (Pro/Contra).
* Slot-Match-Dialog: je Slot Bewertung, Bibliotheks-Alternative, optional KI.
*/
import React, { useMemo, useState } from 'react'
import FormModalOverlay from './FormModalOverlay'
import {
compareDiffsForDialog,
compareSlotReviews,
defaultSelectedCompareDiffs,
pathQaQualityPercent,
qualityDeltaPercent,
rejectedCompareDiffs,
slotFitScorePercent,
slotReviewSelectionKey,
} from '../utils/progressionGraphDraft'
function qaLabel(pathQa) {
@ -17,12 +20,10 @@ function qaLabel(pathQa) {
return ok ? 'OK' : 'Hinweise'
}
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 slotScoreLabel(score) {
const pct = slotFitScorePercent(score)
if (pct == null) return '—'
return `${pct} % Stufen-Fit`
}
function ProContraList({ title, items, tone = 'neutral' }) {
@ -41,89 +42,157 @@ function ProContraList({ title, items, tone = 'neutral' }) {
)
}
function DiffRow({ diff, checked, onToggle, applying }) {
const midx = Number(diff.roadmap_major_step_index)
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
function SlotReviewRow({ review, selected, onToggle, applying }) {
const midx = Number(review.roadmap_major_step_index)
const lib = review.library_alternative
const ai = review.ai_alternative
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
const pc = lib?.pro_contra || {}
const pathDelta = qualityDeltaPercent({ quality_delta: lib?.quality_delta })
const slotDelta = lib?.slot_score_delta
return (
<li
style={{
padding: '10px 12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: checked ? 'var(--surface2)' : 'var(--surface)',
border: `1px solid ${review.slot_problem ? 'var(--danger)' : 'var(--border)'}`,
background: 'var(--surface)',
fontSize: '12px',
}}
>
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(midx)}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span style={{ flex: 1 }}>
<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: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginBottom: '8px' }}>
<strong>Slot {midx + 1}</strong>
{review.slot_problem ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Schachstelle</span>
) : review.off_topic ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>OK</span>
)}
{review.roadmap_learning_goal ? (
<span style={{ fontSize: '10px', color: 'var(--text3)', flex: '1 1 100%' }}>
{review.roadmap_learning_goal}
</span>
) : null}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '10px',
marginTop: '8px',
}}
>
<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={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '10px',
marginBottom: lib || ai ? '10px' : 0,
}}
>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell
</div>
<div style={{ color: 'var(--text2)' }}>
{review.baseline_title || '— leer —'}
{review.baseline_exercise_id != null ? ` (#${review.baseline_exercise_id})` : ''}
</div>
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
{slotScoreLabel(review.baseline_slot_score)}
</div>
{(review.problem_reasons || []).slice(0, 3).map((text, i) => (
<p key={`pr-${i}`} style={{ margin: '4px 0 0', color: 'var(--danger)', fontSize: '11px' }}>
{text}
</p>
))}
</div>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Beste Bibliotheks-Alternative
</div>
{lib ? (
<>
<div style={{ color: 'var(--accent-dark)' }}>
{diff.proposed_title || '—'}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : isAi ? ' (KI)' : ''}
{lib.title || '—'}
{lib.exercise_id != null ? ` (#${lib.exercise_id})` : ''}
</div>
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
{slotScoreLabel(lib.slot_score)}
{slotDelta != null && Number(slotDelta) !== 0 ? (
<span style={{ marginLeft: '6px', color: Number(slotDelta) > 0 ? 'var(--accent-dark)' : 'var(--text2)' }}>
({Number(slotDelta) > 0 ? '+' : ''}{Math.round(Number(slotDelta) * 100)} PP)
</span>
) : null}
{pathDelta != null ? (
<span style={{ marginLeft: '6px' }}>· Pfad {pathDelta > 0 ? `+${pathDelta}` : pathDelta} %</span>
) : null}
</div>
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
</div>
</div>
</span>
</label>
</>
) : (
<div style={{ color: 'var(--text3)' }}>Kein passender Bibliotheks-Treffer</div>
)}
</div>
</div>
{lib ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(libKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
marginBottom: ai ? '8px' : 0,
}}
>
<input
type="checkbox"
checked={selected.has(libKey)}
onChange={() => onToggle(libKey, 'library')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Bibliothek übernehmen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{lib.auto_select
? 'Empfohlen — Stufen-Fit besser als aktuell'
: 'Optional — nicht besser als aktuell bewertet'}
</span>
</span>
</label>
) : null}
{ai ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(aiKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={selected.has(aiKey)}
onChange={() => onToggle(aiKey, 'ai')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'}
</span>
</span>
</label>
) : null}
</li>
)
}
@ -136,10 +205,10 @@ export default function ProgressionOptimizeCompareModal({
onApplySelected,
applying = false,
}) {
const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison])
const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison])
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
const defaultSelected = useMemo(
() => defaultSelectedCompareDiffs(comparison),
() => new Set(defaultSelectedCompareDiffs(comparison)),
[comparison],
)
@ -155,48 +224,59 @@ export default function ProgressionOptimizeCompareModal({
const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const rejectedCount = rejected.length
const reviewError = comparison.review_error || null
const toggle = (midx) => {
const toggle = (key, kind) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(midx)) next.delete(midx)
else next.add(midx)
const parsed = String(key)
const midx = Number(parsed.split(':')[0])
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
if (next.has(parsed)) {
next.delete(parsed)
return next
}
if (kind === 'library') next.delete(aiKey)
if (kind === 'ai') next.delete(libKey)
next.add(parsed)
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 — Verbesserungen' : 'Optimierung vergleichen'
mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen'
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="optimize-compare-title"
style={{ zIndex: 2200 }}
onClick={(e) => {
if (e.target === e.currentTarget && !applying) onClose()
}}
>
<FormModalOverlay open={open} raised onBackdropClick={applying ? undefined : onClose}>
<div
className="card modal-content"
style={{ maxWidth: '760px', width: '100%', maxHeight: '90vh', overflow: 'auto' }}
className="card modal-panel--form modal-panel--narrow"
style={{ maxHeight: '92vh', 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 }}>
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.
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
</p>
{reviewError ? (
<p
style={{
fontSize: '12px',
color: 'var(--danger)',
padding: '8px 10px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
}}
>
{reviewError}
</p>
) : null}
<div
style={{
padding: '10px 12px',
@ -216,97 +296,37 @@ export default function ProgressionOptimizeCompareModal({
{rejectedCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
{rejectedCount} Alternative(n) verworfen kein QS-Gewinn gegenüber deinem Pfad
{baselinePct != null ? ` (${baselinePct} %)` : ''}.
{rejectedCount} Alternative(n) ohne Pfad-Gewinn
{baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.
</p>
) : null}
{dialogDiffs.length === 0 ? (
<>
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine Bibliotheks-Verbesserung mit messbarem Gewinn Schachstellen siehe unten.
KI-Angebote im Bewertungs-Panel oder Brücke / KI-Angebot nutzen.
</p>
{comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '12px 0 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => {
const midx = Number(midxRaw)
const reasonList = Array.isArray(reasons) ? reasons : [reasons]
return (
<li
key={`problem-${midxRaw}`}
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ color: 'var(--danger)' }}>
Schachstelle Slot {midx + 1}
</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{reasonList.filter(Boolean).map((text, i) => (
<li key={`${midxRaw}-r-${i}`}>{text}</li>
))}
</ul>
</li>
)
})}
</ul>
) : null}
</>
{slotReviews.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine Slot-Daten Backend-Stand prüfen oder erneut Graph bewerten und Übungen
matchen.
</p>
) : (
<>
<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>
</>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{slotReviews.map((review) => (
<SlotReviewRow
key={`slot-review-${review.roadmap_major_step_index}`}
review={review}
selected={selected}
onToggle={toggle}
applying={applying}
/>
))}
</ul>
)}
<div
@ -324,13 +344,13 @@ export default function ProgressionOptimizeCompareModal({
<button
type="button"
className="btn btn-primary"
disabled={applying || selected.size === 0 || dialogDiffs.length === 0}
disabled={applying || selected.size === 0}
onClick={() => onApplySelected([...selected])}
>
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
</button>
</div>
</div>
</div>
</FormModalOverlay>
)
}

View File

@ -1010,8 +1010,39 @@ export function annotateCompareDiffKinds(diffs) {
}))
}
export function slotFitScorePercent(score) {
if (score == null || !Number.isFinite(Number(score))) return null
return Math.round(Number(score) * 100)
}
export function slotReviewSelectionKey(midx, kind = 'library') {
return `${Number(midx)}:${kind}`
}
export function parseSlotReviewSelection(raw) {
if (raw == null) return null
const text = String(raw)
if (text.includes(':')) {
const [midxRaw, kind] = text.split(':')
const midx = Number(midxRaw)
if (!Number.isFinite(midx)) return null
return { midx, kind: kind === 'ai' ? 'ai' : 'library' }
}
const midx = Number(text)
if (!Number.isFinite(midx)) return null
return { midx, kind: 'library' }
}
/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */
export function compareSlotReviews(comparison) {
return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : []
}
/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
export function compareDiffsForDialog(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) return reviews
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
if (fromSuggestions.length > 0) {
return fromSuggestions
@ -1037,6 +1068,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'))
}
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
}
export function suggestionDiffKind(suggestion) {
if (!suggestion) return 'skip'
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
@ -1069,10 +1110,6 @@ export function gapOnlyCompareDiffs(comparison) {
).filter((d) => d.diff_kind === 'gap_only')
}
export function defaultSelectedCompareDiffs(comparison) {
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
}
function mergeGapFillOffersFromSteps(steps, offers) {
const merged = (offers || []).map((o) => ({ ...o }))
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean))
@ -1160,11 +1197,13 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) {
: (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []))
const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null
const scoring = res?.slot_diff_scoring
const slotReviews = compareSlotReviews(res)
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : []
const improving = suggestions.filter((s) => s?.improves_path)
const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select)
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 : []
const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
return {
...res,
@ -1177,14 +1216,15 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) {
proposed_path_qa: baselineQa,
proposed_path_qa_pipeline: null,
gap_fill_offers: gapFillOffers,
slot_reviews: slotReviews,
slot_suggestions: suggestions,
slot_diffs: improving,
slot_diffs_improving: improving,
slot_diffs_rejected: rejected,
slot_diffs_dialog: improving,
slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving,
slot_diffs_recommended: improving,
slot_diff_count: improving.length,
slot_diff_count_recommended: improving.length,
slot_diff_count: autoSelectCount || improving.length,
slot_diff_count_recommended: autoSelectCount || improving.length,
slot_diff_count_rejected: rejected.length,
slot_diffs_source: 'unified_slot_review',
slot_diff_scoring: scoring,
@ -1220,25 +1260,59 @@ function suggestionToApplyStep(suggestion) {
}
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */
export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) {
export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) {
const selected = new Set((selectedKeys || []).map((x) => String(x)))
const steps = []
for (const review of reviews) {
const midx = Number(review.roadmap_major_step_index)
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
if (selected.has(aiKey) && review.ai_alternative?.gap_offer) {
const offer = review.ai_alternative.gap_offer
steps.push({
roadmap_major_step_index: midx,
exercise_id: null,
title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`,
is_ai_proposal: true,
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
gap_offer: offer,
slot_status: 'ai_proposal',
})
continue
}
if (selected.has(libKey) && review.library_alternative?.exercise_id != null) {
steps.push({
roadmap_major_step_index: midx,
exercise_id: review.library_alternative.exercise_id,
title: review.library_alternative.title,
slot_status: 'matched',
is_ai_proposal: false,
})
}
}
if (steps.length) return applyMatchStepsToSlots(draft, steps)
}
const selected = new Set(
(selectedMajorIndices || [])
.map((x) => Number(x))
(selectedKeys || [])
.map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x))
.filter((x) => Number.isFinite(x)),
)
if (!selected.size) return draft
const steps = (comparison?.slot_suggestions || [])
const legacySteps = (comparison?.slot_suggestions || [])
.filter((s) => selected.has(Number(s.roadmap_major_step_index)))
.map(suggestionToApplyStep)
.filter(Boolean)
if (!steps.length) {
if (!legacySteps.length) {
return applySelectedCompareSteps(
draft,
comparison?.proposed_steps || comparison?.steps,
selectedMajorIndices,
selectedKeys,
)
}
return applyMatchStepsToSlots(draft, steps)
return applyMatchStepsToSlots(draft, legacySteps)
}
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */