feat(trainer): ИИ-тренажёр — генераторы задач + SimExpr-верификатор, прогресс, фича-флаг
- движок _trainer_engine.js: instantiate/generateBatch/verifyRoot/checkStudentAnswer/exprToLatex - 5 генераторов уравнений 7 класса (generators.js), приём «корень-вперёд» → целые ответы - страница /trainer: KaTeX-рендер, чипы-темы, мгновенная проверка, подсказка/решение, авто-выбор навыка - прогресс practice_progress (мигр.081) + /api/practice/progress|attempt + LS.practiceProgressList/Submit - фича-флаг trainer: тумблер в админке (Модули), requireFeature, FEATURE_HREFS (скрытие сайдбара+редирект), MODULE_CATALOG - fix: подключён Lucide CDN на странице (иначе иконки сайдбара пустые) - тесты practice.test.js (10/10); план развития plans/ai-trainer/PLAN.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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-симуляций (учитель/админ).' },
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,339 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Тренажёр — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/css/ls.css"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
|
||||
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
/* ════════════════════ Страница «Тренажёр» (прототип) ════════════════════ */
|
||||
.tr-wrap { max-width: 760px; margin: 0 auto; padding: 26px 20px 80px; }
|
||||
|
||||
.tr-head { margin-bottom: 18px; }
|
||||
.tr-h1 { font-family: 'Manrope', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text, #1e293b); margin: 0 0 4px; }
|
||||
.tr-sub { color: var(--muted, #64748b); font-size: .92rem; }
|
||||
.tr-pill {
|
||||
display: inline-block; font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
|
||||
padding: 3px 10px; border-radius: 99px; background: rgba(99,102,241,0.12); color: #6366f1; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── выбор темы ── */
|
||||
.tr-topics { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 20px; }
|
||||
.tr-chip {
|
||||
font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer;
|
||||
padding: 8px 14px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32);
|
||||
background: #fff; color: #475569; transition: .15s;
|
||||
}
|
||||
.tr-chip:hover { border-color: #818cf8; color: #4f46e5; }
|
||||
.tr-chip.on { background: #6366f1; border-color: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.28); }
|
||||
|
||||
/* ── карточка задачи ── */
|
||||
.tr-card {
|
||||
background: #fff; border: 1px solid rgba(148,163,184,0.22); border-radius: 18px;
|
||||
padding: 28px 26px; box-shadow: 0 14px 40px rgba(15,23,42,0.06);
|
||||
}
|
||||
.tr-skill { color: #64748b; font-size: .82rem; font-weight: 600; margin-bottom: 14px; }
|
||||
.tr-eq {
|
||||
font-family: 'Cambria Math', 'Times New Roman', Georgia, serif;
|
||||
font-size: clamp(1.7rem, 5vw, 2.4rem); font-weight: 600; letter-spacing: .01em;
|
||||
color: #0f172a; text-align: center; padding: 12px 0 22px; user-select: none;
|
||||
}
|
||||
|
||||
.tr-inrow { display: flex; gap: 10px; align-items: stretch; max-width: 420px; margin: 0 auto; }
|
||||
.tr-eqx { font-family: 'Cambria Math', serif; font-size: 1.4rem; color: #475569; align-self: center; }
|
||||
.tr-input {
|
||||
flex: 1; min-width: 0; font: inherit; font-size: 1.15rem; text-align: center;
|
||||
padding: 11px 14px; border-radius: 12px; border: 2px solid rgba(148,163,184,0.4); outline: none; transition: .15s;
|
||||
}
|
||||
.tr-input:focus { border-color: #818cf8; box-shadow: 0 0 0 4px rgba(129,140,248,0.18); }
|
||||
.tr-input:disabled { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
.tr-btn {
|
||||
font: inherit; font-weight: 700; cursor: pointer; border: none; border-radius: 12px;
|
||||
padding: 11px 20px; transition: .15s; display: inline-flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.tr-btn .ic { width: 17px; height: 17px; }
|
||||
.tr-primary { background: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.3); }
|
||||
.tr-primary:hover { background: #4f46e5; }
|
||||
.tr-ghost { background: rgba(148,163,184,0.14); color: #475569; }
|
||||
.tr-ghost:hover { background: rgba(148,163,184,0.24); }
|
||||
|
||||
.tr-feedback { text-align: center; min-height: 26px; margin: 18px 0 4px; font-weight: 700; font-size: 1rem; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.tr-feedback .ic { width: 19px; height: 19px; }
|
||||
.tr-feedback.ok { color: #16a34a; }
|
||||
.tr-feedback.bad { color: #dc2626; }
|
||||
.tr-feedback.warn { color: #d97706; font-weight: 600; }
|
||||
|
||||
.tr-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 8px; }
|
||||
|
||||
.tr-solution {
|
||||
margin-top: 20px; padding: 16px 18px; border-radius: 12px;
|
||||
background: #f8fafc; border: 1px solid rgba(148,163,184,0.22);
|
||||
}
|
||||
.tr-solution h4 { margin: 0 0 10px; font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; color: #64748b; }
|
||||
.tr-step { font-size: 1.05rem; color: #334155; padding: 7px 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
|
||||
.tr-step + .tr-step { border-top: 1px dashed rgba(148,163,184,0.28); }
|
||||
.tr-step-note { color: #64748b; font-family: 'Manrope', sans-serif; font-size: .85rem; }
|
||||
.tr-step-math { font-family: 'Cambria Math', serif; }
|
||||
.tr-eq .katex-display { margin: 0; }
|
||||
|
||||
/* бейджи прогресса на чипах */
|
||||
.tr-badge { display: inline-flex; margin-left: 7px; color: #16a34a; vertical-align: middle; }
|
||||
.tr-badge .ic { width: 14px; height: 14px; }
|
||||
.tr-chip.on .tr-badge { color: #fff; }
|
||||
.tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; }
|
||||
.tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); }
|
||||
|
||||
/* ── статистика ── */
|
||||
.tr-stats { display: flex; gap: 20px; justify-content: center; margin: 22px 0 4px; }
|
||||
.tr-stat { text-align: center; }
|
||||
.tr-stat b { display: block; font-size: 1.5rem; font-weight: 800; color: #4f46e5; font-family: 'Manrope', sans-serif; line-height: 1.1; }
|
||||
.tr-stat span { font-size: .74rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
.tr-note { margin-top: 24px; text-align: center; color: #94a3b8; font-size: .78rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<main class="sb-content">
|
||||
<div class="tr-wrap">
|
||||
<div class="tr-head">
|
||||
<h1 class="tr-h1">Тренажёр<span class="tr-pill" id="tr-subject">Алгебра · 7 класс</span></h1>
|
||||
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-topics" id="tr-topics"></div>
|
||||
|
||||
<div class="tr-card">
|
||||
<div class="tr-skill" id="tr-skill"></div>
|
||||
<div class="tr-eq" id="tr-eq">—</div>
|
||||
|
||||
<div class="tr-inrow">
|
||||
<span class="tr-eqx">x =</span>
|
||||
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
|
||||
placeholder="ответ" aria-label="Ваш ответ"/>
|
||||
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
|
||||
</div>
|
||||
|
||||
<div class="tr-feedback" id="tr-feedback"></div>
|
||||
|
||||
<div class="tr-actions">
|
||||
<button class="tr-btn tr-ghost" id="tr-hint" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2Z"/></svg>
|
||||
Подсказка
|
||||
</button>
|
||||
<button class="tr-btn tr-ghost" id="tr-solve" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Решение
|
||||
</button>
|
||||
<button class="tr-btn tr-ghost" id="tr-skip" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 12 8-12 8z"/><path d="M20 4v16"/></svg>
|
||||
Другая
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tr-solution" id="tr-solution" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="tr-stats">
|
||||
<div class="tr-stat"><b id="tr-solved">0</b><span>решено</span></div>
|
||||
<div class="tr-stat"><b id="tr-streak">0</b><span>серия</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tr-note" id="tr-note"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<!-- безопасный вычислитель + ядро тренажёра (тот же путь, что lab/sim-builder) -->
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/trainer/_trainer_engine.js"></script>
|
||||
<script src="/js/trainer/generators.js"></script>
|
||||
<!-- KaTeX для рендера уравнений и шагов решения -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// initPage редиректит на /login, если не авторизован
|
||||
if (typeof LS === 'undefined') return;
|
||||
var ip = LS.initPage();
|
||||
if (!ip) return;
|
||||
|
||||
// Фича-гейт: «Тренажёр» можно отключить в админке (feature_trainer_enabled).
|
||||
// Сайдбар/доступ скрываются через FEATURE_HREFS; здесь — редирект при прямом заходе.
|
||||
// Админ имеет доступ всегда — он управляет модулями.
|
||||
if (LS.loadFeatures && !ip.isAdmin) {
|
||||
LS.loadFeatures().then(function (feats) {
|
||||
if (feats && feats.trainer === false) { if (LS.toast) LS.toast('Тренажёр отключён', 'warn'); location.href = '/dashboard'; }
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
var TE = window.TrainerEngine, TG = window.TrainerGenerators;
|
||||
var gens = TG.list();
|
||||
|
||||
var ICON = {
|
||||
ok: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
bad: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
||||
star: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function randSeed() { return (Math.random() * 2147483647) | 0; }
|
||||
function fmt(v) { return TE.prettyMath(String(v)); }
|
||||
|
||||
// KaTeX-рендер выражения → html-строка или null (мягкий фолбэк на текст)
|
||||
function kat(latex, display) {
|
||||
if (window.katex && latex) {
|
||||
try { return window.katex.renderToString(latex, { displayMode: !!display, throwOnError: false }); }
|
||||
catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function setMath(el, latex, fallbackText, display) {
|
||||
var h = kat(latex, display);
|
||||
if (h) el.innerHTML = h; else el.textContent = fallbackText;
|
||||
}
|
||||
|
||||
var curGen = gens[0];
|
||||
var cur = null;
|
||||
var solved = 0, streak = 0;
|
||||
var answered = false; // задача закрыта (верно/решение показано) → «Проверить» становится «Дальше»
|
||||
var prog = {}; // skill → строка прогресса с сервера
|
||||
|
||||
function chipBadge(skill) {
|
||||
var p = prog[skill];
|
||||
if (p && p.mastered) return '<span class="tr-badge" title="Освоено">' + ICON.star + '</span>';
|
||||
if (p && p.solved) return '<span class="tr-badge-n">' + p.solved + '</span>';
|
||||
return '';
|
||||
}
|
||||
function renderTopics() {
|
||||
$('tr-topics').innerHTML = gens.map(function (g, i) {
|
||||
return '<button class="tr-chip' + (g === curGen ? ' on' : '') + '" type="button" data-i="' + i + '">' + esc(g.title) + chipBadge(g.skill) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setMode(done) {
|
||||
answered = done;
|
||||
$('tr-check').textContent = done ? 'Дальше' : 'Проверить';
|
||||
}
|
||||
function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; }
|
||||
|
||||
function stepHtml(st) {
|
||||
if (!st) return '';
|
||||
var note = st.note ? '<span class="tr-step-note">' + esc(st.note) + '</span>' : '';
|
||||
var math = '';
|
||||
if (st.latex) { var h = kat(st.latex, false); math = '<span class="tr-step-math">' + (h || esc(st.tex || '')) + '</span>'; }
|
||||
else if (st.tex) { math = '<span class="tr-step-math">' + esc(st.tex) + '</span>'; }
|
||||
return '<div class="tr-step">' + note + math + '</div>';
|
||||
}
|
||||
|
||||
function newProblem() {
|
||||
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
|
||||
cur = null;
|
||||
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
|
||||
if (!cur) { $('tr-eq').textContent = 'Не удалось сгенерировать задачу'; return; }
|
||||
|
||||
$('tr-skill').textContent = curGen.title;
|
||||
setMath($('tr-eq'), cur.latex, cur.display, true);
|
||||
var inp = $('tr-input');
|
||||
inp.value = ''; inp.disabled = false;
|
||||
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
|
||||
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
|
||||
setMode(false);
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
// фоновая отправка попытки на сервер (прогресс/мастерство)
|
||||
function submitAttempt(correct) {
|
||||
if (!LS.practiceSubmit) return;
|
||||
LS.practiceSubmit(curGen.skill, correct).then(function (r) {
|
||||
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderTopics(); }
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function revealAnswer(giveUp) {
|
||||
var s = $('tr-solution');
|
||||
var steps = (cur.solution || []).map(stepHtml).join('');
|
||||
s.innerHTML = '<h4>Решение</h4>' + (steps || '<div class="tr-step">x = ' + esc(fmt(cur.answer)) + '</div>');
|
||||
s.style.display = 'block';
|
||||
if (giveUp) {
|
||||
streak = 0;
|
||||
$('tr-input').disabled = true;
|
||||
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
|
||||
setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
|
||||
setMode(true);
|
||||
updateStats();
|
||||
submitAttempt(false);
|
||||
}
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (answered) { newProblem(); return; }
|
||||
var r = TE.checkStudentAnswer(cur, $('tr-input').value);
|
||||
var fb = $('tr-feedback');
|
||||
if (r.reason === 'empty' || r.reason === 'parse' || r.reason === 'nan') {
|
||||
fb.className = 'tr-feedback warn'; fb.textContent = r.message; return;
|
||||
}
|
||||
if (r.ok) {
|
||||
solved++; streak++;
|
||||
fb.className = 'tr-feedback ok';
|
||||
fb.innerHTML = ICON.ok + ' <span>Верно!</span> ' + (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
|
||||
$('tr-input').disabled = true;
|
||||
setMode(true);
|
||||
submitAttempt(true);
|
||||
} else {
|
||||
streak = 0;
|
||||
fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Пока неверно — попробуй ещё раз.';
|
||||
submitAttempt(false);
|
||||
}
|
||||
updateStats();
|
||||
}
|
||||
|
||||
// ── события ──
|
||||
$('tr-topics').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.tr-chip'); if (!b) return;
|
||||
curGen = gens[+b.getAttribute('data-i')] || gens[0];
|
||||
renderTopics();
|
||||
newProblem();
|
||||
});
|
||||
$('tr-check').addEventListener('click', check);
|
||||
$('tr-skip').addEventListener('click', newProblem);
|
||||
$('tr-hint').addEventListener('click', function () {
|
||||
if (!cur) return;
|
||||
var s = $('tr-solution');
|
||||
s.innerHTML = '<h4>Подсказка</h4>' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null });
|
||||
s.style.display = 'block';
|
||||
});
|
||||
$('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); });
|
||||
$('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } });
|
||||
|
||||
$('tr-note').textContent = 'Прототип: ' + gens.length + ' генераторов · ответ проверяется подстановкой (5, x=5, 10/2, 2+3) · прогресс сохраняется.';
|
||||
|
||||
// загрузка прогресса → старт (авто-выбор первого неосвоенного навыка)
|
||||
function boot() {
|
||||
for (var i = 0; i < gens.length; i++) {
|
||||
var p = prog[gens[i].skill];
|
||||
if (!(p && p.mastered)) { curGen = gens[i]; break; }
|
||||
}
|
||||
renderTopics();
|
||||
newProblem();
|
||||
}
|
||||
(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);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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', 'Биохимия')}
|
||||
|
||||
@@ -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** (адаптивность) для удержания.
|
||||
Reference in New Issue
Block a user