feat: add training attempt history
This commit is contained in:
@@ -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. 推荐立即开始的第一步
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user