diff --git a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md index 18f7937..f891de5 100644 --- a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md +++ b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md @@ -661,6 +661,21 @@ Invalidation:20 3. started attempt 显示 status。 4. history 仅按 `user_id` 读取,不影响其他用户进度。 +### M19:Attempt 复盘详情 + +完成: + +1. `GET /training/attempts/{id}` 返回 attempt、隐藏答案的 case、annotations、scores、replay。 +2. graded/submitted attempt 的 replay 可 reveal answer,started attempt 继续隐藏答案。 +3. Training Lab 点击 Recent attempts 后进入 attempt review。 +4. Score 面板展示历史分项得分。 + +验收: + +1. attempt detail 的 `case.answer_key` 始终隐藏。 +2. 只有复盘 replay 在已提交/已评分后显示答案。 +3. 历史记录点击后能恢复当时 frame index。 + ## 10. 第一版验收标准 第一版完成时必须满足: @@ -683,6 +698,7 @@ Invalidation:20 16. 高阶 MSS 答案能在图表上直观看到结构打破价位。 17. 每次提交后能明确展示阶段是否推进、掌握或解锁。 18. 读者能回看最近训练记录与分数。 +19. 读者能打开历史 attempt 复盘当时的标注、得分与答案图层。 ## 11. 推荐立即开始的第一步 diff --git a/src/api/training.py b/src/api/training.py index 798498a..0cb50cb 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -108,7 +108,23 @@ class TrainingAPI: attempt = self.repository.fetch_attempt(attempt_id) if not attempt: raise ValueError(f"training_attempt_not_found:{attempt_id}") - payload = {"attempt": attempt} + case = self.repository.fetch_case(attempt["case_id"]) + if not case: + raise ValueError(f"training_case_not_found:{attempt['case_id']}") + case_payload = _hide_answer_key(case) + case_payload["tasks"] = self.repository.list_tasks(attempt["case_id"]) + reveal_answer = attempt.get("status") in {"submitted", "graded"} + replay = self.replay_service.build_case_replay( + case_id=attempt["case_id"], + frame_index=attempt.get("current_frame_index"), + reveal_answer=reveal_answer, + ) + payload = { + "attempt": attempt, + "case": case_payload, + "scores": self.repository.list_scores(attempt_id), + "replay": replay.__dict__, + } if include_annotations: payload["annotations"] = self.repository.list_annotations(attempt_id) return payload diff --git a/src/web/training/training.js b/src/web/training/training.js index e099ced..c94f9dc 100644 --- a/src/web/training/training.js +++ b/src/web/training/training.js @@ -177,7 +177,7 @@ function renderProgress(payload, recommendations = { items: [] }, history = { it (history.items || []).forEach((item) => { const button = document.createElement('button'); button.className = 'attempt-history-item'; - button.addEventListener('click', () => selectCase(item.case_id)); + button.addEventListener('click', () => loadAttempt(item.attempt_id)); const kind = document.createElement('span'); kind.textContent = `#${item.attempt_id} ${item.case_type}`; @@ -275,6 +275,18 @@ async function selectCase(caseId) { renderCase(); } +async function loadAttempt(attemptId) { + const payload = await fetchJson(`/training/attempts/${attemptId}`); + state.selectedCase = payload.case; + state.attempt = payload.attempt; + state.replay = payload.replay; + state.selectedCandleIndex = null; + renderCaseList(); + renderCase(); + renderAttemptReview(payload); + setStatus(`Review attempt #${attemptId}`); +} + function renderCase() { const current = state.selectedCase; if (!current) { @@ -490,6 +502,17 @@ function renderScores(payload) { }); } +function renderAttemptReview(payload) { + const scores = payload.scores || []; + const maxScore = scores.reduce((sum, item) => sum + Number(item.max_score || 0), 0); + const totalScore = payload.attempt?.total_score ?? scores.reduce((sum, item) => sum + Number(item.score || 0), 0); + renderScores({ + total_score: totalScore, + max_score: maxScore, + scores, + }); +} + function updateButtons() { const hasCase = Boolean(state.selectedCase); const hasAttempt = Boolean(state.attempt); diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 8e322f4..0f5a2be 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -66,6 +66,7 @@ class ApiStaticServerTests(unittest.TestCase): app_js = app_file.read_text(encoding="utf-8") self.assertIn("/training/attempts/history", app_js) + self.assertIn("/training/attempts/${attemptId}", app_js) self.assertIn("Recent attempts", app_js) self.assertIn("attempt-history-item", app_js) diff --git a/tests/unit/test_training_module.py b/tests/unit/test_training_module.py index f175426..0c864de 100644 --- a/tests/unit/test_training_module.py +++ b/tests/unit/test_training_module.py @@ -648,6 +648,18 @@ class TrainingApiAnswerHidingTests(unittest.TestCase): self.assertEqual(payload["items"][0]["score_pct"], 80.0) self.assertNotIn("answer_key", payload["items"][0]) + def test_attempt_detail_includes_review_payload_without_case_answer(self) -> None: + api = TrainingAPI(repository=StubAttemptDetailRepository()) + + payload = api.get_attempt(91) + + self.assertEqual(payload["attempt"]["id"], 91) + self.assertEqual(payload["case"]["answer_key"], None) + self.assertEqual(payload["case"]["tasks"][0]["task_type"], "mark_dealing_range") + self.assertEqual(payload["scores"][0]["category"], "dealing_range") + self.assertTrue(payload["replay"]["answer_visible"]) + self.assertIsNotNone(payload["replay"]["case"]["answer_key"]) + def test_recommendations_balance_case_types_when_no_weakness_exists(self) -> None: repo = StubBalancedRecommendationRepository() api = TrainingAPI(repository=repo) @@ -1024,6 +1036,47 @@ class StubAttemptHistoryRepository: ][:limit] +class StubAttemptDetailRepository: + def __init__(self) -> None: + self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) + + def fetch_attempt(self, attempt_id): + return { + "id": attempt_id, + "case_id": 31, + "user_id": "local", + "status": "graded", + "current_frame_index": 1, + "total_score": 60, + "meta": {}, + } + + 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), + "answer_key": {"low": 90.0, "high": 110.0, "equilibrium": 100.0}, + } + + def list_tasks(self, case_id): + return [{"id": 1, "case_id": case_id, "task_type": "mark_dealing_range"}] + + def list_scores(self, attempt_id): + return [{"category": "dealing_range", "score": 20, "max_score": 20, "feedback": {"reason": "matched"}}] + + def list_annotations(self, attempt_id): + return [{"annotation_type": "dealing_range", "payload": {"price_low": 90, "price_high": 110}}] + + 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}, + ] + + class StubBalancedRecommendationRepository: def __init__(self) -> None: self.case_type_queries = []