diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 58c08fe..7722146 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -23,6 +23,7 @@ from planning_path_rematch import ( prune_stripped_after_rematch, rematch_roadmap_slots, ) +from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target from planning_exercise_path_qa import ( apply_llm_path_reorder, @@ -108,6 +109,7 @@ class ProgressionPathSuggestRequest(BaseModel): include_llm_intent: bool = True include_path_qa: bool = True auto_rematch_after_qa: bool = True + auto_refine_stage_spec: bool = True max_rematch_rounds: int = Field(default=2, ge=0, le=3) include_llm_path_qa: bool = True include_path_reorder: bool = True @@ -1268,9 +1270,11 @@ def _run_roadmap_rematch_loop( List[Dict[str, Any]], int, List[Tuple[int, StageSpecArtifact]], + List[Dict[str, Any]], ]: - """Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints.""" + """Phase A/B/C: Rematch-Schleife mit optionaler Stufen-Spec-Verfeinerung.""" rematch_log: List[Dict[str, Any]] = [] + refine_log: List[Dict[str, Any]] = [] rematch_rounds = 0 max_rounds = int(body.max_rematch_rounds or 0) if not body.auto_rematch_after_qa or max_rounds <= 0 or not roadmap_ctx.stage_specs: @@ -1280,7 +1284,15 @@ def _run_roadmap_rematch_loop( brief=semantic_brief, goal_query=goal_query, ) - return steps, rematch_log, stripped_off_topic, off_topic_steps, rematch_rounds, roadmap_unfilled + return ( + steps, + rematch_log, + stripped_off_topic, + off_topic_steps, + rematch_rounds, + roadmap_unfilled, + refine_log, + ) current_stripped = list(stripped_off_topic or []) use_initial_off_topic = not current_stripped @@ -1297,6 +1309,20 @@ def _run_roadmap_rematch_loop( ) optimization_hints = list(mini_qa.get("optimization_hints") or []) + if body.auto_refine_stage_spec: + _, round_refine = apply_stage_spec_refinements( + roadmap_ctx, + optimization_hints=optimization_hints, + off_topic_steps=off_topic_steps if round_idx > 0 else off_topic_before_strip, + goal_query=goal_query, + semantic_brief=semantic_brief, + ) + if round_refine: + for entry in round_refine: + tagged = dict(entry) + tagged["round"] = rematch_rounds + 1 + refine_log.append(tagged) + slot_indices, rematch_reasons = collect_rematch_slot_indices( stripped_off_topic=current_stripped if round_idx == 0 else [], off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [], @@ -1304,6 +1330,16 @@ def _run_roadmap_rematch_loop( stage_specs=roadmap_ctx.stage_specs, roadmap_unfilled=roadmap_unfilled, ) + if body.auto_refine_stage_spec: + refine_targets = collect_refine_stage_targets( + optimization_hints=optimization_hints, + off_topic_steps=off_topic_steps if round_idx > 0 else off_topic_before_strip, + stage_specs=roadmap_ctx.stage_specs, + ) + for midx in refine_targets: + slot_indices.add(int(midx)) + if int(midx) not in rematch_reasons: + rematch_reasons[int(midx)] = "refine_stage_spec" if not slot_indices: break @@ -1358,6 +1394,7 @@ def _run_roadmap_rematch_loop( off_topic_steps, rematch_rounds, roadmap_unfilled, + refine_log, ) @@ -1967,6 +2004,7 @@ def suggest_progression_path( off_topic_steps: List[Dict[str, Any]] = [] stripped_off_topic: List[Dict[str, Any]] = [] rematch_log: List[Dict[str, Any]] = [] + refine_log: List[Dict[str, Any]] = [] rematch_rounds = 0 llm_qa: Optional[Dict[str, Any]] = None llm_qa_applied = False @@ -2062,6 +2100,7 @@ def suggest_progression_path( rematch_off_topic, rematch_rounds, roadmap_unfilled, + refine_log, ) = _run_roadmap_rematch_loop( cur, tenant=tenant, @@ -2162,6 +2201,10 @@ def suggest_progression_path( path_qa["rematch_applied"] = True path_qa["rematch_log"] = rematch_log path_qa["rematch_rounds"] = rematch_rounds + if refine_log: + path_qa["refine_applied"] = True + path_qa["refine_log"] = refine_log + path_qa["refine_count"] = len(refine_log) if roadmap_first and roadmap_ctx is not None: steps = _normalize_roadmap_steps_coverage( @@ -2261,6 +2304,8 @@ def suggest_progression_path( retrieval_parts.append("roadmap_unfilled") if rematch_log: retrieval_parts.append("path_rematch") + if refine_log: + retrieval_parts.append("stage_spec_refine") return { "goal_query": goal_query, diff --git a/backend/planning_path_refine_stage.py b/backend/planning_path_refine_stage.py new file mode 100644 index 0000000..04ee7dd --- /dev/null +++ b/backend/planning_path_refine_stage.py @@ -0,0 +1,233 @@ +""" +Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch. + +Deterministisch — keine LLM-Ratelosigkeit. Schärft anti_patterns / success_criteria +aus QS-Finding, schließt abgelehnte Übung aus, übernimmt Pfad-Ausschlüsse. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from planning_exercise_semantics import ( + PlanningSemanticBrief, + build_stage_match_brief, + parse_stage_goal_constraints, + resolve_path_anti_patterns, +) +from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact + + +def _resolve_major_index( + item: Mapping[str, Any], + stage_specs: Sequence[StageSpecArtifact], +) -> Optional[int]: + raw = item.get("roadmap_major_step_index") + if raw is not None: + return int(raw) + si = item.get("step_index") + if si is not None: + pos = int(si) + specs = list(stage_specs or []) + if 0 <= pos < len(specs): + return int(specs[pos].major_step_index) + return None + + +def collect_refine_stage_targets( + *, + optimization_hints: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], + stage_specs: Sequence[StageSpecArtifact], +) -> Dict[int, Mapping[str, Any]]: + """Major-Step-Indizes mit stage_mismatch / refine_stage_spec + Quell-Finding.""" + targets: Dict[int, Mapping[str, Any]] = {} + + def _register(midx: int, source: Mapping[str, Any]) -> None: + if midx not in targets: + targets[int(midx)] = dict(source) + + for hint in optimization_hints or []: + if not isinstance(hint, dict): + continue + if str(hint.get("action") or "") != "refine_stage_spec": + continue + midx = _resolve_major_index(hint, stage_specs) + if midx is not None: + _register(midx, hint) + + for item in off_topic_steps or []: + if not isinstance(item, dict): + continue + if str(item.get("issue") or "") != "stage_mismatch": + continue + midx = _resolve_major_index(item, stage_specs) + if midx is not None: + _register(midx, item) + + return targets + + +def _append_unique_strings(dest: List[str], items: Sequence[str], *, limit: int = 14) -> List[str]: + out = list(dest or []) + for raw in items: + s = str(raw or "").strip() + if not s or s in out: + continue + out.append(s[:200]) + if len(out) >= limit: + break + return out + + +def refine_stage_spec_artifact( + spec: StageSpecArtifact, + *, + finding: Mapping[str, Any], + goal_query: str, + semantic_brief: Optional[PlanningSemanticBrief] = None, + path_anti_patterns: Optional[Sequence[str]] = None, +) -> Tuple[StageSpecArtifact, List[str]]: + """ + Schärft eine StageSpec aus QS-Finding. Returns (neue Spec, Änderungsliste). + """ + learning_goal = ( + str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip() + or spec.learning_goal + ) + anti = list(spec.anti_patterns or []) + success = list(spec.success_criteria or []) + changes: List[str] = [] + + rejected_title = str(finding.get("title") or "").strip() + if rejected_title: + marker = f"keine Übung wie „{rejected_title[:120]}“" + if marker not in anti: + anti.append(marker) + changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}") + + path_anti = list(path_anti_patterns or []) + if not path_anti and semantic_brief is not None: + path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief) + merged_anti = _append_unique_strings(anti, path_anti) + if len(merged_anti) > len(anti): + changes.append("Pfad-Ausschlüsse in Stufen-anti_patterns übernommen") + anti = merged_anti + + constraints = parse_stage_goal_constraints(learning_goal, anti) + for phrase in constraints.exclude_phrases or []: + if phrase and phrase not in anti: + anti.append(phrase) + changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}") + + stage_brief = build_stage_match_brief( + learning_goal=learning_goal, + anti_patterns=anti, + success_criteria=list(spec.success_criteria or []), + load_profile=list(spec.load_profile or []), + ) + for phrase in (stage_brief.must_phrases or [])[:4]: + p = str(phrase or "").strip() + if len(p) < 4: + continue + crit = f"Bezug zu Stufen-Lernziel: {p[:100]}" + if crit not in success: + success.append(crit) + changes.append(f"Erfolgskriterium: {p[:60]}") + + for raw in finding.get("reasons") or []: + r = str(raw or "").strip() + if len(r) < 8: + continue + crit = f"QS-Hinweis: {r[:120]}" + if crit not in success: + success.append(crit) + if len(changes) < 6: + changes.append(f"Kriterium aus QS: {r[:60]}") + if len(success) >= 8: + break + + if not changes: + return spec, [] + + refined = StageSpecArtifact( + major_step_index=spec.major_step_index, + learning_goal=learning_goal or spec.learning_goal, + start_state=spec.start_state, + target_state=spec.target_state, + load_profile=list(spec.load_profile or []), + exercise_type=spec.exercise_type, + success_criteria=success[:8], + anti_patterns=anti[:14], + ) + return refined, changes + + +def apply_stage_spec_refinements( + roadmap_ctx: ProgressionRoadmapContext, + *, + optimization_hints: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], + goal_query: str, + semantic_brief: Optional[PlanningSemanticBrief] = None, +) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]: + """ + Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx). + + Returns: (stage_specs, refine_log) + """ + stage_specs = list(roadmap_ctx.stage_specs or []) + if not stage_specs: + return stage_specs, [] + + targets = collect_refine_stage_targets( + optimization_hints=optimization_hints, + off_topic_steps=off_topic_steps, + stage_specs=stage_specs, + ) + if not targets: + return stage_specs, [] + + path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief) + spec_by_major = {int(s.major_step_index): s for s in stage_specs} + refine_log: List[Dict[str, Any]] = [] + refined_majors: Set[int] = set() + + for midx in sorted(targets): + spec = spec_by_major.get(int(midx)) + if spec is None: + continue + refined_spec, changes = refine_stage_spec_artifact( + spec, + finding=targets[midx], + goal_query=goal_query, + semantic_brief=semantic_brief, + path_anti_patterns=path_anti, + ) + if not changes: + continue + spec_by_major[int(midx)] = refined_spec + refined_majors.add(int(midx)) + refine_log.append( + { + "roadmap_major_step_index": int(midx), + "action": "refined", + "issue": "stage_mismatch", + "rejected_title": targets[midx].get("title"), + "changes": changes[:6], + "reason": (changes[0] if changes else "refine_stage_spec")[:400], + } + ) + + if not refine_log: + return stage_specs, [] + + ordered = [spec_by_major[int(s.major_step_index)] for s in stage_specs] + roadmap_ctx.stage_specs = ordered + return ordered, refine_log + + +__all__ = [ + "apply_stage_spec_refinements", + "collect_refine_stage_targets", + "refine_stage_spec_artifact", +] diff --git a/backend/tests/test_planning_path_refine_stage.py b/backend/tests/test_planning_path_refine_stage.py new file mode 100644 index 0000000..529e278 --- /dev/null +++ b/backend/tests/test_planning_path_refine_stage.py @@ -0,0 +1,105 @@ +"""Tests Phase C — refine_stage_spec nach stage_mismatch.""" +from planning_exercise_semantics import build_semantic_brief +from planning_path_refine_stage import ( + apply_stage_spec_refinements, + collect_refine_stage_targets, + refine_stage_spec_artifact, +) +from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact + + +def _spec(major=1, goal="Koordination Absprung ohne Tritttechnik"): + return StageSpecArtifact( + major_step_index=major, + learning_goal=goal, + load_profile=["koordination"], + exercise_type="kihon_einzel", + ) + + +def test_collect_refine_stage_targets_from_hint_and_off_topic(): + specs = [_spec(0, "A"), _spec(1, "B"), _spec(2, "C")] + hints = [ + { + "action": "refine_stage_spec", + "roadmap_major_step_index": 1, + "reason": "Passt nicht zum Stufen-Lernziel", + } + ] + off_topic = [ + { + "issue": "stage_mismatch", + "step_index": 2, + "roadmap_major_step_index": 2, + "title": "Kumite Drill", + } + ] + targets = collect_refine_stage_targets( + optimization_hints=hints, + off_topic_steps=off_topic, + stage_specs=specs, + ) + assert targets.keys() == {1, 2} + + +def test_refine_stage_spec_adds_rejected_title_and_criteria(): + spec = _spec() + finding = { + "title": "Mawashi Trittpräzision", + "roadmap_learning_goal": spec.learning_goal, + "reasons": ["Semantik zu schwach für Stufen-Lernziel"], + } + brief = build_semantic_brief("Mawashi Geri Kumite") + refined, changes = refine_stage_spec_artifact( + spec, + finding=finding, + goal_query="Mawashi Geri ohne Kumite", + semantic_brief=brief, + ) + assert changes + assert any("Mawashi Trittpräzision" in a for a in refined.anti_patterns) + assert refined.success_criteria + assert refined.anti_patterns != spec.anti_patterns or refined.success_criteria != spec.success_criteria + + +def test_apply_stage_spec_refinements_mutates_context(): + specs = [_spec(0, "Stand"), _spec(1, "Sprungkoordination")] + ctx = ProgressionRoadmapContext( + goal_query="Mawashi Geri", + max_steps=2, + stage_specs=specs, + ) + _, log = apply_stage_spec_refinements( + ctx, + optimization_hints=[], + off_topic_steps=[ + { + "issue": "stage_mismatch", + "roadmap_major_step_index": 1, + "title": "Yoko Geri", + "roadmap_learning_goal": "Sprungkoordination", + } + ], + goal_query="Mawashi Geri", + semantic_brief=build_semantic_brief("Mawashi Geri"), + ) + assert len(log) == 1 + assert log[0]["action"] == "refined" + assert ctx.stage_specs[1].anti_patterns + assert any("Yoko Geri" in a for a in ctx.stage_specs[1].anti_patterns) + + +def test_refine_no_op_when_no_finding_data(): + spec = StageSpecArtifact( + major_step_index=1, + learning_goal="", + load_profile=[], + exercise_type="kihon_einzel", + ) + refined, changes = refine_stage_spec_artifact( + spec, + finding={"issue": "stage_mismatch"}, + goal_query="x", + ) + assert changes == [] + assert refined is spec diff --git a/backend/version.py b/backend/version.py index b09870e..83b0284 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.226" +APP_VERSION = "0.8.227" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260607090" @@ -38,7 +38,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume - "planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled + "planning_exercise_suggest": "0.23.2", # Phase C: refine_stage_spec bei stage_mismatch vor Rematch "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 53c3df7..e733693 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -8,6 +8,7 @@ import { offerSourceLabel, optimizationHintActionLabel, formatRematchLogEntry, + formatRefineLogEntry, hasRematchSlotHints, resolveHintSlotIndex, resolveOfferSlotIndex, @@ -165,6 +166,7 @@ export default function ProgressionFindingsPanel({ }) { const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] + const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' return ( @@ -246,6 +248,20 @@ export default function ProgressionFindingsPanel({ ) : null} + {pathQa.refine_applied && refineLog.length > 0 ? ( + <> +

+ Stufen-Spec verfeinert ({refineLog.length}) +

+ + + ) : null} {optimizationHints.length > 0 ? ( <>

diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 9aa5ea0..c14de11 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -446,6 +446,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa `Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`, ) } + const refineLog = res?.path_qa?.refine_log + if (Array.isArray(refineLog) && refineLog.length > 0) { + parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`) + } setMatchNotice(parts.join(' ')) } try { diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 19d1fe7..81f7983 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -102,6 +102,16 @@ export function formatRematchLogEntry(entry) { return `${slot}${round}: ${entry.reason || entry.action || 'Rematch'}` } +export function formatRefineLogEntry(entry) { + if (!entry || typeof entry !== 'object') return '' + const slot = Number.isFinite(Number(entry.roadmap_major_step_index)) + ? `Slot ${Number(entry.roadmap_major_step_index) + 1}` + : 'Slot' + const round = entry.round != null ? ` (Runde ${entry.round})` : '' + const changes = Array.isArray(entry.changes) ? entry.changes.join('; ') : entry.reason + return `${slot}${round}: Stufen-Spec geschärft — ${changes || 'refine_stage_spec'}` +} + export function hasRematchSlotHints(pathQa) { return (pathQa?.optimization_hints || []).some((h) => h?.action === 'rematch_slot') }