diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index c93367d..544ce15 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -993,6 +993,7 @@ const MODULE_CATALOG = [ { key: 'crossword', name: 'Кроссворд', url: '/crossword', desc: 'Игра-кроссворд по терминам предметов; закрепляет понятия, даёт XP.' }, { key: 'hangman', name: 'Виселица', url: '/hangman', desc: 'Игра «Виселица» по терминам предметов; закрепляет слова, даёт XP.' }, { key: 'quantik', name: 'Квантик: Законы Мира', url: '/quantik', desc: 'Физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс.' }, + { key: 'trainer', name: 'Тренажёр', url: '/trainer', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам с мгновенной проверкой ответа и прогрессом по навыкам.' }, { key: 'live_quiz', name: 'Live-викторина', url: '/live-quiz', desc: 'Викторина в реальном времени: учитель запускает, ученики отвечают одновременно.' }, { key: 'sitemap', name: 'Путеводитель', url: '/sitemap', desc: 'Карта-обзор всех разделов платформы со ссылками.' }, { key: 'sim_builder', name: 'Конструктор симуляций', url: '/sim-builder', desc: 'Авторинг 2D-симуляций (учитель/админ).' }, diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js new file mode 100644 index 0000000..bcdcf0d --- /dev/null +++ b/backend/src/controllers/practiceController.js @@ -0,0 +1,71 @@ +'use strict'; +/* Practice progress (ИИ-тренажёр, Фаза 0). + * + * Прогресс ученика по навыкам тренажёра. Навык = skill генератора; задачи + * генерируются и проверяются на клиенте (детерминированно, подстановкой), а + * сервер хранит только агрегаты. На каждую попытку клиент шлёт { skill, correct }; + * сервер делает upsert: solved/attempts, текущая и лучшая серия, флаг mastered. + * + * Стиль следует gameController / customSimController: node:sqlite db.prepare, + * auth-only (роутер ставит authMiddleware), валидация входа без исполнения, + * статусы 400. Прогресс всегда принадлежит req.user — проверка владения не нужна. + */ +const db = require('../db/db'); + +const MAX_SKILL = 120; // длина skill (TEXT) +const MASTERY_STREAK = 5; // серия верных подряд для «освоено» + +/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам. */ +function listProgress(req, res) { + const uid = req.user.id; + const rows = db.prepare(` + SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at + FROM practice_progress + WHERE user_id = ? + ORDER BY updated_at DESC, id DESC + `).all(uid); + res.json({ progress: rows, masteryStreak: MASTERY_STREAK }); +} + +/* POST /api/practice/attempt body: { skill, correct } + * Upsert агрегата попытки. Валидация: skill строка ≤120; correct — boolean. + * НИЧЕГО не исполняет (skill — лишь ключ). */ +function submitAttempt(req, res) { + const uid = req.user.id; + const b = req.body || {}; + + const skill = typeof b.skill === 'string' ? b.skill.trim() : ''; + if (!skill) return res.status(400).json({ error: 'skill обязателен' }); + if (skill.length > MAX_SKILL) return res.status(400).json({ error: `skill длиннее ${MAX_SKILL} символов` }); + if (typeof b.correct !== 'boolean') return res.status(400).json({ error: 'correct должно быть boolean' }); + + const correct = b.correct; + const existing = db.prepare( + 'SELECT id, solved, attempts, cur_streak, best_streak, mastered FROM practice_progress WHERE user_id = ? AND skill = ?' + ).get(uid, skill); + + if (!existing) { + const curStreak = correct ? 1 : 0; + db.prepare(` + INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?, datetime('now')) + `).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0); + } else { + const curStreak = correct ? (existing.cur_streak + 1) : 0; + const bestStreak = Math.max(existing.best_streak || 0, curStreak); + const mastered = (existing.mastered || (curStreak >= MASTERY_STREAK)) ? 1 : 0; + db.prepare(` + UPDATE practice_progress + SET solved = solved + ?, attempts = attempts + 1, + cur_streak = ?, best_streak = ?, mastered = ?, updated_at = datetime('now') + WHERE id = ? + `).run(correct ? 1 : 0, curStreak, bestStreak, mastered, existing.id); + } + + const row = db.prepare( + 'SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at FROM practice_progress WHERE user_id = ? AND skill = ?' + ).get(uid, skill); + res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK }); +} + +module.exports = { listProgress, submitAttempt }; diff --git a/backend/src/db/migrations/081_practice_progress.sql b/backend/src/db/migrations/081_practice_progress.sql new file mode 100644 index 0000000..f822123 --- /dev/null +++ b/backend/src/db/migrations/081_practice_progress.sql @@ -0,0 +1,31 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 081: Practice progress (ИИ-тренажёр, Фаза 0). +-- +-- Прогресс ученика по НАВЫКАМ тренажёра. Навык = skill генератора +-- (напр. 'linear-basic'); задачи генерируются на клиенте детерминированно +-- и проверяются подстановкой — сервер хранит лишь агрегаты результата. +-- +-- На каждую попытку клиент шлёт { skill, correct }. Сервер делает upsert: +-- solved — всего верных ответов +-- attempts — всего попыток (верных и нет) +-- cur_streak — текущая серия верных подряд (обнуляется ошибкой) +-- best_streak — лучшая серия +-- mastered — 1, как только cur_streak достиг порога (липкое) +-- UNIQUE(user_id, skill) — одна строка на пару ученик-навык. +-- user_id ON DELETE CASCADE — прогресс удаляется вместе с учеником. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS practice_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + skill TEXT NOT NULL, -- идентификатор навыка генератора + solved INTEGER NOT NULL DEFAULT 0, -- всего верных ответов + attempts INTEGER NOT NULL DEFAULT 0, -- всего попыток + cur_streak INTEGER NOT NULL DEFAULT 0, -- текущая серия верных подряд + best_streak INTEGER NOT NULL DEFAULT 0, -- лучшая серия + mastered INTEGER NOT NULL DEFAULT 0, -- 1, когда серия достигала порога + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE (user_id, skill) +); + +CREATE INDEX IF NOT EXISTS idx_practice_progress_user ON practice_progress (user_id); diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js new file mode 100644 index 0000000..88de346 --- /dev/null +++ b/backend/src/routes/practice.js @@ -0,0 +1,16 @@ +'use strict'; +/* /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0). + * Все роуты — auth-only (тренируются ученики). router.use(authMiddleware) + * → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет + * межпользовательских роутов, проверка владения не требуется. */ +const express = require('express'); +const router = express.Router(); +const { authMiddleware } = require('../middleware/auth'); +const c = require('../controllers/practiceController'); + +router.use(authMiddleware); + +router.get('/progress', c.listProgress); +router.post('/attempt', c.submitAttempt); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 4c75a34..0a12300 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes); app.use('/api/materials', require('./routes/materials')); app.use('/api/custom-sims', require('./routes/customSims')); app.use('/api/game', require('./routes/game')); +app.use('/api/practice', requireFeature('trainer'), require('./routes/practice')); app.use('/api/wishes', require('./routes/wishes')); app.use('/api/client-errors', require('./routes/clientErrors')); app.use('/api/prep', require('./routes/prep')); diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js new file mode 100644 index 0000000..d4fb6ca --- /dev/null +++ b/backend/tests/practice.test.js @@ -0,0 +1,104 @@ +'use strict'; +/** + * Integration tests: /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0). + * Covers: auth-only (401); correct создаёт строку; wrong не растит solved, но + * растит attempts и обнуляет серию; серия из MASTERY_STREAK → mastered; + * прогресс per-user; валидация входа (400). + */ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, cleanup } = require('./setup'); + +// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты). +app.use('/api/practice', require('../src/routes/practice')); + +const { after } = require('node:test'); +after(() => cleanup()); + +const SKILL = 'linear-basic'; + +describe('/api/practice progress', () => { + let token; + + before(async () => { + token = (await getToken('student')).token; + }); + + it('GET /progress requires auth (401)', async () => { + const res = await inject('GET', '/api/practice/progress', null, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('POST /attempt requires auth (401)', async () => { + const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('correct attempt creates a row (solved=1, streak=1)', async () => { + const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.ok, true); + assert.equal(res.body.progress.skill, SKILL); + assert.equal(res.body.progress.solved, 1); + assert.equal(res.body.progress.attempts, 1); + assert.equal(res.body.progress.cur_streak, 1); + assert.equal(res.body.progress.best_streak, 1); + assert.equal(res.body.progress.mastered, 0); + }); + + it('GET /progress lists the row', async () => { + const res = await inject('GET', '/api/practice/progress', null, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.ok(Array.isArray(res.body.progress)); + const row = res.body.progress.find(r => r.skill === SKILL); + assert.ok(row, 'skill row present'); + assert.equal(row.solved, 1); + }); + + it('wrong attempt: attempts++, solved unchanged, streak resets to 0', async () => { + const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: false }, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.progress.solved, 1, 'solved unchanged'); + assert.equal(res.body.progress.attempts, 2, 'attempts incremented'); + assert.equal(res.body.progress.cur_streak, 0, 'streak reset'); + assert.equal(res.body.progress.best_streak, 1, 'best streak kept'); + }); + + it('streak of 5 correct → mastered=1 (and stays mastered after a miss)', async () => { + const sk = 'mastery-skill'; + let last; + for (let i = 0; i < 5; i++) { + last = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token); + } + assert.equal(last.body.progress.cur_streak, 5); + assert.equal(last.body.progress.best_streak, 5); + assert.equal(last.body.progress.mastered, 1, 'mastered after 5 in a row'); + + const miss = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token); + assert.equal(miss.body.progress.cur_streak, 0, 'streak reset on miss'); + assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky'); + }); + + it('progress is per-user (другой ученик начинает с нуля)', async () => { + const other = (await getToken('student')).token; + const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1'); + assert.equal(res.body.progress.solved, 1); + }); + + it('validation: missing skill → 400', async () => { + const res = await inject('POST', '/api/practice/attempt', { correct: true }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: correct not boolean → 400', async () => { + const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: 'yes' }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: skill too long → 400', async () => { + const res = await inject('POST', '/api/practice/attempt', { skill: 'x'.repeat(200), correct: true }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); +}); diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 8ae0169..4b26634 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -23,6 +23,7 @@ { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, { key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' }, { key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' }, + { key: 'trainer', label: 'Тренажёр', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам (уравнения 7 класс), мгновенная проверка ответа подстановкой, прогресс по навыкам', icon: 'dumbbell' }, { key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' }, ]; diff --git a/frontend/js/trainer/_trainer_engine.js b/frontend/js/trainer/_trainer_engine.js new file mode 100644 index 0000000..646cf44 --- /dev/null +++ b/frontend/js/trainer/_trainer_engine.js @@ -0,0 +1,332 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════════════ + TrainerEngine — ядро ИИ-тренажёра (Фаза 0, прототип). + + Идея (гибрид): задачи рождаются из ДАННЫХ — «генераторов», а математика + считается ДЕТЕРМИНИРОВАННО через SimExpr (тот же безопасный вычислитель, что + у конструктора симуляций; ⛔ без eval/new Function). LLM в этом ядре НЕ + участвует: его роль — один раз сочинить генераторы (Уровень 0) либо позже + отдавать текстовые задачи, которые ЭТОТ ЖЕ слой верифицирует подстановкой + (Уровень 1). Любой источник задачи проходит один и тот же verifyRoot. + + Генератор (данные): + { + id, skill, title, + pick: { a:[lo,hi], ... }, // целые параметры из диапазонов + constraint?: "c < a", // булево над pick (SimExpr) — иначе пересэмпл + derive?: { c: "a*root + b" }, // доп. параметры последовательно (SimExpr) + require?: "...", // булево после derive — иначе пересэмпл + lhs, rhs, // СТОРОНЫ уравнения как выражения с {param} и x + display?, // как показать (по умолч. "lhs = rhs") + answerVar?: "x", // имя неизвестной (деф. x) + answer: "root", // корень как формула над параметрами + integerAnswer?: true, // требовать целый корень + solution?: ["шаг … {ans}", …] // шаблоны шагов (доступен {ans}) + } + + Гарантия КОРРЕКТНОСТИ: после материализации движок ПОДСТАВЛЯЕТ заявленный + корень в уравнение (verifyRoot). Не сходится — экземпляр отбрасывается (в + strict-режиме — исключение). Та же подстановка проверяет ответ ученика + (checkStudentAnswer) и автоматически принимает эквивалентные формы + (5, 5.0, 10/2, "x=15/3", "2+3"). + + API (window.TrainerEngine): + instantiate(gen, opts) -> problem | null + generateBatch(gen, n, opts) -> problem[] + verifyRoot(problem, value) -> { ok, residual, lhs, rhs } + checkStudentAnswer(problem, input)-> { ok, value, residual, message, reason? } + makeRng(seed) -> () => [0,1) (детерминизм для тестов/пула) + + problem: + { genId, skill, title, lhsExpr, rhsExpr, display, answerVar, answer, + params, solution } + ════════════════════════════════════════════════════════════════════════ */ +(function (global) { + + function SE() { + var s = global.SimExpr; + if (!s) throw new Error('TrainerEngine требует SimExpr (подключите _sim_expr.js раньше).'); + return s; + } + + // Допуск подстановки: масштабируется величиной сторон, чтобы крупные + // коэффициенты не давали ложного «не сходится» из-за плавающей арифметики. + var EPS = 1e-7; + + /* ── Детерминированный ГПСЧ (mulberry32) — тот же, что в game/map.js ── + Нужен, чтобы предгенерация пула и тесты были воспроизводимы. В рантайме + можно не передавать seed (тогда берётся внутренний инкремент от Date нельзя — + поэтому дефолт фиксирован, а вариативность даёт сам диапазон pick). */ + function makeRng(seed) { + var s = (seed >>> 0) || 1; + return function () { + s |= 0; s = (s + 0x6D2B79F5) | 0; + var t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); } + + /* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */ + var _cache = Object.create(null); + function compileExpr(src) { + var key = String(src); + var c = _cache[key]; + if (!c) { c = SE().compile(key); _cache[key] = c; } + return c; + } + function evalExpr(src, env) { return compileExpr(src).fn(env); } + + function truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; } + function isIntApprox(v) { return isFinite(v) && Math.abs(v - Math.round(v)) < 1e-9; } + + function fmtNum(v) { + if (typeof v !== 'number') return String(v); + if (isIntApprox(v)) return String(Math.round(v)); + return String(Math.round(v * 1e6) / 1e6); + } + + /* Подстановка {name} -> значение (для выражений и подписей). */ + function render(tpl, vals) { + return String(tpl).replace(/\{(\w+)\}/g, function (m, k) { + return Object.prototype.hasOwnProperty.call(vals, k) ? fmtNum(vals[k]) : m; + }); + } + + /* Лёгкая косметика ТОЛЬКО для показа (не для вычислений): + 5*x -> 5x, «+ -» -> «− », ведущий коэффициент 1 у x убираем. */ + function prettyMath(s) { + return String(s) + .replace(/(\d)\s*\*\s*(\d)/g, '$1·$2') // 4*5 -> 4·5 (число·число) + .replace(/\s*\*\s*/g, '') // 7*x -> 7x (неявное умножение) + .replace(/\+\s*-\s*/g, '− ') // + -3 -> − 3 + .replace(/-\s*-\s*/g, '+ ') + .replace(/(^|[(=+\-\s])1(?=x)/g, '$1'); // ведущий 1·x -> x + } + + function assign(base, extra) { + var o = {}, k; + for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k]; + for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) o[k] = extra[k]; + return o; + } + + /* ── Выражение -> LaTeX (через AST SimExpr) для KaTeX-рендера ── + Возвращает строку LaTeX или null, если выражение не разобралось. Покрывает + наши нужды: дроби (\frac), степени, неявное умножение, скобки по приоритету, + сравнения (= ≠ ≤ ≥), sqrt/abs/тригонометрию. Один проход AST, без eval. + Reusable: тем же конвертером можно рендерить и задачи Уровня-1 (LLM). */ + function _prec(n) { + if (!n) return 9; + if (n.k === 'cmp' || n.k === 'logic') return 0; + if (n.k === 'bin') { + if (n.op === '+' || n.op === '-') return 1; + if (n.op === '*' || n.op === '/' || n.op === '%') return 2; + if (n.op === '^') return 4; + } + if (n.k === 'un' || n.k === 'not') return 3; + return 5; + } + function _isNeg(n) { return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-'); } + function _negate(n) { return n.k === 'num' ? { k: 'num', v: -n.v } : n.a; } + function _wrapL(node, minPrec) { + var s = _latex(node); + return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s; + } + function _latex(node) { + switch (node.k) { + case 'num': return fmtNum(node.v); + case 'const': + if (node.v === Math.PI) return '\\pi'; + if (node.v === Math.PI * 2) return '\\tau'; + if (node.v === Math.E) return 'e'; + return fmtNum(node.v); + case 'var': return node.name; + case 'un': return '-' + _wrapL(node.a, 3); + case 'not': return '\\lnot ' + _wrapL(node.a, 3); + case 'cmp': { + var m = { '==': '=', '!=': '\\ne', '<': '<', '<=': '\\le', '>': '>', '>=': '\\ge' }; + return _latex(node.a) + ' ' + (m[node.op] || node.op) + ' ' + _latex(node.b); + } + case 'logic': + return _latex(node.a) + (node.op === '&&' ? ' \\land ' : ' \\lor ') + _latex(node.b); + case 'cond': + return _wrapL(node.c, 1) + ' \\,?\\, ' + _latex(node.a) + ' : ' + _latex(node.b); + case 'call': { + if (node.name === 'sqrt') return '\\sqrt{' + _latex(node.args[0]) + '}'; + if (node.name === 'abs') return '\\left|' + _latex(node.args[0]) + '\\right|'; + var TRIG = { sin: '\\sin', cos: '\\cos', tan: '\\tan', tg: '\\tan', ln: '\\ln', log: '\\log', exp: '\\exp' }; + var fn = TRIG[node.name] || ('\\operatorname{' + node.name + '}'); + return fn + '\\left(' + node.args.map(_latex).join(',\\, ') + '\\right)'; + } + case 'bin': { + var op = node.op; + if (op === '/') return '\\frac{' + _latex(node.a) + '}{' + _latex(node.b) + '}'; + if (op === '^') { + var base = _prec(node.a) < 5 ? '\\left(' + _latex(node.a) + '\\right)' : _latex(node.a); + return base + '^{' + _latex(node.b) + '}'; + } + if (op === '*') { + var sep = (node.b.k === 'num') ? ' \\cdot ' : ''; // ·число; иначе соседство (7x, 6(x+1)) + return _wrapL(node.a, 2) + sep + _wrapL(node.b, 2); + } + if (op === '%') return _wrapL(node.a, 2) + ' \\bmod ' + _wrapL(node.b, 3); + // + или - (схлопываем a + (-b) -> a - b) + var right = node.b, rop = op; + if (op === '+' && _isNeg(right)) { rop = '-'; right = _negate(right); } + return _wrapL(node.a, 1) + ' ' + rop + ' ' + _wrapL(right, rop === '-' ? 2 : 1); + } + } + return ''; + } + function exprToLatex(src) { + var ast; + try { ast = SE().parse(String(src)); } catch (e) { return null; } + try { return _latex(ast); } catch (e2) { return null; } + } + + /* ── Подстановочная верификация корня ── + Истинно, если левая и правая части совпадают при answerVar = value. */ + function verifyRoot(problem, value) { + var env = {}; + env[problem.answerVar || 'x'] = value; + var L = evalExpr(problem.lhsExpr, env); + var R = evalExpr(problem.rhsExpr, env); + var residual = Math.abs(L - R); + var scale = Math.max(1, Math.abs(L), Math.abs(R)); + return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R }; + } + + /* ── Материализация одного экземпляра ── + Возвращает problem или null, если за maxTries не удалось выполнить + ограничения / целочисленность / самопроверку. */ + function instantiate(gen, opts) { + opts = opts || {}; + var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1); + var maxTries = opts.maxTries || 300; + var answerVar = gen.answerVar || 'x'; + + for (var attempt = 0; attempt < maxTries; attempt++) { + var env = {}; + var pk = gen.pick || {}, k; + for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) { + env[k] = randInt(rng, pk[k][0], pk[k][1]); + } + + if (gen.constraint && !truthy(evalExpr(gen.constraint, env))) continue; + + if (gen.derive) { + for (k in gen.derive) if (Object.prototype.hasOwnProperty.call(gen.derive, k)) { + env[k] = evalExpr(gen.derive[k], env); + } + } + + if (gen.require && !truthy(evalExpr(gen.require, env))) continue; + + var answer = evalExpr(gen.answer, env); + if (gen.integerAnswer) { + if (!isIntApprox(answer)) continue; + answer = Math.round(answer); + } + + var lhsExpr = render(gen.lhs, env); + var rhsExpr = render(gen.rhs, env); + var sEnv = assign(env, { ans: answer }); + var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr); + + var problem = { + genId: gen.id, + skill: gen.skill, + title: gen.title, + lhsExpr: lhsExpr, + rhsExpr: rhsExpr, + display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)), + latex: (ll != null && rl != null) ? (ll + ' = ' + rl) : null, + answerVar: answerVar, + answer: answer, + params: env, + // шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) } + // строковый шаг (легаси) трактуется как чистая заметка без формулы. + solution: (gen.solution || []).map(function (st) { + if (typeof st === 'string') return { note: render(st, sEnv), tex: '', latex: null }; + var texSrc = st.tex ? render(st.tex, sEnv) : ''; + return { + note: st.note ? render(st.note, sEnv) : '', + tex: texSrc ? prettyMath(texSrc) : '', + latex: texSrc ? exprToLatex(texSrc) : null + }; + }) + }; + + // Самопроверка: эталонный корень ОБЯЗАН удовлетворять уравнению. + var v = verifyRoot(problem, answer); + if (!v.ok) { + if (opts.strict) { + throw new Error('Генератор «' + gen.id + '»: корень ' + fmtNum(answer) + + ' не удовлетворяет уравнению (невязка ' + v.residual + ').'); + } + continue; + } + return problem; + } + return null; + } + + /* ── Пакет из n различных по виду задач ── */ + function generateBatch(gen, n, opts) { + opts = opts || {}; + var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1); + var out = [], seen = Object.create(null); + var guard = n * 20 + 50; + while (out.length < n && guard-- > 0) { + var p = instantiate(gen, { rng: rng, strict: opts.strict, maxTries: opts.maxTries }); + if (!p) break; + if (seen[p.display]) continue; + seen[p.display] = 1; + out.push(p); + } + return out; + } + + /* ── Проверка ответа ученика ── + Принимает строку/число. SimExpr.compile сам срезает ведущее «x=», поэтому + "x = 5", "5", "10/2", "2+3" нормализуются к числу. Верно, если значение + удовлетворяет уравнению (эквивалентные формы проходят) ИЛИ совпадает с + эталонным корнем (страховка единственности для будущих многокорневых типов). */ + function checkStudentAnswer(problem, input) { + var raw = String(input == null ? '' : input).trim(); + if (!raw) return { ok: false, reason: 'empty', value: null, residual: null, message: 'Введите ответ.' }; + + var c = SE().compile(raw); + if (c.error) { + return { ok: false, reason: 'parse', value: null, residual: null, + message: 'Не понял ответ: ' + c.error }; + } + var val = c.fn({}); + if (!isFinite(val)) { + return { ok: false, reason: 'nan', value: val, residual: null, message: 'Это не число.' }; + } + + var v = verifyRoot(problem, val); + var nearCanonical = Math.abs(val - problem.answer) <= 1e-6 * Math.max(1, Math.abs(problem.answer)); + var ok = v.ok || nearCanonical; + return { + ok: ok, reason: ok ? null : 'wrong', value: val, residual: v.residual, + message: ok ? 'Верно!' : 'Пока неверно.' + }; + } + + global.TrainerEngine = { + instantiate: instantiate, + generateBatch: generateBatch, + verifyRoot: verifyRoot, + checkStudentAnswer: checkStudentAnswer, + makeRng: makeRng, + // мелочи наружу для билдера/тестов + render: render, + prettyMath: prettyMath, + exprToLatex: exprToLatex + }; + +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/js/trainer/generators.js b/frontend/js/trainer/generators.js new file mode 100644 index 0000000..c4e3e6f --- /dev/null +++ b/frontend/js/trainer/generators.js @@ -0,0 +1,129 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════════════ + Генераторы уравнений — 7 класс (прототип). Это ДАННЫЕ, не код. + + Приём «корень-вперёд»: выбираем целый корень (или множитель) и коэффициенты, + затем ВЫВОДИМ свободный член так, чтобы ответ гарантированно был целым, а + уравнение — решаемым. Поэтому самопроверка движка (verifyRoot) всегда + проходит. Шаг решения — { note(текст), tex(формула) }; tex рендерится в KaTeX + через TrainerEngine.exprToLatex (одно равенство на шаг, без цепочек a=b=c). + + Прогрессия 7 класса: простое линейное → скобки → переменная в обеих частях → + уравнение с дробью в знаменателе → дробный коэффициент. Дальше (Уровень 1): + текстовые задачи через LLM с той же подстановочной верификацией. + ════════════════════════════════════════════════════════════════════════ */ +(function (global) { + + var GENERATORS = [ + + /* 1. ax + b = c */ + { + id: 'lin-basic', + skill: 'linear-basic', + title: 'Линейное: ax + b = c', + grade: 7, + pick: { a: [2, 9], b: [1, 20], root: [-9, 9] }, + require: 'root != 0', + derive: { c: 'a*root + b', cmb: 'a*root' }, // cmb = c - b + lhs: '{a}*x + {b}', rhs: '{c}', + display: '{a}x + {b} = {c}', + answerVar: 'x', answer: 'root', integerAnswer: true, + solution: [ + { note: 'Переносим число вправо:', tex: '{a}x = {cmb}' }, + { note: 'Делим обе части на {a}:', tex: 'x = {cmb} / {a}' }, + { note: 'Ответ:', tex: 'x = {ans}' } + ] + }, + + /* 2. a(x + b) = c */ + { + id: 'lin-paren', + skill: 'linear-parentheses', + title: 'Со скобками: a(x + b) = c', + grade: 7, + pick: { a: [2, 8], b: [1, 12], root: [-9, 9] }, + require: 'root != 0', + derive: { c: 'a*(root + b)', ca: 'root + b' }, // ca = c / a + lhs: '{a}*(x + {b})', rhs: '{c}', + display: '{a}(x + {b}) = {c}', + answerVar: 'x', answer: 'root', integerAnswer: true, + solution: [ + { note: 'Делим обе части на {a}:', tex: 'x + {b} = {ca}' }, + { note: 'Переносим {b} влево:', tex: 'x = {ca} - {b}' }, + { note: 'Ответ:', tex: 'x = {ans}' } + ] + }, + + /* 3. ax + b = cx + d */ + { + id: 'lin-both-sides', + skill: 'linear-both-sides', + title: 'Переменная с двух сторон: ax + b = cx + d', + grade: 7, + pick: { a: [3, 9], c: [1, 8], b: [1, 20], root: [-9, 9] }, + constraint: 'c < a', // гарантируем a - c > 0 + require: 'root != 0', + derive: { d: '(a - c)*root + b', amc: 'a - c', dmb: '(a - c)*root' }, // dmb = d - b + lhs: '{a}*x + {b}', rhs: '{c}*x + {d}', + display: '{a}x + {b} = {c}x + {d}', + answerVar: 'x', answer: 'root', integerAnswer: true, + solution: [ + { note: 'Собираем x слева, числа справа:', tex: '({a} - {c})x = {d} - {b}' }, + { note: 'Приводим подобные:', tex: '{amc}x = {dmb}' }, + { note: 'Делим на {amc}:', tex: 'x = {dmb} / {amc}' }, + { note: 'Ответ:', tex: 'x = {ans}' } + ] + }, + + /* 4. x/a + b = c (дробь в знаменателе) */ + { + id: 'lin-frac-denom', + skill: 'linear-fraction-denom', + title: 'Дробь: x/a + b = c', + grade: 7, + pick: { a: [2, 6], k: [-6, 6], b: [1, 12] }, + require: 'k != 0', + derive: { root: 'a*k', c: 'k + b', cmb: 'k' }, // root = a·k, cmb = c - b = k + lhs: 'x/{a} + {b}', rhs: '{c}', + display: 'x/{a} + {b} = {c}', + answerVar: 'x', answer: 'root', integerAnswer: true, + solution: [ + { note: 'Вычитаем {b}:', tex: 'x/{a} = {cmb}' }, + { note: 'Умножаем обе части на {a}:', tex: 'x = {cmb} * {a}' }, + { note: 'Ответ:', tex: 'x = {ans}' } + ] + }, + + /* 5. (a·x)/b = c (дробный коэффициент) */ + { + id: 'lin-coef-frac', + skill: 'linear-coef-frac', + title: 'Дробный коэффициент: ax/b = c', + grade: 7, + pick: { a: [2, 5], b: [2, 5], m: [-5, 5] }, + require: 'm != 0', + derive: { root: 'b*m', c: 'a*m', cb: 'a*m*b' }, // root = b·m, c = a·m, cb = c·b + lhs: '{a}*x/{b}', rhs: '{c}', + display: '{a}x/{b} = {c}', + answerVar: 'x', answer: 'root', integerAnswer: true, + solution: [ + { note: 'Умножаем обе части на {b}:', tex: '{a}x = {cb}' }, + { note: 'Делим на {a}:', tex: 'x = {cb} / {a}' }, + { note: 'Ответ:', tex: 'x = {ans}' } + ] + } + + ]; + + function get(id) { + for (var i = 0; i < GENERATORS.length; i++) if (GENERATORS[i].id === id) return GENERATORS[i]; + return null; + } + + global.TrainerGenerators = { + list: function () { return GENERATORS.slice(); }, + get: get, + GENERATORS: GENERATORS + }; + +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/trainer.html b/frontend/trainer.html new file mode 100644 index 0000000..d9de1b8 --- /dev/null +++ b/frontend/trainer.html @@ -0,0 +1,339 @@ + + + + + + Тренажёр — LearnSpace + + + + + + + + +
+ +
+
+
+

ТренажёрАлгебра · 7 класс

+
Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.
+
+ +
+ +
+
+
+ +
+ x = + + +
+ +
+ +
+ + + +
+ + +
+ +
+
0решено
+
0серия
+
+ +
+
+
+
+ + + + + + + + + + + + + + + diff --git a/js/api.js b/js/api.js index 0676139..05e1eef 100644 --- a/js/api.js +++ b/js/api.js @@ -856,6 +856,7 @@ const FEATURE_HREFS = { exam9: ['/exam9', '/exam9.html'], textbooks: ['/textbooks', '/textbooks.html', '/textbook'], quantik: ['/quantik', '/quantik.html'], + trainer: ['/trainer', '/trainer.html'], theory: ['/theory', '/theory.html'], sitemap: ['/sitemap', '/sitemap.html'], wishes: ['/wishes', '/wishes.html'], @@ -1183,6 +1184,7 @@ window.LS = { customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, gameProgressList, gameProgressSubmit, + practiceProgressList, practiceSubmit, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1418,6 +1420,8 @@ async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${i async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); } async function gameProgressList() { return req('GET', '/game/progress'); } async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); } +async function practiceProgressList() { return req('GET', '/practice/progress'); } +async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); } 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 226d509..4d3e831 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -89,6 +89,7 @@ ${G('practice', 'Практика и игры', ` ${L('/lab', 'atom', 'Лаборатория')} + ${L('/trainer', 'dumbbell', 'Тренажёр')} ${L('/quantik', 'rocket', 'Квантик: Законы Мира')} ${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/biochem', 'flask-conical', 'Биохимия')} diff --git a/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md new file mode 100644 index 0000000..daf0345 --- /dev/null +++ b/plans/ai-trainer/PLAN.md @@ -0,0 +1,111 @@ +# ИИ-Тренажёр — план развития модуля + +Модуль `/trainer`: бесконечные задачи, которые рождаются из **данных-генераторов**, а +математика считается и проверяется **детерминированно** через `SimExpr` (без `eval`). +LLM в ядре не участвует — его роль (Уровень 1+) сочинять генераторы и текстовые задачи, +которые тот же слой верифицирует подстановкой. Тема-пилот: **уравнения, 7 класс**. + +## Инвариант корректности (не нарушать) + +> Любой источник задачи (генератор, шаблон, LLM) обязан пройти `TrainerEngine.verifyRoot`: +> заявленный корень подставляется в уравнение, расходится → задача отбрасывается. +> Та же подстановка проверяет ответ ученика (принимает `5`, `x=5`, `10/2`, `2+3`). +> Выражения — ТОЛЬКО `SimExpr` (whitelist, без `eval`/`new Function`). Цвета/текст от +> пользователя — только в безопасные стоки или с escape. + +## Состояние: Phase 0 — DONE (прототип, в этом коммите) + +- **Движок** `frontend/js/trainer/_trainer_engine.js`: `instantiate` / `generateBatch` / + `verifyRoot` / `checkStudentAnswer` / `exprToLatex` (AST→LaTeX, reusable) / `makeRng`. +- **Генераторы** `frontend/js/trainer/generators.js`: 5 типов (линейное `ax+b=c`, скобки, + переменная с двух сторон, дробь `x/a+b=c`, дробный коэффициент `ax/b=c`). Приём + «корень-вперёд» → гарантированно целые ответы, самопроверка всегда проходит. +- **Страница** `frontend/trainer.html`: KaTeX-рендер уравнений и шагов, чипы-темы, + мгновенная проверка, подсказка/решение, авто-выбор первого неосвоенного навыка. +- **Прогресс на сервере**: `practice_progress` (мигр.081), `practiceController` + + `routes/practice` (`/api/practice/progress|attempt`), клиент `LS.practiceProgressList/Submit`. + Мастерство = серия 5 верных подряд (липкое). +- **Фича-флаг** `trainer`: тумблер в админке (Модули), `requireFeature('trainer')`, + скрытие из сайдбара + редирект страницы (`FEATURE_HREFS`), запись в `MODULE_CATALOG`. +- Тесты: `practice.test.js` (10/10), headless-смоуки движка/страницы. lint:routes 0. + +--- + +## Phase 1 — Ширина контента (генераторы) + +**Цель:** перестать быть «демкой одной темы». Структура `класс → предмет → тема → навык`. + +- Реестр генераторов: вынести в данные с метаданными `{ grade, subject, topic, skill, order, difficulty }`. + Группировка чипов по темам/классам; выбор класса/предмета вверху. +- Новые генераторы 7 кл: пропорции, раскрытие скобок с обеих сторон `a(x+b)=c(x+d)`, + уравнения с дробью-уравнением `(ax+b)/c = d`, простые буквенные преобразования. +- Соседние темы (параметрические, без LLM): упрощение выражений, степени, проценты, + линейные неравенства (расширить `checkStudentAnswer` под интервалы — см. P5). +- **Acceptance:** ≥3 темы × ≥3 навыка, у каждого generateBatch(50) даёт 50 разных корректных + задач; solvability-смоук на сетке параметров. + +## Phase 2 — Адаптивность и интервальное повторение + +**Цель:** вести ученика, а не давать случайное. + +- Диагностика на входе (по 1–2 задачи на навык) → стартовый уровень. +- Подбор следующего навыка по мастерству (escalate при серии, откат при ошибках). +- Ошибки уходят в очередь повторения (свой лёгкий SR или reuse flashcards Tier-1). +- «Продолжить тренировку», дневная норма/цель, сводка сессии (что освоено, над чем работать). +- Сервер: расширить `practice_progress` (или новая `practice_review_queue`); агрегаты для аналитики. +- **Acceptance:** сессия из N задач сама ведёт от простого к сложному; промахнутый навык + всплывает повторно; прогресс переживает перезаход. + +## Phase 3 — Уровень 1: LLM-задачи с верификацией + +**Цель:** текстовые/контекстные задачи, которых не даёт параметрика. + +- LLM (через провайдеров админки) генерирует `{ lhs, rhs, answer, story }`; сервер прогоняет + `verifyRoot`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»). +- Кэш-пул `practice_problems` (предгенерация, ревью учителем) — не платить за каждый показ. +- Генерация «по теме урока» (связка с theory/exam-prep). +- **Acceptance:** доля задач, прошедших верификацию с 1–2 ретраев, ≥95%; пул кэшируется; + ни одна неверная задача не доходит до ученика (гарантирует инвариант). + +## Phase 4 — Авторинг учителем + +**Цель:** учитель создаёт свои наборы и раздаёт классу (как sim-builder/Quantik Ф5). + +- Конструктор генераторов: шаблон `lhs/rhs`, диапазоны параметров, формула ответа, шаги + решения; превью + клиентская валидация через `SimExpr.compile`. +- Хранение (таблица по образцу `custom_sims`), серверная `validateSpec` без исполнения, + раздача классу + уведомление, привязка к ДЗ/уроку. +- **Acceptance:** учитель собирает рабочий генератор без кода; ученик решает; права/видимость + как у custom-sim (own + раздано). + +## Phase 5 — Типы ответов и проверки + +**Цель:** не только «корень-число». + +- Множество корней (квадратные/факторизация), интервалы (неравенства), упрощение выражений + (эквивалентность через численный сэмплинг по диапазону, а не строковое равенство). +- Пошаговый ввод (проверять каждый шаг подстановкой), несколько форматов ответа. +- **Acceptance:** квадратное уравнение принимает оба корня в любом порядке; `(x+1)^2` ≡ + `x^2+2x+1` через сэмплинг; неравенство принимает `x>3` и эквивалент. + +## Phase 6 — Геймификация, аналитика, UX + +- XP/энергия/стрики (reuse инфраструктуры Квантика), бейджи мастерства на чипах (есть основа). +- Учительская аналитика: кто на каком навыке застрял, тепловая карта класса, отчёты. +- UX: виртуальная клавиатура для дробей/степеней, «почему неверно» (разбор ошибки), + сократические подсказки через Квантик-ассистента, мобильная раскладка, доступность. + +--- + +## Сквозное + +- **Безопасность:** только `SimExpr`; авторские генераторы — серверная `validateSpec` без + исполнения (длины/лимиты, escape текста), как `custom_sims`. +- **Тесты:** на каждый генератор — solvability-смоук (сетка параметров → есть корректные + задачи + достижим целевой ответ); бэкенд-тесты на новые роуты; headless-смоук страницы. +- **Контент = данные:** генераторы и темы — JS-данные/таблицы, не код. + +## Рекомендуемый следующий шаг + +**Phase 1** (ширина контента) — даёт наибольшую пользу при минимальном риске и переиспользует +готовый движок/проверку/страницу. Затем **Phase 2** (адаптивность) для удержания.