Исходный код ИИ-ассистента на Node.JS. Часть 2

В прошлой статье мы с вами установили всё то, что нам пригодится при разработке ИИ-ассистента, который сможет отвечать на ваши вопросы или вопросы пользователей. В этой статье - код...

Исходный код ИИ-ассистента на Node.JS. Часть 2

Предисловие

Пет-проект, который было решено свернуть, а результаты работы в виде абсолютно рабочего решения опубликовать под MIT лицензией. Скачать архив проекта можно по этой ссылке (Яндекс.Диск).

Используемые технологии:

  • NodeJS;
  • VueJS;
  • Vuetify;
  • RabbitMQ;
  • ElasticSearch;
  • MySQL 8;
  • Transformers.js;
  • Puppeteer;
  • Socket.IO;
  • ONNX Runtime;

Хотите запустить коммерческий проект в срок и бюджет? Топовая команда по разработке и 40+ человек на аутстафе у вас под рукой. Дайте знать.

Андрей Ш.

Структура

Проект состоит из 3 модулей:

  • Backend;
  • Frontend;
  • Виджет;
WebStorm: то, что мы любим.
WebStorm: то, что мы любим.

Зависимости на frontend:

"@mdi/font": "^7.0.96", "@unhead/vue": "^1.0.22", "@vuepic/vue-datepicker": "^4.0.1", "core-js": "^3.8.3", "moment": "^2.29.4", "pinia": "^2.0.23", "roboto-fontface": "*", "socket.io-client": "^4.6.1", "vue": "^3.2.47", "vue-router": "^4.0.0", "vuetify": "^3.0.0", "webfontloader": "^1.0.0"

Зависимости на backend:

"@elastic/elasticsearch": "^8.10.0", "@xenova/transformers": "^2.9.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "amqplib": "^0.10.3", "bcrypt": "^5.0.1", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "install": "^0.13.0", "migrate": "^1.8.0", "moment": "^2.29.4", "mysql2": "^3.3.1", "node-cron": "^3.0.2", "node-fetch": "^3.3.2", "nodemailer": "^6.9.2", "npm": "^9.7.2", "pino": "^8.15.0", "pino-pretty": "^10.2.0", "puppeteer": "^21.5.2", "randomstring": "^1.2.3", "socket.io": "^4.6.1"

Зависимости виджета:

"@mdi/font": "^7.0.96", "pinia": "^2.1.7", "roboto-fontface": "*", "socket.io-client": "^4.6.1", "vue": "^3.3.8", "vuetify": "^3.0.0", "webfontloader": "^1.6.28"

Frontend

Frontend представляет собой SPA-приложение (Single Page Application), которое посредством HTTP-запросов получает данные с сервера. Вся real-time коммуникация и обмен данными происходят по WebSocket, например, работа чата и смена статусов ИИ-ассистента.

Инициация SPA-приложения осуществляется в файле main.js:

import { createApp } from 'vue' import App from './App.vue' import { registerPlugins } from './plugins' import { createHead } from "@unhead/vue" import VueDatePicker from '@vuepic/vue-datepicker' import '@vuepic/vue-datepicker/dist/main.css' import {useUserStore} from "@/store/user.js" // ... создаём приложение const app = createApp(App) registerPlugins(app) // ... инициализация хранилища пользователя useUserStore().init() // HTML meta tags https://unhead.harlanzw.com/guide/getting-started/how-it-works app.use(createHead()) // ... VueDatePicker https://vue3datepicker.com/installation/ app.component('VueDatePicker', VueDatePicker) // ... монтируем app.mount('#app')

Ключевая строка в этом файле:

// ... инициализация хранилища пользователя useUserStore().init()

После вызова метода init() происходит проверка авторизации пользователя, установка websocket-соединения с сервером и прослушивание / обработка событий.

