feat: advance curriculum after training submit
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user