feat: review training attempt details
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user