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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
+89
-2
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-modal" id="tr-teacher" style="display:none">
|
||||
<div class="tr-modal-card" style="max-width:560px">
|
||||
<div class="tr-modal-head">
|
||||
<span id="tr-tch-title">Своя задача</span>
|
||||
<button class="tr-modal-x" id="tr-tch-close" type="button" aria-label="Закрыть">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="tr-tch-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-topics" id="tr-topics"></div>
|
||||
<div class="tr-skills" id="tr-skills"></div>
|
||||
|
||||
@@ -392,9 +413,15 @@
|
||||
}
|
||||
function renderSkills() {
|
||||
if (isWord()) {
|
||||
var btn = isTeacher ? '<button class="tr-skill" id="tr-gen-btn" type="button">+ Сгенерировать задачу</button>' : '';
|
||||
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + btn;
|
||||
var tb = isTeacher
|
||||
? '<button class="tr-skill" id="tr-gen-btn" type="button">+ ИИ-задача</button>'
|
||||
+ '<button class="tr-skill" id="tr-author-btn" type="button">Своя задача</button>'
|
||||
+ '<button class="tr-skill" id="tr-assign-btn" type="button">Выдать классу</button>'
|
||||
: '';
|
||||
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + 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 = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
|
||||
}
|
||||
|
||||
// ── авторинг своей задачи (учитель) ──
|
||||
function openAuthor() {
|
||||
$('tr-tch-title').textContent = 'Своя задача';
|
||||
$('tr-tch-body').innerHTML =
|
||||
'<div class="tr-form">' +
|
||||
'<label>Условие<textarea id="tr-f-story" rows="3" placeholder="Текст задачи словами"></textarea></label>' +
|
||||
'<div class="tr-form-row">' +
|
||||
'<label>Левая часть<input id="tr-f-lhs" placeholder="2*x + 1"></label>' +
|
||||
'<label>Правая часть<input id="tr-f-rhs" placeholder="7"></label>' +
|
||||
'<label>Ответ x<input id="tr-f-ans" placeholder="3"></label>' +
|
||||
'</div>' +
|
||||
'<div class="tr-form-hint">Сервер проверит подстановкой: при этом x левая часть должна равняться правой.</div>' +
|
||||
'<div class="tr-form-err" id="tr-f-err"></div>' +
|
||||
'<button class="tr-btn tr-primary" id="tr-f-save" type="button">Проверить и добавить</button>' +
|
||||
'</div>';
|
||||
$('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 = '<div class="tr-an-empty">Загрузка классов…</div>';
|
||||
$('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 = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
|
||||
$('tr-tch-body').innerHTML = '<div class="tr-form-hint">Ученики выбранного класса получат уведомление со ссылкой на тренажёр.</div>' +
|
||||
'<div class="tr-an-picker" id="tr-assign-list">' + list.map(function (c) {
|
||||
return '<button class="tr-an-cls" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
|
||||
}).join('') + '</div>';
|
||||
$('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 = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
|
||||
}
|
||||
|
||||
// ── события ──
|
||||
$('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'; });
|
||||
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user