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`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).