Примечание редактора. Это вторая публикация в серии, в которой исследуются проблемы и возможности, связанные с чат-ботами и обработкой естественного языка для удовлетворения потребностей клиентов. Вы можете прочитать первую часть здесь.

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

Предпосылки

Чтобы этот код работал локально, необходимо выполнить несколько шагов. Если вы хотите запустить код на собственном компьютере, вам необходимо:

  1. Зарегистрируйте бота на сайте Microsoft Bot Framework и сгенерируйте идентификатор приложения и пароль.
  2. Настройте экземпляр Redis Labs Cloud или запустите собственный локальный экземпляр Redis.
  3. Установите локально node.js (версия ›= 7.3.0) и npm.
  4. Выполните шаги настройки, описанные в README, находящемся в репозитории Github.

Выполнение

Чтобы обмениваться сообщениями между клиентом через чат-бота и человеком-агентом с использованием веб-интерфейса, у нас должен быть уровень обмена сообщениями, который работает между браузером и сервером чат-бота. Хотя для этого есть много вариантов, мы будем использовать библиотеку socket.io. Полное объяснение socket.io выходит за рамки этого поста, поэтому мы сосредоточимся на ключевых аспектах нашей реализации. Socket.io предоставляет концепцию «комнат», которые охватывают связь через сокет. Думайте об этом как о чате. Когда сокет первоначально подключается, он присоединяется к каналу по умолчанию со всеми другими подключениями. Здесь будет подключена группа агентов-людей, ожидающих запроса клиента.

Когда клиент отправляет сообщение нашему чат-боту (1), мы уведомляем всех агентов в комнате по умолчанию, что начинается новый разговор (2). Один из наших агентов щелкнет ссылку, которая сигнализирует серверу чат-бота, что они намерены помочь клиенту (3), в результате чего его сокет будет присоединен к комнате специально для разговора с этим клиентом.

Как только агент присоединился к комнате, сообщение от клиента отправляется агенту (4). В этот момент агент может ответить, который отправит сообщение обратно в комнату (5). Затем сервер чат-бота передает это сообщение обратно клиенту (6). Этот стиль общения может продолжаться до тех пор, пока взаимодействие не будет завершено.

Весь исходный код этого примера доступен здесь. Полное объяснение приложений node.js и всех библиотек и модулей, используемых в приложении, выходит за рамки этой статьи. Вместо этого я сосредоточусь на деталях, специфичных для функциональности, которую мы создаем, просматривая файл за файлом.

.env

appId=
appPassword=
redisPassword=
redisUrl=
redisHost=
redisPort=

Этот файл настраивает идентификатор / пароль бота Microsoft и детали подключения Redis, используемые остальной частью приложения.

app.js

var env = require('node-env-file');
var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http);
var redis = require('redis').createClient;
var adapter = require('socket.io-redis');
env('.env');
var pub = redis(process.env.redisPort, process.env.redisHost, { auth_pass: process.env.redisPassword });
var sub = redis(process.env.redisPort, process.env.redisHost, { auth_pass: process.env.redisPassword });
io.adapter(adapter({ pubClient: pub, subClient: sub }));
// start bot server with IO for socket notifications
var botServer = require('./lib/botServer')(io, http);
// start socket listener for chat agents
var socketManager = require('./lib/socketManager')(io, http);
app.get('/', function(req, res){
  res.sendFile(__dirname + '/index.html');
});
app.get('/join_chat.html', function(req, res){
  res.sendFile(__dirname + '/join_chat.html');
});

Это код, который запускается для настройки сервера socket.io и сервера ботов. Он также предоставляет информацию о маршрутизации для двух HTML-файлов сервера, которые используются для предоставления пользовательского интерфейса агента, который мы обсудим более подробно чуть позже.

socketManager.js

module.exports = function(io, http) {
  const channelSpecificMessageTypes = ['agent joined', 'channel message', 'customer message', 'agent message'];
  io.on('connection', function(socket){
    console.log('a user connected');
    socket.on('chat message', function(msg){
      console.log('socketMgr: chat message: ' + msg);
      io.emit('chat message', msg);
    });
    channelSpecificMessageTypes.forEach(function(msgType) {
      socket.on(msgType, function(msg){
        console.log('socketMgr: got msgType = ' + msgType + 'in room: ' + socket.room);
        if (socket.room !== undefined) {
          io.sockets.in(socket.room).emit(msgType, msg);
        }
      });
    });
    socket.on('join channel', function(msg){
      console.log("joining channel " + msg);
      socket.room = msg;
      socket.join(msg);
    });
    socket.on('leave channel', function(msg){
      socket.leave(msg);
      socket.room = null;
    });
    socket.on('disconnect', function(){
      console.log('user disconnected');
      socket.room = null;
    });
  });
  http.listen(3000, function(){
    console.log('listening on *:3000');
  });
}