/** * Инициализация пользовательского хранилища. */ function init() { // ... пользовательские credentials по-умолчанию let credentials = { 'X-AUTH-LOGIN': null, 'X-AUTH-TOKEN': null, } // ... проверяем наличие пользовательских credentials в local storage if (LocalStorage.hasItem('user.credentials')) { let item = LocalStorage.getItem('user.credentials') credentials = { 'X-AUTH-LOGIN': item['X-AUTH-LOGIN'], 'X-AUTH-TOKEN': item['X-AUTH-TOKEN'], } } // ... создаём socket-соединение useAppStore().connect(credentials) }

Код авторизации при установке соединения:

/** * Инициализация хранилища приложения - сокеты, общая системная информация, события. * @param {Object} authHeaders авторизационные заголовки socket-соединения */ function connect(authHeaders = {}) { // ... инициализация сокет-соединения с пользовательскими credentials и получение общей информации socket = io(socketConfig.url, { ...socketConfig, auth: authHeaders, }) // ... вешаем на событие подключения сокета запрос на обновление информации об авторизованном пользователе socket.on('connect', async () => { // ... навешиваем обработчики событий на стороне клиента SocketEventController.attachEvents(socket) // ... для неавторизованных не будем отправлять запрос получения информации if (socket.auth['X-AUTH-LOGIN'] === null && socket.auth['X-AUTH-TOKEN'] === null) return // ... получаем данные по пользователю - базовая информация профиля, постоянные уведомления, активную STAGING-конференцию await useUserStore().refreshUser() }) }

Если авторизация прошла успешно, то данные пользователя в локальном Pinia-хранилище обновляются и отображаются на странице:

/** * Инициализация / обновление объекта пользователя. * @param {Object} user */ async function initUser(user) { let credentials = { 'X-AUTH-LOGIN': user.email, 'X-AUTH-TOKEN': user.token, } // ... обновляем объект пользователя в хранилище state.user = user // ... сохраняем credentials пользователя в local storage LocalStorage.saveItem('user.credentials', credentials) // ... обновляем авторизационные заголовки и переподключаемся await useAppStore().reconnectWithAuthHeaders(credentials) // ... генерируем событие локально и для других контекстов (вкладок, окон, фреймов), // чтобы объект пользователя изменился - обновите его тоже LocalEventController.emitEvent(LocalEvent.USER_AUTHORIZED) BroadcastEventController.emitEvent(BroadcastEvent.USER_AUTHORIZED, {user: JSON.parse(JSON.stringify(user))}) } /** * Обновим информацию о текущем авторизованном пользователе. * @param {Object} credentials измененные пользовательские credentials * @returns {Promise} */ async function refreshUser(credentials = null) { // ... переданы ли измененные пользовательские credentials? // если да, то надо производить повторную инициализацию пользователя с переключением сокета с новыми данными (email + token) if (credentials) return initUser({...state.user, ...credentials}) // ... получаем данные по пользователю - базовая информация профиля, постоянные уведомления, активную STAGING-конференцию return useAppStore() .execute(backendActions.USER_GET_INFO) .then(resp => { if (resp.ok) { // ... обновили объект пользователя в хранилище state.user = resp.payload } else { // ... что-то пошло не так - сбрасываем user.credentials LocalStorage.removeItem('user.credentials') } }) }

Затем vue-router определяет загружаемые компоненты согласно описаным маршрутам:

import {createRouter, createWebHistory} from 'vue-router' import {useUserStore} from "@/store/user.js" const routes = [ /** * =============================================== * Авторизация, регистрация, восстановление пароля * =============================================== */ { path: '/auth', redirect: '/', props: true, children: [ { path: 'social', redirect: '/', props: true, children: [ { path: 'vk', name: 'auth_social_vk', props: true, component: () => import('@/screens/auth/SignInVkontakte.vue'), }, { path: 'yandex', name: 'auth_social_yandex', props: true, component: () => import('@/screens/auth/SignInYandex.vue') } ] } ] }, /** * ================ * Главная страница * ================ */ { path: '/', name: 'home', props: true, component: () => import('@/screens/index/Index.vue'), }, /** * ============== * Личный кабинет * ============== */ // ... список ИИ-менеджеров { path: '/my', name: 'my', redirect: '/my/managers', props: true, meta: {requiresAuth: true}, children: [ { path: 'managers', name: 'managers', props: true, component: () => import('../screens/user/AIManagers.vue') }, { path: 'manager-:id', name: 'manager', redirect: to => ({name: 'manager_dashboard', params: {id: to.params.id}}), props: true, component: () => import('../screens/manager/ScreenContainer.vue'), children: [ { path: 'dashboard', name: 'manager_dashboard', props: true, component: () => import('../screens/manager/dashboard/Dashboard.vue') }, { path: 'dialogs', name: 'manager_dialogs', props: true, component: () => import('../screens/manager/dialogs/Dialogs.vue') }, { path: 'pages', name: 'manager_pages', props: true, component: () => import('../screens/manager/pages/IndexedPages.vue') } ] } ] }, /** * =============================== * Demo-примеры интеграций виджета * =============================== */ { path: '/demo', name: 'demo', redirect: '/pack24.ru', props: true, children: [ {path: 'pack24_ru', name: 'demo_pack24', props: true, component: () => import('../screens/demo/Pack24.vue')}, {path: 'productradar_ru', name: 'demo_productradar', props: true, component: () => import('../screens/demo/Productradar.vue')}, ] }, /** * === * 404 * === */ { path: '/:path(.*)*', name: 'not_found', component: () => import('@/screens/404/404.vue'), } ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes, }) router.beforeEach((to, from) => { // ... если пользователь не авторизован, но пытается открыть закрытые разделы - переадресовываем на главную if (to.meta.requiresAuth && !useUserStore().hasCredentialsSaved && !useUserStore().isAuthorized) { return { path: '/', } } return true }) export default router

... и затем уже каждый загружаемый компонент делает запрос к REST API для получения актуальных данных - списков ассистентов, проиндексированных страниц и пр.

Весь frontend и виджет упаковывается в минимальное количество файлов и сжимается. В итоге получается следующая структура директории после сборки:

Исходный код ИИ-ассистента на Node.JS. Часть 2

Сгенерированный виджет затем в виде кода вставляется на произвольный сайт в head-тег.

Backend

Все запросы, приходящие на backend могут быть двух типов - пассивные (я их так называю, это обычные REST API запросы) и активные (запросы по websocket).

index.js на стороне nodejs. выглядит следующим образом:

/** * Загрузка переменных среды окружения и выполнение подготовительных действия для запуска приложения. */ import './bootstrap/bootstrap.js' /** * Остальные импорты */ import express from 'express' import http from 'http' import cors from 'cors' import {Server} from 'socket.io' import SocketController from './controller/websocket/SocketController.js' import HTTPRouter from "./controller/http/routes/HTTPRouter.js" import routes from './controller/http/routes/routes.js' import {config} from './env/env.js' try { // ... получим конфигурацию для сервера let serverConfig = await config('server') // ... создаём приложение и подключаем Socket.IO const app = express() const server = http.createServer(app) const io = new Server(server, serverConfig.socketio) // ... делаем IO доступным глобально - ХЕРОВОЕ решение global.io = io // ... новый посетитель = новое соединение io.on('connection', async (socket) => { // ... сохраняем информацию о подключении при открытии соединения await SocketController.connected(socket) // ... вешаем обработчики действий на сокет await SocketController.attachActions(socket) // ... удаляем информацию об активном сокет-соединении при прерывании соединения socket.on('disconnect', () => SocketController.disconnected(socket)) }) // ... настраиваем HTTP маршруты app.use(cors()) app.use(express.json(serverConfig.express.json)) app.use(express.urlencoded(serverConfig.express.urlencoded)) app.use('/', HTTPRouter.init(express.Router(), routes)) // ... начинаем слушать server.listen(process.env.SERVER_PORT, () => { Logger.info({ENV: process.env.ENV, PORT: process.env.SERVER_PORT}, 'Приложение запустилось и начало слушать порт.') }) } catch (e) { // ... произошла беда - упали Logger.fatal(e, 'Приложение упало') }

Нас больше интересуют действия и события, которые бегают по websocket:

/** * Список действий и связанных обработчиков. * @type {Object} */ const SocketActions = { /** * ====================================================== * Методы авторизации, регистрации, восстановления пароля * ====================================================== */ 'actions:auth:signUp': import('../websocket/actions/auth/SignUp.js'), 'actions:auth:signIn': import('../websocket/actions/auth/SignIn.js'), 'actions:auth:restore': import('../websocket/actions/auth/Restore.js'), 'actions:auth:social:signVK': import('../websocket/actions/auth/SignVK.js'), 'actions:auth:social:signYandex': import('../websocket/actions/auth/SignYandex.js'), /** * ================================ * Действия с профилем пользователя * ================================ */ 'actions:user:getInfo': import('../websocket/actions/user/GetInfo.js'), /** * ====================================== * Действия с ИИ-менеджерами пользователя * ====================================== */ 'actions:user:getAllAIManagers': import('../websocket/actions/user/GetAllAIManagers.js'), 'actions:user:manager:getManagerInfo': import('../websocket/actions/user/manager/GetManagerInfo.js'), 'actions:user:deleteAIManager': import('../websocket/actions/user/DeleteAIManager.js'), 'actions:user:startAIManager': import('../websocket/actions/user/StartAIManager.js'), 'actions:user:pauseAIManager': import('../websocket/actions/user/PauseAIManager.js'), 'actions:user:updateAIManagerPrompts': import('../websocket/actions/user/UpdateAIManagerPrompts.js'), 'actions:user:manager:getWebsiteIndexedPages': import('../websocket/actions/user/manager/GetWebsiteIndexedPages.js'), 'actions:user:manager:isWebsiteIndexedPage': import('../websocket/actions/user/manager/IsWebsiteIndexedPage.js'), 'actions:user:manager:addWebsiteLinkToCrawlQueue': import('../websocket/actions/user/manager/AddWebsiteLinkToCrawlQueue.js'), 'actions:user:manager:reindexWebsite': import('../websocket/actions/user/manager/ReindexWebsite.js'), 'actions:user:manager:clearWebsiteIndex': import('../websocket/actions/user/manager/ClearWebsiteIndex.js'), 'actions:user:manager:deleteWebsitePageIndex': import('../websocket/actions/user/manager/DeleteWebsitePageIndex.js'), 'actions:user:manager:getWebsiteDialogs': import('../websocket/actions/user/manager/GetWebsiteDialogs.js'), 'actions:user:manager:getWebsiteDialogMessages': import('../websocket/actions/user/manager/GetWebsiteDialogMessages.js'), 'actions:user:manager:deleteWebsiteDialog': import('../websocket/actions/user/manager/DeleteWebsiteDialog.js'), 'actions:user:manager:getAnonUserInfo': import('../websocket/actions/user/manager/GetAnonUserInfo.js'), 'actions:user:manager:banAnonUserIp': import('../websocket/actions/user/manager/BanAnonUserIp.js'), 'action:user:manager:analytics:getWebsiteTotalDialogs': import('../websocket/actions/user/manager/analytics/GetWebsiteTotalDialogs.js'), 'action:user:manager:analytics:getWebsiteTotalIndexedPages': import('../websocket/actions/user/manager/analytics/GetWebsiteTotalIndexedPages.js'), /** * ================================================================================== * Действия с финансовыми транзакциями, финансовой статистикой, балансом пользователя * ================================================================================== */ 'actions:user:getPaymentLink': import('../websocket/actions/user/GetPaymentLink.js'), /** * ================================================================== * Методы по работе с сайтом / сайтами (генерация и валидация токенов) * ================================================================== */ 'actions:website:getTemporaryVerificationToken': import('../websocket/actions/website/GetTemporaryVerificationToken.js'), /** * ===================================================================== * Методы по работе с публичным JS-виджетом и для публичного JS-виджета. * ===================================================================== */ 'actions:widget:isActivated': import('../websocket/actions/widget/IsActivated.js'), 'actions:widget:activate': import('../websocket/actions/widget/Activate.js'), 'actions:widget:getMessageHistory': import('../websocket/actions/widget/GetMessageHistory.js'), 'actions:widget:sendMessage': import('../websocket/actions/widget/SendMessage.js'), } export default SocketActions

И ещё точнее, вот эти действия:

/** * ===================================================================== * Методы по работе с публичным JS-виджетом и для публичного JS-виджета. * ===================================================================== */ 'actions:widget:isActivated': import('../websocket/actions/widget/IsActivated.js'), 'actions:widget:activate': import('../websocket/actions/widget/Activate.js'), 'actions:widget:getMessageHistory': import('../websocket/actions/widget/GetMessageHistory.js'), 'actions:widget:sendMessage': import('../websocket/actions/widget/SendMessage.js'),

... которые взаимодействуют с "модулем" ИИ.

Заглянем глубже в ключевой метод - SendMessages.js. Контроллер состоит из 3 методов - подготовки, валидации и обработки данных.

Метод валидации достаточно прост:

async validate({socket, data, response}, next) { Logger.debug({data}, 'SendMessage:validate') const ajv = new Ajv() addFormats(ajv) const schema = { type: 'object', properties: { domain: {type: 'string', format: 'hostname'}, user: { type: 'object', properties: { uuid: {type: 'string', minLength: 1, maxLength: 255, nullable: true}, }, required: ['uuid'], }, message: { type: 'object', properties: { text: {type: 'string', minLength: 1, maxLength: 500}, }, required: ['text'], } }, required: ['domain', 'user', 'message'], additionalProperties: false, }, validate = ajv.compile(schema) if (!validate(data)) return response({ ok: false, error: { message: 'Недействительные данные для выполнения запроса.' } }) // ... получим связанный source_website const website = await SourceWebsiteRepository.findOneByDomain(data.domain) if (!website) return response({ ok: false, error: { message: 'Недействительные данные для выполнения запроса.' } }) next({ socket, website, uuid: data.user.uuid, message: data.message, response, }) }

Метод обработки прост и понятен:

async process({socket, website, uuid, message, response}, next) { Logger.debug({uuid, message}, 'SendMessage:process') // ... найдем socket connection для получения IP и UA let connection = await SocketConnectionRepository.findOneBySocketId(socket.id), visitor = await SocketConnectionRepository.findOneVisitorBySocketConnectionId(connection.id) // ... есть ли UUID? let chatUser = null, isNewUser = false if (!uuid) { // ... новый анонимный пользователь isNewUser = true // ... нет - создадим новый chatUser = await ChatUserRepository.insertOne({ visitor_id: visitor.id, ip: connection.client_ip, ua: connection.client_user_agent, }) } else { // ... да - получим существующий chatUser = await ChatUserRepository.findOneByUUID(uuid) // ... упс, левый UUID - создадим новый if (!chatUser) { // ... новый анонимный пользователь isNewUser = true // ... создаём chatUser = await ChatUserRepository.insertOne({ visitor_id: visitor.id, ip: connection.client_ip, ua: connection.client_user_agent, }) } // ... обновим данные по IP и visitor_id await ChatUserRepository.updateOneByUUID(uuid, { visitor_id: visitor.id, ip: connection.client_ip, }) } // ... если новый анонимный пользователь - создадим контейнер для диалога let chatGroup = await ChatGroupRepository.findOneByChatUserId(chatUser.id) // ... получим существующий контейнер диалога с текущим анонимным пользователем if (!chatGroup) chatGroup = await ChatGroupRepository.insertOne({ source_website_id: website.id, }) // ... создаём сообщение let chatMessage = await ChatMessageRepository.insertOne({ chat_group_id: chatGroup.id, chat_user_id: chatUser.id, payload: JSON.stringify({ text: message.text, }) }) // ... возвращаем ответ пользователю response({ ok: true, payload: { uuid: chatUser.uuid, message: chatMessage, }, }) // ... найдём ИИ-консультанта и поставим ему задачу на ответ только в том случае, если он активен const aiManager = await AIManagerRepository.findOneBySourceWebsiteId(website.id) // ... проверим статус активности if (aiManager.status === AIManagerActivityStatus.WORKING) { // ... проверим, что дневной лимит на кол-во сообщений от консультанта для текущего пользователя не исчерпан let totalAIResponses = await ChatMessageRepository.findTotalAIChatMessagesByAnonUserID(chatUser.id, 24) if (totalAIResponses > MAX_DAILY_RESPONSE_LIMIT) return // ... отправляем сообщение в очередь для обработки ИИ-менеджером ;(await RabbitMQ.instance()).pushToAIResponse({website, message: chatMessage}) } }

Все сообщения пользователя оказываются в очереди на обработку, а метод, который отвечает за обработку (consumer события) навешивается в файле bootstrap.js при загрузке приложения:

/** * Навешивание обработчиков на сообщения из очереди на обход страниц и сохранению данных в базе знаний; */ mq.watchToCrawl(WebsiteCrawler.crawl) mq.watchToKnowledgeBase(KnowledgeBase.save) mq.watchToAIResponse(AIManager.response)

Переходим в AIManager.response метод и смотрим на исходный код:

/** * Обрабатывает полученные данные для формирования ответа на сообщения с учетом: * - основного промпта; * - полученных данных из базы знаний; * - истории диалога (последних Х сообщений); * - последнего или последних сообщений пользователя; * * В результате формирует (создаёт) объект нового сообщения с ответом - новое сообщение в диалоге, сохраняя его в БД * и отправляя по веб-сокету конечному пользователю. * @param {Object} website веб-сайт к которому привязан ИИ-консультант; * @param {Object} message объект последнего сообщения пользователя на которое генерируется ответ; * @return */ static async response({website, message}) { Logger.debug({website, message}, 'AIManager:response') // ... диалог в качестве контекста let dialog = [] // ... проверить возможность обработки сообщения (квоты и срок подписки) let manager = await AIManagerRepository.findOneBySourceWebsiteId(website.id) // ... кончились сообщения или подписка - ничего не делаем if (manager.messages_quota === 0 || manager.subscription_until < Date()) return // ... получить релевантные данные из базы знаний - массив релевантных страниц let kbPages = (await KnowledgeBase.find({website, query: message.payload.text})) .map(page => `${page._source.title}\n${page._source.chunk_content}\nИсточник: ${page._source.url}\n`) .join('\n') kbPages = `${kbPages.length > 0 ? kbPages : 'Отсутствует.'}` // ... получить основной prompt веб-сайта const promptStart = ` ${manager.prompt_manager_description} Направление деятельности компании: ${manager.prompt_company_description} Твои обязанности: 1. Отвечать только на вопросы связанные с направлением деятельности компании; 2. Отвечать на вопросы на основании информации из базы знаний и приводить ссылки на источники; Для ответа тебе будут предоставлены: - История диалога с пользователем (последние 5 сообщений); - Информация из базы знаний; Информация из базы знаний: ${kbPages} Твоя задача: ответить понятно, лаконично, коротко (до 3 предложений) на языке диалога без лишних тегов и разметки, указывая только достоверную информацию на основании базы знаний и описания деятельности компании. Не будь болтливым и не добавляй в текст того о чем тебя не спрашивали. ` // ... добавляем первое сообщение, всегда system prompt dialog = [ {role: 'system', content: promptStart}, ] // ... получить последние Х сообщений из диалога const chatUser = await ChatUserRepository.findOneById(message.chat_user_id) // ... добавляем историю из последних Х сообщений let lastMessages = await ChatMessageRepository.findByUUIDLastX(chatUser.uuid, 5, 0) lastMessages.forEach(message => { dialog.push({ role: message.ai_manager_id === null ? 'user' : 'assistant', content: message.payload.text, }) }) // ... отправить запрос к LLM let response = await VseGPT.call(VseGPTModel.OPENAI_GPT35_TURBO_LATEST, dialog) if (!response.ok) return // ... полученный ответ сохранить в БД const aiMessage = await ChatMessageRepository.insertOne({ chat_group_id: message.chat_group_id, ai_manager_id: manager.id, payload: JSON.stringify({ text: response.text, }) }) Logger.debug({aiMessage}, 'Сгенерированное сообщение от ИИ') // ... и отправить в чат по веб-сокету const visitor = await SocketConnectionRepository.findOneVisitorById(chatUser.visitor_id) // ... может такое быть, что пользователь просто ушел не дождавшись ответа if (visitor) { const connection = await SocketConnectionRepository.findOneById(visitor.socket_connection_id) io.to(connection.socket_id).emit('actions:widget:ai_manager_answered', aiMessage) } // ... обновить значение кол-ва сообщений по квотам await AIManagerRepository.decreaseMessagesQuotaBy(manager.id, 1) }

В этом блоке выполняется одно из основных действий - поиск релевантной информации в базе знаний, динамическое формирование промпта для ChatGPT на основе настроек из личного кабинета, а так же корректная подача всей истории переписки с пользователем для сохранения контекста диалога.

Как факультативное задание для вас - изучить исходный код и разобраться с тем, как работает добавление информации в базу знаний, как простраиваются и сохраняются эмбеддинги и как осуществляется обработка пользовательского запроса. 🙌

Надеюсь, что junior-пет-проект послужит кому-то хорошим стартом.

У меня команда, предлагаем услуги аутсорса и аутстаффа в сфере IT - 40+ опытных разработчиков, тестировщиков, аналитиков, готовых помочь с реализацией проекта любой сложности.

Работаем удаленно над небольшими задачами, и в комплексе внедряем крупные системы.

Если вам нужны профессионалы в сфере веб, мобильной разработки, тестирования ПО, BIG DATA и пр. - обращайтесь.

33
4 комментария

Спасибо, очень полезно и познавательно. Нашел пару интересных моментов которые буду использовать в ближайшее время :)

1
Ответить

Какие именно моменты оказались полезными и интересными?

Ответить