diff --git a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md index bf2c4df..18f7937 100644 --- a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md +++ b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md @@ -645,6 +645,22 @@ Invalidation:20 2. 同一次 submit 能看到 mastered 与 unlocked stage。 3. 未推进时显示继续当前阶段,不误导用户跳级。 +### M18:训练记录回看 + +完成: + +1. 新增 `GET /training/attempts/history`。 +2. 从 `training_attempts` 读取最近训练记录,并关联 case type、skill tags、score pct。 +3. Training Lab Progress 面板展示 `Recent attempts`。 +4. 点击历史记录可回到对应 case。 + +验收: + +1. history 不返回 `answer_key`。 +2. graded attempt 显示 score pct。 +3. started attempt 显示 status。 +4. history 仅按 `user_id` 读取,不影响其他用户进度。 + ## 10. 第一版验收标准 第一版完成时必须满足: @@ -666,6 +682,7 @@ Invalidation:20 15. replay 支持逐根前进、回退和重置。 16. 高阶 MSS 答案能在图表上直观看到结构打破价位。 17. 每次提交后能明确展示阶段是否推进、掌握或解锁。 +18. 读者能回看最近训练记录与分数。 ## 11. 推荐立即开始的第一步 diff --git a/src/api/server.py b/src/api/server.py index e83544b..17a5844 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -71,6 +71,12 @@ def route_request(path: str, query: dict, dependencies: Optional[dict] = None, m user_id = _get_single(query, "user_id", "local") limit = _get_int(query, "limit", 200) return 200, training.get_progress(user_id=user_id, limit=limit) + if path == "/training/attempts/history": + _require_method(method, "GET") + training = deps["training"] + user_id = _get_single(query, "user_id", "local") + limit = _get_int(query, "limit", 20) + return 200, training.get_attempt_history(user_id=user_id, limit=limit) if path == "/training/recommendations": training = deps["training"] user_id = _get_single(query, "user_id", "local") diff --git a/src/api/training.py b/src/api/training.py index 073935b..798498a 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -204,6 +204,12 @@ class TrainingAPI: "skill_progress": [item.__dict__ for item in summary.skill_progress], } + def get_attempt_history(self, user_id: str = "local", limit: int = 20) -> dict: + return { + "user_id": user_id, + "items": self.repository.list_attempt_history(user_id=user_id, limit=limit), + } + def get_recommendations( self, user_id: str = "local", diff --git a/src/services/training/case_repository.py b/src/services/training/case_repository.py index bf776b4..2beb93e 100644 --- a/src/services/training/case_repository.py +++ b/src/services/training/case_repository.py @@ -445,6 +445,34 @@ class TrainingCaseRepository: rows = session.execute(sql, {"user_id": user_id, "limit": limit}).mappings().all() return [dict(row) for row in rows] + def list_attempt_history(self, user_id: str = "local", limit: int = 20) -> list[dict]: + sql = text( + """ + select ta.id as attempt_id, + ta.case_id, + ta.user_id, + ta.status, + ta.current_frame_index, + ta.started_ts, + ta.submitted_ts, + ta.total_score, + ta.meta, + tc.case_type, + tc.skill_tags, + coalesce(sum(ts.max_score), 0) as max_score + from training_attempts ta + join training_cases tc on tc.id = ta.case_id + left join training_scores ts on ts.attempt_id = ta.id + where ta.user_id = :user_id + group by ta.id, tc.id + order by ta.started_ts desc, ta.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"user_id": user_id, "limit": limit}).mappings().all() + return [_attempt_history_payload(dict(row)) for row in rows] + def fetch_sweep_sources(self, instrument_id: int, timeframe: str, limit: int = 20) -> list[dict]: sql = text( """ @@ -608,3 +636,12 @@ def _score_params(record: TrainingScoreRecord) -> dict: "max_score": record.max_score, "feedback": json.dumps(record.feedback or {}, default=str), } + + +def _attempt_history_payload(row: dict) -> dict: + payload = dict(row) + max_score = float(payload.get("max_score") or 0) + total_score = payload.get("total_score") + payload["max_score"] = max_score + payload["score_pct"] = round((float(total_score) / max_score) * 100, 2) if total_score is not None and max_score else None + return payload diff --git a/src/web/training/training.css b/src/web/training/training.css index 5d096e6..807ff92 100644 --- a/src/web/training/training.css +++ b/src/web/training/training.css @@ -197,6 +197,27 @@ button:disabled { font-size: 11px; font-weight: 700; } +.attempt-history { + display: grid; + gap: 6px; + margin-top: 6px; +} +.attempt-history-item { + width: 100%; + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid #263034; + background: #121719; + color: #dce5e8; + text-align: left; +} +.attempt-history-item small { + color: #f2c14e; + font-size: 11px; +} .recommendation-empty { color: #89979c; } diff --git a/src/web/training/training.js b/src/web/training/training.js index 78748b9..e099ced 100644 --- a/src/web/training/training.js +++ b/src/web/training/training.js @@ -1,6 +1,7 @@ const state = { cases: [], recommendations: [], + attemptHistory: [], curriculum: null, selectedCase: null, attempt: null, @@ -78,14 +79,16 @@ async function loadCases() { async function loadProgress() { const instrumentId = el.instrumentId.value || '1'; - const [progress, recommendations, curriculum] = await Promise.all([ + const [progress, recommendations, curriculum, history] = await Promise.all([ fetchJson('/training/progress?user_id=local'), fetchJson(`/training/recommendations?user_id=local&instrument_id=${encodeURIComponent(instrumentId)}&limit=3`), fetchJson(`/training/curriculum?user_id=local&instrument_id=${encodeURIComponent(instrumentId)}&limit_per_stage=2`), + fetchJson('/training/attempts/history?user_id=local&limit=4'), ]); state.recommendations = recommendations.items || []; + state.attemptHistory = history.items || []; state.curriculum = curriculum; - renderProgress(progress, recommendations); + renderProgress(progress, recommendations, history); renderCurriculum(curriculum); } @@ -120,7 +123,7 @@ function renderCaseList() { }); } -function renderProgress(payload, recommendations = { items: [] }) { +function renderProgress(payload, recommendations = { items: [] }, history = { items: [] }) { clearNode(el.progressPanel); const maturity = document.createElement('div'); maturity.innerHTML = `Maturity: ${payload.precision_maturity}`; @@ -163,6 +166,35 @@ function renderProgress(payload, recommendations = { items: [] }) { list.appendChild(empty); } el.progressPanel.appendChild(list); + + const historyTitle = document.createElement('div'); + historyTitle.className = 'recommendation-title'; + historyTitle.textContent = 'Recent attempts'; + el.progressPanel.appendChild(historyTitle); + + const historyList = document.createElement('div'); + historyList.className = 'attempt-history'; + (history.items || []).forEach((item) => { + const button = document.createElement('button'); + button.className = 'attempt-history-item'; + button.addEventListener('click', () => selectCase(item.case_id)); + + const kind = document.createElement('span'); + kind.textContent = `#${item.attempt_id} ${item.case_type}`; + + const score = document.createElement('small'); + score.textContent = item.score_pct == null ? item.status : `${item.score_pct}%`; + + button.append(kind, score); + historyList.appendChild(button); + }); + if (!(history.items || []).length) { + const empty = document.createElement('div'); + empty.className = 'recommendation-empty'; + empty.textContent = 'No attempts yet'; + historyList.appendChild(empty); + } + el.progressPanel.appendChild(historyList); } function renderCurriculum(payload) { diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 4dcfefe..8e322f4 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -60,6 +60,15 @@ class ApiStaticServerTests(unittest.TestCase): self.assertIn("MSS", app_js) self.assertIn("drawPriceLabel", app_js) + def test_training_lab_surfaces_attempt_history(self) -> None: + app_file = _resolve_static_file("/training/static/training.js") + self.assertIsNotNone(app_file) + app_js = app_file.read_text(encoding="utf-8") + + self.assertIn("/training/attempts/history", app_js) + self.assertIn("Recent attempts", app_js) + self.assertIn("attempt-history-item", app_js) + @unittest.skipIf(shutil.which("node") is None, "node is required for JS syntax checks") def test_dashboard_javascript_syntax_is_valid(self) -> None: app_file = _resolve_static_file("/static/app.js") diff --git a/tests/unit/test_training_module.py b/tests/unit/test_training_module.py index f3e2ba4..f175426 100644 --- a/tests/unit/test_training_module.py +++ b/tests/unit/test_training_module.py @@ -485,6 +485,17 @@ class TrainingApiRouteTests(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(payload["precision_maturity"], 82.5) + def test_routes_training_attempt_history(self) -> None: + status, payload = route_request( + "/training/attempts/history", + {"user_id": ["local"], "limit": ["2"]}, + dependencies={"training": StubTrainingAPI()}, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["items"][0]["attempt_id"], 91) + self.assertEqual(payload["items"][0]["case_type"], "range") + def test_routes_training_recommendations(self) -> None: status, payload = route_request( "/training/recommendations", @@ -627,6 +638,16 @@ class TrainingApiAnswerHidingTests(unittest.TestCase): self.assertEqual(payload["items"][0]["matched_weakness_tags"], ["fvg", "pd_array"]) self.assertEqual(repo.case_type_queries[0], "fvg") + def test_attempt_history_returns_recent_attempts_without_answers(self) -> None: + api = TrainingAPI(repository=StubAttemptHistoryRepository()) + + payload = api.get_attempt_history(user_id="local", limit=2) + + self.assertEqual(payload["items"][0]["attempt_id"], 91) + self.assertEqual(payload["items"][0]["case_type"], "range") + self.assertEqual(payload["items"][0]["score_pct"], 80.0) + self.assertNotIn("answer_key", payload["items"][0]) + def test_recommendations_balance_case_types_when_no_weakness_exists(self) -> None: repo = StubBalancedRecommendationRepository() api = TrainingAPI(repository=repo) @@ -888,6 +909,12 @@ class StubTrainingAPI: "skill_progress": [], } + def get_attempt_history(self, user_id="local", limit=20): + return { + "user_id": user_id, + "items": [{"attempt_id": 91, "case_id": 31, "case_type": "range", "score_pct": 80.0}], + } + def get_recommendations(self, user_id="local", limit=5, instrument_id=None): return { "user_id": user_id, @@ -981,6 +1008,22 @@ class StubRecommendationRepository: ] +class StubAttemptHistoryRepository: + def list_attempt_history(self, user_id="local", limit=20): + return [ + { + "attempt_id": 91, + "case_id": 31, + "user_id": user_id, + "case_type": "range", + "status": "graded", + "total_score": 48, + "max_score": 60.0, + "score_pct": 80.0, + } + ][:limit] + + class StubBalancedRecommendationRepository: def __init__(self) -> None: self.case_type_queries = []