feat(trainer): P13 — конструктор параметрических генераторов
- custom_generators (мигр.084, spec_json + draft/published); customGeneratorController: validateGenSpec без исполнения (лимиты/типы), CRUD own+published + ownership - /api/practice/generators[/:id]; клиент LS.practiceGen* - страница /trainer-builder (учитель): форма (pick/derive/lhs/rhs/display/answer/solution) + живое превью через TE.instantiate(strict) (материализация + проверка ответа подстановкой) + список своих (правка/удаление/публикация) - тренажёр грузит свои+опубликованные генераторы в тему «Мои генераторы» (пошаговый режим работает); пункт сайдбара /trainer-builder (teacher-only) - тесты custom-generators.test.js 12/12; смоук движка 402/402 (T17 кастомный спек + strict-валидация); страница 33/33; ROADMAP_V2 P13 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
/* Пользовательские генераторы тренажёра (конструктор, P13).
|
||||
*
|
||||
* Спек генератора — ДАННЫЕ; на клиенте его исполняет безопасный SimExpr (⛔ без
|
||||
* eval). Сервер НЕ исполняет — только валидирует структуру/лимиты и хранит.
|
||||
* Текст НЕ экранируется на сервере: клиент рендерит безопасно (textContent / esc),
|
||||
* а выражения проходят через SimExpr. Стиль — customSimController/studentMaterials:
|
||||
* read auth-only (own + published), мутации — requireRole + per-row ownership.
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
const KINDS = { solve: 1, compute: 1, roots: 1, simplify: 1, inequality: 1 };
|
||||
const MAX_SPEC = 20000;
|
||||
|
||||
function clip(v, n) { return (typeof v === 'string') ? (v.length > n ? v.slice(0, n) : v) : ''; }
|
||||
function expr(v, n) { return (typeof v === 'string') ? clip(v.trim(), n || 200) : ''; }
|
||||
const NAME = /^[a-zA-Z][a-zA-Z0-9]{0,12}$/;
|
||||
|
||||
/* Валидация спека БЕЗ исполнения: типы/лимиты. Возврат { ok, clean?, error? }. */
|
||||
function validateGenSpec(spec) {
|
||||
if (!spec || typeof spec !== 'object') return { ok: false, error: 'спек отсутствует' };
|
||||
if (JSON.stringify(spec).length > MAX_SPEC) return { ok: false, error: 'спек слишком большой' };
|
||||
|
||||
const title = clip(String(spec.title || '').trim(), 120);
|
||||
if (!title) return { ok: false, error: 'нужен заголовок' };
|
||||
const topic = clip(String(spec.topic || 'custom').trim(), 60) || 'custom';
|
||||
const kind = (typeof spec.kind === 'string' && KINDS[spec.kind]) ? spec.kind : 'solve';
|
||||
|
||||
// pick: имя → [min,max] целые
|
||||
const pick = {};
|
||||
if (spec.pick && typeof spec.pick === 'object') {
|
||||
for (const k of Object.keys(spec.pick).slice(0, 20)) {
|
||||
const r = spec.pick[k];
|
||||
if (NAME.test(k) && Array.isArray(r) && r.length === 2 && Number.isInteger(r[0]) && Number.isInteger(r[1])) {
|
||||
pick[k] = [r[0], r[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
// derive: имя → формула (строка)
|
||||
const derive = {};
|
||||
if (spec.derive && typeof spec.derive === 'object') {
|
||||
for (const k of Object.keys(spec.derive).slice(0, 30)) {
|
||||
if (NAME.test(k) && typeof spec.derive[k] === 'string') derive[k] = expr(spec.derive[k]);
|
||||
}
|
||||
}
|
||||
// solution: [{ note, tex }]
|
||||
let solution = [];
|
||||
if (Array.isArray(spec.solution)) {
|
||||
solution = spec.solution.slice(0, 12).map(st => ({
|
||||
note: clip(String((st && st.note) || ''), 300),
|
||||
tex: expr(st && st.tex)
|
||||
}));
|
||||
}
|
||||
// answers: массив выражений (kind roots)
|
||||
let answers;
|
||||
if (Array.isArray(spec.answers)) answers = spec.answers.slice(0, 6).map(a => expr(a)).filter(Boolean);
|
||||
|
||||
const clean = {
|
||||
title, topic, kind,
|
||||
pick,
|
||||
derive: Object.keys(derive).length ? derive : undefined,
|
||||
constraint: expr(spec.constraint) || undefined,
|
||||
require: expr(spec.require) || undefined,
|
||||
lhs: expr(spec.lhs) || 'x',
|
||||
rhs: expr(spec.rhs) || 'x',
|
||||
display: (typeof spec.display === 'string' && spec.display.trim()) ? clip(spec.display, 200) : undefined,
|
||||
srcExpr: expr(spec.srcExpr) || undefined,
|
||||
answerExpr: expr(spec.answerExpr) || undefined,
|
||||
dispOp: ['<', '>', '<=', '>='].indexOf(spec.dispOp) !== -1 ? spec.dispOp : undefined,
|
||||
relOp: ['<', '>', '<=', '>='].indexOf(spec.relOp) !== -1 ? spec.relOp : undefined,
|
||||
bound: expr(spec.bound) || undefined,
|
||||
answer: expr(spec.answer) || undefined,
|
||||
answers: (answers && answers.length) ? answers : undefined,
|
||||
answerVar: /^[a-z]$/.test(spec.answerVar) ? spec.answerVar : 'x',
|
||||
integerAnswer: !!spec.integerAnswer,
|
||||
solution
|
||||
};
|
||||
Object.keys(clean).forEach(k => clean[k] === undefined && delete clean[k]);
|
||||
return { ok: true, clean };
|
||||
}
|
||||
|
||||
/* Строка БД → объект-генератор для клиента (готов к TE.instantiate). */
|
||||
function toClientGen(row) {
|
||||
let spec = {};
|
||||
try { spec = JSON.parse(row.spec_json) || {}; } catch (e) { spec = {}; }
|
||||
spec.id = 'cg' + row.id; // ключ навыка/прогресса
|
||||
spec.title = row.title;
|
||||
spec.topic = row.topic || 'custom';
|
||||
spec.dbid = row.id;
|
||||
spec.owner_id = row.owner_id;
|
||||
spec.status = row.status;
|
||||
spec._custom = true;
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* GET /api/practice/generators — свои + опубликованные. */
|
||||
function genList(req, res) {
|
||||
const uid = req.user.id;
|
||||
const rows = db.prepare(
|
||||
"SELECT * FROM custom_generators WHERE owner_id = ? OR status = 'published' ORDER BY updated_at DESC, id DESC"
|
||||
).all(uid);
|
||||
res.json({ generators: rows.map(toClientGen) });
|
||||
}
|
||||
|
||||
/* GET /api/practice/generators/:id — свой или опубликованный. */
|
||||
// @public-by-design: auth-only; видимость own+published проверяется в хендлере.
|
||||
function genGet(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && row.status !== 'published' && role !== 'admin') return res.status(403).json({ error: 'нет доступа' });
|
||||
res.json({ generator: toClientGen(row) });
|
||||
}
|
||||
|
||||
function genCreate(req, res) {
|
||||
const v = validateGenSpec(req.body && req.body.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
const status = (req.body && req.body.status === 'published') ? 'published' : 'draft';
|
||||
const info = db.prepare(
|
||||
'INSERT INTO custom_generators (owner_id, title, topic, spec_json, status) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(req.user.id, v.clean.title, v.clean.topic, JSON.stringify(v.clean), status);
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(info.lastInsertRowid);
|
||||
res.json({ ok: true, generator: toClientGen(row) });
|
||||
}
|
||||
|
||||
function genUpdate(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||
|
||||
let title = row.title, topic = row.topic, specJson = row.spec_json;
|
||||
if (req.body && req.body.spec) {
|
||||
const v = validateGenSpec(req.body.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
title = v.clean.title; topic = v.clean.topic; specJson = JSON.stringify(v.clean);
|
||||
}
|
||||
const status = (req.body && (req.body.status === 'published' || req.body.status === 'draft')) ? req.body.status : row.status;
|
||||
db.prepare("UPDATE custom_generators SET title = ?, topic = ?, spec_json = ?, status = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(title, topic, specJson, status, row.id);
|
||||
const upd = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(row.id);
|
||||
res.json({ ok: true, generator: toClientGen(upd) });
|
||||
}
|
||||
|
||||
function genDelete(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||
db.prepare('DELETE FROM custom_generators WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { validateGenSpec, genList, genGet, genCreate, genUpdate, genDelete };
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 084: Пользовательские генераторы тренажёра (конструктор, Roadmap P13).
|
||||
--
|
||||
-- Учитель создаёт ПАРАМЕТРИЧЕСКИЙ генератор задач — это ДАННЫЕ (spec_json):
|
||||
-- диапазоны pick, формулы derive, шаблоны lhs/rhs, ответ, шаги решения. На
|
||||
-- клиенте спек исполняет БЕЗОПАСНЫЙ SimExpr (⛔ без eval), на сервере он только
|
||||
-- хранится и валидируется по структуре/лимитам (НЕ исполняется). Прогресс по
|
||||
-- такому навыку ключуется как 'cg<id>'.
|
||||
-- status: draft (видит только автор) | published (видят и ученики).
|
||||
-- owner_id ON DELETE CASCADE — генераторы удаляются вместе с автором.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_generators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
topic TEXT NOT NULL DEFAULT 'custom',
|
||||
spec_json TEXT NOT NULL, -- полный спек генератора (данные)
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_generators_owner ON custom_generators (owner_id, status);
|
||||
@@ -22,4 +22,12 @@ router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass);
|
||||
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
|
||||
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
|
||||
|
||||
// Конструктор генераторов (P13): чтение — own+published; мутации — учитель/админ + ownership.
|
||||
const cg = require('../controllers/customGeneratorController');
|
||||
router.get('/generators', cg.genList);
|
||||
router.post('/generators', requireRole('teacher', 'admin'), cg.genCreate);
|
||||
router.get('/generators/:id', cg.genGet); // @public-by-design: own/published в хендлере
|
||||
router.put('/generators/:id', requireRole('teacher', 'admin'), cg.genUpdate);
|
||||
router.delete('/generators/:id', requireRole('teacher', 'admin'), cg.genDelete);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user