NodeJS 机器人开发实践指南(四)

原文:Practical Bot Development

协议:CC BY-NC-SA 4.0

九、创建新的通道连接器

现在应该很清楚,用内置的 bot 服务支持集成各种通道是可行的。Bot Builder SDK 设计人员意识到,Bot 服务并不能处理每个通道的每个功能,因此保持了 SDK 的灵活性以支持可扩展性。

bot 服务支持相当多的通道,但是如果我们的 bot 需要支持像 Twitter Direct Messages API 这样的通道呢?如果我们需要集成一个直接与 Facebook Messenger 集成的实时聊天平台,而我们不能利用 Bot 框架脸书通道连接器,该怎么办?机器人服务包括通过 Twilio 支持短信,但如果我们想将其扩展到 Twilio 的语音 API,这样我们就可以真正地与我们的机器人交谈了,该怎么办?

所有这些都可以通过微软提供的一种叫做直线 API 的工具来实现。在这一章中,我们将介绍这是什么,如何构建一个自定义的 web 聊天界面来与我们的机器人通信,以及如何将我们的机器人与 Twilio 的语音 API 挂钩。在本章结束时,我们将会拨打一个电话号码,对我们的机器人说话,并听它回应我们!

直线 API

如果您浏览了 bot 服务条目中的 channels 部分,您可能会遇到一种叫做 Direct Line 的东西。直线通道只是我们通过一个易于使用的 API 从客户端应用调用 bot 的一种方式,这些客户端应用没有能力托管 webhook 来接收响应。那是一口。我们来复习一下。通常,如图 9-1 所示,通道通过调用机器人的消息端点与机器人通信。传入的消息由机器人处理。在创建响应时,我们的 bot 将消息和响应消息一起发送到通道的响应 URL。回想一下,传入的消息包括一个服务 Url 。这是响应 HTTP 端点所在的位置。如果我们要编写一个定制的客户端应用,比如一个移动应用,这个 URL 必须是由用户手机上的客户端应用托管的一个端点。这个异步模型相当强大;对于消息必须何时返回以及需要返回多少消息,没有任何限制。当然,缺点是我们的客户端应用需要托管一个 web 服务器。在许多环境中,这是不可能的。人们甚至可以在 iOS 设备上托管 HTTP 服务器吗?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-1

客户端应用和机器人框架机器人之间的交互

微软提供的解决方案是为我们创建一个封装 HTTP 服务器的通道。Direct Line 可以轻松地将消息发送到我们的 bot 中,并为我们的客户端应用提供了一个接口来轮询 bot 发送回用户的任何响应。微软的直接线 API,目前在其第三个版本中,也支持 WebSockets, 1 所以开发者不需要使用轮询机制。图 9-2 给出了总体设计。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-2

避免了客户端托管 HTTP 服务器的需要

直线通道也很方便,因为它为我们处理 bot 身份验证。我们只需要将一个直接线路密钥作为承载令牌传递到直接线路通道中。

Direct Line v3 API 包含以下与对话相关的操作:

  • StartConversation :开始与机器人的新对话。机器人将收到必要的消息,表明一个新的对话正在开始。

  • GetConversation :获取现有对话的详细信息,包括客户端可以用来通过 WebSocket 连接的 streamUrl。

  • GetActivities :获取机器人和用户之间交换的所有活动。这提供了传递水印的可选能力,以便只获取水印之后的活动。

  • PostActivity :从用户向机器人发送一个新的活动。

  • 上传文件:从用户上传一个文件到机器人。

该 API 还包含两种身份验证方法。

我们可以使用共享的直线密码访问直线 API。但是,如果恶意参与者获得了密钥,他可以作为新用户或已知用户与我们的机器人开始任意数量的新对话。如果我们只是进行服务器到服务器的通信,只要我们正确地管理密钥,这应该不是一个巨大的风险。然而,如果我们希望客户端应用与 API 对话,我们需要另一个解决方案。直线提供了两个令牌端点供我们使用。

  • 生成令牌 : POST /v3/directline/tokens/generate

  • 刷新令牌 : POST /v3/directline/tokens/refresh

生成端点生成一个用于一个且仅一个会话的令牌。该响应还包括一个 expires_in 字段。如果需要延长时间线,API 提供刷新端点来刷新令牌,每次刷新另一个 expires_in 值。在撰写本文时,的值在中到期是 30 分钟。

