feat(trainer): P3 — текстовые задачи от LLM с серверной проверкой подстановкой

- practiceVerify.js: грузит SimExpr в Node (require), verifyRoot подстановкой корня
- practiceGenService.js: LLM (инъектируемый ask) → parse → validateAndVerify (SimExpr + подстановка + санитизация) → авторетрай по фидбэку; дефолт ask = assistantController.callLLMFailover
- пул practice_problems (мигр.083); POST /api/practice/generate (учитель/админ) + GET /api/practice/pool
- инвариант: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт
- клиент: LS.practicePool/Generate, тема «Текстовые задачи» (из пула; учителю кнопка «Сгенерировать»)
- тесты practice-gen.test.js 13/13 (verify, ретраи, off→503, 403 ученику, пул); смоуки страница 26/26; план P3 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:00:39 +03:00
parent 48a73d9f8e
commit 8c4c9bf04c
9 changed files with 445 additions and 9 deletions
+50 -1
View File
@@ -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 };
@@ -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);
+5 -1
View File
@@ -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;
+121
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 };
+31
View File
@@ -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 };
+121
View File
@@ -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: <b>3x + 4 = 19</b>. Найдите 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('<b>') === -1 && v.problem.story.indexOf('&lt;b&gt;') !== -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);
});
});
+73 -4
View File
@@ -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 ? '<button class="tr-skill" id="tr-gen-btn" type="button">+ Сгенерировать задачу</button>' : '';
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + 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 '<button class="tr-skill' + (g === curGen ? ' on' : '') + '" type="button" data-si="' + i + '">' + esc(g.title) + skillBadge(g) + '</button>';
@@ -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;
+3 -1
View File
@@ -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 }); }
+13 -2
View File
@@ -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`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).