From d003a0e100a08496880be84c05e366814d70e4b6 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 14:24:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20P6=20=E2=80=94=20=D1=83=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D0=BA=D0=B0=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=81=D0=B0=20+=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ - клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats - лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress - тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/practiceController.js | 44 ++++++- backend/src/routes/practice.js | 3 + backend/tests/practice.test.js | 48 ++++++- frontend/trainer.html | 117 +++++++++++++++++- js/api.js | 3 +- plans/ai-trainer/PLAN.md | 13 +- 6 files changed, 222 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index 3646aba..9a6e4da 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -131,4 +131,46 @@ async function generateProblem(req, res) { res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts }); } -module.exports = { listProgress, submitAttempt, listPool, generateProblem }; +/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя. + * Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для + * тепловой карты. Доступ: владелец класса (teacher_id) или админ. */ +function classStats(req, res) { + const uid = req.user.id, role = req.user.role; + const classId = parseInt((req.query && req.query.class_id), 10); + if (!classId) return res.status(400).json({ error: 'class_id обязателен' }); + + if (role !== 'admin') { + const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid); + if (!own) return res.status(403).json({ error: 'не ваш класс' }); + } + + const students = db.prepare( + 'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name' + ).all(classId); + if (!students.length) return res.json({ students: [], skills: [], perSkill: [] }); + + const ids = students.map(s => s.id); + const ph = ids.map(() => '?').join(','); + const rows = db.prepare( + `SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})` + ).all(...ids); + + const bySkill = {}, byStudent = {}; + for (const r of rows) { + const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 }); + s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++; + const st = byStudent[r.user_id] || (byStudent[r.user_id] = {}); + st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0, + accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 }; + } + const skills = Object.keys(bySkill).sort(); + const perSkill = skills.map(k => { + const s = bySkill[k]; + return { skill: k, attempted: s.attempted, mastered: s.mastered, + accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 }; + }); + const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} })); + res.json({ students: studentRows, skills, perSkill }); +} + +module.exports = { listProgress, submitAttempt, listPool, generateProblem, classStats }; diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index 4a77911..53d1896 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -17,4 +17,7 @@ router.post('/attempt', c.submitAttempt); router.get('/pool', c.listPool); router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem); +// Аналитика класса — только учитель/админ (владение проверяется в хендлере). +router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats); + module.exports = router; diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js index 480172b..c20ef24 100644 --- a/backend/tests/practice.test.js +++ b/backend/tests/practice.test.js @@ -7,7 +7,7 @@ */ const { describe, it, before } = require('node:test'); const assert = require('node:assert/strict'); -const { app, inject, getToken, cleanup } = require('./setup'); +const { app, db, inject, getToken, cleanup } = require('./setup'); // Mount /api/practice on the shared test app (setup.js не монтирует новые роуты). app.use('/api/practice', require('../src/routes/practice')); @@ -114,3 +114,49 @@ describe('/api/practice progress', () => { assert.equal(res.status, 400, `got ${res.status}`); }); }); + +describe('/api/practice/class-stats (аналитика класса)', () => { + let teacher, other, s1, s2, classId; + + before(async () => { + teacher = await getToken('teacher'); + other = await getToken('teacher'); + s1 = await getToken('student'); + s2 = await getToken('student'); + // класс учителя + два ученика в нём + const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE'); + classId = info.lastInsertRowid; + db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId); + db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId); + // прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic + await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token); + await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token); + await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token); + }); + + it('владелец класса видит агрегаты и матрицу', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.students.length, 2, 'два ученика'); + assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке'); + const lb = res.body.perSkill.find(s => s.skill === 'lin-basic'); + assert.ok(lb, 'агрегат по lin-basic есть'); + assert.equal(lb.attempted, 2, 'оба пробовали lin-basic'); + assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%'); + }); + + it('чужой класс → 403', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('ученику запрещено (требуется роль) → 403', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('без class_id → 400', async () => { + const res = await inject('GET', '/api/practice/class-stats', null, teacher.token); + assert.equal(res.status, 400, `got ${res.status}`); + }); +}); diff --git a/frontend/trainer.html b/frontend/trainer.html index aab3e0a..2387832 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -103,6 +103,32 @@ .tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; } .tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); } + /* ── общий прогресс (лёгкая геймификация) ── */ + .tr-overall { color: #6366f1; font-size: .84rem; font-weight: 600; margin: -2px 0 14px; } + .tr-overall:empty { display: none; } + + /* ── модалка аналитики + тепловая карта ── */ + .tr-modal { position: fixed; inset: 0; z-index: 50; background: rgba(15,23,42,0.5); display: flex; align-items: center; justify-content: center; padding: 20px; } + .tr-modal-card { background: #fff; border-radius: 16px; max-width: 920px; width: 100%; max-height: 86vh; overflow: auto; box-shadow: 0 24px 60px rgba(0,0,0,0.3); } + .tr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid rgba(148,163,184,0.2); font-weight: 800; font-family: 'Manrope', sans-serif; font-size: 1.05rem; position: sticky; top: 0; background: #fff; } + .tr-modal-x { background: none; border: none; cursor: pointer; color: #64748b; padding: 4px; border-radius: 8px; } + .tr-modal-x:hover { background: rgba(148,163,184,0.15); color: #1e293b; } + .tr-modal-x .ic { width: 18px; height: 18px; } + #tr-an-body { padding: 18px 20px; } + .tr-an-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } + .tr-an-cls { font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; padding: 7px 13px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; } + .tr-an-cls:hover, .tr-an-cls.on { border-color: #818cf8; color: #4338ca; background: #eef2ff; } + .tr-an-empty { color: #94a3b8; padding: 20px; text-align: center; } + .tr-hm-wrap { overflow-x: auto; } + table.tr-hm { border-collapse: collapse; font-size: .8rem; } + table.tr-hm th, table.tr-hm td { border: 1px solid rgba(148,163,184,0.22); padding: 6px 8px; text-align: center; white-space: nowrap; } + table.tr-hm th { background: #f8fafc; color: #475569; font-weight: 700; position: sticky; top: 0; } + .tr-hm-name { text-align: left !important; font-weight: 600; color: #334155; background: #f8fafc; position: sticky; left: 0; } + .tr-hm-none { color: #cbd5e1; } + .tr-hm-cell { font-weight: 700; color: #334155; } + .tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; } + .tr-hm-sum { font-weight: 800; color: #4f46e5; background: #eef2ff; } + /* ── режим (умная тренировка) ── */ .tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } .tr-mode-btn { @@ -145,12 +171,30 @@
Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.
+
+
+ +
+ +
@@ -413,7 +457,7 @@ function submitAttempt(correct) { if (!LS.practiceSubmit) return; LS.practiceSubmit(currentSkill(), correct).then(function (r) { - if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); } + if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); } }).catch(function () {}); } @@ -515,7 +559,75 @@ updateStats(); } + // ── общий прогресс (лёгкая геймификация) ── + function updateOverall() { + var solvedTotal = 0, mastered = 0; + for (var k in prog) { + if (k === '__ms' || !Object.prototype.hasOwnProperty.call(prog, k)) continue; + var p = prog[k]; if (!p) continue; + solvedTotal += (p.solved || 0); + if (p.mastered) mastered++; + } + var el = $('tr-overall'); + if (el) el.textContent = solvedTotal ? ('Освоено навыков: ' + mastered + ' из ' + gens.length + ' · решено всего: ' + solvedTotal) : ''; + } + + // ── учительская аналитика класса ── + var _anClasses = [], _anCur = null; + function skillTitle(id) { var g = TG.get ? TG.get(id) : null; return g ? g.title : id; } + function anPicker() { + if (_anClasses.length <= 1) return ''; + return '
' + _anClasses.map(function (c) { + return ''; + }).join('') + '
'; + } + function renderHeatmap(data) { + if (!data.skills || !data.skills.length) return '
Пока нет данных — ученики ещё не решали задачи.
'; + var head = 'Ученик' + data.skills.map(function (s) { return '' + esc(skillTitle(s)) + ''; }).join('') + ''; + var rows = data.students.map(function (st) { + var cells = data.skills.map(function (s) { + var c = st.perSkill[s]; + if (!c) return '—'; + if (c.mastered) return '' + ICON.star + ''; + var bg = c.accuracy >= 70 ? '#bbf7d0' : c.accuracy >= 40 ? '#fef9c3' : '#fecaca'; + return '' + c.accuracy + '%'; + }).join(''); + return '' + esc(st.name) + '' + cells + ''; + }).join(''); + var sumCells = data.skills.map(function (s) { + var ps = data.perSkill.filter(function (x) { return x.skill === s; })[0]; + return '' + (ps ? ps.accuracy + '%' : '') + ''; + }).join(''); + return '
' + head + rows + '' + sumCells + '
Класс
'; + } + function showStats(classId) { + _anCur = classId; + $('tr-an-body').innerHTML = anPicker() + '
Загрузка…
'; + LS.practiceClassStats(classId).then(function (data) { + $('tr-an-body').innerHTML = anPicker() + renderHeatmap(data); + }).catch(function () { + $('tr-an-body').innerHTML = anPicker() + '
Не удалось загрузить аналитику.
'; + }); + } + function openAnalytics() { + $('tr-analytics').style.display = 'flex'; + $('tr-an-body').innerHTML = '
Загрузка…
'; + (LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) { + var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || []; + _anClasses = list; + if (!list.length) { $('tr-an-body').innerHTML = '
У вас пока нет классов.
'; return; } + showStats(list[0].id); + }).catch(function () { $('tr-an-body').innerHTML = '
Не удалось загрузить классы.
'; }); + } + // ── события ── + $('tr-analytics-btn').addEventListener('click', openAnalytics); + $('tr-an-close').addEventListener('click', function () { $('tr-analytics').style.display = 'none'; }); + $('tr-analytics').addEventListener('click', function (e) { if (e.target === $('tr-analytics')) $('tr-analytics').style.display = 'none'; }); + $('tr-an-body').addEventListener('click', function (e) { + var b = e.target.closest('.tr-an-cls'); if (!b) return; + showStats(+b.getAttribute('data-cid')); + }); $('tr-topics').addEventListener('click', function (e) { var b = e.target.closest('.tr-chip'); if (!b) return; var t = topics[+b.getAttribute('data-ti')]; if (!t) return; @@ -561,7 +673,8 @@ curGen = ss[0] || gens[0]; for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } } if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий) - renderTopics(); renderSkills(); updateSession(); newProblem(); + renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem(); + if (isTeacher) $('tr-analytics-btn').style.display = ''; } (LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null)) .then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); }) diff --git a/js/api.js b/js/api.js index 68a7417..b82c96c 100644 --- a/js/api.js +++ b/js/api.js @@ -1184,7 +1184,7 @@ window.LS = { customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, gameProgressList, gameProgressSubmit, - practiceProgressList, practiceSubmit, practicePool, practiceGenerate, + practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1424,6 +1424,7 @@ async function practiceProgressList() { return req('GET', '/practice/progre async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); } async function practicePool(skill) { return req('GET', '/practice/pool' + (skill ? ('?skill=' + encodeURIComponent(skill)) : '')); } async function practiceGenerate(topic) { return req('POST', '/practice/generate', { topic: topic || 'word-linear' }); } +async function practiceClassStats(classId) { return req('GET', '/practice/class-stats?class_id=' + encodeURIComponent(classId)); } async function assistantContext() { return req('GET', '/assistant/context'); } async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } diff --git a/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md index cc1d782..936d2bd 100644 --- a/plans/ai-trainer/PLAN.md +++ b/plans/ai-trainer/PLAN.md @@ -128,8 +128,19 @@ T13 latex). **Осталось (стретч):** неравенства (нуж - **Acceptance:** квадратное уравнение принимает оба корня в любом порядке; `(x+1)^2` ≡ `x^2+2x+1` через сэмплинг; неравенство принимает `x>3` и эквивалент. -## Phase 6 — Геймификация, аналитика, UX +## Phase 6 — Геймификация, аналитика, UX — DONE (частично) +**Сделано:** **учительская аналитика** — `GET /api/practice/class-stats?class_id=` +(`classStats`, владелец класса/админ): агрегаты по навыкам (attempted/mastered/accuracy) ++ матрица ученик×навык. Клиент: кнопка «Аналитика класса» (учителю) → модалка с +**тепловой картой** (ученики × навыки, цвет по точности, ✓ освоено) + пикер классов. +`LS.practiceClassStats`. **Лёгкая геймификация**: строка общего прогресса «Освоено +навыков M из N · решено всего K» (из агрегатов `practice_progress`), бейджи мастерства +на чипах (P2). Тесты practice.test.js +4 (владелец видит, чужой/ученик→403, без id→400). +Смоук страницы 27/27. **Осталось (стретч):** XP в общую геймификацию, виртуальная +клавиатура, сократические подсказки — не вошло (отдельные крупные направления). + +Изначальный список: - XP/энергия/стрики (reuse инфраструктуры Квантика), бейджи мастерства на чипах (есть основа). - Учительская аналитика: кто на каком навыке застрял, тепловая карта класса, отчёты. - UX: виртуальная клавиатура для дробей/степеней, «почему неверно» (разбор ошибки),