API 作为 REST 调用被调用到以下端点(全部托管在 https://directline.botframework.com ):

  • 开始对话 : POST /v3/conversations

  • 获取对话 : GET /v3/conversations/{conversationId}?watermark={watermark}

  • 活动 : GET /v3/conversations/{conversationId}/activities?watermark={watermark}

  • 活动后 : POST /v3/conversations/{conversationId}/activities

  • 上传文件POST /v3/conversations/{conversationId}?userId={userId}

您可以在在线文档中找到更多关于直线 API 的详细信息。 2

自定义网络聊天界面

网上有很多直线样品;一个控制台 Node 应用的上下文可以在这里找到: https://github.com/Microsoft/BotBuilder-Samples/tree/master/Node/core-DirectLine/DirectLineClient

我们将以这段代码为模板,创建一个定制的 web 聊天界面,来讨论如何从客户端应用连接到机器人。虽然 Bot Builder SDK 已经包含了一个网络聊天的组件化版本,但我们自己构建它将会是一个很好的直接体验。

首先,我们需要启用直线电话。在我们的 bot 的 Channels blade 中,单击直线按钮(图 9-3 )进入直线配置屏幕。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-3

直线频道图标

我们可以创建多个密钥来验证我们的客户对直线。在本例中,我们将简单地使用默认的站点密钥(图 9-4 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-4

直接线路配置界面

现在我们已经准备好了密钥,我们将创建一个 Node 包,其中包含一个 bot 和一个简单的支持 jQuery 的 web 页面,以演示如何将 bot 与客户端应用连接在一起。以下工作的完整代码包含在我们的 git repo 中。

我们将创建一个可以响应一些简单输入的基本机器人,因此我们将创建一个托管我们的 web 聊天组件的index.html页面。bot 的.env文件应该像往常一样包含 MICROSOFT_APP_ID 和 MICROSOFT_APP_PASSWORD 值。我们还添加了 DL_KEY,这是图 9-4 中我们共享的直线键的值。当页面打开时,代码将从机器人获取一个令牌,这样我们就不会向客户端暴露秘密。这需要在我们的 bot 上实现端点。

首先,用我们典型的依赖项设置一个空的 bot。基本的对话代码如下所示。我们支持一些愚蠢的事情,如“你好”、“退出”、“生命的意义”、“沃尔多在哪里”和“苹果”如果输入与这些都不匹配,我们默认为不屑一顾的“哦,这很酷。”

const bot = new builder.UniversalBot(connector, [
    session => {
        session.beginDialog('sampleConversation');
    },
    session => {
        session.send('conversation over');
        session.endConversation();
    }
]);

bot.dialog('sampleConversation', [
    (session, arg) => {
        console.log(JSON.stringify(session.message));

        if (session.message.text.indexOf('hello') >= 0 || session.message.text.indexOf('hi') >= 0)
            session.send('hey!');
        else if (session.message.text === 'quit') {
            session.send('ok, we\'re done');
            return;
        } else if (session.message.text.indexOf('meaning of life') >= 0) {
            session.send('42');
        } else if (session.message.text.indexOf('waldo') >= 0) {
            session.send('not here');
        } else if (session.message.text === 'apple') {
            session.send({
                text: "Here, have an apple.",
                attachments: [
                    {
                        contentType: 'image/jpeg',
                        contentUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Red_Apple.jpg/1200px-Red_Apple.jpg',
                        name: 'Apple'
                    }
                ]
            });
        }
        else {
            session.send('oh that\'s cool');
        }
    }
]);

其次,我们想要创建一个 web 聊天页面index.html页面,其中包含来自 CDN 的 jQuery 和 Bootstrap。

server.get(/\/?.*/, restify.serveStatic({
    directory: './app',
    default: 'index.html'
}))

我们的index.html提供了简单的用户体验。我们将有一个包含两个元素的聊天客户端容器:一个聊天历史视图,它将呈现用户和机器人之间的任何消息,以及一个文本输入框。我们将假设按回车键发送消息。对于聊天历史,我们将插入聊天条目元素,并使用 CSS 和 JavaScript 来正确调整条目元素的大小和位置。我们将使用消息传递范例,左边是来自用户的消息,右边是来自另一方的消息。

<!doctype html>
<html lang="en">
    <head>
        <title>Direct Line Test</title>
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" type="text/css" />

        <link rel="stylesheet" href="app/chat.css" type="text/css" />
    </head>

    <body>
        <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>

        <script src="app/chat.js"></script>

        <h1>Sample Direct Line Interface</h1>

        <div class="chat-client">
            <div class="chat-history">

            </div>
            <div class="chat-controls">
                <input type="text" class="chat-text-entry" />
            </div>
        </div>
    </body>
</html>

chat.css样式表如下所示:

body {
    font-family: Helvetica, Arial, sans-serif;
    margin: 10px;
}

.chat-client {
    max-width: 600px;
    margin: 20px;
    font-size: 16px;
}

.chat-history {
    border: 1px solid lightgray;
    height: 400px;
    overflow-x: hidden;
    overflow-y: scroll;
}

.chat-controls {
    height: 20px;
}

.chat-img {

    background-size: contain;
    height: 160px;
    max-width: 400px;
}

.chat-text-entry {
    width: 100%;
    border: 1px solid lightgray;
    padding: 5px;
}

.chat-entry-container {
    position: relative;
    margin: 5px;
    min-height: 40px;
}

.chat-entry {
    color: #666666;
    position: absolute;
    padding: 10px;
    min-width: 10px;
    max-width: 400px;
    overflow-y: auto;
    word-wrap: break-word;
    border-radius: 10px;
}
.chat-from-bot {
    right: 10px;
    background-color: #2198F4;
    border: 1px solid #2198F4;
    color: white;
    text-align:right; 

}
.chat-from-user {
    background-color: #E5E4E9;
    border: 1px solid #E5E4E9;
}

我们的客户端逻辑存在于chat.js中。在这个文件中,我们声明了几个函数来帮助我们调用必要的直线端点。

const pollInterval = 1000;
const user = 'user';
const baseUrl = 'https://directline.botframework.com/v3/directline';
const conversations = baseUrl + '/conversations';

function startConversation(token) {
    // POST to conversations endpoint
    return $.ajax({
        url: conversations,
        type: 'POST',
        data: {},
        datatype: 'json',
        headers: {
            'authorization': 'Bearer ' + token
        }
    });
}

function postActivity(token, conversationId, activity) {
    // POST to conversations endpoint
    const url = conversations + '/' + conversationId + '/activities';

    return $.ajax({

        url: url,
        type: 'POST',
        data: JSON.stringify(activity),
        contentType: 'application/json; charset=utf-8',
        datatype: 'json',
        headers: {
            'authorization': 'Bearer ' + token
        }
    });
}

function getActivities(token, conversationId, watermark) {
    // GET activities from conversations endpoint
    let url = conversations + '/' + conversationId + '/activities';
    if (watermark) {
        url = url + '?watermark=' + watermark;
    }

    return $.ajax({
        url: url,
        type: 'GET',
        data: {},
        datatype: 'json',
        headers: {
            'authorization': 'Bearer ' + token
        }
    });
}

function getToken() {
    return $.getJSON('/api/token').then(function (data) {
        // we need to refresh the token every 30 minutes at most.
        // we'll try to do it every 25 minutes to be sure
        window.setInterval(function () {
            console.log('refreshing token');
            refreshToken(data.token);
        }, 1000 * 60 * 25);
        return data.token;
    });
}

function refreshToken(token) {
    return $.ajax({
        url: '/api/token/refresh',
        type: 'POST',
        data: token,
        datatype: 'json',
        contentType: 'text/plain'
    });
}

为了支持 getToken ()和 refreshToken ()客户端功能,我们在 bot 上公开了两个端点。/api/token生成一个新令牌,/api/token/refresh接受一个令牌作为输入并刷新它,延长它的生命周期。

server.use(restify.bodyParser({ mapParams: false }));
server.get('/api/token', (req, res, next) => {
    // make a request to get a token from the secret key
    const jsonClient = restify.createStringClient({ url: 'https://directline.botframework.com/v3/directline/tokens/generate' });
    jsonClient.post({
        path: '',
        headers: {
            authorization: 'Bearer ' + process.env.DL_KEY
        }
    }, null, function (_err, _req, _res, _data) {
        let jsonData = JSON.parse(_data);
        console.log('%d -> %j', _res.statusCode, _res.headers);
        console.log('%s', _data);
        res.send(200, {
            token: jsonData.token
        });
        next();
    });
});

server.post('/api/token/refresh', (req, res, next) => {
    // make a request to get a token from the secret key
    const token = req.body;
    const jsonClient = restify.createStringClient({ url: 'https://directline.botframework.com/v3/directline/tokens/refresh' });
    jsonClient.post({
        path: '',
        headers: {
            authorization: 'Bearer ' + token
        }
    }, null, function (_err, _req, _res, _data) {
        let jsonData = JSON.parse(_data);
        console.log('%d -> %j', _res.statusCode, _res.headers);
        console.log('%s', _data);
        res.send(200, {
            success: true
        });
        next();
    });
});

当页面加载到浏览器上时,我们开始一个对话,获取一个令牌,并监听传入的消息。

getToken().then(function (token){
    startConversation(token)
        .then(function (response){
            return response.conversationId;
    })
        .then(function (conversationId){
            sendMessagesFromInputBox(conversationId, token);
            pollMessages(conversationId, token);
    });
});

下面是sendmessagessfrominputbox的样子:

function sendMessagesFromInputBox(conversationId, token) {
    $('.chat-text-entry').keypress(function (event) {
        if (event.which === 13) {
            const input = $('.chat-text-entry').val();
            if (input === '') return;

            const newEntry = buildUserEntry(input);
            scrollToBottomOfChat();

            $('.chat-text-entry').val('');

            postActivity(token, conversationId, {
                textFormat: 'plain',
                text: input,
                type: 'message',
                from: {
                    id: user,
                    name: user
                }
            }).catch(function (err) {
                $('.chat-history').remove(newEntry);
                console.error('Error sending message:', err);
            });
        }
    });
}

function buildUserEntry(input) {
    const c = $('<div/>');
    c.addClass('chat-entry-container');
    const entry = $('<div/>');
    entry.addClass('chat-entry');
    entry.addClass('chat-from-user');
    entry.text(input);
    c.append(entry);
    $('.chat-history').append(c);

    const h = entry.height();
    entry.parent().height(h);
    return c;
}

function scrollToBottomOfChat() {
    const el = $('.chat-history');
    el.scrollTop(el[0].scrollHeight);
}

该代码侦听文本框上的 Return 键。如果用户输入不为空,它会将消息发送给机器人,并将用户的消息添加到聊天历史中。如果发送给机器人的消息由于任何原因失败,用户的消息将从聊天历史中删除。我们还确保聊天历史控件滚动到底部,以便最新的消息可见。在接收端,我们直接轮询消息。下面是支持代码:

function pollMessages(conversationId, token) {
    console.log('Starting polling message for conversationId: ' + conversationId);
    let watermark = null;
    setInterval(function () {
        getActivities(token, conversationId, watermark)
            .then(function (response) {
                watermark = response.watermark;
                return response.activities;
            })
            .then(insertMessages);
    }, pollInterval);
}

function insertMessages(activities) {
    if (activities && activities.length) {
        activities = activities.filter(function (m) { return m.from.id !== user });
        if (activities.length) {
            activities.forEach(function (a) {
                buildBotEntry(a);
            });
            scrollToBottomOfChat();
        }
    }
}

function buildBotEntry(activity) {
    const c = $('<div/>');
    c.addClass('chat-entry-container');
    const entry = $('<div/>');
    entry.addClass('chat-entry');
    entry.addClass('chat-from-bot');
    entry.text(activity.text);

    if (activity.attachments) {
        activity.attachments.forEach(function (attachment) {
            switch (attachment.contentType) {
                case 'application/vnd.microsoft.card.hero':
                    console.log('hero card rendering not supported');
                    // renderHeroCard(attachment, entry);
                    break;

                case 'image/png':
                case 'image/jpeg':
                    console.log('Opening the requested image ' + attachment.contentUrl);
                    entry.append("<div class='chat-img' style='background-size: cover; background-image: url(" + attachment.contentUrl + ")' />");
                    break;
            }
        });
    }

    c.append(entry);
    $('.chat-history').append(c);

    const h = entry.height();
    entry.parent().height(h);
}

请注意,Direct Line API 返回用户和 bot 之间的所有消息,因此我们必须过滤掉用户发送的任何内容,因为我们已经在最初发送消息时附加了这些内容。除此之外,我们还有自定义逻辑来支持图像附件。

entry.append("<div class='chat-img' style='background-size: cover; background-image: url(" + attachment.contentUrl + ")' />");

我们可以扩展它来支持 hero(在我们的代码中已经有了一个 switch case,但是我们还没有实现一个 renderHeroCard 函数)或者自适应卡、音频附件,或者我们应用需要的任何其他类型的自定义渲染。

简要说明:由于我们使用的是直线 API 和定制的客户端应用,我们可以选择定义定制的附件。因此,如果我们的机器人需要在网络聊天中呈现一些应用用户界面,我们可以通过使用我们自己的附件来指定这个呈现逻辑。 buildBotEntry 中的代码将简单地知道如何去做。

如果我们构建机器人并在localhost:3978上运行它,我们可以通过将浏览器指向http://localhost:3978来访问我们的网络聊天。当我们如图 9-5 运行时,界面看起来很简单。图 9-6 显示了与我们的机器人进行几次交互后的对话。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-6

哦,等等,我们走吧!那很酷

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-5

纯空聊天界面

练习 9-1

Node 控制台界面

在本练习中,您将使用一些返回文本的基本命令创建一个 bot,并创建一个命令行界面来与它通信。目标是同时使用轮询客户端和 web sockets 客户端,并比较性能。

  1. 创建一个简单的机器人,它可以用文本响应几个用户话语选项。通过使用模拟器确保 bot 按预期工作。

  2. 将您的 bot 配置为接受 bot 通道注册通道刀片上的直接线路输入。

  3. 编写一个 Node 命令行应用,它侦听用户的控制台输入,并在用户按下 Return 键时将输入发送到 Direct Line。

  4. 对于收到的消息,编写轮询消息的代码,并在屏幕上打印出来。每 1 到 2 秒钟轮询一次。使用控制台应用向机器人发送多条消息,并查看它的响应速度。

  5. 作为第二个练习,编写利用 streamUrl 初始化新的 WebSocket 连接的代码。可以使用 ws Node.js 包,这里记载: https://github.com/websockets/ws 。将收到的消息打印到屏幕上。

  6. 与 WebSocket 选项相比,轮询解决方案的性能如何?

现在,您已经非常精通与直接线 API 的集成。如果您正在开发自定义通道适配器,这是开始的地方。

语音机器人

好的,所以我们在 Bot 框架上有很大的灵活性。关于通道,我们还计划解决另一个领域,那就是定制通道实现。比方说,你正在为一个客户构建一个机器人,一切都进展顺利,按计划进行。在一个周五的下午,客户过来问你,“嘿,机器人开发者女士,用户可以拨打 800 号码与我们的机器人通话吗?”

嗯,当然,我想只要有足够的时间和金钱,任何事情都是可能的,但是我们如何开始呢?有一次非常类似的事情发生在我身上,我最初的反应是“不可能,这太疯狂了。有太多的问题。语音和聊天不一样。”其中一些保留意见仍然存在;在消息和语音通道之间重用 bot 是一个需要非常小心的棘手领域,因为这两个接口非常不同。当然,这并不意味着我们不打算尝试!

事实证明,Twilio 是一家可靠且易于使用的语音通话和短信 API 提供商。幸运的是,不久前,Twilio 在其平台上添加了语音识别功能,现在它可以将用户的语音翻译成文本。未来,意图识别将被集成到系统中。与此同时,现在有什么应该足够我们的目的。事实上,Bot 框架已经通过 Twilio 集成到 SMS 中;也许有一天我们也会有完全的语音支持。

特维里奥

在我们进入 bot 代码之前,让我们先谈谈 Twilio 及其工作原理。Twilio 的产品之一叫做可编程语音。任何时候一个注册的电话号码打来电话,Twilio 服务器都会向开发者定义的端点发送一条消息。端点必须作出响应,通知 Twilio 它应该执行的动作,例如说一句话、拨另一个号码进入呼叫、收集数据、暂停等等。每当交互发生时,比如 Twilio 通过语音识别收集用户输入,Twilio 就会呼叫这个端点来接收下一步该做什么的指令。这对我们有好处。这意味着我们的代码不需要知道任何关于电话的事情。只是 API 而已!

我们指导 Twilio 做什么的方式是通过一种叫做 TwiML 的 XML 标记语言。 4 这里显示了一个例子:

<?xml version="1.0" encoding="UTF-8"?
<Response>
    <Say voice="woman">Please leave a message after the tone.</Say>
    <Record maxLength="20" />
</Response>

在这个上下文中,名为 SayRecord 的 XML 元素被称为动词。在撰写本文时,Twilio 总共包含了 13 个动词。

  • 说出:对来电者说出文字

  • 播放:为来电者播放音频文件

  • 拨打:将另一方加入通话

  • 录音:记录来电者的声音

  • 收集:收集来电者在键盘上输入的数字,或将语音翻译成文本

  • SMS :在通话过程中发送短信

  • 挂断:挂断电话

  • 入队:将呼叫者添加到呼叫者队列中

  • 离开:从呼叫者队列中删除一个呼叫者

  • 重定向:将调用流重定向到不同的 TwiML 文档

  • 暂停:等待执行更多指令

  • 拒绝:拒绝来电而不计费

  • 消息:发送彩信或短信回复

TwiML 响应可以有一个或多个动词。对于系统上的特定行为,可以嵌套一些动词。如果您的 TwiML 文档包含多个动词,Twilio 将依次执行每个动词。例如,我们可以创建以下 TwiML 文档:

<?xml version="1.0" encoding="UTF-8"?
<!-- page located at http://example.com/complex_gather.xml -->
<Response>
    <Gather action="/process_gather.php" method="GET">
        <Say>
            Please enter your account number,
            followed by the pound sign
        </Say>
    </Gather>
    <Say>We didn't receive any input. Goodbye!</Say>
</Response>

本文档将从尝试收集用户输入开始。它将首先提示用户输入他们的账号,然后是井号。 Say集合中的嵌套行为意味着用户可以在 Say 语音内容完成之前说出他们的响应。这对于回头客来说是一个很棒的功能。如果 Gather 谓词没有导致用户输入,Twilio 将继续处理下一个元素,这是一个 Say 元素,通知用户 Twilio 没有收到响应。此时,由于不再有动词,电话通话结束。

每个动词都有详细的文档和示例,正如我们所料,一个成熟的 TwiML 应用会变得很复杂。与所有用户界面一样,有许多细节。出于我们的目的,我们将创建一个基本的集成,这样我们就可以与刚刚为我们的自定义 web 聊天创建的同一个 bot 对话。

将我们的机器人与 Twilio 整合

我们将从向 Twilio 注册我们的应用开始。首先,我们需要用 Twilio 创建一个试用帐户。参观 www.twilio.com ,点击报名。根据图 9-7 将相关信息填入表格。完成后,您将输入您的电话号码和验证码。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-7

注册一个 Twilio 账户

接下来,Twilio 将询问我们的项目名称。请随意提供比图 9-8 中的名称更有趣的内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-8

创建新的 Twilio 项目

我们将被重定向到 Twilio 仪表盘(图 9-9 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-9

Twilio 项目仪表板

我们的下一个任务是设置一个电话号码并指向我们的机器人。点击左侧窗格中的号码导航项,我们将被带到电话号码仪表盘(图 9-10 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-10

让我们为我们的项目找一个电话号码吧!

点击获取一个号码。Twilio 会给你分配一个号码。因为我们只是测试,任何数字都可以。您也可以购买一个免费号码或从不同的服务机构转移一个号码。 5 之后,点击管理号码然后点击你刚刚被分配的号码。找到在来电时要联系的 URL 的字段,并复制到您的 bot 的 ngrok 端点中(图 9-11 )。我们将在接下来的页面中创建这个端点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-11

配置端点 Twilio 将在有来电时向发送消息

现在,任何时候任何人呼叫该号码,我们的端点都会收到一个 HTTP POST 请求,其中包含与该呼叫相关的所有信息。我们将能够接受这个调用,并使用 TwiML 文档进行响应,就像我们之前讨论的那样。

好吧,那现在怎么办?在我们的 bot 代码中,我们可以添加/api/voice端点来开始接受调用。目前,我们只是添加了一个日志,但没有返回任何响应。让我们看看从 Twilio 得到什么样的数据。

server.post('/api/voice', (req, res, next) => {
    console.log('%j', req.body);
});

{
    "Called": "+1xxxxxxxxxx",
    "ToState": "NJ",
    "CallerCountry": "US",
    "Direction": "inbound",
    "CallerState": "NY",
    "ToZip": "07050",
    "CallSid": "xxxxxxxxxxxxxxxxxxxxxx",
    "To": "+1xxxxxxxxxx",
    "CallerZip": "10003",
    "ToCountry": "US",
    "ApiVersion": "2010-04-01",
    "CalledZip": "07050",
    "CalledCity": "ORANGE",
    "CallStatus": "ringing",
    "From": "+1xxxxxxxxxx",
    "AccountSid": "xxxxxxxxxxxxxxxxxxxxx",
    "CalledCountry": "US",
    "CallerCity": "MANHATTAN",
    "Caller": "+1xxxxxxxxxx",
    "FromCountry": "US",
    "ToCity": "ORANGE",
    "FromCity": "MANHATTAN",
    "CalledState": "NJ",
    "FromZip": "10003",
    "FromState": "NY"
}

Twilio 发送了一些有趣的数据。因为我们获得了呼叫者号码,所以在与我们的机器人交互时,我们可以很容易地使用它作为用户 ID。让我们创建一个对 API 调用的响应。让我们首先安装 Twilio Node API。

npm install twilio –-save

然后,我们可以将相关类型导入到我们的 Node 应用中。

const twilio = require('twilio');
const VoiceResponse = twilio.twiml.VoiceResponse;

VoiceResponse 是一种方便的类型,有助于生成响应 XML。以下是我们如何返回基本 TwiML 响应的示例:

server.post('/api/voice', (req, res, next) => {
    let twiml = new VoiceResponse();

    twiml.say('Hi, I\'m Direct Line bot!', { voice: 'Alice' });

    let response = twiml.toString();

    res.writeHead(200, {
        'Content-Length': Buffer.byteLength(response),
        'Content-Type': 'text/html'
    });
    res.write(response);
    next();
});

现在,当我们拨打 Twilio 提供的电话号码时,在一个免责声明之后,我们应该会看到一个对我们的 API 端点的请求,一个女性声音应该会通过电话对我们说话,然后挂断。恭喜你!您已经建立了连接!

当我们的机器人几乎立即挂断时,这不是一个很好的体验,但我们可以改善这一点。首先,让我们从用户那里收集一些信息。

收集动词包括几个不同的选项,但我们主要关心的是这样一个事实,即收集可以用于接受来自用户电话的语音或双音多频(DTMF)信号。DTMF 只是当你按下手机上的一个键时发出的信号。这就是电话系统如何在用户不说话的情况下可靠地收集诸如信用卡号之类的信息。出于这个例子的目的,我们只关心收集语音。

这是一个收集样本,就像我们将要使用的一样:

<?xml version="1.0" encoding="UTF-8"?
<Response>
    <Gather input="speech" action="/api/voice/gather" method="POST">
        <Say>
            Tell me what's on your mind
        </Say>
    </Gather>
    <Say>We didn't receive any input. Goodbye!</Say>
</Response>

这个片段告诉 Twilio 从用户那里收集语音,并让 Twilio 使用 POST 向/api/voice/gather发送识别出的语音。就这样! Gather 还有许多关于超时和发送部分语音识别结果的其他选项,但这些对于我们的目的来说是不必要的。 6

让我们建立一个 echo Twilio 集成。我们扩展了针对/api/voice的代码,使其包含了收集动词,然后为/api/voice/gather创建了端点,该端点回显用户所说的内容并收集更多信息,从而建立了一个实际上永无止境的对话循环。

server.post('/api/voice', (req, res, next) => {
    let twiml = new VoiceResponse();

    twiml.say('Hi, I\'m Direct Line bot!', { voice: 'Alice' });
    let gather = twiml.gather({ input: 'speech', method: 'POST', action: '/api/voice/gather' });
    gather.say('Tell me what is on your mind', { voice: 'Alice' });

    let response = twiml.toString();

    res.writeHead(200, {
        'Content-Length': Buffer.byteLength(response),
        'Content-Type': 'text/html'
    });
    res.write(response);
    next();
});

server.post('/api/voice/gather', (req, res, next) => {
    let twiml = new VoiceResponse();
    const input = req.body.SpeechResult;
    twiml.say('Oh hey! That is so interesting. ' + input, { voice: 'Alice' });
    let gather = twiml.gather({ input: 'speech', method: 'POST', action: '/api/voice/gather' });
    gather.say('Tell me what is on your mind', { voice: 'Alice' });

    let response = twiml.toString();

    res.writeHead(200, {
        'Content-Length': Buffer.byteLength(response),
        'Content-Type': 'text/html'
    });
    res.write(response);
    next();
});

继续在您的 bot 中运行这段代码。拨打电话号码。跟你聊天机器人。很酷,对吧?太好了。这没什么用,但我们已经在 Twilio 电话对话和我们的机器人之间建立了一个有效的对话循环。

最后,让我们通过使用直线将它集成到我们的 bot 中。在我们进入代码之前,我们写一些函数来帮助我们的机器人调用直线。

const baseUrl = 'https://directline.botframework.com/v3/directline';
const conversations = baseUrl + '/conversations';

function startConversation (token) {
    return new Promise((resolve, reject) => {
        let client = restify.createJsonClient({
            url: conversations,
            headers: {
                'Authorization': 'Bearer ' + token
            }
        });

        client.post('', {},
            function (err, req, res, obj) {
                if (err) {
                    console.log('%j', err);
                    reject(err);
                    return;
                }
                console.log('%d -> %j', res.statusCode, res.headers);
                console.log('%j', obj);
                resolve(obj);
            });
    });
}

function postActivity (token, conversationId, activity) {
    // POST to conversations endpoint
    const url = conversations + '/' + conversationId + '/activities';
    return new Promise((resolve, reject) => {
        let client = restify.createJsonClient({
            url: url,
            headers: {
                'Authorization': 'Bearer ' + token
            }

        });

        client.post('', activity,
            function (err, req, res, obj) {
                if (err) {
                    console.log('%j', err);
                    reject(err);
                    return;
                }
                console.log('%d -> %j', res.statusCode, res.headers);
                console.log('%j', obj);
                resolve(obj);
            });
    });
}

function getActivities (token, conversationId, watermark) {
    // GET activities from conversations endpoint
    let url = conversations + '/' + conversationId + '/activities';
    if (watermark) {
        url = url + '?watermark=' + watermark;
    }

    return new Promise((resolve, reject) => {
        let client = restify.createJsonClient({
            url: url,
            headers: {
                'Authorization': 'Bearer ' + token
            }
        });

        client.get('',
            function (err, req, res, obj) {
                if (err) {
                    console.log('%j', err);
                    reject(err);
                    return;
                }
                console.log('%d -> %j', res.statusCode, res.headers);
                console.log('%j', obj);
                resolve(obj);
            });
    });
}

我们将 TwiML 响应的创建和发送提取到它自己的函数buildandsendtimlresponse中。我们在监听输入的行为中加入了更多的结构,如果没有收到输入,就在挂断之前再次请求输入。

function buildAndSendTwimlResponse(req, res, next, userId, text) {
    const twiml = new VoiceResponse();

    twiml.say(text, { voice: 'Alice' });
    twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
    twiml.say('I didn\'t quite catch that. Please try again.', { voice: 'Alice' });
    twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });
    twiml.say('Ok, call back anytime!');
    twiml.hangup();

    const response = twiml.toString();
    console.log(response);

    res.writeHead(200, {
        'Content-Length': Buffer.byteLength(response),
        'Content-Type': 'text/html'
    });
    res.write(response);
    next();
}

当一个呼叫第一次开始时,我们需要为我们的机器人创建一个直线对话。我们还需要缓存用户 ID(呼叫者电话号码)到对话 ID 的映射。我们在本地 JavaScript 对象中这样做( cachedConversations )。如果我们将这项服务扩展到多台服务器,这种方法将会失败;我们可以通过使用 Redis 这样的缓存来解决这个问题。

server.post('/api/voice', (req, res, next) => {
    let userId = req.body.Caller;
    console.log('starting convo for user id %s', userId);

    startConversation(process.env.DL_KEY).then(conv => {
        cachedConversations[userId] = { id: conv.conversationId, watermark: null, lastAccessed: moment().format() };
        console.log('%j', cachedConversations);
        buildAndSendTwimlResponse(req, res, next, userId, 'Hello! Welcome to Direct Line bot!');
    });
});

Gather 元素的代码应该检索对话 ID,获取用户输入,通过直接线路 API 将活动发送给机器人,然后等待响应返回,然后作为 TwiML 发送回 Twilio。因为我们需要轮询新消息,所以我们需要使用 setInterval 直到我们得到机器人的响应。代码不包括任何类型的超时,但我们肯定应该考虑它,以防机器人出现问题。我们也只支持每个消息有一个来自机器人的响应。语音交互不是锻炼机器人异步发送多个响应的能力的地方,尽管我们当然可以尝试。一种方法是包含自定义的通道数据,以传达预期返回的消息数,或者等待预定义的秒数,然后发送回所有消息。

server.post('/api/voice/gather', (req, res, next) => {
    const input = req.body.SpeechResult;
    let userId = req.body.Caller;
    console.log('user id: %s | input: %s', userId, input);
    let conv = cachedConversations[userId];
    console.log('got convo: %j', conv);
    conv.lastAccessed = moment().format();

    postActivity(process.env.DL_KEY, conv.id, {
        from: { id: userId, name: userId },
        type: 'message',
        text: input
    }).then(() => {
        console.log('posted activity to bot with input %s', input);

        console.log('setting interval');
        let interval = setInterval(function () {
            console.log('getting activities...');
            getActivities(process.env.DL_KEY, conv.id, conv.watermark).then(activitiesResponse => {
                console.log("%j", activitiesResponse);
                let temp = _.filter(activitiesResponse.activities, (m) => m.from.id !== userId);
                if (temp.length > 0) {
                    clearInterval(interval);
                    let responseActivity = temp[0];
                    console.log('got response %j', responseActivity);

                    conv.watermark = activitiesResponse.watermark;
                    buildAndSendTwimlResponse(req, res, next, userId, responseActivity.text);
                    conv.lastAccessed = moment().format();
                } else {
                    console.log('no activities for you...');
                }
            });
        }, 500);
    });
});

如果你运行这个,你现在应该可以和我们通过 Twilio 的 webchat 暴露的同一个机器人对话了!

练习 9-2

Twilio 语音集成

本练习的目标是创建一个 bot,并通过与 Twilio 集成来调用它。

  1. 注册一个试用 Twilio 帐户,并获得一个测试电话号码。

  2. 输入您的 bot 语音端点,以便 Twilio 在您的电话号码收到来电时使用。

  3. 将带有直线呼叫的语音端点集成到您的机器人中。返回你从你的机器人那里收到的第一个回复。

  4. 探索 Twilio 的语音仪表盘。仪表板提供了关于每个呼叫的信息,更重要的是,提供了查看所有错误和警告的功能。如果你的机器人看起来工作正常,但是打电话给你的机器人失败了,“错误和警告”部分是开始调查可能发生了什么的好地方。

  5. 收集动词添加到您的响应中,以便用户可以与机器人进行对话。在对一个不会说话的机器人的新鲜感消失之前,你可以进行多长时间的对话,然后你想要实现一些有意义的事情?

  6. 像你在练习 9-1 中做的那样,用轮询机制代替 WebSocket。对这个解决方案有帮助吗?

  7. 玩一玩 Twilio 的语音识别。有多好?认自己名字的能力有多强?它有多容易被打破?

  8. 将语音识别应用于任意的语音数据已经够有挑战性了,更不用说应用于电话质量的语音数据了。Twilio 的收集动词允许提示 7 为语音识别引擎 8 准备单词或短语的词汇表。通常,这可以提高语音识别性能。继续添加一些包含您的机器人支持的单词的提示。语音识别是否表现得更好?

您刚刚创建了自己的语音聊天机器人,并尝试了一些有趣的 Twilio 功能。您可以使用类似的技术为任何其他通道创建连接器。

与 SSML 融合

回想一下,像谷歌助手和亚马逊的 Alexa 这样的系统支持通过语音合成标记语言(SSML)进行语音输出。使用这种标记语言,开发人员可以在机器人的语音响应中指定音调、速度、强调和暂停。不幸的是,在撰写本文时,Twilio 并不支持 SSML。幸运的是,微软有一些 API 可以使用 SSML 将文本转换成语音。

其中一个 API 是微软的 Bing 语音 API。 9 该服务提供语音到文本和文本到语音的功能。对于文本到语音的功能,我们提供了一个 SSML 文档,并接收一个音频文件作为响应。我们对输出格式有一些控制,尽管对于我们的示例,我们将接收一个 wave 文件。一旦我们有了文件,我们就可以利用播放动词来播放电话呼叫的音频。让我们看看这是如何工作的。

我们将首先引入 bing-speech client-API node . js 包。

npm install --save bingspeech-api-client

一个示例 Play TwiML 文档如下所示:

<?xml version="1.0" encoding="UTF-8"?
<Response>
    <Play loop="10">https://api.twilio.com/cowbell.mp3</Play>
</Response>

Twilio 在 Play 动词中接受了 URI。因此,我们需要将 Bing Speech API 的输出保存到文件系统上的一个文件中,并生成一个 Twilio 可以用来检索音频文件的 URI。我们将把所有输出音频文件写入一个名为 audio 的目录中。我们还将建立一条新的 restify 路线来检索这些文件。

首先,让我们创建我们的函数来生成音频文件并将其存储在正确的位置。给定一些文本,我们想返回一个 URI 供调用函数使用。我们将使用文本的 MD5 散列作为音频文件的标识符。

npm install md5 --save

这是生成一个音频文件并保存在本地的代码。有两个前提。首先,我们需要生成一个 API 密匙来利用微软的 Bing 语音 API。我们可以通过在 Azure 门户中创建一个新的 Bing 语音 API 资源来实现这一点。这个 API 有一个免费的计划版本。一旦我们有了密钥,我们就将它添加到.env文件中,并将其命名为 MICROSOFT_BING_SPEECH_KEY。其次,我们将我们的基本 ngrok URI 作为 BASE_URI 添加到.env文件中。

const md5 = require('md5');
const BingSpeechClient = require('bingspeech-api-client').BingSpeechClient;
const fs = require('fs');

const bing = new BingSpeechClient(process.env.MICROSOFT_BING_SPEECH_KEY);
function generateAudio (text) {
    const id = md5(text);
    const file = 'public\\audio\\' + id + '.wav';
    const resultingUri = process.env.BASE_URI + '/audio/' + id + '.wav';

    if (!fs.existsSync('public')) fs.mkdirSync('public');
    if (!fs.existsSync('public/audio')) fs.mkdirSync('public/audio');

    return bing.synthesize(text).then(result => {
        const wstream = fs.createWriteStream(file);
        wstream.write(result.wave);

        console.log('created %s', resultingUri);
        return resultingUri;
    });
}

为了测试这一点,我们创建了一个测试端点,它创建了一个音频文件并用 URI 进行响应。然后,我们可以使用浏览器指向 URI,下载生成的声音文件。下面的 SSML 是从谷歌的 SSML 文档中借来的,我用 Date()添加了当前时间。getTime(),这样我们每次都会生成一个唯一的 MD5。

server.get('/api/audio-test', (req, res, next) => {
    const sample = 'Here are <say-as interpret-as="characters">SSML</say-as> samples. I can pause <break time="3s"/>.' +
        'I can speak in cardinals. Your number is <say-as interpret-as="cardinal">10</say-as>.' +
        'Or I can even speak in digits. The digits for ten are <say-as interpret-as="characters">10</say-as>.' +
        'I can also substitute phrases, like the <sub alias="World Wide Web Consortium">W3C</sub>.' +
        'Finally, I can speak a paragraph with two sentences.' +
        '<p><s>This is sentence one.</s><s>This is sentence two.</s></p>';

    generateAudio(sample + ' ' + new Date().getTime()).then(uri => {
        res.send(200, {
            uri: uri
        });
        next();
    });
});

如果我们从 curl 调用 URL,我们会得到下面的结果。URI 引用的音频文件显然是 SSML 文档的语音合成。

$ curl https://botbook.ngrok.io/api/audio-test
{"uri":"https://botbook.ngrok.io/audio/1ce776f3560e54064979c4eb69bbc308.wav"}

最后,我们将它集成到我们的代码中。我们更改buildandsendtimlresponse函数来为我们发送的任何文本生成音频文件。我们还在 generateAudio 函数中做了一个更改,使用任何之前基于 MD5 散列生成的音频文件。这意味着我们只能为每个输入生成一个音频文件。

function buildAndSendTwimlResponse(req, res, next, userId, text) {
    const twiml = new VoiceResponse();

    Promise.all(
        [
            generateAudio(text),
            generateAudio('I didn\'t quite catch that. Please try again.'),
            generateAudio('Ok, call back anytime!')]).then(
        uri => {
            let msgUri = uri[0];
            let firstNotCaughtUri = uri[1];
            let goodbyeUri = uri[2];

            twiml.play(msgUri);
            twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });

            twiml.play(firstNotCaughtUri);
            twiml.gather({ input: 'speech', action: '/api/voice/gather', method: 'POST' });

            twiml.play(goodbyeUri);
            twiml.hangup();

            const response = twiml.toString();
            console.log(response);

            res.writeHead(200, {
                'Content-Length': Buffer.byteLength(response),
                'Content-Type': 'text/html'
            });
            res.write(response);
            next();
        });
}

