diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js
index e8468db..3646aba 100644
--- a/backend/src/controllers/practiceController.js
+++ b/backend/src/controllers/practiceController.js
@@ -82,4 +82,53 @@ function submitAttempt(req, res) {
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
-module.exports = { listProgress, submitAttempt };
+/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
+const genService = require('../services/practiceGenService');
+const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
+
+function toClientProblem(r) {
+ let solution = [];
+ try { solution = r.solution_json ? JSON.parse(r.solution_json) : []; } catch (e) { solution = []; }
+ return {
+ id: r.id, kind: 'word', topic: r.topic, skill: r.skill,
+ story: r.story, lhsExpr: r.lhs, rhsExpr: r.rhs,
+ answerVar: r.answer_var, answer: r.answer, solution: solution
+ };
+}
+
+/* GET /api/practice/pool?skill=&limit= — одобренные задачи пула (ученикам). */
+function listPool(req, res) {
+ const skill = (req.query && typeof req.query.skill === 'string') ? req.query.skill.trim().slice(0, MAX_SKILL) : '';
+ const limit = Math.min(parseInt((req.query && req.query.limit), 10) || 20, 50);
+ const rows = skill
+ ? db.prepare("SELECT * FROM practice_problems WHERE status='approved' AND (skill = ? OR topic = ?) ORDER BY id DESC LIMIT ?").all(skill, skill, limit)
+ : db.prepare("SELECT * FROM practice_problems WHERE status='approved' ORDER BY id DESC LIMIT ?").all(limit);
+ res.json({ problems: rows.map(toClientProblem) });
+}
+
+/* POST /api/practice/generate { topic } — учитель/админ генерирует задачу в пул.
+ * Сервис проверяет корректность подстановкой; не прошло — в БД НЕ пишем. */
+async function generateProblem(req, res) {
+ const topic = (req.body && typeof req.body.topic === 'string') ? req.body.topic.trim() : 'word-linear';
+ if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
+
+ let result;
+ try { result = await genService.generate(topic, { maxRetries: 3 }); }
+ catch (e) { return res.status(500).json({ error: 'generation failed' }); }
+
+ if (!result.ok) {
+ const code = (result.error === 'off') ? 503 : 422; // нет провайдера → 503; не проверилось → 422
+ return res.status(code).json({ error: result.error, reason: result.reason || null, attempts: result.attempts });
+ }
+
+ const p = result.problem;
+ const info = db.prepare(`
+ INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?)
+ `).run(topic, topic, 1, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
+
+ const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
+ res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
+}
+
+module.exports = { listProgress, submitAttempt, listPool, generateProblem };
diff --git a/backend/src/db/migrations/083_practice_problems.sql b/backend/src/db/migrations/083_practice_problems.sql
new file mode 100644
index 0000000..b51398d
--- /dev/null
+++ b/backend/src/db/migrations/083_practice_problems.sql
@@ -0,0 +1,28 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 083: Пул текстовых задач тренажёра (Уровень 1, Фаза 3).
+--
+-- Кэш сгенерированных LLM и ПРОВЕРЕННЫХ задач: модель предлагает условие +
+-- уравнение (lhs/rhs) + корень, сервер подтверждает подстановкой (practiceVerify)
+-- и только тогда пишет сюда. Ученик берёт готовые задачи из пула (не платим за
+-- генерацию на каждый показ). story и заметки решения уже санитизированы.
+-- status: approved (видна ученикам) | draft (на ревью учителю).
+-- created_by ON DELETE SET NULL — задача переживает удаление автора.
+-- ═══════════════════════════════════════════════════════════════
+
+CREATE TABLE IF NOT EXISTS practice_problems (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ topic TEXT NOT NULL, -- word-linear | word-proportion | word-percent
+ skill TEXT NOT NULL, -- ключ навыка (для прогресса)
+ difficulty INTEGER NOT NULL DEFAULT 1,
+ story TEXT NOT NULL, -- условие словами (экранировано)
+ lhs TEXT NOT NULL, -- левая часть уравнения (выражение от x)
+ rhs TEXT NOT NULL, -- правая часть
+ answer_var TEXT NOT NULL DEFAULT 'x',
+ answer REAL NOT NULL, -- проверенный корень
+ solution_json TEXT, -- шаги [{note,tex}] (JSON)
+ status TEXT NOT NULL DEFAULT 'approved', -- approved | draft
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_practice_problems_skill ON practice_problems (skill, status);
diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js
index 88de346..4a77911 100644
--- a/backend/src/routes/practice.js
+++ b/backend/src/routes/practice.js
@@ -5,7 +5,7 @@
* межпользовательских роутов, проверка владения не требуется. */
const express = require('express');
const router = express.Router();
-const { authMiddleware } = require('../middleware/auth');
+const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/practiceController');
router.use(authMiddleware);
@@ -13,4 +13,8 @@ router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/attempt', c.submitAttempt);
+// Текстовые задачи (Уровень 1): пул читают все; генерирует учитель/админ.
+router.get('/pool', c.listPool);
+router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
+
module.exports = router;
diff --git a/backend/src/services/practiceGenService.js b/backend/src/services/practiceGenService.js
new file mode 100644
index 0000000..bd2e978
--- /dev/null
+++ b/backend/src/services/practiceGenService.js
@@ -0,0 +1,121 @@
+'use strict';
+/* Генерация ТЕКСТОВЫХ задач (Уровень 1) через LLM с ОБЯЗАТЕЛЬНОЙ проверкой.
+ *
+ * LLM предлагает { story, lhs, rhs, answer, solution }; сервер компилирует
+ * выражения через SimExpr и ПОДСТАВЛЯЕТ корень (practiceVerify). Не сходится —
+ * авторетрай с фидбэком об ошибке; не починилось за N попыток — задача
+ * отбрасывается и ученику НЕ попадает (инвариант корректности). Текст условия и
+ * заметки решения экранируются; выражения идут только в SimExpr (без eval).
+ *
+ * LLM-вызов инъектируется (opts.ask) — тесты подают фейковую модель, реальный
+ * вызов берёт провайдеров ассистента (callLLMFailover) лениво.
+ */
+const { verifyRoot, compileOk } = require('../utils/practiceVerify');
+
+const MAX_STORY = 600, MAX_EXPR = 200, MAX_STEPS = 8, MAX_NOTE = 300;
+
+function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
+function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); }
+function sanitizeText(s, n) { return esc(clip(s, n)); }
+
+const TOPIC_HINTS = {
+ 'word-linear': 'линейное уравнение вида a·x + b = c (одна неизвестная x); задачи на возраст, числа, покупки',
+ 'word-proportion': 'пропорцию a/b = c/x (задачи на части, рецепты, скорость)',
+ 'word-percent': 'нахождение процента от числа или числа по проценту'
+};
+
+function buildMessages(topic, opts) {
+ opts = opts || {};
+ const hint = TOPIC_HINTS[topic] || TOPIC_HINTS['word-linear'];
+ const sys =
+ 'Ты генератор школьных задач по математике (7 класс). Возвращай СТРОГО один JSON-объект, ' +
+ 'без markdown и пояснений. Формат: ' +
+ '{"story":"<условие словами на русском>","lhs":"<левая часть уравнения как выражение от x>",' +
+ '"rhs":"<правая часть>","answer":<целое число>,"answerVar":"x",' +
+ '"solution":[{"note":"<пояснение шага словами>","tex":"<один шаг как равенство, выражение>"}]}. ' +
+ 'Уравнение должно соответствовать условию и иметь целый корень. В lhs/rhs/tex — ТОЛЬКО ' +
+ 'математические выражения (символы + - * / ( ) и x), без слов.';
+ let user = 'Составь текстовую задачу на ' + hint + '. Корень — целое число. Верни только JSON.';
+ if (opts.feedback) user += ' Предыдущая попытка отклонена. ' + opts.feedback + ' Верни исправленный JSON.';
+ return [{ role: 'system', content: sys }, { role: 'user', content: user }];
+}
+
+/* Достаём первый JSON-объект из ответа модели (терпимо к обёрткам/markdown). */
+function parseProblem(text) {
+ if (!text) return null;
+ const m = String(text).match(/\{[\s\S]*\}/);
+ if (!m) return null;
+ try { return JSON.parse(m[0]); } catch (e) { return null; }
+}
+
+/* Валидация структуры + КОРРЕКТНОСТЬ (подстановка) + санитизация. */
+function validateAndVerify(obj) {
+ if (!obj || typeof obj !== 'object') return { ok: false, reason: 'no-json' };
+ const story = obj.story, lhs = obj.lhs, rhs = obj.rhs;
+ const answerVar = (typeof obj.answerVar === 'string' && /^[a-z]$/.test(obj.answerVar)) ? obj.answerVar : 'x';
+ const answer = Number(obj.answer);
+
+ if (typeof story !== 'string' || !story.trim()) return { ok: false, reason: 'no-story' };
+ if (typeof lhs !== 'string' || typeof rhs !== 'string') return { ok: false, reason: 'no-expr' };
+ if (lhs.length > MAX_EXPR || rhs.length > MAX_EXPR) return { ok: false, reason: 'expr-too-long' };
+ if (!Number.isFinite(answer)) return { ok: false, reason: 'bad-answer' };
+ if (!compileOk(lhs) || !compileOk(rhs)) return { ok: false, reason: 'expr-parse' };
+
+ const v = verifyRoot(lhs, rhs, answerVar, answer);
+ if (!v.ok) return { ok: false, reason: 'verify-failed' + (v.residual != null ? ' (residual ' + v.residual.toFixed(4) + ')' : '') };
+
+ let solution = [];
+ if (Array.isArray(obj.solution)) {
+ solution = obj.solution.slice(0, MAX_STEPS).map(function (st) {
+ st = st || {};
+ const out = { note: sanitizeText(st.note, MAX_NOTE) };
+ if (typeof st.tex === 'string' && st.tex.length <= MAX_EXPR && compileOk(st.tex)) out.tex = clip(st.tex, MAX_EXPR);
+ else out.tex = '';
+ return out;
+ });
+ }
+
+ return {
+ ok: true,
+ problem: {
+ story: sanitizeText(story, MAX_STORY),
+ lhs: clip(lhs, MAX_EXPR), rhs: clip(rhs, MAX_EXPR),
+ answerVar: answerVar, answer: answer, solution: solution
+ }
+ };
+}
+
+function _defaultAsk(messages, maxTokens) {
+ // лениво, чтобы не тянуть assistantController (и провайдеров) в юнит-тестах
+ const { callLLMFailover } = require('../controllers/assistantController');
+ return callLLMFailover(messages, maxTokens, 20000);
+}
+
+/* Главная: вернёт { ok, problem, attempts } или { ok:false, error, reason, attempts }. */
+async function generate(topic, opts) {
+ opts = opts || {};
+ const ask = opts.ask || _defaultAsk;
+ const maxRetries = Math.max(1, Math.min(opts.maxRetries || 3, 5));
+ let feedback = '', lastReason = 'off';
+
+ for (let i = 0; i < maxRetries; i++) {
+ let res;
+ try { res = await ask(buildMessages(topic, { feedback }), 420); }
+ catch (e) { return { ok: false, error: 'ask-threw', attempts: i }; }
+ if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off', attempts: i };
+
+ const obj = parseProblem(res.text);
+ if (!obj) { feedback = 'Верни строго один JSON-объект без текста вокруг.'; lastReason = 'no-json'; continue; }
+
+ const v = validateAndVerify(obj);
+ if (v.ok) return { ok: true, problem: v.problem, attempts: i + 1 };
+
+ lastReason = v.reason;
+ feedback = 'Причина: ' + v.reason + '. Проверь, что при ' + answerVarOf(obj) + '=' + obj.answer + ' левая часть равна правой.';
+ }
+ return { ok: false, error: 'unverified', reason: lastReason, attempts: maxRetries };
+}
+
+function answerVarOf(obj) { return (obj && typeof obj.answerVar === 'string') ? obj.answerVar : 'x'; }
+
+module.exports = { generate, validateAndVerify, parseProblem, buildMessages };
diff --git a/backend/src/utils/practiceVerify.js b/backend/src/utils/practiceVerify.js
new file mode 100644
index 0000000..ecd2f95
--- /dev/null
+++ b/backend/src/utils/practiceVerify.js
@@ -0,0 +1,31 @@
+'use strict';
+/* Серверная проверка задач тренажёра через SimExpr — тот же безопасный
+ * вычислитель, что на клиенте (⛔ без eval/new Function). Гарантирует, что любая
+ * задача (от LLM или учителя) КОРРЕКТНА: подставляем заявленный корень в обе
+ * части уравнения и сверяем с допуском. SimExpr — чистый (без DOM), грузится в
+ * Node через require: его IIFE цепляется к globalThis.SimExpr. */
+require('../../../frontend/js/labs/_sim_expr.js'); // → globalThis.SimExpr
+const SimExpr = globalThis.SimExpr;
+
+const EPS = 1e-7;
+
+/* Компиляция выражения; null при синтаксической ошибке (мусор от модели). */
+function compileOk(expr) {
+ if (typeof expr !== 'string') return null;
+ const c = SimExpr.compile(expr);
+ return (c && !c.error) ? c : null;
+}
+
+/* Подстановочная проверка: lhs(var=value) ≈ rhs(var=value). */
+function verifyRoot(lhs, rhs, varName, value) {
+ const cl = compileOk(lhs), cr = compileOk(rhs);
+ if (!cl || !cr) return { ok: false, reason: 'parse' };
+ if (typeof value !== 'number' || !isFinite(value)) return { ok: false, reason: 'bad-value' };
+ const env = {}; env[varName || 'x'] = value;
+ const L = cl.fn(env), R = cr.fn(env);
+ const residual = Math.abs(L - R);
+ const scale = Math.max(1, Math.abs(L), Math.abs(R));
+ return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
+}
+
+module.exports = { SimExpr, compileOk, verifyRoot };
diff --git a/backend/tests/practice-gen.test.js b/backend/tests/practice-gen.test.js
new file mode 100644
index 0000000..0da24f7
--- /dev/null
+++ b/backend/tests/practice-gen.test.js
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Tests: текстовые задачи тренажёра (Уровень 1) — генерация + проверка + пул.
+ * - validateAndVerify: корректную принимает, неверный корень/мусор отвергает, текст экранирует.
+ * - generate (LLM застаблен): валидная с 1 попытки; ретраи; провал → unverified; провайдер off.
+ * - endpoints: /generate только учитель/админ (403 ученику; 503 без провайдера); /pool отдаёт пул.
+ */
+const { describe, it, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const { app, db, inject, getToken, cleanup } = require('./setup');
+const gen = require('../src/services/practiceGenService');
+
+app.use('/api/practice', require('../src/routes/practice'));
+after(() => cleanup());
+
+const GOOD = { story: 'Задумали число x: 3x + 4 = 19. Найдите x.', lhs: '3*x + 4', rhs: '19', answer: 5, answerVar: 'x', solution: [{ note: 'Перенесём 4', tex: '3*x = 15' }] };
+
+describe('practiceGenService.validateAndVerify', () => {
+ it('принимает корректную задачу и экранирует текст', () => {
+ const v = gen.validateAndVerify(GOOD);
+ assert.equal(v.ok, true, v.reason);
+ assert.equal(v.problem.answer, 5);
+ assert.ok(v.problem.story.indexOf('') === -1 && v.problem.story.indexOf('<b>') !== -1, 'story escaped');
+ assert.equal(v.problem.solution[0].tex, '3*x = 15');
+ });
+
+ it('отвергает неверный корень (подстановка не сходится)', () => {
+ const v = gen.validateAndVerify(Object.assign({}, GOOD, { answer: 6 }));
+ assert.equal(v.ok, false);
+ assert.ok(/verify-failed/.test(v.reason), v.reason);
+ });
+
+ it('отвергает невалидное выражение', () => {
+ const v = gen.validateAndVerify(Object.assign({}, GOOD, { lhs: '3x +' }));
+ assert.equal(v.ok, false);
+ assert.equal(v.reason, 'expr-parse');
+ });
+
+ it('отвергает без условия', () => {
+ const v = gen.validateAndVerify(Object.assign({}, GOOD, { story: '' }));
+ assert.equal(v.ok, false);
+ assert.equal(v.reason, 'no-story');
+ });
+
+ it('сбрасывает мусорный tex шага в пустую строку', () => {
+ const v = gen.validateAndVerify(Object.assign({}, GOOD, { solution: [{ note: 'ok', tex: 'не выражение!!!' }] }));
+ assert.equal(v.ok, true);
+ assert.equal(v.problem.solution[0].tex, '');
+ });
+});
+
+describe('practiceGenService.generate (LLM застаблен)', () => {
+ const askValid = async () => ({ text: '```json\n' + JSON.stringify(GOOD) + '\n```' });
+ const askWrong = async () => ({ text: JSON.stringify(Object.assign({}, GOOD, { answer: 99 })) });
+ const askOff = async () => ({ text: null, error: 'off' });
+
+ it('валидная задача с первой попытки', async () => {
+ const r = await gen.generate('word-linear', { ask: askValid, maxRetries: 3 });
+ assert.equal(r.ok, true);
+ assert.equal(r.attempts, 1);
+ assert.equal(r.problem.answer, 5);
+ });
+
+ it('ретраит и берёт валидную со второй попытки', async () => {
+ let n = 0;
+ const ask = async () => { n++; return n === 1 ? { text: 'мусор без json' } : { text: JSON.stringify(GOOD) }; };
+ const r = await gen.generate('word-linear', { ask, maxRetries: 3 });
+ assert.equal(r.ok, true);
+ assert.equal(r.attempts, 2);
+ });
+
+ it('неверный корень N раз → unverified (в пул не попадёт)', async () => {
+ const r = await gen.generate('word-linear', { ask: askWrong, maxRetries: 3 });
+ assert.equal(r.ok, false);
+ assert.equal(r.error, 'unverified');
+ assert.equal(r.attempts, 3);
+ });
+
+ it('нет провайдера → off', async () => {
+ const r = await gen.generate('word-linear', { ask: askOff, maxRetries: 3 });
+ assert.equal(r.ok, false);
+ assert.equal(r.error, 'off');
+ });
+});
+
+describe('/api/practice pool endpoints', () => {
+ let teacher, student;
+ before(async () => {
+ teacher = (await getToken('teacher')).token;
+ student = (await getToken('student')).token;
+ });
+
+ it('POST /generate запрещён ученику (403)', async () => {
+ const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, student);
+ assert.equal(res.status, 403, `got ${res.status}`);
+ });
+
+ it('POST /generate учителю без провайдера → 503 (off)', async () => {
+ const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, teacher);
+ assert.equal(res.status, 503, `got ${res.status}`);
+ assert.equal(res.body.error, 'off');
+ });
+
+ it('POST /generate неизвестная тема → 400', async () => {
+ const res = await inject('POST', '/api/practice/generate', { topic: 'nope' }, teacher);
+ assert.equal(res.status, 400, `got ${res.status}`);
+ });
+
+ it('GET /pool отдаёт одобренные задачи', async () => {
+ db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status)
+ VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run();
+ const res = await inject('GET', '/api/practice/pool?skill=word-linear', null, student);
+ assert.equal(res.status, 200, `got ${res.status}`);
+ assert.ok(Array.isArray(res.body.problems));
+ const p = res.body.problems.find(x => x.skill === 'word-linear');
+ assert.ok(p, 'pool problem present');
+ assert.equal(p.kind, 'word');
+ assert.equal(p.lhsExpr, '3*x + 4');
+ assert.equal(p.answer, 5);
+ });
+});
diff --git a/frontend/trainer.html b/frontend/trainer.html
index 57b5bec..2f21191 100644
--- a/frontend/trainer.html
+++ b/frontend/trainer.html
@@ -93,6 +93,8 @@
}
.tr-skill:hover { border-color: #818cf8; color: #4338ca; }
.tr-skill.on { background: #eef2ff; border-color: #818cf8; color: #4338ca; }
+ .tr-pool-info { font-size: .82rem; color: #64748b; align-self: center; margin-right: 4px; }
+ #tr-gen-btn { border-style: dashed; color: #4f46e5; }
/* бейджи прогресса на чипах */
.tr-badge { display: inline-flex; margin-left: 7px; color: #16a34a; vertical-align: middle; }
@@ -254,9 +256,66 @@
if (h) el.innerHTML = h; else el.textContent = fallbackText;
}
- var topics = TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }];
+ var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
+ var isTeacher = !!(ip && ip.isTeacher);
function skillKey(g) { return g.skill || g.id; }
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
+ function isWord() { return curTopic === 'word'; }
+ function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); }
+
+ // ── пул текстовых задач (Уровень 1, LLM + серверная проверка) ──
+ var wordPool = [], wordIdx = 0, wordLoading = false;
+ function toWordProblem(p) {
+ return {
+ kind: 'word', skill: p.skill || 'word-linear', title: 'Текстовая задача',
+ display: p.story, latex: null,
+ lhsExpr: p.lhsExpr, rhsExpr: p.rhsExpr, answerVar: p.answerVar || 'x', answer: p.answer,
+ solution: (p.solution || []).map(function (st) {
+ var tex = st.tex || '';
+ return { note: st.note || '', tex: tex ? TE.prettyMath(tex) : '', latex: tex ? TE.exprToLatex(tex) : null };
+ })
+ };
+ }
+ function loadWordPool(done) {
+ if (!LS.practicePool) { wordPool = []; if (done) done(); return; }
+ wordLoading = true; renderSkills();
+ LS.practicePool('word-linear').then(function (r) {
+ wordPool = ((r && r.problems) || []).map(toWordProblem); wordIdx = 0;
+ }).catch(function () { wordPool = []; }).then(function () {
+ wordLoading = false; renderSkills(); if (done) done();
+ });
+ }
+ function serveWordProblem() {
+ var eq = $('tr-eq'); eq.classList.add('tr-eq-text');
+ $('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
+ var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
+ if (!wordPool.length) {
+ cur = null;
+ $('tr-skill').textContent = 'Текстовые задачи';
+ eq.textContent = wordLoading ? 'Загрузка…' : (isTeacher ? 'Банк пуст. Нажмите «Сгенерировать задачу».' : 'Здесь появятся текстовые задачи.');
+ $('tr-input').disabled = true; setMode(false);
+ return;
+ }
+ cur = wordPool[wordIdx % wordPool.length]; wordIdx++;
+ $('tr-skill').textContent = cur.title;
+ setMath(eq, null, cur.display, true); // условие как текст
+ var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
+ setMode(false); inp.focus();
+ }
+ function genWordProblem() {
+ var gb = $('tr-gen-btn'); if (gb) { gb.disabled = true; gb.textContent = 'Генерирую…'; }
+ LS.practiceGenerate('word-linear').then(function (r) {
+ if (r && r.ok && r.problem) {
+ wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
+ if (LS.toast) LS.toast('Задача добавлена (проверена за ' + r.attempts + ' попыт.)', 'success');
+ serveWordProblem();
+ }
+ renderSkills();
+ }).catch(function () {
+ if (LS.toast) LS.toast('Не удалось сгенерировать (LLM-провайдер не настроен?)', 'error');
+ renderSkills();
+ });
+ }
var curTopic = topics[0] ? topics[0].key : null;
var curGen = skillsOf(curTopic)[0] || gens[0];
@@ -287,6 +346,12 @@
}).join('');
}
function renderSkills() {
+ if (isWord()) {
+ var btn = isTeacher ? '' : '';
+ $('tr-skills').innerHTML = '' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '' + btn;
+ var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem);
+ return;
+ }
var ss = skillsOf(curTopic);
$('tr-skills').innerHTML = ss.map(function (g, i) {
return '';
@@ -311,6 +376,7 @@
}
function newProblem() {
+ if (isWord()) { serveWordProblem(); return; }
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
cur = null;
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
@@ -331,7 +397,7 @@
// фоновая отправка попытки на сервер (прогресс/мастерство)
function submitAttempt(correct) {
if (!LS.practiceSubmit) return;
- LS.practiceSubmit(skillKey(curGen), correct).then(function (r) {
+ LS.practiceSubmit(currentSkill(), correct).then(function (r) {
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); }
}).catch(function () {});
}
@@ -358,7 +424,7 @@
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
}
function recordAnswer(correct) {
- var sk = skillKey(curGen);
+ var sk = currentSkill();
sessEvents.push({ skill: sk, correct: correct });
sessAnswered++;
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
@@ -366,6 +432,7 @@
}
function advance() {
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
+ if (isWord()) { serveWordProblem(); return; } // банк — без адаптивного подбора
if (smart) pickNext();
newProblem();
}
@@ -435,11 +502,13 @@
var b = e.target.closest('.tr-chip'); if (!b) return;
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
curTopic = t.key;
+ renderTopics();
+ if (t.word) { renderSkills(); loadWordPool(function () { serveWordProblem(); }); return; }
var ss = skillsOf(curTopic);
// первый неосвоенный навык темы, иначе первый
curGen = ss[0] || curGen;
for (var i = 0; i < ss.length; i++) { var p = prog[skillKey(ss[i])]; if (!(p && p.mastered)) { curGen = ss[i]; break; } }
- renderTopics(); renderSkills(); newProblem();
+ renderSkills(); newProblem();
});
$('tr-skills').addEventListener('click', function (e) {
var b = e.target.closest('.tr-skill'); if (!b) return;
diff --git a/js/api.js b/js/api.js
index 05e1eef..68a7417 100644
--- a/js/api.js
+++ b/js/api.js
@@ -1184,7 +1184,7 @@ window.LS = {
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
gameProgressList, gameProgressSubmit,
- practiceProgressList, practiceSubmit,
+ practiceProgressList, practiceSubmit, practicePool, practiceGenerate,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
@@ -1422,6 +1422,8 @@ 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 practicePool(skill) { return req('GET', '/practice/pool' + (skill ? ('?skill=' + encodeURIComponent(skill)) : '')); }
+async function practiceGenerate(topic) { return req('POST', '/practice/generate', { topic: topic || 'word-linear' }); }
async function 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/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md
index dad928d..a0a52c0 100644
--- a/plans/ai-trainer/PLAN.md
+++ b/plans/ai-trainer/PLAN.md
@@ -75,9 +75,20 @@ practice.test.js 11/11 (+SR box/due).
- **Acceptance:** сессия из N задач сама ведёт от простого к сложному; промахнутый навык
всплывает повторно; прогресс переживает перезаход.
-## Phase 3 — Уровень 1: LLM-задачи с верификацией
+## Phase 3 — Уровень 1: LLM-задачи с верификацией — DONE
-**Цель:** текстовые/контекстные задачи, которых не даёт параметрика.
+**Сделано:** серверная проверка `backend/src/utils/practiceVerify.js` (грузит `SimExpr`
+в Node через require, `verifyRoot` подстановкой). Сервис `practiceGenService.js`:
+`buildMessages`→LLM→`parseProblem`→`validateAndVerify` (компиляция SimExpr + подстановка
+корня + санитизация story/шагов) с **авторетраем по фидбэку**; LLM-вызов инъектируется
+(`opts.ask`, дефолт — `assistantController.callLLMFailover`). Пул `practice_problems`
+(мигр.**083**, status approved/draft). Эндпоинты: `POST /api/practice/generate`
+(учитель/админ) + `GET /api/practice/pool` (ученикам). Клиент: `LS.practicePool/Generate`,
+тема **«Текстовые задачи»** на странице (берёт из пула; учителю — кнопка «Сгенерировать»).
+Гарантия: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт.
+Тесты `practice-gen.test.js` 13/13 (verify, ретраи, off→503, 403 ученику, пул).
+
+**Цель (исходная):** текстовые/контекстные задачи, которых не даёт параметрика.
- LLM (через провайдеров админки) генерирует `{ lhs, rhs, answer, story }`; сервер прогоняет
`verifyRoot`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).