diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index 9a6e4da..8c81feb 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -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 }; diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index 53d1896..db0a4ca 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -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); diff --git a/backend/tests/practice-gen.test.js b/backend/tests/practice-gen.test.js index 0da24f7..0d72419 100644 --- a/backend/tests/practice-gen.test.js +++ b/backend/tests/practice-gen.test.js @@ -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(); diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js index c20ef24..1359c42 100644 --- a/backend/tests/practice.test.js +++ b/backend/tests/practice.test.js @@ -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}`); + }); }); diff --git a/frontend/trainer.html b/frontend/trainer.html index 2387832..707e0eb 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -128,6 +128,15 @@ .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-form { display: flex; flex-direction: column; gap: 12px; } + .tr-form label { display: flex; flex-direction: column; gap: 4px; font-size: .85rem; font-weight: 600; color: #475569; } + .tr-form input, .tr-form textarea { font: inherit; padding: 9px 11px; border: 1px solid rgba(148,163,184,0.4); border-radius: 10px; outline: none; resize: vertical; } + .tr-form input:focus, .tr-form textarea:focus { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.15); } + .tr-form-row { display: flex; gap: 10px; flex-wrap: wrap; } + .tr-form-row label { flex: 1; min-width: 110px; } + .tr-form-hint { font-size: .8rem; color: #64748b; } + .tr-form-err { color: #dc2626; font-size: .85rem; font-weight: 600; min-height: 18px; } /* ── режим (умная тренировка) ── */ .tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } @@ -197,6 +206,18 @@ + +
@@ -392,9 +413,15 @@ } function renderSkills() { if (isWord()) { - var btn = isTeacher ? '' : ''; - $('tr-skills').innerHTML = '' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '' + btn; + var tb = isTeacher + ? '' + + '' + + '' + : ''; + $('tr-skills').innerHTML = '' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '' + tb; var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem); + var ab = $('tr-author-btn'); if (ab) ab.addEventListener('click', openAuthor); + var asg = $('tr-assign-btn'); if (asg) asg.addEventListener('click', openAssign); return; } var ss = skillsOf(curTopic); @@ -620,7 +647,67 @@ }).catch(function () { $('tr-an-body').innerHTML = '
Не удалось загрузить классы.
'; }); } + // ── авторинг своей задачи (учитель) ── + function openAuthor() { + $('tr-tch-title').textContent = 'Своя задача'; + $('tr-tch-body').innerHTML = + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
Сервер проверит подстановкой: при этом x левая часть должна равняться правой.
' + + '
' + + '' + + '
'; + $('tr-teacher').style.display = 'flex'; + $('tr-f-save').addEventListener('click', submitAuthor); + } + function submitAuthor() { + var data = { topic: 'word-linear', story: $('tr-f-story').value, lhs: $('tr-f-lhs').value, rhs: $('tr-f-rhs').value, answer: Number($('tr-f-ans').value) }; + var err = $('tr-f-err'); err.textContent = ''; + var btn = $('tr-f-save'); btn.disabled = true; btn.textContent = 'Проверяю…'; + LS.practiceAuthor(data).then(function (r) { + if (r && r.ok && r.problem) { + wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0; + if (LS.toast) LS.toast('Задача добавлена в банк', 'success'); + $('tr-teacher').style.display = 'none'; + if (isWord()) { renderSkills(); serveWordProblem(); } + } else { err.textContent = 'Не удалось добавить.'; btn.disabled = false; btn.textContent = 'Проверить и добавить'; } + }).catch(function () { + err.textContent = 'Проверка не прошла: при этом x левая часть не равна правой. Исправьте уравнение или ответ.'; + btn.disabled = false; btn.textContent = 'Проверить и добавить'; + }); + } + + // ── выдать тему классу (учитель) ── + function openAssign() { + $('tr-tch-title').textContent = 'Выдать классу'; + $('tr-tch-body').innerHTML = '
Загрузка классов…
'; + $('tr-teacher').style.display = 'flex'; + (LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) { + var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || []; + if (!list.length) { $('tr-tch-body').innerHTML = '
У вас пока нет классов.
'; return; } + $('tr-tch-body').innerHTML = '
Ученики выбранного класса получат уведомление со ссылкой на тренажёр.
' + + '
' + list.map(function (c) { + return ''; + }).join('') + '
'; + $('tr-assign-list').addEventListener('click', function (e) { + var b = e.target.closest('.tr-an-cls'); if (!b) return; + b.disabled = true; + LS.practiceAssign(+b.getAttribute('data-cid'), 'word-linear', 'Текстовые задачи').then(function (res) { + if (LS.toast) LS.toast('Выдано классу (' + ((res && res.notified) || 0) + ' ученикам)', 'success'); + $('tr-teacher').style.display = 'none'; + }).catch(function () { if (LS.toast) LS.toast('Не удалось выдать классу', 'error'); b.disabled = false; }); + }); + }).catch(function () { $('tr-tch-body').innerHTML = '
Не удалось загрузить классы.
'; }); + } + // ── события ── + $('tr-tch-close').addEventListener('click', function () { $('tr-teacher').style.display = 'none'; }); + $('tr-teacher').addEventListener('click', function (e) { if (e.target === $('tr-teacher')) $('tr-teacher').style.display = 'none'; }); $('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'; }); diff --git a/js/api.js b/js/api.js index b82c96c..9fa1058 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, practiceClassStats, + practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats, practiceAuthor, practiceAssign, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1425,6 +1425,8 @@ async function practiceSubmit(skill, correct) { return req('POST', '/practice/at 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 practiceAuthor(data) { return req('POST', '/practice/author', data); } +async function practiceAssign(classId, topic, title) { return req('POST', '/practice/assign', { class_id: classId, topic: topic || 'word-linear', title }); } 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 936d2bd..e83f44e 100644 --- a/plans/ai-trainer/PLAN.md +++ b/plans/ai-trainer/PLAN.md @@ -97,9 +97,21 @@ practice.test.js 11/11 (+SR box/due). - **Acceptance:** доля задач, прошедших верификацию с 1–2 ретраев, ≥95%; пул кэшируется; ни одна неверная задача не доходит до ученика (гарантирует инвариант). -## Phase 4 — Авторинг учителем +## Phase 4 — Авторинг учителем — DONE (lean) -**Цель:** учитель создаёт свои наборы и раздаёт классу (как sim-builder/Quantik Ф5). +**Сделано (переиспользуя P3-проверку):** ручной авторинг — `POST /api/practice/author` +(учитель пишет story/lhs/rhs/answer → та же `validateAndVerify` подстановкой → пул; +не сходится → 422). Раздача классу — `POST /api/practice/assign` (владелец/админ → +durable `pushNotif` каждому ученику класса, ссылка `/trainer`). Клиент: +`LS.practiceAuthor/Assign`; в теме «Текстовые задачи» учителю — кнопки «Своя задача» +(модалка-форма с серверной проверкой) и «Выдать классу» (пикер классов → уведомление). +Тесты: author (валид→пул, неверный→422, ученик→403), assign (владелец уведомляет, +чужой→403). **Не делалось (осознанно):** полноценный визуальный конструктор +ПАРАМЕТРИЧЕСКИХ генераторов (pick/derive/lhs/rhs DSL) — крупный отдельный билдер; +текущий авторинг закрывает «учитель создаёт задачи + раздаёт классу» переиспользованием +пула и инварианта проверки. + +**Цель (исходная):** учитель создаёт свои наборы и раздаёт классу (как sim-builder/Quantik Ф5). - Конструктор генераторов: шаблон `lhs/rhs`, диапазоны параметров, формула ответа, шаги решения; превью + клиентская валидация через `SimExpr.compile`.