function generateAudio (text) {
    const id = md5(text);
    const file = 'public\\audio\\' + id + '.wav';
    const resultingUri = process.env.BASE_URI + '/audio/' + id + '.wav';

    if (!fs.existsSync('public')) fs.mkdirSync('public');
    if (!fs.existsSync('public/audio')) fs.mkdirSync('public/audio');

    if (fs.existsSync(file)) {
        return Promise.resolve(resultingUri);

    }

    return bing.synthesize(text).then(result => {
        const wstream = fs.createWriteStream(file);
        wstream.write(result.wave);

        console.log('created %s', resultingUri);
        return resultingUri;
    });
}

最后润色

我们差不多完成了。我们还没有做的一件事是让机器人用 SSML 来响应,而不是使用文本。我们没有利用 Bot Builder 的所有语音功能。如第六章所示,我们可以让每条消息填充input int来帮助确定应该使用哪些 TwiML 动词,甚至合并来自机器人的多个响应。我们坚持简单地用适当的 SSML 填充每个消息中的 speak 字段。我们还必须修改连接器代码,使用 speak 字段,而不是 text 字段。

bot.dialog('sampleConversation', [
    (session, arg) => {
        console.log(JSON.stringify(session.message));

        if (session.message.text.toLowerCase().indexOf('hello') >= 0 || session.message.text.indexOf('hi') >= 0)
            session.send({
                text: 'hey!',
                speak: '<emphasis level="strong">really like</emphasis> hey!</emphasis>'
            });
        else if (session.message.text.toLowerCase() === 'quit') {
            session.send({
                text: 'ok, we\'re done!',
                speak: 'ok, we\'re done',
                sourceEvent: {
                    hangup: true
                }
            });
            session.endDialog();
            return;
        } else if (session.message.text.toLowerCase().indexOf(' meaning of life') >= 0) {
            session.send({
                text: '42',
                speak: 'It is quite clear that the meaning of life is <break time="2s" /><emphasis level="strong">42</emphasis>'
            });
        } else if (session.message.text.toLowerCase().indexOf('waldo') >= 0) {
            session.send({
                text: 'not here',
                speak: '<emphasis level="strong">Definitely</emphasis> not here'
            });
        } else if (session.message.text.toLowerCase() === 'apple') {
            session.send({
                text: "Here, have an apple.",
                speak: "Apples are delicious!",
                attachments: [
                    {
                        contentType: 'image/jpeg',
                        contentUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Red_Apple.jpg/1200px-Red_Apple.jpg',
                        name: 'Apple'
                    }
                ]
            });
        }
        else {
            session.send({ text: 'oh that\'s cool', speak: 'oh that\'s cool' });
        }
    }
]);

