Enhance Progression Path Suggestion with Stage Specification Refinement
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s

- Introduced `auto_refine_stage_spec` to `ProgressionPathSuggestRequest`, enabling optional refinement of stage specifications during the rematch process.
- Updated `_run_roadmap_rematch_loop` to incorporate stage specification refinements, logging changes for better tracking of adjustments made during rematching.
- Enhanced `suggest_progression_path` to include refine logs in the output, providing clearer insights into the refinement process.
- Added utility functions for formatting refine log entries, improving the display of refinement actions in the frontend components.
- Updated frontend components to display refine logs, enhancing user feedback on stage specification adjustments during progression analysis.
- Bumped version to 0.8.227 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-11 21:43:45 +02:00
parent de9fdf3ac0
commit f36a747efa
7 changed files with 417 additions and 4 deletions

View File

@ -23,6 +23,7 @@ from planning_path_rematch import (
prune_stripped_after_rematch, prune_stripped_after_rematch,
rematch_roadmap_slots, 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_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import ( from planning_exercise_path_qa import (
apply_llm_path_reorder, apply_llm_path_reorder,
@ -108,6 +109,7 @@ class ProgressionPathSuggestRequest(BaseModel):
include_llm_intent: bool = True include_llm_intent: bool = True
include_path_qa: bool = True include_path_qa: bool = True
auto_rematch_after_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) max_rematch_rounds: int = Field(default=2, ge=0, le=3)
include_llm_path_qa: bool = True include_llm_path_qa: bool = True
include_path_reorder: bool = True include_path_reorder: bool = True
@ -1268,9 +1270,11 @@ def _run_roadmap_rematch_loop(
List[Dict[str, Any]], List[Dict[str, Any]],
int, int,
List[Tuple[int, StageSpecArtifact]], 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]] = [] rematch_log: List[Dict[str, Any]] = []
refine_log: List[Dict[str, Any]] = []
rematch_rounds = 0 rematch_rounds = 0
max_rounds = int(body.max_rematch_rounds or 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: 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, brief=semantic_brief,
goal_query=goal_query, 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 []) current_stripped = list(stripped_off_topic or [])
use_initial_off_topic = not current_stripped 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 []) 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( slot_indices, rematch_reasons = collect_rematch_slot_indices(
stripped_off_topic=current_stripped if round_idx == 0 else [], 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 [], 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, stage_specs=roadmap_ctx.stage_specs,
roadmap_unfilled=roadmap_unfilled, 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: if not slot_indices:
break break
@ -1358,6 +1394,7 @@ def _run_roadmap_rematch_loop(
off_topic_steps, off_topic_steps,
rematch_rounds, rematch_rounds,
roadmap_unfilled, roadmap_unfilled,
refine_log,
) )
@ -1967,6 +2004,7 @@ def suggest_progression_path(
off_topic_steps: List[Dict[str, Any]] = [] off_topic_steps: List[Dict[str, Any]] = []
stripped_off_topic: List[Dict[str, Any]] = [] stripped_off_topic: List[Dict[str, Any]] = []
rematch_log: List[Dict[str, Any]] = [] rematch_log: List[Dict[str, Any]] = []
refine_log: List[Dict[str, Any]] = []
rematch_rounds = 0 rematch_rounds = 0
llm_qa: Optional[Dict[str, Any]] = None llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False llm_qa_applied = False
@ -2062,6 +2100,7 @@ def suggest_progression_path(
rematch_off_topic, rematch_off_topic,
rematch_rounds, rematch_rounds,
roadmap_unfilled, roadmap_unfilled,
refine_log,
) = _run_roadmap_rematch_loop( ) = _run_roadmap_rematch_loop(
cur, cur,
tenant=tenant, tenant=tenant,
@ -2162,6 +2201,10 @@ def suggest_progression_path(
path_qa["rematch_applied"] = True path_qa["rematch_applied"] = True
path_qa["rematch_log"] = rematch_log path_qa["rematch_log"] = rematch_log
path_qa["rematch_rounds"] = rematch_rounds 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: if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage( steps = _normalize_roadmap_steps_coverage(
@ -2261,6 +2304,8 @@ def suggest_progression_path(
retrieval_parts.append("roadmap_unfilled") retrieval_parts.append("roadmap_unfilled")
if rematch_log: if rematch_log:
retrieval_parts.append("path_rematch") retrieval_parts.append("path_rematch")
if refine_log:
retrieval_parts.append("stage_spec_refine")
return { return {
"goal_query": goal_query, "goal_query": goal_query,

View File

@ -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",
]

View File

@ -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

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.226" APP_VERSION = "0.8.227"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume "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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung

View File

@ -8,6 +8,7 @@ import {
offerSourceLabel, offerSourceLabel,
optimizationHintActionLabel, optimizationHintActionLabel,
formatRematchLogEntry, formatRematchLogEntry,
formatRefineLogEntry,
hasRematchSlotHints, hasRematchSlotHints,
resolveHintSlotIndex, resolveHintSlotIndex,
resolveOfferSlotIndex, resolveOfferSlotIndex,
@ -165,6 +166,7 @@ export default function ProgressionFindingsPanel({
}) { }) {
const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] 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' const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
return ( return (
@ -246,6 +248,20 @@ export default function ProgressionFindingsPanel({
</ul> </ul>
</> </>
) : null} ) : null}
{pathQa.refine_applied && refineLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Stufen-Spec verfeinert ({refineLog.length})
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{refineLog.map((entry, i) => (
<li key={`refine-${i}-${entry.roadmap_major_step_index}`}>
{formatRefineLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{optimizationHints.length > 0 ? ( {optimizationHints.length > 0 ? (
<> <>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}> <p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>

View File

@ -446,6 +446,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`, `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(' ')) setMatchNotice(parts.join(' '))
} }
try { try {

View File

@ -102,6 +102,16 @@ export function formatRematchLogEntry(entry) {
return `${slot}${round}: ${entry.reason || entry.action || 'Rematch'}` 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) { export function hasRematchSlotHints(pathQa) {
return (pathQa?.optimization_hints || []).some((h) => h?.action === 'rematch_slot') return (pathQa?.optimization_hints || []).some((h) => h?.action === 'rematch_slot')
} }