Здесь мы реализуем большую часть логики, необходимой для управления связью наших веб-сокетов, которая действует как связующее звено между интерфейсами бота и веб-агента. Мы создаем базовый протокол сообщений чата, который состоит из пары типов сообщений. Сообщения имеют тип (например, сообщение клиента, сообщение агента), а также могут содержать данные, сопровождающие сообщение. Некоторые сообщения относятся к конкретной комнате / каналу, в то время как другие являются более общими или управляют поведением взаимодействий клиент / сервер.

Типы сообщений, не относящиеся к комнате

  • Сообщение чата: используется для передачи общих сообщений группе агентов в комнате по умолчанию.
  • Канал присоединения: сигнал от клиента серверу о том, что он запрашивает присоединение к определенной комнате
  • Выйти из канала: сигнал от клиента серверу о том, что его следует удалить из определенной комнаты.

Типы сообщений для конкретных комнат

  • Агент присоединился: отправляется веб-интерфейсом агента, чтобы сообщить боту, что агент присутствует, чтобы помочь с взаимодействием в конкретном разговоре с ботом.
  • Сообщение канала: используется для передачи общих сообщений агенту и клиенту в комнате.
  • Сообщение клиента: используется для передачи сообщений от клиента через бота.
  • Сообщение агента: используется для передачи сообщений от агента через веб-интерфейс.

botServer.js

var restify = require('restify');
var builder = require('botbuilder');
var env = require('node-env-file');
var client = require("socket.io-client");

function findRooms(io) {
  var availableRooms = [];
  var rooms = io.sockets.adapter.rooms;
  if (rooms) {
    for (var room in rooms) {
      if (!rooms[room].hasOwnProperty(room)) {
          availableRooms.push(room);
      }
    }
  }
  return availableRooms;
}
//=========================================================
// Bot Setup
//=========================================================
module.exports = function(io, http) {
  // Setup Restify Server
  var server = restify.createServer();
  server.listen(process.env.port || process.env.PORT || 3978, function () {
     console.log('%s listening to %s for bot requests', server.name, server.url);
  });
  // Create chat bot
  var connector = new builder.ChatConnector({
      appId: process.env.appId,
      appPassword: process.env.appPassword
  });
  var bot = new builder.UniversalBot(connector);
  server.post('/api/messages', connector.listen());
  //=========================================================
  // Bots Dialogs
  //=========================================================
  bot.dialog('/', new builder.SimpleDialog(function (session, results) {
    if (findRooms(io).indexOf(session.message.address.conversation.id) > -1) {
      console.log("found existing room for conversation");
      // create a socket connection for this dialog`
      var socket = client.connect("http://localhost:3000/");
      // join a channel that scopes the discussion between the bot and the
      // agent using the session conversation ID
      socket.emit("join channel", session.message.address.conversation.id);
      // once the agent joins, send the message we received from the
      // customer via the bot
      socket.emit("customer message", session.message.text);
      // TODO: AI/ML processing goes here
    } else {
      var socket = client.connect("http://localhost:3000/");
      socket.emit("chat message", "A new customer/bot discussion has been initiated. " +
        "Click here to join.");
      // join a channel that scopes the discussion between the bot and the
      // agent using the session conversation ID
      socket.emit("join channel", session.message.address.conversation.id);
      // setup a callback that will be invoked once an agent joins
      // the discussion
      var agentJoinedCallback = function() {
        session.channelInitiated = true;
        // once the agent joins, send the message we received from the
        // customer via the bot
        socket.emit("customer message", session.message.text);
        // TODO: AI/ML processing goes here
        // setup a callback to be invoked once the agent responds so that
        // we can reply to the customer using the text
        var respondCallback = function(msg) {
          console.log("botChatStart dialog: call back invoked with msg:" + msg);
          session.send(msg);
        };
        // setup an event handler so we respond to the customer when
        // the agent responds
        socket.on("agent message", function(msg) {
          console.log("botChatStart dialog: got channel message: " + msg);
          // here we invoke the response callback
          respondCallback(msg);
        });
      };
      // setup an event handler so we know once the agent joins
      socket.on("agent joined", function(msg) {
        // invoke our callback from above
        agentJoinedCallback();
      });
    }
  }));
};

Пакет SDK Microsoft Bot Framework построен на основе концепции диалогового окна, которое представляет собой взаимодействие между клиентом и ботом. Диалоги следуют шаблону маршрутизации, аналогичному веб-сайтам или фреймворкам MVC; диалог по умолчанию представлен как «/» и является точкой входа для всех взаимодействий, исходящих от бота. В этом диалоговом окне происходит вся логика обмена сообщениями между ботом и веб-интерфейсом.

