Продолжая начатую тему хочется поделиться успешным опытом создания билингвистического 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) {

    let payload = {
        workspace_id: workspace,
        context: [],
        input: {
            text: session.message.text
        }
    };

    let 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 и их идентификаторы можно, запустив простой скрипт:

workspaces.js
// 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:

language.js
var request = require("request-promise");

module.exports.Detect = async function LanguageDetect(text) {
    let 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 {
        let 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
let locale = await language.Detect(session.message.text);
session.preferredLocale(locale);

Список распознаваемых языков можно помотреть в Watson API Explorer, там же можно протестировать этот API.

Для каждого языка создаём 2 отдельных объекта Разговора (Conversation):

English and Arabic Conversation objects
// 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 — теперь она также будет меняться динамически в зависимости от определённого языка:

Modifications to app.js
// Detect language en/ar first and set correspondent locale
let 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
let payload = {
    workspace_id: workspace,
    context: [],
    input: {
        text: session.message.text
    }
};

let conversationContext = {
    workspaceId: workspace,
    watsonContext: {}
};

...

По умолчанию система локализации Bot Builder SDK основана на файлах и позволяет боту поддерживать несколько языков, используя JSON-файлы, хранящиеся на диске. По умолчанию система локализации при вызове таких методов как builder.Prompts.choice() или session.send() ищет сообщения бота в файле ./locale/<тэг>/index.json, где языковой тэг IETF представляет выбранную локаль, для которой необходимо искать сообщения. На следующем скриншоте показана получившаяся структура директорий проекта для английского и арабского языков:

image

Структура этого JSON-файла — это простое отображение (соответствие) идентификатора сообщения к локализованной текстовой строке. Бот автоматически извлекает локализованную версию сообщения, если в метод session.send() передаётся идентификатор сообщения вместо заранее локализованной текстовой строки:

session.send("greeting_message");

Ещё один способ получить локализованную текстовую строку по идентификатору сообщения — это вызов метода session.localizer.gettext(). Для удобства использования я написал расширение класса Session и написал обёртку по типу функции tr() из Qt (всё таки JavaScript временами очень удобная штука!). Здесь же можно реализовать подстановку токенов типа {name}, {id}, {phone} и т.п.:

tr() extension function
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-объекта для обоих языков, который работает как ассоциативный массив функций:

Responses object
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:

Watson request callback function
// 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;
    }
});

Здесь представлен простой вариант этой функции, в реальном проекте она, конечно, сложнее.

Вот и всё по теме! Мы получили билингвистический бот. После запуска можем насладиться результатом — автоматическими ответами бота:

Комментарии (2)


  1. mak_ufo
    25.11.2017 20:31

    Использование var в 2017 — моветон


    1. Tantrido Автор
      25.11.2017 21:09

      В основном let, несколько var в функции корневого диалога осталось из чужого шаблона: blog.onchatbot.com/how-to-build-a-chatbot-ibm-watson-microsoft-bot-framework В остальных случаях var оправдано. Исправим.