feat: add training attempt history

This commit is contained in:
yuqei
2026-04-30 01:09:35 +08:00
parent a096521447
commit 61d8414cb9
8 changed files with 174 additions and 3 deletions
@@ -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. 推荐立即开始的第一步
+6
View File
@@ -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")
+6
View File
@@ -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",
+37
View File
@@ -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
+21
View File
@@ -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;
}
+35 -3
View File
@@ -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: <strong>${payload.precision_maturity}</strong>`;
@@ -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) {
+9
View File
@@ -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")
+43
View File
@@ -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 = []