feat: review training attempt details

This commit is contained in:
yuqei
2026-04-30 01:15:28 +08:00
parent 61d8414cb9
commit 09adaafe59
5 changed files with 111 additions and 2 deletions
@@ -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. 推荐立即开始的第一步
+17 -1
View File
@@ -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
+24 -1
View File
@@ -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);
+1
View File
@@ -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)
+53
View File
@@ -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 = []