From 313d613b7c5965d387dceb6c79cdde0967d0a1af Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 17:13:35 +0200 Subject: [PATCH 01/11] Enhance Path Quality Assessment and Slot Management Features - 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. --- backend/planning_exercise_path_builder.py | 25 +- backend/planning_exercise_path_qa.py | 237 +++++++++++++++--- ...st_planning_deterministic_quality_score.py | 15 +- backend/tests/test_planning_path_qa_split.py | 62 +++++ .../tests/test_planning_problematic_slots.py | 22 ++ .../components/ProgressionFindingsPanel.jsx | 72 +++++- .../ProgressionOptimizeCompareModal.jsx | 25 +- frontend/src/utils/progressionGraphDraft.js | 24 +- 8 files changed, 427 insertions(+), 55 deletions(-) create mode 100644 backend/tests/test_planning_path_qa_split.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 618a467..2cfcea6 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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: - return True - return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 + 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 + + +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 diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 48770a1..6b8032a 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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) + + assignment_qa = build_assignment_qa_snapshot( + steps=steps, + 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["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, - steps=steps, - multistage_qa=multistage_qa, - ) 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") - ) - 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))) + """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, + ) + 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", diff --git a/backend/tests/test_planning_deterministic_quality_score.py b/backend/tests/test_planning_deterministic_quality_score.py index f5a0975..ed7e0dd 100644 --- a/backend/tests/test_planning_deterministic_quality_score.py +++ b/backend/tests/test_planning_deterministic_quality_score.py @@ -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 diff --git a/backend/tests/test_planning_path_qa_split.py b/backend/tests/test_planning_path_qa_split.py new file mode 100644 index 0000000..77bb0f9 --- /dev/null +++ b/backend/tests/test_planning_path_qa_split.py @@ -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"]) diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 8276d70..bae6fd1 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -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 diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 0b4135e..f79a0fb 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -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 ( +
+ + {title}: {subsection.overall_ok ? 'OK' : 'Hinweise'} + {pct != null ? ` (${pct} %)` : ''} + + {Array.isArray(subsection.issues) && subsection.issues.length > 0 ? ( + + ) : null} + {children} +
+ ) +} + 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 (
@@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({ }} > - Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} + Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'} {qualityPct != null ? ` (${qualityPct} %)` : ''} + {hasSplitQa ? ( +

+ Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung). +

+ ) : null} {pathQa.assignments_preserved ? (

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.

) : null} - {pathQa.topic_coverage ? ( + {hasSplitQa ? ( + <> + + {roadmapQa?.topic_coverage ? ( +

{roadmapQa.topic_coverage}

+ ) : null} +
+ + {assignmentQa?.empty_slot_count > 0 ? ( +

+ {assignmentQa.empty_slot_count} leere Slot(s) — „Übungen matchen“ oder manuell befüllen. +

+ ) : null} +
+ + ) : null} + {!hasSplitQa && pathQa.topic_coverage ? (

{pathQa.topic_coverage}

) : null} {highlightTexts.length > 0 ? ( @@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({ ) : null} - {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( + {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
{rejectedCount > 0 ? ( diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index eec0907..40f0dee 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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)) } -- 2.43.0 From b629f192acce9152a088c8d511059cea8ece3fab Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 17:33:36 +0200 Subject: [PATCH 02/11] Refactor ProgressionSlotCard Key Prop for Improved Stability - Updated the key prop in the ProgressionSlotCard component to use a simpler index-based key, enhancing the stability of component rendering during updates. - This change aims to prevent potential issues with component re-renders and improve overall performance in the ProgressionGraphEditor. --- frontend/src/components/ProgressionGraphEditor.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 59875ad..d540725 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -1149,7 +1149,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa {draft.slots.map((slot, idx) => ( Date: Sun, 14 Jun 2026 06:44:12 +0200 Subject: [PATCH 03/11] Enhance Progression Graph Management with F15 Features and Evaluation Improvements - Updated `PROJECT_STATUS.md` to reflect the implementation of F15 features, including the unified slot review and handling of `findings_stale`. - Enhanced `PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md` with detailed descriptions of new functionalities related to the match dialog and path quality assessments. - Introduced new functions in `exercise_progression_graphs.py` to validate exercise visibility against progression graph settings, ensuring proper governance. - Improved frontend components to support new governance parameters (visibility and club_id) in exercise creation workflows. - Updated documentation in `HANDOVER.md` and `PLANNING_KI_ROADMAP.md` to outline the latest developments and validation results for the F15 features. - Enhanced utility functions for exercise creation to incorporate governance settings, improving the overall user experience in the path builder and editor. --- .claude/docs/PROJECT_STATUS.md | 2 +- .../PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md | 50 +++++++--- .../routers/exercise_progression_graphs.py | 92 ++++++++++++++++++- ...t_exercise_progression_graph_visibility.py | 59 ++++++++++++ docs/HANDOVER.md | 35 ++++--- docs/architecture/PLANNING_KI_ROADMAP.md | 20 +++- .../PLANNING_PROGRESSION_GRAPH_KI.md | 38 ++++++-- .../ExerciseProgressionGraphPanel.jsx | 2 + .../ExerciseProgressionPathBuilder.jsx | 7 +- .../src/components/ProgressionGraphEditor.jsx | 5 +- frontend/src/utils/exerciseAiQuickCreate.js | 31 +++++-- 11 files changed, 291 insertions(+), 50 deletions(-) create mode 100644 backend/tests/test_exercise_progression_graph_visibility.py diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 209c8fd..e1c2886 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -15,7 +15,7 @@ **Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent). -**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8. +**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**, **F15** Match-Dialog + getrennte Pfad-QS lokal): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8. **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) diff --git a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md index 3f5d0cf..571f5f8 100644 --- a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md +++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md @@ -1,6 +1,6 @@ -# Progressionsgraph — Slot-Editor (Phase B) +# Progressionsgraph — Slot-Editor (Phase B + F15) -**Stand:** 2026-06-10 · **Status:** In Umsetzung +**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233) ## Ziel @@ -35,35 +35,52 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende slots: Slot[], // index = major_step_index pathSkillExpectations?, lastFindings?, // path_qa-Snapshot + findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale) dirty: boolean, } ``` **Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`. -**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`. +**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**. ## Findings-Panel -Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …). +Nutzt `path_qa`: + +| Feld | Bedeutung | +|------|-----------| +| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** | +| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) | +| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) | +| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher | **API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match. -Persistenz: `planning_roadmap.last_findings`. +**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`. + +## Match-Flow („Übungen matchen“) + +1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“) +2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`** +3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag +4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot) +5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung ## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`) -Zusätzlich optional: +Optional: - `slot_contents[]` — `{ major_step_index, primary, siblings[] }` - `last_findings` — letzter `path_qa`-Snapshot +- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand ## UI (konsolidiert) - **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings) - Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel - Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph) -- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel) +- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen ## Ersetzt (Legacy, nicht mehr im Panel) @@ -71,11 +88,14 @@ Zusätzlich optional: ## Implementierungsreihenfolge -| ID | Inhalt | -|----|--------| -| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | -| B.1 | Slot-Karten, Bibliothek + Entwurf | -| B.2 | Findings-Panel + `evaluate_only` | -| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | -| B.4 | Route + Panel vereinfachen | -| B.5 | `last_findings` + Phase-C-Vorbereitung | +| ID | Inhalt | Status | +|----|--------|--------| +| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ | +| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ | +| B.2 | Findings-Panel + `evaluate_only` | ✅ | +| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ | +| B.4 | Route + Panel vereinfachen | ✅ | +| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ | +| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ | + +**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15 diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 9e4edaa..7b3c0bf 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -4,7 +4,7 @@ Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage. AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral. """ import json -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator @@ -19,6 +19,7 @@ from club_tenancy import ( assert_library_content_editable, assert_library_content_governance_transition, assert_valid_governance_visibility, + is_platform_admin, library_content_visible_to_profile, ) @@ -176,6 +177,87 @@ def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung") +def _exercise_allowed_in_progression_graph( + exercise_row: Mapping[str, Any], + *, + graph_visibility: str, + graph_club_id: Optional[int], + profile_id: int, + role: str, +) -> bool: + """Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt.""" + ex_vis = (exercise_row.get("visibility") or "private").strip().lower() + gvis = (graph_visibility or "private").strip().lower() + if gvis == "private": + if ex_vis == "official": + return True + if ex_vis == "club": + return True + if ex_vis == "private": + if is_platform_admin(role): + return True + try: + return int(exercise_row.get("created_by") or 0) == int(profile_id) + except (TypeError, ValueError): + return False + return False + if gvis == "club": + if ex_vis == "official": + return True + if ex_vis != "club": + return False + ex_club = exercise_row.get("club_id") + if ex_club is None: + return False + if graph_club_id is None: + return True + return int(ex_club) == int(graph_club_id) + return ex_vis == "official" + + +def _assert_exercises_allowed_in_graph( + cur, + graph_id: int, + profile_id: int, + role: str, + *exercise_ids: int, +) -> None: + """400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt.""" + row = _graph_row(cur, graph_id) + gvis = (row.get("visibility") or "private").strip().lower() + gclub_raw = row.get("club_id") + gclub = int(gclub_raw) if gclub_raw is not None else None + unique = list(dict.fromkeys(exercise_ids)) + if not unique: + return + ph = ",".join(["%s"] * len(unique)) + cur.execute( + f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})", + tuple(unique), + ) + by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()} + for eid in unique: + ex = by_id.get(int(eid)) + if not ex: + continue + if _exercise_allowed_in_progression_graph( + ex, + graph_visibility=gvis, + graph_club_id=gclub, + profile_id=profile_id, + role=role, + ): + continue + title = (ex.get("title") or "").strip() or f"#{eid}" + raise HTTPException( + status_code=400, + detail=( + f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) " + f"passt nicht zum Progressionsgraphen ({gvis})." + ), + ) + + def _insert_edge_row( cur, graph_id: int, @@ -359,8 +441,10 @@ def list_visibility_promotion_candidates( if not library_content_visible_to_profile( cur, profile_id, + (exd.get("visibility") or "private").strip().lower(), + exd.get("club_id"), + exd.get("created_by"), role, - exd, ): continue exercises.append( @@ -565,6 +649,9 @@ def create_progression_edge( cur = get_cursor(conn) _require_graph_write(cur, graph_id, profile_id, role) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) + _assert_exercises_allowed_in_graph( + cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id + ) fv = body.from_exercise_variant_id tv = body.to_exercise_variant_id _assert_variant_for_exercise(cur, body.from_exercise_id, fv) @@ -613,6 +700,7 @@ def create_progression_sequence( ex_ids = [s.exercise_id for s in steps] _assert_exercises_exist(cur, *ex_ids) + _assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids) try: for i in range(n_seg): diff --git a/backend/tests/test_exercise_progression_graph_visibility.py b/backend/tests/test_exercise_progression_graph_visibility.py new file mode 100644 index 0000000..98cac37 --- /dev/null +++ b/backend/tests/test_exercise_progression_graph_visibility.py @@ -0,0 +1,59 @@ +"""Sichtbarkeit: Progressionsgraph ↔ Übungen (Promotion, Kanten, Match).""" +from routers.exercise_progression_graphs import _exercise_allowed_in_progression_graph + + +def test_club_graph_rejects_private_exercise(): + assert not _exercise_allowed_in_progression_graph( + {"visibility": "private", "club_id": None, "created_by": 1}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_club_graph_accepts_matching_club_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "club", "club_id": 5, "created_by": 2}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_club_graph_accepts_official_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "official", "club_id": None, "created_by": 99}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_private_graph_accepts_own_private_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "private", "club_id": None, "created_by": 7}, + graph_visibility="private", + graph_club_id=None, + profile_id=7, + role="trainer", + ) + + +def test_official_graph_requires_official_exercise(): + assert not _exercise_allowed_in_progression_graph( + {"visibility": "club", "club_id": 5, "created_by": 2}, + graph_visibility="official", + graph_club_id=None, + profile_id=1, + role="trainer", + ) + assert _exercise_allowed_in_progression_graph( + {"visibility": "official", "club_id": None, "created_by": 2}, + graph_visibility="official", + graph_club_id=None, + profile_id=1, + role="trainer", + ) diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index a9d5820..6159fe6 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-22 -**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**). +**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**) +**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**). Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -114,11 +114,25 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.8.232** | | **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** | | **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** | +| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) | | **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** | **Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16. -**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch. +**Validierung (Mae Geri, Härtetest):** Roadmap-QS nach Trainer-Roadmap oft **~85–88 %** — gilt **Stufenlogik**, nicht leere Slots. **Gesamt-Pfad-QS** = Minimum aus **`roadmap_qa`** + **`assignment_qa`** (leere Slots → Besetzung ~8–15 %). Workbench universell; Mae Geri Referenzfall. + +#### F15 — Match-Dialog, Bewertung, Pfad-QS (Stand 2026-05-22) + +| Thema | Ist | +|--------|-----| +| **„Übungen matchen“** | Schritt 1: `evaluate_only` (wie „Graph bewerten“) · Schritt 2: `unified_slot_review: true` → Dialog **pro Slot** (Bewertung, Bibliotheks-Alternative, optional KI) | +| **Vorauswahl Dialog** | Bibliothek nur bei Stufen-Fit **≥ 50 %** und besser als aktuell; bei leerem Slot + schwacher Bibliothek → **KI-Vorschlag** vorausgewählt | +| **Übernahme** | Nur gewählte Slots speichern — **keine** automatische teure Nach-Bewertung | +| **Bewertung veraltet** | Nach Graph-Änderungen Hinweis im Findings-Panel; persistiert als **`findings_stale`** im `planning_roadmap`-Artefakt (mit Speichern) | +| **Getrennte QS** | `path_qa.roadmap_qa` (Stufen/Roadmap/LLM) + `path_qa.assignment_qa` (Slot-Befüllung); **`quality_score`** = Minimum beider | +| **UX-Fix** | Slot-Karten: stabiler React-Key (`slot-{index}`) — Lernziel editierbar ohne Fokusverlust | + +**Code:** `ProgressionOptimizeCompareModal.jsx`, `planning_exercise_path_builder.py` (`_build_unified_slot_review_entry`, `_slot_auto_select_*`), `planning_exercise_path_qa.py` (`build_*_qa_snapshot`), `progression_graph_planning_artifact.py` (`findings_stale`), `progressionGraphDraft.js` **Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py` @@ -129,12 +143,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl **Offen (priorisiert):** 1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri) 2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor -3. QS-UI — positive LLM-Hinweise als Highlights -4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) -5. Graph-Erweiterungsmodus (Start ab Knoten) -6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots -7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) -8. Technik-Katalog konfigurierbar (Backlog) +3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) +4. Graph-Erweiterungsmodus (Start ab Knoten) +5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots +6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) +7. Technik-Katalog konfigurierbar (Backlog) +8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen) #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) @@ -271,8 +285,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl 1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung). 2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest. -2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`. -3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale. +3. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`. 4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert. 5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`. 6. **Trainingsplanung G0–G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**. diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md index 98b9cfb..56ec945 100644 --- a/docs/architecture/PLANNING_KI_ROADMAP.md +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -89,14 +89,24 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**. - [x] Vier Planungskontext-Dropdowns im Editor - [x] `progressionGraphDraft.js` — Artefakt + API-Payload +### F15 — Match-Dialog & getrennte Pfad-QS (2026-05-22, lokal) + +- [x] **`unified_slot_review`** — Dialog pro Slot (Bibliothek + KI, Stufen-Fit-Vergleich) +- [x] Vorauswahl: Bibliothek nur bei Stufen-Fit ≥ 50 %; sonst KI bei leerem/schwachem Slot +- [x] Übernahme ohne teure Auto-Nach-Bewertung; manuell „Graph bewerten“ +- [x] **`path_qa.roadmap_qa`** + **`path_qa.assignment_qa`**; Gesamt = Minimum +- [x] **`findings_stale`** im Graph-Artefakt — Hinweis „Bewertung veraltet“ (persistiert) +- [x] Slot-Key-Fix — Lernziel editierbar ohne Fokusverlust + ### Validierung (Referenz Mae Geri, 2026-05) -| Phase | Pfad-QS | Ergebnis | -|-------|---------|----------| -| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic | -| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise | +| Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis | +|-------|------------|-----------|--------|----------| +| Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic | +| Roadmap ok, Slots leer | ~88 % | ~8–15 % | **~8–15 %** | Besetzung fehlt | +| Nach Match + Fill | ~88 % | hoch | **~85 %+** | Vollständige Abdeckung | -**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht. +**Fazit:** Roadmap-QS und Besetzungs-QS getrennt betrachten; Workbench + Katalog + Roadmap universell. --- diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md index 647b4e2..7015c99 100644 --- a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md +++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md @@ -157,6 +157,10 @@ flowchart TB | `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) | | `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) | | `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote | +| `evaluate_only` | bool | Nur QS auf `evaluate_steps[]` — kein Match | +| `unified_slot_review` | bool | Pro-Slot-Review (Bibliothek + optional KI) für Match-Dialog; erfordert `baseline_evaluate_steps` + Roadmap | +| `baseline_evaluate_steps` | array? | Slot-Stand für Schritt 1 / Review-Baseline | +| `baseline_path_qa_snapshot` | object? | `path_qa` aus evaluate_only (Schritt 1 des Match-Flows) | ### 4.2 Wichtige Response-Felder @@ -166,7 +170,9 @@ flowchart TB | `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` | | `path_skill_expectations` | Pfadweite Skill-Erwartungen | | `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` | -| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` | +| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log`; **F15:** auch `roadmap_qa`, `assignment_qa` (siehe §8.1) | +| `slot_reviews[]` | Bei `unified_slot_review`: je Slot `library_alternative`, `ai_alternative`, `auto_select`-Flags | +| `findings_stale` | Im Graph-Artefakt (nicht API-Response): Bewertung veraltet seit letztem „Graph bewerten“ | | `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) | | `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` | @@ -221,12 +227,13 @@ Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, ### Referenz-Validierung (Mae Geri, 2026-05) -| Phase | Pfad-QS | Ergebnis | -|-------|---------|----------| -| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) | -| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen | +| Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis | +|-------|------------|-----------|--------------|----------| +| Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken | +| Nach Trainer-Roadmap, **Slots leer** | ~85–88 % | ~8–15 % | **~8–15 %** | Roadmap ok, Besetzung fehlt | +| Nach Match + befüllte Slots | ~85–88 % | hoch | **~85 %+** | Vollständige Curriculum-Abdeckung | -**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik. +**Lesson:** **`roadmap_qa`** und **`assignment_qa`** getrennt interpretieren; Gesamt-QS allein bei leerer Roadmap irreführend (historisch nur LLM-Roadmap-Score). --- @@ -330,6 +337,21 @@ API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezi Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match. +### 8.1 Getrennte Pfad-QS — Roadmap vs. Übungsbesetzung (F15) + +`build_path_qa_summary()` in `planning_exercise_path_qa.py` liefert drei Ebenen: + +| Feld | Inhalt | Score-Logik | +|------|--------|-------------| +| **`roadmap_qa`** | Stufenlogik, LLM `topic_coverage`, Roadmap-Hinweise | LLM-`quality_score` oder heuristisch (Lücken, Hints) | +| **`assignment_qa`** | Leere Slots, Off-Topic auf belegten Slots, Fill-Statistik | Stark abwertend bei leeren Slots (~8–15 % bei 100 % leer) | +| **`quality_score`** (gesamt) | Anzeige „Pfad-QS gesamt“ | **`min(roadmap_qa, assignment_qa)`** | +| **`overall_ok`** | Gesamt-OK | Beide Dimensionen müssen OK sein | + +UI: **`ProgressionFindingsPanel`** — zwei Unterblöcke; Match-Dialog zeigt Roadmap- vs. Besetzungs-Prozent. Nach Graph-Änderung: **`findings_stale: true`** im Artefakt → Hinweis „Bewertung veraltet“ (bis erneut „Graph bewerten“ + Speichern). + +Tests: `test_planning_path_qa_split.py`, `test_planning_deterministic_quality_score.py` + ## 9. Fähigkeiten-Scoring-Anbindung Modul: `planning_skill_expectations.py` @@ -379,6 +401,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.8.232 | | **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** | | **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 | +| **F15** | Unified Slot-Review, getrennte Pfad-QS, `findings_stale`, Match-Vorauswahl | ✅ | lokal (2026-05-22) | | **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** | | **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — | | **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — | @@ -391,8 +414,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` 1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** 2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren -2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` -3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“ +3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` 4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert 5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz 6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 2d02149..d12022a 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -285,10 +285,12 @@ function ExerciseProgressionGraphPanel( } } + const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, visibility: metaVisibility, + ...(promoteClubId != null ? { club_id: promoteClubId } : {}), }) await refreshGraphs() alert('Graph-Metadaten gespeichert.') diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 7b2efc1..46af07c 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -589,6 +589,7 @@ export default function ExerciseProgressionPathBuilder({ const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('') const [gapPrepError, setGapPrepError] = useState('') const [loadedPlanningHint, setLoadedPlanningHint] = useState(false) + const [graphGovernance, setGraphGovernance] = useState({ visibility: 'private', clubId: null }) const [wizardStep, setWizardStep] = useState(1) const [pathInsertNotice, setPathInsertNotice] = useState('') @@ -670,6 +671,10 @@ export default function ExerciseProgressionPathBuilder({ .getExerciseProgressionGraph(Number(graphId)) .then((g) => { if (cancelled) return + setGraphGovernance({ + visibility: g?.visibility || 'private', + clubId: g?.club_id ?? null, + }) const art = g?.planning_roadmap if (!art) return if (art.goal_query) setGoalQuery(String(art.goal_query)) @@ -1056,7 +1061,7 @@ export default function ExerciseProgressionPathBuilder({ setQuickSaving(true) setQuickAiError('') try { - const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft) + const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft, graphGovernance) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') insertExerciseFromOffer(created, activeOffer) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index d540725..5b87768 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -880,7 +880,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setSlotQuickSaving(true) setSlotQuickError('') try { - const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft) + const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, { + visibility: graphMeta?.visibility || 'private', + clubId: graphMeta?.club_id ?? null, + }) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') setDraft((prev) => ({ diff --git a/frontend/src/utils/exerciseAiQuickCreate.js b/frontend/src/utils/exerciseAiQuickCreate.js index 298a364..27c9e27 100644 --- a/frontend/src/utils/exerciseAiQuickCreate.js +++ b/frontend/src/utils/exerciseAiQuickCreate.js @@ -208,11 +208,27 @@ export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketc } } +/** + * Sichtbarkeit/club_id für Schnellanlage (z. B. aus Progressionsgraph). + * @param {{ visibility?: string, clubId?: number|null }} [governance] + */ +function resolveQuickCreateGovernance(governance) { + const rawVis = (governance?.visibility || 'private').trim().toLowerCase() + const vis = rawVis === 'club' || rawVis === 'official' ? rawVis : 'private' + let clubId = null + if (vis === 'club' && governance?.clubId != null && governance.clubId !== '') { + const n = Number(governance.clubId) + if (Number.isFinite(n) && n > 0) clubId = n + } + return { visibility: vis, club_id: clubId } +} + /** * createExercise-Payload aus bearbeitetem Entwurf. + * @param {{ visibility?: string, clubId?: number|null }} [governance] * @throws {Error} */ -export function buildQuickCreateExercisePayloadFromDraft(draft) { +export function buildQuickCreateExercisePayloadFromDraft(draft, governance) { const title = (draft?.title || '').trim() if (title.length < 3) { throw new Error('Titel: mindestens 3 Zeichen.') @@ -239,6 +255,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) { if (summary && !stripHtmlToText(summary).trim()) summary = null const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after) + const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance) return { title, @@ -247,7 +264,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) { execution, preparation: prep, trainer_notes: trainerNotes, - visibility: 'private', + visibility, status: 'draft', equipment: [], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], @@ -256,15 +273,16 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) { target_groups_multi: [], age_groups: [], skills, - club_id: null, + club_id: clubId, } } /** * createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus). + * @param {{ visibility?: string, clubId?: number|null }} [governance] * @throws {Error} */ -export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) { +export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) { const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const fieldMap = {} for (const c of preview?.instructionChoices || []) { @@ -288,6 +306,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc } const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after) + const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance) const fid = Number(focusAreaId) if (!Number.isFinite(fid) || fid < 1) { @@ -301,7 +320,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc execution, preparation: prep, trainer_notes: trainerNotes, - visibility: 'private', + visibility, status: 'draft', equipment: [], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], @@ -310,7 +329,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc target_groups_multi: [], age_groups: [], skills, - club_id: null, + club_id: clubId, } } -- 2.43.0 From 87d9fa9b6571b134621ee8b08054f1a1c7e28cc6 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Jun 2026 07:10:11 +0200 Subject: [PATCH 04/11] Enhance Exercise Progression Graph Panel with Club Management Features - Added functionality to select and manage clubs within the Exercise Progression Graph Panel, allowing users to assign clubs to exercises. - Introduced state management for club selection and manual entry, improving user experience for platform admins. - Updated visibility handling to ensure proper governance and club association during exercise promotion. - Enhanced error handling to provide clearer feedback when no club is selected, ensuring users are guided to make necessary selections. --- .../ExerciseProgressionGraphPanel.jsx | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index d12022a..1a9eadd 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' -import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' +import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphListCard from './ProgressionGraphListCard' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' @@ -41,6 +41,8 @@ function ExerciseProgressionGraphPanel( const { user } = useAuth() const location = useLocation() const isSuperadmin = user?.role === 'superadmin' + const isPlatformAdmin = isSuperadmin || user?.role === 'admin' + const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const filteredGraphVisOptions = useMemo( @@ -61,6 +63,8 @@ function ExerciseProgressionGraphPanel( const [metaName, setMetaName] = useState('') const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') + const [metaClubSelect, setMetaClubSelect] = useState('') + const [metaClubManual, setMetaClubManual] = useState('') const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) @@ -157,6 +161,8 @@ function ExerciseProgressionGraphPanel( setMetaName('') setMetaDescription('') setMetaVisibility('private') + setMetaClubSelect('') + setMetaClubManual('') return } const g = graphs.find((x) => x.id === selectedGraphId) @@ -164,6 +170,13 @@ function ExerciseProgressionGraphPanel( setMetaName(g.name || '') setMetaDescription(g.description || '') setMetaVisibility(g.visibility || 'private') + if (g.club_id != null) { + setMetaClubSelect(String(g.club_id)) + } else { + const fallback = getDefaultClubIdForGovernanceForms(user) + setMetaClubSelect(fallback != null ? String(fallback) : '') + } + setMetaClubManual('') } let cancelled = false ;(async () => { @@ -176,7 +189,20 @@ function ExerciseProgressionGraphPanel( return () => { cancelled = true } - }, [selectedGraphId, graphs, refreshEdges]) + }, [selectedGraphId, graphs, refreshEdges, user]) + + const resolveGovernanceClubId = useCallback(() => { + const g = graphs.find((x) => x.id === selectedGraphId) + if (g?.club_id != null) return Number(g.club_id) + + const manual = String(metaClubManual || '').trim() + if (manual && /^\d+$/.test(manual)) return Number(manual) + + const sel = String(metaClubSelect || '').trim() + if (sel && /^\d+$/.test(sel)) return Number(sel) + + return getDefaultClubIdForGovernanceForms(user) + }, [graphs, selectedGraphId, metaClubManual, metaClubSelect, user]) const filteredEdges = useMemo(() => { if (!filterAnchorOnly || anchorExerciseId == null) return edges @@ -226,13 +252,7 @@ function ExerciseProgressionGraphPanel( } } - const resolvePromoteClubId = () => { - const g = graphs.find((x) => x.id === selectedGraphId) - if (g?.club_id != null) return Number(g.club_id) - const memberships = activeClubMemberships(user?.clubs) - const active = memberships.find((c) => c.is_active) || memberships[0] - return active?.club_id != null ? Number(active.club_id) : null - } + const resolvePromoteClubId = resolveGovernanceClubId const handleSaveMeta = async () => { if (!selectedGraphId) return @@ -268,7 +288,9 @@ function ExerciseProgressionGraphPanel( if (promote) { const clubId = resolvePromoteClubId() if (!clubId) { - alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.') + throw new Error( + 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', + ) } else { const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) const res = await api.bulkPatchExercisesMetadata({ @@ -286,6 +308,11 @@ function ExerciseProgressionGraphPanel( } const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null + if (nextVis === 'club' && !promoteClubId) { + throw new Error( + 'Vereins-Sichtbarkeit: Bitte einen Verein unter „Verein zuordnen“ wählen oder den Vereins-Umschalter setzen.', + ) + } await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, @@ -541,7 +568,14 @@ function ExerciseProgressionGraphPanel( + {metaVisibility === 'club' ? ( +
+ + + {isPlatformAdmin ? ( + <> + + setMetaClubManual(e.target.value)} + /> + + ) : null} +
+ ) : null}
) : null}
-- 2.43.0 From 0b203489f7be11fc606e7f40d8e5b7627ea6fb72 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Jun 2026 07:30:26 +0200 Subject: [PATCH 06/11] Implement Graph Visibility Promotion Logic and Update UI Components - Added a new function `_graph_promotion_transition` to determine the necessary exercise visibility changes during graph promotions. - Updated the `list_visibility_promotion_candidates` endpoint to utilize the new promotion logic, ensuring accurate exercise visibility handling. - Enhanced the frontend components to prompt users for exercise visibility adjustments based on graph visibility changes, improving user experience. - Introduced tests for the new promotion logic to ensure correctness and reliability in visibility transitions. --- .../routers/exercise_progression_graphs.py | 35 ++++++++--- backend/routers/planning_exercise_suggest.py | 1 - ...t_exercise_progression_graph_visibility.py | 23 ++++++- .../ExerciseProgressionGraphPanel.jsx | 62 ++++++++++++------- .../src/components/ProgressionGraphEditor.jsx | 14 ++++- 5 files changed, 101 insertions(+), 34 deletions(-) diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 7b3c0bf..b2d3419 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -394,6 +394,22 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]: return ids +def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]: + """ + Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen. + + Returns None wenn kein Übungs-Promotion-Hinweis nötig. + """ + gvis = (graph_visibility or "private").strip().lower() + tvis = (target_visibility or "").strip().lower() + transitions: Dict[tuple[str, str], tuple[str, ...]] = { + ("private", "club"): ("private",), + ("private", "official"): ("private", "club"), + ("club", "official"): ("private", "club"), + } + return transitions.get((gvis, tvis)) + + @router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates") def list_visibility_promotion_candidates( graph_id: int, @@ -401,7 +417,9 @@ def list_visibility_promotion_candidates( tenant: TenantContext = Depends(get_tenant_context), ): """ - Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + + Unterstützt: private→club, private→official, club→official. """ profile_id = tenant.profile_id role = tenant.global_role @@ -409,11 +427,13 @@ def list_visibility_promotion_candidates( cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) graph_vis = (row.get("visibility") or "private").strip().lower() - if graph_vis != "private" or target_visibility != "club": + target_vis = (target_visibility or "club").strip().lower() + need_vis = _graph_promotion_transition(graph_vis, target_vis) + if not need_vis: return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id) @@ -421,19 +441,20 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } + vis_placeholders = ",".join(["%s"] * len(need_vis)) ph = ",".join(["%s"] * len(ref_ids)) cur.execute( f""" SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph}) - AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private' + AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders}) ORDER BY title """, - list(ref_ids), + list(ref_ids) + list(need_vis), ) exercises = [] for ex in cur.fetchall(): @@ -457,7 +478,7 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": exercises, } diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index beb0934..8619cd6 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -70,7 +70,6 @@ def post_progression_path_suggest( uses_ai = ( body.include_llm_intent or body.include_llm_path_qa - or body.include_ai_gap_fill or body.include_llm_roadmap or body.include_llm_start_target or (body.start_target_only and body.include_llm_start_target) diff --git a/backend/tests/test_exercise_progression_graph_visibility.py b/backend/tests/test_exercise_progression_graph_visibility.py index 98cac37..5a01668 100644 --- a/backend/tests/test_exercise_progression_graph_visibility.py +++ b/backend/tests/test_exercise_progression_graph_visibility.py @@ -1,5 +1,26 @@ """Sichtbarkeit: Progressionsgraph ↔ Übungen (Promotion, Kanten, Match).""" -from routers.exercise_progression_graphs import _exercise_allowed_in_progression_graph +from routers.exercise_progression_graphs import ( + _exercise_allowed_in_progression_graph, + _graph_promotion_transition, +) + + +def test_graph_promotion_transition_private_to_club(): + assert _graph_promotion_transition("private", "club") == ("private",) + + +def test_graph_promotion_transition_private_to_official(): + assert _graph_promotion_transition("private", "official") == ("private", "club") + + +def test_graph_promotion_transition_club_to_official(): + assert _graph_promotion_transition("club", "official") == ("private", "club") + + +def test_graph_promotion_transition_noop(): + assert _graph_promotion_transition("club", "club") is None + assert _graph_promotion_transition("official", "club") is None + assert _graph_promotion_transition("private", "private") is None def test_club_graph_rejects_private_exercise(): diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 68a3038..8beac1e 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -24,6 +24,21 @@ const VIS_OPTIONS = [ { value: 'official', label: 'Offiziell' }, ] +const GRAPH_VISIBILITY_PROMOTION_LABEL = { + club: 'Vereins-Sichtbarkeit', + official: 'offizielle Sichtbarkeit', +} + +/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */ +function shouldPromptGraphExercisePromotion(prevVis, nextVis) { + const p = (prevVis || 'private').trim().toLowerCase() + const n = (nextVis || 'private').trim().toLowerCase() + return ( + (p === 'private' && (n === 'club' || n === 'official')) || + (p === 'club' && n === 'official') + ) +} + function edgeTypeLabel(type) { if (type === 'next_exercise') return 'Nachfolger' if (type === 'sibling') return 'Schwester' @@ -310,42 +325,43 @@ function ExerciseProgressionGraphPanel( setBusy(true) try { - if (prevVis === 'private' && nextVis === 'club') { + if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) { const preview = await api.getProgressionGraphVisibilityPromotionCandidates( selectedGraphId, - { targetVisibility: 'club' }, + { targetVisibility: nextVis }, ) - const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] - if (privateExercises.length > 0) { - const titles = privateExercises + const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] + if (promotionExercises.length > 0) { + const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis + const titles = promotionExercises .slice(0, 8) .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`) .join('\n') const more = - privateExercises.length > 8 - ? `\n… und ${privateExercises.length - 8} weitere` + promotionExercises.length > 8 + ? `\n… und ${promotionExercises.length - 8} weitere` : '' const promote = window.confirm( - `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`, + `Der Graph wird auf „${visLabel}“ gestellt. Im Graph sind noch ${promotionExercises.length} Übung(en) mit niedrigerer Sichtbarkeit:\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf ${visLabel} anheben?`, ) if (promote) { - const clubId = resolvePromoteClubId() - if (!clubId) { - throw new Error( - 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', - ) - } else { - const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) - const res = await api.bulkPatchExercisesMetadata({ - exercise_ids: ids, - visibility: 'club', - club_id: clubId, - }) - if ((res?.failed || []).length) { - const f = res.failed[0] - throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + let clubId = null + if (nextVis === 'club') { + clubId = resolvePromoteClubId() + if (!clubId) { + throw new Error( + 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', + ) } } + const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null) + const bulkPayload = { exercise_ids: ids, visibility: nextVis } + if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId + const res = await api.bulkPatchExercisesMetadata(bulkPayload) + if ((res?.failed || []).length) { + const f = res.failed[0] + throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + } } } } diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 5b87768..4bb2b81 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ProgressionSlotCard from './ProgressionSlotCard' @@ -85,6 +87,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) { } export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) { + const { user } = useAuth() const [graphMeta, setGraphMeta] = useState(null) const [draft, setDraft] = useState(null) const [busy, setBusy] = useState(false) @@ -880,9 +883,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setSlotQuickSaving(true) setSlotQuickError('') try { + const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase() + const graphClubId = + graphMeta?.club_id != null + ? graphMeta.club_id + : graphVis === 'club' + ? getDefaultClubIdForGovernanceForms(user) + : null const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, { - visibility: graphMeta?.visibility || 'private', - clubId: graphMeta?.club_id ?? null, + visibility: graphVis, + clubId: graphClubId, }) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') -- 2.43.0 From 9cee862c326ee2678fb7c9ee52b4ca8ef4c7c1bc Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Jun 2026 07:50:49 +0200 Subject: [PATCH 07/11] Implement Planning Prompt Enhancements and LLM Usage Tracking - Added new fields for goal query, user notes, max steps, and search query in the AiPromptPreviewBody to support planning prompts. - Integrated planning prompt handling in the preview_ai_prompt function, allowing for distinct processing of planning and exercise prompts. - Introduced LLM usage tracking in openrouter_chat_completion and planning_exercise_suggest functions to monitor AI call metrics. - Updated frontend components to accommodate new input fields for planning prompts, enhancing user experience and functionality. --- backend/ai_prompt_planning_preview.py | 272 ++++++++++++++++++ backend/openrouter_chat.py | 7 + backend/planning_llm_usage.py | 62 ++++ backend/routers/ai_prompts_admin.py | 23 +- backend/routers/planning_exercise_suggest.py | 21 +- .../tests/test_ai_prompt_planning_preview.py | 89 ++++++ backend/tests/test_planning_llm_usage.py | 94 ++++++ .../src/components/ProgressionGraphEditor.jsx | 23 -- frontend/src/pages/AdminAiPromptsPage.jsx | 154 +++++++--- 9 files changed, 679 insertions(+), 66 deletions(-) create mode 100644 backend/ai_prompt_planning_preview.py create mode 100644 backend/planning_llm_usage.py create mode 100644 backend/tests/test_ai_prompt_planning_preview.py create mode 100644 backend/tests/test_planning_llm_usage.py diff --git a/backend/ai_prompt_planning_preview.py b/backend/ai_prompt_planning_preview.py new file mode 100644 index 0000000..a7188fb --- /dev/null +++ b/backend/ai_prompt_planning_preview.py @@ -0,0 +1,272 @@ +""" +Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest). + +Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB. +""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Mapping, Optional + +from pydantic import BaseModel, Field + +from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief +from planning_intent_context import build_planning_intent_context + +PLANNING_PROMPT_SLUGS = frozenset( + { + "planning_progression_start_target", + "planning_progression_goal_analysis", + "planning_progression_roadmap", + "planning_progression_stage_spec", + "planning_exercise_query_semantics", + "planning_exercise_path_qa", + "planning_exercise_search_intent", + "planning_exercise_search_rank", + "planning_exercise_expectation_profile", + } +) + + +class PlanningPromptPreviewInput(BaseModel): + goal_query: str = Field( + default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe", + max_length=2000, + ) + user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000) + max_steps: int = Field(default=5, ge=2, le=10) + search_query: Optional[str] = Field(default=None, max_length=2000) + + +def is_planning_prompt_slug(slug: str) -> bool: + return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS + + +def _compact_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _sample_goal_analysis() -> Dict[str, Any]: + return { + "primary_topic": "Mae Geri", + "start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt", + "target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung", + "success_criteria": [ + "Hüfte öffnet vor dem Kick", + "Ballen trifft Zielzone", + "Rückzug ohne Balanceverlust", + ], + "constraints": { + "partner_required": False, + "excluded_themes": ["reine Kraft ohne Technikbezug"], + "trainer_notes": "Breitensport, kein Wettkampf", + }, + } + + +def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]: + phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"] + titles = [ + "Grundstellung und Mae Geri Einstieg", + "Hüftöffnung und Ballen-Fokus", + "Koordination und Rückzug", + "Anwendung in Partnerübung", + "Qualität unter leichtem Druck", + ] + out: List[Dict[str, Any]] = [] + for i in range(max_steps): + out.append( + { + "index": i, + "phase": phases[min(i, len(phases) - 1)], + "title": titles[min(i, len(titles) - 1)], + "learning_goal": titles[min(i, len(titles) - 1)], + } + ) + return out + + +def _sample_path_steps() -> List[Dict[str, Any]]: + return [ + { + "index": 1, + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "goal": "Frontkick mit geöffneter Hüfte aus Grundstellung", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Stufen-Gate: Grundlagen"], + }, + { + "index": 2, + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "goal": "Präziser Ballentreffer mit kontrolliertem Rückzug", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Nachfolger im Graph"], + }, + ] + + +def _sample_planning_context() -> Dict[str, Any]: + return { + "scope": "progression_path", + "goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe", + "stage_index": 1, + "learning_goal": "Hüftöffnung und Ballen-Fokus", + } + + +def _sample_target_profile() -> Dict[str, Any]: + return { + "primary_focus": "Kihon", + "training_type": "Breitensport", + "skill_expectations": ["Geri Waza", "Koordination"], + } + + +def _sample_candidates() -> List[Dict[str, Any]]: + return [ + { + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "summary": "Frontkick mit Hüftöffnung", + "skill_names": ["Geri Waza"], + "score_hint": 0.82, + }, + { + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "summary": "Ballentreffer mit Rückzug", + "skill_names": ["Geri Waza", "Koordination"], + "score_hint": 0.76, + }, + ] + + +def _load_catalog_variables(cur) -> Dict[str, str]: + from planning_exercise_intent import ( + _load_compact_catalog, + _load_skills_catalog_compact, + ) + + return { + "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)), + "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")), + "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")), + "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")), + "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")), + } + + +def resolve_planning_prompt_preview_variables( + cur, + slug: str, + body: PlanningPromptPreviewInput, +) -> Dict[str, str]: + """Mustache-Variablen für Planungs-Prompt-Vorschau im Admin.""" + s = (slug or "").strip().lower() + if s not in PLANNING_PROMPT_SLUGS: + raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}") + + goal_query = (body.goal_query or "").strip() or "Mae Geri Progression" + search_query = (body.search_query or "").strip() or goal_query + max_steps = int(body.max_steps) + brief = build_semantic_brief(goal_query) + brief_json = _compact_json(brief_to_summary_dict(brief)) + goal_analysis = _sample_goal_analysis() + major_steps = _sample_major_steps(max_steps) + intent_ctx = build_planning_intent_context( + goal_query=goal_query, + goal_analysis=goal_analysis, + semantic_brief=brief, + extra_context=(body.user_notes or "").strip() or None, + ) + intent_ctx_json = _compact_json(intent_ctx.to_api_dict()) + ctx = _sample_planning_context() + target = _sample_target_profile() + catalogs = _load_catalog_variables(cur) + + if s == "planning_progression_start_target": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + "user_notes": (body.user_notes or "").strip(), + } + + if s == "planning_progression_goal_analysis": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + } + + if s == "planning_progression_roadmap": + return { + "goal_query": goal_query, + "goal_analysis_json": _compact_json(goal_analysis), + "semantic_brief_json": brief_json, + "max_steps": str(max_steps), + } + + if s == "planning_progression_stage_spec": + return { + "goal_query": goal_query, + "goal_analysis_json": _compact_json(goal_analysis), + "major_steps_json": _compact_json(major_steps), + "intent_context_json": intent_ctx_json, + "semantic_brief_json": brief_json, + } + + if s == "planning_exercise_query_semantics": + return { + "search_query": search_query, + "semantic_brief_json": brief_json, + } + + if s == "planning_exercise_path_qa": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + "steps_json": _compact_json(_sample_path_steps()), + "gaps_json": _compact_json([]), + "bridge_inserts_json": _compact_json([]), + } + + if s == "planning_exercise_search_intent": + return { + "search_query": search_query, + "heuristic_intent": "progression_next", + "scenario_hint": "preset_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **catalogs, + } + + if s == "planning_exercise_search_rank": + return { + "search_query": search_query, + "intent": "progression_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + "candidates_json": _compact_json(_sample_candidates()), + "result_limit": "5", + } + + if s == "planning_exercise_expectation_profile": + return { + "heuristic_intent": "suggest_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"}, + } + + raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}") + + +__all__ = [ + "PLANNING_PROMPT_SLUGS", + "PlanningPromptPreviewInput", + "is_planning_prompt_slug", + "resolve_planning_prompt_preview_variables", +] diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py index 8a7dd88..516b2dc 100644 --- a/backend/openrouter_chat.py +++ b/backend/openrouter_chat.py @@ -196,6 +196,13 @@ def openrouter_chat_completion( cc, ) + try: + from planning_llm_usage import record_planning_llm_call + + record_planning_llm_call(1) + except Exception: + pass + return joined diff --git a/backend/planning_llm_usage.py b/backend/planning_llm_usage.py new file mode 100644 index 0000000..567c3ba --- /dev/null +++ b/backend/planning_llm_usage.py @@ -0,0 +1,62 @@ +""" +Zähler für produktive OpenRouter-Aufrufe innerhalb einer Planungs-API-Anfrage. + +Wird per ContextVar gesetzt (Router: ``planning_llm_call_meter``); ``openrouter_chat_completion`` +erhöht den Zähler nach erfolgreicher Antwort — nur wenn ein Meter aktiv ist. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator, Optional + +_llm_call_counter: ContextVar[Optional["PlanningLlmCallCounter"]] = ContextVar( + "planning_llm_call_counter", + default=None, +) + + +class PlanningLlmCallCounter: + """Anzahl erfolgreicher OpenRouter-Chat-Completions in einem Request-Kontext.""" + + __slots__ = ("count",) + + def __init__(self) -> None: + self.count = 0 + + def record(self, amount: int = 1) -> None: + try: + n = int(amount) + except (TypeError, ValueError): + n = 1 + if n > 0: + self.count += n + + +def current_planning_llm_call_counter() -> Optional[PlanningLlmCallCounter]: + return _llm_call_counter.get() + + +def record_planning_llm_call(amount: int = 1) -> None: + counter = _llm_call_counter.get() + if counter is not None: + counter.record(amount) + + +@contextmanager +def planning_llm_call_meter() -> Iterator[PlanningLlmCallCounter]: + """Aktiviert LLM-Zählung für den umschlossenen Block (inkl. verschachtelter Aufrufe).""" + counter = PlanningLlmCallCounter() + token = _llm_call_counter.set(counter) + try: + yield counter + finally: + _llm_call_counter.reset(token) + + +__all__ = [ + "PlanningLlmCallCounter", + "current_planning_llm_call_counter", + "planning_llm_call_meter", + "record_planning_llm_call", +] diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py index 2a4e106..a051435 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -14,6 +14,11 @@ from auth import require_auth from club_tenancy import is_superadmin from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_job import resolve_exercise_form_variables +from ai_prompt_planning_preview import ( + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) from ai_prompt_runtime import render_ai_prompt_template_for_row from db import get_cursor, get_db, r2d from prompt_resolver import exercise_placeholder_catalog @@ -62,7 +67,12 @@ class AiPromptUpdateBody(BaseModel): class AiPromptPreviewBody(ExerciseFormAiPromptContext): - """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint).""" + """Preview-POST: Übungs-KI und Planungs-Prompts.""" + + goal_query: Optional[str] = Field(default=None, max_length=2000) + user_notes: Optional[str] = Field(default=None, max_length=2000) + max_steps: Optional[int] = Field(default=None, ge=2, le=10) + search_query: Optional[str] = Field(default=None, max_length=2000) @router.get("/api/admin/ai-prompts/catalog/placeholders") @@ -223,6 +233,17 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = vars_map = resolve_exercise_form_variables(cur, slug, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + elif is_planning_prompt_slug(slug): + planning_in = PlanningPromptPreviewInput( + goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(), + user_notes=(body.user_notes or "").strip(), + max_steps=body.max_steps if body.max_steps is not None else 5, + search_query=(body.search_query or body.goal_query or "").strip() or None, + ) + try: + vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e elif slug == "pipeline": vars_map = {} warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 8619cd6..1545b00 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,7 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from planning_llm_usage import planning_llm_call_meter from account_lifecycle import assert_min_account_state from capabilities import probe_capability from club_features import ( @@ -46,19 +47,25 @@ def post_planning_exercise_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_planning_exercises(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_planning_exercises(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="planning_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result @@ -97,17 +104,23 @@ def post_progression_path_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_progression_path(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_progression_path(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="progression_path_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result diff --git a/backend/tests/test_ai_prompt_planning_preview.py b/backend/tests/test_ai_prompt_planning_preview.py new file mode 100644 index 0000000..d5c31c1 --- /dev/null +++ b/backend/tests/test_ai_prompt_planning_preview.py @@ -0,0 +1,89 @@ +"""Admin-Vorschau für Planungs-Prompt-Slugs.""" +from unittest.mock import MagicMock, patch + +import pytest + +from ai_prompt_planning_preview import ( + PLANNING_PROMPT_SLUGS, + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) + + +def test_is_planning_prompt_slug(): + assert is_planning_prompt_slug("planning_progression_roadmap") + assert is_planning_prompt_slug("PLANNING_EXERCISE_PATH_QA") + assert not is_planning_prompt_slug("exercise_summary") + assert not is_planning_prompt_slug("") + + +def test_resolve_roadmap_preview_variables(): + body = PlanningPromptPreviewInput(goal_query="Mae Geri Basics", max_steps=4) + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_roadmap", + body, + ) + assert vars_map["goal_query"] == "Mae Geri Basics" + assert vars_map["max_steps"] == "4" + assert "goal_analysis_json" in vars_map + assert "semantic_brief_json" in vars_map + + +def test_resolve_stage_spec_includes_intent_context(): + body = PlanningPromptPreviewInput(user_notes="Breitensport") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_stage_spec", + body, + ) + assert "intent_context_json" in vars_map + assert "major_steps_json" in vars_map + + +@patch("ai_prompt_planning_preview._load_catalog_variables") +def test_resolve_search_intent_includes_catalogs(mock_catalog): + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + body = PlanningPromptPreviewInput(search_query="Mae Geri nächster Schritt") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_exercise_search_intent", + body, + ) + assert vars_map["search_query"] == "Mae Geri nächster Schritt" + assert vars_map["skills_catalog_json"] == "[]" + + +def test_non_planning_slug_raises(): + with pytest.raises(ValueError, match="Kein Planungs-Prompt-Slug"): + resolve_planning_prompt_preview_variables( + MagicMock(), + "exercise_summary", + PlanningPromptPreviewInput(), + ) + + +def test_all_registered_slugs_resolve(): + for slug in PLANNING_PROMPT_SLUGS: + with patch("ai_prompt_planning_preview._load_catalog_variables") as mock_catalog: + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + slug, + PlanningPromptPreviewInput(), + ) + assert isinstance(vars_map, dict) + assert len(vars_map) >= 1 diff --git a/backend/tests/test_planning_llm_usage.py b/backend/tests/test_planning_llm_usage.py new file mode 100644 index 0000000..03946ca --- /dev/null +++ b/backend/tests/test_planning_llm_usage.py @@ -0,0 +1,94 @@ +"""LLM-Zählung für Planungs-APIs (P1-C2).""" +from unittest.mock import MagicMock, patch + +import pytest + +from planning_llm_usage import ( + current_planning_llm_call_counter, + planning_llm_call_meter, + record_planning_llm_call, +) + + +def test_meter_inactive_by_default(): + assert current_planning_llm_call_counter() is None + record_planning_llm_call(3) + assert current_planning_llm_call_counter() is None + + +def test_meter_counts_within_scope(): + with planning_llm_call_meter() as meter: + record_planning_llm_call(1) + record_planning_llm_call(2) + assert meter.count == 3 + + +def test_openrouter_increments_active_meter(): + from openrouter_chat import openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 200 + fake_resp.json.return_value = { + "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], + } + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + out = openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert out == "ok" + assert meter.count == 1 + + +def test_openrouter_skips_meter_on_http_error(): + from openrouter_chat import OpenRouterError, openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 500 + fake_resp.json.return_value = {"error": {"message": "fail"}} + fake_resp.text = "fail" + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + with pytest.raises(OpenRouterError): + openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert meter.count == 0 + + +def test_uses_ai_gap_fill_not_counted_without_openrouter(): + """Regression: Gap-Fill-Flag allein löst keinen OpenRouter-Aufruf aus.""" + from planning_exercise_path_builder import ProgressionPathSuggestRequest + + body = ProgressionPathSuggestRequest( + query="Mae Geri Progression", + include_llm_intent=False, + include_llm_path_qa=False, + include_llm_roadmap=False, + include_llm_start_target=False, + include_ai_gap_fill=True, + evaluate_only=True, + evaluate_steps=[], + ) + uses_ai = ( + body.include_llm_intent + or body.include_llm_path_qa + or body.include_llm_roadmap + or body.include_llm_start_target + or (body.start_target_only and body.include_llm_start_target) + ) + assert uses_ai is False diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 4bb2b81..502c297 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -39,7 +39,6 @@ import { compareDiffsForDialog, dedupeGapOffersBySlot, draftHasLibrarySlotAssignments, - draftRetrievalBoostExerciseIds, EMPTY_PLANNING_CATALOG_CONTEXT, filterGapOffersForUnfilledSlots, hydrateProgressionGraphDraft, @@ -478,28 +477,6 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } - const buildMatchRequestBase = (synced) => { - const override = majorStepsToOverridePayload(synced.slots) - return { - query: (synced.goalQuery || '').trim(), - max_steps: synced.slots.length, - include_llm_intent: true, - include_path_qa: true, - include_llm_path_qa: true, - include_path_reorder: false, - include_ai_gap_fill: true, - include_roadmap_preview: true, - include_llm_roadmap: false, - roadmap_first: true, - roadmap_override: override, - slot_assignments: slotsToSlotAssignments(synced), - retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced), - progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), - ...catalogApiPayload, - } - } - const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') const baselineRes = await fetchPathEvaluate(synced) diff --git a/frontend/src/pages/AdminAiPromptsPage.jsx b/frontend/src/pages/AdminAiPromptsPage.jsx index 768a314..5a4779b 100644 --- a/frontend/src/pages/AdminAiPromptsPage.jsx +++ b/frontend/src/pages/AdminAiPromptsPage.jsx @@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() { const [pvExec, setPvExec] = useState('

Ablauf hier

') const [pvHint, setPvHint] = useState('') const [pvFocusId, setPvFocusId] = useState('') + const [pvGoalQuery, setPvGoalQuery] = useState( + 'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe' + ) + const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.') + const [pvMaxSteps, setPvMaxSteps] = useState('5') + const [pvSearchQuery, setPvSearchQuery] = useState('') const [pvPreview, setPvPreview] = useState(null) + const selectedSlug = (detail?.slug || '').trim().toLowerCase() + const isExercisePreviewSlug = [ + 'exercise_summary', + 'exercise_skill_suggestions', + 'exercise_instruction_rewrite', + ].includes(selectedSlug) + const isPlanningPreviewSlug = selectedSlug.startsWith('planning_') + const loadList = useCallback(async () => { const [pList, cat] = await Promise.all([ api.listAdminAiPrompts(), @@ -133,15 +147,23 @@ export default function AdminAiPromptsPage() { if (!detail?.id) return setError('') try { - const body = { - title: pvTitle, - goal: pvGoal, - execution: pvExec, - focus_hint: pvHint || undefined, - } - const fid = parseInt(String(pvFocusId).trim(), 10) - if (Number.isFinite(fid) && fid >= 1) { - body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + const body = {} + if (isPlanningPreviewSlug) { + body.goal_query = pvGoalQuery.trim() || undefined + body.user_notes = pvUserNotes.trim() || undefined + const ms = parseInt(String(pvMaxSteps).trim(), 10) + if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms + const sq = pvSearchQuery.trim() + if (sq) body.search_query = sq + } else if (isExercisePreviewSlug) { + body.title = pvTitle + body.goal = pvGoal + body.execution = pvExec + body.focus_hint = pvHint || undefined + const fid = parseInt(String(pvFocusId).trim(), 10) + if (Number.isFinite(fid) && fid >= 1) { + body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + } } const r = await api.previewAdminAiPrompt(detail.id, body) setPvPreview(r) @@ -171,8 +193,8 @@ export default function AdminAiPromptsPage() {

KI Prompts

- Datenbankvorlagen (ai_prompts) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig - aufgelöst — die Vorschau unten ruft kein externes Modell auf. + Datenbankvorlagen (ai_prompts) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden + serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.

{error ?

{error}

: null} @@ -301,33 +323,89 @@ export default function AdminAiPromptsPage() {

Vorschau (ohne OpenRouter)

-
-
- - setPvTitle(e.target.value)} /> -
-
- - setPvFocusId(e.target.value)} - /> -
-
-
- - setPvHint(e.target.value)} /> -
-
- -