feat(trainer): P4 — авторинг задач учителем + раздача классу

- POST /api/practice/author: учитель пишет story/lhs/rhs/answer → та же проверка подстановкой (validateAndVerify) → пул; не сходится → 422
- POST /api/practice/assign: выдать тему классу → durable pushNotif каждому ученику (ссылка /trainer); владелец/админ, чужой → 403
- клиент: LS.practiceAuthor/Assign; в теме «Текстовые задачи» учителю кнопки «Своя задача» (модалка-форма) и «Выдать классу» (пикер классов)
- тесты: author (валид→пул, неверный→422, ученик→403), assign (владелец уведомляет, чужой→403) — practice 19/19 + practice-gen 16/16
- смоук страницы 27/27; план P4 → DONE (lean: ручной авторинг + раздача, без полного DSL-конструктора)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:30:02 +03:00
parent d003a0e100
commit cd7c75ff08
7 changed files with 185 additions and 7 deletions
+43 -1
View File
@@ -84,6 +84,7 @@ function submitAttempt(req, res) {
/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
const genService = require('../services/practiceGenService');
const { pushNotif } = require('../utils/notifications');
const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
function toClientProblem(r) {
@@ -131,6 +132,47 @@ async function generateProblem(req, res) {
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
}
/* POST /api/practice/author — учитель пишет задачу ВРУЧНУЮ (без LLM).
* Та же проверка подстановкой (validateAndVerify): не сходится → 422, в пул не пишем. */
function authorProblem(req, res) {
const b = req.body || {};
const topic = (typeof b.topic === 'string') ? b.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
const v = genService.validateAndVerify({
story: b.story, lhs: b.lhs, rhs: b.rhs, answer: b.answer, answerVar: b.answerVar, solution: b.solution
});
if (!v.ok) return res.status(422).json({ error: 'verify', reason: v.reason });
const p = v.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row) });
}
/* POST /api/practice/assign { class_id, topic, title } — выдать тему классу.
* Адресное durable-уведомление каждому ученику (pushNotif → таблица + SSE), ссылка /trainer.
* Доступ: владелец класса или админ. */
function assignToClass(req, res) {
const uid = req.user.id, role = req.user.role;
const b = req.body || {};
const classId = parseInt(b.class_id, 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
const title = (typeof b.title === 'string' ? b.title.trim() : '').slice(0, 200);
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 members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId);
const msg = 'Тренажёр: ' + (title || 'новое задание для практики');
members.forEach(m => pushNotif(m.user_id, 'practice', msg, '/trainer'));
res.json({ ok: true, notified: members.length });
}
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
@@ -173,4 +215,4 @@ function classStats(req, res) {
res.json({ students: studentRows, skills, perSkill });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem, classStats };
module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats };
+3 -1
View File
@@ -13,9 +13,11 @@ router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/attempt', c.submitAttempt);
// Текстовые задачи (Уровень 1): пул читают все; генерирует учитель/админ.
// Текстовые задачи (Уровень 1): пул читают все; генерирует/авторит учитель/админ.
router.get('/pool', c.listPool);
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
router.post('/author', requireRole('teacher', 'admin'), c.authorProblem);
router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass);
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
+21
View File
@@ -106,6 +106,27 @@ describe('/api/practice pool endpoints', () => {
assert.equal(res.status, 400, `got ${res.status}`);
});
it('POST /author учителем (валидная) → в пул', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'Задача от учителя', lhs: '2*x + 1', rhs: '7', answer: 3 }, teacher);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.problem.answer, 3);
assert.equal(res.body.problem.kind, 'word');
});
it('POST /author с неверным корнем → 422 (в пул не попадёт)', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 5 }, teacher);
assert.equal(res.status, 422, `got ${res.status}`);
});
it('POST /author ученику запрещён (403)', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 3 }, student);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('GET /pool отдаёт одобренные задачи', async () => {
db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status)
VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run();
+12
View File
@@ -159,4 +159,16 @@ describe('/api/practice/class-stats (аналитика класса)', () => {
const res = await inject('GET', '/api/practice/class-stats', null, teacher.token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('POST /assign владельцем → уведомляет всех учеников', async () => {
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear', title: 'Линейные уравнения' }, teacher.token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.notified, 2, 'двое учеников уведомлены');
});
it('POST /assign чужой класс → 403', async () => {
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear' }, other.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
});