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:
@@ -82,4 +82,53 @@ function submitAttempt(req, res) {
|
|||||||
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
|
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,7 +5,7 @@
|
|||||||
* межпользовательских роутов, проверка владения не требуется. */
|
* межпользовательских роутов, проверка владения не требуется. */
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
const c = require('../controllers/practiceController');
|
const c = require('../controllers/practiceController');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
@@ -13,4 +13,8 @@ router.use(authMiddleware);
|
|||||||
router.get('/progress', c.listProgress);
|
router.get('/progress', c.listProgress);
|
||||||
router.post('/attempt', c.submitAttempt);
|
router.post('/attempt', c.submitAttempt);
|
||||||
|
|
||||||
|
// Текстовые задачи (Уровень 1): пул читают все; генерирует учитель/админ.
|
||||||
|
router.get('/pool', c.listPool);
|
||||||
|
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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, '<').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 };
|
||||||
@@ -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 };
|
||||||
@@ -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('<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+73
-4
@@ -93,6 +93,8 @@
|
|||||||
}
|
}
|
||||||
.tr-skill:hover { border-color: #818cf8; color: #4338ca; }
|
.tr-skill:hover { border-color: #818cf8; color: #4338ca; }
|
||||||
.tr-skill.on { background: #eef2ff; 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; }
|
.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;
|
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 skillKey(g) { return g.skill || g.id; }
|
||||||
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
|
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 curTopic = topics[0] ? topics[0].key : null;
|
||||||
var curGen = skillsOf(curTopic)[0] || gens[0];
|
var curGen = skillsOf(curTopic)[0] || gens[0];
|
||||||
@@ -287,6 +346,12 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
function renderSkills() {
|
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);
|
var ss = skillsOf(curTopic);
|
||||||
$('tr-skills').innerHTML = ss.map(function (g, i) {
|
$('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>';
|
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() {
|
function newProblem() {
|
||||||
|
if (isWord()) { serveWordProblem(); return; }
|
||||||
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
|
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
|
||||||
cur = null;
|
cur = null;
|
||||||
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
|
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
|
||||||
@@ -331,7 +397,7 @@
|
|||||||
// фоновая отправка попытки на сервер (прогресс/мастерство)
|
// фоновая отправка попытки на сервер (прогресс/мастерство)
|
||||||
function submitAttempt(correct) {
|
function submitAttempt(correct) {
|
||||||
if (!LS.practiceSubmit) return;
|
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(); }
|
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); }
|
||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
}
|
}
|
||||||
@@ -358,7 +424,7 @@
|
|||||||
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
|
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
|
||||||
}
|
}
|
||||||
function recordAnswer(correct) {
|
function recordAnswer(correct) {
|
||||||
var sk = skillKey(curGen);
|
var sk = currentSkill();
|
||||||
sessEvents.push({ skill: sk, correct: correct });
|
sessEvents.push({ skill: sk, correct: correct });
|
||||||
sessAnswered++;
|
sessAnswered++;
|
||||||
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
|
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
|
||||||
@@ -366,6 +432,7 @@
|
|||||||
}
|
}
|
||||||
function advance() {
|
function advance() {
|
||||||
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
|
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
|
||||||
|
if (isWord()) { serveWordProblem(); return; } // банк — без адаптивного подбора
|
||||||
if (smart) pickNext();
|
if (smart) pickNext();
|
||||||
newProblem();
|
newProblem();
|
||||||
}
|
}
|
||||||
@@ -435,11 +502,13 @@
|
|||||||
var b = e.target.closest('.tr-chip'); if (!b) return;
|
var b = e.target.closest('.tr-chip'); if (!b) return;
|
||||||
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
|
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
|
||||||
curTopic = t.key;
|
curTopic = t.key;
|
||||||
|
renderTopics();
|
||||||
|
if (t.word) { renderSkills(); loadWordPool(function () { serveWordProblem(); }); return; }
|
||||||
var ss = skillsOf(curTopic);
|
var ss = skillsOf(curTopic);
|
||||||
// первый неосвоенный навык темы, иначе первый
|
// первый неосвоенный навык темы, иначе первый
|
||||||
curGen = ss[0] || curGen;
|
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; } }
|
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) {
|
$('tr-skills').addEventListener('click', function (e) {
|
||||||
var b = e.target.closest('.tr-skill'); if (!b) return;
|
var b = e.target.closest('.tr-skill'); if (!b) return;
|
||||||
|
|||||||
@@ -1184,7 +1184,7 @@ window.LS = {
|
|||||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||||
gameProgressList, gameProgressSubmit,
|
gameProgressList, gameProgressSubmit,
|
||||||
practiceProgressList, practiceSubmit,
|
practiceProgressList, practiceSubmit, practicePool, practiceGenerate,
|
||||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
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 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 practiceProgressList() { return req('GET', '/practice/progress'); }
|
||||||
async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); }
|
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 assistantContext() { return req('GET', '/assistant/context'); }
|
||||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||||
|
|||||||
@@ -75,9 +75,20 @@ practice.test.js 11/11 (+SR box/due).
|
|||||||
- **Acceptance:** сессия из N задач сама ведёт от простого к сложному; промахнутый навык
|
- **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 }`; сервер прогоняет
|
- LLM (через провайдеров админки) генерирует `{ lhs, rhs, answer, story }`; сервер прогоняет
|
||||||
`verifyRoot`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).
|
`verifyRoot`; расхождение → авторетрай с фидбэком («корень не удовлетворяет, исправь»).
|
||||||
|
|||||||
Reference in New Issue
Block a user