注意,我们还添加了一个额外的元数据控制字段。对输入 quit 的响应包括一个名为 hangup 的字段,设置为 true。这表明我们的连接器包含了挂断动词。我们创建一个名为 buildAndSendHangup 的函数来生成响应。

function buildAndSendHangup(req, res, next) {
    const twiml = new VoiceResponse();

    Promise.all([generateAudio('Ok, call back anytime!')]).then(
        (uri) => {
            twiml.play(uri[0]);
            twiml.hangup();

            const response = twiml.toString();
            console.log(response);

            res.writeHead(200, {
                'Content-Length': Buffer.byteLength(response),
                'Content-Type': 'text/html'
            });
            res.write(response);
            next();
        });
}

我们修改了/api/voice/gather处理程序来使用 speak 属性,并正确解释了 hangup 字段。

server.post('/api/voice/gather', (req, res, next) => {
    const input = req.body.SpeechResult;
    let userId = req.body.Caller;
    console.log('user id: %s | input: %s', userId, input);

    let conv = cachedConversations[userId];
    console.log('got convo: %j', conv);
    conv.lastAccessed = moment().format();

    postActivity(process.env.DL_KEY, conv.id, {
        from: { id: userId, name: userId }, // required (from.name is optional)
        type: 'message',
        text: input
    }).then(() => {
        console.log('posted activity to bot with input %s', input);

        console.log('setting interval');
        let interval = setInterval(function () {
            console.log('getting activities...');
            getActivities(process.env.DL_KEY, conv.id, conv.watermark).then(activitiesResponse => {
                console.log("%j", activitiesResponse);
                let temp = _.filter(activitiesResponse.activities, (m) => m.from.id !== userId);
                if (temp.length > 0) {
                    clearInterval(interval);
                    let responseActivity = temp[0];
                    console.log('got response %j', responseActivity);

                    conv.watermark = activitiesResponse.watermark;
                    if (responseActivity.channelData && responseActivity.channelData.hangup) {
                        buildAndSendHangup(req, res, next);
                    } else {
                        buildAndSendTwimlResponse(req, res, next, userId, responseActivity.speak);
                        conv.lastAccessed = moment().format();
                    }
                } else {
                    console.log('no activities for you...');
                }
            });
        }, 500);
    });
});