Диалоги сохраняют свое состояние через сеанс. В объект сеанса встроено сообщение, которое передается в диалог, и каждое сообщение содержит идентификатор диалога, который сохраняется на протяжении всего разговора с ботом. Доступ к этому идентификатору диалога осуществляется через объект сеанса, переданный в диалоговое окно как s ession.message.address.conversation.id. Мы используем уникальность и постоянство идентификатора разговора, чтобы назвать комнату, где агент, использующий веб-интерфейс, и клиент, взаимодействующий с ботом, могут обмениваться сообщениями.

Когда клиент инициирует диалог, вызывается диалоговое окно «/». Сначала мы проверяем, существует ли уже комната с идентификатором разговора. Когда начинается новый диалог, места не будет, поэтому мы устанавливаем соединение от бота-сервера к конечной точке socket.io. Затем мы транслируем сообщение с использованием типа chat message всем агентам, уведомляя всех агентов о начале нового разговора с клиентом. Затем агент может щелкнуть ссылку в сообщении, чтобы войти в специальную комнату и поговорить с клиентом.

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

После первого сообщения от клиента каждое последующее сообщение, полученное от клиента, может быть отправлено прямо в существующую комнату. Следовательно, если комната существует для данного идентификатора беседы, бот может просто присоединиться к комнате и транслировать сообщение, не дожидаясь агента.

index.html

Socket.IO chat
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font: 13px Helvetica, Arial; }
  form { background: #000; padding: 3px; position: fixed; bottom: 0; width: 100%; }
  form input { border: 0; padding: 10px; width: 90%; margin-right: .5%; }
  form button { width: 9%; background: rgb(130, 224, 255); border: none; padding: 10px; }
  #messages { list-style-type: none; margin: 0; padding: 0; }
  #messages li { padding: 5px 10px; }
  #messages li:nth-child(odd) { background: #eee; }
  Send

  var socket = io();
  $('form').submit(function(){
    socket.emit('agent message', $('#m').val());
    $('#m').val('');
    return false;
  });
  socket.on('chat message', function(msg){
    $('#messages').append('<li>' + msg);
  });
  socket.on('new conversation', function(msg){
    $('#messages').append('<li>' + msg);
  });
Copy

Это точка входа для пользовательского интерфейса агента. JavaScript на этой странице устанавливает соединение socket.io с сервером и получает общие сообщения чата от чат-бота. В тело сообщения встроена ссылка на другую страницу, join_chat.html, которая включает параметр запроса URI («id»), который содержит идентификатор нового разговора.

join_chat.html

Socket.IO chat
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font: 13px Helvetica, Arial; }
  form { background: #000; padding: 3px; position: fixed; bottom: 0; width: 100%; }
  form input { border: 0; padding: 10px; width: 90%; margin-right: .5%; }
  form button { width: 9%; background: rgb(130, 224, 255); border: none; padding: 10px; }
  #messages { list-style-type: none; margin: 0; padding: 0; }
  #messages li { padding: 5px 10px; }
  #messages li:nth-child(odd) { background: #eee; }
  Send

  function getParameterByName(name, url) {
    if (!url) {
      url = window.location.href;
    }
    name = name.replace(/[\[\]]/g, "\\[https://gist\.github\.com/hartct/7e0b578b87398962e7419ba9ccccb7c0](https://gist.github.com/hartct/7e0b578b87398962e7419ba9ccccb7c0)");
    var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
        results = regex.exec(url);
    if (!results) return null;
    if (!results[2]) return '';
    return decodeURIComponent(results[2].replace(/\+/g, " "));
  }
  var socket = io();
  socket.emit('join channel', getParameterByName("id"));
  socket.emit('agent joined', '');
  $('form').submit(function(e){
    e.preventDefault();
    socket.emit('agent message', $('#m').val());
    $('#messages').append('<li>you: ' + $('#m').val());
    $('#m').val('');
    return false;
  });
  socket.on('customer message', function(msg){
    $('#messages').append('<li>customer: ' + msg);
  });
Copy

Когда эта страница загружается, она устанавливает соединение socket.io с сервером, присоединяется к каналу с именем, указанным в параметре запроса «id», и отправляет сообщение присоединено к агенту. Все сообщения, набранные агентом, затем отправляются на сервер socket.io с типом сообщения агента. Бот-сервер, подключенный к этому каналу, получает сообщение агента и, в свою очередь, отправляет его клиенту.

Следующие шаги

Эта реализация сейчас очень проста, но она создает основу для интеграции машинного обучения в бота. В нашей следующей записи блога мы представим обработку естественного языка (NLP), которая будет использоваться для определения намерения запроса клиента. Мы также научим нашу модель НЛП извлекать из сообщения определенные элементы, которые мы можем использовать, чтобы найти правильный ответ на вопрос клиента.