Продолжая начатую тему хочется поделиться успешным опытом создания билингвистического Node.JS бота на Microsoft Bot Framework под Linux. От заказчика поступила задача разработать простой социальный бот в формате вопрос-ответ для большой торговой сети, однако сложность заключалась в другом — бот должен быть двуязычным: на английском и арабском. Хотя, как будет показано ниже, выбор инструментов для решения задачи сделал разработку лёгкой, приятной и интересной.
Как и ранее выбор фреймворка был сделан в пользу Microsoft Bot Framework, который имеет огромное количество функционала, сильно облегчающего построение и развёртывание бота: управление потоками диалогов, триггерные действия, сохранение состояния, красочные интерактивные сообщения, лёгкое подключение каналов, таких как Facebook Messenger, Skype, WebChat и много другого. Как оказалось, в нём также присутствует очень простой и удобный механизм локализации (о нём ниже).
Для распознавания смысла сообщений пользователя можно воспользоваться системой ИИ, такой как LUIS, IBM Watson, Google Dialogflow (Api.ai) и др. Естественнее и удобнее для BotBuilder использовать LUIS: есть встроенные в Bot Framework методы, классы и т.д. Однако в LUIS пока нет арабского языка — второй язык, на котором по требованию заказчика должен был работать бот. Поэтому выбор пал на IBM Watson, у которого, как оказалось, значительно более развитый функционал, стабильность и удобство работы. Заказчик изначально думал о возможности создания 2-х ботов, однако огромное разнообразие инструментов в IBM Watson и Bot Framework позволило легко объединить функционал в одном. Далее расскажем о том как это можно сделать.
Выбираем новую папку, в которой будет находиться проект и запускаем:
npm init
Устанавливаем необходимые пакеты для построения бота, подключения к Watson и асинхронных запросов:
npm install dotenv
npm install restify
npm install botbuilder
npm install watson-developer-cloud
npm install request-promise
Создаём файл app.js
и копируем нижеследующий код:
var restify = require('restify');
var builder = require('botbuilder');
var Conversation = require('watson-developer-cloud/conversation/v1'); // watson sdk
require('dotenv').config({
silent: true
});
var contexts;
var workspace = process.env.WORKSPACE_ID;
// Setup Restify Server
var server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, function() {
console.log('%s listening to %s', server.name, server.url);
});
// Create the service wrapper
var conversation = new Conversation({
username: process.env.WATSON_USERNAME,
password: process.env.WATSON_PASSWORD,
url: process.env.WATSON_URL + process.env.WORKSPACE_ID + '/message?version=2017-05-26',
version_date: Conversation.VERSION_DATE_2017_05_26
});
// Create chat connector for communicating with the Bot Framework Service
var connector = new builder.ChatConnector({
appId: process.env.MICROSOFT_APP_ID,
appPassword: process.env.MICROSOFT_APP_PASSWORD
});
// Listen for messages from users
server.post('/api/messages', connector.listen());
// Create your bot with a function to receive messages from the user
var bot = new builder.UniversalBot(connector, function(session) {
var payload = {
workspace_id: workspace,
context: [],
input: {
text: session.message.text
}
};
var conversationContext = {
workspaceId: workspace,
watsonContext: {}
};
if (!conversationContext) {
conversationContext = {};
}
payload.context = conversationContext.watsonContext;
conversation.message(payload, function(err, response) {
if (err) {
console.log(err);
session.send(err);
} else {
console.log(JSON.stringify(response, null, 2));
session.send(response.output.text);
conversationContext.watsonContext = response.context;
}
});
});
Это собственно база, от которой можно отталкиваться дальше. Здесь перед созданием бота, мы создаём объект Разговора (Conversation) с пользователем. Conversation используется для передачи ответов пользователя в Watson, который распознаёт в нём пары намерение-сущность (intent-entity). Переменные WATSON_URL и WORKSPACE_ID, как вы наверное уже поняли, хранятся в файле .env
:
# Bot Framework Credentials
MICROSOFT_APP_ID=...
MICROSOFT_APP_PASSWORD=...
#Watson Url
WATSON_URL=https://gateway.watsonplatform.net/conversation/api/v1/workspaces/
WATSON_USERNAME=...
WATSON_PASSWORD=...
WORKSPACE_ID=<UUID>
Workspace (рабочее пространство) связано с экземпляром сервиса Разговора с пользователем, проще — с обученной моделью. Эта модель создаётся и обучается для одного языка. Для другого языка необходимо создать второй workspace. Получить список доступных нам workspaces и их идентификаторы можно, запустив простой скрипт:
// This loads the environment variables from the .env file
require('dotenv-extended').load();
var Conversation = require('watson-developer-cloud/conversation/v1'); // watson sdk
var conversation = new Conversation({
username: process.env.WATSON_USERNAME,
password: process.env.WATSON_PASSWORD,
version_date: Conversation.VERSION_DATE_2017_05_26
});
conversation.listWorkspaces(function(err, response) {
if (err) {
console.error(err);
} else {
console.log(JSON.stringify(response, null, 2));
}
});
node workspaces.js
Чтобы задействовать механизм локализации Microsoft Bot Framework, нам нужно для начала выяснить, на каком языке к нам обращается пользователь. И здесь нам на помощь опять приходит Watson, имеющий в арсенале огромное количество всевозможного API для перевода, распознавания, классификации, конвертации и т.п. Здесь также есть API для идентификации языка. Для его использования создаём небольшой модуль, который будет отвечать за запросы к этому API:
var request = require("request-promise");
module.exports.Detect = async function LanguageDetect(text) {
var options = {
baseUrl: "https://watson-api-explorer.mybluemix.net",
uri: "/language-translator/api/v2/identify",
method: "GET",
qs: { // Query string like ?text=some text
text: text
},
json: true
};
try {
var result = await request(options);
return result.languages[0].language;
} catch (err) {
console.error(err);
}
};
Подключим этот модуль в главном приложении:
var language = require('./language');
Вначале основной функции бота вставим строки для определения текущего языка и установки соответствующей локали. SDK BotBuilder предоставляет метод session.preferredLocale()
для сохранения или получения этого свойства для каждого пользователя:
// Detect language en/ar first and set correspondent locale
var locale = await language.Detect(session.message.text);
session.preferredLocale(locale);
Список распознаваемых языков можно помотреть в Watson API Explorer, там же можно протестировать этот API.
Для каждого языка создаём 2 отдельных объекта Разговора (Conversation):
// Get Watson service wrapper for English
var conversation_en = new Conversation({
username: process.env.WATSON_USERNAME,
password: process.env.WATSON_PASSWORD,
url: process.env.WATSON_URL + process.env.WORKSPACE_ID_EN + '/message?version=2017-05-26',
version_date: Conversation.VERSION_DATE_2017_05_26
});
// Get Watson service wrapper for Arabic
var conversation_ar = new Conversation({
username: process.env.WATSON_USERNAME,
password: process.env.WATSON_PASSWORD,
url: process.env.WATSON_URL + process.env.WORKSPACE_ID_AR + '/message?version=2017-05-26',
version_date: Conversation.VERSION_DATE_2017_05_26
});
Примечание. Обратите внимание: теперь в файле
.env
у нас находятся 2 переменныеWORKSPACE_ID_EN
иWORKSPACE_ID_AR
, вместо однойWORKSPACE_ID
.
Эти объекты остаются неизменными, поэтому можно поместить их в начало app.js или вынести в отдельный файл. Затем после кода определения локали вставляем строку, инициализирующую нашу переменную conversation, изменяем и переменную workspace — теперь она также будет меняться динамически в зависимости от определённого языка:
// Detect language en/ar first and set correspondent locale
var locale = await language.Detect(session.message.text);
session.preferredLocale(locale);
let workspace = (locale == "ar") ? process.env.WORKSPACE_ID_AR : process.env.WORKSPACE_ID_EN;
// Get Watson service wrapper according to the locale
let conversation = (locale == "ar") ? conversation_ar : conversation_en;
// Prepare Watson request
var payload = {
workspace_id: workspace,
context: [],
input: {
text: session.message.text
}
};
var conversationContext = {
workspaceId: workspace,
watsonContext: {}
};
...
По умолчанию система локализации Bot Builder SDK основана на файлах и позволяет боту поддерживать несколько языков, используя JSON-файлы, хранящиеся на диске. По умолчанию система локализации при вызове таких методов как builder.Prompts.choice()
или session.send()
ищет сообщения бота в файле ./locale/<тэг>/index.json
, где языковой тэг IETF представляет выбранную локаль, для которой необходимо искать сообщения. На следующем скриншоте показана получившаяся структура директорий проекта для английского и арабского языков:
Структура этого JSON-файла — это простое отображение (соответствие) идентификатора сообщения к локализованной текстовой строке. Бот автоматически извлекает локализованную версию сообщения, если в метод session.send()
передаётся идентификатор сообщения вместо заранее локализованной текстовой строки:
session.send("greeting_message");
Ещё один способ получить локализованную текстовую строку по идентификатору сообщения — это вызов метода session.localizer.gettext()
. Для удобства использования я написал расширение класса Session и написал обёртку по типу функции tr()
из Qt (всё таки JavaScript временами очень удобная штука!). Здесь же можно реализовать подстановку токенов типа {name}, {id}, {phone} и т.п.:
const { Session } = require('botbuilder');
// Object extension function for strings localization (translation)
Session.prototype.tr = function (text) {
return this.localizer.gettext(this.preferredLocale(), text)
.replace("{name}", this.userName());
};
// Object extension function to get user id
Session.prototype.userId = function () {
return this.message.address.user.id;
};
// Object extension function to get user name
Session.prototype.userName = function () {
return this.message.address.user.name;
};
Теперь мы легко можем реализовать ответ пользователю на любом языке. При реализации простого бота в формате вопрос-ответ, несомненным достоинством Watson для нас явилось то, что в независимости от языка workspace, распознанные пары intent-entity он может возвращать на любом языке (как обучишь), в нашем случае — на английском. Поэтому возможные ответы были удобно организованы в виде единственного JS-объекта для обоих языков, который работает как ассоциативный массив функций:
var responses = { // Responses object
"greeting": {
"no_entities": async function (session) { session.send("greeting_message"); },
},
"purchase": {
"sale-stop": async function (session) { session.send("3_sales_end_dates"); },
"product-sale": async function (session) { session.send("4_sale_still_running"); },
/** @param {Session} session */
"price-product": async function (session) { session.send(session.tr("6_product_prices")); },
"price": async function (session) { session.send(session.tr("6_product_prices")); },
},
"inquiry": {
"job": async function (session) { session.send("5_job_opportunity"); },
...
},
...
}
Теперь мы можем переписать коллбэк, который вызывается после запроса к Watson:
// Send request to Watson
conversation.message(payload, async function (err, response) {
if (err) {
console.log(err);
session.send(err);
} else {
// Generate response to user according to Watson intent-entity pairs
let processed = false;
// Get intent
let intent = (response.intents[0]) ? response.intents[0].intent : undefined;
for(i = 0; i < response.entities.length; i++) {
// Process single entity in response
let entity = (response.entities[i]) ? response.entities[i].entity : undefined;
// Process single entity in response
if (responses[intent] && responses[intent][entity]) {
await responses[intent][entity](session, intent, [response.entities[i]]);
processed = true;
break;
}
}
// Message was not recognized
if(!processed) {
session.send(session.tr("get_started"));
}
conversationContext.watsonContext = response.context;
}
});
Здесь представлен простой вариант этой функции, в реальном проекте она, конечно, сложнее.
Вот и всё по теме! Мы получили билингвистический бот. После запуска можем насладиться результатом — автоматическими ответами бота:
Автор: Алексей