diff --git a/backend/src/controllers/customGeneratorController.js b/backend/src/controllers/customGeneratorController.js new file mode 100644 index 0000000..0ed7d23 --- /dev/null +++ b/backend/src/controllers/customGeneratorController.js @@ -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 }; diff --git a/backend/src/db/migrations/084_custom_generators.sql b/backend/src/db/migrations/084_custom_generators.sql new file mode 100644 index 0000000..e4aa9a6 --- /dev/null +++ b/backend/src/db/migrations/084_custom_generators.sql @@ -0,0 +1,24 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 084: Пользовательские генераторы тренажёра (конструктор, Roadmap P13). +-- +-- Учитель создаёт ПАРАМЕТРИЧЕСКИЙ генератор задач — это ДАННЫЕ (spec_json): +-- диапазоны pick, формулы derive, шаблоны lhs/rhs, ответ, шаги решения. На +-- клиенте спек исполняет БЕЗОПАСНЫЙ SimExpr (⛔ без eval), на сервере он только +-- хранится и валидируется по структуре/лимитам (НЕ исполняется). Прогресс по +-- такому навыку ключуется как 'cg'. +-- 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); diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index db0a4ca..4ce1c53 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -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; diff --git a/backend/tests/custom-generators.test.js b/backend/tests/custom-generators.test.js new file mode 100644 index 0000000..2083d94 --- /dev/null +++ b/backend/tests/custom-generators.test.js @@ -0,0 +1,100 @@ +'use strict'; +/** + * Tests: конструктор генераторов тренажёра (P13) — валидация + CRUD + доступ. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, cleanup } = require('./setup'); +const cg = require('../src/controllers/customGeneratorController'); + +app.use('/api/practice', require('../src/routes/practice')); +after(() => cleanup()); + +const SPEC = { + title: 'Моё уравнение', topic: 'custom', kind: 'solve', + pick: { a: [2, 9], b: [1, 20], root: [-9, 9] }, + derive: { c: 'a*root + b', cmb: 'a*root' }, + require: 'root != 0', + lhs: '{a}*x + {b}', rhs: '{c}', answer: 'root', integerAnswer: true, + solution: [{ note: 'делим на {a}', tex: 'x = {cmb} / {a}' }] +}; + +describe('validateGenSpec', () => { + it('принимает корректный спек', () => { + const v = cg.validateGenSpec(SPEC); + assert.equal(v.ok, true, v.error); + assert.equal(v.clean.kind, 'solve'); + assert.deepEqual(v.clean.pick.a, [2, 9]); + assert.equal(v.clean.integerAnswer, true); + }); + it('отвергает без заголовка', () => { + assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { title: '' })).ok, false); + }); + it('фильтрует нецелые диапазоны pick', () => { + const v = cg.validateGenSpec(Object.assign({}, SPEC, { pick: { a: [1.5, 9], b: [1, 20] } })); + assert.equal(v.ok, true); + assert.equal(v.clean.pick.a, undefined, 'нецелый диапазон отброшен'); + assert.deepEqual(v.clean.pick.b, [1, 20]); + }); + it('отвергает слишком большой спек', () => { + assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { display: 'x'.repeat(30000) })).ok, false); + }); +}); + +describe('/api/practice/generators CRUD', () => { + let teacher, other, student, gid; + before(async () => { + teacher = (await getToken('teacher')).token; + other = (await getToken('teacher')).token; + student = (await getToken('student')).token; + }); + + it('учитель создаёт генератор', async () => { + const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.ok, true); + assert.ok(/^cg\d+$/.test(res.body.generator.id), 'id вида cg'); + gid = res.body.generator.dbid; + }); + + it('ученику создавать запрещено (403)', async () => { + const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, student); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('невалидный спек → 400', async () => { + const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, teacher); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('автор видит свой генератор в списке', async () => { + const res = await inject('GET', '/api/practice/generators', null, teacher); + assert.equal(res.status, 200); + assert.ok(res.body.generators.some(g => g.dbid === gid), 'свой генератор в списке'); + }); + + it('чужой draft не виден другому учителю', async () => { + const res = await inject('GET', '/api/practice/generators', null, other); + assert.ok(!res.body.generators.some(g => g.dbid === gid), 'чужой draft скрыт'); + }); + + it('чужой не может изменить (403)', async () => { + const res = await inject('PUT', '/api/practice/generators/' + gid, { spec: SPEC }, other); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('публикация делает генератор видимым другим', async () => { + const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, teacher); + assert.equal(pub.status, 200); + assert.equal(pub.body.generator.status, 'published'); + const res = await inject('GET', '/api/practice/generators', null, other); + assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден другому'); + }); + + it('автор удаляет свой генератор', async () => { + const res = await inject('DELETE', '/api/practice/generators/' + gid, null, teacher); + assert.equal(res.status, 200); + const after = await inject('GET', '/api/practice/generators/' + gid, null, teacher); + assert.equal(after.status, 404, 'после удаления 404'); + }); +}); diff --git a/frontend/trainer-builder.html b/frontend/trainer-builder.html new file mode 100644 index 0000000..c0b9545 --- /dev/null +++ b/frontend/trainer-builder.html @@ -0,0 +1,371 @@ + + + + + + Конструктор генераторов — LearnSpace + + + + + + + + +
+ +
+
+

Конструктор генераторов

+
Создайте параметрический генератор задач: диапазоны → формулы → шаблон → ответ. Сервер проверит, что ответ согласован с условием.
+ +
+
+

Мои генераторы

+
Загрузка…
+ +
+ +
+

Новый генератор

+ +
+
+
+
+ +
+
+ +
+ +
+ +
имя, от, до — напр. a: 2…9. Зарезервированы: x, e, pi, tau.
+
+ +
+ +
+ +
напр. c = a*root + b. Приём «корень-вперёд»: задайте root и выведите c.
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+ +
+ +
пояснение словами + формула шага (одно равенство), напр. x = {cmb} / {a}.
+
+ +
+ + + +
+ +
+ +
+
+
+
+
+ + + + + + + + + + + diff --git a/frontend/trainer.html b/frontend/trainer.html index 98b5147..fd563ff 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -428,8 +428,12 @@ var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]); var isTeacher = !!(ip && ip.isTeacher); + var customGens = []; // пользовательские генераторы (P13), тема «Мои генераторы» function skillKey(g) { return g.skill || g.id; } - function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; } + function skillsOf(topicKey) { + if (topicKey === 'custom') return customGens; + return TG.byTopic ? TG.byTopic(topicKey) : gens; + } function isWord() { return curTopic === 'word'; } function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); } @@ -998,10 +1002,18 @@ 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; }); }) - .catch(function () {}) - .then(boot); + Promise.all([ + LS.practiceProgressList ? LS.practiceProgressList().catch(function () { return null; }) : Promise.resolve(null), + LS.practiceGenList ? LS.practiceGenList().catch(function () { return null; }) : Promise.resolve(null) + ]).then(function (res) { + var pr = res[0], cgr = res[1]; + if (pr && pr.progress) pr.progress.forEach(function (row) { prog[row.skill] = row; }); + if (cgr && cgr.generators && cgr.generators.length) { + customGens = cgr.generators; + topics.push({ key: 'custom', label: 'Мои генераторы', custom: true }); + } + boot(); + }).catch(boot); })(); diff --git a/js/api.js b/js/api.js index 9fa1058..18081bc 100644 --- a/js/api.js +++ b/js/api.js @@ -1185,6 +1185,7 @@ window.LS = { customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, gameProgressList, gameProgressSubmit, practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats, practiceAuthor, practiceAssign, + practiceGenList, practiceGenGet, practiceGenCreate, practiceGenUpdate, practiceGenDelete, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1427,6 +1428,11 @@ async function practiceGenerate(topic) { return req('POST', '/practice/genera 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 practiceGenList() { return req('GET', '/practice/generators'); } +async function practiceGenGet(id) { return req('GET', '/practice/generators/' + id); } +async function practiceGenCreate(spec, status) { return req('POST', '/practice/generators', { spec, status }); } +async function practiceGenUpdate(id, spec, status) { return req('PUT', '/practice/generators/' + id, { spec, status }); } +async function practiceGenDelete(id) { return req('DELETE', '/practice/generators/' + id); } 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/js/sidebar.js b/js/sidebar.js index 4d3e831..819d410 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -90,6 +90,7 @@ ${G('practice', 'Практика и игры', ` ${L('/lab', 'atom', 'Лаборатория')} ${L('/trainer', 'dumbbell', 'Тренажёр')} + ${L('/trainer-builder', 'wand-2', 'Конструктор задач', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/quantik', 'rocket', 'Квантик: Законы Мира')} ${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/biochem', 'flask-conical', 'Биохимия')} diff --git a/plans/ai-trainer/ROADMAP_V2.md b/plans/ai-trainer/ROADMAP_V2.md index 4623cd7..ef80f2c 100644 --- a/plans/ai-trainer/ROADMAP_V2.md +++ b/plans/ai-trainer/ROADMAP_V2.md @@ -82,8 +82,19 @@ solved-форме `x=c` → общий `onSolved` (засчитывается к прогресс трекается; учитель видит выполнение и результаты; интеграция с journal/homework. - Апгрейд текущего `assign` (уведомление) до отслеживаемого задания (таблица). -## P13 — Конструктор генераторов + управление пулом -Учитель создаёт ПАРАМЕТРИЧЕСКИЕ генераторы (не только одиночные задачи). +## P13 — Конструктор генераторов + управление пулом — DONE (частично) +**Сделано:** таблица `custom_generators` (мигр.**084**, spec_json + status draft/published), +`customGeneratorController` (`validateGenSpec` без исполнения — лимиты/типы; CRUD, +own+published, per-row ownership), роуты `/api/practice/generators[/:id]`, клиент +`LS.practiceGen*`. **Страница-конструктор** `/trainer-builder` (учитель/админ): форма +(заголовок/тема/тип/диапазоны pick/формулы derive/lhs/rhs/display/ответ/решение) + +**живое превью** (тот же `TE.instantiate(strict)` материализует и проверяет ответ +подстановкой) + список своих с правкой/удалением/публикацией. Тренажёр грузит свои+ +опубликованные генераторы в тему **«Мои генераторы»** (пошаговый режим работает и для +них). Пункт сайдбара `/trainer-builder` (teacher-only). Тесты `custom-generators.test.js` +12/12; смоук движка T17 (кастомный спек + strict-валидация). **Осталось (стретч):** +форма для kind roots/simplify/inequality (движок их поддерживает), управление пулом +LLM-задач (P3) из UI, генерация по теме урока. - Визуальный билдер: диапазоны `pick`, формулы `derive`, шаблоны `lhs/rhs`, ответ, шаги решения + live-превью + валидация (отложенный «полный P4»). - Управление пулом (ревью/правка/удаление), генерация по теме урока/§ учебника.