feat: advance curriculum after training submit

This commit is contained in:
yuqei
2026-04-30 00:35:15 +08:00
parent 8377bd06fa
commit 2ac0cd233a
6 changed files with 204 additions and 0 deletions
@@ -582,6 +582,22 @@ Invalidation:20
3. 推荐系统能通过 `mss / displacement / structure_shift` 弱点定位到该案例类型。
4. 案例仍然隐藏未来,提交前不泄露 `answer_key`。
### M14:提交后阶段推进闭环
完成:
1. `POST /training/attempts/{id}/submit` 返回最新 `curriculum`。
2. submit 后根据最新阶段状态计算 `next_drill`。
3. 前端 Score 面板展示下一阶段/下一题,并可一键开始下一轮训练。
4. 阶段路径在评分后立即刷新,读者不需要手动重新加载 Path。
验收:
1. 提交评分后能看到最新 `active_stage_id`。
2. 达标后可从 Foundation 自动推进到 Sweep/FVG 阶段。
3. `next_drill.case.answer_key` 继续隐藏。
4. 下一题启动仍通过 curriculum path 创建 attempt,并写入 stage meta。
## 10. 第一版验收标准
第一版完成时必须满足:
@@ -599,6 +615,7 @@ Invalidation:20
11. 能从阶段路径一键开始训练 attempt。
12. 提交后能获得面向下一步训练的规则教练反馈。
13. 高阶阶段能训练 MSS + Displacement 的结构接管判断。
14. 每次提交后能自动刷新阶段路径并给出下一题入口。
## 11. 推荐立即开始的第一步
@@ -857,3 +857,38 @@ browser.visible_cases=4
- Training 继续只读消费 `structure_events / displacement_events`。
- Training 标注、评分、进度不写回 `trade_signals`、`trade_executions`、回测或实盘链路。
## Training M14:提交后阶段推进闭环(2026-04-30)
本轮目标:
- 把“做题 -> 评分 -> 看反馈”推进成“做题 -> 评分 -> 自动刷新阶段 -> 下一题继续”。
- 让低阶到高阶路径不只停留在 Path 面板,而是在每次 submit 后真正驱动下一轮训练。
实现内容:
- `submit_attempt()` 评分写入后立即重新计算 curriculum。
- submit payload 新增 `curriculum` 与 `next_drill`。
- `next_drill` 指向当前 active stage 的第一条推荐案例,并继续隐藏 `answer_key`。
- Training Lab Score 面板展示下一阶段/下一题入口,可直接启动下一轮 stage drill。
自动化验收命令:
```text
python3 -m unittest tests/unit/test_training_module.py tests/unit/test_runtime_script_json.py tests/unit/test_api_static_server.py
python3 -m py_compile src/api/training.py src/api/server.py src/services/training/*.py scripts/run_training_cases.py
node --check src/web/training/training.js
python3 -m unittest discover -s tests/unit -p "test_*.py"
python3 scripts/run_migration_static_checks.py
python3 scripts/run_script_static_checks.py
```
本轮真实链路结果:
```text
http_submit.curriculum.active_stage_id=foundation_range
http_submit.next_drill.stage_id=foundation_range
http_submit.next_drill.case_type=range
http_submit.next_drill.case.answer_key=null
browser.error_logs=0
```
+30
View File
@@ -158,6 +158,11 @@ class TrainingAPI:
scores=grading.scores,
stage_id=(attempt.get("meta") or {}).get("stage_id"),
)
curriculum = self.get_curriculum(
user_id=submitted.get("user_id") or attempt.get("user_id", "local"),
instrument_id=case.get("instrument_id"),
limit_per_stage=2,
)
return {
"attempt": submitted,
"annotations": self.repository.list_annotations(attempt_id),
@@ -166,6 +171,8 @@ class TrainingAPI:
"max_score": grading.max_score,
"coach": coach,
"replay": replay.__dict__,
"curriculum": curriculum,
"next_drill": _next_curriculum_drill(curriculum),
}
def get_progress(self, user_id: str = "local", limit: int = 200) -> dict:
@@ -311,6 +318,29 @@ def _find_curriculum_stage(curriculum: dict, stage_id: Optional[str] = None) ->
return None
def _next_curriculum_drill(curriculum: dict) -> Optional[dict]:
active_stage = _find_curriculum_stage(curriculum=curriculum)
candidate_stages = []
if active_stage:
candidate_stages.append(active_stage)
for stage in curriculum.get("stages", []):
if stage is active_stage:
continue
if stage.get("status") in {"open", "in_progress"}:
candidate_stages.append(stage)
for stage in candidate_stages:
recommended_cases = stage.get("recommended_cases") or []
if recommended_cases:
return {
"stage_id": stage.get("stage_id"),
"stage_title": stage.get("title"),
"stage_status": stage.get("status"),
"case": recommended_cases[0],
}
return None
def _case_types_for_weakness_tags(weakness_tags: list[str]) -> list[str]:
case_types = []
for tag in weakness_tags:
+12
View File
@@ -296,6 +296,18 @@ button:disabled {
color: #aab6ba;
margin-top: 5px;
}
.next-drill {
border: 1px solid #1f8a70;
border-radius: 6px;
background: #10201d;
padding: 8px;
margin-bottom: 8px;
}
.next-drill-label {
color: #dce5e8;
font-size: 12px;
margin-bottom: 6px;
}
@media (max-width: 1120px) {
.workspace {
grid-template-columns: 1fr;
+21
View File
@@ -316,6 +316,10 @@ async function submitAttempt() {
const payload = await postJson(`/training/attempts/${state.attempt.id}/submit`, { reveal_answer: true });
state.attempt = payload.attempt;
state.replay = payload.replay;
if (payload.curriculum) {
state.curriculum = payload.curriculum;
renderCurriculum(payload.curriculum);
}
renderScores(payload);
await loadProgress();
renderReplay();
@@ -399,6 +403,23 @@ function renderScores(payload) {
el.scorePanel.appendChild(coach);
}
if (payload.next_drill?.case) {
const next = document.createElement('div');
next.className = 'next-drill';
const label = document.createElement('div');
label.className = 'next-drill-label';
label.textContent = `${payload.next_drill.stage_title || payload.next_drill.stage_id} · #${payload.next_drill.case.id} ${payload.next_drill.case.case_type}`;
const button = document.createElement('button');
button.className = 'stage-drill primary';
button.textContent = 'Start next drill';
button.addEventListener('click', () => startCurriculumDrill(payload.next_drill.stage_id));
next.append(label, button);
el.scorePanel.appendChild(next);
}
(payload.scores || []).forEach((score) => {
const row = document.createElement('div');
row.className = 'score-row';
+89
View File
@@ -639,6 +639,17 @@ class TrainingApiAnswerHidingTests(unittest.TestCase):
self.assertEqual(repo.attempts[0].meta["stage_id"], "foundation_range")
self.assertEqual(payload["replay"]["frame_index"], 1)
def test_submit_attempt_returns_refreshed_curriculum_and_next_drill(self) -> None:
api = TrainingAPI(repository=StubSubmitFollowupRepository())
payload = api.submit_attempt(91)
self.assertEqual(payload["curriculum"]["active_stage_id"], "liquidity_sweep")
self.assertEqual(payload["next_drill"]["stage_id"], "liquidity_sweep")
self.assertEqual(payload["next_drill"]["case"]["case_type"], "sweep")
self.assertEqual(payload["next_drill"]["case"]["answer_key"], None)
self.assertEqual(payload["total_score"], 60)
class StubTrainingRepository:
def __init__(self) -> None:
@@ -1010,6 +1021,84 @@ class StubCurriculumDrillRepository:
}
class StubSubmitFollowupRepository:
def __init__(self) -> None:
self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc)
self.scores = []
self.submitted_total_score = None
def fetch_attempt(self, attempt_id):
return {
"id": attempt_id,
"case_id": 31,
"user_id": "local",
"current_frame_index": 1,
"status": "submitted" if self.submitted_total_score is not None else "started",
"total_score": self.submitted_total_score,
"meta": {"stage_id": "foundation_range"},
}
def fetch_case(self, case_id):
return {
"id": case_id,
"instrument_id": 1,
"timeframe": "1m",
"case_type": "range",
"decision_ts": self.ts.replace(minute=1),
"skill_tags": ["dealing_range", "equilibrium"],
"answer_key": {"low": 90.0, "high": 110.0, "equilibrium": 100.0},
}
def list_annotations(self, attempt_id):
return [
{"annotation_type": "dealing_range", "payload": {"price_low": 90.0, "price_high": 110.0}},
{"annotation_type": "equilibrium", "payload": {"price": 100.0}},
{"annotation_type": "free_text", "payload": {"text": "wait for discount"}},
]
def upsert_score(self, record):
self.scores.append(record)
return len(self.scores)
def submit_attempt(self, attempt_id, total_score):
self.submitted_total_score = total_score
return None
def fetch_case_candles(self, case_id):
return [
{"ts_open": self.ts, "open": 100, "high": 101, "low": 99, "close": 100},
{"ts_open": self.ts.replace(minute=1), "open": 100, "high": 102, "low": 98, "close": 101},
]
def fetch_progress_attempts(self, user_id="local", limit=500):
return [
{"case_type": "range", "skill_tags": ["dealing_range"], "total_score": 50, "max_score": 60},
{"case_type": "range", "skill_tags": ["equilibrium"], "total_score": 48, "max_score": 60},
{"case_type": "range", "skill_tags": ["location"], "total_score": 45, "max_score": 60},
]
def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50):
if case_type == "sweep":
return [
{
"id": 32,
"case_type": "sweep",
"skill_tags": ["liquidity", "sweep"],
"answer_key": {"hidden": True},
}
]
if case_type == "fvg":
return [
{
"id": 33,
"case_type": "fvg",
"skill_tags": ["pd_array", "fvg"],
"answer_key": {"hidden": True},
}
]
return []
class StubGradingRepository:
def __init__(self) -> None:
self.scores = []