现在,我们可以打电话和一个机智的机器人进行一场精彩的对话,这个机器人在说生命的意义是 42 岁之前停顿一下,并强调这样一个事实:沃尔多绝对不在机器人所在的地方!

结论

直线是一个强大的功能,是从客户端应用调用我们的机器人的主要接口。我们可以创建自定义的通道连接器,因为我们能够将其他通道视为某种客户端应用。我们在本章中完成的一个更有趣的任务是将 SSML 支持添加到我们的 bot 集成中。这种集成只是我们可以开始构建到我们的机器人体验中的智能的一种尝试。我们使用的 Bing 语音 API 只是众多被称为认知服务 API 的微软 API 之一。在下一章中,我们将着眼于将该家族中的其他 API 应用于我们在机器人领域可能遇到的任务。

网络套接字协议: https://en.wikipedia.org/wiki/WebSocket

2

Bot 框架中的关键概念直线 API: https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-direct-line-3-0-concepts

3

Bot 框架 WebChat 是一个 React 组件。可以扩展代码以提供不同的呈现行为或更改控件的样式。你可以在 https://github.com/Microsoft/BotFramework-WebChat 找到更多信息。

4

TwiML 文档: https://www.twilio.com/docs/api/twiml

5

从 Twilio 购买免费号码: https://support.twilio.com/hc/en-us/articles/223183168-Buying-a-toll-free-number-with-Twilio

6

https://www.twilio.com/docs/voice/twiml/gather

7

TwiML 聚集动词提示属性: https://www.twilio.com/docs/voice/twiml/gather#attributes-hints

8

Bot 框架 Bot: https://docs.microsoft.com/en-us/azure/bot-service/bot-service-manage-speech-priming 背景下的语音启动

9

必应语音 API: https://azure.microsoft.com/en-us/services/cognitive-services/speech/

十、让聊天机器人更智能

在前一章中,我们花了时间将聊天机器人的语音合成标记语言(SSML)输出连接到基于云的文本到语音引擎,以使聊天机器人尽可能像人一样说话。我们使用的 Bing 语音 API 就是一个被统称为认知服务的例子。这些典型的服务支持与应用进行更自然的、类似人类的交互。最初,微软称之为牛津项目。 1 如今,这套 API 如今被打上了 Azure 认知服务的烙印。

在更高的技术层面上,这些服务允许轻松访问执行认知类型任务的机器学习(ML)算法,例如,语音识别、语音合成、拼写检查、自动纠正、推荐引擎、决策引擎和视觉对象识别。LUIS 是 Azure 认知服务的另一个例子,我们在第三章 3 中对此进行了深入探讨。微软显然不是这个领域的唯一玩家。IBM 在其 Watson 旗下有许多类似的服务。Google 的云平台在 Google stack 上包含了类似的服务。

这种 ML 即服务的方法对于许多任务来说非常方便。虽然从延迟和成本角度来看,它可能不适合所有工作负载,但对于许多工作负载来说,它只适用于原型开发、试点和生产部署。在这一章中,我们将探索一些微软的 Azure 认知服务。这并不意味着对该主题的详尽论述,而是对聊天机器人开发者可能感兴趣的服务类型的介绍。

无论哪种情况,都值得探索这些服务,以了解提供了什么,了解哪些类型的技术可以应用于我们的业务问题应用,最重要的是,使我们的聊天机器人具有一些相关的智能。

在我们开始之前,请注意所有的认知服务都可以通过使用位于 https://portal.azure.com 的 Azure 门户来提供。将所需的服务资源添加到资源组将允许我们获得访问密钥。例如,当我们试图将一个“bing 拼写检查”资源添加到“图书测试”资源组中时,我们可以选择 Bing 拼写检查 v7 API(图 10-1 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-1

在 Azure 中添加 Bing 拼写检查 v7 API

