From 2ac0cd233abd8f6805f0c1d8b5ba83a28ebdf677 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 30 Apr 2026 00:35:15 +0800 Subject: [PATCH] feat: advance curriculum after training submit --- ...AI_ICT_Training_Case_Engine_开发方案_v1.md | 17 ++++ docs/AI_ICT_离线自动化推进记录_2026-04-29.md | 35 ++++++++ src/api/training.py | 30 +++++++ src/web/training/training.css | 12 +++ src/web/training/training.js | 21 +++++ tests/unit/test_training_module.py | 89 +++++++++++++++++++ 6 files changed, 204 insertions(+) diff --git a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md index 4411d1c..cc13949 100644 --- a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md +++ b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md @@ -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. 推荐立即开始的第一步 diff --git a/docs/AI_ICT_离线自动化推进记录_2026-04-29.md b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md index d491957..aca080c 100644 --- a/docs/AI_ICT_离线自动化推进记录_2026-04-29.md +++ b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md @@ -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 +``` diff --git a/src/api/training.py b/src/api/training.py index a146134..1d18d22 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -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: diff --git a/src/web/training/training.css b/src/web/training/training.css index 73bed5d..2f6cd56 100644 --- a/src/web/training/training.css +++ b/src/web/training/training.css @@ -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; diff --git a/src/web/training/training.js b/src/web/training/training.js index b71b57c..5577ce0 100644 --- a/src/web/training/training.js +++ b/src/web/training/training.js @@ -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'; diff --git a/tests/unit/test_training_module.py b/tests/unit/test_training_module.py index ca1007e..2c0214b 100644 --- a/tests/unit/test_training_module.py +++ b/tests/unit/test_training_module.py @@ -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 = []