在我们给服务命名并选择定价层之后(图 10-2 ,我们可以看到访问键。通常有两个访问键可供我们使用(图 10-3 )。拥有两把钥匙便于钥匙旋转。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-3

查找 Bing 拼写检查 v7 API 资源的访问键

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-2

创建 Bing 拼写检查 v7 API 资源

对于其余的服务,这个过程类似地工作;入门不需要高级的门户知识。

当这些服务第一次在公开预览中开发时,大多数都是免费提供的。随着服务从预览阶段进入全面普及阶段,分层定价模式也随之建立起来。幸运的是,大多数服务仍然有一个允许大量使用的免费层。例如,LUIS 允许我们每月免费呼叫端点 10,000 次。我们可以使用 Translator Text API 每月免费翻译 200 万个字符。您可以在 https://azure.microsoft.com/en-us/pricing/details/cognitive-services/ 找到所有服务的更多定价详情。

拼写检查

任何处理用户生成的文本输入的应用都有一个特性就是拼写检查。我们希望有一个灵活的引擎,可以处理常见的拼写问题,如处理俚语,处理上下文中的专有名称错误,找出单词中断,并找出同音词的错误。此外,引擎应该不断更新新的实体,如品牌和流行文化表达。这是一个不小的壮举,然而微软提供了拼写检查 API 来做这件事。

微软的 Bing 拼写检查 API 提供了两种拼写检查模式:校对拼写Proof 设计用于文档拼写检查,包括大写和标点符号建议,以帮助文档创作(这种类型的拼写检查可以在 Microsoft Word 中找到)。拼写是为纠正网络搜索中的拼写而设计的。微软声称拼写模式更具侵略性,因为它旨在优化搜索结果。 2 聊天机器人的上下文更接近于网页搜索,而不是起草长文档,因此拼写可能是更好的选择。

我们将从基础开始,传递模式、我们想要进行拼写检查的文化(称为市场)以及文本本身。我们还可以选择在输入文本的前后添加上下文。在许多情况下,上下文对于拼写检查器来说可能是重要的和相关的。您可以在 API 参考文档中找到更多详细信息。??

为了演示 API 的用法,我们将创建一个基本的聊天机器人,它简单地将用户输入传递给拼写检查器,并通过修改用户输入做出响应,给出分数高于 0.5 的改进建议。机器人将首先提示用户选择拼写检查模式。此时,任何输入都将被发送到使用所选模式的拼写检查 API。最后,我们可以随时发送消息“退出”以返回主菜单并再次选择模式。这是基本的,但是它将说明与 API 的交互。你可以在本书的 GitHub repo 的chapter10-spell-check-bot文件夹下找到这个机器人的代码。

我们首先在 Azure 中创建 Bing 拼写检查 v7 API 资源,这样我们就可以获得一个密钥。虽然我们可以编写自己的客户端库来使用该服务,但我们将使用一个名为cognitive-services4的 Node.js 包,其中包含了大多数微软认知服务的客户端实现。

npm install cognitive-services --save

const cognitiveServices = require('cognitive-services');

我们像往常一样建立了我们的宇宙机器人。我们将拼写检查 API 密钥添加到我们的.env文件中,并将字段称为 SC_KEY

const welcomeMsg = 'Say \'proof\' or \'spell\' to select spell check mode';
const bot = new builder.UniversalBot(connector, [
    (session, arg, next) => {
        if (session.message.text === 'proof') {
            session.beginDialog('spell-check-dialog', { mode: 'proof' });
        } else if (session.message.text === 'spell') {
            session.beginDialog('spell-check-dialog', { mode: 'spell' });
        } else {
            session.send(welcomeMsg);
        }
    },
    session => {
        session.send(welcomeMsg);
    }
]);
const inMemoryStorage = new builder.MemoryBotStorage();
bot.set('storage', inMemoryStorage);

接下来,我们创建一个名为拼写检查对话框的对话框。在这段代码中,每当用户发送新消息时,我们都会向拼写检查 API 发送一个请求。当我们收到结果时,我们用分数大于或等于 0.5 的建议修正替换标记为有问题的片段。为什么是 0.5?这有点武断,建议修改分数阈值和输入选项,以找到最适合您的应用的值。

bot.dialog('spell-check-dialog', [
    (session, arg) => {
        session.dialogData.mode = arg.mode;
        builder.Prompts.text(session, 'Enter your input text. Say \'exit\' to reconfigure mode.');
    },
    (session, arg) => {
        session.sendTyping();

        const text = arg.response;

        if (text === 'exit') {
            session.endDialog('ok, done.');
            return;
        }

        spellCheck(text, session.dialogData.mode).then(resultText => {
            session.send(resultText);
            session.replaceDialog('spell-check-dialog', { mode: session.dialogData.mode });
        });
    }
]);

我们定义了拼写检查函数来调用 Bing 拼写检查 API,并用建议的更正替换拼写错误的单词。

function spellCheck(text, mode) {
    const parameters = {
        mkt: 'en-US',
        mode: mode,
        text: text
    };

    const spellCheckClient = new cognitiveServices.bingSpellCheckV7({
        apiKey: process.env.SC_KEY
    })

    return spellCheckClient.spellCheck({
        parameters
    }).then(response => {
        console.log(response); // we do this so we can easily inspect the resulting object
        const resultText = applySpellCheck(text, response.flaggedTokens);
        return resultText;
    });
}

function applySpellCheck(originalText, possibleProblems) {
    let tempText = originalText;
    let diff = 0;

    for (let i = 0; i < possibleProblems.length; i++) {
        const problemToken = possibleProblems[i];
        const offset = problemToken.offset;
        const originalTokenLength = problemToken.token.length;

        const suggestionObj = problemToken.suggestions[0];
        if (suggestionObj.score < .5) {
            continue;
        }

        const suggestion = suggestionObj.suggestion;
        const lengthDiff = suggestion.length - originalTokenLength;

        tempText = tempText.substring(0, offset + diff) + suggestion + tempText.substring(offset + diff + originalTokenLength);

        diff += lengthDiff;
    }

    return tempText;
}

图 10-4 显示了结果对话。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-4

运行中的拼写检查机器人

效果很好!另一种方法是总是在输入到达对话框堆栈之前通过拼写检查器运行输入。我们可以通过在 bot 中安装定制的中间件来做到这一点。中间件背后的想法是能够将逻辑添加到 Bot Builder 用来处理每个传入和传出消息的管道中。中间件对象的结构如下。方法 bot.use 将中间件对象添加到 Bot Builder 的管道中。

bot.use({
    receive: function (event, next) {
        logicOnIncoming(event);
        next();
    },
    send: function (event, next) {
        logicOnOutgoing(event);
        next();
    }
});

我们可以使用之前定义的代码创建以下中间件。我们对输入进行拼写检查,用自动更正的文本覆盖输入。我们不定义任何传出消息的逻辑。

bot.use({
    receive: function (event, next) {
        if (event.type === 'message') {
            spellCheck(event.text, 'spell').then(resultText => {
                event.text = resultText;
                next();
            });
        }
    },
    send: function (event, next) {
        next();
    }
});

就这样!现在我们的对话可以简单得多了!

bot.dialog('middleware-dialog', [
    (session, arg) => {
        let text = session.message.text;
        session.send(text);
    }
]);

最终的对话看起来如图 10-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-5

使用中间件方法进行拼写检查

在第三章中,我们探讨了语言理解智能服务(LUIS)提供的拼写检查选项。如前所述,LUIS 是微软的另一个认知服务;这是一个 NLU 系统,它允许我们对意图进行分类并提取命名实体。它可以完成的任务之一是集成 Bing 拼写检查 API,并通过 NLU 模型运行拼写检查查询(相对于原始输入)。这种方法的好处是,我们的 LUIS 应用不需要用拼写错误的单词进行训练。缺点是我们的场景可能包含特定领域的语言,拼写检查器无法识别,但 LUIS 模型可以识别。

我们不推荐使用中间件来完全改变用户的输入,让机器人永远看不到原始输入。至少,我们应该记录原始输入和原始输出。如果对 LUIS 启用拼写检查本身会在我们的模型中产生有问题的行为,我们可以将一些逻辑移到我们的 bot 中。一种选择是将 LUIS 识别器包装在自定义拼写检查 LUIS 识别器周围。在这个自定义识别器中,您将有逻辑来确保拼写检查器永远不会修改某个词汇子集。实际上,我们将执行部分拼写检查。

感情

在第一章中,我们演示了一个机器人,它可以响应它从用户输入中检测到的情绪(图 10-6 )。使用“好”和“坏”单词的查找可以简单地实现基本的情感分析。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-6

一个能对情绪做出反应的机器人

显然,这种方法有局限性,比如不考虑单词的上下文。如果我们要开发自己的查找,我们需要确保列表随着文化规范的变化而不断更新。更高级的方法使用机器学习分类技术来创建情感函数,以对话语的情感进行评分。微软提供了一种基于大量预先标注了情感的文本的 ML 算法。

微软的情感分析是其文本分析 API 的一部分。该服务提供三个主要功能:情感分析、关键短语提取和语言检测。我们将首先关注情感分析。

API 允许我们发送一个或多个文本字符串,并接收一个或多个介于 0 和 1 之间的数字分数的响应,其中 0 表示负面情绪,1 表示正面情绪。这里有一个例子(你可以用这个例子告诉我儿子太早叫醒我了):

{
    "documents": [
        {
            "id": "1",
            "language": "en",
            "text": "i hate early mornings"
        }
    ]
}

结果如下:

{
    "documents": [
        {
            "score": 0.073260486125946045,
            "id": "1"
        }
    ],
    "errors": []
}

情感分析在聊天机器人领域有一些有趣的应用。我们可以利用分析报告中的事后数据来了解哪些功能对用户的挑战最大。或者,我们可以利用实时情绪得分将对话自动转移到人工代理,以立即解决用户的问题或挫折。

支持多种语言

在聊天机器人中支持多种语言本身就是一个复杂的话题,我们无法在本书的范围内完全涵盖。然而,我们演示了如何通过使用文本分析和翻译器 API 来更新我们在整本书中一直致力于的日历机器人,以支持多种语言。代码可以在该书的 GitHub repo 的chapter10-calendar-bot文件夹下找到。我们将按如下方式完成这项任务:

  • 每当用户向机器人发送消息时,我们的聊天机器人将使用文本分析 API 来识别用户的语言。

  • 如果语言是英语,请照常继续。如果不是,把这句话翻译成英语。

  • 请把这个英语短语读给路易斯听。

  • 在退出时,如果用户的语言是英语,继续正常操作。否则,在发送给用户之前,将机器人的响应翻译成用户的语言。

实质上,我们使用英语作为中介语言来为 LUIS 提供支持。这种方法不是万无一失的。LUIS 支持多种文化是有原因的,比如语言中的许多细微差别和文化差异。没有额外上下文的直接直译可能没有意义。事实上,我们可能希望支持用一种语言和用英语表达完全不同的方式。解决问题的正确方法是为我们希望提供一流支持的每种文化开发详细的 LUIS 应用,使用基于语言检测的那些应用,并且仅在我们没有 LUIS 对语言的支持时使用翻译器 API 和中介英语。或者,我们甚至完全避免使用翻译 API,因为翻译可能会有问题。

虽然我们在下面的例子中没有使用这种方法,但是由于我们可以控制机器人的文本输出,我们可以提供那些跨我们想要支持的所有语言本地化的静态字符串(而不是使用翻译服务)。我们可以依靠自动翻译来翻译任何没有明确写下来的东西。

从技术角度来看,我们必须选择何时进行翻译。比如是识别器的作用还是对话框的作用?还是要加中间件把输入翻译成英文?对于这个例子,我们将利用中间件方法,因为我们在传入和传出内容上都利用了翻译服务,并希望它对机器人的其余部分尽可能透明。如果我们有一组特定于文化的 LUIS 应用和本地化的输出字符串,我们可以混合使用识别器和对话逻辑。

  • 在我们开始之前,确保您已经在 Azure 门户中创建了文本分析 API 和翻译文本 API 资源,就像我们创建 Bing 拼写检查 v7 API 资源一样。这两个 API 都有一个免费的定价层,所以一定要选择它。请注意,文本分析 API 要求我们选择一个区域。所有与 Bing 无关的认知服务都要求这样设置。这显然对可用性和延迟有影响,超出了本书的范围。创建之后,我们必须将密钥保存到.env文件中。将文本分析键命名为 TA_KEY ,将翻译键命名为 TRANSLATOR_KEY 。此外,认知服务包要求指定端点。端点映射到地区,因此如果我们选择 West US 作为文本分析服务地区,端点值就是 westus.api.cognitive.microsoft.com。 5 将此设置为.env文件中的 TA_ 端点键。

我们将使用cognitive-servicesnode . js 包与文本分析 API 进行交互;然而,翻译器 API 是这个包不支持的服务之一。我们可以安装 mstranslator Node.js 包。

npm install mstranslator --save

const translator = require('mstranslator');

接下来,我们可以创建一个包含翻译逻辑的中间件模块,这样我们就可以轻松地将此功能应用于任何机器人。

const TranslatorMiddleware = require('./translatorMiddleware').TranslatorMiddleware;
bot.use(new TranslatorMiddleware());

中间件代码本身将依赖于使用文本分析和翻译 API。

const textAnalytics = new cognitiveServices.textAnalytics({
    apiKey: process.env.TA_KEY,
    endpoint: process.env.TA_ENDPOINT
});
const translatorApi = new translator({ api_key: process.env.TRANSLATOR_KEY }, true); // the second parameter ensures that the token is autogenerated

之后,我们创建一个 TranslatorMiddleware 类,它带有一个映射,告诉我们哪些用户使用哪种语言。这需要存储用户的输入语言,以便输出逻辑能够从英语翻译回英语。

const userLanguageMap = {};

class TranslatorMiddleware {
    ...
}

接收逻辑跳过任何不是消息的内容。如果我们有一个消息,用户的语言被检测。如果语言是英语,我们继续;否则,我们将消息翻译成英语,将消息文本重置为英语版本(从而丢失原始语言输入),然后继续。如果在我们翻译收到的信息时出现错误,我们只需假定是英语。

receive(event, next) {
    if (event.type !== 'message') { next(); return; }

    if (event.text == null || event.text.length == 0) {
        // if there is not input and we already have a language, leave as is, otherwise set to English
        userLanguageMap[event.user.id] = userLanguageMap[event.user.id] || 'en';
        next();
        return;
    }

    textAnalytics.detectLanguage({
        body: {
            documents: [
                {
                    id: "1",
                    text: event.text
                }
            ]
        }
    }).then(result => {
        const languageOptions = _.find(result.documents, p => p.id === "1").detectedLanguages;
        let lang = 'en';

        if (languageOptions && languageOptions.length > 0) {
            lang = languageOptions[0].iso6391Name;
        }
        this.userLanguageMap[event.user.id] = lang;

        if (lang === 'en') next();
        else {
            translatorApi.translate({
                text: event.text,
                from: languageOptions[0].iso6391Name,
                to: 'en'
            }, function (err, result) {
                if (err) {
                    console.error(err);
                    lang = 'en';
                    userLanguageMap[event.user.id] = lang;
                    next();
                }
                else {
                    event.text = result;
                    next();
                }
            });
        }
    });
}

在出去的路上,我们只需弄清楚用户的语言,然后将发出的消息翻译成那种语言。如果用户的语言是英语,我们跳过翻译步骤。

send(event, next) {
    if (event.type === 'message') {
        const userLang = this.userLanguageMap[event.address.user.id] || 'en';

        if (userLang === 'en') { next(); }
        else {
            translatorApi.translate({
                text: event.text,
                from: 'en',
                to: userLang
            }, (err, result) => {
                if (err) {
                    console.error(err);
                    next();
                }
                else {
                    event.text = result;
                    next();
                }
            });
        }
    }
    else {
        next();
    }
}

图 10-7 显示了不同语言对问候语的回应。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-7

不同语言的机器人响应

恭喜,我们现在有了一个天真的多语言聊天机器人!基本的请求和响应看起来不错,但是在收集数据方面存在一些问题。例如,机器人似乎中途切换语言(图 10-8 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-8

用西班牙语创建约会流,带有一个标志

问题是, café 这个词在英语和西班牙语中都有效。这可能需要在对话中进行某种语言锁定。“什么时候开会?”听起来也不对。cual这个词翻译过来就是的*,而不是时的。我们可以通过提供静态本地化输出字符串来解决这个问题。*

实现一个生产级多语言机器人还有更多的事情要做,但这是一个很好的概念证明,展示了我们如何使用 Azure 认知服务来检测和翻译语言。

QnA 制造商

机器人的一个常见用例是为用户提供一个 FAQ,以获取关于某个主题、品牌或产品的信息。通常,这类似于 web FAQ,但更适合于对话式交互。一种典型的方法是创建一个问答对数据库,并提供某种模糊匹配算法来搜索给定用户输入的数据集。

一种实现方法是将所有问答数据加载到 Lucene 之类的搜索引擎中,并使用其模糊搜索算法来搜索正确的配对。在微软 Azure 中,相当于将数据加载到诸如 Cosmos DB 之类的存储库中,并使用 Azure Search 来创建数据的搜索索引。

出于我们的目的,我们将使用一个更简单的选项,称为 QnA Maker,这是我们可以使用的另一种认知服务。QnA Maker ( https://qnamaker.ai/ )于 2018 年 5 月正式上市。该系统很简单:我们将一组问答对输入知识库,训练该系统并将其作为 API 发布。然后,模糊逻辑匹配通过我们在 Azure 应用服务计划中托管的 API 变得可用,因此我们可以根据需要调整其性能。

我们必须首先登录 Azure 门户并创建一个新的 QnA Maker 实例(图 10-9 )。UI 将从我们这里收集一些数据。我们输入一个名称,管理服务定价层(免费定价!)、资源组、搜索服务定价层(还是免费的!),搜索服务位置,服务位置,以及我们是否要包含应用洞察。如果您启用或禁用 Application Insights,该服务也能正常工作。保持启用状态,您可以查看用户向 QnA Maker 提问的日志。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-9

创建新的 QnA 制造商服务

在 Azure 门户完成它的工作后,我们最终得到了几个资源。搜索服务托管搜索索引,应用服务托管我们将调用的 API,应用洞察提供关于我们的服务使用的分析。请确保将应用服务计划定价层更改为免费!

此时,我们可以进入 QnA Maker 门户网站。使用您在 Azure 上使用的同一帐户登录 https://www.qnamaker.ai 。点击创建一个知识 。您将看到图 10-10 中的屏幕。从您的 Azure 订阅中选择 QnA 服务,并命名您的知识库。有几个选项来填充内容:您可以提供一个带有常见问题的 URL,上传一个包含数据的 TSV 文件、一个 PDF 文件,或者手动输入数据。这些都是非常有趣的选择,我们建议你自己去探索。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-10

创建新的 QnA 知识库

出于我们的目的,我们将使用手动界面。输入服务名后,点击创建新的知识库。我们遇到了一个丰富的界面,允许我们编辑知识库中的内容,并保存、重新培训或发布它(图 10-11 )。我们使用右上角的 +添加新的 QnA 对链接添加几对。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-11

向我们的知识库添加更多 QnA 对

我们现在可以点击保存和训练,然后点击发布。点击发布按钮会将知识库移动到 Azure 门户中创建的 Azure 搜索实例中。一旦发布,我们将会看到如何调用 API 的细节(见图 10-12 )。请注意,URL 对应于我们在 Azure 门户中创建的应用服务。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-12

我们发布了一个 QnA Maker KB!

让我们使用 curl 来看看 API 的运行情况。我们将尝试一些我们没有明确训练过的东西,比如“你叫什么名字?”请注意,我们可以包含 top 参数,以向 QnA Maker 表明我们愿意处理多少结果。如果 QnA Maker 找到多个分数足够接近的可能候选答案,它将返回 top options 的值。

curl -X POST
-H "Authorization: EndpointKey f3c15268-40c1-4e66-8790-392c29f2f704"
-H "Content-Type: application/json"  "https://booktestqna.azurewebsites.net/qnamaker/knowledgebases/ce45743a-62e5-42b1-a572-f912ea6836f9/generateAnswer"
-d '{ "question": "whats your name?", "top": 5 }'

回应如下:

{
  "answers": [
    {
      "questions": [
        "what is your name?"
      ],
      "answer": "Szymon",
      "score": 60.98,
      "id": 3,
      "source": "Editorial",
      "metadata": []
    }
  ]
}

反响看起来不错。如果我们问一个我们没有训练过的问题,我们会得到“在知识库中没有找到好的匹配”的响应。

curl -X POST
-H "Authorization: EndpointKey f3c15268-40c1-4e66-8790-392c29f2f704"
-H "Content-Type: application/json"  "https://booktestqna.azurewebsites.net/qnamaker/knowledgebases/ce45743a-62e5-42b1-a572-f912ea6836f9/generateAnswer"
-d '{ "question": "when are you going to give me your bitcoin?", "top": 5 }'
{
  "answers": [
    {
      "questions": [],
      "answer": "No good match found in KB.",
      "score": 0.0,
      "id": -1,
      "metadata": []
    }
  ]
}

结果正如我们所料:不匹配。用户界面还提供了一个测试功能,让我们在发布到公共 API 之前,用不同的措辞询问知识库问题,看看模型返回什么。如果算法选择了错误的答案,我们可以将它指向正确的答案。您还可以轻松地添加备选问题措辞(图 10-13 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-13

QnA Maker 测试界面,添加新问题短语和新问题对的强大方法

微软提供了 QnA Maker 识别器和对话框,作为其bot builder-cognitive services6node . js 包的一部分。如果我们希望我们的聊天机器人同时利用 QnA Maker 和 LUIS,我们可以使用一个自定义识别器来查询这两个服务,并根据这两个服务的结果选择正确的操作过程。

练习 10-1

与 QnA Maker 集成

本练习的目标是向现有聊天机器人添加问答功能。

  1. 创建一个简单的 QnA Maker 知识库,其中包含关于您的一些问题的答案。姓名、出生日期和兄弟姐妹的数量是一些可能性。

  2. 创建一个聊天,利用bot builder-cognitive servicesnode . js 包连接到您的 QnA Maker 服务。

  3. 将 QnA Maker 对话框和识别器集成到一个也连接到 LUIS 的机器人中。可以用第七章的日历机器人举例。框架是否擅长区分 LUIS 查询和 QnA 查询?

  4. 试着用类似于你训练 LUIS 模型的话语来训练 QnA Maker。机器人的行为如何?如果我们改变识别器注册的顺序,行为会改变吗?

在本练习中,您探索了如何将 QnA Maker 集成到聊天机器人中。您还探索了混合使用 QnA Maker 和 LUIS 识别器,这对于 Bot Builder 机制和可能的排序陷阱都是一个很好的练习。

计算机视觉

到目前为止,我们探索的所有认知服务都以某种形式明显应用于聊天机器人。拼写检查、情感分析、翻译和语言检测以及模糊输入匹配都明显适用于我们日常的机器人交互。另一方面,有许多机器学习任务对机器人的适用性并不清楚。计算机视觉就是这样一个例子。

微软的 Azure 认知服务包括提供多种功能的计算机视觉系列服务。例如,有一个检测和分析人脸的服务,还有一个分析人的情绪的服务。有一种内容调节服务和一种允许您定制现有计算机视觉模型以适应我们的用例的服务(想象一下,试图让一种算法变得擅长识别不同类型的树)。还有一种更通用的服务叫做计算机视觉,它返回一组带有置信度得分的图像标签。它还可以创建图像的文本摘要,并确定图像是否色情或包含成人内容,以及其他任务。

因为我对那些唯一的任务就是确定一张照片是否是热狗的移动应用的无休止的娱乐,我们将研究一个机器人的代码,它可以判断用户发送的图像是否是热狗。代码可以在该书的 GitHub repo 的chapter10-hot-dog-or-not-hot-dog-bot文件夹下找到。

原则上,我们将使用模拟器来练习这个机器人,以确保我们可以在本地开发。当用户通过任何通道发送图像时,机器人通常会收到图像的 URL。我们可以将该 URL 发送给服务,但是由于模拟器发送的是本地主机地址,所以这是行不通的。我们的代码需要做的是将所述图像下载到临时目录,然后将其上传到计算机视觉 API。我们将使用这段代码和使用 request Node.js 包下载图像。

const getImage = function (uri, filename) {
    return new Promise((resolve, reject) => {
        request.head(uri, function (err, res, body) {
            request(uri).pipe(fs.createWriteStream(filename))
                .on('error', () => { reject(); })
                .on('close', () => {
                    resolve();
                });
        });
    });
};

然后,我们创建一个简单的对话框,它接受任何输入并在服务中运行,以判断热狗是否被识别。

bot.dialog('hot-dog-or-not-hot-dog', [
    (session, arg) => {
        if (session.message.attachments == null || session.message.attachments.length == 0 || session.message.attachments[0].contentType.indexOf('image') < 0) {
            session.send('Not supported. Require an image to be sent!');
            return;
        }

        // let them know we're thinking....
        session.sendTyping();

        const id = uuid();
        const dirName = 'images';

        if (!fs.existsSync(dirName)) {
            fs.mkdirSync(dirName);
        }
        const imagePath = dirName + '/' + id;
        const imageUrl = session.message.attachments[0].contentUrl;

        getImage(imageUrl, imagePath).then(() => {
            const cv = new cognitiveServices.computerVision({ apiKey: process.env.CV_KEY, endpoint: process.env.CV_ENDPOINT });
            return cv.describeImage({
                headers: { 'Content-Type': 'application/octet-stream' },
                body: fs.readFileSync(imagePath)
            });
        }).then((analysis) => {
            // let's look at the raw object
            console.log(JSON.stringify(analysis));

            if (analysis.description.tags && ) {
                if (_.find(analysis.description.tags, p => p === 'hotdog')) {
                    session.send('HOT DOG!');
                }
                else {
                    session.send('not hot dog');
                }
            }
            else {
                session.send('not hot dog');
            }
            fs.unlinkSync(imagePath);
        });
    }
]);

如果我们上传这张漂亮的热狗图片(图 10-14 ,我们会得到下面的 JSON 结果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-14

普通的老热狗

{
    "description": {
        "tags": [
            "sitting", "food", "paper", "hot",
            "piece", "bun", "table", "orange",
            "top", "dog", "laying", "hotdog",
            "sandwich", "yellow", "close", "plate",
            "cake", "phone"
        ],
        "captions": [
            {
                "text": "a close up of a hot dog on a bun",
                "confidence": 0.5577123828705269
            }
        ]
    },
    "requestId": "4fa77b1a-1b27-491c-b895-8640d6a196fd",
    "metadata": {
        "width": 1200,
        "height": 586,
        "format": "Png"
    }
}

如果我们上传这张索诺兰热狗照片(图 10-15 ),不管那是什么,我们仍然会得到不错的结果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-15

另一种热狗?

{
    "description": {
        "tags": [
            "food", "sandwich", "dish", "box",
            "dog", "table", "hot", "sitting",
            "piece", "top", "square", "toppings",
            "paper", "slice", "close", "different",
            "hotdog", "holding", "pizza", "plate",
            "laying"
        ],
        "captions": [
            {
                "text": "a close up of a hot dog",
                "confidence": 0.9727350601423388
            }
        ]
    },
    "requestId": "11a12305-d36a-4db0-aca0-2a1870a8b9e7",
    "metadata": {
        "width": 1280,
        "height": 960,
        "format": "Jpeg"
    }
}

我不知道索诺兰热狗是什么,但看过之后,听起来真的很好吃。我有点觉得好笑,服务可以正确地确定它是一个热狗。更让我觉得有趣的是,它还给图片贴上了标签披萨不同。这将是一个有趣的练习,看看如何疯狂的热狗需要完全欺骗这个模型。

我们可以通过图像检测和分析做很多有趣的事情,尽管热狗或不热狗是一个愚蠢的例子,但应该清楚这种通用图像描述生成可以有多强大。当然,更具体的应用需求可能意味着微软或其他提供商提供的通用模型是不够的,定制模型更合适。定制视觉服务 7 涵盖了那些用例。在这两种情况下,使用易于使用的 REST API 快速构建这些函数原型的能力都是不可低估的。

练习 10-2

探索计算机视觉

计算机视觉允许我们做一些事情,而不仅仅是获取标签。我们可以用 API 做的一个更引人注目的动作是光学字符识别(OCR)。

  1. 通过使用 Azure 门户获取计算机视觉 API 的访问密钥。这个过程和其他认知服务是一样的。

  2. 创建一个聊天机器人,接受照片并从照片中提取文本信息。像我们在热狗聊天机器人中一样处理图片上传。

  3. 试着在一张纸上写一些文字,然后通过你的聊天机器人运行它。它能正确识别你的笔迹吗?

  4. 在 OCR 努力识别文本之前,图像中的对比度可以有多差,或者您的书写可以有多差?

现在,您已经练习了计算机视觉 API,并以特别的方式测试了它的 OCR 算法的性能。

结论

这个世界在机器学习算法的准确性方面取得了很大的进步,以至于很多功能已经通过 REST APIs 向开发人员公开。通过一个简单的 REST 端点访问其中一些算法的能力,而不需要学习新的环境和语言(如 Anaconda、Python 和 scikit-learn),刺激了一大批开发人员尝试新的想法,并在他们的应用中包含 AI 功能。大型科技公司提供的一些服务可能不像定制开发和策划的模型那样高效、经济或准确,但它们的易用性以及随着时间的推移不断提高的准确性和成本效益是生产场景中考虑的催化剂。

作为聊天机器人领域的专业人士,我们应该对可以帮助我们的聊天机器人开发的认知产品类型有所了解。使用所有这些强大的功能可以极大地改善对话体验。

原项目牛津博客公告: https://blogs.microsoft.com/ai/microsofts-project-oxford-helps-developers-build-more-intelligent-apps/

2

关于必应拼写检查 API 的更多信息: https://azure.microsoft.com/en-us/services/cognitive-services/spell-check/

3

Bing 拼写检查 API V7 API 文档: https://docs.microsoft.com/en-us/rest/api/cognitiveservices/bing-spell-check-api-v7-reference

4

Node.js 认知服务包和认知服务 API 支持列表: https://www.npmjs.com/package/cognitive-services

5

我们可以在 Node.js 包代码中找到所有其他可能的端点值; https://github.com/joshbalfour/node-cognitive-services/blob/master/src/language/textAnalytics.js

6

js 包提供了访问 QnA Maker 的助手。代码可以在 GitHub 的 https://github.com/Microsoft/BotBuilder-CognitiveServices/tree/master/Node 找到。

7

定制视觉服务允许我们用我们的特定应用图像增强现有的计算机视觉模型: https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值