NodeJS-机器人开发实践指南-全-
NodeJS 机器人开发实践指南(全)
一、聊天机器人简介
近年来,聊天机器人和人工智能(AI)已经成为科技领域和公众想象中的热门话题。聊天机器人,即可以使用自然语言交流的计算机程序,正在做从订购比萨饼到购买衣服到节省停车罚单的钱到它们之间的谈判的一切事情。最初,开发一个聊天机器人相当于开发一个与消息平台的集成。没有简单的方法用代码表示对话流。当微软创建 Bot 框架和 Bot Builder SDK 时,这种情况发生了变化。微软创造了一个丰富的环境,在这个环境中,开发人员从与单个频道集成的顾虑中解放出来,可以专注于编写代码来执行聊天机器人需要完成的对话任务。Bot Builder SDK 提供了一种开发对话体验的通用方法。微软的 Bot 连接器实现了将通用格式转换为特定于通道的消息的逻辑。
结果是聊天机器人的开发对于数百万开发者来说变得更加容易。工程师们不再需要学习如何与脸书的 Messenger APIs 或 Slack 的 Web API 集成。相反,开发人员专注于核心机器人逻辑和对话体验。微软担心剩下的。
Bot Builder SDK 可用于。NET 和 Node.js,并在 GitHub 上作为 MIT 授权的开源项目运行。团队在开发和应对开发团队遇到的各种问题方面都很活跃。而且球队很友好的开机!
2017 年 12 月,微软同时推出了 Bot 框架和语言理解智能服务(LUIS)。LUIS 是微软的自然语言服务,它将帮助我们为机器人添加对话智能。Bot 框架现在也被称为 Azure Bot 服务;这两者指的是同一个东西。顾名思义,Azure Bot 服务现在是微软 Azure 云产品的成熟部分。微软也提供了免费的服务层,所以我们可以尽情地玩这个框架。书中所有的样本和技术都可以免费实验!
在过去的几年里,所有大型科技公司,如微软、脸书和谷歌,以及许多较小的公司,都在尝试创建最好、最易用的聊天机器人开发框架。这个领域非常活跃。框架来来去去。事情似乎每天都在变化。尽管空间是动态的,微软的机器人框架仍然是开发强大、快速和灵活的聊天机器人的最佳平台。我很高兴用这个工具带你踏上聊天机器人开发之旅。
期望游戏
两年多来,我与客户交谈的大部分时间都花在讨论聊天机器人的功能,它们是什么,更重要的是,它们不是什么。我们的文化在很大程度上混淆了聊天机器人的能力和人工智能,很容易看出为什么。一些聊天机器人使用丰富的自然语言能力,让我们想象它们有更多的功能。同样,基于语音的数字助理,如 Cortana、Alexa 和 Google Assistant,生活在我们的家中,可以像真人一样说话。为什么聊天机器人不会显示出更高的智能?
这种文化还渗透着对 IBM 的沃森(Watson)在《危险边缘》(Jeopardy)上的引用、 4 《纽约时报》在谷歌大脑团队 5 上的特写,以及他们在语言翻译方面使用深度学习、无人驾驶汽车的壮举,以及 AlphaZero 在学习如何下棋仅四个小时后就摧毁了世界上最高评级的下棋引擎。 6
这些和其他许多故事凸显了对这些技术的投资和兴趣,预示着我们正在走向的人工智能驱动的设备交互。人工智能领域的发展改变了我们与技术互动的方式,以及我们对技术的期望。为我们的设备赋予人类属性和能力正变得越来越普遍。认知和科幻领域的思想家长期以来一直在努力解决这种可能性,阿西莫夫的机器人三定律(Three Laws of Robotics)推广了这种可能性,这是一套机器人必须遵守的规则,以确保机器人不会追逐人类。现在现实世界中已经有了一些清晰而具体的人工智能例子,这种现实似乎更加接近了。
然而,现实与人工智能在一些非常具体的问题领域的成功所设定的期望并不匹配。虽然我们在自然语言处理、计算机视觉、情感检测等方面取得了巨大的飞跃,但将所有这些组成一个类似人类的智能,通常被称为人工通用智能 AGI,还不在我们的掌握之中,也不是聊天机器人的现实目标。对于每一篇庆祝人工智能领域巨大成就的文章,都有一篇匹配的文章淡化了围绕同一技术的炒作,并展示了为什么这种类型的人工智能仍然远非完美的例子(想想那些展示计算机视觉算法仍然无法正确分类的所有图像的文章)。正如任何被媒体大肆宣传的技术一样,我们必须合理对待我们对它的期望。
我们的机器人会成为具有人类智能的代理人,与我们的用户进行对话吗?不。考虑到我们希望机器人完成的技术和任务,我们能让机器人很好地完成这些任务吗?绝对的。这本书旨在让读者掌握构建引人注目、引人入胜且有用的聊天机器人的必要技能。在这段旅程中,你想融入多少最新的人工智能技术,这取决于工程师。当然,对于一个优秀的聊天机器人来说,这些技术并不是必需的。
什么是聊天机器人?
在最基本的层面上,聊天机器人,在本书中也被简称为机器人,是一个计算机程序,它可以接受用户以自然语言输入的信息,并向用户返回文本或富媒体。用户通过 Facebook Messenger、Skype、Slack 等消息应用,或者通过亚马逊 Echo、谷歌 Home 或微软 Cortana 支持的 Harmon Kardon's Invoke 等语音激活设备,与聊天机器人进行交流。
图 1-1 展示了我们第一个使用微软 bot 框架构建的 Bot。这个 bot 只是向用户返回相同的消息,并以字符串“echo:”作为前缀。在 Bot 框架上运行这种体验的逻辑非常简单。
图 1-1
一个简单的回声机器人
var bot = new builder.UniversalBot(connector, [
function (session) {
// for every message, send back the text prepended by echo:
session.send('echo: ' + session.message.text);
}
]);
这是一个聊天机器人。基本的,不是很有用,对吧?我们可以轻松地创建一个 YouTube 机器人,给定用户文本输入,搜索该主题的视频,并将这些视频的链接发送给用户(图 1-2 和 1-3 )。
图 1-2
猫也可以
这是另一个基本的机器人,只做一件事,而且做得很好。它与 YouTube API 集成,使用您的输入作为搜索参数,并返回在 Bot 框架中被称为 cards 的内容,我们将在本书的后面进行探讨。这些图像带来了更丰富、更引人入胜的体验——更有趣,但仍相当基础。
图 1-3
狗更好!
接下来显示了这个程序的代码。我们向 YouTube 发出请求,并将响应从 YouTube 格式翻译成 Bot 框架卡。
const bot = new builder.UniversalBot(connector, [
session => {
const url = vsprintf(urlTemplate, [session.message.text]);
request.get(url, (err, response, body) => {
if (err) {
console.log('error while fetching video:\n' + err);
session.endConversation('error while fetching video. please try again later.');
return;
}
const result = JSON.parse(body);
// we have at most 5 results
let cards = [];
result.items.forEach(item => {
const card = new builder.HeroCard(session)
.title(item.snippet.title)
.text(item.snippet.description)
.images([
builder.CardImage.create(session, item.snippet.thumbnails.medium.url)
])
.buttons([
builder.CardAction.openUrl(session, 'https://www.youtube.com/watch?v=' + item.id.videoId, 'Watch Video')
]);
cards.push(card);
});
const reply = new builder.Message(session)
.text('Here are some results for you')
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments(cards);
session.send(reply);
});
}
]);
好吧,这个怎么样?我们可以有一个机器人,给定一个陈述,它可以判断这是一个中性的、积极的还是消极的陈述,并返回一个适当的响应(图 1-4 )。我们没有展示它,但是这个例子的代码和前面的例子一样简单:我们从一个简单的情感 REST API 中获取一个情感分数,并使用它来呈现一个答案。
图 1-4
利用人工智能推动对话的一个简单例子
这是一个简单的例子,表明如果我们走这条路,我们的代码可以多么容易地与人工智能集成。机器人不必总是遵循问答模式。机器人可以主动接触用户。例如,我们可以有一个欺诈警报机器人(图 1-5 )。
图 1-5
主动用户信息
机器人可以更加任务驱动。例如,想象一个日历机器人,它可以创建约会,检查可用性,编辑或删除约会,并给你一个日历摘要(图 1-6 )。
图 1-6
一个简单的日历机器人集成了谷歌日历
现在事情开始变得有点有趣了。我们开始采用自然语言并付诸行动。
为什么不
为什么机器人变得如此重要?当然,它们以各种形式存在于像 IRC 7 和 AOL Instant Messenger 这样的老派应用中。 8 这些都不是小实验。IRC 机器人已经存在很长时间了。我记得通过 IRC 与相当多的机器人互动过。当谈到技术的时候,我还很年轻,很天真,最初我以为真的有人在回应我的信息。我很快意识到,某个地方有一台机器在回应我写的东西。我与 IRC 机器人互动得越多,我就越把它们当成命令行。然而,这在当时是非常小众的技术。公众不是每天都与机器人互动,所以没有必要迎合自然语言的互动。
今天,我们与周围技术互动的方式完全不同,这是由三种力量驱动的:人工智能的进步,消息应用作为对话智能平台的想法,以及语音激活的对话界面。
人工智能的进步
在整个 20 世纪,计算机科学家、生物学家、语言学家和经济学家在认知、人工智能、人工生命、机器学习和深度学习领域取得了巨大进步。执行指令的计算机程序的概念,通用图灵机 9 和可以数字存储代码并执行代码的计算机架构的想法,接受输入并产生输出,以及冯诺依曼架构, 10 在人类历史标准中是最近的,但却是我们在计算机上的工作所基于的基本概念。1943 年,麦卡洛克和皮茨在他们的论文《神经活动中内在思想的逻辑演算》中首次发表了关于神经网络的想法111950 年,阿西莫夫在其著作《机器人三定律 I、 机器人中收录了机器人三定律。 12 同年,第一篇描述计算机如何下棋的论文——克劳德·香农(Claude Shannon)的《为下棋编写计算机程序》发表。他继续从本质上发明了信息论领域。从 20 世纪 60 年代开始,这个领域的研究数量和增长速度令人震惊;我们每天都能在媒体对最新人工智能应用的报道中看到这方面的证据。
可以说,自 20 世纪 60 年代以来,机器学习和使用各种算法构建我们自己的模型的过程已经变得更好执行和更容易获得。像 scikit-learn for Python 和 Google 的 Tensor Flow 等库都有很好的记录,并得到了社区的大力支持。大型科技公司也在他们的计算能力和能力上投入了足够的资金,以便能够在合理的时间框架内完成一些计算量最大的任务。微软、亚马逊、谷歌、IBM 和其他公司现在都以这样或那样的方式涉足云平台。下一步是按需提供一些机器学习算法。如果我们简单地以微软的认知服务为例,我们会发现在撰写本文时有 30 个 API。这些包括计算机视觉工具,如面部和情感检测、内容审核和 OCR 功能。它还包括语言工具,如自然语言处理、语言和文本分析以及自然语言理解。它甚至包括搜索和知识工具,如推荐引擎和语义搜索。任何开发人员都可以在任何时间以合理的成本接入这些强大功能的服务的可用性是智能系统在我们生活中变得如此普遍的重要原因,也是我们的机器人可以利用的基础设施之一。我们将在第十章中探讨微软的认知服务。
作为对话智能平台的消息应用
近年来,移动通讯应用风靡一时。Snapchat、Slack、Telegram、iMessage、FB Messenger、WhatsApp 和微信是移动用户手机上最常用的一些应用。事实上,它们的使用率已经超过了脸书等社交网络。据 Business Insider 报道,在 2015 年第一季度的某个时候,即时通讯应用开始比社交网络更受欢迎,这一趋势一直持续到现在。虽然这本书不会详细介绍美国和全球市场的所有相关参与者,但关键是,微信和 LINE 等亚洲通讯应用已经找到了通过聊天应用增加使用量的最佳方式,以及如何利用这种使用来赚钱。货币化趋势尚未完全赶上美国市场,但苹果、Twitter 和脸书等公司已经领先,允许开发者创建简单的聊天机器人,甚至支付集成。我并不是说讨论仅限于上述玩家;开放消息平台的趋势是普遍的。
在现有的信息平台中托管这些机器人的能力为更多的客户打开了品牌。用户体验停留在消息应用中。机器人开发者不需要像移动应用开发者那样关心动画和内存管理;主要关注的是与用户的对话。我们将在整本书中遇到的一个有趣的概念是,机器人不仅仅是文本。它们可以包括图像、视频、音频以及调用其他命令的按钮。在现有消息应用的范围内创建对话体验是在应用中编写应用的练习;我们的 bot 受到消息传递平台支持的本机特性的限制。Bot 框架有必要的设施来最大限度地利用所有这些功能。
声控智能助理
另一个显著加速对话智能技术发展的因素是支持语音的硬件设备的发展。苹果公司在 2011 年推出了更重要的现代虚拟助手之一 Siri。Siri 现在是一个家喻户晓的名字,它是由最著名的桌面语音识别系统之一 Nuance 的语音到文本产品 Dragon NaturallySpeaking 背后的一些技术驱动的。
Siri 是第一个上市的,似乎鼓励了许多其他公司跳入语音助手的游戏。微软在 2014 年发布了 Cortana 助手,同年发布了第一款亚马逊 Echo 设备。Cortana 最初仅限于 Windows Phone 和 Windows 桌面操作系统,但后来在移动操作系统甚至 Xbox 上也可以使用。亚马逊的 Echo 采用了 Alexa 语音助手,是第一款商业上成功的独立硬件设备,并让亚马逊在早期主导了语音助手市场。在随后的几年里,脸书和谷歌分别推出了 2018 年初关闭)和谷歌助手。谷歌正通过 Google Home 进入语音设备领域。哈曼卡顿将一款名为 Invoke 的产品推向市场,这是一款基于微软 Cortana 的扬声器。许多其他参与者正在向该市场扩张,这进一步鼓励了该领域的创新。
人工智能和语音识别、自然语言处理和自然语言理解技术的进步加速了这种增加的活动和竞争。这些技术的显著发展增加了在标准、框架和工具方面为这些平台创建定制功能的活动。正如我们将很快看到的,这些自定义功能或技能可以由聊天机器人来支持。
我们为什么要创造机器人?
为什么我们要编写机器人,并使用消息应用作为平台?我们可以轻松地编写移动应用,发布到应用商店,然后一劳永逸,不是吗?不完全是。用户行为的各种趋势使得这种方法不太可行。
对于一些较大的品牌来说,下载他们的应用是一项简单的任务。我想用脸书?好吧,我去拿应用。我想查看我的电子邮件;我会使用应用。但是,我想和我当地的花店谈谈?我不需要一个应用。我不希望有这样的应用。为什么我要为我接触的每一家企业下载应用?理想情况下,我可以给他们打电话或者发短信,对吗?
公司在市场上采取的行动允许用户直接与企业对话。让我们以脸书为例。当地的花店可以有一个脸书页面,并在该页面上启用消息传递。企业员工可以在一个地方回答客户的询问。Twitter 在其新的直接消息 API 中也有类似的功能。这为企业提供了很多价值。应用下载摩擦的消除使得用户开始与企业对话变得更加容易。当然,下一步是自动化一些通信。这就是机器人的用武之地。消息传递平台负责许多问题,例如用户身份、身份验证、应用的整体稳定性等等。
这也转化为其他用例。让我们以 Slack 这样的生产力工具为例。Slack 是一个很棒的工作协作平台,它使人们能够跨多个主题进行聊天和协作。Slack 平台上的聊天机器人通常更注重生产力。例如,你可能很难让人们在 Slack 上使用约会机器人,而不是在脸书这样的社交网络上。图 1-7 显示了排名靠前的 Slack 机器人列表。这些类型的机器人更多地与工作任务相关联,如待办事项列表、站立、任务分配等。很明显,如果一个团队完全投入并沉浸在懈怠中,创建一个机器人来执行共同的任务可能比创建一个完全独立的网站更有效。
图 1-7
Slack bot 列表
尽管 Slack 的列表中包含一个名为机器人的特定类别,但事实是所有这些应用都是机器人。其中一些可能更具有对话性,而另一些可能更具有命令行的感觉;就我们而言,机器人只是简单地监听消息并根据消息采取行动。对于大量对话型聊天机器人来说,自然语言理解的主题,即理解人类语言的学科,对于良好的用户体验至关重要。因此,我们将章节 2 和 3 献给这个主题。
机器人解剖学
当我们深入 Bot 框架时,有必要将聊天机器人的开发分解成单独的组件。一般来说,每个组件都有几种方法。在下面的讨论中,我试图描述一般概念,然后强调微软在 Bot 框架中解决问题的方式。
-
机器人运行时
-
自然语言理解引擎
-
对话引擎
-
通道整合
机器人运行时
在最基本的层面上,聊天机器人是一种响应用户请求的网络服务。根据我们集成的消息传递平台的不同,细节会有所不同,但想法是相同的:消息传递平台通过 HTTP 端点用包含用户输入的消息调用 bot。我们的聊天机器人的角色是处理消息,并向平台发送消息,包括机器人的响应以及任何附件或特定于平台的数据。图 1-8 展示了一种通用方法。根据平台的不同,我们可能能够返回带有 HTTP 状态代码或其他格式的异常情况。当我们的 bot 处理消息时,它通过调用通道的 HTTP 端点进行响应。然后,通道将消息传递给用户。
图 1-8
用户、消息平台和通用机器人之间的消息交换
这种方法有一些问题,主要是我们将机器人绑定到特定的消息传递通道,而我们的机器人应该是通道不可知的,这样我们就可以尽可能多地重用逻辑。bot 框架通过提供位于消息传递平台和 Bot 之间的连接器服务来解决这个问题。实际上,交互看起来更像图 1-9 。请注意,通道连接器拥有与消息传递平台的连接和通信,并将消息转换为我们的 bot 可以识别的通用格式。我们将在本章后面的“通道集成”部分更详细地介绍通道。
图 1-9
使用 bot 框架在用户、消息传递平台、连接器服务和 Bot 之间进行消息交换
由于 bot 运行时只是一个监听 HTTP 端点的计算机程序,我们可以使用任何允许我们接收 HTTP 消息的技术来开发 bot。我们可以利用。NET、Node.js、Python 和 PHP。事实上,我们可以简单地使用 Bot 框架来获得连接器的优势,并使用我们喜欢的任何方法实现 HTTP 端点。然而,如果我们这样做了,我们将失去 Bot Builder SDK。我们将在本章后面的“对话引擎”部分介绍它的好处和使用它的理由。
自然语言理解引擎
编写一个能够阅读和理解用户话语的聊天机器人是一项挑战。人类语言是具有灵活和不一致规则的非结构化输入。然而,我们的机器人需要能够接受这些输入,并找出用户在谈论什么。在高层次上,自然语言理解引擎为机器人开发者解决了两个问题:意图分类和实体提取。
我们将通过例子来说明什么是意图和实体。比方说,我们正在开发一个恒温器控制机器人。最初,我们希望支持四个动作:打开、关闭、设置模式为制冷或制热,以及设置温度。用户可以用自然语言表达的动作类别(意味着开/关、设置模式或设置温度)被称为意图。模式本身(冷或热)和温度值是实体。NLU 引擎允许机器人开发者定义一组与应用相关的自定义意图和实体。表 1-1 列出了一些示例映射。
表 1-1
由 NLU 系统解析的用户输入到意图的示例映射
|说话
|
目的
|
实体
|
| --- | --- | --- |
| "打开" | 接通开启 | 没有人 |
| “关闭电源” | 岔道 | 没有人 |
| “设置为 68 度” | 设定温度 | “68 度”类型:温度 |
| “将模式设置为冷” | 设置模式 | “酷”类型:模式 |
显然,我们的代码基于意图和实体值执行逻辑要比基于原始的用户话语更容易。
bot 开发人员可以利用几种服务来获得这种 NLU 功能。在当前的技术环境下,有大量基于云的 API 可用,如 LUIS、Wit.ai 和 Dialog flow 等。LUIS 是这一组中最富有和表现最好的,他是第三章中 NLU 深入探讨的主题。
对话引擎
在构建机器人时,我们通常会开发一个工作流来实现我们的机器人想要完成的任务。按照基本的恒温器示例,我们可以设想如图 1-10 所示的机器人架构。
图 1-10
示例 bot 对话设计图
工作流总是从机器人监听用户话语开始。用户说出的话语将被解析为表 1-1 中的意图。如果意图是打开或关闭,机器人可以执行正确的逻辑,并用确认消息进行响应。如果我们收到一个设置温度的意图,我们的机器人可以验证温度实体存在。如果没有,我们向用户询问。一旦我们收到它,我们就可以执行正确的逻辑并发送确认响应。SetMode 的工作方式类似于 SetTemperature,因为我们将确认实体的存在,如果它不存在,就引出它。
这种基于用户输入的对机器人行为的描述是一种对话。设计输入、输出和转换类型的活动被称为对话体验设计。我们将在第四章中深入讨论这个话题。
对话引擎是跟踪传入消息、处理它们并在对话图 Node(也称为对话)之间执行状态转换的引擎。它为每个用户分别执行此操作。对话的状态被存储起来,这样当下一条用户消息进入机器人时,机器人就知道用户的当前状态。Bot 框架在通过 Bot Builder SDK 提供对话引擎方面做得很好。
旁白:意图,实体,行动,插槽,哦,我的!
开发机器人有多种方法,但它们可以总结为两种方法:机器人引擎和我所说的机器人对话即服务。前面描述了 bot 引擎:我们将 bot 作为 web 服务运行,根据需要调用 NLU 平台,并使用对话引擎将消息路由到对话。像 Dialogflow 这样的公司推广了“机器人对话即服务”的方法。该方法意味着 NLU 解析、对话映射、状态和转换发生在 Dialogflow 基础设施上的云中。然后,Dialogflow 调用您的 bot 来修改响应或与其他系统集成。
当用户的话语映射到一个意图和一组定义的实体时,它被称为一个动作。一个动作有一个意图和一组参数。基于我们的恒温器机器人,我们可以定义一个名为 SetTemperatureAction 的动作。此操作是使用温度参数设置温度的目的。温度参数的类型是温度实体。当 Dialogflow 解析一个动作时,它可以调用您的 bot 来完成该动作。在该模型中,bot 逻辑关注于基于 NLU 服务的解析逻辑的逻辑执行;对话引擎外包给 NLU 服务。
这种 bot 开发方法的一个高级主题是槽填充。这是一个过程,通过这个过程,服务注意到一个动作只有部分被用户输入填充,并自动要求用户填充剩余的槽,也就是我们所说的动作参数。表格 1-2 和 1-3 展示了两个示例动作。
表 1-3
基于机票预订机器人的更复杂的动作
|行动
|
名字
|
类型
|
必需的?
|
提示
|
| --- | --- | --- | --- | --- |
| 订机票 | 从 | 城市 | 是 | 出发城市 |
| 到 | 城市 | 是 | 目的地城市 |
| 日期 | 日期时间 | 是 | 你什么时候旅行? |
表 1-2
在我们的恒温器机器人中设置温度的动作定义
|行动
|
名字
|
类型
|
必需的?
|
提示
|
| --- | --- | --- | --- | --- |
| 设定温度 | 温度 | 温度 | 是 | 你想要设置什么温度? |
图 1-11 展示了在这个对话即服务模型中,用户、消息传递平台、连接器、NLU 服务和机器人之间的整个端到端流程。
图 1-11
典型的 bot 对话即服务流
对话即服务方法善于在短时间内启动并运行某些东西。不幸的是,这样会失去一些控制和灵活性。使用 bot 框架可以让我们完全控制 Bot 引擎,从而避开这些问题。
通道整合
构建机器人意味着处理多个消息平台。你的老板让你写一个 Facebook 信使机器人。你发布它,你的老板祝贺你的伟大工作。然后他问你,“我们能把这个作为网络聊天添加到我们的 FAQ 页面吗?”您的 bot 代码绑定到 Messenger Webhooks 和 Send API。您四处游荡,认为您可以在传输接口后面隔离一些与 Messenger 通信的逻辑。您创建了同一个接口的第二个实现,它通过 web 套接字与您的聊天机器人对话。现在,您已经创建了自己的机器人和消息传递平台之间接口的抽象。
我们希望我们的机器人逻辑尽可能地从单个消息传递平台中抽象出来。如何从通道接收消息和发送响应的细节是我们不想过多关注的细节,除非我们是构建各种平台连接器的专业人员。我不认为你会读这本书,如果你是。你想开发一个机器人,而不是基础设施。幸运的是,市场上不同的 bot 框架通常会为我们完成所有这些工作,如图 1-12 所示。这些框架允许我们以一种与通道无关的方式编写一个机器人,然后通过几次点击和输入一些数据来连接到这些通道。这些功能通常被称为频道或频道集成。
与许多通用框架一样,也有一些框架不支持的边缘情况,因为平台特性要么太新,要么特定于平台。在这种情况下,框架应该允许我们以其本地格式与平台进行通信。Bot 框架为此提供了一种机制。
此外,我们的框架应该足够灵活,允许我们创建定制的通道连接器。例如,如果我们希望构建一个提供聊天机器人界面的移动应用,框架应该允许我们这样做。如果我们的企业正在使用一个不受微软连接器支持的即时消息通道,我们应该能够创建一个。微软的 Bot 框架通过我最喜欢的特性之一:Directline API 实现了这种程度的集成。
图 1-12
你的机器人不应该关心与哪个通道对话。应该为你抽象出来。
我们将在第 9 和 10 章中介绍通道和定制通道集成。
结论
在这一章中,我们快速浏览了可用于构建机器人的不同组件的表面。在我的工作中,Bot 框架明显战胜了使用对话作为服务方法的竞争对手。Bot 框架提供的灵活性和控制是许多企业场景的需求。Bot 框架还提供了更好、更丰富的抽象,更深层次的连接器集成,以及开放和多样化的社区。机器人框架团队已经创建了一个非常强大的套件,可以作为任何对话机器人的基础。我和我的团队使用 Bot 框架已经快两年了,没有找到放弃这个平台的理由。事实上,该框架的对话引擎方法和连接器架构已经被证明对我们抛出的任何用例都具有弹性。
由于这些和许多其他原因,这本书围绕使用微软的 Bot 框架作为框架的选择。该框架适用于 C#/。NET 和 Node.js 开发平台。出于本书的目的,我们将使用 Node.js 版本。我们不会利用任何额外的工具,如 TypeScript 或 CoffeeScript。我们简单地使用普通的 JavaScript 来展示使用 Bot Framework SDK for Node.js(又名 Bot Builder)开始编写 Bot 是多么容易和简单。
不管宣传与否,用于构建机器人的技术和技巧确实令人惊叹。作为这次冒险的一部分,我想确保我们不仅涵盖了构建机器人的基础知识,还学习了更多关于一些底层技术和方法的知识。我们不会非常深入地研究这些主题,但我会涵盖足够多的内容,让读者对如何实现机器人中的智能有一个初步的了解,以便探索更复杂的场景。为了书籍的整体重点,当我涉及这些主题时,我会提供额外阅读材料的链接和信息来补充内容。我不是数据科学家,但我已经尽我所能介绍了相关的机器学习(ML)概念。
我们即将踏上一段激动人心的旅程,穿越对话设计、自然语言理解和应用于聊天机器人的机器学习的世界。当我们讨论这些主题和构建机器人时,请记住,这些技术适用于从聊天机器人到语音助手技能的所有东西。随着自然语言和语音界面在家庭和工作场所变得越来越流行,我保证你会在当前的项目和未来的自然语言应用中应用这些概念。我们走吧!
二、聊天机器人自然语言理解
在我们开始创建机器人和花哨的自然语言模型之前,我们将快速了解自然语言理解(NLU)及其一些机器学习(ML)基础。在下一章中,我们将使用微软的语言理解智能服务(LUIS)来实现这些 NLU 概念。您可以使用其他服务(例如,微软的认知服务)或 Python/R ML 工具来探索其他一些概念。这一章旨在为你提供一个关于 ML 领域的快速入门,因为它与自然语言任务有关。如果你熟悉这些概念,请务必跳到第三章。否则,我们希望传授对 NLU 的根源以及如何将其应用于机器人领域的基本理解。互联网上有大量深入讨论所有这些话题的内容;如果你喜欢冒险,我们会提供合适的参考资料!
如果我们选择开发一个 NLU 集成的聊天机器人,我们的日常工程将涉及与系统的持续互动,这些系统可以理解用户所说的话。这是一项艰巨的任务。考虑使用强力编码来理解与我们的自然语言控制器恒温器相关的自由文本用户输入。我们在第一章中介绍了这个用例。我们有四个目标:通电、断电、设置模式和设置温度。让我们考虑一下设定温度的目的。你如何编码一个系统,让它理解用户想要设置一个温度,以及用户输入的哪一部分代表温度?
我们可以使用一个正则表达式来尝试匹配像“将温度设置为{温度}”、“设置为{温度}”和“设置{温度}”这样的句子你测试一下。你感觉很好,然后一个测试者过来说,“我想要 80 度。”好吧,没什么大不了的。我们加上“我希望它是{温度}”第二天,有人过来说,“把温度降低 2 度。”我们可以加上“温度降低{diff}”和“温度升高{diff}”但是现在我们需要检测字降低和增加。我们如何解释这些词的变体呢?不要让我们开始使用多个命令,比如“白天设置为 68,晚上设置为 64。”仔细想想,我们在谈论什么温度单位?
当我们思考我们想要在聊天机器人上支持的交互时,我们很快注意到使用暴力方法会导致一个非常乏味的系统,最终,鉴于自然语言交流的迷人和令人讨厌的不一致性,它不会执行得很好。如果我们想利用蛮力方法,我们可以得到的最接近的方法,并且仍然可以获得相当好的性能,就是使用正则表达式。Bot 框架支持这一点,我们将在第五章中看到。如果我们使用这样的方法,假设你不是一个正则表达式迷,我们的交互模型出于维护的原因需要保持简单。
自然语言理解(NLU)是复杂的自然语言处理(NLP)领域的一个子集,涉及人类语言的机器理解。NLU 和 NLP 与我们对人工智能的理解有着千丝万缕的联系,可能是因为我们经常将智力与沟通技能联系起来。这可能也有潜在的心理本质;我们认为,如果机器人理解我们在说什么,它就更聪明,不管我们的智力水平和我们说话的复杂程度如何。事实上,我们可能会对那种能够理解我们在想什么而不是我们在说什么的人工智能感到最高兴。但是我跑题了。
在这种假设下,命令行并不智能,因为它要求命令采用特定的格式。如果我们可以通过请求命令行“启动 node……我不确定是哪个文件,”来启动 Node.js 脚本,我们会认为它是智能的吗?你能帮我吗?."使用现代 NLU 技术,我们可以建立似乎了解某些专业或任务的模型。随后,从表面上看,机器人似乎有点智能。是吗?
事实是,我们还没有开发出计算能力和技术来创建一个与人类智能相匹配的 NLU 系统。如果一个问题只有在我们能让计算机像人类一样聪明的情况下才能解决,那么这个问题就被称为“人工智能难题”。一个像人类一样行为和理解自然语言输入的合适的 NLU 系统还不在我们的掌握之中;但是,我们可以创建狭窄而聪明的系统,能够很好地理解一些事情,从而创建合理的对话体验。
考虑到最近围绕 ML 和 AI 的大肆宣传,我们从一开始就设定这些期望是很重要的。在我们与客户关于对话智能的最初对话中,我总是首先提到的一件事是,期望和现实之间存在差距。我喜欢说,房间里的任何人都可以很容易地用人类可以理解但机器人不能理解的方式来表达事物,从而击败机器人。这项技术是有局限性的,在可用的预算和时间范围内它能做的事情也是有限的。没关系。只要我们创建一个专注于特定任务的聊天机器人,让我们的用户生活得更好,我们就在正确的道路上。如果我们能通过在聊天机器人中加入一些 NLU 来取悦用户,那就太好了!
自然语言机器学习背景
NLP 领域的起源可以追溯到艾伦·图灵,特别是图灵测试, 1 一种确定机器是否能够智能运行的测试。在测试中,评估者可以向两个参与者提问。以参与者之一是人类的身份回答;计算机是第二个参与者。基于评估者从两个参与者那里得到的答案,如果评估者不能确定哪个参与者是人类,哪个是计算机,那么就说计算机通过了图灵测试。一些系统声称能够通过图灵测试,但这些声明被认为是不成熟的。有一种批评认为,编写一个机器人来试图欺骗人类相信它是人类和理解人类的输入是两码事。我们距离通过图灵测试还有好几年的时间。
自然语言处理领域最著名的早期成功案例之一是伊莱扎,这是约瑟夫·韦森鲍姆写的一个心理学家的模拟。它写于 20 世纪 60 年代中期,是一个简单且看似智能的机器人的好例子。该机器人由一个脚本驱动,该脚本根据关键字为输入赋值,并将得分输入与输出进行匹配,这与机器人框架中的识别器没有什么不同。JavaScript 实现可以在网上找到;见图 2-1 。许多其他类似的系统被创造出来,并取得了不同程度的成功。
图 2-1
与 JavaScript 版本的 Eliza 交互的示例
NLU 引擎通常是基于规则的;它们用结构化的知识表示进行编码,供系统在处理用户输入时使用。大约在 20 世纪 80 年代,机器学习领域开始普及。机器学习是让计算机在没有为任务编码的情况下学习的过程——这似乎比基于规则的方法更接近智能。例如,我们简要地探讨了如何构建一个强力 NLU 引擎,以及使用各种规则进行编码的繁琐工作。使用机器学习,我们的系统不需要提前知道关于我们的领域和意图分类的任何事情,尽管我们当然可以从预先训练的模型开始。相反,我们将创建一个引擎,显示标有特定意图名称的样本输入。这被称为训练数据集。基于输入和标记的意图,我们训练一个模型来将输入识别为呈现的标签。一旦经过训练,模型就能够接收它尚未看到的输入,并为每个意图分配分数。我们训练模型的例子越多,它的性能就越好。这就是人工智能的用武之地:用高质量数据训练模型的净效果是,通过使用统计模型,系统可以开始对它尚未遇到的输入进行标签预测。
刚才描述的是监督学习的简化版本。该名称来源于输入数据被标记的事实。监督学习的性能可以很好地进行定量分析,因为我们知道真实的标签,并且能够将它们与预测的标签进行比较,以获得定量值,这种技术被称为交叉验证。最适合监督学习的任务类型是分类和回归问题。对于 C 类,分类是确定输入 I 是否属于 C 类的任务;例如,照片是熊猫的吗?或者,我们可以给定一组类 S,确定输入 I 的类。常见的分类算法包括支持向量机和决策树。图 2-2 、 2-3 和 2-4 展示了一个典型的监督学习场景。
图 2-2,2-3,2-4
一个监督学习的例子。图 2-2 是我们的训练数据,我们想请系统对图 2-3 中带问号的数据点进行分类。分类算法将利用数据点根据标记数据计算出边界,然后预测输入数据点的标记(图 2-4 )。
回归与此类似,但与预测连续值有关。例如,假设我们有一个跨机场的天气特征数据集。也许我们有温度、湿度、云量、风速、降雨量以及当天纽约 JFK、旧金山国际机场和芝加哥奥黑尔机场取消的航班数量的数据。我们可以将这些数据输入一个回归模型,并使用它来估计纽约、旧金山和芝加哥的一些假设天气的取消数量。
除了监督学习,还有其他形式的机器学习。无监督学习是理解未标记数据的任务,通常是如图 2-5 和 2-6 所示的数据聚类任务。
图 2-5 和 2-6
无监督学习,其中算法识别三组数据
半监督学习是用一些标记数据和一些未标记数据训练模型的想法。强化学习是一种系统学习的思想,通过进行观察,并基于所述观察,做出最大化某种回报函数的决策。如果决策产生了更好的回报,它就会被强化。否则,该决定将受到处罚。关于不同类型学习的更多信息可以在别处找到。 5
在斯坦福的 CS 页面上有一个关于深度强化学习的迷人插图。, 6 如图 2-7 所示。在这个演示中,一个代理人导航一个空间,并学习导航到有积极奖励的红苹果,并避免有毒的绿苹果。
图 2-7
深度强化学习算法的可视化
需要强调的一个有趣的点是,NLU 和 NLP 的普遍流行的 bot 应用都相当肤浅。事实上,有人批评称沃森在 Jeopardy 上做的事情或者机器人在 NLU 做的事情。正如雷·库兹韦尔在《华尔街日报》的一篇文章中所说,沃森不知道它赢了《危险边缘》。理解和分类/提取信息是两个不同的任务。这是一个公平的批评,但当涉及到在特定的狭窄背景下理解人类语言时,一个构建良好的意图和实体模型可以证明是有用的,这正是聊天机器人所做的。
除了意图分类问题之外,NLP 还关注诸如语音标记、语义分析、翻译、命名实体识别、自动摘要、自然语言生成、情感分析等任务。我们将在第十章的多语言机器人环境中研究翻译。
在 20 世纪 80 年代,对人工神经网络(ANN)研究的兴趣日益增加。在接下来的几十年里,该领域的进一步研究取得了引人入胜的成果。对人工神经网络中神经元的一种简单看法是,将其视为一个具有 N 个权重/输入和一个输出的简单函数。人工神经网络是一组相互连接的神经元。作为一个单元,神经网络接受一组输入并产生一个输出。训练神经网络的过程就是设置神经元权重值的过程。研究人员专注于分析许多不同类型的神经网络。深度学习是训练深度神经网络的过程,深度神经网络是在输入和输出之间有许多隐藏层的 ann(图 2-8 )。
谷歌的 Translate、AlphaGo 和微软的语音识别都通过利用深度神经网络取得了积极的成果。深度学习的成功是对隐藏层内各种连接架构进行研究的结果。一些更流行的架构是卷积神经网络(CNN)和递归神经网络(RNNs)。 8 与机器人相关的应用可能包括翻译、文本摘要、语言生成等。如果你想深入研究人工神经网络如何应用于自然语言任务,还有许多其他资源可供你探索。 9
当数据在神经元之间来回传递时,许多人工神经网络层内发生了什么?似乎没有人十分确定。例如,人们观察到谷歌的翻译创造了一种语言的中间表现形式。脸书创造可以与其他机器人或人类谈判的人工智能的项目导致人工智能创造了自己的速记甚至撒谎。这被认为是人工智能正在接管世界的一些迹象,而在现实中,尽管这些是迷人的和值得讨论的行为,但它们是训练过程的副作用。在未来,随着网络的复杂性产生更多意想不到的突发行为,这些副作用中的一些可能会变得更加令人毛骨悚然和可怕。目前,我们是安全的,不会被人工智能接管。
通过使用微软的认知工具包 10 和谷歌的张量流 11 等工具包来开发深度学习模型的便利性也是最近人工神经网络模型受欢迎程度上升的一个重要推动因素。
图 2-8
安
深度学习技术在自然语言处理任务中得到了非常成功的应用。特别是,语音识别和翻译已经从深度学习的引入中受益匪浅。事实上,微软研究院已经创建了语音识别软件,“它可以识别对话以及专业的人类转录员”, 12 ,谷歌通过利用深度学习,将其翻译算法的错误率降低了 55%至 85%。 13 然而,在意图分类等 NLU 任务中的有效性并不像深度学习宣传的那样强。这里的关键见解是,深度学习是 ML 工具包中的另一个工具,而不是银弹。
常见 NLP 任务
一般来说,NLP 处理大量的问题,其中的一个子集就是我们认为的 NLU 任务。在高层次上,主题可以与语言句法、语义和话语分析相关。并非每个 NLP 任务都与聊天机器人开发直接相关;其中一些是更相关的高阶特征的基础,例如意图分类和实体提取。
句法
语法任务通常处理与获取文本输入并将其分解成组成部分相对应的问题。这些任务中有许多是基础性的,不会被机器人直接使用。将输入分割成更小的语音单元,称为语素,并在一些语法中建立代表语音的结构就是两个例子。部分语音标记,用其词性(例如,名词、动词、代词)标记用户输入中的每个单词的过程,可用于细化用户查询。
语义学
语义任务与在自然语言输入中寻找意义有关。这些任务对聊天机器人有实际应用,包括以下内容:
-
命名实体提取:给定一些文本,确定哪些单词映射到名称以及名称的类型(例如,位置、人)。这直接适用于我们希望聊天机器人做的事情。
-
情感分析:识别某个文本的内容总体上是正面的、负面的还是中性的。这可以用于确定用户对机器人响应的情绪,重定向到人类代理,或者在机器人分析中理解用户在哪里出错以及对机器人的反应不好。
-
主题分割:给定一段文字,将其分割成与主题相关的片段,提取出那些主题。
-
关系提取:提取文本中对象之间的关系。
话语分析
话语分析是着眼于更大的自然语言结构并把它们作为一个单元来理解的过程。在这个领域,我们感兴趣的是从一个文本体的上下文中推导出意义。自动摘要用于摘要大量的内容,如公司财务报表。与聊天机器人更相关的是共同引用解析的概念。共指解析是确定多个单词所指的实体的思想。在下面的输入中, I 指的是 Szymon:
My name is Szymon. I am piling up cereal for my son.
常见的机器人 NLU 任务
如果我们计划在聊天机器人中使用 NLU,在评估解决方案时需要考虑几个特性。最基本的功能是识别定制意图和实体的能力。以下是一些需要考虑的功能:
-
多语言支持:在 NLU 实现中对多语言的支持充分说明了 NLU 平台的重要性。不同语言的优化经验可以很好地反映团队对 NLU 的整体体验。
-
包含预建模型的能力:领先总是值得的,许多系统将包含许多与特定领域相关的预建意图和实体,供您开始使用。
-
预构建的实体:我们希望现有系统能够轻松地为我们提供许多类型的实体,例如数字和日期/时间对象。
-
实体类型:应该能够指定不同类型的实体(比如列表和非列表)。
-
同义词:系统应该接受显示给实体分配同义词的能力。
-
通过主动学习进行持续培训:系统应支持利用真实用户输入作为 NLU 模型培训数据的能力。
-
虽然这些工具会为你实现某种用户界面来训练模型,但是应该有一个你可以利用的 API。
-
导出/导入:该工具应该允许您导入/导出模型,最好是像 JSON 这样的开放文本格式。
利用现有服务的另一种方法是编写自己的服务。这是一个高级话题。如果你正在读这本书,很可能你没有足够的经验和知识来完成它。有一些易于使用的 ML 包,如 scikit-learn,可能会给人这样的印象,即创建这样的东西很容易,但这需要大量的优化、调优、测试和操作。从这些通用 NLU 系统中获得正确的性能需要大量的时间、精力和专业知识。如果你对这些技术的工作原理感兴趣,网上有大量的资料供你自学。 14
基于云的 NLU 系统
来自云计算领域的好消息是,大型技术公司正在对 ML 即服务领域进行投资,我们的机器人需要的任务的基本功能可以作为服务获得。从实践的角度来看,这里有许多好处:开发人员不必关心为我们的分类问题选择最佳算法,没有必要扩展实现,有现有的高效用户界面和升级,优化是无缝的。如果你正在创建一个聊天机器人,并且需要基本的分类和实体提取功能,那么使用基于云的服务是最好的选择。
这个领域是非常动态的,这些系统的特征和焦点随着时间而变化。无论如何,在撰写本文时,以下是最佳选择,排名不分先后:
-
微软的语言理解情报服务(LUIS) :这是一个最纯粹的 LU 系统的例子,因为它完全独立于一个对话引擎。LUIS 允许开发人员添加意图和实体,对 LUIS 应用进行版本控制,在发布之前测试应用,并最终发布到测试或生产端点。此外,它还包括一些非常有趣的主动学习功能。
-
Google 的 dialog flow(Api.ai):dialog flow,以前叫 API . ai,已经有一段时间了。它允许开发人员创建 NLU 模型,定义转换流,并在满足特定条件时调用 webhooks 或云函数。可以通过 API 或通过集成到许多消息传递通道来访问对话。
-
亚马逊的 Lex :亚马逊的 Alexa 早就允许用户创建意图分类和实体提取模型。随着 Lex 的引入,Amazon 通过 bot 开发为 NLU 带来了更好的用户界面。在撰写本文时,Lex 有一些通道集成,可以通过 API 访问。像 Dialogflow 一样,Lex 允许开发人员使用 API 与机器人对话。
-
IBM Watson Conversation :另一个类似的系统 Watson Conversation 允许用户定义意图、实体和基于云的对话。该对话可通过 API 访问。在撰写本文时,还没有预构建的通道连接器;尽管存在示例,但代理必须由 bot 开发人员编写。
-
脸书的 Wit.ai : Wit.ai 已经存在了一段时间,它包括一个定义意图和实体的接口。截至 2017 年 7 月,它正在重新关注 NLU,并移除 bot 引擎部分。Wit.ai 也正在与 Facebook Messenger 生态系统更紧密地结合在一起。
在下一章的 NLU 深海潜水中,我们将利用路易斯。作为一个纯粹的 NLU 系统,LUIS 具有显著的优势,尤其是在 Bot 框架集成方面。尽管目前在 NLU 领域没有太多的基准,LUIS 仍然是市场上表现最好的 NLU 系统之一。 十五
企业空间
在企业领域还有许多其他选择,实在是不胜枚举。你可能会遇到一些更大的公司和产品名称,如 IPsoft 的 Amelia 和 Nuance 的 Nina。这一领域的产品通常都很先进,包含多年的企业级投资。一些公司专注于 IT 或其他流程自动化。一些公司关注内部用例。一些公司专注于特定的垂直行业。然而,其他公司完全专注于围绕特定用例预先构建的 NLU 模型。在一些产品中,我们将通过专有语言而不是开放语言来编写 bot 实现。
最终,企业的决策是一个典型的购买还是构建的两难选择。利基解决方案可能会持续一段时间,但有理由认为,随着 IBM、亚马逊、微软、谷歌和脸书投入这一领域的投资金额,资金支持较少的公司可能会受到阻碍。不解决一般 bot 问题的利基参与者肯定会蓬勃发展,我认为我们会发现更多的公司在专业 NLU 和 bot 解决方案方面进行创造和创新,这些解决方案由大型科技公司提供支持。
结论
我们确实看到了人工智能在 NLU 领域的民主化。几年前,bot 开发人员必须使用现有的 NLU 和 ML 库来创建一个系统,该系统可以像我们现在可用的云选项一样容易地训练和使用。现在,创建一个集成了 NLU、情感分析和共同参照的机器人非常容易。这些公司在这些系统背后的努力也不值得嘲笑;最大的技术公司正在挖掘这个领域,为他们的用户提供工具,为他们自己的平台建立对话体验。对于身为机器人开发者的你来说,这太棒了。这意味着竞争将不断推动该领域的创新,随着该领域研究的进展,分类、实体提取和主动学习方面的改进将提高 NLU 系统的性能。Bot 开发者将从所有这些 NLP 服务的加速研究和改进性能中获益。
三、语言理解智能服务(LUIS)
LUIS 是我和我的团队广泛使用的 NLU 系统,是应用意图分类和实体提取的重要概念的完美学习工具。进入 https://luis.ai
即可进入系统。使用 Microsoft 帐户登录后,将显示一个页面,描述如何构建 LUIS 应用。这很好地介绍了我们将在本章中完成的不同任务。完成后,点击底部附近的创建路易斯应用按钮。您将进入 LUIS 申请页面。点击新建 app 按钮,输入名称;将为您创建一个 LUIS 应用,您可以在其中创建一个新模型,并训练、测试和发布它,以便在准备就绪时通过 API 使用。
在这一章中,我们将创建一个 LUIS 应用,让我们为日历礼宾机器人供电。日历礼宾机器人将能够添加、编辑和删除约会;总结我们的日历;并在一天内找到空房。这项任务将带我们浏览 LUIS 的各种功能。到本章结束时,我们将开发出一个 LUIS 应用,它不仅可以用来创建一个有用的机器人,而且可以不断进化,表现得更好。
首先,让我们在 LUIS 中创建新的应用。当我们点击新建 app 按钮时,会弹出如图 3-1 所示的窗口。填写名称和描述字段。路易斯不仅用英语工作,还支持其他文化。不同的语言需要不同的语言模型和优化。该选择通知 LUIS 您的应用将使用哪种文化,以便可以利用这些优化。在撰写本文时,LUIS 支持巴西葡萄牙语、中文、荷兰语、英语、法语、加拿大法语、德语、意大利语、日语、韩语、西班牙语和墨西哥西班牙语。随着系统的成熟,可能会引入更广泛的文化支持。
图 3-1
创建新的 LUIS 应用
一旦应用被创建,你将会看到 LUIS 界面的构建部分(图 3-2 )。如您所见,除了 None 意图之外,它是空的。一旦我们开始训练意图,我们将进入那个。您还会看到“审阅端点话语”链接。这是 LUIS 的主动学习特性,我们将在后续章节中探讨。
图 3-2
LUIS 构建部分
请注意,在撰写本文时,LUIS 应用仅限于 500 个 intents、30 个实体和 50 个 list 实体。当 LUIS 第一次发布时,限制更接近于 10 个意图和 10 个实体。最新的数字总是可以在网上找到。 1
在页面顶部,您将看到您的应用名称、活动版本以及 LUIS 的仪表板、构建、发布和设置部分的链接。我们还可以从界面中轻松地训练和测试模型。在构建日历礼宾应用时,我们将探索 LUIS 的每个部分。
分类意图
我们在前一章中介绍了意图分类的概念,但这将是我们第一次在实践中深入探讨。再次重申,我们想创建一个 LUIS 应用,让我们添加、编辑或删除日历条目;显示日历的摘要;并在我们的日历中查看是否有空。我们将创建以下意图:
-
AddCalendarEntry
-
RemoveCalendarEntry
-
编辑日历目录
-
ShowCalendarSummary
-
检查可用性
我们在构建部分停止了。在左侧窗格中,我们选择了 Intents 项。系统中只有一个意图:没有。每当用户的输入与任何其他意图都不匹配时,就会解析这个意图。我们可以在我们的机器人中使用这一点来告诉用户,他们正在试图问机器人专业领域之外的问题,并提醒他们机器人有什么能力。
使用 LUIS 的典型工作流程是添加一个意图,并向 LUIS 展示几个代表该意图的示例话语。这正是我们要做的。图 3-3 说明了创建意图的过程。UI 允许我们在自由文本输入字段中输入话语。我们输入一个样本,按 enter 键,输入另一个样本,按 Enter 键,等等。一旦我们添加了足够多的示例话语,我们点击保存按钮,我们就完成了意图(图 3-4 )。
图 3-4
为 AddCalendarEntry 目的添加话语
图 3-3
添加新的 LUIS 意向
请注意,用户界面允许我们搜索话语、删除话语、将意图重新分配给话语,并以几种不同的格式显示数据。您可以随意探索这一功能。
在我们添加其余的意图之前,让我们看看到目前为止我们是否可以训练和测试应用。注意右上方的火车按钮有一个红色的指示灯;这意味着应用有尚未训练的变化。请点击“培训”按钮。您的请求将被发送到 LUIS 服务器,您的应用将排队等待培训。您可能会注意到出现一条消息,通知您 LUIS 正在训练您的应用,并且“0/2 已完成”2 是您的应用当前包含的分类器模型的数量。一个用于 None intent,一个用于 AddCalendarEntry。训练完成后,训练按钮指示器将变为绿色,表示应用是最新的。
意图界面还为我们提供了关于最新训练的应用对每个话语的哪个意图得分最高的信息(图 3-5 )。这段数据很重要,因为我们可以很容易地看到,当一个应用被训练为将一个话语分类为一个意图,但将最高分分配给不同的意图。训练和结果意图之间的差异通常表明在一个或多个模型中有东西在错误的方向上影响结果。我们将在本章的故障排除部分讨论这种情况和其他情况。目前,似乎我们所有的话语都已经被成功地训练,在 AddCalendarEntry 意图上的得分为 1,在 None 意图上的得分在 0.05 到 0.07 之间(见图 3-6);这些数字可能会根据您的确切发言以及 LUIS 工程团队所做的更改而有所不同。
图 3-6
我们应用中每个意图的话语得分
图 3-5
AddCalendarEntry 意向得分最高的意向(也称为预测意向)
训练完成后,我们可以使用“训练”按钮旁边的“测试”幻灯片来测试模型,并查看它们如何响应不同的输入(图 3-7 )。批量测试面板链接允许执行更大量的测试。出于我们的目的,我们将坚持交互模式。
图 3-7
交互式测试我们的模型
LUIS 的工作方式是,它在我们应用的训练阶段训练的所有模型中运行每个输入。对于每个模型,我们都会得到一个介于 0 到 1 之间的分数。得分最高的意向会突出显示。请注意,分数并不对应于概率。分数取决于所使用的算法,通常表示输入与意图的理想形式之间的距离。如果 LUIS 在一个以上的意向上给一个输入打了相似的分数,我们可能需要做一些额外的培训。
在训练和测试我们的应用后,它似乎表现良好,直到我们试图打破它。然后,它很快开始看起来不对劲。图 3-8 说明了这一点。
图 3-8
测试古怪和荒谬的输入
哎呀。这并不十分令人惊讶。我们已经用有限数量的话语训练了一个意图。我们向无意图者提供了零样本话语。这是一个训练不足的模型会表现出来的行为。让我们添加一些这些愚蠢的短语到无意图,训练,再测试。你可以尝试添加一些无意义的测试用例,如图 3-9 所示。应该效果更好。我们现在不会解决像这样的所有问题。这需要一些时间、奉献和用户反馈。但我们应该意识到,训练应用不应该知道的东西和训练应用应该知道的东西一样重要。
图 3-9
我们取得了一些进展!
接下来,我们将添加剩余的意图。图 3-10 、图 3-11 、图 3-12 和图 3-13 显示了 CheckAvailability、EditCalendarEntry、DeleteCalendarEntry 和 ShowCalendarSummary 意图的一些示例语句。
图 3-13
ShowCalendarSummary 意图示例话语
图 3-12
DeleteCalendarEntry 意图示例话语
图 3-11
EditCalendarEntry 意图示例话语
图 3-10
检查可用性意图示例话语
一旦所有的意图都被创建并用样本话语填充,我们就训练并确认预测的意图看起来是准确的。你可能会注意到,尽管每个话语的得分最高的意图是正确的,但得分相当低(图 3-14 )。这是我们进一步培训应用的机会。事实上,我们永远不能假设我们可以用如此有限的词汇和数据集来训练一个被识别的意图。正确对待 NLU 需要耐心、奉献和思考。在接下来的练习中,我们将向我们的应用添加更多话语。
图 3-14
分数看起来不太好。这是一个进一步训练的机会。
练习 3-1
训练路易斯意图
前面的示例显示了我们训练的意图的一些示例输入。你的任务是创建一个 LUIS 应用,创建相同的意图集,并用足够的话语样本训练该应用,以便所有意图得分都在 0.80 以上。
-
创建以下意图,并为每个意图输入至少十个示例话语:
-
AddCalendarEntry
-
RemoveCalendarEntry
-
编辑日历目录
-
ShowCalendarSummary
-
检查可用性
-
-
给无意图增加一些训练。关注在这个应用中没有意义或者没有意义的输入,比如“我喜欢咖啡”这是有意义的,但不适用于此应用。
-
训练 LUIS 应用,并通过访问意图页面观察每个话语的预测分数。也可以使用交互式测试选项卡。
-
分数是多少?它们是否高于 0.80?更低?不断给每一个意图添加示例话语以提高分数。请确保经常训练应用,并重新加载意图话语,以查看更新的分数。需要多少话语才能让你对自己的应用充满信心?
一旦你完成了这些练习,你就积累了训练和测试路易斯意图的经验。
发布应用
显然,我们还没有完成开发我们的应用。有相当多的东西丢失,路易斯的许多细节我们还没有探索。我们也还没有看到任何真实的用户数据。但是,我们可以并行开发 LUIS 应用和消费应用。获取我们训练过的应用并通过 HTTP 访问它的过程被称为发布我们的应用。
在应用的顶部导航栏上,在构建部分的旁边,我们可以找到发布部分。当我们点击这个按钮时,我们会看到一个页面,允许我们部署 LUIS 应用(图 3-15 )。LUIS 允许我们在两个部署位置之一发布应用:试运行或生产。Staging 是指在我们仍在开发和测试 LUIS 应用时使用。生产插槽旨在供生产应用使用。这两个插槽背后的想法是,您可以将 LUIS 应用的先前稳定版本部署到生产中,同时在 staging 插槽中开发新的应用功能。
图 3-15
LUIS 发布页面
我们将从“发布到”下拉列表中选择分段插槽。一旦发布,我们就可以通过 HTTP 端点访问应用。
在我们使用 cURL(一个通过 HTTP(以及许多其他协议)传输数据的命令行工具)测试最终的端点之前,您可能已经注意到在 publish 设置下面有一个 Add Key 按钮和一组用于几个部署区域的键。当访问 LUIS 应用时,我们必须提供一个密钥,这是 LUIS 可以为 API 使用向我们收费的方式。LUIS 被部署到几个地区;一个键必须与一个区域相关联。密钥是使用微软的 Azure 门户创建的。Azure 是微软的云服务保护伞。我们将在第五章利用它来注册和部署一个机器人。要将密钥与应用相关联,我们必须使用 Add Key 按钮。幸运的是,LUIS 提供了一个免费的启动密钥,可以用来攻击发布在 Staging slot 中的应用。
一旦我们发布到暂存槽,就会发生一些事情。我们现在有了关于应用版本和上次发布时间的信息。Starter_Key 下的 URL 现在可以使用了。我们可能会通过 URL 查询参数启用详细结果(我们稍后将对此进行研究)或 Bing 拼写检查集成(我们将在本章稍后讨论)。让我们仔细看看网址。
https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/3a26be6f-6227-4136-8bf4-c1074c9d14b6?
subscription-key=a9fe39aca38541db97d7e4e74d92268e&
staging=true&
verbose=true&
timezoneOffset=0&
q=
URL 的第一行是美国西部地区 Azure 认知服务的服务端点,特别是我们的 LUIS 应用。以下是查询参数:
-
订阅密钥,在这种情况下是启动密钥。这个密钥也可以通过 Ocp-Apim-Subscription-Key 头传递。
-
指示是使用分段插槽还是生产插槽的标志。不包括此参数假定生产插槽。
-
详细标志,指示是返回所有意向及其得分,还是仅返回得分最高的意向。
-
时区偏移有助于时间标记日期时间解析,这是我们在探索内置日期时间实体时将深入探讨的主题。
-
q 表示用户的查询。
我们可以通过使用 curl 发出请求并查看响应来使用 API。在其核心,curl 是一个命令行工具,通过各种协议传输数据。我们将用它在 HTTPS 传输数据。你可以在 https://curl.haxx.se/
找到更多信息。我们可以使用的命令如下。注意,我们将订阅密钥作为 HTTP 头传递。
curl -X GET -G -H "Ocp-Apim-Subscription-Key: a9fe39aca38541db97d7e4e74d92268e" -d staging=true -d verbose=true -d timezoneOffset=0 "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/3a26be6f-6227-4136-8bf4-c1074c9d14b6" --data-urlencode "q=hello world"
该查询产生以下 JSON。它为我们的 LUIS 应用中的每个意向给出了分数。
{
"query": "hello world",
"topScoringIntent": {
"intent": "None",
"score": 0.24031198
},
"intents": [
{
"intent": "None",
"score": 0.24031198
},
{
"intent": "DeleteCalendarEntry",
"score": 0.1572571
},
{
"intent": "AddCalendarEntry",
"score": 0.123305522
},
{
"intent": "EditCalendarEntry",
"score": 0.0837310851
},
{
"intent": "CheckAvailability",
"score": 0.07568088
},
{
"intent": "ShowCalendarSummary",
"score": 0.0100482805
}
],
"entities": []
}
你可能会想,哇,我们刚刚了解到我们可以有多达 500 个意图,所以这个响应的大小将是荒谬的。你这样想是非常正确的(尽管 gzip 在这里肯定会有所帮助)!将 verbose query 参数设置为 false 会产生一个非常紧凑的 JSON 列表。
{
"query": "hello world",
"topScoringIntent": {
"intent": "None",
"score": 0.24031198
},
"entities": []
}
一旦我们准备好部署到生产中,我们将把 LUIS 应用发布到生产插槽中,并从 URL 请求中删除 staging 参数。实现这一点最简单的方法就是让您的开发和测试配置文件指向登台槽 URL,让生产配置指向生产槽 URL。
当然,您也可以使用任何其他您熟悉的 HTTP 工具。此外,微软提供了一个易于使用的控制台来测试在线 API 文档中的 LUIS API。 2
练习 3-2
发布 LUIS 应用
现在,您将发布练习 3-1 中的 LUIS 应用,并通过 curl 访问它。
-
按照上一节中的步骤,将 LUIS 应用发布到登台槽中。
-
使用 curl 从 LUIS API 为您输入的示例话语和您能想到的其他话语获取预测意图的 JSON。
-
确保 curl 命令使用您的应用 ID 和 starter 键。
将应用发布到插槽中的过程非常简单。习惯使用 curl 测试 HTTP 端点非常重要,因为您通常需要访问 API 来检查 LUIS 的结果。
提取实体
到目前为止,我们已经开发了一个简单的基于意图的 LUIS 应用。但是除了它能够告诉我们的机器人用户的意图,我们真的不能做太多。LUIS 给我们提供用户想要添加日历条目的信息是一回事,但是最好能够告诉我们日期和时间、地点、持续时间以及与谁一起。我们可以开发一个机器人,每当它看到 AddCalendarEntry 时,就以线性顺序向用户询问所有这些细节。然而,这是乏味的,并且忽略了这样一个事实,即用户很可能向机器人呈现这样的话语:
"add meeting with Huck tomorrow at 6pm"
要求用户重新输入所有这些数据将是糟糕的用户体验。机器人应该立即知道“明天下午 6 点”的日期时间值是多少,并且应该将“Huck”添加到邀请中。
让我们从基础开始。我们如何确保“明天下午 6 点”“一周后”和“下个月”是机器可读的吗?这就是实体识别的用武之地。幸运的是,LUIS 配备了许多内置实体,我们可以将它们添加到我们的应用中。通过这样做,日期时间提取将“正常工作”
如果我们回到 LUIS 应用的构建部分并单击实体标题,我们将看到一个空的实体列表(图 3-16 )。我们可以添加三种不同类型的实体。现在,我们将简单地添加一个预构建的实体。我们将在后面的章节中讨论普通实体和预构建的域实体。
图 3-16
空实体页面
预先构建的实体是预先训练的定义,可以在话语中识别。实体在输入中被自动标记,我们不能改变预构建实体的识别方式。它们中有大量的逻辑,我们可以在我们的应用中利用,在构建我们自己的实体之前,最好理解微软已经构建了什么。
有许多不同的预构建实体。并非所有实体在所有支持的区域性中都可用。LUIS 文档提供了跨文化可用的预构建实体的详细信息 3 (图 3-17 )。
图 3-17
LUIS 跨不同文化的内置实体支持
其中一些实体包括所谓的值解析。值解析是获取文本输入并将其转换为计算机可以解释的值的过程。例如,“十万”应解析为 100000,“明年 5 月 10 日”应解析为 05/10/2019,依此类推。
您可能已经注意到 LUIS 的 JSON 结果包含了一个名为 entities 的空数组。这是用户输入中识别的所有实体的占位符。LUIS 应用可以识别输入中任意数量的实体。每个实体的格式如下:
{
"entity": "[entity text]",
"type": "[entity type]",
"startIndex": [number],
"endIndex": [number],
"resolution": {
"values": [
{
"value": "[machine readable string of resolved value]"
}
]
}
}
根据检测到的实体类型,解析对象可能包含额外的属性。让我们看看不同的预建实体类型,它们允许我们做什么,以及 LUIS API 的结果是什么样子。
年龄、维度、金钱和温度
年龄实体允许我们检测年龄表达式,如“五个月大”、“100 岁”和“两天大”result 对象包括数字格式的值和一个单位参数,如日、月或年。
{
"entity": "five months old",
"type": "builtin.age",
"startIndex": 0,
"endIndex": 14,
"resolution": {
"unit": "Month",
"value": "5"
}
}
使用尺寸实体可以检测任何长度、重量、体积和面积度量。输入可以从“10 英里”到“1 厘米”到“50 平方米”不等像年龄实体一样,结果解析将包括一个值和一个单位。
{
"entity": "two milliliters",
"type": "builtin.dimension",
"startIndex": 0,
"endIndex": 14,
"resolution": {
"unit": "Milliliter",
"value": "2"
}
}
货币实体可以帮助我们检测输入中使用的货币。分辨率再次包括一个单元和值属性。
{
"entity": "12 yen",
"type": "builtin.currency",
"startIndex": 0,
"endIndex": 5,
"resolution": {
"unit": "Japanese yen",
"value": "12"
}
}
温度实体帮助我们检测温度,并在分辨率中包含一个单位和值属性。
{
"entity": "98 celsius",
"type": "builtin.temperature",
"startIndex": 0,
"endIndex": 9,
"resolution": {
"unit": "C",
"value": "98"
}
}
日期时间 V2
DatetimeV2 是一个强大的分层实体,它取代了之前的 datetime 实体。分层实体定义类别及其成员;当某些实体相似且密切相关,但具有不同的含义时,使用它是有意义的。datetimeV2 实体还尝试以机器可读的格式解析日期时间,如 TIMEX(代表“时间表达式”);TIMEX3 是 TimeML 的一部分)和以下格式:yyyy:MM:dd、HH:mm:ss 和 yyyy:MM:dd HH:mm:ss(分别表示日期、时间和日期时间)。下面是一个基本的例子。
{
"entity": "tomorrow at 5pm",
"type": "builtin.datetimeV2.datetime",
"startIndex": 0,
"endIndex": 14,
"resolution": {
"values": [
{
"timex": "2018-02-18T17",
"type": "datetime",
"value": "2018-02-18 17:00:00"
}
]
}
}
除了前面示例中的 datetime 子类型之外,DatetimeV2 实体还可以检测各种子类型。以下是示例响应列表。
这将显示 builtin.datetimeV2.date,其中包含“昨天”、“下周一”和“2015 年 8 月 23 日”等短语:
{
"entity": "yesterday",
"type": "builtin.datetimeV2.date",
"startIndex": 0,
"endIndex": 8,
"resolution": {
"values": [
{
"timex": "2018-02-16",
"type": "date",
"value": "2018-02-16"
}
]
}
}
这将显示 builtin.datetimeV2.time,其中包含诸如“下午 1 点”、“上午 5 点 43 分”、“8 点”或“上午 8 点半”之类的短语:
{
"entity": "half past eight in the morning",
"type": "builtin.datetimeV2.time",
"startIndex": 0,
"endIndex": 29,
"resolution": {
"values": [
{
"timex": "T08:30",
"type": "time",
"value": "08:30:00"
}
]
}
}
这将显示 builtin.datetimeV2.daterange,其中包含诸如“下周”、“去年”或“2 月 1 日至 2 月 20 日”等短语:
{
"entity": "next week",
"type": "builtin.datetimeV2.daterange",
"startIndex": 0,
"endIndex": 8,
"resolution": {
"values": [
{
"timex": "2018-W08",
"type": "daterange",
"start": "2018-02-19",
"end": "2018-02-26"
}
]
}
}
这显示了 building.datetimeV2.timerange,带有诸如“1 到 5p”和“1 到 5pm”的短语:
{
"entity": "from 1 to 5pm",
"type": "builtin.datetimeV2.timerange",
"startIndex": 0,
"endIndex": 12,
"resolution": {
"values": [
{
"timex": "(T13,T17,PT4H)",
"type": "timerange",
"start": "13:00:00",
"end": "17:00:00"
}
]
}
}
这将显示 builtin . datetime v2 . datetime range,其中包含诸如“明天早上”或“昨晚”之类的短语:
{
"entity": "tomorrow morning",
"type": "builtin.datetimeV2.datetimerange",
"startIndex": 0,
"endIndex": 15,
"resolution": {
"values": [
{
"timex": "2018-02-19TMO",
"type": "datetimerange",
"start": "2018-02-19 08:00:00",
"end": "2018-02-19 12:00:00"
}
]
}
}
这显示了 builtin.datetimeV2.duration,带有诸如“一小时”、“20 分钟”或“一整天”的短语该值以秒为单位进行解析。
{
"entity": "an hour",
"type": "builtin.datetimeV2.duration",
"startIndex": 0,
"endIndex": 6,
"resolution": {
"values": [
{
"timex": "PT1H",
"type": "duration",
"value": "3600"
}
]
}
}
builtin.datetimeV2.set 类型表示一组日期,可以通过包含“每日”、“每月”、“每周”或“每周四”这样的短语来检测这种类型的分辨率不同,因为没有单个值来表示一个集合。timex 分辨率将通过两种方式之一进行解析。首先,timex 字符串将遵循模式 P[n][u],其中[n]是一个数字,而[u]是日期单位,如 D 代表天,M 代表月,W 代表周,Y 代表年。意思是“每[n] [u]个单位。”P4W 表示每四周,P2Y 表示每隔一年。第二个 timex 解析是一个日期模式,其中 Xs 代表任意值。例如,XXXX-10 表示每年十月,XXXX-WXX-6 表示一年中任何一周的每个星期六。
{
"entity": "daily",
"type": "builtin.datetimeV2.set",
"startIndex": 0,
"endIndex": 4,
"resolution": {
"values": [
{
"timex": "P1D",
"type": "set",
"value": "not resolved"
}
]
}
}
{
"entity": "every saturday",
"type": "builtin.datetimeV2.set",
"startIndex": 0,
"endIndex": 13,
"resolution": {
"values": [
{
"timex": "XXXX-WXX-6",
"type": "set",
"value": "not resolved"
}
]
}
}
如果在日期和/或时间上有歧义,LUIS 将返回多个决议来证明选项。例如,日期的不确定性意味着如果今天是 7 月 20 日,我们输入“7 月 21 日”,系统将返回今年和去年的 7 月 21 日。同样,如果您的查询没有指定上午或下午,LUIS 将返回这两个时间。你可以在这里看到这两种情况:
{
"entity": "july 21",
"type": "builtin.datetimeV2.date",
"startIndex": 0,
"endIndex": 6,
"resolution": {
"values": [
{
"timex": "XXXX-07-21",
"type": "date",
"value": "2017-07-21"
},
{
"timex": "XXXX-07-21",
"type": "date",
"value": "2018-07-21"
}
]
}
}
{
"entity": "tomorrow at 5",
"type": "builtin.datetimeV2.datetime",
"startIndex": 0,
"endIndex": 12,
"resolution": {
"values": [
{
"timex": "2018-02-19T05",
"type": "datetime",
"value": "2018-02-19 05:00:00"
},
{
"timex": "2018-02-19T17",
"type": "datetime",
"value": "2018-02-19 17:00:00"
}
]
}
}
日期时间 V2 实体是强大的,并真正展示了路易斯 NLU 的一些伟大的功能。
电子邮件、电话号码和网址
这三种类型都是基于文本的。LUIS 可以识别用户输入中何时存在其中之一。由 LUIS 来做这件事很方便,而不是必须在我们的系统中实现正则表达式逻辑。我们在这里演示三种类型:
{
"entity": "srozga@bluemetal.com",
"type": "builtin.email",
"startIndex": 0,
"endIndex": 19
}
{
"entity": "212-222-1234",
"type": "builtin.phonenumber",
"startIndex": 0,
"endIndex": 11
}
{
"entity": "https://luis.ai",
"type": "builtin.url",
"startIndex": 0,
"endIndex": 14
}
数字、百分比和序数
LUIS 也可以为我们提取和解析数字和百分比。用户输入可以是数字或文本格式。它甚至可以处理像“38.5”这样的输入
{
"entity": "one hundred",
"type": "builtin.number",
"startIndex": 0,
"endIndex": 10,
"resolution": {
"value": "100"
}
}
{
"entity": "52 percent",
"type": "builtin.percentage",
"startIndex": 0,
"endIndex": 9,
"resolution": {
"value": "52%"
}
}
序数实体允许我们识别文本或数字形式的序数。
{
"entity": "second",
"type": "builtin.ordinal",
"startIndex": 0,
"endIndex": 5,
"resolution": {
"value": "2"
}
}
实体培训
让我们回到我们的应用,应用一些我们刚刚学到的东西。因为我们正在编写一个与日历相关的应用,所以最明显的首选预建实体是 datetimeV2。在实体页面,点击“管理预置实体”,选择日期时间 V2,如图 3-18 所示。
图 3-18
向模型中添加 datetimeV2 实体
添加实体后,我们应该训练我们的模型。在交互测试 UI 中,当我们输入“添加明天下午 5 点的日历条目”时,我们应该会看到如图 3-19 所示的结果。
图 3-19
datetimeV2 实体处于活动状态!
那很容易。我们再次将应用发布到登台槽。使用 curl 运行相同的查询,我们会收到以下 JSON:
{
"query": "add calendar entry tomorrow at 5pm",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.42710492
},
"entities": [
{
"entity": "tomorrow at 5pm",
"type": "builtin.datetimeV2.datetime",
"startIndex": 19,
"endIndex": 33,
"resolution": {
"values": [
{
"timex": "2018-02-19T17",
"type": "datetime",
"value": "2018-02-19 17:00:00"
}
]
}
}
]
}
太好了。我们现在可以在任何意图中使用日期时间实体。这将与我们所有应用的意图相关,而不仅仅是 AddCalendarEntry。此外,我们将继续添加电子邮件预构建实体,重新培训,并再次发布到暂存槽。现在我们可以试着说“明天下午 5 点见”,来得到我们想要的结果。
{
"query": "meet with szymon.rozga@gmail.com at 5p tomorrow",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.3665758
},
"entities": [
{
"entity": "szymon.rozga@gmail.com",
"type": "builtin.email",
"startIndex": 10,
"endIndex": 31
},
{
"entity": "5p tomorrow",
"type": "builtin.datetimeV2.datetime",
"startIndex": 36,
"endIndex": 46,
"resolution": {
"values": [
{
"timex": "2018-02-19T17",
"type": "datetime",
"value": "2018-02-19 17:00:00"
}
]
}
}
]
}
练习 3-3
添加日期时间和电子邮件实体支持
在本练习中,您将在您目前正在使用的 LUIS 应用上启用预构建实体。
-
将 email 和 datetimev2 预构建实体添加到您的应用中。训练你的应用。
-
进入您的 AddCalendarEntry 意图,尝试添加几个带有日期时间和电子邮件表达式的话语。请注意,LUIS 为您突出显示了这些实体。
-
将 LUIS 应用发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
预构建的实体非常容易使用。作为进一步的练习,向您的模型中添加一些其他预构建的实体,以了解它们如何工作以及它们如何在不同类型的输入中被拾取。如果您想防止 LUIS 识别它们,只需将它们从应用的实体中移除即可。
自定义实体
预构建的实体可以为我们的模型做很多事情,而不需要任何额外的培训。如果我们需要的一切都可以由现有的预构建实体提供,那将是令人惊讶的。在我们的日历应用的例子中,根据定义,日历条目包括一些我们感兴趣的属性。
首先,我们通常希望给会议一个主题(不仅仅是“会见 Bob”)和一个地点。两者都是会议主题和地点的任意字符串。我们如何做到这一点?
LUIS 让我们能够训练自定义实体来检测这些概念,并从用户输入中提取它们的值。这就是实体提取算法真正发挥作用的地方;我们向 LUIS 展示了单词何时应该被识别为实体,何时应该被忽略的例子。NLP 算法考虑了上下文。例如,给定多个话语样本,我们可以教路易斯,并确保它不会将星巴克与莫比·迪克中的角色星巴克混淆。
在 LUIS 中,我们可以利用四种不同类型的定制实体:简单、复合、分层和列表。让我们逐一检查。
简单实体
简单的自定义实体是一种实体,如日历条目主题或预建的电子邮件、电话号码和 URL 实体。基于用户输入的一个片段在话语中的位置及其周围单词的上下文,可以将其识别为所述类型的实体。LUIS 使创建和训练这些类型的实体变得很容易。让我们创建日历主题实体。
比方说,当我们告诉日历机器人条目的主题名称时,我们想弄清楚。假设我们想要接受输入,比如“下午 5 点与 Kim 会面,讨论抵押申请。”在这个例子中,主题是“抵押申请”让我们把这个放好。
导航到实体页面,点击“创建新实体”按钮,创建一个名为 Subject 的新的简单实体,如图 3-20 所示。
图 3-20
创建新的简单实体
单击完成后,该条目将被添加到应用的实体列表中。训练实体的过程发生在与训练意图相同的界面中。让我们导航到 AddCalendarEntry 意图,并添加语句“在下午 5 点与 Kim 会面,讨论抵押申请”,如图 3-21 所示。请注意,这只是一个普通的说法,没有实体被识别。
图 3-21
添加话语。路易斯对科目还不了解。
我们现在将鼠标悬停在抵押贷款和申请词语上,注意到路易斯允许我们选择词语。点击抵押,然后点击申请,这样路易斯就选择了短语“抵押申请”。弹出窗口将列出应用中的所有自定义实体类型。选择主题。LUIS 的话语现在应该如图 3-22 所示。
图 3-22
突出显示和分配的实体
保存话语并训练您的应用。在这一点上,路易斯还不太擅长辨别对象。毕竟我们只是提供了一个例子,实体识别比意图分类更难做好。它需要更多的样本。我们可以在话语编辑器中为添加日历条目输入更多的话语。一些样品如图 3-23 所示。
图 3-23
添加更多带有主题的话语。在用一个样本训练 LUIS 之后,没有一个被识别出来。
请注意,没有确定任何受试者。我们来强化一下这个概念。系统开始识别实体需要相当多的例子。我添加了十多条在话语中某处有某种主题的话语,如图 3-24 所示。此外,一定要标记你自己添加的任何话语的主题。我称之为“让路易斯屈从于你的意志”的过程与其说是一门科学,不如说是一门艺术。要记住的关键点是,将会有一个拐点,算法开始意识到,根据统计推断,一个词后面的东西总是一个实体,直到其他一些关键词。想象一个你正在慢慢尝试平衡的天平。我们的话语应该精心制作,以确保我们捕捉到尽可能多的变化,以展示给路易斯。通常,每个变体还需要包括一些样本,以真正捕捉算法可以在话语的上下文中找到特定实体的本质。
图 3-24
用许多不同风格的主题话语训练路易斯。请注意,我们将“实体”下拉列表右侧的切换更改为“令牌”视图。这允许我们看到哪些令牌被识别为实体。
在训练这个数据集之后,我们看到交互式测试工具在识别实体方面越来越好。我输入“嗨,让我们在 1:45p 见面讨论草坪护理和口琴”(不要问我是怎么想到的…),并收到图 3-25 中的结果。我们正在取得良好的进展。但是,如果我们开始输入不同长度和变化的输入,LUIS 可能无法正确识别实体。这只是意味着我们需要进一步训练我们的实体模型。我们将把这作为一个练习留给读者。
图 3-25
我们的模型现在在一些测试案例中识别主题。太好了。
我们现在已经很好地掌握了日历主题实体,尽管可能有许多情况还不能工作。说实话,除非你有一个好的测试阶段,否则你不可能捕捉到用户提问的所有不同类型的方式。这就是 LUIS 应用开发的方式。发布这个应用时,值得看一看生成的 JSON。
{
"query": "hi let's meet about lawn care and harmonicas at 1:45pm",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.8653278
},
"entities": [
{
"entity": "1:45pm",
"type": "builtin.datetimeV2.time",
"startIndex": 48,
"endIndex": 53,
"resolution": {
"values": [
{
"timex": "T13:45",
"type": "time",
"value": "13:45:00"
}
]
}
},
{
"entity": "lawn care and harmonicas",
"type": "Subject",
"startIndex": 20,
"endIndex": 43,
"score": 0.587688446
}
]
}
请注意,时间实体是按照预期进行标识的。主体实体返回相关的实体值。它还会返回一个分数。在这种情况下,分数也是与意图分数相似的度量;这是与理想实体距离的度量。与 intents 不同,LUIS 不会返回您的所有实体及其分数。LUIS 将只返回分数高于阈值的简单和分层实体。对于内置实体,该分数是隐藏的。
对实体进行定型的好处是,即使带有实体的样本是在 AddCalendarEntry 意图中定义的,它们也可以用于其他意图。意图和实体并不直接联系在一起。我可以说“取消关于奥林匹克曲棍球的会议”,它的工作如图 3-26 所示。
图 3-26
一个意向中的实体培训可以延续到其他意向中
另一个观察结果是在识别 DeleteCalendarEntry 意图方面得分较低。我们在 AddCalendarEntry 意图中添加了更多的语句,但是 DeleteCalendarEntry 和 EditCalendarEntry 的示例要少得多。花些时间来改善这一点。在我们继续之前,为我们的新主题实体添加一些替换的措辞和例子。
练习 3-4
培训主题实体,强化我们的 LUIS App
在本练习中,我们将通过训练 LUIS 应用进行一些额外的训练来改进它。
-
按照上一节中的说明,添加一个主题实体。
-
在你的意图中加入话语来支持主题实体。经常训练和测试,看看你的进步。
-
LUIS 开始时至少要准备 25 到 30 个样本。确保传达不同表达方式的多个实例。
-
确保你所有的意图都引起了你的注意。确保每个意向有 15 到 20 个样本。在每个意向中包含实体。
-
训练 LUIS 应用并将其发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
训练自定义实体,尤其是那些在定位和上下文方面有点模糊的实体,可能具有挑战性,但是经过一些练习,您将开始看到 LUIS 提取它们的能力中的模式。注意需要明确训练的东西:主题中的字数、带有单词和的主题、后跟日期时间的主题等等。你可能已经注意到了样品数量的明确提及。这些只是起点。像路易斯这样的 NLU 系统拥有越多的样本数据就越好。不要忽视这一点。如果 LUIS 的行为不符合您的预期,很可能不是 LUIS 的性能问题,而是您的应用需要更多的培训。
我们计划添加的第二个实体是位置实体。让我们创建一个新的简单的定制实体,并将其命名为 Location。像主题实体一样,位置将是一个自由文本实体,所以我们需要用许多样本来训练 LUIS。
我们将再次尝试在 AddCalendarEntry 意图中添加语句。我们需要添加以下形式的话语:
Meet with kim to talk about {Subject} at {Location}
Meet about {Subject} at {Location}
Add entry with teddy for {Subject} at {Location}
Add meeting at {Location}
Meet at {Location}
Meet in {Location} at {Subject}
你明白了。您还应该在这些话语中添加日期时间实例。训练位置将更加棘手,因为我们正在教 LUIS 区分位置和主题,这两个概念只需要 LUIS 开始区分的大量数据,因为这是两个自由文本实体。最后,我添加了 30 多个句子,要么只包含一个位置,要么包含一个与其他实体相结合的位置。经过这么多的训练,我们取得了不错的成绩。我可以输入“明天晚上 8 点在餐馆见面吃晚饭”,然后得到下面的 JSON 结果:
{
"query": "meet for dinner at the diner tomorrow at 8pm",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.979418
},
"entities": [
{
"entity": "tomorrow at 8pm",
"type": "builtin.datetimeV2.datetime",
"startIndex": 29,
"endIndex": 43,
"resolution": {
"values": [
{
"timex": "2018-02-19T20",
"type": "datetime",
"value": "2018-02-19 20:00:00"
}
]
}
},
{
"entity": "the diner tomorrow",
"type": "Location",
"startIndex": 19,
"endIndex": 36,
"score": 0.392795324
},
{
"entity": "dinner",
"type": "Subject",
"startIndex": 9,
"endIndex": 14,
"score": 0.5891273
}
]
}
我们建议你花些时间来进一步强化这些实体。这将是一次很好的经历,能够真正理解自然语言的复杂性和模糊性,并训练像 LUIS 这样的 NLU 系统。
练习 3-5
训练定位实体
在本练习中,您将在 LUIS 应用中添加位置实体。你会发现这比主题实体本身花费的时间要长一些。
-
按照上一节中的说明,添加一个主题实体。
-
将话语添加到 AddCalendarEntry 中以支持位置实体。经常训练和测试,看看你的进步。
-
LUIS 的目标是从 35 到 40 个样本开始,可能更多。由于您的意图支持更多的实体,您可能需要向 LUIS 提供更多的样本,以便正确区分。当你添加话语时,不断地训练和测试,看看 LUIS 是如何学习的。确保使用许多变体和例子。
-
将 LUIS 应用发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
当单个发声包含许多实体时,这种练习在加强实体解析方面应该是一种很好的体验。
复合实体
恭喜你。到目前为止,我们所做的工作是 LUIS 能够完成的工作的重要部分。使用所描述的意图分类和简单的实体提取技术,我们可以开始处理我们的日历应用。尽管我们检查了简单的实体,但是我们很快遇到了一些复杂的 NLU 场景。如果没有像 LUIS 这样的工具,进行这种语言识别将会非常乏味和具有挑战性。
自然语言中还有另一个有趣的场景。我们的模型目前支持用户说出这样一句话:
"Meet at Starbucks for coffee at 2pm"
如果用户想要添加多个日历条目,该怎么办?如果用户想说类似下面这样的话该怎么办?
"Meet at trademark for lunch at noon and at Starbucks for coffee at 2pm"
现在没有什么是不允许用户这么说的。如果我们对我们的应用进行了足够的训练,它肯定会处理这个输入,并且会识别两个主题实例、两个位置实例和两个日期时间实例,如下所示:
{
"query": "meet at culture for coffee at 11am and at the office for a code review at noon",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.996190667
},
"entities": [
{
"entity": "11am",
"type": "builtin.datetimeV2.time",
"startIndex": 30,
"endIndex": 33,
"resolution": {
"values": [
{
"timex": "T11",
"type": "time",
"value": "11:00:00"
}
]
}
},
{
"entity": "noon",
"type": "builtin.datetimeV2.time",
"startIndex": 74,
"endIndex": 77,
"resolution": {
"values": [
{
"timex": "T12",
"type": "time",
"value": "12:00:00"
}
]
}
},
{
"entity": "culture",
"type": "Location",
"startIndex": 8,
"endIndex": 14,
"score": 0.770069957
},
{
"entity": "the office",
"type": "Location",
"startIndex": 42,
"endIndex": 51,
"score": 0.9432623
},
{
"entity": "coffee",
"type": "Subject",
"startIndex": 20,
"endIndex": 25,
"score": 0.9667959
},
{
"entity": "a code review",
"type": "Subject",
"startIndex": 57,
"endIndex": 69,
"score": 0.9293087
}
]
}
然而,使用代码解析这一点是相当具有挑战性的。我们如何判断哪些实体应该被分组在一起?哪个地点与哪个主题相匹配?我认为您应该能够使用 startIndex 属性来解决这个问题,但是这并不总是那么明显。
幸运的是,LUIS 可以将实体分组为所谓的复合实体。LUIS 将告诉我们哪些实体是哪个复合实体的一部分,而不是之前显示的混乱结果。这使我们更容易知道有两个单独的 AddCalendar 请求,一个是上午 11 点在文化中心喝咖啡,另一个是中午在办公室进行代码审查。
可以在 LUIS 的实体页面上创建复合实体。图 3-27 说明了该过程。点击创建新实体按钮,输入实体名称,选择复合实体类型,并选择子实体类型作为新实体的一部分。我们将使用名称 CalendarEntry 来标识我们的复合实体。
图 3-27
创建新的复合实体
一旦它被创建,我们需要适当地训练路易斯去识别它。让我们再次看看 AddCalendarEntry 意图。训练 LUIS 最简单的方法是找到所有具有所需的三个实体的话语,并将这些实体包装成复合实体。图 3-28 显示了一个例子。
图 3-28
带有日期时间、主题和位置的“正确的”CalendarEntry。这是包装在复合实体中的完美选择。
单击第一个位置实体。将出现一个弹出窗口,要求您重新标记实体或将其包装在一个复合实体中。点击复合实体(图 3-29 )。
图 3-29
单击位置实体将允许我们在一个复合实体中包装部分话语
我们将鼠标移到 Subject 和 datetimeV2 实体上。请注意,绿色下划线会扩展以覆盖每个实体(图 3-30 )。单击 datetimeV2,使其包含在复合实体中,然后单击 CalendarEntry 名称。
图 3-30
一旦选择了复合实体的开始,就需要向 LUIS 显示它的结束位置
对 CalendarEntry 实体的第二个实例执行相同的操作。结果应该如图 3-31 所示。
图 3-31
LUIS 现在有了一个如何包装复合实体的例子
我们应该对我们能找到的包括这三个实体的任何其他话语做同样的事情。一旦我们训练并发布了应用,LUIS 应该开始提取这个复合实体。这里我们只展示相关的 API 部分:
"compositeEntities": [
{
"parentType": "CalendarEntry",
"value": "culture for coffee at 11am",
"children": [
{
"type": "builtin.datetimeV2.time",
"value": "11am"
},
{
"type": "Subject",
"value": "coffee"
},
{
"type": "Location",
"value": "culture"
}
]
},
{
"parentType": "CalendarEntry",
"value": "the office for a code review at noon",
"children": [
{
"type": "builtin.datetimeV2.time",
"value": "noon"
},
{
"type": "Subject",
"value": "a code review"
},
{
"type": "Location",
"value": "the office"
}
]
}
]
练习 3-6
复合实体
在本练习中,您将向 LUIS 应用添加复合实体。
-
创建一个名为 CalendarEntry 的复合实体,由 datetimeV2、Subject 和 Location 实体组成。
-
训练每一个有这三个实体的话语来识别复合实体。
-
使用 CalendarEntry 复合实体的多个实例训练其他示例。记住,做好这件事需要时间、奉献和坚持。
-
将 LUIS 应用发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
复合实体是将实体分组为逻辑数据对象的一个很好的特性。复合实体允许我们封装更复杂的表达式。
分层实体
分层实体允许我们定义一类实体及其子实体。您可以将分层实体视为定义实体之间的父/子类型关系。我们以前遇到过这种情况。您还记得 Datetimev2 实体吗?它有七个子类型,如日期范围、集合和时间。
LUIS 允许我们轻松创建自己的子类型。假设我们希望在模型中添加支持,将日历条目的可见性指定为公共或私有。我们可以添加这样的语句支持:
"create private entry for interview with competitor at starbucks"
"create invisible entry for interview with recruiter at trademark"
这里的私人或不可见表示日历的可见性字段。为什么我们要创建一个层次实体而不是一个简单的实体?难道我们不能只看一个 Visibility 属性的值来确定它是否应该是一个私人会议吗?是和不是。如果用户坚持这两个词,是的。但是记住,自然语言是模棱两可的,模糊的。措辞变了。用户可以说不可见、私有、私密、隐藏。公家也一样。如果我们在代码中对一组封闭的选项进行假设,那么每当出现新的选项时,我们就必须修改代码。应该使用层次实体而不是简单实体的原因是,层次实体在上下文中出现的位置的统计模型是由子类型共享的。一旦被识别,识别子实体的步骤本质上是一个分类问题。与两个简单的实体相比,使实体具有层次结构可以获得更好的 LUIS 性能。更不用说,让 LUIS 在我们的应用的上下文中对实体的含义进行分类比编写代码更有效。
图 3-32 展示了一个新的层级实体的创建。我们通过访问“实体”页面,单击“创建新实体”,并从“实体类型”下拉列表中选择“层次结构”来完成此操作。我们给父实体一个名称,并添加子实体。一旦我们点击完成,这是一个进入意图话语和训练路易斯的问题。让我们进入 AddCalendarEntry 并添加几个示例。
图 3-32
创建新的分层实体
您可能会注意到一两个样本是不够的。我们需要给 LUIS 一个很好的主意,在它开始识别我们输入中的实体之前,它可能在哪里以及如何遇到公共和私有可见性修饰符。图 3-33 中的十个样本是一个良好的开端。
图 3-33
样本可见性分层实体话语
一旦我们进行了训练和发布,我们就可以通过 curl 查看生成的 JSON,如下所示:
{
"query": "create private meeting for tomorrow 6pm with teddy",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.9856489
},
"entities": [
{
"entity": "tomorrow 6pm",
"type": "builtin.datetimeV2.datetime",
"startIndex": 27,
"endIndex": 38,
"resolution": {
"values": [
{
"timex": "2018-02-19T18",
"type": "datetime",
"value": "2018-02-19 18:00:00"
}
]
}
},
{
"entity": "private",
"type": "Visibility::Private",
"startIndex": 7,
"endIndex": 13,
"score": 0.9018322
}
]
}
{
"query": "create public meeting with jeff",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.975892961
},
"entities": [
{
"entity": "public",
"type": "Visibility::Public",
"startIndex": 7,
"endIndex": 12,
"score": 0.6018059
}
]
}
列出实体
到目前为止,预先构建的、简单的、复合的和分层的实体都是通过机器学习技术从用户输入中提取的。每当我们添加一个这样的实体并训练 LUIS 时,您可能会注意到被训练的模型数量在增加。回想一下,LUIS 应用由每个意向/实体的一个模型组成。现在,我们应该有十个模型了。每当我们训练我们的应用时,这些都会被重新构建。
列表实体存在于这个机器学习世界之外。列表实体只是术语和这些术语的同义词的集合。例如,如果我们想识别城市,我们可以添加一个纽约条目,它有同义词 NY、大苹果、不夜城、哥谭、新阿姆斯特丹等。路易斯会把这些化名解析到纽约。
一旦创建了自定义列表实体类型,我们就被重定向到列表实体编辑器,在这里我们可以输入规范术语和同义词。这个接口允许我们添加新的术语及其同义词。它还建议添加额外的术语,这些术语似乎与我们到目前为止添加的术语相关。列表实体限于 20,000 个术语,包括同义词。我们每个应用可以有多达 50 个列表实体,因此基于 LUIS 的术语和同义词查找功能有很大的潜力。图 3-34 显示了一个示例自定义列表实体定义。
图 3-34
LUIS 列表实体用户界面
由于 LUIS 没有学习列表实体,因此无法基于上下文识别新值。如果 LUIS 看到“Gotham”,它会将其识别为纽约。如果它看到“Gohtam”,它不会。它实际上是一个查找列表。
{
"query": "meet in the big apple",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.943692744
},
"entities": [
{
"entity": "the big apple",
"type": "Cities",
"startIndex": 8,
"endIndex": 20,
"resolution": {
"values": [
"New York"
]
}
}
]
}
使用 API 时,LUIS 将突出显示与列表实体类型匹配的术语,并将在解析值中返回规范名称。这允许您的消费应用忽略一个术语的所有可能的同义词,并基于规范名称执行逻辑。列表实体在您提前知道术语的一组可能值的情况下非常有用。
正则表达式实体
LUIS 允许我们创建正则表达式实体。与列表实体一样,这些实体不是基于上下文,而是基于严格的正则表达式。例如,如果我们期望一个知识库 id 总是使用语法 KB143230 来表示,其中文本 KB 后跟 6 个数字,我们可以用正则表达式 kb[0-9]{6,6} 来创建一个实体。一旦被训练,如果任何用户话语片段匹配该表达,该实体将总是被识别。
预构建的域
我希望你已经了解了建立 NLU 模型的一些挑战。机器学习工具允许我们让计算机开始学习,但我们需要确保我们正在用大量好的数据训练它们。人类需要多年的日常互动才能沉浸在一种语言中,才能真正理解它。然而,我们假设人工智能意味着一台计算机将能够用十个样本获得概念。当事实并非如此时,有时我们会对自己说,“哦,得了吧,你现在应该知道了!”
为了帮助我们的旅程,许多 NLU 平台提供了所谓的预构建模型或域。本质上,LUIS 和其他平台的创建者希望给我们一些领域的先机,我们可以很容易地将它们包含在我们的应用中,训练 LUIS,然后开始比赛。路易斯的一些预建模型如图 3-35 所示。
图 3-35
预构建的域
我们可以在 LUIS 中找到预构建的域,方法是导航到 Build 部分,然后单击左下角的预构建的域链接。在撰写本文时,该特性仍处于预览模式。这就是它如此孤立的原因,也是为什么它是动态的,在你读到这篇文章的时候可能会改变。LUIS 包括各种领域,从相机到家庭自动化到游戏到音乐,甚至日历,这与我们在本章中一直在开发的应用类似。事实上,我们将在练习 3-7 中这样做。“了解更多”文本链接到一个页面,该页面详细描述了每个域引入的意图和实体,以及哪些域受哪些文化的支持。 4
当我们向您的应用添加一个域时,LUIS 会将该域的所有意图和实体添加到我们的应用中,它们将计入应用的最大值。在这一点上,我们能够修改我们认为合适的意图和实体。有时,您可能想要删除某些意图,或者添加新的意图来补充预先构建的意图。其他时候,我们可能需要用更多的样本来训练系统。我们建议将预构建的域视为起点。我们的目标是扩展它们,并在它们的基础上建立更好的体验。
历史观点
这些年来,路易斯变了很多。即使在写这本书的过程中,系统也改变了用户界面和功能集。LUIS 曾经有一个 Cortana 应用,任何人都可以通过使用已知的应用 ID 和订阅密钥来访问该应用。Cortana 应用定义了许多预构建的意图和实体,但它是一个封闭的系统。你不能以任何方式根据自己的喜好对它进行定制或强化。从那以后,微软已经放弃了这个特性,转而支持预构建的域。但是,与他人公开共享您的模型以便他们可以使用自己的订阅密钥调用它的想法仍然可用,并可通过设置页面访问。
练习 3-7
利用预先构建的域
在本练习中,您将利用预构建的日历域创建一个 LUIS 应用,该应用类似于我们在本章中构建的应用。
-
创建新的 LUIS 应用。
-
导航到预构建域部分并添加日历域。
-
训练应用。
-
使用交互式测试用户界面来检查应用的性能。它在探测意图和实体方面有多好?在设计和性能方面,它与我们创建的应用相比如何?
预构建的域对于一个域的开始可能是有用的,但是 LUIS 需要勤奋的训练来拥有一个真正表现良好的模型。
短语列表
到目前为止,我们一直在探索不同的技术来创建伟大的模型。我们拥有所需的工具来确保我们能够为用户创造良好的对话体验。当我们训练 LUIS 时,有些情况下模型性能不如我们希望的那样好。实体可能不会像我们希望的那样得到认可。也许我们正在构建一个 LUIS 应用,专门处理内部术语,而这些术语并不完全是您的应用所使用的文化的一部分。也许我们还没有机会用一个实体的每一个已知的可能值来训练 LUIS entities,列表实体没有削减它,因为我们希望我们的实体保持灵活性。
在这种情况下,提高 LUIS 性能的一个方法是使用短语列表。短语列表是 LUIS 在训练我们的应用时使用的提示,而不是严格的规则。他们不是银弹,但可以非常有效。短语列表允许我们向 LUIS 呈现一类彼此相关的单词或短语。这种分组是对 LUIS 的一种暗示,即以类似的方式对待该类别中的单词。在实体值未被正确识别的情况下,我们可以将所有已知的可能值作为短语列表输入,并将该列表标记为可交换的,这向 LUIS 表明,在实体的上下文中,这些值可以以相同的方式处理。如果我们试图用 LUIS 可能不熟悉的单词来提高他的词汇量,短语列表就不会被标记为不可替换。
假设我们想要提高日历模型的私有可见性实体的性能。毕竟,有很多方式来表达我们想要一个私人会议。作为起点,我们可以添加一个短语列表,其中包含我们希望模型看到的所有不同的单词。图 3-36 显示了用于处理短语列表的 LUIS 用户界面。你可以通过选择构建页面下的短语列表项并点击创建新短语列表来到达这里。
图 3-36
我可能有点过火了。我责怪相关值函数。
短语列表需要一个名称和一些值。我们在值字段中逐个输入值。当我们按 Enter 键时,它会将它们添加到短语列表值字段中。相关值字段包含 LUIS 自动加载的同义词。然后,我们选中复选框,告诉 LUIS 这些值是可以互换的。
在培训之前,让我们在不启用短语列表的情况下尝试一些不同的私人会议用语。如果你尝试像“私下会面”、“秘密会面”或“创建一个隐藏的会议”这样的话语,路易斯不会识别这个实体。然而,如果我们用短语列表训练应用,LUIS 在这些样本和许多其他样本中识别实体没有问题。 5
练习 3-8
训练特点
在本练习中,您将通过添加功能来改进我们的 LUIS 应用。
-
将可见性分层实体添加到 LUIS 应用中。
-
添加您自己的短语列表以提高私有可见性实体的性能。
-
将 LUIS 应用发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
-
将短语列表设置为不可互换对其性能有何影响?
短语列表是帮助我们的应用更好地识别不同实体的强大功能。
练习 3-9
添加被邀请人实体
您可能已经注意到,我们还没有谈到我们如何捕获与会者,到目前为止,我们已经忽略了这个问题。在本练习中,我们将解决这个问题。
-
添加名为“被邀请者”的新自定义实体。
-
检查到目前为止的每个样本话语,并识别话语中的被邀请者实体。
-
如果它需要额外的训练,增加更多的样本。确保包含样本,其中被邀请者是话语中的唯一实体或者是许多实体中的一个。
-
对于奖励积分,将被邀请者实体添加到 CalendarEntry 复合实体。
-
训练并确保所有的意图和实体仍然表现良好。
-
将 LUIS 应用发布到暂存槽中。
-
使用 curl 检查生成的 JSON。
如果您成功完成了这个练习,那么恭喜您!你越来越擅长利用路易斯了。
主动学习
我们花了几周时间训练一个模型,我们经历了一轮测试,我们已经将应用部署到生产中,我们已经打开了我们的机器人。现在怎么办?我们如何知道模型是否尽了最大努力?我们如何知道某个用户是否向我们的应用抛出了意外的输入,从而破坏了我们的 bot 并导致了糟糕的用户体验?错误报告肯定是一种方法,但是我们依赖于获得反馈。如果我们能在这些问题出现时就发现它们,会怎么样呢?我们可以利用路易斯主动学习的能力来做到这一点。
回想一下,监督学习是从有标签的数据进行机器学习,无监督学习是从无标签的数据进行机器学习。半监督学习介于两者之间。主动学习是一种半监督式的学习,学习者要求监督者标记新的数据样本。根据 LUIS 看到的输入,它可以要求您(LUIS app trainer)帮助标记来自用户的数据。这提高了模型性能,随着时间的推移,通过使用真实的用户输入作为样本数据,使我们的应用更加智能。
您可以通过构建页面上的审查端点发言链接来访问此功能(图 3-37 )。在应用的整个培训过程中,我们一直在利用发布的应用端点来测试各种话语。LUIS 的主动学习基于对端点的输入,而不是交互式测试功能。
图 3-37
主动学习界面
该界面允许我们回顾过去的话语及其得分最高的意图,称为一致的意图。作为训练者,我们可以将话语添加到对齐意图中,重新分配给不同的意图,或者完全删除话语。如果我们知道它们中的任何一个有问题,我们也可以聚焦在特定的意图或实体上。
在将话语添加到对齐的意图之前,我们需要确认话语被正确标记并且任何实体都被正确识别。我们建议使用这个接口来改进 LUIS 应用是任何团队的惯例。
仪表板概述
既然我们已经训练了我们的应用并利用它进行测试,那么突出显示仪表板提供的数据是非常值得的。仪表板让我们可以很好地了解应用的整体状态、使用情况以及我们用来训练它的数据量。
根据图 3-38 ,最上面提供了有关我们上次培训和发布应用的信息。我们还可以获得一些指标,比如我们正在使用的意图和实体的数量,我们拥有的列表实体的数量,以及我们的应用到目前为止总共有多少带标签的话语。
图 3-38
申请状态
下一节将展示应用通过 API 获得的使用类型。我们可以监控从上周到去年的端点点击量。只有当应用发布到生产插槽时,这些数据才可用。如图 3-39 所示。
图 3-39
API 端点使用摘要
最后,我们会看到一个意图和实体的分解,如图 3-40 所示。这里我们看到了用于训练每个意图的话语百分比的分布。你可以清楚地看到,我们的一些意图比其他意图包含更多的示例语句。实体也一样。分布不均并不一定意味着某个实体或意向需要更多的培训。
图 3-40
关于意图/实体话语计数和分布的统计。单击一个意图导航到该意图的话语页面。
管理和版本化您的应用
到目前为止,我们所做的一切都是添加示例、培训和发布 LUIS 应用的通用工作流的一部分。在开发阶段,这个工作流程会一遍又一遍地重复。一旦你的应用投入生产,你应该小心你对你的应用做了什么。添加新的意图或实体的过程可能会对应用的其余部分产生不可预见的影响,最好是单独编辑现有的应用,以便可以正确地对其进行测试。
我们对试运行和生产部署槽的概念有经验。这当然有帮助;我们知道,我们可以在不发布到生产端点的情况下测试变更。一个常见的规则是让登台槽托管应用的开发/测试版本,让生产槽托管生产版本。每当一个新的应用准备好投入生产时,我们就把它从准备槽移到生产槽。但是如果我们在模型中犯了一个错误呢?如果我们需要回滚生产插槽呢?这就是版本出现的原因。
LUIS 允许您随时创建应用的命名版本。到目前为止,默认情况下,我们一直在 0.1 版本上工作。一旦它为生产做好准备,我们就可以发布它并将其克隆到新的版本 0.2 中。此时,您将 0.2 版本设置为活动的。现在,LUIS 界面正在编辑 0.2 版本。如果我们不小心将版本 0.2 发布到生产插槽中,我们可以很容易地返回到版本 0.1 并发布它。一旦版本 0.2 准备好生产,我们就将其部署到生产插槽中,克隆到版本 0.3 中,并将该版本设置为活动版本。等等。如果在任何时候您将一个版本部署到生产插槽中并需要恢复,您可以将 LUIS active 设置回 0.2,并将该版本发布到生产插槽中。工作流程如图 3-41 所示。
图 3-41
LUIS 开发、培训、测试和发布工作流程
我们通过设置页面访问应用版本信息。图 3-42 和图 3-43 显示了将 0.1 版本克隆到 0.2 版本后的界面和外观。
图 3-43
0.1 版被克隆到 0.2 版
图 3-42
“设置”页面上的版本控制功能
请注意,关闭 0.1 后,它仍保留在登台槽中,但 0.2 成为活动版本。LUIS 也不允许简单的分支。如果多个用户想要对单个版本进行更改,他们不能创建新版本,然后使用用户界面合并他们的更改。一种方法是通过点击图 3-42 中的导出版本按钮下载 LUIS App JSON,利用 Git 等源代码控制工具进行分支和合并,最后使用“导入新版本”按钮从 JSON 文件上传新版本。
该页面还允许我们向应用添加协作者。这是一种很好的方式,可以让您的组织中的其他人帮助编辑、培训和测试应用版本。在撰写本文时,没有微调的审计控制;除了添加/删除其他合作者之外,所有合作者都可以对应用执行任何操作(图 3-44 )。
图 3-44
向 LUIS 应用添加协作者
与拼写检查集成
LUIS 的一个高级特性是能够与拼写检查器集成,自动修复用户输入中的拼写错误。用户输入本质上是混乱的。拼写错误非常普遍。将这一点与消息应用的常见用法结合起来,你就有了一个一致的拼写错误输入的诀窍。
拼写检查器集成通过 Bing 的拼写检查器服务运行用户查询,得到一个可能被修改过的查询,并修复了拼写错误,然后通过 LUIS 运行这个修改过的查询。这个特性是通过包含查询参数 spellCheck 和 bing-spell-check-subscription-key 来调用的。你可以从 Azure 门户获得一个订阅密钥,我们将在第五章中介绍。我们还将在第十章中更直接地使用拼写检查 API。
这个功能很有帮助,我们通常会带着警告推荐它。如果我们的实体包含特定于域的值或产品名称,严格来说,它们不是英语的一部分,那么我们可能会得到一个修改后的查询,LUIS 无法在其中检测到实体。例如,当这种行为不受欢迎时,它可能会将一个单词分解成多个单词。或者,如果我们的应用期待金融行情,它可能只是改变它们。例如,Vanguard ETF 的 VEA 被改为 VA。在美国,这是对弗吉尼亚州的常见引用。意义的丧失是相当显著的;我建议谨慎使用这一功能。
拼写检查对 LUIS API 结果的影响很容易发现。结果现在包括一个名为 alteredQuery 的字段。这是传递给 LUIS 模型的文本。这里给出了一个 curl 请求和响应 JSON 示例:
curl -X GET -G -H "Ocp-Apim-Subscription-Key: a9fe39aca38541db97d7e4e74d92268e" -d staging=true -d spellCheck=true -d bing-spell-check-subscription-key=c23d51fc861b45c4b3401a6f8d37e47c -d verbose=true -d timezoneOffset=0 "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/3a26be6f-6227-4136-8bf4-c1074c9d14b6" --data-urlencode "q=add privtae meeting wth kim tomoorow at 5pm"
{
"query": "add privtae meeting wth kim tomoorow at 5pm",
"alteredQuery": "add private meeting with kim tomorrow at 5pm",
"topScoringIntent": {
"intent": "AddCalendarEntry",
"score": 0.9612303
},
"entities": [
{
"entity": "tomorrow at 5pm",
"type": "builtin.datetimeV2.datetime",
"startIndex": 29,
"endIndex": 43,
"resolution": {
"values": [
{
"timex": "2018-02-20T17",
"type": "datetime",
"value": "2018-02-20 17:00:00"
}
]
}
}
]
}
进口/出口申请
LUIS 中构建的任何应用都可以导出到 JSON 文件中,然后再导入到 LUIS 中。JSON 文件格式正是我们所期望的。有些元素定义了应用使用哪些自定义意图、自定义实体和预构建实体。还有其他元素来捕获短语列表。毫不奇怪,有相当大的一段描述了所有的样本话语、它们的意图标签以及话语中任何实体的开始和结束索引。我们可以通过点击 LUIS 的我的应用部分的导出应用或者设置页面的导出版本来导出应用,如图 3-41 。
尽管导出的应用的格式特定于 LUIS,但是很容易想象我们如何编写代码来解释其他应用的数据。从治理的角度来看,导出我们的应用并将 JSON 存储在源代码控制中是一种很好的做法,因为发布操作的操作是不可逆的。如果我们的团队遵循这样的策略,即发布到产品插槽意味着创建新的应用版本,这应该不是问题,但是错误确实会发生。
在与 LUIS 的合作中,我们收到的最常见的问题之一是“为什么我们不能将应用导入到现有的应用中?”原因是,这相当于一个聪明的合并,特别是在有不同意图的重叠话语或具有完全不同应用内涵的相同名称意图的情况下。因为每个应用都有不同的语义,所以这种合并将是一项艰巨的任务。我们建议要么利用 Git 来管理和合并应用 JSON 代码,要么使用 LUIS Authoring API 创建自定义代码来进行合并。
使用 LUIS 创作 API
当谈到 LUIS 及其功能时,开发人员的第一个问题是,“这可以通过 API 来实现吗?”答案是肯定的!创作 API 允许我们通过 API 使用用户界面执行所有的任务。创作 API 分为以下资源:
-
应用:添加、管理、删除、发布应用。
-
示例:将一组示例话语上传到应用的特定版本中。
-
特性:在应用的特定版本中添加、管理或删除短语或模式特性。
-
模型:添加、管理或删除自定义意图分类器和实体提取器;添加/删除预构建的实体;添加/删除预构建的域意图和实体。
-
权限:添加、管理和删除应用中的用户。
-
培训:排队申请培训版本,获取培训状态。
-
用户:管理 LUIS 应用中的 LUIS 订阅密钥和外部密钥。
-
版本:添加和删除版本;将密钥与版本相关联;导出、导入、克隆版本
API 非常丰富,支持培训、自定义主动学习,并支持 CI/CD 类型的场景。API 参考文档 6 是学习 API 的好地方。
排除模型故障
我们重点关注 LUIS 本身以及通过将定制意图分类器和定制实体提取器与预构建的实体和预构建的域相结合来创建应用的过程。在这个过程中,我们注意到系统的一些有趣的行为。机器学习并不完美。我们几乎肯定会遇到奇怪的情况,在这些情况下,我们的意图或实体会遇到麻烦。以下是我们应该如何解决 LUIS 问题的列表:
-
最常见的问题之一是训练模型而不发布它。如果您正在使用登台槽测试应用,请确保将其发布到登台槽中。如果您调用应用的生产插槽,请确保应用已发布。并确保在调用 API 时根据需要传递暂存标志。
-
如果意向被错误分类,为有问题的意向提供更多的意向例子。如果问题仍然存在,花些时间分析意图本身。它们真的是两种不同的意图吗?或者这真的是一种意图,我们需要一个自定义实体来区分这两者?此外,确保用一些与您的应用完全不相关的输入来训练 None 意图。测试数据非常适合这个目的。
-
如果应用难以识别实体,请考虑您正在创建的实体的类型。有一些实体通常是一个单词的修饰语,出现在意图的同一个地方,比如我们的可见性实体。另一方面,还有更微妙的实体,它们可以出现在话语的任何地方,通常以一些单词为前缀和后缀。前者不需要像后者那样多的话语样本。通常,实体识别问题可以通过执行以下操作来解决:
-
根据不同的变体和同一变体的多个样本添加更多的话语样本。
-
值得一问的是,这个实体是否应该是一个列表实体。一个好的经验法则是,这个实体是一个查找列表吗?或者应用在识别这种类型的实体时需要灵活性吗?
-
考虑使用短语列表向 LUIS 展示一个实体可能的样子。
-
-
LUIS 是否在两个实体之间感到困惑?实体是否相似,只是基于上下文略有不同?如果是,这可能是一个分层实体的候选。
-
如果您的用户试图交流由多个实体组成的更高层次的概念,请使用复合实体。
构建 LUIS 应用与其说是科学,不如说是艺术。你有时会花很多时间教 LUIS 一些实体之间的区别,或者一个实体可以出现在句子的什么地方。耐心点。要彻底。并且总是用统计学的术语来思考问题;系统需要看到足够多的样本,才能真正开始理解正在发生的事情。作为人,我们可以认为我们的智力和语言理解是理所当然的。相对而言,我们能如此迅速地训练出像路易斯这样的系统是相当令人惊讶的。当你与路易斯或任何其他 NLU 系统一起工作时,请记住这一点。
结论
这是相当多的信息!祝贺您,我们现在已经准备好使用 LUIS 这样的工具开始构建我们自己的 NLU 模型。概括地说,我们通过利用预构建的实体、自定义意图和自定义实体来完成创建应用的练习。我们探索了各种预构建实体的功能,并涉猎了 LUIS 提供的预构建域。在将应用发布到不同类型的插槽中并使用 curl 测试 API 端点之前,我们花时间训练和测试了我们的应用。我们使用短语特性优化了我们的应用,并利用 LUIS 的主动学习能力进一步改进了它。我们探讨了 LUIS 应用中的版本控制、协作、集成拼写检查、应用的导出和导入、使用创作 API 以及常见的故障排除技术。
我必须重申,你刚刚学到的概念和技术都适用于其他 NLU 平台。训练意图和实体以及优化模型的过程是你工具包中的一项强大技能,无论是对于机器人、语音助手还是任何其他自然语言界面。我们现在准备开始思考如何构建一个机器人。正如我们所做的,我们将继续检查这个 LUIS 应用,因为它被我们的 bot 使用。
四、对话设计
虽然这项技术允许我们开发一个可以以任何方式运行的机器人,但这并不意味着我们应该这样做。用户对他们的消息通信有一定的期望,例如确认收到消息、快速响应以及稍后继续对话的能力。虽然与机器人交谈不同于与人交谈,但给朋友发消息是最相似的体验。由于用户仍在习惯机器人,将这些交互作为机器人行为的样本是合理的。
成功的机器人可以表现出许多类型的行为,但有一些共同的模式和风格。这并不是说创新停滞了;一点也不!在给定技术和预算限制的情况下,这些使用案例基于空间中常见的观察模式。创新的空间已经成熟,唯一的问题是,我们集体想象力的极限是什么?
这些常见的用例在与用户交流时也遵循一定的规则。在我的职业生涯中,对我来说,最重要的是要认识到大多数技术用户并不像我一样使用技术。我喜欢命令行及其精确性。不是以英语为母语的人,自然语言的模糊性一直令人困扰。但是机器人给了用户使用这种模糊自然语言的能力。因此,bot 开发者需要进行一定程度的自我约束。开发人员可以很容易地创建一个更像使用命令行的机器人体验。
考虑到自然语言处理(NLP)的局限性和用户的期望,小心机器人在不理解事物和要求用户反馈时的行为变得比以往任何时候都重要。通过谨慎的方法和有意识地选择我们发送给用户的回复类型,创造愉快的体验是可以实现的。
常见使用案例
开发人员正在创造各种各样的对话体验。我们可以体验专门从事销售物品、回答产品问题、发送订单状态、回答订单查询、提供云基础设施、搜索多个数据源、共享 cat GIFs 以及做数百万其他事情的机器人。
在高层次上,我们将把机器人分成两个更大的类别:消费者和企业。当然,子类别之间有重叠,但也有一些明显的分界线。
常见的消费者案例
消费者机器人通常可以通过 Facebook Messenger、Slack 和其他公共消息应用等通道获得;网络聊天;语音接口;或者甚至是定制的移动应用。在质量等级的低端,它们只不过是玩具。在高端,它们可能是令人印象深刻的设计和工程壮举。由于我们在第一章中讨论的人工智能和机器人热,许多公司都在他们的产品中部署机器人。例如,Atlassian 为其 JIRA 产品设计了一款 Slackbot。甚至亚马逊也在其移动购物应用中集成了聊天机器人。你还会发现一些品牌通过 Facebook Messenger 涉足机器人。脸书页面使公司很容易拥有一个面向外部的公共通道,通过公共帖子或 Messenger 与客户交谈。如果是 Messenger,人工代理需要登录到页面收件箱并回复每条消息。许多公司的第一步是部署一个信使机器人,它可以回复一些类型的用户查询,剩下的就交给人类来回复。就效用而言,我们仍在试图回答这个问题,什么对用户最有意义?该领域的各种机器人无疑表明了这一点。以下是一些有效方法的大类。
常见问题解答
FAQ bot 通常是团队首次进入 bot 和 NLP 领域进行技术测试。这是一个简单的用例:让我们将现有的 FAQ 作为一个机器人放在 Facebook Messenger 或企业消息中。这样,最常见的问题可以在员工花时间回答之前被机器人捕捉到。从用户的角度来看,一个简单的基于文本的 FAQ 机器人可以变成非常有趣和美观的东西。一个常见问题的答案不仅仅是一段无聊的文字。答案可以包括更多的内容,例如图像、视频和附加内容的链接。
例如,考虑一个金融服务机器人,它可以回答关于金融话题的不同类型的问题。在其响应中,它可以嵌入额外的感兴趣的建议主题作为按钮。此时,用户可以查看相关术语及其定义。如果有网站直观地表现了一个概念,例如,铁秃鹰期权投资策略,这些链接可以包含在用户点击以获得更多信息的响应中。当然,我们的对话设计需要平衡所有的内容和可能的用户超载。介于两者之间的最佳点可以有效地为用户提供愉快的机器人体验。图 4-1 是国际儿童基金会嵌入网页的 FAQ bot 的一个例子。
图 4-1
一个基本的 FAQ 机器人
面向任务的机器人
面向任务的机器人是一个虚拟代理,可以帮助用户完成特定领域的各种任务。这些类型的机器人有时被称为礼宾机器人。例如,JIRA 的 Slackbot(图 4-2 )是面向任务的。它可以根据团队正在进行的对话创建任务和分配任务。
图 4-2
JIRA Slackbot 公司
我曾经做过一个糖尿病教练聊天机器人,它可以帮助患有二型糖尿病的用户根据之前的对话和其他用户数据,个性化地询问饮食和锻炼建议。还有一些金融服务机器人连接到交易账户,更新用户的账户余额和头寸,甚至进行交易,如 TD Ameritrade 机器人(图 4-3 )。我们在第三章中开发的日历路易斯应用是日历任务机器人的基础。
图 4-3
使用 TD Ameritrade bot 进行股票交易
广播机器人
广播机器人是一个有趣的概念,非常普遍。我们可以认为这是一个无需提示就向用户伸出手的机器人,而不是用户先联系机器人。在一些机器人中,让机器人参与进来更像是一种模式。例如,不同的新闻机器人,如 Facebook Messenger 上的 CNN 机器人,将每天发布当天的重大新闻。
在一些名人机器人实现中可以看到这种情况的子集和更细微的版本。通常,这些类型的机器人是为了好玩而存在的。他们采用名人的个性,可以与用户谈论感兴趣的话题、产品以及与名人分支机构互动的其他方式。这个机器人可以引导你浏览主题脚本,给你发送视频和图像,也许还可以谈论名人代言的产品。对话几乎完全由机器人驱动,而不是用户。这是一个有趣的讲故事的设备,但它的成功归结于一致的新鲜内容。图 4-4 展示了一个项目 Cali 的例子,这是一个为了好玩而创建的 Snoop Dogg 机器人。
图 4-4
卡利项目:Snoop Dogg bot
电子商务 Bot
尽管机器人在北美还不算大,但它们正慢慢开始向消费者出售产品。从技术角度来看,这并不是一个非常具有挑战性的任务;更大的挑战是让用户使用短信,而不是应用或网站。这些类型的机器人中电子商务集成的数量各不相同。例如,一些机器人提供完整的端到端购物体验。通过机器人看衣服(图 4-5 )或鲜花(图 4-6 )与网购体验是不同的。一些机器人倾向于这一点,并提供古怪或创新的方式来计算出什么产品显示给用户,以获得冲动购买!
图 4-5
路易威登机器人
我们还遇到过这样的情况,机器人只负责广播购买收据和订单状态更新,具有有限的机器人功能。其他所有事情都会自动发送给人工客户支持代表。虽然这种体验并没有完全融入电子商务,但它是进入这一旅程并让客户熟悉机器人的第一步。简而言之,公司正在拥抱所谓的数字化驱动的消费者之旅,机器人是这一战略的一部分。 1
图 4-6
1-800-Flowers.com 助手
不同的消息平台提供不同级别的支付支持。我们当然可以通过机器人创建电子商务,提供一个自定义的结帐页面,用户可以在那里输入他们的支付信息。对话在此暂停。一旦付款被处理,一条消息被发送到机器人继续对话。另一方面,Facebook Messenger 提供了与 Stripe 和 PayPal 等系统的更深层次的集成。在这个版本中,支付体验完全保留在 Facebook Messenger 应用中。从用户的角度来看,摩擦越少越好。随着用户开始更加信任消息应用来存储他们的支付信息,我们将会看到越来越多这样的支付集成。苹果发布了其商务聊天 2 产品,你可以打赌 Apple Pay 支付已经完全集成。 3
常见企业案例
企业机器人可能更专门针对某个领域或主题。它们通常使用网络聊天组件部署,或者集成到企业消息系统中,甚至集成到企业呼叫中心和交互式语音应答(IVR)系统中,如思科的统一通信中心。它们也可以部署在电子邮件端点上。机器人可以与单点登录解决方案、强大的现有企业后端和知识管理数据库集成。根据企业的实践,这些将从简单的试点机器人到机器学习驱动的大规模部署。
自助服务机器人
企业场景中最常见的用例之一是事件自助服务。企业拥有庞大的知识库,内部问讯台代理使用这些知识库向用户传达可能的解决方案,并指导他们解决问题。机器人可以向用户传达这些一步一步的故障诊断指导。例如,对内部帮助台最常见的查询之一是密码重置。如果公司能够自动处理这样的请求,它们可以减少大量的交易量,坦率地说,还可以节省资金。你可以想象一个电器制造商发布了一个聊天机器人来帮助诊断和修复问题,然后才让服务工程师介入。
这些自助服务机器人背后的想法是,它们可以为用户提供各种自助服务内容,尤其是最常见的查询,甚至可以自动化客户支持团队正在进行的一些常见工作。这些机器人通常与实时聊天系统集成在一起,这样用户最初可能会与机器人聊天,但在机器人的指示不能解决问题的情况下,可以迅速重新路由到与人类客户服务代理的实时聊天或电话对话。
过程自动化机器人
机器人过程自动化(RPA)是当今的一个大话题。像 IPsoft 这样的公司专注于构建能够自动化业务和 IT 任务的机器人和技术。在这种情况下,机器人不一定是聊天机器人,而是执行自动化的计算机代理。这些任务可以包括从账户供应、网站自动化和业务流程自动化的一切。有些公司专注于创建自动化平台,如 Automation Anywhere 和 UiPath。随着机器学习这些天被用于从合同分析到皮肤癌诊断的一切,聊天机器人可以作为这些过程的优秀前端。在 RPA 场景中,聊天机器人更像是一个指挥者,而不是自动化者。此外,这些机器人可能会集成到 Remedy 和 ServiceNow 等票务系统中,以跟踪其工作。
在其他情况下,聊天机器人对用户不太可见。例如,Slack 是一个很好的机器人平台,可以监听团队对话,并在合适的自然语言出现时显示数据。简单监听一些自然语言输入并提供答案的机器人是一种自动化机器人。举例来说,一组医学专家浏览程序的文本描述,并负责将它们翻译成保险代码。这个过程可以由机器学习算法自动完成,该算法可以观察团队的行为和结果,然后接管数据。
同样,逻辑背后的实际大脑可能不在机器人本身内部。可能有一个单独的系统实现保险代码的机器学习模型。或者,自动化代码可以是 Python、PowerShell 或任何其他脚本。机器人充当接收自然语言和编排自动化的前端(图 4-7 )。
图 4-7
自动化机器人流程示例
知识管理机器人
另一种类型的企业机器人可以解决跨各种数据源的自然语言搜索问题。许多公司在不同的系统上有巨大的知识库。能够使用自然语言集成所有这些资源是非常重要的。在这些机器人中有一些有趣的选择,比如向用户显示哪些内容,以什么格式显示,以及如何收集反馈,以确定哪些内容对查询最有用。
这些项目试图解决的自然语言搜索的更大问题非常有趣,超出了本书的范围。这种类型的机器人在群组对话环境中非常有趣,当群组就感兴趣的主题进行对话时,机器人会查询文章、报告、白皮书和案例研究。在搜索过程中,小组对机器人的反馈可以进一步提供监督学习数据,以进一步改善搜索体验。
代表对话
我们如何开始开发一个对话式聊天机器人?一个好的地方是试图用图形表示对话流。聊天机器人可以处理什么样的任务?它需要寻找什么意图和实体来实现这些目标?它如何帮助填充缺失的数据?
我们将这个对话称为一个图,它是由边连接的 Node 的集合。图 4-8 展示了一个无向图。图中的每个 Node 都至少与一个其他 Node 相连。每个 Node 代表对话的一个状态,边代表状态之间的转换。
图 4-8
无向图
我们将在边缘使用箭头来显示流向。这被称为有向图。我们从根 Node 开始。根 Node 是会话开始时的状态。使用我们的日历机器人作为示例,我们知道我们的机器人应该支持添加新条目、编辑现有条目、删除条目、检查可用性以及提供日历或事件的摘要。我们可以用图 4-9 来表示这个机器人。
图 4-9
日历礼宾机器人对话的表示
注意,会话基于用户的话语在状态之间移动,这决定了 LUIS 意图。会话中的每个 Node 都有内置的逻辑来解析实体并为状态执行正确的逻辑。在一个状态执行完它的逻辑后,会话转移回根 Node。
状态之间的转换可以通过编程或用户输入来调用。例如,假设我们的机器人支持创建日历约会。回想一下在第三章中,我们创建了一个 LUIS 应用,它允许我们传递几个实体或者不传递实体作为话语的一部分来添加一个日历条目。如果我们的添加新条目对话框没有收到关于主题和被邀请者的信息,例如在话语“明天下午 2 点见面”中,我们可以在另一个状态中引出该信息。另一方面,如果用户使用包含这些实体的话语,例如“明天下午 2 点和 kim 见面喝咖啡”,我们不需要引出这些额外的信息。这种有条件的状态转换如图 4-10 所示。
图 4-10
基于用户输入有条件地转移到状态
创建对话图的过程通常被称为意图和实体映射;我们将意图和实体组合建模为状态 Node 之间的转换。
机器人响应
机器人对用户查询的响应可以采取多种形式。理解不同的选项以及如何最好地利用它们是任何 bot 设计的关键。在接下来的章节中,我们将深入探讨各种通道中的一些概念。
积木
我们现在理解了如何获取用户输入并将其映射到机器人状态和功能。我们也知道如何将我们的机器人代码组织成各种对话状态。我们设计的下一步是弄清楚机器人给用户发送什么作为回报。机器人可以以多种方式做出反应。默认情况下,我们认为文本或语音输出。最典型的情况是,我们简单地发送回纯文本。一些消息通道支持更复杂的东西,比如 Markdown 或 HTML。Markdown 是一种纯文本格式语法。 5 以下降价输入转换成图 4-11 中的格式化内容:
图 4-11
格式化的减价文件
# H1
# H2
Hello, my _name_ is **Szymon Rozga**
I like:
1\. Bots
1\. Dogs
1\. Music
机器人平台也可以支持语音响应。许多平台也支持语音合成标记语言(SSML)作为语音输出格式。SSML 是一种标记语言,它提供了关于应该如何使用诸如停顿、中断、速率和音高的变化等元素来构造语音的元数据。这里有一个来自 WC3 推荐 6 的不言自明的例子:
<?xml version="1.0"?>
<speak version="1.0" xmlns:="http://www.w3.org/2001/10/synthesis"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.w3.org/2001/10/synthesis
http://www.w3.org/TR/speech-synthesis/synthesis.xsd"
xml:lang="en-US">
That is a <emphasis> big </emphasis> car!
That is a <emphasis level="strong"> huge </emphasis>
bank account!
</speak>
对用户的输出不一定总是文本。我们可以使用图像和视频向用户传达许多想法。作为发送回用户的任何消息的一部分,我们可能会附加各种内容,如视频、音频文件和图像。具体支持的格式将取决于底层操作系统和通道。一些系统还允许其他文件附件,例如 XML 文件或某种本机格式。
向用户展示内容的另一种机制是卡片。卡片通常是图像、文本和作为行动号召的可选按钮的组合。我们的 YouTube 搜索机器人从第章 1 (图 4-12 )清晰地显示了视频名称、描述,以及一组卡片中的观看按钮。
图 4-12
卡片横向列表;也称为旋转木马
这种布局被称为传送带。它并排展示了几个卡片,并让用户能够滑动或滚动各个卡片。
按钮通常作为卡片的一部分发送,但是它们也可以作为没有关联图像的独立元素发送。按钮有很多种。最受欢迎的三个按钮用于打开网页、向机器人发回消息(IM back)或向机器人发回消息(post back)。IM back 和 post back 的区别在于,post back 消息不会出现在消息历史中,而 IM back 消息会出现在消息历史中。不是所有的通道都支持这两种方法,但是通过点击按钮向机器人发送消息的总体精神得到了广泛的支持。
另一种按钮是登录按钮。登录按钮通过 web 视图中的登录启动身份验证或授权流程。登录完成后,机器人会收到任何必要的访问令牌,并可以继续进行认证会话,如图 4-13 所示。
图 4-13
具有建议操作/快速回复的认证机器人
前面描述的所有内容都保存在用户的聊天记录中。传送带、卡片、按钮,当然还有所有的文本都可供用户滚动浏览。有一种形式的元素只在包含它的消息的上下文中显示。该功能是建议操作,也称为快速回复。这些按钮显示在用户界面的底部,直到用户做出响应。这些按钮是清晰的行动号召,也是愉悦对话体验不可或缺的工具。图 4-14 显示了一个使用建议动作引导用户到 TD Ameritrade bot 中可用的视频类别的示例。
图 4-14
TD Ameritrade bot 中的视频类别建议操作
机器人中的认证和授权
老实说,没有人会向机器人聊天窗口发送用户名和密码。这是一个安全风险。我们不希望脸书或 Slack 或任何其他通道在他们的消息历史中有我们用户的登录凭证。归根结底,bot 只是一个 web 服务,因此使用标准的 OAuth 或 OpenID 连接流是一种自然的选择。
正确的方法是利用登录卡,这是一种包含按钮的卡,该按钮可以打开登录网页,供用户输入他们的凭据(图 4-15 )。
图 4-15
标准的签到卡
通常,这个登录页面将是一个 OAuth 页面(图 4-16 )。
图 4-16
OAuth 授权代码流
OAuth 2.0 7 是互联网上基于令牌授权的标准。OAuth 2.0 支持几种不同类型的授权流。三足 OAuth 流允许资源所有者(用户)向 API(服务提供者)授予对应用(消费者)的访问权。在 bot 的上下文中,它看起来如下:
-
用户点击按钮以在第三方的 web 视图中打开服务的登录页面,并输入他们的用户名/密码组合。这个登录页面的 URI 通常包括一个客户端 ID 和一个重定向 URI。重定向 URI 是我们的 bot web 服务的一个端点。
-
一旦用户成功登录,该服务会将用户重定向回机器人重定向 URI。机器人重定向 URI 端点接收授权码。这是用户对应用使用服务的授权。机器人用授权码交换来自令牌端点的访问令牌(和可选的刷新令牌)。
-
当代表 bot 用户向服务发出请求时,bot 使用访问令牌。
-
通常,访问令牌是短期的,而刷新令牌是长期的。在任何时候,机器人都可以通过发布刷新令牌从令牌端点请求新的访问令牌。
关于这个和其他 OAuth 流程的细节有大量的文档。RFC 是一个很好的起点。 8 关键是 bot 是一个 web 服务,完整的 OAuth 流程可以以集成的方式发生。从 UX 的角度来看,唯一棘手的部分是确保浏览器窗口在登录完成后自动关闭。各种通道处理这一问题的方式略有不同。虽然我们可以手动实现整个流程,我们在第八章中展示了这一点,但是 Bot 框架确实提供了额外的工具来促进这一过程。9
专业卡
在支持卡片的平台上,卡片是用户体验的一个重要组成部分。我们讨论了通用卡的概念。一些频道提供几种专用卡。例如,可以发送收据卡(图 4-17 )来传达购物收据,其中包含总额、税款、付款确认等信息。
图 4-17
信使收据模板
此外,Messenger 使开发人员能够使用四种航空旅行卡,如行程、登机牌(图 4-18 )、值机和航班更新。
图 4-18
Messenger 登机牌模板
轻按登机牌会显示一个带有二维码的全屏版本,可以在机场使用(图 4-19 )。根据我们的目标平台,可能会有其他模板供我们使用。如果它们存在并且符合你的用例,就使用它们。它们提供了良好的本地用户体验。
图 4-19
Messenger 登机牌模板详细信息
另一种形式的专用卡是使用自定义图形的卡。一种常见的方法是在机器人处理用户输入时在 web 服务上生成自定义图形。在第十一章中,我们将使用 Headless Chrome 构建一个简单的自定义图形渲染器,以展示使用 HTML 和 JavaScript 构建自定义图形是多么容易。
最后,微软推出了一种新的卡片格式,叫做适配卡。 10 自适应卡,我们将在第十一章展开,是一种平台无关的方式,使用简单的基于容器的布局引擎来描述文本、图像和输入字段的布局。然后,Microsoft Bot 通道连接器能够将这些卡渲染成特定于平台的渲染。自适应卡是定制图形方法的专用版本,集成了逻辑以在卡中生成按钮和行为。有多少频道最终会支持这种格式还有待观察,但许多微软拥有的频道已经这样做了。
图 4-20 显示了一个适配卡的 HTML 渲染示例。
图 4-20
自适应卡样本
图 4-21 显示了微软团队应用上输入表单卡的渲染。
图 4-21
输入表单卡示例
其他功能
机器人可能包括几个其他有趣的功能,可以真正使机器人体验发光。以下是其中的一些功能:
-
主动消息传递:机器人可以异步联系用户,由事件而不是传入的消息触发。如果 bot 存储了用户的地址(服务 URL、对话和用户 id 的组合),它可以利用它与用户进行通信。
-
人工移交:在客户服务场景和高度可见的面向公众的机器人部署中,拥有一种将对话从机器人无缝转移到人工代理的机制是一个成功机器人的必要条件。
-
支付:越来越多的平台开放他们的支付系统,以方便对话整合。Facebook Messenger 的支付程序集成了 easy Stripe/PayPal。微软为整个 Windows 生态系统和 Bot 框架的支付提供了简单的条纹集成。
对话体验指南
在开发 bot 体验时,我们应该遵循一些重要的指导原则。其中一些可能并不适用于每种类型的机器人,或者可能与消费者机器人和企业机器人更相关,但是在设计机器人时应该至少记住这个列表。
焦点
正如在第一章和第二章中所讨论的,机器人的技术和智能程度是有限的。我们的机器人不应该试图变得太聪明;人类总是能以这样或那样的方式破解机器人。例如,处理来自用户的问候,如“嗨”或“你好”,这是完全可以的。我们不想陷入处理各种不同类型问候的困境。不要开始为“怎么了?"对"嗨。“如果你正在读这本书,你很可能没有微软或谷歌的预算(图 4-22 )。我们是来帮忙做任务的,不是一般的 AI。诚实地承认我们机器人的局限性是可以的。
图 4-22
构建机器人的好建议
不要假装机器人是人类
我们不希望我们的机器人最终出现在恐怖谷。 11 也就是说,与大多数(如果不是全部的话)类人物体一样,真实的人类会觉得有些事情不太对劲,从而产生奇怪和诡异的感觉(图 4-23 )。我们不希望我们的用户有这种感觉。这与用人类的形象来表现你的机器人是相辅相成的。如果你通过一个虚拟形象来表现你的机器人,使用一个明显暗示非人类实体的图标。Siri 和 Cortana 在这方面做得非常好。
图 4-23
我们绝对是在恐怖谷
不要区分机器人的性别
围绕这个话题有很多文章。 12 值得注意的是,即使 Siri、Cortana 和 Alexa 以及一些较老的虚拟助手都有女性名字,但谷歌和脸书却选择了谷歌助手和 m。这种非性别化机器人的趋势在业内一直持续着。采用女性角色会很快变得怪异,就像电影中 AI 的性感化一样。
总是呈现下一个最佳行动
我们的机器人不应该在用户不知道下一步该做什么的情况下让用户无所事事。bot 应该有一个欢迎消息,向用户介绍它自己、它的功能以及它能做什么。当用户感到困惑并寻求帮助,或者机器人无法识别用户的输入时,机器人也应该建议一些选项。关键是,如果在对话的任何一点,用户看到一个没有建议后续步骤的空白消息框,这将成为令人困惑的对话体验。Facebook Messenger、Skype 和其他频道具有上下文快速回复功能,在聊天界面底部显示按钮选项(图 4-24 )。提出这样的建议是一个很好的方式来交流我们的机器人的能力和局限性。
图 4-24
下一步最佳行动
语气一致
机器人通常最终会获得一个名字和个性。虽然我不认为性别化的名字有意义,但你的机器人应该有个性和一致的语气。记住,这些是品牌在向你的顾客说话。有些机器人很健谈。其他人就不那么想了。有些是正式的。其他的更轻松。为你的机器人选择一个并保持一致。此外,尽管这是一项有趣的技术,但如果我们想保持以品牌为中心的声音,我们应该避免使用自然语言生成模型(自动生成响应的机器学习算法)。
利用丰富的内容
机器人为我们提供了不仅仅利用文本的机会。我们可以格式化文本并包含图像、视频和音频文件。我们可以渲染卡片(图 4-25 ),甚至在您的卡片中创建一些自定义图形。我们需要最大限度地利用这些功能
图 4-25
丰富的机器人内容是一个好主意
宽容一点
自然语言很复杂。预计用户输入是模糊的。我们的机器人应该有对话路径来确认信息或引出丢失的数据。如果希望用户输入一个数字,我们应该解析任何可能的输入,但也要清楚机器人的期望。如果可能的话,通过使用快速回复功能向用户提供一些可能值的建议。用户会对这些建议感到满意。没有什么比不知道和不被教导如何与机器人交流更令人沮丧的了。
避免陷入困境
在我们的机器人的任何一点上,用户应该能够改变对话主题。除非绝对必要,否则我们的机器人应该尝试卡在对话上下文中。例如,让我们假设一个日历机器人正在向用户询问日期。我们的机器人需要一个解析为日期的字符串。如果用户输入“删除明天上午 9 点的约会”,我们的机器人应该优雅地处理查询,而不是说“对不起,这不是一个日期”。请以年/月/日的格式输入日期。
不要滥用主动信息
机器人给了我们随时联系用户的能力,即使机器人看不到来自用户的消息。不要滥用这种特权。在消息应用中,用户在收到消息时会收到通知。没有比不断发送提醒或试图重新参与更简单的方法了。一些频道对此也有具体的政策。在信息传递通道中做一个好公民。
为人类提供一条清晰的道路
如果现在有一件事应该是清楚的,那就是机器人不能理解一切。即使功能范围有限,也会有机器人无法处理的问题。如果与用例相关,我们的机器人应该有能力以某种方式将用户连接到人类代理。无论是显示带有案例号的电话号码,还是无缝集成到实时聊天系统中,我们的机器人都应该清楚我们的用户如何与人类交流,以帮助他们解决问题(图 4-26 )。比如我曾经遇到过一个可以回答常见问题的 bot。我看了一篇关于这个机器人的新闻稿,所以我决定尝试一下。我开始对话,收到一条关于点击按钮的消息。没有按钮。我问:“我能怎么办?”我被转到了一个人类特工那里。在这一点上,我不能做任何事情,直到一个人类处理我的情况。我也不知道需要多长时间。他们的呼叫中心还开着吗?一旦代理人来了,我就和他们说话,然后被送回机器人那里。我完全沉默,没有按钮。我说的是“测试”我得到的下一条消息是我又要被调走了。在这一点上,我只是退出。不要让你的用户沮丧地退出。
图 4-26
与人类交谈的清晰路径
向你的用户学习
使用对话式体验从用户那里收集数据很简单。使用用户输入来解决 LUIS 的冲突意图并利用该数据来训练 LUIS 也很容易。当然,给予用户输入的重要性应该与给予培训师提供的话语的权重有很大不同。但是如果我们有数据,我们应该利用它。图 4-27 显示了我们如何实现这种方法的例子。在图中,我们将用户反馈存储到主动学习数据存储中,并且我们的主动学习过程确定在使用数据点训练 LUIS 之前应该观察多少相同的反馈。小心基于用户输入的自动化训练。你不想走泰的路。 13
图 4-27
实施主动学习
随着你在不同的消息通道中积累经验,你可能会学到更多的规则,但这个列表是一个很好的起点,我建议我们在每个聊天机器人项目中遵循。
结论
对话设计是一个丰富的领域。对于如何与用户互动,以及如何以文本之外的形式交流想法,我们有许多选择。当开发机器人时,我们的方法应该总是“由用户做正确的事情”用户的会话体验可能对语气、品牌、冗长和过度使用非常敏感(你不需要事事都用卡)。尽管在早期阶段有一些关键的抽象,比如卡片,但这个领域已经发展到可以最好地处理机器人与用户的交互。随着机器人变得越来越普遍,这些机制将会改进,数量也会增加。例如,微软的 adaptive cards 是一个试图拓展机器人在与用户对话中所能提供的功能的项目。我希望随着 bot 变得越来越普遍,消息通道将支持越来越多类型的 bot 卡行为。
我们现在对机器人执行的常见操作以及它们是如何执行的有了很好的基本理解。唯一剩下的问题是,我们如何将所有这些放在代码中?在下一章中,我们将会这样做,并将这些想法付诸实践。
五、Microsoft Bot 框架简介
微软的 Bot Builder SDK 有两种版本:C#和 Node.js。正如在第一章中提到的,出于本书的目的,我们将使用 Node.js 版本。Node.js 是跨平台的 JavaScript 运行时;事实上,它是跨平台的,并且基于像 JavaScript 这样的入门语言,这意味着我们可以更容易地展示使用该技术构建机器人是多么容易。我们保持在 EcmaScript6 的范围内;然而,机器人框架机器人可以使用任何风格的 JavaScript 来构建。Bot Builder 框架本身是用 TypeScript 编写的,TypeScript 是 JavaScript 的超集,包含可选的静态类型,可以编译成 JavaScript。
对于本章,我们应该对 Node.js 和 npm(Node 包管理器)有一个初步的了解。整本书提供的代码将包括 npm 包定义,所以我们只需要运行两个命令。
npm install
npm start
我们本章的目的是编写一个基本的 echo bot,并使用微软的 channel connectors 将其部署到 Facebook Messenger。一旦我们设置了基本的机器人,我们将深入到机器人构建器 SDK 中的不同概念,这些概念真正允许我们编写杀手级机器人:瀑布、对话框、识别器、会话、卡等等。走吧!
Microsoft Bot Builder SDK 基础知识
我们将用来编写机器人的核心库称为机器人构建器 SDK ( https://github.com/Microsoft/BotBuilder
)。首先,您需要创建一个新的 Node 包,并安装 botbuilder 、 dotenv-extended 和 restify 包。为此,您可以创建一个新目录,并键入以下命令:
npm init
npm install botbuilder dotenv-extended restify --save
图 5-1 显示了本地机器上典型的高级 bot 架构。这个想法是,node 应用主要依赖于两个组件。首先,bot Builder SDK 是我们用来构建 Bot 的 Bot 引擎。第二,来自任何通道的所有消息,无论是来自机器外部还是来自开发人员机器的 bot 框架仿真器,都通过 HTTP 端点发送到 Bot。我们使用 restify 来监听 HTTP 消息,并将它们发送给 SDK。
图 5-1
典型的高级 bot 架构
作为手动创建 package.json 文件的替代方法,我们可以使用随书提供的 echo-bot 代码来引导这个练习。回声机器人的 package.json 如下所示。注意, eslint 依赖项纯粹是针对我们的开发环境的,所以我们可以运行 JavaScript linter1来检查风格和潜在的编程错误。
{
"name": "practical-bot-development-echo-bot",
"version": "1.0.0",
"description": "Echo Bot from Chapter 1, Practical Bot Development",
"scripts": {
"start": "node app.js"
},
"author": "Szymon Rozga",
"license": "MIT",
"dependencies": {
"botbuilder": "³.9.0",
"dotenv-extended": "¹.0.4",
"restify": "⁴.3.0"
},
"devDependencies": {
"eslint": "⁴.10.0",
"eslint-config-google": "⁰.9.1",
"eslint-config-standard": "¹⁰.2.1",
"eslint-plugin-import": "².8.0",
"eslint-plugin-node": "⁵.2.1",
"eslint-plugin-promise": "³.6.0",
"eslint-plugin-standard": "³.0.1"
}
}
bot 本身在 app.js 文件中定义。注意,包定义中的启动脚本将 app.js 指定为我们的 bot 的入口点。
// load env variables
require('dotenv-extended').load();
const builder = require('botbuilder');
const restify = require('restify');
// setup our web server
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
console.log('%s listening to %s', server.name, server.url);
});
// initialize the chat bot
const connector = new builder.ChatConnector({
appId: process.env.MICROSOFT_APP_ID,
appPassword: process.env.MICROSOFT_APP_PASSWORD
});
server.post('/api/messages', connector.listen());
const bot = new builder.UniversalBot(connector, [
(session) => {
// for every message, send back the text prepended by echo:
session.send('echo: ' + session.message.text);
}
]);
让我们看看这段代码。我们使用一个名为 dotenv 的库来加载环境变量。
require('dotenv-extended').load();
环境变量从名为的文件中加载。env 到 process.env JavaScript 对象中。 .env.defaults 文件包含默认的环境变量,可以用来指定 Node.js 所需的值。在这种情况下,文件如下所示:
MICROSOFT_APP_ID=
MICROSOFT_APP_PASSWORD=
我们需要 botbuilder 和 restify 库。Botbuilder 不言自明。Restify 用于为我们运行一个 web 服务器端点。
const builder = require('botbuilder');
const restify = require('restify');
现在我们设置我们的 web 服务器来监听端口 3978 上的消息。
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
console.log('%s listening to %s', server.name, server.url);
});
接下来,我们创建一个所谓的聊天连接器。在 Bot 框架的上下文中,通道连接器是由 Microsoft 创建和维护的端点,有助于将消息从本机平台格式转换为 Bot Builder SDK 格式。的建造者。ChatConnector 对象知道如何从这些连接器接收 HTTP 消息,将它们传递给 bot 会话引擎,并将任何传出的消息发送回连接器,如图 5-2 所示。
图 5-2
Microsoft Bot 框架连接器
环境变量 MICROSOFT_APP_ID 和 MICROSOFT_APP_PASSWORD 是我们的 bot 的凭证。我们将在稍后使用 Azure 创建 Azure Bot 服务注册时,在 Bot 框架中设置它们。目前,我们可以将这些值留空,因为我们还不太关心如何保护我们的 bot。
const connector = new builder.ChatConnector({
appId: process.env.MICROSOFT_APP_ID,
appPassword: process.env.MICROSOFT_APP_PASSWORD
});
接下来我们告诉 restify,任何对/api/messages
端点的请求,或者更确切地说是对http://localhost:3978/api/messages
的请求,都应该由 connector.listen()返回的函数来处理。也就是说,我们允许 Bot 框架处理所有进入该端点的消息。
server.post('/api/messages', connector.listen());
最后,我们创建了通用机器人。它被称为通用机器人,因为它不依赖于任何特定的平台。它使用连接器来接收和发送数据。任何进入机器人的消息都将被发送到函数数组。目前,我们只有一个功能。该函数接受一个会话对象。该对象包含诸如消息之类的数据,但也包含关于用户和对话的数据。机器人通过调用 session.send 函数来响应用户。
const bot = new builder.UniversalBot(connector, [
(session) => {
// for every message, send back the text prepended by echo:
session.send('echo: ' + session.message.text);
}
]);
注意,Bot Builder SDK 负责为传入的 HTTP 请求提供正确的 HTTP 响应。实际上,如果 Bot Builder 处理代码没有问题,内部将返回 HTTP Accepted (202),否则将返回 HTTP Internal Server Error (500)。
我们的响应的内容是异步的,这意味着我们的 bot 收到的对原始请求的响应不包含任何内容。正如我们将在下一章中看到的,一个传入的请求包括一个通道 ID、连接器的名称如 slack 或 facebook ,以及一个我们的机器人发送消息的响应 URL。URL 通常类似于 https://facebook.botframework.com
。 Session.send 将向响应 URL 发送一个 HTTP POST 请求。
我们可以通过简单地执行以下命令来运行这个 bot:
npm install
npm start
我们将在控制台中看到一些 Node.js 输出。应该有一个服务器运行在端口 3978 上,路径为/api/messages。根据我们的本地 Node.js 设置和我们机器上预先存在的软件,我们可能需要更新到最新版本的 node-gyp 包,一个用于编译本地插件工具的工具。
我们如何与机器人交流?我们可以尝试使用像 curl 这样的命令行 HTTP 工具来发送消息,但是我们必须拥有一个响应 url 来查看任何响应。此外,我们需要添加逻辑来获取访问令牌,以通过任何安全检查。简单地测试这个机器人似乎工作量太大了。
当然,我们不一定要这样做。微软为我们提供了一个模拟器来测试我们的机器人。可在 https://emulator.botframework.com/
下载。仿真器支持 Linux、Windows 和 OS X(图 5-3 )。
图 5-3
Bot 框架仿真器
准备好,因为我们会经常使用模拟器。以下是我们应该注意的几点:
-
我们可以在地址栏中输入我们的机器人 URL (
/api/messages
)。模拟器还允许我们处理 bot 安全性并指定应用 ID/密码。我们稍后会谈到这一点。 -
日志部分向我们展示了 bot 和模拟器之间发送的所有消息。我们可以看到模拟器打开了一个端口来托管响应 URL。在本例中,它是端口 58462。
-
模拟器日志指示何时有更新,所以我们总是运行最新和最好的版本。
-
有一些关于 ngrok 的措辞。Ngrok 是一个反向代理,允许我们将来自公共 HTTPS 端点的请求隧道传输到本地 web 服务器。当从远程计算机测试机器人连接性时,这非常有用,例如,如果我们想在 Facebook Messenger 上运行本地机器人。我们还可以使用模拟器向远程机器人发送消息。
-
细节部分显示了在 bot 和模拟器之间发送的每条消息的 JSON。
让我们继续连接到我们的机器人。我们在地址栏中输入http://localhost:3978/api/messages
,暂时将微软应用 ID 和微软应用密码字段留空(图 5-4 ),因为我们还没有设置。env 文件。我们将在控制台中收到安全警告;现在可以忽略这些。此时,我们真的要单击连接按钮了。
图 5-4
模拟器连接用户界面
我们将在模拟器日志中看到两条消息。两者都是类型对话更新(图 5-5 )。
图 5-5
从模拟器到我们的 bot 建立连接时更新消息
这是什么意思?bot 和消费连接器(本例中是模拟器)之间的每条消息都被称为一个活动,每个活动都有一个类型。有像留言或打字这样的类型。如果活动属于类型消息,那么它实际上就是机器人和用户之间的消息。一个打字活动告诉连接器显示一个打字指示器。之前,我们看到了 conversationUpdate 类型。此类型表示对话中有更改;最常见的情况是,用户加入或离开对话。在用户和机器人之间的一对一对话中,用户和机器人将是对话的两个成员。在群聊场景中,机器人加上所有用户将成为对话的一部分。消息元数据将包括关于哪些用户加入或离开对话的信息。事实上,如果我们点击两个 conversationUpdate 活动的 POST 链接,我们会在 Details 部分找到 JSON。以下是两条消息的内容:
{
"type": "conversationUpdate",
"membersAdded": [
{
"id": "default-user",
"name": "User"
}
],
"id": "hg71ma8cfj27",
"channelId": "emulator",
"timestamp": "2018-02-22T22:02:10.507Z",
"localTimestamp": "2018-02-22T17:02:10-05:00",
"recipient": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"conversation": {
"id": "mf24ln43lde3"
},
"serviceUrl": "http://localhost:58462"
}
{
"type": "conversationUpdate",
"membersAdded": [
{
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
}
],
"id": "jfcdbhek0m4m",
"channelId": "emulator",
"timestamp": "2018-02-22T22:02:10.502Z",
"localTimestamp": "2018-02-22T17:02:10-05:00",
"recipient": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"conversation": {
"id": "mf24ln43lde3"
},
"from": {
"id": "default-user",
"name": "User"
},
"serviceUrl": "http://localhost:58462"
}
现在,让我们向机器人发送一条消息,文本为“echo!”并查看仿真器日志(图 5-6 )。请注意,如果我们不设置显式的 bot 存储实现,我们可能会收到类似这样的警告:“警告:Bot 框架状态 API 不推荐用于生产环境,在未来的版本中可能会被弃用。”我们将在下一章深入探讨这个问题。简单地说,强烈建议我们不要使用默认的 bot 存储。我们现在可以用下面的代码来替换它:
图 5-6
它还活着!
const inMemoryStorage = new builder.MemoryBotStorage();
bot.set('storage', inMemoryStorage);
啊哈!我们的机器人还活着。模拟器现在包含了更多的东西。一个消息类型的传入帖子,文本为“echo!”以及带有文本“echo: echo!”的 message 类型的传出帖子和带有调试事件数据的 POST。单击 POST 链接将再次显示在这个请求中接收或发送的 JSON。请注意,这两种有效载荷是不同的,尽管在它们的下面使用了相同的接口 IMessage。我们将在第六章对此进行更深入的探讨。以下是传入或传出消息中的部分数据列表:
-
发送者信息(id/name) :发送者的特定于频道的标识符和用户名。如果消息是从用户到机器人的,这就是用户。在相反的方向,发送者是机器人。Bot Builder SDK 负责填充这些数据。在我们的 JSON 中,这是来自字段的。
-
收件人信息(id/name) :发件人信息的逆。这是接收方字段。
-
时间戳:消息发送的日期和时间。通常,的时间戳将采用 UTC 时间,而的 localTimestamp 将采用当地时区,尽管很容易混淆,但 bot 响应的 localTimestamp 是一个 UTC 时间戳。
-
ID :唯一活动标识符。这通常映射到特定于通道的消息 ID。id 由通道分配。在模拟器中,传入的消息将被分配一个 ID。传出的消息不会。
-
ReplyToId :当前消息响应的活动的标识符。这用于在消息客户端中线程化对话。
-
会话:平台上的会话标识。
-
类型:活动的类型。可能的值包括 message、conversationUpdate、contactRelationUpdate、typing、ping、deleteUserData、endOfConversation、event 和 invoke。
-
文本:消息的文本。
-
TextFormat :文本字段格式。可能的值有 plain、markdown 和 xml。
-
附件(Attachments):这是 Bot 框架发送媒体附件(如视频、图像、音频或其他类型,如英雄卡)的结构。我们也可以将该字段用于任何类型的自定义附件。
-
文本本地:用户的语言。
-
ChannelData :特定于频道的数据。对于传入的消息,这可以包括来自通道的原始本地消息,例如本地 Facebook Messenger SendAPI。对于传出消息,这将是我们希望传递给通道的原始本机消息。这通常在 Microsoft channel connectors 没有针对通道实现特定类型的消息时使用。我们将在第八章和第九章中探究一些例子。
-
ChannelId :消息平台通道标识。
-
ServiceUrl :机器人向其发送消息的端点。
-
实体:用户和机器人之间传递的数据对象集合。
让我们更详细地检查交换的消息。来自模拟器的传入消息如下所示:
{
"type": "message",
"text": "echo!",
"from": {
"id": "default-user",
"name": "User"
},
"locale": "en-US",
"textFormat": "plain",
"timestamp": "2018-02-22T22:03:40.871Z",
"channelData": {
"clientActivityId": "1519336929414.7950057585459784.0"
},
"entities": [
{
"type": "ClientCapabilities",
"requiresBotState": true,
"supportsTts": true,
"supportsListening": true
}
],
"id": "50769feaaj9j",
"channelId": "emulator",
"localTimestamp": "2018-02-22T17:03:40-05:00",
"recipient": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"conversation": {
"id": "mf24ln43lde3"
},
"serviceUrl": "http://localhost:58462"
}
这里应该没有惊喜。响应看起来很相似,但不那么冗长。这是典型的。通道连接器将用尽可能多的支持数据填充传入的消息。响应不需要包含所有这些内容。值得注意的一点是 ID 没有被填充;通道连接器通常会为我们处理这些问题。
{
"type": "message",
"text": "echo: echo!",
"locale": "en-US",
"localTimestamp": "2018-02-22T22:03:41.136Z",
"from": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"recipient": {
"id": "default-user",
"name": "User"
},
"inputHint": "acceptingInput",
"id": null,
"replyToId": "50769feaaj9j"
}
我们还注意到input int字段的存在,它主要与语音助手系统相关,并且向消息平台指示麦克风的建议状态。例如,接受输入将指示用户可能对机器人消息做出响应,而期望输入将指示用户响应正在等待中。
最后,调试事件提供关于机器人如何执行请求的数据。
{
"type": "event",
"name": "debug",
"value": [
{
"type": "log",
"timestamp": 1519337020880,
"level": "info",
"msg": "UniversalBot(\"*\") routing \"echo!\" from \"emulator\"",
"args": []
},
{
"type": "log",
"timestamp": 1519337020881,
"level": "info",
"msg": "Session.beginDialog(/)",
"args": []
},
{
"type": "log",
"timestamp": 1519337020882,
"level": "info",
"msg": "waterfall() step 1 of 1",
"args": []
},
{
"type": "log",
"timestamp": 1519337020882,
"level": "info",
"msg": "Session.send()",
"args": []
},
{
"type": "log",
"timestamp": 1519337021136,
"level": "info",
"msg": "Session.sendBatch() sending 1 message(s)",
"args": []
}
],
"relatesTo": {
"id": "50769feaaj9j",
"channelId": "emulator",
"user": {
"id": "default-user",
"name": "User"
},
"conversation": {
"id": "mf24ln43lde3"
},
"bot": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"serviceUrl": "http://localhost:58462"
},
"text": "Debug Event",
"localTimestamp": "2018-02-22T22:03:41.157Z",
"from": {
"id": "8k53ghlggkl2jl0a3",
"name": "Bot"
},
"recipient": {
"id": "default-user",
"name": "User"
},
"id": null,
"replyToId": "50769feaaj9j"
}
注意,这些值与 bot 控制台输出中显示的值相同。同样,如果我们不覆盖默认的 bot 状态,我们将会看到更多与废弃代码相关的数据。控制台输出如下所示:
UniversalBot("*") routing "echo!" from "emulator"
Session.beginDialog(/)
/ - waterfall() step 1 of 1
/ - Session.send()
/ - Session.sendBatch() sending 1 message(s)
该输出跟踪用户请求是如何执行的,以及它是如何遍历对话的。我们将在本章中进一步讨论这个问题。
如果我们使用模拟器发送更多的消息,我们会看到相同类型的输出,因为这个机器人非常简单。随着我们在卡片等特性上获得更多的经验,我们将从使用模拟器和进一步检查 JSON 消息中受益。该协议是 Bot 框架强大功能的重要组成部分:我们应该尽可能地熟悉它。
练习 5-1
连接到仿真器
检索 echo bot 代码,并使用 npm install 和 npm start 在本地运行它。下载模拟器并将其连接到机器人。
-
仔细检查请求/响应消息。
-
观察模拟器和机器人之间的行为。
-
探索模拟器。使用“设置”菜单创建新对话或向机器人发送系统活动消息。它有什么反应?你能写一些代码来处理这些消息吗?
在本练习结束时,您应该熟悉如何运行一个未经身份验证的本地 bot 并通过仿真器连接到它。
Bot 框架端到端设置
我们现在有了一个机器人。我们如何将它连接到所有这些不同的通道?Bot 框架使这变得简单。我们的目标是通过 Azure 门户向 bot 框架注册我们的 bot 及其端点,并向 Facebook Messenger 频道订阅 Bot。
有几件事我们必须做。首先,我们必须在 Azure 门户上创建一个 Azure Bot 服务注册。我们可能需要创建我们的第一个 Azure 订阅。这个设置的一部分是使用 ngrok 来允许从互联网访问这个机器人,所以我们应该确保我们已经从这里安装了 ngrok:https://ngrok.com/
。最后,我们将把机器人部署到 Facebook Messenger。这意味着我们需要创建一个脸书页面,一个脸书应用,以及 Messenger 和 Webhook 集成,并将所有这些连接回机器人框架。有很多步骤,但是一旦我们熟悉了 Azure 和脸书的术语,就没那么麻烦了。我们将首先快速浏览说明,然后回头解释每一步都做了什么。
步骤 1:连接到 Azure
我们的第一步是登录 Azure 门户。如果你有 Azure 账户,那太好了。如果您已经订阅了 Azure,请直接跳到步骤 2。如果您没有,您可以前往 https://azure.microsoft.com/en-us/free/
创建一个免费的开发者账户,拥有 200 美元的 30 天信用额度。
点击“免费开始”您需要使用 Microsoft 或工作帐户登录。如果你两者都没有,你可以在 https://account.microsoft.com/account
轻松创建一个微软账户。一旦你通过认证,你会看到如图 5-7 所示的页面。该页面将收集您的个人信息,并通过短信和有效信用卡验证您的身份。不要惊慌。信用卡是验证你的身份所必需的。机会是,你甚至不会接近使用 200 美元的信用,如果你这样做,你不会被收费;您将无法继续使用这些服务。我们在本书中使用 Azure 的大部分内容可以通过各种 Azure 服务的免费层来实现。
图 5-7
Azure 注册页面
一旦这个过程完成,你就可以在 https://portal.azure.com
进入 Azure 门户。它看起来有点像图 5-8 。在右上角,您将看到您注册时使用的电子邮件地址和您的目录名。例如,如果我的电子邮件是szymon.rozga@aol.com
(它不是),那么我的目录名将是 SZYMONROZGAAOL。如果您被添加到其他目录,该菜单将是一个下拉菜单,供您选择要导航到哪个目录。
Azure 帐户包含订阅。订阅是一个计费实体。如果我们导航到 https://portal.azure.com/#blade/Microsoft_Azure_Billing/SubscriptionsBlade
,或者门户中的订阅服务,并且我们刚刚创建了 200 美元的试用帐户,我们应该会看到一个名为“免费试用”的订阅。每个 Azure 订阅还可以包含一个或多个资源组。资源组是资源的逻辑容器,资源是单独的 Azure 服务。与每个资源组中的资源相关联的所有成本都根据与包含订阅相关联的付款方法来收取。有了 200 美元的试用账户,当综合费用达到支出限额时,服务会自动关闭。如果需要,免费帐户可以转换为付费帐户,从您的信用卡中收取额外费用(或其他支付方式)。
图 5-8
空 Azure 门户
步骤 2:创建 Bot 注册
在 Azure 门户中,单击左上角窗格中的“创建资源”按钮。在搜索市场文本字段中,输入 azure bot 。你会得到很多结果,但我们感兴趣的是前三名(图 5-9 )。
图 5-9
Azure bot 资源
这是三个选项:
-
web 应用 Bot :指向部署在 Azure 上的 Web 应用的 Bot 注册
-
Functions bot :指向作为 Azure 函数运行的 Bot 的 Bot 注册,Azure 的无服务器计算选项之一
-
Bot 通道注册:无云端后端的 Bot 注册
出于我们的目的,我们将创建一个 bot 通道注册 bot,因为我们将继续在我们的笔记本电脑上本地运行 Bot。单击 Bot 频道注册,然后单击创建。根据图 5-10 ,输入一个 bot 名称、将包含该注册的资源组的名称以及资源位置,即,将托管该注册的 Azure 区域。对于定价层,选择 F0;这是自由选择,足以满足我们的需求。暂时将消息传递端点保留为空,并将 Application Insights 选择为 on。Application Insights 是微软的云遥测和日志记录服务之一。bot 框架使用它来存储关于您的 Bot 注册使用的数据和分析。默认情况下,这将创建应用洞察的基本和免费层。选择尽可能靠近 Bot 通道注册位置的位置。准备就绪后,单击创建。
图 5-10
创建新的 bot 通道注册
门户顶部有一个进度指示器,当注册准备就绪时,我们会收到通知。我们还可以通过使用左侧窗格上的资源组按钮导航到资源组(图 5-11 )。
图 5-11
我们资源组中的资源
导航到 bot 通道注册,然后导航到设置刀片(图 5-12 )。请注意,Azure 自动填充了应用洞察标识符和密钥。这些将用于跟踪我们的机器人分析数据。我们将在第十三章中看到其中一个分析仪表板。
图 5-12
Bot 通道注册设置刀片
我们还会看到微软应用 ID。记下这个值。单击其正上方的管理链接,导航至 Microsoft 应用门户。这可能会再次要求我们的登录信息,因为它是一个独立于 Azure 的网站。一旦我们在应用列表中找到新创建的 bot,单击 Generate New Password(在 Application Secrets 部分)并保存值;你只能看一次!还记得我们在机器人控制台输出中看到了我们的机器人不安全的警告吗?我们现在将解决这个问题。
步骤 3:保护我们的机器人
在包含 echo bot 代码的目录中,创建一个名为。env 并提供 Microsoft 应用 ID 和密码:
# Bot Framework Credentials
MICROSOFT_APP_ID={ID HERE}
MICROSOFT_APP_PASSWORD={PASSWORD HERE}
关闭并重新启动 bot (npm start)。
如果我们现在尝试从模拟器连接,模拟器将显示以下日志消息:
[08:00:16] -> POST 401 [conversationUpdate]
[08:00:16] Error: The bot's MSA appId or password is incorrect.
[08:00:16] Edit your bot's MSA info
bot 控制台输出将包含以下消息:
ERROR: ChatConnector: receive - no security token sent.
现在看起来安全多了,对吧?我们必须在模拟器端输入相同的应用 ID 和密码。单击“编辑我们的机器人的 MSA(微软帐户)信息”链接,并将数据输入模拟器。如果我们现在尝试使用模拟器连接,它会工作得很好。在继续之前向机器人发送消息进行确认。
步骤 4:设置远程访问
我们可以将机器人部署到 Azure,将脸书连接器连接到该端点,然后就到此为止。但是我们如何开发或调试脸书特有的功能呢?bot 框架方式是运行 bot 的本地实例,并将测试脸书页面连接到本地 Bot 进行开发。
为此,请从命令行运行 ngrok。
ngrok http 3978
我们将看到图 5-13 中的数据。默认情况下,ngrok 会分配一个随机的子域(付费的 ngrok 版本允许您指定一个域名)。在这种情况下,我的网址是 https://cc6c5d5f.ngrok.io
。注意,ngrok 的免费版本在我们每次运行它时都会提供一个随机的子域。我们可以通过升级到付费版本或者简单地尽可能长时间保留 ngrok 会话来解决这个问题。
图 5-13
Ngrok 将 HTTP/HTTPS 请求转发到我们的本地 bot
让我们看看这是否有效。在模拟器中,输入 ngrok URL,后跟 /api/messages 。例如,对于前面的 URL,正确的消息端点是 https://cc6c5d5f.ngrok.io/api/messages
。将应用 ID 和应用密码信息添加到模拟器中。单击 Connect 后,模拟器应该会成功连接到 bot 并与之聊天。
现在,在 Bot 通道注册设置刀片中分配相同的消息传递端点 URL,图 5-12 。接下来,使用 Web Chat blade 导航到测试,并尝试向机器人发送消息。应该能行。您已经将第一个频道连接到您的机器人(图 5-14 )!
图 5-14
有效!我们的机器人连接到我们的第一个频道!
步骤 5:连接到 Facebook Messenger
很酷,对吧?bot 框架几乎完全与我们的 Bot 集成在一起。我们现在将继续整合我们的机器人与 Facebook Messenger。Bot 通道注册上的通道刀片使我们能够连接到微软支持的通道(图 5-15 )。
图 5-15
频道仪表板
点击 Facebook Messenger 按钮,进入 Messenger 配置界面(图 5-16 )。我们将需要从脸书获得四条数据:页面 ID、应用 ID、应用秘密和页面访问令牌。最后,我们应该注意回调 URL 和验证令牌。我们将需要这些建立脸书和 Bot 框架之间的连接。
图 5-16
Facebook Messenger Bot 框架连接器设置
现在让我们设置必要的脸书资产。我们必须有一个脸书帐户来完成以下任务。导航到 Facebook.com,使用右上角的下拉菜单创建一个新页面(图 5-17 )。脸书将询问页面的类型。出于本例的目的,我们可以选择品牌/产品类型和应用页面子类别。
图 5-17
创建新的脸书页面
我创建了一个名为 Szymon 测试页面的页面。我们可以通过点击左侧导航窗格上的“关于”链接来找到页面 ID(图 5-18 )。在最底部,我们会找到页面 ID。我们需要将该值复制到 Bot 框架 Facebook Messenger 频道配置表单中(图 5-16 )。
图 5-18
脸书页面关于页面,包括页面 ID
接下来,在新的浏览器标签或窗口中,导航至 https://developers.facebook.com
。如果您还没有注册,请注册一个开发者帐户。创建一个新的应用(图 5-19 )。给它起任何你喜欢的名字。
图 5-19
创建新的脸书应用
完成后,通过左侧边栏菜单导航到设置➤基本页面,并将 Facebook 应用 ID 和应用秘密复制到 Bot 框架表单中(图 5-20 )。
图 5-20
应用 ID 和应用密码
接下来,导航到仪表板(从左侧栏的链接)并设置 Messenger 产品。向下滚动页面,直到到达令牌生成部分。在令牌生成部分选择页面,生成页面访问令牌(图 5-21 )。将令牌复制到 Azure 门户内的 Bot 框架表单中。
图 5-21
生成页面访问令牌
接下来,滚动到 Webhooks 部分(就在脸书应用仪表板的令牌生成部分下方),然后单击设置 Webhooks。您将看到一个弹出窗口,要求您输入回拨 URL 和验证令牌。从 Azure 门户的配置 Facebook Messenger 表单中复制并粘贴这两个内容。
在订阅字段部分,选择以下字段:
-
信息
-
消息 _ 交付
-
消息 _ 阅读
-
消息传递 _ 回发
-
消息传递 _ 选项
-
消息 _ 回应
单击验证并保存。最后,从下拉列表中选择您希望 bot 订阅的页面,然后单击 subscribe。您的设置页面应该如图 5-22 所示。
图 5-22
订阅我们测试页面上的消息
确保保存 Bot 框架配置。就这样!您可以在 Messenger 联系人中找到该页面。你可以给它发送一条信息,你应该得到它的回应(图 5-23 )。
图 5-23
在 Messenger 中工作的回声机器人
步骤 6:部署到 Azure
如果我们不将代码部署到云中,这就不是一个完整的教程。我们将创建一个 web 应用,并使用 Kudu ZipDeploy 部署 Node.js 应用。最后,我们将把 bot 通道注册指向 web 应用。
进入我们在步骤 2 中创建的 Azure 资源组,并创建一个新资源。搜索 web app 。选择 Web App 而不是 Web App Bot。Web 应用 bot 是 Bot 通道注册和应用服务的组合。我们不需要这个组合,因为我们已经创建了一个 bot 通道注册。
创建 web 应用时,我们需要给它一个名称。还要确保选择了正确的资源组(图 5-24 )。Azure 会将其添加到我们现有的资源组中,并为我们创建一个新的应用服务计划。应用服务计划是 web 应用和类似计算资源的容器;它定义了我们的应用运行的硬件以及成本。在图 5-24 中,我们创建了一个新的应用服务计划,并选择了免费定价等级。免费很好。
图 5-24
创建新的应用服务和应用服务计划
在部署我们的 echo bot 之前,我们需要添加两件事情。首先,我们向基本 URL 端点添加一个响应,以验证我们的 bot 是否已部署。将此代码添加到 app.js 文件的末尾:
server.get('/', (req, res, next) => {
res.send(200, { "success": true });
next();
});
其次,对于基于 Windows 的 Azure 设置,我们还需要包含一个自定义的 web.config 文件来告诉互联网信息服务(IIS) 2 如何运行 Node 应用。 3
<?xml version="1.0" encoding="utf-8"?
<!--
This configuration file is required if iisnode is used to run node processes behind
IIS or IIS Express. For more information, visit:
https://github.com/tjanczuk/iisnode/blob/master/src/samples/configuration/web.config
-->
<configuration>
<system.webServer>
<!-- Visit http://blogs.msdn.com/b/windowsazure/archive/2013/11/14/introduction-to-websockets-on-windows-azure-web-sites.aspx for more information on WebSocket support -->
<webSocket enabled="false" />
<handlers>
<!-- Indicates that the server.js file is a node.js site to be handled by the iisnode module -->
<add name="iisnode" path="app.js" verb="*" modules="iisnode"/>
</handlers>
<rewrite>
<rules>
<!-- Do not interfere with requests for node-inspector debugging -->
<rule name="NodeInspector" patternSyntax="ECMAScript" stopProcessing="true">
<match url="^app.js\/debug[\/]?" />
</rule>
<!-- First we consider whether the incoming URL matches a physical file in the /public folder -->
<rule name="StaticContent">
<action type="Rewrite" url="public{REQUEST_URI}"/>
</rule>
<!-- All other URLs are mapped to the node.js site entry point -->
<rule name="DynamicContent">
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="True"/>
</conditions>
<action type="Rewrite" url="app.js"/>
</rule>
</rules>
</rewrite>
<!-- 'bin' directory has no special meaning in node.js and apps can be placed in it -->
<security>
<requestFiltering>
<hiddenSegments>
<remove segment="bin"/>
</hiddenSegments>
</requestFiltering>
</security>
<!-- Make sure error responses are left untouched -->
<httpErrors existingResponse="PassThrough" />
<!--
You can control how Node is hosted within IIS using the following options:
* watchedFiles: semi-colon separated list of files that will be watched for changes to restart the server
* node_env: will be propagated to node as NODE_ENV environment variable
* debuggingEnabled - controls whether the built-in debugger is enabled
See https://github.com/tjanczuk/iisnode/blob/master/src/samples/configuration/web.config for a full list of options
-->
<!--<iisnode watchedFiles="web.config;*.js"/>-->
</system.webServer>
</configuration>
接下来,我们通过浏览器访问我们的 bot web 应用。在我的例子中,我导航到 https://srozga-test-bot-23.azurewebsites.net
。将有一个默认的“您的应用服务应用已启动并正在运行”页面。在我们部署之前,我们必须压缩 echo bot 以传输到 Azure。我们压缩所有的应用文件,包括 Node 模块目录。我们可以使用以下命令:
# Bash
zip -r echo-bot.zip .
# PowerShell
Compress-Archive -Path * -DestinationPath echo-bot.zip
现在我们有了一个 zip 文件,关于如何部署我们有两个选项。在选项 1 中,我们使用命令行,通过在https://{WEB_APP_NAME}.scm.azurewebsites.net
使用 Kudu 4 端点来部署 bot。为此,我们必须首先访问应用服务中的部署凭证刀片(图 5-25 )来设置部署用户名和密码组合。
图 5-25
设置部署凭据
一旦完成,我们就可以开始了。运行以下 curl 命令将启动部署过程:
curl -v POST -u srozga321 --data-binary @echo-bot.zip https://srozga-test-bot-23.scm.azurewebsites.net/api/zipdeploy
一旦你运行了这个,curl 会要求你输入密码,如图 5-25 所示。它将上传 zip 并在应用服务上设置应用。完成后,向您的应用的基本 URL 发出请求,您应该会看到一个 200 的响应,成功设置为 true。
$ curl -X GET https://srozga-test-bot-23.azurewebsites.net
{"success":true}
另一种部署方式是使用 SCM 网站上的 Kudu 接口: https://srozga-test-bot-23.scm.azurewebsites.net/ZipDeploy
。您可以简单地将 zip 文件拖放到图 5-26 中的文件列表上。
图 5-26
Kudu ZipDeploy 用户界面
还有一步。进入 Bot Channels 注册条目中的 Settings blade,将 messages endpoint 设置为您的新应用服务(图 5-27 )。确保点击保存按钮。
图 5-27
消息传递端点的最终更新
在网上聊天和 Messenger 上随心所欲地保存和测试。恭喜你!我们完成了很多!我们现在有一个运行在 Azure 上的机器人,使用 Node.js 和微软机器人框架与网络聊天和 Facebook Messenger 对话。接下来,我们将深入描述我们刚刚完成的任务。
我们刚刚做了什么?
在上一节中,我们经历了很多。在注册和创建一个 bot、建立到脸书的连接以及部署到 Azure 方面,有许多移动部分。许多这些动作只需要执行一次,但是作为一个 bot 开发者,你应该对不同的系统、它们如何相互连接以及它们如何设置有一个坚实的理解。
Microsoft Azure
微软 Azure 是微软的云平台。有许多类型的资源,从基础设施即服务到平台即服务,甚至软件即服务。我们可以像创建新的应用服务一样轻松地调配新的虚拟机。我们可以使用 Azure PowerShell、Azure CLI(或云 Shell)、Azure 门户(如我们在示例中所做的)或 Azure 资源管理器来创建、修改和编辑资源。这些细节超出了本书的范围,我们建议您参考 Microsoft 在线文档以获取更多信息。
Bot 频道注册条目
当我们创建 bot 通道注册时,我们创建了一个全局注册,所有的通道连接器都可以使用它来识别、验证和与我们的 bot 通信。每个连接器,无论是与 Messenger、Slack、Web Chat 还是 Skype 通信,都知道我们的机器人、其微软应用 ID/密码、消息端点和其他设置(图 5-28 )。bot 通道注册是 Bot 框架 Bot 的起点。
图 5-28
概念 Bot 框架体系结构
我们跳过了 Azure 中另外两种类型的 bot 资源:Web App Bot 和 Functions Bot。一个 Web 应用机器人正是我们刚刚设置的;我们提供一个服务器来运行机器人应用。Azure Functions 是 Azure 实现无服务器计算的方法之一。它允许我们在云环境中托管不同的代码或功能,以便按需运行。我们只为我们使用的资源付费。Azure 基于负载动态扩展基础设施。函数是 bot 开发的一种非常有效的方法。对于更复杂的场景,我们需要小心设计向外扩展和多服务器部署的功能代码。出于本书的目的,我们不使用功能机器人。然而,我们建议您尝试这个主题,因为无服务器计算正变得越来越突出。
证明
我们如何确保只有授权的通道连接器或应用才能与我们的 bot 通信?这就是微软应用 ID 和应用密码的用武之地。当连接器向我们的 bot 发送消息时,它会在 HTTP 授权头中包含一个令牌。我们的机器人必须验证这个令牌。当我们的 bot 向连接器发送传出消息时,我们的 bot 必须从 Azure 检索有效的令牌,否则连接器将拒绝该消息。
Bot Builder SDK 提供了所有代码,因此这个过程对开发人员是透明的。Bot 框架文档详细描述了两个流程中的步骤: https://docs.microsoft.com/en-us/bot-framework/rest-api/bot-framework-rest-connector-authentication
。
连通性和 Ngrok
虽然 ngrok 不是 Bot 框架的一部分,但它是我们工具集不可或缺的一部分。Ngrok 是一个反向代理,通过 ngrok.io 上的外部可访问子域将所有请求隧道传输到我们计算机上的一个端口。免费版每次运行时都会创建一个新的随机子域;专业版允许我们有一个静态子域。Ngrok 还公开了一个 HTTPS 端点,这使得本地开发设置变得轻而易举。
通常,我们不会遇到 Ngrok 的任何问题。如果我们的 ngrok 配置正确,任何问题都可以缩小到外部服务或我们的机器人。
部署到 Facebook Messenger
每个平台都是不同的,但我们对脸书的机器人复杂性有所了解。首先,脸书用户使用脸书页面与品牌和公司互动。页面上的用户请求通常由对页面有足够访问权限的人来响应,以便通过页面的收件箱进行查看和响应。有许多企业实时聊天系统连接到脸书页面,并允许一组客户服务代表实时响应用户的查询。有了机器人框架的 Facebook Messenger 连接器,我们现在可以让机器人响应这些查询。我们将在第十三章的中讨论机器人将对话移交给代理人的想法,称为人工移交。
脸书上的一个机器人是一个脸书应用,它通过网络钩子订阅进入脸书页面的消息。我们注册了 Bot 框架 web hook 端点,当消息进入我们的脸书页面时,脸书会调用该端点。bot 通道注册页面还提供了验证令牌,脸书使用它来确保连接到正确的 web 钩子。Azure 的 Bot 连接器需要知道脸书应用 ID 和应用秘密,以验证每个传入消息的签名。我们需要页面访问令牌在与页面聊天时向用户发回消息。我们可以在脸书的文档页面中找到更多关于脸书的 SendAPI 和 Messenger Webhooks 的细节: https://developers.facebook.com/docs/messenger-platform/reference/send-api/
和 https://developers.facebook.com/docs/messenger-platform/webhook/
。
一旦所有这些都准备就绪,消息就可以很容易地在脸书和我们的机器人之间传递。虽然脸书有一些独特的概念,如页面访问令牌和 webhook 类型的特定名称,但我们所做的事情背后的总体想法与其他通道类似。通常,我们将在平台上创建一个应用,并在该应用和 Bot 框架端点之间建立联系。将消息转发给我们是 Bot 框架的角色。
部署到 Azure
有很多方法可以将代码部署到 Azure。我们使用的工具 Kudu 允许我们通过 REST API 进行部署。Kudu 也可以配置为从 git repo 或其他位置部署。还有其他工具可以简化部署。如果我们要使用微软的 Visual Studio 或 Visual Studio 代码编写一个机器人,有一些扩展允许我们轻松地将代码部署到 Azure 中。同样,这是一个超出本书范围的话题。为了在 Linux 应用服务上运行 Node.js bot,使用 ZipDeploy REST API 就足够了。
因为我们可以通过使用模拟器在本地开发我们的 bot,并通过运行 ngrok 在各种通道上测试本地 bot,所以在本书的其余部分我们不再部署到 Azure。如有必要,关闭 web 应用实例,这样就不会对订阅收费。确保删除 app 服务计划;简单地停止 web 应用是行不通的。
关键 Bot Builder SDK 概念
通过模拟器和 Facebook Messenger 完成运行机器人的细节感觉很好,但是机器人没有做任何有用的事情!在本节中,我们将深入研究 Node.js 库的 Bot Builder SDK。这是本章剩余部分和下一章的重点。现在,我们将讨论 Bot Builder SDK 的四个基本概念。之后,我们展示了一个日历机器人对话的框架代码,它是基于第三章第一节中 NLU 对路易斯的研究。这个机器人将知道如何与用户谈论许多日历任务,但还不会与任何 API 集成。这是一种常见的方法,用来演示对话流以及它是如何工作的,而不需要经历整个后端集成工作。让我们开始吧。
会话和消息
Session 是一个对象,它表示当前会话以及可以在其上调用的操作。在最基本的层面上,我们可以使用会话对象来发送消息。
const bot = new builder.UniversalBot(connector, [
session => {
// for every message, send back the text prepended by echo:
session.send('echo: ' + session.message.text);
}
]);
邮件可以包括图像、视频、文件和自定义附件类型。图 5-29 显示了结果信息。
图 5-29
发送图像
session => {
session.send({
text: 'hello',
attachments: [{
contentType: 'image/png',
contentUrl: 'https://upload.wikimedia.org/wikipedia/commons/b/ba/New_York-Style_Pizza.png',
name: 'image'
}]
});
}
我们也可以送一张英雄卡。英雄卡是独立的容器,包括图像、标题、副标题、文本以及可选的按钮列表。图 5-30 显示了结果交换。
图 5-30
比萨饼转盘样品
let msg = new builder.Message(session);
msg.text = 'Pizzas!';
msg.attachmentLayout(builder.AttachmentLayout.carousel);
msg.attachments([
new builder.HeroCard(session)
.title('New York Style Pizza')
.subtitle('the best')
.text("Really, the best pizza in the world.")
.images([builder.CardImage.create(session, 'https://upload.wikimedia.org/wikipedia/commons/b/ba/New_York-Style_Pizza.png')])
.buttons([
builder.CardAction.imBack(session, "I love New York Style Pizza!", "LOVE THIS")
]),
new builder.HeroCard(session)
.title('Chicago Style Pizza')
.subtitle('not bad')
.text("some people don't believe this is pizza.")
.images([builder.CardImage.create(session, 'https://upload.wikimedia.org/wikipedia/commons/3/33/Ginoseastdeepdish.jpg')])
.buttons([
builder.CardAction.imBack(session, "I love Chicago Style Pizza!", "LOVE THIS")
]),
]);
session.send(msg);
这个例子引入了一些新概念。英雄卡只是 Bot Builder SDK 支持的一种卡。以下是其他受支持的卡:
-
适配卡:柔性卡,包含容器、按钮、输入域、语音、文本、图像等项目的组合;并非所有频道都支持。我们将在第十一章中深入探讨适应卡。
-
动画卡:支持动画 gif 或短视频的卡。
-
声卡:播放音频的卡。
-
缩略图卡:类似英雄卡,但图像尺寸更小。
-
收据卡:呈现一张收据,包括描述、税、合计等常见行项目。
-
签到卡:发起签到流程的卡片。
-
显卡:播放视频的卡。
另一个有趣的地方是附件布局。默认情况下,附件在垂直列表中发送。我们选择使用 carousel,一个可滚动的水平列表,为用户提供更好的体验。
此代码中的按钮使用 IM Back 操作。这将发送按钮的值字段(“我喜欢纽约风格的披萨!”或者“我爱芝加哥风格的披萨!”)作为当点击“爱这个”按钮时给机器人的文本消息。其他动作类型如下所述。每个消息传递平台对这些类型都有不同级别的支持。
-
回发:和 IM back 一样,但是用户看不到消息。
-
openUrl :在浏览器中打开一个 Url。这可以是桌面上的默认浏览器或应用内的 web 视图。
-
通话:拨打电话号码。
-
downloadFile :下载文件到用户设备。
-
playAudio :播放音频文件。
-
播放视频:播放视频文件。
-
showImage :在图像浏览器中显示图像。
我们还可以使用 Session 对象在支持书面和口头响应的通道中发送语音同意。我们既可以像在 carousel hero card 示例中那样构建一个消息对象,也可以在会话中使用一种方便的方法。以下代码片段中的输入提示告诉用户界面机器人是在等待响应、接受输入还是根本不接受输入。对于有语音助手技能开发背景的开发者来说,就像对于亚马逊的 Alexa 来说,这应该是一个熟悉的概念。
const bot = new builder.UniversalBot(connector, [
session => {
session.say('this is just text that the user will see', 'hello', { inputHint: builder.InputHint.acceptingInput});
}
]);
会话也是帮助我们访问相关用户对话数据的对象。例如,我们可以将用户发送给机器人的最后一条消息存储在会话的privateconversiondata中,并在以后的会话中使用它,如下例所示(图 5-31 ):
图 5-31
存储消息之间的会话数据
session => {
var lastMsg = session.privateConversationData.last;
session.privateConversationData.last = session.message.text;
if(lastMsg) {
session.send(lastMsg);
} else {
session.send('i am memorizing what you are saying');
}
}
Bot Builder SDK 使得在会话对象中存储三种类型的数据变得很容易。
-
privateconversiondata:会话范围内的私有用户数据
-
conversationData :对话的数据,在参与对话的所有用户之间共享
-
userData :用户在一个频道上所有对话的数据
默认情况下,这些对象都存储在内存中,但是我们可以很容易地提供一个替代的存储服务实现。我们将在第六章看到一个例子。
瀑布和提示
一个瀑布是在一个机器人上处理输入消息的一系列函数。通用 Bot 构造函数将一组函数作为参数。这是瀑布。Bot Builder SDK 连续调用每个函数,将上一步的结果传递给当前步骤。这种方法最常见的用途是使用提示符向用户询问更多信息。在下面的代码中,我们使用文本提示,但是 Bot Builder SDK 支持数字、日期或多选等输入(图 5-32 )。
图 5-32
基本瀑布样本
const bot = new builder.UniversalBot(connector, [
session => {
session.send('echo 1: ' + session.message.text);
builder.Prompts.text(session, 'enter for another echo!');
},
(session, results) => {
session.send('echo 2: ' + results.response);
}
]);
我们也可以使用下一个函数手动推进瀑布,在这种情况下,机器人不会等待额外的输入(图 5-33 )。这在第一步可能有条件地要求额外输入的情况下很有用。我们将在日历机器人代码中使用它。
图 5-33
计划性瀑布前进
const bot = new builder.UniversalBot(connector, [
(session, args, next) => {
session.send('echo 1: ' + session.message.text);
next({response: 'again!'});
},
(session, results, next) => {
session.send('echo 2: ' + results.response);
}
]);
下面是一个更复杂的数据收集瀑布:
const bot = new builder.UniversalBot(connector, [
session => {
builder.Prompts.choice(session, "What do you want to do?", "add appointment|delete appointment", builder.ListStyle.button);
},
(session, results) => {
session.privateConversationData.action = { type: results.response.index };
builder.Prompts.time(session, "when?");
},
(session, results, next) => {
session.privateConversationData.action.datetime = results.response.resolution.start;
if (session.privateConversationData.action.type == 0) {
builder.Prompts.text(session, "where?");
} else {
next({ response: null });
}
},
(session, results, next) => {
session.privateConversationData.action.location = results.response;
let summary = null;
const dt = moment(session.privateConversationData.action.datetime).format('M/D/YYYY h:mm:ss a');
if (session.privateConversationData.action.type == 0) {
summary = 'Add Appointment ' + dt + ' at location ' + session.privateConversationData.action.location;
} else {
summary = 'Delete appointment ' + dt;
}
const action = session.privateConversationData.action;
// do something with action
session.endConversation(summary);
}
]);
在这个示例中,我们使用了更多类型的提示:选择和时间。选择提示要求用户选择一个选项。提示可以使用内嵌文本(例如,与 SMS 场景相关)或按钮来呈现选择。时间提示使用 chronos Node.js 库将日期时间的字符串表示解析为日期时间对象。像“明天下午 5 点”这样的输入可以解析为计算机可以使用的值。
注意,我们使用逻辑来跳过某些瀑布步骤。具体来说,如果我们在删除约会分支中,我们不需要事件位置。因此,我们甚至不要求它。我们利用privateconversiondata对象来存储动作对象,该对象表示我们想要针对 API 调用的操作。最后,我们使用 session.endConversation 方法来结束对话。这个方法将清除用户的状态,这样下次用户与机器人交互时,机器人就好像看到了一个新用户。
图 5-34 显示了结果对话。
图 5-34
数据收集瀑布
对话
让我们用对话式设计来实现这个完整的循环。在第四章中,我们讨论了如何使用我们称之为对话的 Node 图来建立对话模型。到目前为止,在这一章中,我们已经学习了瀑布,以及如何用代码建立对话模型。
我们还学会了如何利用提示从用户那里收集数据。回想一下,提示是从用户那里收集数据的简单机制。
builder.Prompts.text(session, "where?");
提示很有意思。我们称函数为( builder。Prompts.text ),将对话转换为提示。一旦用户发送了有效的响应,瀑布中的下一步就可以访问提示的结果。图 5-35 显示了整个过程。从我们的瀑布的角度来看,我们并不真正知道 Prompts.choice 调用在做什么,我们也不关心。它监听用户输入,进行一些验证,重新提示错误的输入,并且只返回一个有效的结果,除非用户取消。所有这些逻辑对我们来说都是隐藏的。
图 5-35
对话之间概念上的控制转移
这种交互与编程函数调用的模型相同。通常实现函数调用的方式是使用堆栈。检查图 5-36 和以下代码:
function f(a,b) { return a + b; }
当函数 f 被调用时,函数的参数被压入栈顶。然后,该函数的代码处理堆栈。在此示例中,函数添加参数。最后,留在堆栈顶部的唯一值是函数的返回值。然后,调用函数可以对返回值做任何它想做的事情。
图 5-36
堆栈上的函数调用
这是对话中提示的工作方式。Bot Builder SDK 中的一般概念是一个对话框。提示是一种对话框。对话只不过是对对话逻辑的封装,类似于函数调用。用一些参数初始化一个对话框。它接收来自用户的输入,执行自己的代码或调用其他对话框,并可以向用户发送响应。一旦对话框的目的实现了,它就向调用对话框返回值。简而言之,调用对话框将子对话框推到堆栈的顶部。当子对话框完成时,它会从堆栈中弹出自己。
让我们回到我们选择提示的例子。在对话框堆栈模型中,根对话框放置提示。选择堆栈顶部的对话框。在对话框完成执行后,产生的用户输入对象被向下传递回根对话框。然后根对话框对结果对象做任何它需要做的事情。图 5-37 记录了随时间变化的行为。
图 5-37
一段时间内对话框堆栈上的对话框
我们可以进一步发展这个概念。我们可以想象日历机器人中的一个流程,其中添加一个新的日历条目会调用一个新的对话框。姑且称之为 AddCalendarEntry 。然后它会调用一个提示。Time 对话框收集事件的日期和时间,并调用一个提示。Text 对话框收集事件的主题。AddCalendarEntry 打包收集的数据,并通过调用一些日历 API 创建一个新的日历条目。控制权然后返回到根对话框。我们在图 5-32 中对此进行了说明。我们甚至可以让 AddCalendarEntry 调用另一个对话框,该对话框封装了调用 API 的逻辑,如果该过程足够复杂,并且我们想要重用来自其他对话框的逻辑(图 5-38 )。
图 5-38
一个更复杂的对话框堆栈,随着时间的推移而显示
瀑布和对话框是将对话设计转化为实际工作代码的主力。当然,还有更多的细节,我们将在下一章中讨论,但这是 Bot Builder SDK 背后的魔力。它的关键价值是一个引擎,可以使用对话抽象来驱动对话。在对话过程中的每一点,都会存储对话堆栈以及支持用户和对话数据。这意味着,根据对话的存储实现,用户可能会停止与机器人交谈几天,然后回来,机器人可以从用户停止的地方继续。
我们如何应用这些概念?重新查看添加和删除约会瀑布示例,我们可以创建一个 bot,它根据选择提示启动两个对话框中的一个:一个添加日历条目,另一个删除它。这些对话框有所有必要的逻辑来判断添加或删除哪个约会、解决冲突、提示用户确认等等。
const bot = new builder.UniversalBot(connector, [
session => {
builder.Prompts.choice(session, "What do you want to do?", "add appointment|delete appointment", builder.ListStyle.button);
},
(session, results) => {
if (results.response.index == 0) {
session.beginDialog('AddCalendarEntry');
} else if (results.response.index == 1) {
session.beginDialog('RemoveCalendarEntry');
}
},
(session, results) => {
session.send('excellent! we are done!');
}
]);
bot.dialog('AddCalendarEntry', [
(session, args) => {
builder.Prompts.time(session, 'When should the appointment be added?');
},
(session, results) => {
session.dialogData.time = results.response.resolution.start;
builder.Prompts.text(session, 'What is the meeting subject?');
},
(session, results) => {
session.dialogData.subject = results.response;
builder.Prompts.text(session, 'Where should the meeting take place?');
},
(session, results) => {
session.dialogData.location = results.response;
// TODO: take the data and call an API to add the calendar entry
session.endDialog('Your appointment has been added!');
}]);
bot.dialog('RemoveCalendarEntry', [
(session, args) => {
builder.Prompts.time(session, 'Which time do you want to clear?');
},
(session, results) => {
var time = results.response.resolution.start;
// TODO: find the relevant appointment, resolve conflicts, confirm prompt, and delete
session.endDialog('Your appointment has been removed!');
}]);
我们通过调用 session.beginDialog 方法启动一个新的对话框,并传入对话框名称。我们还可以传递一个可选的参数对象,它可以通过被调用对话框中的 args 参数来访问。我们使用 session.dialogData 对象来存储对话状态。我们之前遇到过用户数据、私有会话数据和会话数据。这些都是整个对话的范围。然而,DialogData 的作用范围仅限于当前对话框实例的生存期。为了结束对话,我们调用 session.endDialog 。这将控制返回到根瀑布的下一步。有一个名为 session.endDialogWithResult 的方法允许我们将数据传递回调用对话框。
Messenger 中的对话最终看起来如图 5-39 所示。
图 5-39
AddCalendarEntry 对话框实现的演示
这段代码有一些缺点。首先,如果我们想取消添加或删除约会,没有办法做到这一点。第二,如果我们正在添加一个约会,并决定要删除一个约会,我们不能轻易切换到删除约会对话框。我们必须完成当前对话,然后切换。第三,但不是必不可少的,将机器人连接到我们的 LUIS 模型会很好,这样用户就可以使用自然语言与机器人进行交互。我们接下来将解决前两点,然后连接到我们的 LUIS 模型,以真正在机器人中建立一些智能。
调用对话框
让我们继续下面的练习。假设我们希望允许用户在对话的任何时候寻求帮助;这是一个典型的场景。有时,帮助会与对话框相关联。在其他时候,帮助将是一个全局操作,一个可以从对话中的任何地方访问的机器人行为。Bot Builder SDK 允许我们在对话框中插入这两种类型的行为。
我们引入一个简单的帮助对话框。
bot.dialog('help', (session, args, next) => {
session.endDialog("Hi, I am a calendar concierge bot. I can help you make and cancel appointments!");
})
.triggerAction({
matches: /^help$/i
});
这段代码定义了一个新的对话框,它带有一个与“help”输入相匹配的全局操作处理程序。 TriggerAction 定义一个全局动作。我们说,只要用户的输入与正则表达式^help字符表示一行的结束。然而,一个问题出现了。正如我们在图 5-40 中看到的,看起来好像当我们寻求帮助时,我们的机器人忘记了我们在添加约会对话框中。事实上,全局动作匹配的默认行为是替换堆栈顶部的对话框。换句话说,添加约会对话框被移除,并被帮助对话框所取代。
图 5-40
帮助取消上一个对话框。不好。
我们可以通过实现 onSelectAction 回调来覆盖这种行为。
bot.dialog('help', (session, args, next) => {
session.endDialog("Hi, I am a calendar concierge bot. I can help you make and cancel appointments!");
})
.triggerAction({
matches: /^help$/i,
onSelectAction: (session, args, next) => {
session.beginDialog(args.action, args);
}
});
这带来了一个有趣的问题:我们如何影响对话框堆栈?当我们正在处理一个对话流,并且想要将控制转移到另一个对话时,我们可以使用 beginDialog 或者 replaceDialog 。replaceDialog 替换堆栈顶部的对话框,beginDialog 将一个对话框推到堆栈顶部。该会话还有一个名为 reset 的方法,用于重置整个对话框堆栈。默认行为是重置堆栈并将新对话框推至顶部。
如果我们想包含上下文帮助呢?让我们创建一个新的对话框来处理添加日历条目对话框的帮助。我们可以在一个对话框上使用 beginDialogAction 方法来定义在 AddCalendarEntry 对话框上启动新对话框的触发器。
bot.dialog('AddCalendarEntry', [
...
])
.beginDialogAction('AddCalendarEntryHelp', 'AddCalendarEntryHelp', { matches: /^help$/ });
bot.dialog('AddCalendarEntryHelp', (session, args, next) => {
let msg = "Add Calendar Entry Help: we need the time of the meeting, the subject and the location to create a new appointment for you.";
session.endDialog(msg);
});
当我们运行这个时,我们得到了想要的效果,如图 5-41 所示。
图 5-41
正确处理上下文动作
我们将在下一章更深入地探讨动作和它们的行为。
识别器
回想一下,我们定义了帮助对话框将通过正则表达式触发。Bot Builder SDK 如何实现这一点?这就是识别器的用武之地。识别器是一段代码,它接受传入的消息并确定用户的意图。识别器返回意图名称和分数。意图和得分可以来自像路易斯这样的 NLU 服务,但他们不一定要这样做。
默认情况下,如前面的例子所示,我们的 bot 中唯一的识别器是正则表达式或纯文本匹配器。它接受一个正则表达式或硬编码的字符串,并将其与传入消息的文本进行匹配。我们可以通过向 bot 的识别器列表中添加一个 RegExpRecognizer 来使用这个识别器的显式版本。下面的实现声明,如果用户的输入与提供的正则表达式匹配,则名为 HelpIntent 的意图以 1.0 的分数被解析。否则得分为 0.0。
bot.recognizer(new builder.RegExpRecognizer('HelpIntent', /^help$/i));
bot.dialog('help', (session, args, next) => {
session.endDialog("Hi, I am a calendar concierge bot. I can help you make and cancel appointments!");
})
.triggerAction({
matches: 'HelpIntent',
onSelectAction: (session, args, next) => {
session.beginDialog(args.action, args);
}
});
识别器模型允许我们做的另一件事是创建一个定制的识别器,它执行我们想要的任何代码,并用一个分数来解析一个意图。这里有一个例子:
bot.recognizer({
recognize: (context, done) => {
var intent = { score: 0.0 };
if (context.message.text) {
if (context.message.text.toLowerCase().startsWith('help')) intent = { score: 1.0, intent: 'HelpIntent' };
}
done(null, intent);
}
});
这是一个非常简单的例子,但是我们的思维应该与可能性赛跑。例如,如果用户的输入是非文本媒体,如图像或视频,我们可以编写一个自定义识别器来验证媒体并做出相应的响应。
Bot Builder SDK 允许我们向 Bot 注册多个识别器。每当有消息进入机器人时,都会调用每个识别器,得分最高的识别器被认为是获胜者。如果两个或更多识别器得到相同的分数,首先注册的识别器获胜。
最后,同样的机制可以用来将我们的 bot 连接到 LUIS,事实上 Bot Builder SDK 包含了一个识别器。为此,我们获取 LUIS 应用的端点 URL(可能是我们在第三章中创建的那个)并将其用作 LuisRecognizer 的参数。
bot.recognizer(new builder.LuisRecognizer('https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/{APP_ID}?subscription-key={SUBSCRIPTION_KEY}}'));
一旦我们设置好了,我们就为我们想要全局处理的每个意图添加一个 triggerAction 调用,就像我们在帮助对话框中做的那样。作为“matches”成员传递的字符串必须对应于我们的 LUIS intent 名称。
bot.dialog('AddCalendarEntry', [
...
])
.beginDialogAction('AddCalendarEntryHelp', 'AddCalendarEntryHelp', { matches: /^help$/ })
.triggerAction({matches: 'AddCalendarEntry'});
bot.dialog('RemoveCalendarEntry', [
...
])
.triggerAction({matches: 'DeleteCalendarEntry'});
此时,我们的机器人对话可以通过使用 LUIS intents 在对话框之间导航(图 5-42 )。LUIS 的意图和实体对象被传递到对话框中。
图 5-42
最终由我们的 LUIS 车型提供动力!
练习 5-2
将你的机器人连接到路易斯
在本任务中,您将把一个机器人连接到您在第三章中创建的 LUIS 应用。
-
创建一个空机器人,并创建一个对话框来处理在第三章中创建的每种类型的意图。对于每个对话,只需发送一条带有对话名称的消息。
-
向您的机器人注册 LUIS 识别器,并确认其工作正常。
-
每个对话瀑布的第一个方法是传递会话对象和一个 args 对象。使用调试器浏览对象。 5 来自路易斯的数据是什么结构?或者,将代表 args 对象的 JSON 字符串发送给用户。
识别器是 Bot Builder SDK 中的一个强大功能,它允许我们基于传入的消息为我们的 Bot 配备各种行为。
构建一个简单的日历机器人
理想情况下,我们构建对话的模式变得越来越清晰。本书提供的 git repos 包括一个日历礼宾机器人,我们在本书的剩余章节中构建了这个机器人。对 bot 进行更改的每个章节在 repo 中都有自己的文件夹。Chapter 5 文件夹包括与 LUIS 集成的框架代码,并发回一条消息,说明机器人理解了什么。Auth 和 API 集成将在第七章中介绍。我们在第十章中添加了基本的多语言支持,在第十二章中添加了人工交接,在第十三章中添加了分析集成。
这些是我们打算在第五章回答的一些问题:
-
在 Node 的上下文中,我们如何构造一个 bot 和它的组件对话框?
-
解释传入对话框的数据的代码的一般模式是什么?
-
尽管可以使用 Bot Builder SDK 创建端到端测试,但就其当前形式而言,单元测试对话逻辑并不是最直接的任务。我们如何构建我们的代码,以便尽可能好地进行单元测试?
当我们深入代码并检查不同的组件时,请记住以下几点:
-
随着代码的构建和测试,我们会发现 LUIS 应用中存在一些漏洞。在这段代码的构建过程中,我的模型与第三章中产生的模型有了一点变化。这些不是突破性的变化,而是新的话语和实体。代码示例包括这个版本的模型。
-
我们需要定义每个对话的范围。例如,“编辑日历条目”对话框旨在关注移动约会。
-
我们创建了一些助手类,包含一些最复杂的逻辑,即从 LUIS 结果中读取每种类型的实体,并将它们转换成可以在对话框中使用的对象。例如,我们的许多对话框基于日期时间或范围以及主题或被邀请者在日历上执行操作。
我们利用 Bot Builder 库将对话框适当地模块化到库中。暂时不要担心这个。这只是捆绑对话框功能的一种方式。我们将在下一章讨论这个概念。开始回顾代码,我们将在下一章深入更多的 Bot Builder 细节。该代码的结构如下:
-
常量和助手
-
将 LUIS 意图和实体转换成应用对象的代码
-
支持添加、移动和删除约会的对话框;检查可用性;并获得当天的日程安排
-
最后,一个 app.js 入口点将所有这些联系在一起
结论
这是对 Bot 框架和 Bot Builder SDK 的介绍。我们现在可以构建基本的机器人体验了。创建 bot 通道注册、将我们的 Bot 连接到通道连接器、使用 Bot 框架仿真器和 ngrok 进行调试以及使用 Bot Builder SDK 构建 Bot 的核心概念是我们需要理解的关键部分,以便提高工作效率。Bot Builder SDK 是一个强大的库,可以在这个过程中帮助我们。我们介绍了 SDK 的核心概念。在不深入 SDK 的细节的情况下,我们开发了一个聊天机器人,它可以解释各种各样的自然语言输入,执行我们旨在支持第三章中的用例。剩下唯一要做的事情就是引入一个日历 API,并将 LUIS 意图和实体组合转换成正确的 API 调用。
在我们开始之前,我们将更深入地研究 Bot Builder SDK,以确保我们在最终实现中选择了正确的方法。
六、深入研究 Bot Builder SDK
在前一章中,我们构建了一个简单的 bot,它可以利用现有的 LUIS 应用和 Bot Builder SDK 来实现日历 Bot 的对话流。就目前情况来看,这个机器人毫无用处。它用描述它从用户输入中理解的内容的文本来响应,但是它没有完成任何实质性的事情。我们正在将我们的 bot 连接到 Google Calendar API,但与此同时,我们需要弄清楚 Bot Builder SDK 为我们提供了哪些工具来创建有意义的对话体验。
在本章中,我们将详细阐述我们在第五章代码中使用的一些技术,并更彻底地探索 Bot Builder SDK 的一些特性。我们将弄清楚 SDK 如何存储状态、构建具有丰富内容的消息、构建动作和卡片,并允许框架定制通道行为、对话行为和用户动作处理。最后,我们将看看如何最好地将机器人功能组合成可重用的组件。
对话状态
正如前面几章所提到的,一个好的对话引擎会存储每个用户和对话的状态,这样每当用户与机器人通信时,都会检索到对话流的正确状态,从而为用户提供一致的体验。在 Bot Builder SDK 中,默认情况下,这种状态通过恰当命名的 MemoryBotStorage 存储在内存中。历史上,状态存储在云端点中;然而,这已被否决。有时,我们可能会在一些旧文档中遇到对 state service 的引用,所以请注意它已经不存在了。
每个对话的状态由 bot 开发人员可以访问的三个桶组成。我们在前一章中介绍了所有这些方法,但要重申的是,它们如下:
-
userData :一个用户在一个频道的所有对话中的数据
-
privateconversiondata:会话范围内的私有用户数据
-
conversationData :会话的数据,由参与会话的任何用户共享
此外,当一个对话框正在执行时,我们可以访问它的状态对象,称为 dialogData 。每当收到来自用户的消息时,Bot Builder SDK 将从状态存储中检索用户的状态,在会话对象上填充三个数据对象和 dialogData,并执行对话中当前步骤的逻辑。一旦所有的响应都被发送出去,框架将把状态保存回状态存储器。
let entry = new et.EntityTranslator(session.dialogData.addEntry);
if (!entry.hasDateTime) {
entry.setEntity(results.response);
}
session.dialogData.addEntry = entry;
在前一章的一些代码中,有些情况下我们必须从 dialogData 重新创建一个自定义对象,然后将该对象存储到 dialogData 中。这样做的原因是,将对象保存到 dialogData(或任何其他状态容器)中会将对象转换为普通的 JavaScript 对象,就像使用 JSON.stringify 一样。在重置为新对象之前,尝试调用前面代码中 session.dialogData.addEntry 上的任何方法都会导致错误。
存储机制是由一个名为 IBotStorage 的接口实现的。
export interface IBotStorage {
getData(context: IBotStorageContext, callback: (err: Error, data: IBotStorageData) => void): void;
saveData(context: IBotStorageContext, data: IBotStorageData, callback?: (err: Error) => void): void;
}
我们在构建新的 bot 实例时实例化的 ChatConnector 类会安装默认的 MemoryBotStorage 实例,这对于开发来说是一个很好的选择。SDK 允许我们提供自己的实现来替换默认功能,这是您在生产部署中最想做的事情,因为这可以确保在实例重新启动时存储状态,而不是删除状态。例如,微软提供了两个额外的接口实现,一个是 Azure Cosmos DB 1 的 NoSQL 实现,另一个是 Azure Table Storage 的实现。 2 两者都是可以通过 Azure 门户获得的 Azure 服务。你可以在 botbuilder-azure Node 包中找到这两个存储实现,记录在 https://github.com/Microsoft/BotBuilder-Azure
。您还可以编写自己的 IBotStorage 实现,并将其注册到 SDK。编写自己的实现就是遵循简单的 IBotStorage 接口。
const bot = new builder.UniversalBot(connector, (session) => {
// ... Bot code ...
})
.set('storage', storageImplementation);
信息
在前一章中,我们的机器人通过使用 session.send 或 session.endDialog 方法发送文本消息来与用户通信。这很好,但它限制了我们的机器人相当数量。机器人和用户之间的消息由我们在前一章的“机器人构建器 SDK 基础”一节中遇到的各种数据组成。
Bot Builder IMessage 接口定义了消息的真正组成。
interface IEvent {
type: string;
address: IAddress;
agent?: string;
source?: string;
sourceEvent?: any;
user?: IIdentity;
}
interface IMessage extends IEvent {
timestamp?: string; // UTC Time when message was sent (set by service)
localTimestamp?: string; // Local time when message was sent (set by client or bot, Ex: 2016-09-23T13:07:49.4714686-07:00)
summary?: string; // Text to be displayed by as fall-back and as short description of the message content in e.g. list of recent conversations
text?: string; // Message text
speak?: string; // Spoken message as Speech Synthesis Markup Language (SSML)
textLocale?: string; // Identified language of the message text.
attachments?: IAttachment[]; // This is placeholder for structured objects attached to this message
suggestedActions: ISuggestedActions; // Quick reply actions that can be suggested as part of the message
entities?: any[]; // This property is intended to keep structured data objects intended for Client application e.g.: Contacts, Reservation, Booking, Tickets. Structure of these object objects should be known to Client application.
textFormat?: string; // Format of text fields [plain|markdown|xml] default:markdown
attachmentLayout?: string; // AttachmentLayout - hint for how to deal with multiple attachments Values: [list|carousel] default:list
inputHint?: string; // Hint for clients to indicate if the bot is waiting for input or not.
value?: any; // Open-ended value.
name?: string; // Name of the operation to invoke or the name of the event.
relatesTo?: IAddress; // Reference to another conversation or message.
code?: string; // Code indicating why the conversation has ended.
}
对于这一章,我们将对文本、附件、建议动作和附件布局最感兴趣,因为它们构成了一个好的对话式 UX 的基础。
为了用代码创建一个消息对象,我们创建一个生成器。消息对象。此时,您可以按照下面的示例分配属性。然后可以将消息传递到 session.send 方法中。
const reply = new builder.Message(session)
.text('Here are some results for you')
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments(cards);
session.send(reply);
同样,当消息进入您的 bot 时,会话对象包含一个消息对象。同样的界面。相同类型的数据。但是,这一次,它来自通道,而不是来自机器人。
const bot = new builder.UniversalBot(connector, [
(session) => {
const input = session.message.text;
}]);
请注意,IMessage 继承自 IEvent,这意味着它有一个类型字段。该字段被设置为 IMessage 的消息,但也有其他事件可能来自框架或自定义应用。
基于通道支持,bot 框架支持的其他一些事件类型如下:
-
conversationUpdate :在对话中添加或删除用户,或者对话的某些元数据发生变化时引发;用于群聊管理。
-
contactRelationUpdate :在用户的联系人列表中添加或删除机器人时引发。
-
输入:用户输入消息时引发;并非所有频道都支持。
-
ping :判断 bot 端点是否可用。
-
deleteUserData :当用户请求删除他们的用户数据时引发。
-
endOfConversation :当对话结束时引发。
-
invoke :当请求机器人执行一些自定义逻辑时引发。例如,一些通道可能需要调用机器人上的一个函数并期待响应。Bot 框架将把这个请求作为 invoke 请求发送,期待一个同步的 HTTP 回复。这种情况并不常见。
我们可以通过使用 UniversalBot 上的 on 方法为每种事件类型注册一个处理程序。与处理事件的机器人的对话可以为您的用户提供更身临其境的对话体验(图 6-1 )。
图 6-1
响应输入和对话更新事件的机器人
const bot = new builder.UniversalBot(connector, [
(session) => {
}
]);
bot.on('conversationUpdate', (data) => {
if (data.membersAdded && data.membersAdded.length > 0) {
if (data.address.bot.id === data.membersAdded[0].id) return;
const name = data.membersAdded[0].name;
const msg = new builder.Message().address(data.address);
msg.text('Welcome to the conversation ' + name + '!');
msg.textLocale('en-US');
bot.send(msg);
}
});
bot.on('typing', (data) => {
const msg = new builder.Message().address(data.address);
msg.text('I see you typing... You\'ve got me hooked! Reel me in!');
msg.textLocale('en-US');
bot.send(msg);
});
地址和主动信息
在消息接口中,address 属性唯一地表示对话中的用户。看起来是这样的:
interface IAddress {
channelId: string; // Unique identifier for channel
user: IIdentity; // User that sent or should receive the message
bot?: IIdentity; // Bot that either received or is sending the message
conversation?: IIdentity; // Represents the current conversation and tracks where replies should be routed to.
}
地址背后的重要性在于,我们可以使用它在对话范围之外主动发送消息。例如,我们可以创建一个进程,每五秒钟向一个随机地址发送一条消息。这个消息对用户的对话框堆栈没有任何影响。
const addresses = {};
const bot = new builder.UniversalBot(connector, [
(session) => {
const userid = session.message.address.user.id;
addresses[userid] = session.message.address;
session.send('Give me a couple of seconds');
}
]);
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
setInterval(() => {
const keys = Object.keys(addresses);
if (keys.length == 0) return;
const r = getRandomInt(0, keys.length-1);
const addr = addresses[keys[r]];
const msg = new builder.Message().address(addr).text('hello from outside dialog stack!');
bot.send(msg);
}, 5000);
如果我们确实想修改对话框堆栈,也许通过调用一个复杂的对话框操作,我们可以在 UniversalBot 对象上使用 beginDialog 方法。
setInterval(() => {
var keys = Object.keys(addresses);
if (keys.length == 0) return;
var r = getRandomInt(0, keys.length-1);
var addr = addresses[keys[r]];
bot.beginDialog(addr, "dialogname", { arg: true});
}, 5000);
这些概念的意义在于,我们可以让不同系统中的外部事件开始影响用户在机器人内部的对话状态。在下一章中,我们将看到 OAuth web 钩子的应用。
丰富的内容
可以使用 BotBuilder IMessage 界面中的附件功能向用户发送丰富的内容。在 Bot Builder SDK 中,附件只是一个名称、内容 URL 和 MIME 类型。3Bot Builder SDK 中的一条消息接受零个或多个附件。由 bot 连接器将消息翻译成通道能够理解的内容。并非每个频道都支持所有类型的邮件和附件。创建各种 MIME 类型的附件时要小心。
例如,要共享一个图像,我们可以使用下面的代码:
const bot = new builder.UniversalBot(connector, [
(session) => {
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'
}
]
})
}
]);
图 6-2 显示了模拟器中的用户界面,图 6-3 显示了 Facebook Messenger 中的用户界面。我们可以想象在其他平台上类似的渲染。
图 6-3
Facebook Messenger 图片附件
图 6-2
模拟器图像附件
这段代码将发送音频文件附件,可以在消息通道中直接播放。
const bot = new builder.UniversalBot(connector, [
(session) => {
session.send({
text: "Here, have some sound!",
attachments: [
{
contentType: 'audio/ogg',
contentUrl: 'https://upload.wikimedia.org/wikipedia/en/f/f4/Free_as_a_Bird_%28Beatles_song_-_sample%29.ogg',
name: 'Free as a bird'
}
]
})
}
]);
图 6-4 显示的是模拟器,图 6-5 显示的是 Facebook Messenger()。
图 6-5
Facebook Messenger 中的 OGG 声音文件附件
图 6-4
模拟器中的 OGG 声音文件附件
呜呜!好像不支持 OGG 4 文件。这是一个很好的例子,说明了当我们的机器人向脸书或任何其他通道发送无效消息时,机器人框架的行为。我们将在本章后面的“通道错误”部分对此进行进一步的研究。我的控制台错误日志包含以下消息:
Error: Request to 'https://facebook.botframework.com/v3/conversations/1912213132125901-1946375382318514/activities/mid.%24cAAbqN9VFI95k_ueUOVezaJiLWZXe' failed: [400] Bad Request
如果我们查看 Bot Framework Messenger Channels 页面中的错误列表,我们应该会发现如图 6-6 所示的另一条线索。
图 6-6
Messenger 上 OGG 声音文件的 Bot 框架错误
好了,它们让诊断问题变得有些容易。我们知道我们必须提供不同的文件格式。让我们试试 MP3。
const bot = new builder.UniversalBot(connector, [
(session) => {
session.send({
text: "Ok have a vulture instead!",
attachments: [
{
contentType: 'audio/mp3',
contentUrl: 'http://static1.grsites.com/archive/sounds/birds/birds004.mp3',
name: 'Vulture'
}
]
})
}
]);
你可以在图 6-7 和图 6-8 中看到模拟器和 Facebook Messenger 的渲染结果。
图 6-8
Facebook Messenger MP3 文件附件
图 6-7
模拟器 MP3 文件附件
模拟器仍然产生一个链接,但 Messenger 有一个内置的音频播放器,你可以利用!上传视频的体验也是类似的。Messenger 将在对话中提供内置的视频播放器。
练习 6-1
试验附件
这个练习的目标是编写一个简单的机器人,它可以向用户发送不同类型的附件,并观察模拟器和另一个通道(如 Facebook Messenger)的行为。
-
使用 echo bot 作为起点创建一个基本 bot。
-
通过 bot 功能,在消息中发送不同类型的附件,如 JSON、XML 或 file。尝试一些类型的富媒体,如视频。模拟器如何呈现这些类型的附件?Messenger 怎么样?
-
尝试从模拟器向机器人发送图像。传入的消息包含什么数据?这与您通过 Messenger 发送图像有什么不同吗?
附件是与用户共享各种丰富内容的一种简单方式。明智地使用它们来创造丰富多彩、引人入胜的对话体验。
小跟班
机器人还可以向用户发送按钮。按钮是用户执行任务的一个独特的行动号召。每个按钮都有一个与之关联的标签,以及一个值。按钮也有一个动作类型,它将决定当按钮被点击时按钮如何处理这个值。三种最常见的操作类型是打开 URL、回发和 IM back。Open URL 通常在消息应用中打开一个 web 视图,或者在桌面设置中打开一个新的浏览器窗口。post back 和 IM back 都将按钮的值作为消息发送给机器人。两者的区别在于,点击回发不应该在聊天记录中显示来自用户的消息,而 IM 回发应该显示。并非所有通道都实现这两种类型的按钮。
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const card = new builder.HeroCard(session).buttons(cardActions);
const msg = new builder.Message(session).text("sample actions").addAttachment(card);
session.send(msg);
}
]);
注意,在前面的代码中,我们使用了一个 CardAction 对象。CardAction 是我们前面讨论过的数据的封装:动作类型、标题和值。通道连接器通常会将一个动作呈现到单个平台上的一个按钮中。
图 6-9 显示了在模拟器中运行这段代码的样子,图 6-10 显示了它在 Facebook Messenger 中的样子。如果我们点击模拟器中的 Open Google 按钮,它会在默认浏览器中打开网页。我们首先点击 Im Back,然后一旦收到回复卡,我们就点击 Post Back。请注意,Im Back 发送了一条消息,该消息出现在聊天历史中,而 Post Back 按钮发送了一条机器人会响应的消息,但该消息不会出现在聊天历史中。
图 6-9
模拟器中 Bot Builder 按钮行为的示例
Messenger 的工作方式略有不同。 5 我们来看看手机 app 行为。如果我们点击打开谷歌,一个网页视图将会出现,覆盖了大约 90%的屏幕。如果我们点击 Im Back 和 Post Back,应用会显示相同的行为。Messenger 仅支持回发;此外,消息值永远不会显示给用户。聊天记录只包含被点击按钮的标题。
图 6-10
Facebook Messenger 中的按钮行为示例
Bot Builder SDK 支持以下操作类型:
-
openUrl :在浏览器中打开一个 Url
-
imBack :从用户向机器人发送一条消息,所有对话参与者都可以看到这条消息
-
回发:从用户向机器人发送一条消息,这条消息可能对所有对话参与者都不可见
-
通话:拨打电话
-
playAudio :在 bot 界面中播放音频文件
-
playVideo :在 bot 界面中播放视频文件
-
showImage :显示机器人界面内的图像
-
下载文件:下载文件到设备
-
登录:启动 OAuth 流程
当然,并不是所有通道都支持所有类型。此外,通道本身可能支持 Bot Builder SDK 不支持的其他功能。例如,图 6-11 显示了截至本文撰写时 Messenger 通过其按钮模板支持的操作的文档。在本章的后面,我们将研究如何利用本机通道功能。
图 6-11
Messenger 按钮模板类型
在 Bot Builder SDK 中,可以通过使用 card action 类中的静态工厂方法来创建每个卡片动作。以下是来自 Bot Builder 源代码的相关代码:
CardAction.call = function (session, number, title) {
return new CardAction(session).type('call').value(number).title(title || "Click to call");
};
CardAction.openUrl = function (session, url, title) {
return new CardAction(session).type('openUrl').value(url).title(title || "Click to open website in your browser");
};
CardAction.openApp = function (session, url, title) {
return new CardAction(session).type('openApp').value(url).title(title || "Click to open website in a webview");
};
CardAction.imBack = function (session, msg, title) {
return new CardAction(session).type('imBack').value(msg).title(title || "Click to send response to bot");
};
CardAction.postBack = function (session, msg, title) {
return new CardAction(session).type('postBack').value(msg).title(title || "Click to send response to bot");
};
CardAction.playAudio = function (session, url, title) {
return new CardAction(session).type('playAudio').value(url).title(title || "Click to play audio file");
};
CardAction.playVideo = function (session, url, title) {
return new CardAction(session).type('playVideo').value(url).title(title || "Click to play video");
};
CardAction.showImage = function (session, url, title) {
return new CardAction(session).type('showImage').value(url).title(title || "Click to view image");
};
CardAction.downloadFile = function (session, url, title) {
return new CardAction(session).type('downloadFile').value(url).title(title || "Click to download file");
};
信用卡
另一种类型的机器人建设者附件是英雄卡。在我们之前的按钮动作的例子中,我们忽略了按钮动作需要成为英雄卡对象的一部分,但是那是什么呢?
英雄卡一词源于赛车界。卡片本身通常比棒球卡要大,旨在宣传比赛团队,特别是车手和赞助商。它包括照片、关于司机和赞助商的信息、联系信息等等。但实际上这个概念让人想起典型的棒球或神奇宝贝卡片。
在 UX 设计中,卡片是展示图像、文本和动作的一种有组织的方式。当谷歌在 Android 和网络上向世界介绍其材料设计 6 时,它给大众带来了卡片。图 6-12 显示了来自谷歌材料设计文档的两个卡片设计示例。注意图像、标题、副标题和行动号召的不同用法。
图 6-12
谷歌的材料设计卡样本
在机器人的上下文中,术语英雄卡指的是一组带有文本、动作按钮和可选默认点击行为的图像。不同的通道会叫卡不同的东西。脸书称它们为模板。其他平台只是将这种想法称为将内容附加到消息中。归根结底,UX 的概念是一样的。
在 Bot Builder SDK 中,我们可以使用以下代码创建一个卡。我们还展示了这张卡片如何在模拟器中呈现(图 6-13 )和在 Facebook Messenger 上呈现(图 6-14 )。
图 6-14
Facebook Messenger 中的相同英雄卡
图 6-13
由模拟器渲染的英雄卡
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const card = new builder.HeroCard(session)
.buttons(cardActions)
.text('this is some text')
.title('card title')
.subtitle('card subtitle')
.images([new builder.CardImage(session).url("https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png").toImage()])
.tap(builder.CardAction.openUrl(session, "http://dev.botframework.com"));
const msg = new builder.Message(session).text("sample actions").addAttachment(card);
session.send(msg);
}
]);
卡片是传达用户调用的机器人操作结果的一种很好的方式。如果你想用图像和后续行动显示一些数据,没有比使用卡片更好的方法了。事实上,您得到的只是几个不同的文本字段,具有有限的格式化能力,这意味着这种方法产生的 UX 可能有点有限。这是有意的。对于更复杂的可视化和场景,您可以利用自适应卡或渲染自定义图形。我们将在第十一章探讨这两个主题。
下一个问题是,我们能以旋转木马的方式并排展示卡片吗?当然可以。Bot Builder SDK 中的消息有一个名为 attachmentLayout 的属性。我们将此设置为 carousel,添加更多的卡,我们就完成了!模拟器(图 6-15 )和 Facebook Messenger(图 6-16 )负责以一种友好的转盘格式将卡片展开。默认的附件布局是一个列表。使用这种布局,卡片会一张一张地出现。这不是最用户友好的方法。
图 6-16
信使上同样的英雄卡旋转木马
图 6-15
模拟器中的英雄卡转盘
const bot = new builder.UniversalBot(connector, [
(session) => {
const cardActions = [
builder.CardAction.openUrl(session, 'http://google.com', "Open Google"),
builder.CardAction.imBack(session, "Hello!", "Im Back"),
builder.CardAction.postBack(session, "Hello!", "Post Back")
];
const msg = new builder.Message(session).text("sample actions");
for(let i=0;i<3;i++) {
const card = new builder.HeroCard(session)
.buttons(cardActions)
.text('this is some text')
.title('card title')
.subtitle('card subtitle')
.images([new builder.CardImage(session).url("https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png").toImage()])
.tap(builder.CardAction.openUrl(session, "http://dev.botframework.com"));
msg.addAttachment(card);
}
msg.attachmentLayout(builder.AttachmentLayout.carousel);
session.send(msg);
}
]);
卡片可能有点棘手,因为按钮和图像有很多种布局方式。每个平台都有稍微不同的规则。在某些平台上,openUrl 按钮(但不是其他的)必须指向一个 HTTPS 地址。还可能存在限制每张卡的按钮数量、转盘中的卡数量和图像纵横比的规则。微软的机器人框架将尽可能以最好的方式处理这一切,但意识到这些限制将有助于我们调试我们的机器人。
建议的行动
我们已经在对话式设计的背景下讨论了建议的动作;它们是特定于消息上下文的操作,可以在收到消息后立即执行。如果有另一条消息进来,上下文就会丢失,建议的操作也会消失。这与卡片操作相反,卡片操作几乎永远留在聊天记录中。典型的建议动作 UX,也称为快速回复,是沿着屏幕底部水平排列的按钮列表。
构建建议动作的代码类似于英雄卡片,除了我们需要的唯一数据是卡片动作的集合。“建议操作”区域中允许的操作类型将取决于频道。图 6-17 和图 6-18 分别显示了模拟器和 Facebook Messenger 上的效果图。
图 6-18
在 Messenger 中建议相同的操作
图 6-17
模拟器中呈现的建议操作
msg.suggestedActions(new builder.SuggestedActions(session).actions([
builder.CardAction.postBack(session, "Option 1", "Option 1"),
builder.CardAction.postBack(session, "Option 2", "Option 2"),
builder.CardAction.postBack(session, "Option 3", "Option 3")
]));
建议的动作按钮很好地保持了与用户的对话,而不要求用户猜测他们可以在文本消息字段中键入什么。
练习 6-2
卡片和 建议动作
字典和辞典是好的机器人导航体验的好灵感。用户可以输入一个单词。得到的卡片可以显示单词的图像和定义。下面的一个按钮可以让我们打开一个参考页面,比如在 https://www.merriam-webster.com/
上。建议的动作可以是一组当前单词的同义词按钮。让我们把这种互动落实到位。
-
使用
https://dictionaryapi.com
创建帐户并建立连接。这个 API 将允许您使用字典和同义词库 API。 -
创建一个机器人,它可以使用 Dictionary API 根据用户输入查找单词,并以包含单词和定义文本的 hero card 作为响应。包括一个打开词典网站上该单词页面的按钮。
-
连接到同义词库 API,返回前十个同义词作为建议操作。
-
作为奖励,使用 Bing 图像搜索 API 来填充卡片中的图像。您可以在 Azure 中获得一个访问密钥,并使用以下示例作为指南:
https://docs.microsoft.com/en-us/azure/cognitive-services/bing-image-search/image-search-sdk-node-quickstart
。
现在,您已经有了将您的机器人连接到不同 API 并将这些 API 响应转换成英雄卡、按钮和建议动作的经验。干得好!
通道误差
在“丰富内容”部分,我们注意到当我们的机器人向 Facebook Messenger 连接器发送错误请求时,我们的机器人将收到 HTTP 错误。这个错误也被打印在机器人的控制台输出中。似乎脸书机器人连接器从脸书 API 向我们的机器人报告了一个错误。太酷了。我们看到的额外功能是 Azure 中的频道详情页面也包含所有这些错误。虽然很小,但这是一个强大的功能。它允许我们快速查看有多少消息被 API 拒绝以及错误代码。我们遇到的情况是,不支持特定的文件类型格式,这只是许多可能的错误之一。如果消息格式不正确,如果存在身份验证问题,或者如果脸书出于任何其他原因拒绝连接器消息,我们都会看到错误。类似的想法也适用于另一组连接器。一般来说,连接器善于将 Bot 框架活动转换成不会被通道拒绝的东西,但它确实发生了。
一般来说,如果我们的 bot 向 Bot 框架连接器发送消息,而消息没有出现在接口上,那么连接器和通道之间的交互很可能有问题,这个在线错误日志将包含有关失败的信息。
频道数据
我们已经多次提到,不同的通道可能会以不同的方式呈现消息,或者对某些项目有不同的规则,例如旋转木马中英雄卡的数量或英雄卡中按钮的数量。我们已经展示了 Messenger 和模拟器渲染的例子,因为这些通道通常工作良好。Skype 是另一个支持大量 Bot Builder 功能的软件(这很有意义,因为两者都属于微软)。Slack 对这些特性没有丰富的支持,但是它的可编辑消息是一个巧妙的特性,我们将在第八章中介绍。
为了便于说明,图 6-19 是之前讨论过的具有建议动作的转盘在松弛状态下的样子。
图 6-19
Slack 中呈现的相同 Bot 生成器对象
那不是旋转木马。Slack 里没有这个概念!也没有什么牌可言;它只是带有附件的消息。图像也不可点击;默认链接显示在图像上方。Im Back 和 Post Back 按钮都显示为回发。没有建议行动/快速回复的概念。您可以在网上找到有关松弛消息格式的更多信息。 7
然而,Bot Builder SDK 背后的团队已经考虑到了这样一个问题,即您可能希望指定确切的本机通道消息,而不是该通道的默认 Bot 框架连接器呈现。解决方案是在消息对象上提供一个包含传入消息的本机通道 JSON 数据的字段,以及一个可能包含本机通道 JSON 响应的字段。
Node SDK 中使用的术语是 source event(Bot Builder 的 C#版本将这个概念称为 channelData)。Node SDK 中的 sourceEvent 存在于 IEvent 接口上。记住,这也是 IMessage 实现的接口。这意味着来自 bot 连接器的任何事件都可能包含原始通道 JSON。
让我们看看 Facebook Messenger 中的一个特性,它并不容易被 Bot 框架支持。默认情况下,Messenger 中的卡片要求图像的宽高比为 1.91:1。 8 连接者默认的英雄卡转换利用了这个模板。然而,有能力利用 1:1 的图像比例。文档中还有其他被 Bot 框架隐藏的选项。例如,脸书有一个特殊的标志,将卡片设置为可共享。此外,您可以控制由 Messenger 中的 openURL 按钮调用的 WebView 的大小。现在,我们将坚持修改图像的纵横比。
首先,让我们看看发送相同卡片的代码,我们已经使用 hero card 对象发送了该卡片,但是使用了脸书的本地格式:
const bot = new builder.UniversalBot(connector, [
(session) => {
if (session.message.address.channelId == 'facebook') {
const msg = new builder.Message(session);
msg.sourceEvent({
facebook: {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
elements: [
{
title: 'card title',
subtitle: 'card subtitle',
image_url: 'https://bot-framework.azureedge.net/bot-icons-v1/bot-framework-default-7.png',
default_action: {
type: 'web_url',
url: 'http://dev.botframework.com',
webview_height_ratio: 'tall',
},
buttons: [
{
type: "web_url",
url: "http://google.com",
title: "Open Google"
},
{
type: 'postback',
title: 'Im Back',
payload: 'Hello!'
},
{
type: 'postback',
title: 'Post Back',
payload: 'Hello!'
}
]
}
],
}
}
}
});
session.send(msg);
} else {
session.send('this bot is unsupported outside of facebook!');
}
}
]);
渲染图(图 6-20 )看起来与使用英雄卡的渲染图一样。
图 6-20
在 Messenger 中呈现通用模板
我们设置 image_aspect_ratio 为正方形,现在脸书渲染为正方形(图 6-21 )!
图 6-21
在 Messenger 上呈现带有方形图像的通用模板
const msg = new builder.Message(session);
msg.sourceEvent({
facebook: {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
image_aspect_ratio: 'square',
// more...
}
}
}
});
session.send(msg);
就这么简单!这只是一个味道。在第八章中,我们将探索使用 Bot 框架来集成本机 Slack 特性。
群聊
有些类型的机器人是为了在群体环境中使用。在 Messenger、Twitter direct messages 或类似平台的环境中,用户和机器人之间的交互通常是一对一的。然而,一些频道,尤其是 Slack,专注于协作。在这种情况下,同时与多个用户对话的能力变得非常重要。让你的机器人能够有效地参与群体对话以及正确处理提及标签是非常重要的。
一些通道将允许机器人查看在通道中用户之间发送的每一条消息。其他频道只会在提到机器人时向其发送消息(例如,“嘿@szymonbot,写一本关于机器人的书好吗?”).
如果我们在一个允许我们的机器人在一个群组设置中查看所有消息的通道中,我们的机器人可以监控对话并根据讨论悄悄执行代码(因为回复群组对话中的每条消息有点烦人),或者它可以忽略没有提到机器人的所有内容。它还可以实现这两种行为的组合,通过使用某个命令来激活机器人,并使其变得健谈。
在“消息”部分,我们展示了消息的界面。我们忽略了实体列表,但它在这里变得相关。我们可能从连接器接收到的一种类型的实体是提及。该对象包括上述用户的姓名和 id,如下所示:
{
mentioned: {
id: '',
name: ''
},
text: ''
};
脸书不支持这种类型的实体,但 Slack 支持。我们将在第八章中连接一个机器人到 Slack,但与此同时,这里的代码可以在直接消息场景中总是回复,但只有在群聊中被提到时才会回复:
const bot = new builder.UniversalBot(connector, [
(session) => {
const botMention = _.find(session.message.entities, function (e) { return e.type == 'mention' && e.mentioned.id == session.message.address.bot.id; });
if (session.message.address.conversation.isGroup && botMention) {
session.send('hello ' + session.message.user.name + '!');
}
else if (!session.message.address.conversation.isGroup) {
// 1 on 1 session
session.send('hello ' + session.message.user.name + '!');
} else {
// silently looking at non-mention messages
// session.send('bein creepy...');
}
session.send(msg);
}
]);
图 6-22 是在直接对话的松弛状态下的体验。
图 6-22
Slack 中支持群聊的机器人直接发送消息
图 6-23 显示了群聊中的行为(原谅过于原始的用户名 srozga2)。
图 6-23
支持群聊的机器人忽略没有提及的消息
自定义对话框
我们已经使用 bot.dialog(…)方法构建了我们的对话框。我们还讨论了瀑布的概念。在我们在前一章开始的日历机器人中,我们的每个对话都是通过瀑布实现的:一组按顺序执行的步骤。我们可以跳过一些步骤或在所有步骤完成之前结束对话,但预定义顺序的想法是关键。这个逻辑是由 Bot Builder SDK 中的一个名为 WaterfallDialog 的类实现的。如果我们看看对话框(…)调用背后的代码,我们会发现这一点:
if (Array.isArray(dialog) || typeof dialog === 'function') {
d = new WaterfallDialog(dialog);
} else {
d = <any>dialog;
}
如果我们想要编码的对话片段不容易用瀑布式抽象来表示呢?我们有什么选择?我们可以创建一个对话框的自定义实现!
在 Bot Builder SDK 中,对话框是一个表示用户和 Bot 之间某种交互的类。对话框可以调用其他对话框,并接受这些子对话框的返回值。它们存在于对话堆栈中,与函数调用堆栈没有什么不同。使用默认的瀑布帮助器隐藏了其中的一些细节;实现一个定制的对话框让我们更接近对话框堆栈的现实。Bot Builder 中的抽象对话框类如下所示:
export abstract class Dialog extends ActionSet {
public begin<T>(session: Session, args?: T): void {
this.replyReceived(session);
}
abstract replyReceived(session: Session, recognizeResult?: IRecognizeResult): void;
public dialogResumed<T>(session: Session, result: IDialogResult<T>): void {
if (result.error) {
session.error(result.error);
}
}
public recognize(context: IRecognizeDialogContext, cb: (err: Error, result: IRecognizeResult) => void): void {
cb(null, { score: 0.1 });
}
}
Dialog 只是一个我们可以继承的类,它有四个重要的方法。
-
Begin :当对话框第一次放入堆栈时调用。
-
ReplyReceived :每当用户的消息到达时调用。
-
DialogResumed :当子对话框结束,当前对话框再次激活时调用。dialogResumed 方法接收的参数之一是子对话框的结果对象。
-
识别:允许我们添加自定义对话框识别逻辑。默认情况下,BotBuilder 提供声明性方法来设置自定义全局或对话框范围的识别。但是,如果我们想添加进一步的识别逻辑,我们可以使用这种方法。我们将在“操作”部分对此进行更深入的讨论。
为了说明这些概念,我们创建了一个 BasicCustomDialog。由于 Bot Builder 是用 TypeScript 编写的, 9 是 JavaScript 的一个类型化超集,我们继续用 TypeScript 编写子类,用 TypeScript 编译器(tsc)编译成 JavaScript,然后在 app.js 中使用它
让我们看看自定义对话框的代码。这恰好是 TypeScript,因为它在使用继承时有一个更干净的接口;编译后的 JavaScript 将在后面显示。当对话开始时,它发送“开始”文本。当它收到一条消息时,它用“已收到回复”文本进行响应。如果用户发送了“提示”文本,对话框将要求用户输入一些文本。然后,它将在 dialogResumed 方法中接收文本输入,并打印结果。如果用户输入了“done”,则对话框结束并返回到根对话框。
import { Dialog, ResumeReason, IDialogResult, Session, Prompts } from 'botbuilder'
export class BasicCustomDialog extends Dialog {
constructor() {
super();
}
// called when the dialog is invoked
public begin<T>(session: Session, args?: T): void {
session.send('begin');
}
// called any time a message is received
public replyReceived(session: Session): void {
session.send('reply received');
if(session.message.text === 'prompt') {
Prompts.text(session, 'please enter any text!');
} else if(session.message.text == 'done') {
session.endDialog('dialog ending');
} else {
// no-op
}
}
public dialogResumed(session: Session, result: any): void {
session.send('dialog resumed with value: ' + result);
}
}
我们在 app.js 中直接使用对话框的实例。在默认的瀑布中,我们回显任何消息,除了开始自定义对话框的“自定义”输入。
const bot = new builder.UniversalBot(connector, [
(session) => {
if(session.message.text === 'custom') {
session.beginDialog('custom');
} else {
session.send('echo ' + session.message.text);
}
}
]);
const customDialogs = require('./customdialogs');
bot.dialog('custom', new customDialogs.BasicCustomDialog());
图 6-24 显示了一个示例交互的样子。
图 6-24
与自定义对话框交互
顺便提一下,Promps.text、Prompts.number 和其他提示对话框都是作为自定义对话框实现的。
接下来显示了为定制对话框编译的 JavaScript。推理起来有点困难,但归根结底,这是标准的 ES5 JavaScript 原型继承。10
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return function (d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
exports.__esModule = true;
var botbuilder_1 = require("botbuilder");
var BasicCustomDialog = /** @class */ (function (_super) {
__extends(BasicCustomDialog, _super);
function BasicCustomDialog() {
return _super.call(this) || this;
}
// called when the dialog is invoked
BasicCustomDialog.prototype.begin = function (session, args) {
session.send('begin');
};
// called any time a message is received
BasicCustomDialog.prototype.replyReceived = function (session) {
session.send('reply received');
if (session.message.text === 'prompt') {
botbuilder_1.Prompts.text(session, 'please enter any text!');
}
else if (session.message.text == 'done') {
session.endDialog('dialog ending');
}
else {
// no-op
}
};
BasicCustomDialog.prototype.dialogResumed = function (session, result) {
session.send('dialog resumed with value: ' + result);
};
return BasicCustomDialog;
}(botbuilder_1.Dialog));
exports.BasicCustomDialog = BasicCustomDialog;
练习 6-3
实现自定义提示号
作为自定义对话框概念的练习,您现在将创建一个自定义 Prompts.number 对话框。这个练习纯粹是学术性的;了解框架级的行为是如何实现的是很有趣的。
-
创建一个具有两步瀑布的 bot,它使用标准的 Prompts.number 收集一个数值,并在第二个瀑布步骤中将该数值发送回用户。请注意,您将在瀑布函数的 args 参数上使用响应字段。
-
创建一个自定义对话框,收集用户输入,直到它收到一个数字。出于练习的目的,您可以使用 parseFloat。当收到有效的号码时,使用与 Prompts.number 返回的结构相同的对象调用 session.endDialogWithResult。如果用户的输入无效,则返回一条错误消息并再次请求号码。
-
在瀑布中,不要调用 Prompts.number,而是调用新的自定义对话框。你的瀑布应该还能用!
-
另外,在你的自定义对话框中添加逻辑,允许最多五次尝试。之后,返回一个取消的结果到你的瀑布。
您现在已经了解了 Bot Builder SDK 中所有对话框的构建块!我们可以利用这些知识来建立任何形式的互动。
行动
我们现在对抽象对话框有多强大以及 Bot Builder SDK 如何管理对话框堆栈有了很好的了解。该框架的一个关键部分是如何将用户动作与对话堆栈的转换联系起来,我们对此没有很好的理解。在最基本的层面上,我们可以编写简单地调用 beginDialog 的代码。但是我们如何根据用户输入做出决定呢?我们如何将它与我们在前一章中学习的识别器挂钩,特别是 LUIS?这就是行动允许我们做的事情。
Bot Builder SDK 包含六种类型的操作,其中两种是全局的,四种是对话框范围的。这两个全局操作是 triggerAction 和 customAction。我们以前遇到过触发作用。它允许机器人在对话期间的任何时候当意图匹配时调用对话,假设该意图事先不匹配对话范围内的动作。每当接收到用户输入时,都会对这些进行评估。默认行为是在调用对话框之前清除整个对话框堆栈。
lib.dialog(constants.dialogNames.AddCalendarEntry, [
function (session, args, next) {
...
]).triggerAction({
matches: constants.intentNames.AddCalendarEntry
});
上一章日历机器人代码中的每个主要对话框都使用默认的 triggerAction 行为,除了帮助。帮助对话框在对话框栈的顶部被调用,所以当它完成时,我们回到用户开始所在的对话框。为了达到这个效果,我们重写 onSelectAction 方法并指定我们想要的行为。
lib.dialog(constants.dialogNames.Help, (session, args, next) => {
...
}).triggerAction({
matches: constants.intentNames.Help,
onSelectAction: (session, args, next) => {
session.beginDialog(args.action, args);
}
});
customAction 直接绑定到 bot 对象,而不是对话框。它允许我们绑定一个函数来响应用户输入。我们没有机会像对话框实现那样向用户查询更多信息。这对于简单地返回消息或根据用户输入执行一些 HTTP 调用的功能来说是很好的。事实上,我们可以像这样重写帮助对话框。代码看起来很简单,但是我们失去了对话模型的封装性和可扩展性。换句话说,我们在自己的对话框中不再有逻辑,不再有执行几个步骤、收集用户输入或向调用对象提供结果的能力。
lib.customAction({
matches: constants.intentNames.Help,
onSelectAction: (session, args, next) => {
session.send("Hi, I am a calendar concierge bot. I can help you create, delete and move appointments. I can also tell you about your calendar and check your availability!");
}
});
四种类型的上下文操作是 beginDialogAction、reloadAction、cancelAction 和 endConversationAction。让我们逐一检查。
BeginDialogAction 创建一个操作,只要该操作匹配,就会在堆栈上推一个新对话框。日历机器人中的上下文帮助对话框使用了这种方法。我们创建了两个对话框:一个是 AddCalendarEntry 对话框的帮助,另一个是 RemoveCalendarEntry 对话框的帮助。
// help message when help requested during the add calendar entry dialog
lib.dialog(constants.dialogNames.AddCalendarEntryHelp, (session, args, next) => {
const msg = "To add an appointment, we gather the following information: time, subject and location. You can also simply say 'add appointment with Bob tomorrow at 2pm for an hour for coffee' and we'll take it from there!";
session.endDialog(msg);
});
// help message when help requested during the remove calendar entry dialog
lib.dialog(constants.dialogNames.RemoveCalendarEntryHelp, (session, args, next) => {
const msg = "You can remove any calendar either by subject or by time!";
session.endDialog(msg);
});
然后,我们的 AddCalendarEntry 对话框可以将 beginDialogAction 绑定到相应的帮助对话框。
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
]).beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });
请注意,此操作的行为与手动调用 beginDialog 相同。新对话框放在对话框堆栈的顶部,当前对话框完成后继续。
reloadAction 调用执行 replaceDialog。replaceDialog 是 session 对象上的一个方法,该方法结束当前对话框并用另一个对话框的实例替换它。在新对话框完成之前,父对话框不会得到结果。在实践中,我们可以利用它来重新开始一个交互,或者在流程中间切换到一个更合适的对话。
以下是对话的代码(见图 6-25 ):
图 6-25
触发 reloadAction 的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });
CancelAction 允许我们取消当前对话框。父对话框将在其恢复处理程序中收到一个设置为 true 的取消标志。这允许对话框正确地对取消进行操作。代码如下(对话可视化如图 6-26 所示):
图 6-26
触发取消的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.cancelAction('cancel', 'Cancelled.', { matches: /^cancel$/i})
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });
最后,endConversationAction 允许我们绑定到 session.endConversation 调用。结束对话意味着清除整个对话堆栈,并从状态存储中删除所有用户和对话数据。如果用户再次开始向机器人发送消息,就会创建一个新的对话,而不知道之前的交互。代码如下(图 6-27 显示对话可视化):
图 6-27
触发 endConversationAction 的示例对话
lib.dialog(constants.dialogNames.AddCalendarEntry, [
// code
])
.beginDialogAction(constants.dialogNames.AddCalendarEntryHelp, constants.dialogNames.AddCalendarEntryHelp, { matches: constants.intentNames.Help })
.reloadAction('startOver', "Ok, let's start over...", { matches: /^restart$/i })
.cancelAction('cancel', 'Cancelled.', { matches: /^cancel$/i})
.endConversationAction('end', "conversation over!", { matches: /^end!$/i })
.triggerAction({ matches: constants.intentNames.AddCalendarEntry });
关于行动的额外注释
回想一下上一章,每个识别器接受一个用户输入,并返回一个带有意图文本值和分数的对象。我们提到了这样一个事实,即我们可以使用识别器来确定 LUIS 的意图,使用正则表达式,或者实现任何定制逻辑。我们创建的每个动作中的匹配对象是我们指定一个动作对哪个识别器意图感兴趣的一种方式。matches 对象实现以下接口:
export interface IDialogActionOptions {
matches?: RegExp|RegExp[]|string|string[];
intentThreshold?: number;
onFindAction?: (context: IFindActionRouteContext, callback: (err: Error | null, score: number, routeData?: IActionRouteData) => void) => void;
onSelectAction?: (session: Session, args?: any, next?: Function) => void;
}
以下是该对象包含的内容:
-
Matches 是操作要查找的目的名称或正则表达式。
-
intentThreshold 是识别器为使此操作被调用而必须分配给意图的最低分数。
-
onFindAction 允许我们在检查一个动作是否应该被触发时调用定制逻辑。
-
onSelectAction 允许您自定义操作的行为。例如,如果您不想清除对话框堆栈,而是想将对话框放在堆栈顶部,请使用它。在之前的动作示例中,我们已经看到了这一点。
除了这种级别的定制之外,Bot Builder SDK 对操作及其优先级有非常具体的规则。回想一下,在关于自定义对话框的讨论中,我们已经看到了全局操作、对话框范围的操作以及每个对话框上可能的识别实现。消息到达时的动作解析顺序如下。首先,系统试图定位当前对话框的识别功能的实现。之后,SDK 查看对话框堆栈,从当前对话框一直到根对话框。如果该路径上没有匹配的动作,则查询全局动作。这个顺序确保最接近当前用户体验的动作被首先处理。当你设计你的机器人交互时,请记住这一点。
图书馆
库是打包和分发相关机器人对话框、识别器和其他功能的一种方式。库可以引用其他库,从而产生功能高度组合的机器人。从开发人员的角度来看,库只是一个包装精美的对话框、识别器和其他 Bot Builder 对象的集合,带有一个名称,通常还有一组帮助调用对话框和其他特定于库的特性的 helper 方法。在我们第五章的日历礼宾机器人中,每个对话框都是与高级机器人功能相关的库的一部分。app.js 代码加载所有模块,然后通过 bot.library 调用将它们安装到主 bot 中。
const helpModule = require('./dialogs/help');
const addEntryModule = require('./dialogs/addEntry');
const removeEntryModule = require('./dialogs/removeEntry');
const editEntryModule = require('./dialogs/editEntry');
const checkAvailabilityModule = require('./dialogs/checkAvailability');
const summarizeModule = require('./dialogs/summarize');
const bot = new builder.UniversalBot(connector, [
(session) => {
// code
}
]);
bot.library(addEntryModule.create());
bot.library(helpModule.create());
bot.library(removeEntryModule.create());
bot.library(editEntryModule.create());
bot.library(checkAvailabilityModule.create());
bot.library(summarizeModule.create());
这是库组合在起作用:UniversalBot 本身就是库的一个子类。我们的主 UniversalBot 库导入了其他六个库。从任何其他上下文中对对话框的引用必须使用库名作为前缀来命名空间。从根库或 UniversalBot 对象中的对话框的角度来看,调用任何其他库的对话框都必须使用格式为 libName:dialogName 的限定名。这种完全限定的对话框名称引用过程只有在跨越库边界时才是必要的。在同一库的上下文中,库前缀不是必需的。
一种常见的模式是在调用库对话框的模块中公开一个助手方法。把它想象成库封装;一个库不应该知道另一个库的内部情况。例如,我们的帮助库公开了一个方法来实现这一点。
const lib = new builder.Library('help');
exports.help = (session) => {
session.beginDialog('help:' + constants.dialogNames.Help);
};
结论
微软的 bot Builder SDK 是一个强大的 Bot 构造库和对话引擎,可以帮助我们开发各种类型的异步对话体验,从简单的来回到具有多种行为的复杂 Bot。对话抽象是一种强大的对话建模方式。识别器定义了我们的机器人用来将用户输入转换成机器可读意图的机制。动作将那些识别器结果映射到对话堆栈上的操作。一个对话框主要关心三件事:当它开始时会发生什么,当收到用户消息时会发生什么,当子对话框返回结果时会发生什么。每个对话框都利用 bot 上下文,称为会话,来检索用户消息并创建响应。响应可以由文本、视频、音频或图像组成。此外,卡片可以产生更丰富和上下文敏感的体验。建议的动作负责防止用户猜测下一步该做什么。
在下一章中,我们将应用这些概念将我们的机器人与谷歌日历 API 集成,我们将采取措施创建一个引人注目的第一版日历机器人体验。
七、构建集成的机器人体验
到目前为止,我们已经构建了一个非常好的 LUIS 应用,它一直在不断发展。我们还利用了 Bot Builder 对话引擎,该引擎采用我们的自然语言模型,从用户话语中提取相关的意图和实体,并包含围绕进入 Bot 的许多不同输入排列的条件逻辑。但是我们的代码实际上什么也不做。我们如何让它做一些有用和真实的事情?在整本书中,我们一直在探索日历机器人的概念。这意味着我们需要集成某种日历 API。出于本书的目的,我们将与 Google 的日历 API 集成。设置好之后,我们将探索如何将这些调用集成到 bot 流中。在 OAuth 时代,我们不会花时间在聊天窗口中收集用户名和密码。那是不安全的。相反,我们将使用 Google OAuth 库实现一个三条腿的 OAuth 流。然后,我们将继续对代码进行修改,以支持与 Google Calendar API 的通信。在本章的最后,我们将得到一个可以用来创建约会和查看日历条目的机器人。
注意,本章的代码可以作为代码库的一部分获得。贯穿 bot 代码和本书中的代码,您会发现许多库的使用。使用较多的一个是下划线。下划线是一个漂亮的库,它提供了一系列有用的实用函数,尤其是在集合方面。
关于 OAuth 2.0 的一句话
这不是一本关于安全性的书,但是理解基本的身份验证和授权机制对于开发人员来说是必不可少的。OAuth 2.0 是一个标准的授权协议。三足 OAuth 2.0 流允许第三方应用代表另一个实体访问服务。在我们的例子中,我们将代表用户访问用户的 Google 日历数据。在三条腿的 OAuth 流的末尾,我们以两个令牌结束:一个访问令牌和一个刷新令牌。访问令牌包含在对授权 HTTP 头中的 API 的请求中,并向 API 提供数据,声明我们正在请求哪个用户的数据。访问令牌通常是短暂的,以减少可利用受损访问令牌的窗口。当访问令牌过期时,我们可以使用刷新令牌来接收新的访问令牌。
为了启动这个流程,我们首先将用户重定向到一个他们可以验证的服务,比如说 Google。Google 提供了一个 OAuth 2.0 登录页面,在该页面中,它对用户进行身份验证,并征求用户的同意,以便机器人可以代表他们从 Google 访问用户的数据。当认证和同意成功时,谷歌通过所谓的重定向 URI 将授权码发送回机器人的 API。最后,我们的 bot 通过向 Google 的令牌端点提供授权代码来请求访问和刷新令牌。Google 的 OAuth 库将帮助我们在日历机器人中实现三足流。
设置 Google APIs
在我们开始之前,我们应该让自己能够使用 Google APIs。幸运的是,谷歌通过谷歌云平台 API 控制台使这变得非常容易。谷歌云平台是谷歌的 Azure 或者 AWS 它是谷歌供应和管理不同云服务的一站式商店。首先,我们导航到 https://console.cloud.google.com
。如果这是我们第一次访问网站,我们将被要求接受服务条款。之后,我们将被放置在仪表板中(图 7-1 )。
图 7-1
谷歌云平台仪表板
我们接下来的步骤如下。我们将创建一个新项目。在该项目中,我们将要求访问日历 API。我们还将赋予我们的项目使用 OAuth2 代表用户登录的能力。一旦完成,我们将收到一个客户端 ID 和秘密。这两段数据,加上我们的重定向 URI,足以让我们在 bot 中使用 Google API 库。
单击下拉菜单选择一个 项目。你会看到一个弹出窗口,如果你以前没有使用过这个控制台,它应该是空的(图 7-2 )。
图 7-2
谷歌云平台仪表板项目
单击+按钮添加新项目。为项目命名。一旦项目被创建,我们将能够通过选择项目功能导航到它(图 7-3 )。项目还被分配一个 ID,以项目名称为前缀。
图 7-3
我们的项目创建完成了!
当打开项目时,我们会看到项目仪表板,最初看起来很吓人(图 7-4 )。在这里我们可以做很多事情。
图 7-4
一个项目有很多事情要做
让我们从访问 Google 日历 API 开始。我们首先单击 API 和服务。我们可以在左侧导航窗格的前几个项目中找到此链接。该页面已经填充了相当多的内容。这些是默认的谷歌云平台服务。因为我们不使用它们,我们可以禁用每一个。准备就绪后,我们可以单击启用 API 和服务按钮。我们搜索日历,点击谷歌日历 API。最后,我们点击启用按钮将其添加到我们的项目中(图 7-5 )。我们将收到一条警告,指出我们可能需要凭据才能使用 API。没问题,我们接下来会这样做。
图 7-5
为我们的项目启用日历 API
要设置授权,我们单击左侧窗格中的凭证链接。我们将会看到创建凭据的提示。在我们的用例中,我们将访问用户的日历,我们需要一个 OAuth 客户端 ID 1 (图 7-6 )。
图 7-6
设置我们的客户凭证
我们将首先被要求设置同意屏幕(图 7-7 )。这是用户向 Google 验证时显示的屏幕。我们大多数人可能在不同的 web 应用中遇到过这些类型的屏幕。例如,每当我们通过脸书登录一个应用时,我们都会看到一个页面,告诉我们该应用需要权限才能读取你的所有联系信息和照片,甚至是最深的秘密。这是谷歌建立类似页面的方式。它要求提供产品名称、徽标、服务条款、隐私政策 URL 等数据。为了测试功能,我们至少需要一个产品名称。
图 7-7
OAuth 同意配置
此时,我们将回到创建客户端 ID 功能。作为应用类型设置,我们应该选择 Web 应用,并给我们的客户端一个名称和一个重定向 URI(图 7-8 )。我们利用我们的 ngrok 代理 URI(参见第五章了解更多关于 ngrok 的信息)。对于本地测试,我们可以自由输入本地主机地址。比如可以输入http://localhost:3978
。
图 7-8
创建新的 OAuth 2.0 客户端 ID 并提供重定向 URI
一旦我们点击创建 按钮,我们将收到一个带有客户端 ID 和客户端密码的弹出窗口(图 7-9 )。复制它们,因为我们将需要我们的 bot 中的值。如果我们丢失了客户端 ID 和密码,我们总是可以通过导航到项目的凭证页面并选择我们在 OAuth 2.0 客户端 ID 中创建的条目来访问它们。
图 7-9
我们总能找到丢失的 ID 和秘密
此时,我们已经准备好将我们的机器人连接到 Google OAuth2 提供者。
将身份验证与 Bot Builder 集成
我们将需要安装 googleapis Node 包以及 crypto-js ,一个让我们加密数据的库。当我们将用户发送到 OAuth 登录页面时,我们还在 URL 中包含一个州。状态只是一个有效负载,我们的应用可以用它来标识用户及其对话。当 Google 将一个授权码作为 OAuth 2.0 三足流的一部分发回时,它也会发回状态。状态参数应该是我们的 API 可以识别的,但恶意参与者很难猜到的,比如会话散列或我们感兴趣的其他信息。一旦我们从 Google 的 auth 页面收到它,我们就可以使用 state 参数中的数据继续用户的对话。
为了屏蔽不良行为者的数据,我们将把这个对象编码为 Base64 字符串。Base64 是二进制数据的 ASCII 表示。 2 由于一个恶意的参与者可以通过简单地从 Base64 解码来轻易地泄露这些信息,我们将使用 crypto-js 来加密状态字符串。
首先,让我们安装这两个包。
npm install googleapis crypto-js --save
其次,让我们添加三个变量。代表客户端 ID、机密和重定向 URI 的 env 文件。我们使用我们在图 7-8 中提供的重定向 URI 和我们在图 7-9 中收到的客户端 ID 和密码。
GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io
第三,我们需要生成登录页面的 URL,并发送一个可以打开该 URL 的按钮。Google Auth APIs 可以为我们做很多这方面的工作。我们将在代码中做一些事情。首先,我们导入 crypto-js 和 googleapis 包。接下来,我们创建一个包含客户机数据的 OAuth2 客户机实例。我们将作为登录 URL 的一部分发送的状态包含用户的地址。如前一章所示,一个地址足以唯一地标识用户的对话,Bot Builder 包含一些工具,可以帮助我们通过简单地显示对话地址向该用户发送消息。我们使用 crypto-js 来加密状态,使用 ASE 算法。 3 AES 是一种对称密钥算法,这意味着数据使用相同的密钥或密码进行加密和解密。我们将密码短语添加到。名为 AES_PASSPHRASE 的 env 文件。
GOOGLE_OAUTH_CLIENT_ID=693978449559-8t03j8064o6hfr1f8lh47s9gvc4afed4.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=X6lzSlw500t0wmQQ2SpF6YV6
GOOGLE_OAUTH_REDIRECT_URI=https://a4b5518e.ngrok.io/oauth2callback
AES_PASSPHRASE=BotsBotsBots!!!
另一件要注意的事情是范围数组。当请求对 Google APIs 的授权时,我们使用作用域向 Google 指定我们要访问的 API。我们可以将 scopes 数组中的每一项看作是我们希望从 Google 的 API 中访问的关于用户的一段数据。当然,这个数组需要是我们的 Google 项目可能访问的 API 的子集。如果我们添加了之前没有为项目启用的范围,授权过程将会失败。
const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;
const CryptoJS = require('crypto-js');
const oauth2Client = getAuthClient();
const state = {
address: session.message.address
};
const googleApiScopes = [
'https://www.googleapis.com/auth/calendar'
];
const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: googleApiScopes,
state: encryptedState
});
我们还需要能够发送一个按钮,让用户利用授权机器人。为此,我们使用内置的 SigninCard。
const card = new builder.SigninCard(session).button('Login to Google', authUrl).text('Need to get your credentials. Please login here.');
const loginReply = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card]);
仿真器根据图 7-10 渲染信号。
图 7-10
在 Bot 框架模拟器中呈现的签名
此时,我们可以单击 Login 按钮登录到 Google,并授权我们的 bot 访问我们的数据,但这会失败,因为我们还没有提供代码来处理来自返回 URI 的消息。我们使用与安装 API 消息端点相同的方法为 https://a4b5518e.ngrok.io/oauth2callback
端点安装处理程序。我们还启用了 restify.queryParser ,这将把查询字符串中的每个参数公开为 req.query 对象中的一个字段。例如, redirectUri 形式的回调?state=state & code=code 会产生一个查询对象,它有两个属性,state 和 code。
const server = restify.createServer();
server.use(restify.queryParser());
server.listen(process.env.port || process.env.PORT || 3978, function () {
console.log('%s listening to %s', server.name, server.url);
});
server.get('/oauth2callback', function (req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
...
});
我们从回调中读取授权代码,并使用 Google OAuth2 客户端从令牌端点获取令牌。标记 JSON 看起来像下面的数据。请注意, expiry_date 是自纪元以来以毫秒为单位的日期时间。 4
{
"access_token": "ya29.GluMBfdm6hPy9QpmimJ5qjJpJXThL1y GcKHrOI7JCXQ46XdQaCDBcJzgp1gWcWFQNPTXjbBYoBp43BkEAyLi3 ZPsR6wKCGlOYNCQIkeLEMdRTntTKIf5CE3wkolU",
"refresh_token": "1/GClsgQh4BvHTxPdbQgwXtLW2hBza6FPLXDC9zBJsKf4NK_N7AfItv073kssh5VHq",
"token_type": "Bearer",
"expiry_date": 1522261726664
}
一旦我们收到令牌,我们就在 OAuth2 对象上调用 setCredentials ,现在可以用它来访问 Google Calendar API 了!
server.get('/oauth2callback', function (req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
const oauth2Client = new OAuth2(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
process.env.GOOGLE_OAUTH_REDIRECT_URI
);
res.contentType = 'json';
oauth2Client.getToken(code, function (error, tokens) {
if (!error) {
oauth2Client.setCredentials(tokens);
// We can now use the oauth2Client to call the calendar API
next();
} else {
res.send(500, {
status: 'error',
error: error
});
next();
}
});
});
在我们可以访问日历 API 的代码位置,我们可以编写代码来获取我们拥有的日历列表并打印出它们的名称。注意,下面代码中的 calapi 是一个 helper 对象,它用 JavaScript promises 包装了 Google Calendar API。代码可以在本章的代码库中找到。
calapi.listCalendars(oauth2Client).then(function (data) {
const myCalendars = _.filter(data, p => p.accessRole === 'owner');
console.log(_.map(myCalendars, p => p.summary));
});
这段代码产生了下面的控制台输出,这是一个不幸的提醒,提醒了我自从当了爸爸后就没怎么活动过的相当孤独的锻炼计划。
Array(5) ["BotCalendar", "Szymon Rozga", "Work", "Szymon WFH Schedule", "Workout schedule"]
撇开父亲的体重增加不谈,这太棒了!我们确实面临一些挑战。我们需要存储用户的 OAuth 令牌,这样我们就可以在用户向我们发送消息时随时访问它们。我们把它们存放在哪里?这个很简单:私人谈话数据。在这种情况下,我们如何访问数据字典呢?我们通过将用户的地址传递给 bot.loadSession 方法来实现这一点。
回想一下,我们将用户的地址存储到加密的状态变量中。我们可以使用与加密数据相同的密码来解密该对象。
const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));
收到令牌后,我们可以从该地址加载 bot 会话。此时,我们有了一个 session 对象,它包含了所有的对话方法,如 beginDialog 供我们使用。
oauth2Client.getToken(code, function (error, tokens) {
bot.loadSession(state.address, (sessionLoadError, session) => {
if (!error && !sessionLoadError) {
oauth2Client.setCredentials(tokens);
calapi.listCalendars(oauth2Client).then(function (data) {
const myCalendars = _.filter(data, p => p.accessRole === 'owner');
session.beginDialog('processUserCalendars', { tokens: tokens, calendars: myCalendars });
res.send(200, {
status: 'success'
});
next();
});
// We can now use the oauth2Client to call the calendar API
} else {
res.send(500, {
status: 'error',
error: error
});
next();
}
});
});
processUserCalendars 对话框可能看起来像这样。它将令牌设置到私人对话数据中,让用户知道他们已经登录,并显示所有客户端日历的名称。
bot.dialog('processUserCalendars', (session, args) => {
session.privateConversationData.userTokens = args.tokens;
session.send('You are now logged in!');
session.send('You own the following calendars. ' + _.map(args.calendars, p => p.summary).join(', '));
session.endDialog();
});
交互将如图 7-11 所示。
图 7-11
与对话框集成的登录流程
无缝登录流程
我们已经成功地登录并存储了访问令牌,但是我们还没有演示一个无缝的机制,当一个对话框要求我们的用户登录时,它可以重定向到登录流。更具体地说,如果在日历机器人的上下文中,用户没有登录并要求机器人添加新的日历条目,机器人应该显示登录按钮,然后在登录成功后继续添加条目对话框。
下面列出了与现有对话流集成的一些要求:
-
我们希望允许用户在任何时候向机器人发送文本登录或注销的消息,并让机器人做正确的事情。
-
当需要授权的对话开始时,它需要验证用户授权是否存在。如果 auth 不存在,登录按钮应该出现,并阻止用户继续所述对话,直到用户被授权。
-
如果用户说注销,令牌应该从私人对话数据中清除,并用 Google 撤销。
-
如果用户说登录,bot 需要渲染登录按钮。该按钮将用户指向授权 URL。这与前面描述的相同。然而,我们必须确保点击按钮两次不会混淆机器人和它对用户状态的理解。
我们自然会实现一个登录对话框和一个注销对话框。注销只是检查会话状态中是否存在令牌。如果我们没有令牌,我们已经注销。如果我们这样做,我们使用谷歌的图书馆撤销用户的凭证。 5 代币不再有效。
function getAuthClientFromSession(session) {
const auth = getAuthClient(session.privateConversationData.tokens);
return auth;
};
function getAuthClient(tokens) {
const auth = new OAuth2(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
process.env.GOOGLE_OAUTH_REDIRECT_URI
);
if (tokens) {
auth.setCredentials(tokens);
}
return auth;
}
bot.dialog('LogoutDialog', [(session, args) => {
if (!session.privateConversationData.tokens) {
session.endDialog('You are already logged out!');
} else {
const client = getAuthClientFromSession(session);
client.revokeCredentials();
delete session.privateConversationData['tokens'];
session.endDialog('You are now logged out!');
}
}]).triggerAction({
matches: /^logout$/i
});
登录是一个瀑布式对话框,它在进入下一步之前开始一个确保凭证对话框。在第二步中,它验证是否已登录。请参见下面的代码。它通过验证是否从确保凭证对话框接收到认证标志来做到这一点。如果是,它只是让用户知道她已经登录。否则,会向用户显示一个错误。
注意我们在这里做了什么。我们外包了判断我们是否登录、登录,然后将结果发送回不同对话框的逻辑。只要该对话框返回一个带有字段已验证和可选的错误的对象,就可以正常工作。我们将使用相同的技术将授权流注入到任何其他需要它的对话框中。
bot.dialog('LoginDialog', [(session, args) => {
session.beginDialog(constants.dialogNames.Auth.EnsureCredentials);
}, (session, args) => {
if (args.response.authenticated) {
session.send('You are now logged in!');
} else {
session.endDialog('Failed with error: ' + args.response.error)
}
}]).triggerAction({
matches: /^login$/i
});
所以,最重要的问题变成了,保证凭证做什么?这段代码需要处理四种情况。前两个很简单。
-
如果对话框需要凭据并且授权成功,会发生什么情况?
-
如果一个对话框需要凭证,而授权失败了,会发生什么?
后两个稍微有点微妙。我们的问题是,如果一个对话框没有等待授权,但它还是进来了,机器人应该做什么。或者换句话说,如果 EnsureCredentials 不在栈顶会发生什么?
-
如果用户在需要登录的对话框范围之外单击登录按钮,并且授权成功,会发生什么情况?
-
如果用户在需要登录的对话框范围之外单击登录按钮,并且授权失败,会发生什么情况?
我们在图 7-12 中说明了第一种情况的流程。一个对话框要求我们在继续之前获得用户的授权,就像前面代码中的登录对话框一样。用户将被发送到身份验证页面。一旦 auth 页面返回一个成功的授权代码,它就向我们的 oauth2callback 发送一个回调。一旦我们得到令牌,我们调用一个 StoreTokens 对话框来将令牌存储到对话数据中。该对话框将向 EnsureCredentials 返回成功消息。反过来,这将向调用对话框返回一个成功的身份验证消息。
图 7-12
对话框需要授权,成功授权
如果发生错误,流程是类似的,只是我们用错误对话框替换了确保凭证对话框。然后,错误对话框将向调用对话框返回一个失败的验证消息,调用对话框可以以它认为最合适的方式处理错误(图 7-13 )。回想一下,正如我们在第五章中提到的, replaceDialog 是一个用另一个对话框的实例替换栈顶当前对话框的调用。调用对话不知道,也不关心这个实现细节。
图 7-13
对话需要授权,授权失败
如果用户在对话框不期待回复时点击登录按钮,并且 EnsureCredentials 不在堆栈顶部,那么流程会略有不同。如果授权成功或失败,我们希望向用户显示成功或失败的消息。为了实现这一点,我们将在调用 StoreTokens 对话框之前在堆栈上放置一个确认对话框 AuthConfirmation (图 7-14 )。
图 7-14
用户表示登录,授权成功
同样,在我们收到授权错误的情况下,我们在推送错误对话框之前,推送堆栈顶部的授权确认对话框(图 7-15 )。这将确保确认对话框向用户显示正确类型的消息。
图 7-15
用户说登录,授权失败
让我们看看这个的代码是什么样子的。登录和注销对话框已经完成,但是让我们看看确保凭证、存储令牌和错误。
确保凭证由两个步骤组成。首先,如果用户定义了一组令牌,那么对话框会传递一个结果,表明用户可以使用了。否则,我们创建 auth URL 并向用户发送一个 SigninCard ,就像我们在上一节中所做的那样。第二步也在案例 1 中执行。它只是告诉调用对话框用户已被授权。
bot.dialog('EnsureCredentials', [(session, args) => {
if(session.privateConversationData.tokens) {
// if we have the tokens... we're good. if we have the tokens for too long and the tokens expired
// we'd need to somehow handle it here.
session.endDialogWithResult({ response: { authenticated: true } });
return;
}
const oauth2Client = getAuthClient();
const state = {
address: session.message.address
};
const encryptedState = CryptoJS.AES.encrypt(JSON.stringify(state), process.env.AES_PASSPHRASE).toString();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: googleApiScopes,
state: encryptedState
});
const card = new builder.HeroCard(session)
.title('Login to Google')
.text("Need to get your credentials. Please login here.")
.buttons([
builder.CardAction.openUrl(session, authUrl, 'Login')
]);
const loginReply = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card]);
session.send(loginReply);
}, (session, args) => {
session.endDialogWithResult({ response: { authenticated: true } });
}]);
StoreTokens 和错误类似。两者本质上都将授权结果返回给其父对话框。在存储令牌的情况下,我们也将令牌存储到对话数据中。
bot.dialog('Error', [(session, args) => {
session.endDialogWithResult({ response: { authenticated: false, error: args.error } });
}]);
bot.dialog('StoreTokens', function (session, args) {
session.privateConversationData.tokens = args.tokens;
session.privateConversationData.calendarId = args.calendarId;
session.endDialogWithResult({ response: { authenticated: true }});
});
请注意, EnsureCredentials 将使用这两者中任何一个的结果,并简单地将其传递给调用对话框。由调用对话框决定显示成功还是错误消息。甚至可能没有成功消息;调用对话框可能会直接跳到它自己的步骤中。
这涵盖了案例 1 和案例 2。为了确保案例 3 和 4 被涵盖,我们需要实现这个 AuthConfirmation 对话框。该对话框的作用是显示成功或失败消息。回想一下,我们在 AuthConfirmation 之上放置了一个错误(案例 3)或存储令牌(案例 4)对话框。这个想法是, AuthConfirmation 将接收对话框的名称并放在它自己的上面,然后当它接收到结果时向用户发送适当的消息。
bod.dialog('AuthConfirmation', [
(session, args) => {
session.beginDialog(args.dialogName, args);
},
(session, args) => {
if (args.response.authenticated) {
session.endDialog('You are now logged in.')
}
else {
session.endDialog('Error occurred while logging in. ' + args.response.error);
}
}
]);
最后,我们如何改变端点回调代码?在我们到达那里之前,我们编写一些助手来调用不同的对话框。我们公开了一个名为is insure的函数,该函数验证我们是否正在从 EnsureCredentials 对话框进入这段代码。这将决定我们是否需要认证。beginErrorDialog 和beginStoreTokensAndResume都利用了这种方法。最后, ensureLoggedIn 是每个需要授权的对话框必须调用来启动流程的函数。
function isInEnsure(session) {
return _.find(session.dialogStack(), function (p) { return p.id.indexOf('EnsureCredentials') >= 0; }) != null;
}
const beginErrorDialog = (session, args) => {
if (isInEnsure(session)) {
session.replaceDialog('Error', args);
}
else {
args.dialogName = 'Error';
session.beginDialog('AuthConfirmation', args);
}
};
const beginStoreTokensAndResume = (session, args) => {
if (isInEnsure(session)) {
session.beginDialog('StoreTokens', args);
} else {
args.dialogName = 'StoreTokens';
session.beginDialog('AuthConfirmation', args);
}
};
const ensureLoggedIn = (session) => {
session.beginDialog('EnsureCredentials');
};
最后来看回调。代码看起来与上一节中的回调类似,只是我们需要添加逻辑来启动正确的对话框。如果我们在加载我们的会话对象时遇到任何错误,或者我们得到一个 OAuth 错误,比如用户拒绝访问我们的 bot,我们将用户重定向到错误对话框。否则,我们使用来自 Google 的授权代码来获取令牌,在 OAuth 客户端中设置凭证,并调用 StoreTokens 或 AuthConfirmation 对话框。以下代码涵盖了本节开头突出显示的四种情况:
exports.oAuth2Callback = function (bot, req, res, next) {
const code = req.query.code;
const encryptedState = req.query.state;
const oauthError = req.query.error;
const state = JSON.parse(CryptoJS.AES.decrypt(encryptedState, process.env.AES_PASSPHRASE).toString(CryptoJS.enc.Utf8));
const oauth2Client = getAuthClient();
res.contentType = 'json';
bot.loadSession(state.address, (sessionLoadError, session) => {
if (sessionLoadError) {
console.log('SessionLoadError:' + sessionLoadError);
beginErrorDialog(session, { error: 'unable to load session' });
res.send(401, {
status: 'Unauthorized'
});
} else if (oauthError) {
console.log('OAuthError:' + oauthError);
beginErrorDialog(session, { error: 'Access Denied' });
res.send(401, {
status: 'Unauthorized'
});
} else {
oauth2Client.getToken(code, (error, tokens) => {
if (!error) {
oauth2Client.setCredentials(tokens);
res.send(200, {
status: 'success'
});
beginStoreTokensAndResume(session, {
tokens: tokens
});
} else {
beginErrorDialog(session, {
error: error
});
res.send(500, {
status: 'error'
});
}
});
}
next();
});
};
练习 7-1
设置 谷歌 使用 Gmail 访问权限认证
本练习的目标是创建一个允许用户根据 Gmail API 进行授权的机器人。您的目标是遵循以下步骤:
-
设置一个 Google 项目,并启用对 Google Gmail API 的访问。
-
创建 OAuth 客户端 ID 和密码。
-
在您的 bot 中创建一个基本工作流,允许用户使用 Gmail 范围登录 Google,并将令牌存储在用户的私人对话数据中。
在本练习结束时,您将已经创建了一个可以代表 bot 用户访问 Gmail API 的 bot。
与 Google 日历 API 集成
我们现在已经准备好与 Google 日历 API 集成了。有几件事我们应该先解决。Google 日历允许用户访问多个日历,并且每个日历有不同的权限级别。在我们的机器人中,我们假设在任何时候我们只在一个日历中查询或添加事件,尽管这看起来有缺陷。我们可以扩展 LUIS 应用和 bot,使其能够为每个话语指定一个日历。
为了解决这个问题,我们创建了一个 PrimaryCalendar 对话框,允许用户设置、重置和检索他们的主日历。类似于在每个需要认证的对话开始时调用的确保凭证对话,我们创建了一个类似的机制来保证日历被设置为主日历。
在我们到达那里之前,让我们谈论连接到 Google 日历 API。Google API Node 包包括日历 API 等。API 使用以下格式:
API.Resource.Method(args, function (error, response) {
});
日历呼叫将如下所示:
calendar.events.get({
auth: auth,
calendarId: calendarId,
eventId: eventId
}, function (err, response) {
// do stuff with the error and/or response
});
首先,我们将使其适应 JavaScript Promise6模式。承诺使异步调用变得容易。JavaScript 中的承诺表示操作的最终完成或失败,以及它的返回值。它支持一个允许我们对结果执行操作的然后方法和一个允许我们对错误对象执行操作的 catch 方法。承诺可以链接:一个承诺的结果可以传递给另一个承诺,后者产生的结果可以传递给另一个承诺,依此类推,产生如下所示的代码:
promise1()
.then(r1 => promise2(r2))
.then(r2 => promise3(r2))
.catch(err => console.log('Error in promise chain. ' + err));
我们修改后的 Google Calendar Promise API 将如下所示:
gcalapi.getCalendar(auth, temp)
.then(function (result) {
// do something with result
}).catch(function (err) {
// do something with err
});
我们将所有必要的功能包装在一个名为 calendar-api 的模块中。下面是一些代码:
const google = require('googleapis');
const calendar = google.calendar('v3');
function listEvents (auth, calendarId, start, end, subject) {
const p = new Promise(function (resolve, reject) {
calendar.events.list({
auth: auth,
calendarId: calendarId,
timeMin: start.toISOString(),
timeMax: end.toISOString(),
q: subject
}, function (err, response) {
if (err) reject(err);
resolve(response.items);
});
});
return p;
}
function listCalendars (auth) {
const p = new Promise(function (resolve, reject) {
calendar.calendarList.list({
auth: auth
}, function (err, response) {
if (err) reject(err);
else resolve(response.items);
});
});
return p;
};
随着 API 的工作,我们现在将焦点转向 PrimaryCalendar 对话框。这个对话框必须处理几种情况。
-
如果用户发送诸如“获取主日历”或“设置主日历”之类的话语,会发生什么?前者应该返回日历的卡片表示,后者应该允许用户选择日历卡片。
-
如果用户登录后没有设置主日历,会发生什么?此时,我们会自动尝试让用户选择一个日历。
-
如果用户通过日历卡上的操作按钮选择日历,会发生什么?
-
如果用户通过键入日历名称来选择日历,会发生什么情况?
-
如果用户试图执行一个需要设置日历的操作(比如添加一个新的约会),会发生什么?
PrimaryCalendar 对话框是一个包含三个步骤的瀑布式对话框。步骤 1 通过调用确保凭证来确保用户登录。步骤 2 期望接收来自用户的命令。我们可以获取当前的主日历,设置日历,或者重置日历;因此,这三个命令是 get、set 或 reset。设置日历需要一个可选的日历 ID。如果没有传递日历 ID,set 命令等同于 reset 命令。Reset 只是向用户发送一个用户可以写访问的所有可用日历的列表(另一个简化的假设)。
get 案例由以下代码处理:
let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
temp = session.privateConversationData.calendarId;
}
gcalapi.getCalendar(auth, temp).then(result => {
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([utils.createCalendarCard(session, result)]);
session.send(msg);
}).catch(err => {
console.log(err);
session.endDialog('No calendar found.');
});
复位盒向用户发送一系列日历卡片。如果用户输入一个文本输入,瀑布的第三步假设输入是一个日历名称,并设置正确的日历。如果输入未被识别,则会发送一条错误消息。
handleReset(session, auth);
function handleReset (session, auth) {
gcalapi.listCalendars(auth).then(result => {
const myCalendars = _.filter(result, p => { return p.accessRole !== 'reader'; });
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments(_.map(myCalendars, item => { return utils.createCalendarCard(session, item); }));
builder.Prompts.text(session, msg);
}).catch(err => {
console.log(err);
session.endDialog('No calendar found.');
});
}
createCalendarCard 方法只是发送一张带有标题、副标题和发送设置日历命令的按钮的卡片。按钮回发该值:设置主日历为{日历} 。
function createCalendarCard (session, calendar) {
const isPrimary = session.privateConversationData.calendarId === calendar.id;
let subtitle = 'Your role: ' + calendar.accessRole;
if (isPrimary) {
subtitle = 'Primary\r\n' + subtitle;
}
let buttons = [];
if (!isPrimary) {
let btnval = 'Set primary calendar to ' + calendar.id;
buttons = [builder.CardAction.postBack(session, btnval, 'Set as primary')];
}
const heroCard = new builder.HeroCard(session)
.title(calendar.summary)
.subtitle(subtitle)
.buttons(buttons);
return heroCard;
};
这提出了一个有趣的挑战。如果在除了 PrimaryCalendar 对话框之外的任何上下文中发送日历卡片,我们需要一个完整的话语来解析一个全局动作,然后调用 PrimaryCalendar 对话框。然而,如果我们在主日历对话框的上下文中提供这样的卡片,按钮仍然会触发全局动作,因此重置我们的整个堆栈。我们不想根据哪个对话框创建了卡片来设置不同的文本,因为这些按钮保留在聊天历史中,可以随时点击。
此外,如果调用了 PrimaryCalendar 对话框,我们希望确保它不会删除当前对话框。例如,如果我正在添加一个约会,我应该能够切换日历,然后回到流程中的正确步骤。
我们覆盖了触发动作和选择动作方法来确保正确的行为。如果 PrimaryCalendar 对话框的另一个实例在堆栈上,我们替换它。否则,我们将把 PrimaryCalendar 对话框推到堆栈的顶部。
.triggerAction({
matches: constants.intentNames.PrimaryCalendar,
onSelectAction: (session, args, next) => {
if (_.find(session.dialogStack(), function (p) { return p.id.indexOf(constants.dialogNames.PrimaryCalendar) >= 0; }) != null) {
session.replaceDialog(args.action, args);
} else {
session.beginDialog(args.action, args);
}
}
});
如果当用户在另一个主日历对话框的实例中时调用一个主日历对话框,我们用主日历对话框的另一个实例替换顶部的对话框。实际上,在这里请原谅我,这只会发生在重置命令中,它实际上会取代构建器。我们在中调用的提示文本对话框。
所以,本质上我们以一个 PrimaryCalendar 对话框等待一个响应对象结束,这个响应对象现在可以来自另一个 PrimaryCalendar 对话框。我们可以让最顶层的实例在完成后返回一个标志,这样当第三步继续时,另一个实例就退出了。下面是说明这一逻辑的最后一个瀑布步骤:
function (session, args) {
// if we have a response from another primary calendar dialog, we simply finish up!
if (args.response.calendarSet) {
session.endDialog({ response: { calendarSet: true } });
return;
}
// else we try to match the user text input to a calendar name
var name = session.message.text;
var auth = authModule.getAuthClientFromSession(session);
// we try to find the calendar with a summary that matches the user's input.
gcalapi.listCalendars(auth).then(function (result) {
var myCalendars = _.filter(result, function (p) { return p.accessRole != 'reader'; });
var calendar = _.find(myCalendars, function (item) { return item.summary.toUpperCase() === name.toUpperCase(); });
if (calendar == null) {
session.send('No such calendar found.');
session.replaceDialog(constants.dialogNames.PrimaryCalendar);
}
else {
session.privateConversationData.calendarId = result.id;
var card = utils.createCalendarCard(session, result);
var msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card])
.text('Primary calendar set!');
session.send(msg);
session.endDialog({ response: { calendarSet: true } });
}
}).catch(function (err) {
console.log(err);
session.endDialog('No calendar found.');
});
}
设置动作不太复杂。如果我们在收到用户消息的同时收到一个日历 ID,我们只需设置该消息并发回一张日历卡片。如果我们没有收到日历 ID,我们假设与 reset 相同的行为。
let temp = null;
if (calendarId) { temp = calendarId.entity; }
if (!temp) {
handleReset(session, auth);
} else {
gcalapi.getCalendar(auth, temp).then(result => {
session.privateConversationData.calendarId = result.id;
const card = utils.createCalendarCard(session, result);
const msg = new builder.Message(session)
.attachmentLayout(builder.AttachmentLayout.carousel)
.attachments([card])
.text('Primary calendar set!');
session.send(msg);
session.endDialog({ response: { calendarSet: true } });
}).catch(err => {
console.log(err);
session.endDialog('this calendar does not exist');
// this calendar id doesn't exist...
});
}
这是一个很大的过程,但它很好地说明了一些需要发生的对话体操,以确保一致和全面的对话体验。在下一节中,我们将把认证和主日历流程集成到我们在第六章中开发的对话框中,并将逻辑连接到对 Google Calendar API 的调用。
实现 Bot 功能
至此,我们已经准备好将我们的 bot 代码连接到 Google Calendar API。我们的代码从它的第五章状态没有太大的变化。这些是我们的对话框的主要变化:
-
我们必须确保用户已经登录。
-
我们必须确保设置了主日历。
-
利用谷歌日历 API 最终让事情发生!
让我们从前两项开始。为此,我们创建了保证凭证和主日历对话框。在提供的代码中,我们的 authModule 和 primaryCalendarModule 模块包含两个助手来调用 EnsureCredentials 和 PrimaryCalendar 对话框。我们的每个功能都可以利用助手来确保设置凭证和主日历。
对于那些对话来说,这是太多的责任了。我们必须在每个对话框中添加两个步骤。相反,让我们创建一个对话框,它可以按照正确的顺序评估所有的预检查,并简单地将一个结果传递给调用对话框。下面是我们实现这一目标的方法。我们创建一个名为 PreCheck 的对话框。该对话框将进行必要的检查,如果有错误,将返回一个带有错误集的响应对象,以及一个指示哪个检查失败的标志。
bot.dialog('PreCheck', [
function (session, args) {
authModule.ensureLoggedIn(session);
},
function (session, args) {
if (!args.response.authenticated) {
session.endDialogWithResult({ response: { error: 'You must authenticate to continue.', error_auth: true } });
} else {
primaryCalendarModule.ensurePrimaryCalendar(session);
}
},
function (session, args, next) {
if (session.privateConversationData.calendarId) session.endDialogWithResult({ response: { } });
else session.endDialogWithResult({ response: { error: 'You must set a primary calendar to continue.', error_calendar: true } });
}
]);
任何需要设置 auth 和主日历的对话框只需调用预检查对话框并确保没有错误。这里有一个来自示例代码中的 ShowCalendarSummary 对话框的例子。注意,瀑布中的第一步调用预检,第二步确保所有预检成功通过。
lib.dialog(constants.dialogNames.ShowCalendarSummary, [
function (session, args) {
g = args.intent;
prechecksModule.ensurePrechecks(session);
},
function (session, args, next) {
if (args.response.error) {
session.endDialog(args.response.error);
return;
}
next();
},
function (session, args, next) {
// do stuff
}
]).triggerAction({ matches: constants.intentNames.ShowCalendarSummary });
前两项就这样了。至此,只剩下第三个了;我们需要实现与谷歌日历 API 的实际集成。以下是 ShowCalendarSummary 对话框第三步的示例。注意,我们收集了 datetimeV2 实体来计算我们需要检索哪个时间段的事件,我们可以选择使用 Subject 实体来过滤日历项目,并且我们构建了一个按日期排序的事件卡片转盘。 createEventCard 方法为每个 Google 日历 API 事件对象创建一个 HeroCard 对象。
其余对话框的实现可以在本书附带的 calendar-bot-building 存储库中找到。
function (session, args, next) {
var auth = authModule.getAuthClientFromSession(session);
var entry = new et.EntityTranslator();
et.EntityTranslatorUtils.attachSummaryEntities(entry, session.dialogData.intent.entities);
var start = null;
var end = null;
if (entry.hasRange) {
if (entry.isDateTimeEntityDateBased) {
start = moment(entry.range.start).startOf('day');
end = moment(entry.range.end).endOf('day');
} else {
start = moment(entry.range.start);
end = moment(entry.range.end);
}
} else if (entry.hasDateTime) {
if (entry.isDateTimeEntityDateBased) {
start = moment(entry.dateTime).startOf('day');
end = moment(entry.dateTime).endOf('day');
} else {
start = moment(entry.dateTime).add(-1, 'h');
end = moment(entry.dateTime).add(1, 'h');
}
}
else {
session.endDialog("Sorry I don't know what you mean");
return;
}
var p = gcalapi.listEvents(auth, session.privateConversationData.calendarId, start, end);
p.then(function (events) {
var evs = _.sortBy(events, function (p) {
if (p.start.date) {
return moment(p.start.date).add(-1, 's').valueOf();
} else if (p.start.dateTime) {
return moment(p.start.dateTime).valueOf();
}
});
// should also potentially filter by subject
evs = _.filter(evs, function(p) {
if(!entry.hasSubject) return true;
var containsSubject = entry.subject.toLowerCase().indexOf(entry.subject.toLowerCase()) >= 0;
return containsSubject;
});
var eventmsg = new builder.Message(session);
if (evs.length > 1) {
eventmsg.text('Here is what I found...');
} else if (evs.length == 1) {
eventmsg.text('Here is the event I found.');
} else {
eventmsg.text('Seems you have nothing going on then. What a sad existence you lead.');
}
if (evs.length >= 1) {
var cards = _.map(evs, function (p) {
return utils.createEventCard(session, p);
});
eventmsg.attachmentLayout(builder.AttachmentLayout.carousel);
eventmsg.attachments(cards);
}
session.send(eventmsg);
session.endDialog();
});
}
function createEventCard(session, event) {
var start, end, subtitle;
if (!event.start.date) {
start = moment(event.start.dateTime);
end = moment(event.end.dateTime);
var diffInMinutes = end.diff(start, "m");
var diffInHours = end.diff(start, "h");
var duration = diffInMinutes + ' minutes';
if (diffInHours >= 1) {
var hrs = Math.floor(diffInHours);
var mins = diffInMinutes - (hrs * 60);
if (mins == 0) {
duration = hrs + 'hrs';
} else {
duration = hrs + (hrs > 1 ? 'hrs ' : 'hr ') + (mins < 10 ? ('0' + mins) : mins) + 'mins';
}
}
subtitle = 'At ' + start.format('L LT') + ' for ' + duration;
} else {
start = moment(event.start.date);
end = moment(event.end.date);
var diffInDays = end.diff(start, 'd');
subtitle = 'All Day ' + start.format('L') + (diffInDays > 1 ? end.format('L') : '');
}
var heroCard = new builder.HeroCard(session)
.title(event.summary)
.subtitle(subtitle)
.buttons([
builder.CardAction.openUrl(session, event.htmlLink, 'Open Google Calendar'),
builder.CardAction.postBack(session, 'Delete event with id ' + event.id, 'Delete')
]);
return heroCard;
};
练习 7-2
与 Gmail API 集成
虽然欢迎您按照上一节中的代码,然后使用随书提供的代码来组装一个日历机器人,但本练习的目标是创建一个可以从用户的 Gmail 帐户发送电子邮件的机器人。通过这种方式,您可以练习练习 7-1 中的验证逻辑,并与以前没有见过的客户端 API 集成。
-
以练习 7-1 中的代码为起点,创建一个包含两个对话框的机器人,一个用于发送邮件,一个用于查看未读消息。没有必要创建 LUIS 应用(尽管您当然可以自由地使用它)。使用关键字发送和列表来调用对话框。
-
对于发送操作,创建一个名为 SendMail 的对话框。这个对话框应该收集电子邮件地址、标题和消息正文。确保该对话框与授权流集成。
-
与 Gmail 客户端库集成,使用在身份验证流程中收集的用户访问令牌发送电子邮件。使用这里的文档获取 messages.send API 调用:
https://developers.google.com/gmail/api/v1/reference/users/messages/send
。 -
对于列表操作,创建一个名为 ListMail 的对话框。该对话框应该使用在授权流期间收集的用户访问令牌从用户的收件箱中获取所有未读邮件。使用这里的文档来调用 messages . list API:
https://developers.google.com/gmail/api/v1/reference/users/messages/list
。 -
将未读邮件列表呈现为一个转盘。显示标题、接收日期和在 web 浏览器中打开电子邮件的按钮。您可以在这里找到消息对象的引用:
https://developers.google.com/gmail/api/v1/reference/users/messages#resource
。消息的 URL 是https://mail.google.com/mail/#inbox/{MESSAGE_ID
}
。
如果你成功创造了这个机器人,恭喜你!这不是最容易的练习,但结果非常值得。现在,您已经掌握了创建 bot、将其与 OAuth 流集成、使用第三方 API 使 bot 发挥作用以及将项目呈现为卡片的技能。干得好!
结论
构建机器人既容易又具有挑战性。用一些简单的命令很容易建立一个基本的机器人。很容易获得用户话语并基于它们执行代码。然而,获得恰到好处的用户体验是相当具有挑战性的。正如我们所观察到的,开发机器人的挑战是双重的。
首先,我们需要理解自然语言话语的许多排列。我们的用户可以用不同的方式说同样的事情,只是有细微的差别。我们为这本书构建的 LUIS 应用是一个良好的开端,但是还有许多其他方式来表达相同的想法。我们需要判断什么时候 LUIS 应用足够好。Bot 测试是很多这类评估发生的地方。一旦我们在你的机器人上释放一组用户,我们将看到用户最终如何使用你的机器人,以及他们期望处理什么类型的输入和行为。这是我们提高自然语言理解和决定下一步构建什么功能所需的数据。我们将在第十三章介绍帮助完成这项任务的分析工具。
第二,花时间在整体对话体验上是很重要的。虽然这不是本书的重点,但适当的体验是我们机器人成功的关键。我们确实花了一些时间来思考如何确保用户在进入针对日历 API 的任何操作的对话框之前登录。这是我们开发机器人时需要考虑的行为和流程类型的一个例子。一个更天真的机器人可能只是给用户发送一个错误,说他们需要先登录,然后用户不得不重复输入。一个更好的实现是通过我们在本章中创建的对话框进行重定向。幸运的是,Bot Builder SDK 及其对话模型帮助我们在代码中描述这些复杂的流程。
我们现在有技能和经验来开发复杂和惊人的机器人体验,与所有类型的 API 集成。这才是 LUIS 和微软 Bot 框架真正的合力!
八、扩展通道功能
到目前为止,我们已经花了大量的时间讨论 NLU 系统、对话体验,以及我们如何通过 Bot Builder SDK 使用通用格式以通用方式开发 Bot。Bot Builder SDK 让我们可以快速启动并运行。这是为什么它是如此强大的抽象的部分原因。但坦率地说,该领域的许多创新来自各种消息平台。例如,Slack 在协作软件方面处于领先地位。Slack 编辑消息的能力非常强大,支持交互式工作流。
在这一章中,我们将探索从一个机器人框架内调用本机功能的能力。我们将学习调用 Slack 的特性,将简单的基于文本的工作流转换成丰富的基于按钮和菜单的体验。在这个过程中,我们将注册一个 Slack 集成,将我们的 bot 连接到我们的 Slack 工作区,然后使用本地 Slack 调用来创建一个引人注目的简单工作流。让我们开始吧。
更深层次的松散集成
Slack 是一个丰富的平台,允许内部和外部团队的不同成员之间紧密协作。界面很简单,但消息传递框架与 Facebook Messenger 之类的东西非常不同。例如,虽然有一个名为 attachments 的工具可以产生一个类似于卡片的用户界面,但它并没有被以同样的方式对待。没有旋转木马,对图像的长宽比没有要求。
Slack 中的消息只是一个带有文本属性的 JSON 对象,其中的文本可以有引用用户、通道或团队的特殊序列。这些引用名为@提及,是类似@频道的文本串,通知一个频道的所有用户关注一条消息。其他例子还有@这里和@大家。一封邮件最多可以包含 20 个附件。附件只是一个为邮件提供附加上下文的对象。JSON 对象如下所示:
{
"attachments": [
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "Optional text that appears within the attachment",
"fields": [
{
"title": "Priority",
"value": "High",
"short": false
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png",
"footer": "Slack API",
"footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png",
"ts": 123456789
}
]
}
像英雄卡片一样,我们可以包含标题、文本和图片。此外,我们还可以为 Slack 提供各种其他参数。我们可以引用消息作者、数据字段或主题颜色。
为了帮助处理附件的细微差别,Slack 包含了一个消息生成器(图 8-1 ,它可以用来可视化 JSON 对象在 Slack 用户界面中的呈现方式。
图 8-1
松弛消息生成器和预览
Slack 还为消息提供了最佳实践文档。网站上的建议之一是尽可能少地使用对我们的应用有意义的附件(图 8-2 )。
图 8-2
好方向…
不幸的是,这似乎不是 Bot 框架的工作方式。事实上,Slack Bot 通道连接器将一个 HeroCard 对象呈现为多个附件(图 8-3 )。
图 8-3
只是 Slack Bot 通道连接器没有完全遵守 Slack 指南
这是一个小细节,但它就是不好看。图像和按钮的默认样式是渲染图像下方的按钮(图 8-4 )。不幸的是,渲染违反了 Slack 提供的方向。
图 8-4
一个格式良好的附件会是什么样子
自然,这是 Bot 框架团队将来最有可能支持的细节。在此之前,如果我们想要呈现的界面类型和平台支持的内容不匹配,我们可以使用原生 JSON 来实现我们的目标。
Slack 还包括一些我们作为 bot 服务中的一等公民无法访问的功能。Slack 支持临时消息,即在组设置中仅对一个用户可见的消息。Bot Builder SDK 没有提供实现这一点的简单方法。此外,Slack 支持交互式消息的概念,即带有按钮和菜单的消息,用户可以对其进行操作。更好的是,用户的动作可以触发消息呈现的更新!一条消息可以包括按钮,作为从用户那里收集数据的一种方式(如图 8-3 和 8-4 所示),或者一条消息可以包括菜单来选择一个选项(图 8-5 )。
图 8-5
简单的菜单
在本节中,我们将探讨如何通过本机消息紧密集成来实现交互式消息效果。
首先,我们将把我们的机器人与一个 Slack 工作空间集成起来。其次,我们将创建一个一步到位的交互式消息。第三,我们将创建一个多步骤的交互式消息,提供丰富的、松散的本地数据收集体验。
在我们继续之前,让我们回顾一些基本规则。本章并不打算让你深入了解 Slack 的消息传递 API 和特性。我们鼓励你自己去阅读这些;Slack 有关于这个主题的非常丰富的文档。我们想要展示的是我们如何利用 bot 服务来提供与 Slack 的更深层次的集成。你可能会问,为什么不直接用 Slack 的 Node 开发工具包开发一个原生 Slackbot 呢?当然可以,但是使用 Bot Builder 库有两个主要原因。第一,您可以获得对话和对话引擎来帮助指导用户完成对话;第二,如果您在多个消息传递通道上公开体验,一个代码库可以实现代码重用。
连接到时差
让我们假设你从来没有使用过 Slack。我们首先需要创建一个宽松的工作空间。工作空间只是一个团队协作的宽松环境。我们可以免费创造这些。有一些限制,但自由团队仍然非常实用,肯定会允许我们开发和演示 Slack 机器人。转到 https://slack.com/create
创建一个工作区。Slack 会要求发邮件(图 8-6 )并发送确认码来验证我们的身份。
图 8-6
创建新的可宽延工作空间
一旦我们输入确认码,它将要求我们输入我们的姓名、密码、(组)工作区名称、目标受众和工作区 URL。我们可以向工作区发送邀请,但现在我们将跳过这一步。我们不会被重定向到工作区。出于演示的目的,我的名字是 https://srozgaslacksample.slack.com
。
此时,我们应该整合 bot 服务和 Slack。在 Azure 上的 Bot 服务条目中,单击 Slack 频道。我们将看到松弛配置屏幕(图 8-7 )。
图 8-7
配置我们的机器人的松散集成
该界面类似于 Facebook Messenger 频道配置界面,但要求不同的数据。我们需要来自 Slack 的三条信息:客户机 ID、客户机秘密和验证令牌。
在 https://api.slack.com/apps
登录 Slack,新建一个 app。输入应用名称并选择我们刚刚创建的开发工作区(图 8-8 )。最后点击创建应用按钮。
图 8-8
创建 Slack 应用
创建应用后,我们将被重定向到应用页面。点击权限设置重定向网址(图 8-9 )。你将被带到一个名为 OAuth &权限的页面。
图 8-9
设置 bot 服务重定向 URI
点击添加新的重定向网址,输入 https://slack.botframework.com
。接下来选择左侧工具条中的机器人用户项,并为机器人添加一个用户。这允许我们给机器人分配一个用户名,并指示它是否应该总是在线出现(图 8-10 )。
图 8-10
创建在通道中代表机器人的机器人用户
接下来,我们将订阅几个事件,这些事件将被发送到 bot 服务 web 钩子。这将确保 bot 服务能够正确地将相关的 Slack 事件发送到我们的 bot 中。导航到事件订阅,通过右边的开关启用事件,输入 https://slack.botframework.com/api/Events/{YourBotHandle}
作为请求 URL。在第五章中,一个机器人句柄被分配给我们的机器人频道注册,可以在设置页面中找到。一旦进入,Slack 将建立到端点的连接。最后,在下订阅 Bot 事件(不是工作区事件!)添加以下事件:
-
会员 _ 加入 _ 通道
-
成员 _ 左 _ 频道
-
消息.通道
-
消息.组
-
message.im
-
message.er
图 8-11 显示了最终的配置。
图 8-11
为我们的机器人订阅空闲事件
我们还需要启用交互式组件来支持通过菜单、按钮或交互式对话框接收消息。在左侧菜单中选择交互组件,点击启用交互消息,输入如下请求 URL: https://slack.botframework.com/api/Actions
(图 8-12 )。点击启用交互组件并保存更改。
图 8-12
在我们的机器人中启用交互式组件。这意味着按钮和菜单!
最后,我们从应用凭证部分(可通过基本信息菜单项访问)提取凭证,并将客户端 ID、客户端密码和验证令牌输入 Azure 门户中 bot 通道注册的通道刀片内的配置备用屏幕。提交后,您将被要求登录到您的 Slack 工作区并验证该应用。授权后,你的 bot 会出现在你的 Slack workspace 界面(在 Apps 类别下),你就可以和它交流了(图 8-13 )。
图 8-13
我们已经连接到 Azure bot 服务
记得运行 ngrok!在图 8-13 中,你可以看出我忘记了运行我的 ngrok。
练习 8-1
基本松弛度整合和 消息渲染
本练习的目标是将一个机器人连接到 Slack,这样您就可以熟悉作为消息传递和机器人平台的 Slack。你的目标是将你在第 5 和 7 章节中创建的日历机器人部署到 Slack。部署完成后,您可以对比模拟器或 Facebook Messenger 检查不同元素在 Slack 中的呈现方式。
-
创建一个测试松弛工作空间。
-
按照上一节中的步骤将 Azure Bot 服务 Bot 连接到工作区。
-
确认你可以通过 Slack 和你的机器人交流。
-
测试机器人并回答以下问题:机器人如何呈现登录按钮?机器人如何渲染主卡选择卡?bot 在多用户对话中如何表现(您可能需要向工作区添加一个新的测试用户)?
干得好。现在,您可以将一个现有的 bot 连接到 Slack,并且了解 Slack、它的消息和附件。
试用 Slack APIs
我们只是使用 Bot Builder SDK 和 Bot 框架向 Slack 发送消息,但是我们也可以直接访问 Slack APIs。我们对几个 Slack API 方法感兴趣。 2
-
Chat.postMessage :在空闲频道发布新消息
-
Chat.update :更新 Slack 中的现有消息
-
Chat.postEphemeral :在 Slack 频道中发布一条新的短暂消息,只有一个用户可以看到
-
Chat.delete :删除一条松弛消息
要调用这些,我们需要一个访问令牌。例如,假设我们有一个令牌,我们可以使用下面的 Node.js 代码来创建一个新消息:
function postMessage(token, channel, text, attachments) {
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: 'https://slack.com/api/chat.postMessage',
headers: {
Authorization: 'Bearer ' + token
}
});
client.post('',
{
channel: channel,
text: text,
attachments: attachments
},
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);
});
});
}
一个自然的问题是我们如何获得令牌?如果我们检查来自 bot 服务通道连接器的消息,我们会注意到我们拥有所有这些信息。来自 Slack 的完整传入消息如下所示:
{
"type": "message",
"timestamp": "2017-11-23T17:27:13.5973326Z",
"text": "hi",
"attachments": [],
"entities": [],
"sourceEvent": {
"SlackMessage": {
"token": "fffffffffffffffffffffff",
"team_id": "T84FFFFF",
"api_app_id": "A84SFFFFF",
"event": {
"type": "message",
"user": "U85MFFFFF",
"text": "hi",
"ts": "1511458033.000193",
"channel": "D85TN0231",
"event_ts": "1511458033.000193"
},
"type": "event_callback",
"event_id": "Ev84PDKPCK",
"event_time": 1511458033,
"authed_users": [
"U84A79YTB"
]
},
"ApiToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"address": {
"id": "ffffffffffffffffffffffffffffffffff",
"channelId": "slack",
"user": {
"id": "U85M9EQJ2:T84V64ML5",
"name": "szymon.rozga"
},
"conversation": {
"isGroup": false,
"id": "B84SQJLLU:T84V64ML5:D85TN0231"
},
"bot": {
"id": "B84SQJLLU:T84V64ML5",
"name": "szymontestbot"
},
"serviceUrl": "https://slack.botframework.com"
},
"source": "slack",
"agent": "botbuilder",
"user": {
"id": "U85M9EQJ2:T84V64ML5",
"name": "szymon.rozga"
}
}
请注意, sourceEvent 包括一个 ApiToken 和一个 SlackMessage ,其中包含了关于机器人位于哪个通道以及原始消息来自哪个用户的所有详细信息。本例中,通道为 D85TN0231,用户为 U85M9EQJ2。此外,我们可以找到团队、机器人、机器人用户和应用的 id。传入消息在 Slack 中实际上没有 ID;每条消息都有一个唯一的每通道时间戳,称为 ts 。
因此,一旦我们收到来自用户的第一条消息,我们可以通过使用 Bot Builder 的 session.send 方法或者直接使用 chat.postMessage 端点来轻松地做出响应(图 8-14 )。当然, session.send 通过调用 Slack channel 连接器来为我们做所有的令牌工作,然后 Slack channel 连接器调用 chat.postMessage 。
图 8-14
使用本机松弛调用进行响应
const bot = new builder.UniversalBot(connector, [
session => {
let token = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
postMessage(token, channel, 'POST!');
}
]);
除了 chat.postMessage 返回消息的原生 ts 值,而 session.send 不返回之外,postMessage 并没有比 session.send 更好的东西。非常酷。这意味着我们现在可以更新消息了!我们定义一个 updateMessage 方法如下:
function updateMessage(token, channel, ts, text, attachments) {
return new Promise((resolve, reject) => {
let client = restify.createJsonClient({
url: 'https://slack.com/api/chat.update',
headers: {
Authorization: 'Bearer ' + token
}
});
client.post('',
{
channel: channel,
ts: ts,
text: text,
attachments: attachments
},
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);
});
});
};
现在,我们可以编写代码来发送消息,并在任何其他响应到来时更新它(参见图 8-15 ,图 8-16 ,图 8-17 )。
图 8-17
完全按照设计
图 8-16
似乎在起作用…
图 8-15
到目前为止一切顺利…
let msgts = null;
const bot = new builder.UniversalBot(connector, [
session => {
let token = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
if (msgts) {
updateMessage(token, channel, msgts, '<@' + user + '> said ' + session.message.text);
} else {
postMessage(token, channel, 'A placeholder...').then(r => {
msgts = r.ts;
});
}
}
]);
现在这是一个虚构的例子,但是它说明了我们调用一个 postMessage 后跟一个更新来修改消息内容的能力。关于 update 到底能做什么有一些规则,但是我们把阅读文档 3 作为开发人员的练习。
我们可以用 API 完成的另一个例子是发布和删除短暂的消息。短暂的消息仅对消息的接收者可见。例如,机器人可以向用户提供反馈,而不在频道中显示结果,直到收集了所有必要的数据。虽然交互模型略有不同,但是 giphy 4 斜杠命令是这种模型的一个很好的例子。
使用/ giphy 允许我们搜索任何文本,并在短暂的消息中显示一些 GIF 选项。在利用集成之前,您可能必须首先启用它。一旦我们决定使用哪一个并点击发送,GIF 就会以我们的名义发送到频道(图 8-18 ,图 8-19 ,图 8-20 )。
图 8-20
我现在通过使用/giphy mean girls,在 Slack conversation 中使 2004 年的邪教经典《Mean Girls》不朽
图 8-19
一个酷妈妈意味着女孩 GIF 预览
图 8-18
调用/giphy 斜杠命令
我们可以使用 postEphemeral 消息只给某些用户反馈。当然,delete 使我们能够从 bot 中删除旧消息。从可用性的角度来看,删除功能并不有趣。用一个修正来更新一个消息,或者通知用户一个消息已经被删除了,这是一个更好的体验,而不是简单地删除它而不做任何解释。
简单的交互式消息
Slack 允许我们使用所谓的交互式消息来实现更好的对话体验。 5 交互消息是包括通常的消息数据加上按钮和菜单的消息。此外,当用户与用户界面元素交互时,消息可以改变以反映这一点。
下面是一个例子:机器人会发送一条请求批准的消息,当用户单击“是”或“否”按钮时,我们的机器人会修改消息以反映选择(图 8-21 ,图 8-22 ,图 8-23 )。
图 8-23
请求未被批准
图 8-22
请求已批准
图 8-21
简单的互动信息
当然,我们可以使用 postMessage 和 updateMessage 来编排这种类型的行为,但是有一种更简单、更集成的方式来实现。首先,我们定义了一个名为 simpleflow 的对话框,它使用选择提示来发送带有按钮的消息。
const bot = new builder.UniversalBot(connector, [
session => {
session.beginDialog('simpleflow');
},
session => {
session.send('done!!!');
session.endConversation();
}
]);
bot.dialog('simpleflow',
[
(session, arg) =>{
builder.Prompts.choice(session, 'A request for access to /SYS13/ABD has come in. Do you want to approve?', 'Yes|No');
},
... // next code snippet goes here
]);
然后,我们通过向 response_url 发出 POST 请求来处理对按钮点击的响应。
(session, arg) =>{
let r = arg.response.entity;
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
let userId = session.message.sourceEvent.Payload.user.id;
let attachment ={
color: 'danger',
text: 'Rejected by <@' + userId + '>'
};
if (r === 'No'){} else if (r === 'Yes'){
attachment ={
color: 'good',
text: 'Approved by <@' + userId + '>'
};
}
client.post('',
{
token: token,
text: 'Request for access to /SYS13/ABD',
attachments: [attachment
]
}, function (err, req, res, obj){
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
这里发生了一些事情。首先,我们从 Slack 获取响应,该响应被解析为实体值。其次,我们从 Slack 消息中获取所谓的 response_url。response_url 是一个 url,它允许我们修改用户刚刚响应的交互消息,或者在频道中创建新消息。接下来,我们获取授权我们向 response_url 发送 POST 请求的令牌。最后,我们向 response_url 发送更新后的消息。
我们将围绕交互式消息结构讨论更多的细节,但是让我们讨论用户体验。在开发利用这种功能的机器人时,我们必须做出决定:当机器人呈现交互消息时,用户是必须立即回答它,还是可以在用户和机器人讨论其他话题时将交互消息保留在历史中?在后一种情况下,在对话后期的任何时候,用户都可以向上滚动并单击一个按钮来完成该操作。前一个示例使用了前一种方法;这就是 Bot Builder 提示的工作方式。图 8-24 显示了如果用户没有回复消息时的情况。
图 8-24
嗯…似乎我有两组按钮来回答同一个问题
好的,我们有两组按钮。有道理。如果我们点击是或否按钮,该信息将根据图 8-25 进行修改。对话框结束,bot 瀑布的第二步发送“完成!!!"消息。然而,谈话处于一种奇怪的状态;似乎原始请求仍未完成。
图 8-25
第一条消息不也应该更新吗?
现在,对话框堆栈顶部不再包含选择提示。这意味着,如果我们点击上方消息中的 Yes 或 No 按钮,我们将会遇到问题,因为我们的代码不期望这种类型的响应(图 8-26 )。事实上,我们将收到另一个提示,因为机器人再次调用 beginDialog 。拥有多个未解决的交互消息而没有能力解决所有这些消息是糟糕的 UX。
图 8-26
哦,那没有意义…
这种经历会很快变得复杂。这是任何平台上呈现按钮的问题:按钮留在聊天记录中,可以随时点击。作为开发人员,我们的角色是确保机器人能够在任何时候处理按钮和它们的有效载荷。
这里有一种方法可以解决前面的问题。我们保留默认行为不变,但是我们创建了一个自定义识别器,它处理交互式消息输入并将消息重定向到一个对话框,告诉用户操作已经过期,如果这些输入不是预期的。让我们从对话开始。它将读取交互式消息的 response_url,并简单地发布一条“对不起,此操作已过期。”给它发信息。当机器人解析意图 practicalbot.expire 时,该对话框被调用。这样的命名约定允许我们区分 LUIS 意图和机器人内部意图。
bot.dialog('remove_action',
[
(session, arg) =>{
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
client.post('',
{
token: token,
text: 'Sorry, this action has expired.'
}, function (err, req, res, obj){
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
]).triggerAction({ matches: 'practicalbot.expire'
});
自定义识别器如下所示:
bot.recognizer({
recognize: function (context, done){
let intent = { score: 0.0 };
if (context.message.sourceEvent &&
context.message.sourceEvent.Payload &&
context.message.sourceEvent.Payload.response_url)
{
intent = { score: 1.0, intent: 'practicalbot.expire' };
}
done(null, intent);
}
});
简而言之,我们说如果我们的对话框不能显式地处理来自用户的动作响应,那么全局 practicalbot.expire 意图将会被触及。在这种情况下,我们只需告诉用户操作已经过期。净效果如图 8-27 和图 8-28 所示。我们首先进入这样一个场景,有两条交互消息要求我们输入是或否。我们赞成第二个。在图 8-28 中,我们在第一组按钮上点击是。
图 8-28
有效。我们现在可以在不造成 UX 混乱的情况下使用旧的交互信息。
图 8-27
好,回到这个场景
有几个我们应该提到的警告。首先,如果您尝试使用文本而不是单击按钮来响应提示,所提供的代码将会失败。这是为什么?Slack 不发送包含消息交互细节的有效负载对象。这只会被认为是文本输入,我们没有办法正确地将消息更新为被批准或被拒绝。处理这个问题的一种方法是只需要按钮输入,而不是文本输入。另一种方法是接受它,但将确认作为新消息发送。以下是在图 8-29 中用一条文本消息响应后产生的对话的行为代码:
图 8-29
我们现在也可以处理文本回复
(session, arg) => {
let r = arg.response.entity;
let userId = null;
const isTextMessage = session.message.sourceEvent.SlackMessage; // this means we receive a slack message
if (isTextMessage) {
userId = session.message.sourceEvent.SlackMessage.event.user;
} else {
userId = session.message.sourceEvent.Payload.user.id;
}
Let attachment = {
color: 'danger',
text: 'Rejected by <@' + userId + '>'
};
if (r === 'No') {
} else if (r === 'Yes') {
attachment = {
color: 'good',
text: 'Approved by <@' + userId + '>'
};
}
if (isTextMessage) {
// if we got a text message, reply using
// session.send with the confirmation message
let msg = new builder.Message(session).sourceEvent({
'slack': {
text: 'Request for access to /SYS13/ABD',
attachments: [attachment]
}
});
session.send(msg);
} else {
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
let client = restify.createJsonClient({
url: responseUrl
});
client.post('', {
token: token,
text: 'Request for access to /SYS13/ABD',
attachments: [attachment]
}, function (err, req, res, obj) {
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
session.endDialog();
});
}
}
}
第二点需要注意的是,在前面的例子中,我们使用了 choice 提示,该提示会阻止对话,直到用户发出 yes 或 no 响应。我们希望避免这种行为,以便用户可以继续使用机器人,而不必立即回答提示。更好的方法是安装一个全局识别器,它能够将交互式消息响应映射到意图,而意图又映射到完成某些动作的对话框。我们将在练习 8-2 中看到这一点。
练习 8-2
探索 Slack 中的非阻塞交互消息
在上一节中,我们探讨了如何利用选择提示来要求用户使用交互式消息进行输入。在本练习中,您将创建一个自定义识别器来将交互式消息响应映射到对话框。对话框将包含使用 Slack 提供的 response_url 更新交互消息的逻辑。
-
创建一个通用机器人,它启动一个名为sendbenseapproval的对话框。
-
创建一个名为sendpenseapproval的对话框。对话框应该创建一个随机费用对象,有四个字段: ID 、用户、类型、金额。该对象将表示用户在类型类型的商品上花费了$ 金额的事实。ID 应该只是一个随机的唯一标识符。例如,创建一个对象,表示 Szymon 花了 60 美元乘出租车,或者 Bob 花了 20 美元买了一箱加味汽水。生成随机费用后,向用户发送一张总结费用的英雄卡和两个标签为批准和拒绝的按钮。使用 session.send 发送响应后,结束对话。
-
此时,机器人不做任何事情。修改英雄卡中的批准和拒绝按钮,以便发送到机器人的值是 id 为{ID}的批准请求和 ID 为{ID}的拒绝请求。
-
创建一个自定义识别器来匹配这些模式并提取 ID。您的自定义识别器应该根据输入返回意图 ApproveRequestIntent 或 RejectRequestIntent 。确保在结果识别器对象中包含 ID。
-
创建两个对话框,一个名为 ApproveRequestDialog ,一个名为 RejectRequestDialog 。使用触发动作将对话框连接到相应的意图。
-
确保两个对话框向 response_url 发送正确的批准或拒绝响应,以便更新原始 hero 卡。
本练习中使用的全局处理所有交互消息的技术是强大的和可扩展的。您可以轻松地为任何未来行为添加更多的消息类型、意图和对话框。实际上,您最终可能会得到阻塞和非阻塞消息的混合。您现在已经准备好处理这两种风格。
多步体验
在上一节中,我们创建了一个单步交互式消息。我们将通过一个更复杂的多步骤交互来继续探索 Slack 上的交互消息。假设我们想引导用户通过一个多步骤的过程来选择一种比萨饼、一些配料和一个尺寸。我们将使用多步互动信息来构建体验。本部分的代码包含在本书的 git repos 中;我们将在接下来的几页中分享最相关的内容。
我们的经验如下。该机器人将首先要求用户为他们的比萨饼提供一种酱料类型(图 8-30 )。
图 8-30
你想要什么比萨饼调味汁?
如果用户回答番茄酱,我们的有限机器人将要求用户从两种馅饼中选择一种:普通馅饼或意大利香肠馅饼(图 8-31 )。
图 8-31
番茄酱披萨
如果用户选择了油和大蒜酱,他们将得到一组不同的选项(图 8-32 )。
图 8-32
油大蒜比萨的额外配料选择
最后一步要求用户选择一个尺寸。我们为这一步渲染一个菜单(图 8-33 )。
图 8-33
你想要多大的?
一旦完成,信息将变成订单的总结(图 8-34 )。
图 8-34
用户订单摘要
作为练习,我们将利用本机 Slack APIs。Bot Builder SDK 需要一个对话框步骤来明确使用提示从一个步骤前进到下一个步骤。因为我们将直接使用 Slack API,所以我们将有一个单步瀑布式对话框。这意味着相同的函数将被反复调用,直到识别出不同的全局动作,或者我们的对话框调用 endDialog 。
您可能还记得,在前面的例子中,我们利用 Bot Builder 的提示发送回按钮,并将结果收集回 Bot 中的逻辑。Bot 框架为我们抽象出的一件事情是,向用户发送提示实际上是发送一个带有附件的 Slack 消息,其中包含一组操作,每个按钮都是不同的操作。当用户点击或点击一个按钮时,我们的机器人会有一个回调,回调 ID 用来标识这个动作。
例如,如果我们将这条消息发送给 Slack,它将呈现一条类似图 8-31 的消息。
pizzatype: {
text: 'Sauce',
attachments: [
{
callback_id: 'pizzatype',
title: 'Choose a Pizza Sauce',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Tomato Sauce',
type: 'button'
},
{
name: 'step2b',
value: 'oilandgarlic',
text: 'Oil & Garlic',
type: 'button'
}
]
}
]
}
当点击其中一个按钮时,我们的机器人将收到一个回调 ID 为 pizzatype 和所选值的消息。下面是我们点击番茄酱时收到的消息的相关 JSON 片段:
"sourceEvent": {
"Payload": {
"type": "interactive_message",
"actions": [
{
"name": "regular",
"type": "button",
"value": "regular"
}
],
"callback_id": "pizzatype",
...
},
"ApiToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
所以,判断我们是否得到一个类型的回调的逻辑很简单。事实上,该代码类似于前面展示的识别器代码。我们创建了一个 isCallbackResponse 函数,它可以告诉我们消息是否是回调,或者,它是否是某种类型的回调。
const isCallbackResponse = function (context, callbackId){
const msg = context.message;
let result = msg.sourceEvent &&
msg.sourceEvent.Payload &&
msg.sourceEvent.Payload.response_url;
if (callbackId){
result = result && msg.sourceEvent.Payload.callback_id === callbackId;
}
return result;
};
然后我们可以配置我们的识别器来使用这个函数。
bot.recognizer({
recognize: function (context, done) {
let intent = { score: 0.0 };
if (isCallbackResponse(context)) {
intent = { score: 1.0, intent: 'practicalbot.expire' };
}
done(null, intent);
}
});
现在,我们可以构建一个能够引导用户完成整个过程的对话框。我们首先声明我们将为每个步骤发送的消息。我们将发送以下五条消息之一:
-
选择比萨饼类型的第一条消息
-
根据选择的比萨饼类型,两种配料选择之一
-
披萨尺寸的选择
-
最终确认消息
下面是我们使用的 JSON:
exports.multiStepData = {
pizzatype: {
text: 'Sauce',
attachments: [
{
callback_id: 'pizzatype',
title: 'Choose a Pizza Sauce',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Tomato Sauce',
type: 'button'
},
{
name: 'step2b',
value: 'oilandgarlic',
text: 'Oil & Garlic',
type: 'button'
}
]
}
]
},
regular: {
text: 'Pizza Type',
attachments: [
{
callback_id: 'ingredient',
title: 'Do you want a regular or pepperoni pie?',
actions: [
{
name: 'regular',
value: 'regular',
text: 'Regular',
type: 'button'
},
{
name: 'pepperoni',
value: 'pepperoni',
text: 'Pepperoni',
type: 'button'
}
]
}
]
},
oilandgarlic: {
text: 'Extra Ingredients',
attachments: [
{
callback_id: 'ingredient',
title: 'Do you want ricotta or caramelized onions?',
actions: [
{
name: 'ricotta',
value: 'ricotta',
text: 'Ricotta',
type: 'button'
},
{
name: 'carmelizedonions',
value: 'carmelizedonions',
text: 'Caramelized Onions',
type: 'button'
}
]
}
]
},
collectsize: {
text: 'Size',
attachments: [
{
text: 'Which size would you like?',
callback_id: 'finish',
actions: [
{
name: 'size_list',
text: 'Pick a pizza size...',
type: 'select',
options: [
{
text: 'Small',
value: 'small'
},
{
text: 'Medium',
value: 'medium'
},
{
text: 'Large',
value: 'large'
}
]
}
]
}
]
},
finish: {
attachments: [{
color: 'good',
text: 'Well done'
}]
}
};
然后我们用一个步骤创建一个水流对话框。如果我们从用户那里收到的消息不是回调,我们使用 postMessage 发送第一步。
let apiToken = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
let typemsg = multiFlowSteps.pizzatype;
session.privateConversationData.workflowData ={};
postMessage(apiToken, channel, typemsg.text, typemsg.attachments).then(function (){
console.log('created message');
});
否则,如果消息是回调,我们将确定回调类型,获取消息中传递的数据(根据消息是来自按钮还是菜单,传递的数据会略有不同),适当地保存响应数据,并使用下一个相关消息进行响应。我们使用privateconversiondata来跟踪该状态。一个警告是,我们需要显式地保存状态。
session.save();
通常,状态会被保存为 session.send 调用的一部分。因为我们不再使用这种机制,因为我们直接使用 Slack API,所以我们将在方法的最后显式调用它。我们检测用户是否说“退出”来退出流程。下面是整个方法的样子:
(session, arg, next) => {
if (session.message.text === 'quit') {
session.endDialog();
return;
}
if (isCallbackResponse(session)) {
let responseUrl = session.message.sourceEvent.Payload.response_url;
let token = session.message.sourceEvent.Payload.token;
console.log(JSON.stringify(session.message));
let client = restify.createJsonClient({
url: responseUrl
});
let text = '';
let attachments = [];
let val = null;
const payload = session.message.sourceEvent.Payload;
const callbackChannel = payload.channel.id;
if (payload.actions && payload.actions.length > 0) {
val = payload.actions[0].value;
if (!val) {
val = payload.actions[0].selected_options[0].value;
}
}
if (isCallbackResponse(session, 'pizzatype')) {
session.privateConversationData.workflowData.pizzatype = val;
let ingredientStep = multiFlowSteps[val
];
text = ingredientStep.text;
attachments = ingredientStep.attachments;
}
else if (isCallbackResponse(session, 'ingredient')) {
session.privateConversationData.workflowData.ingredient = val;
var ingredientstep = multiFlowSteps.collectsize;
text = ingredientstep.text;
attachments = ingredientstep.attachments;
}
else if (isCallbackResponse(session, 'finish')) {
session.privateConversationData.workflowData.size = val;
text = 'Flow completed with data: ' + JSON.stringify(session.privateConversationData.workflowData);
attachments = multiFlowSteps.finish.attachments;
}
client.post('',
{
token: token,
text: text,
attachments: attachments
}, function (err, req, res, obj) {
if (err) console.log('Error -> %j', err);
console.log('%d -> %j', res.statusCode, res.headers);
console.log('%j', obj);
if (isCallbackResponse(session, 'finish')) {
session.send('The flow is completed!');
session.endDialog();
return;
}
});
} else {
let apiToken = session.message.sourceEvent.ApiToken;
let channel = session.message.sourceEvent.SlackMessage.event.channel;
let user = session.message.sourceEvent.SlackMessage.event.user;
// we are beginning the flow... so we send an ephemeral message
let typemsg = multiFlowSteps.pizzatype;
session.privateConversationData.workflowData = {};
postMessage(apiToken, channel, typemsg.text, typemsg.attachments).then(function () {
console.log('created message');
});
}
session.save();
}
写完所有代码后,让我们看看会发生什么(图 8-35 和 8-36 )。
图 8-36
哎呀!
图 8-35
到目前为止一切顺利 ‘
发生了什么事?事实证明,我们之前创建的用于拒绝不期望的交互消息响应的识别器开始工作,并告诉我们该操作已经过期。似乎提示代码抢占了全局识别器,而如果我们使用瀑布对话框,我们就没有办法控制识别过程。
在第六章中,当我们讨论自定义对话框时,我们简要地提到了一种叫做识别的方法。这个方法允许我们向 Bot Builder SDK 表明,我们希望当前对话框在解释用户消息时排在第一位。在这种情况下,我们有来自 Slack 的特定回调。这是识别功能的一个很好的用例。但是我们如何访问它呢?原来,我们可以创建一个定制的 WaterfallDialog 的子类,并定义一个定制的识别实现。
class WaterfallWithRecognizeDialog extends builder.WaterfallDialog {
constructor(callbackId, steps) {
super(steps);
this.callbackId = callbackId;
}
recognize(context, done) {
var cb = this.callbackId;
if (_.isFunction(this.callbackId)) {
cb = this.callbackId();
// callback can be a function that returns an ID
}
if (!_.isArray(cb)) cb = [cb]; // or a list of IDs
let intent = { score: 0.0 };
// lastly we evaluate each ID to see if it matches the message.
// if yes, handle within this dialog
for (var i = 0; i < cb.length; i++) {
if (isCallbackResponse(context, cb[i])) {
intent = { score: 1.0 };
break;
}
}
done(null, intent);
}
}
简而言之,识别在任何消息进来的时候都会被调用。我们从 this.callbackId 对象解析对话框中支持的回调。我们支持单个回调值、回调值数组或返回回调值的函数。如果回调是任何支持的回调 id,我们返回 1.0 分,这意味着我们的对话框将处理消息。否则,我们通过 0.0 分。这意味着这些回调将会上升到全局识别器,正如在第六章中所讨论的。任何其他回拨 ID 将被视为过期。
我们可以轻松地使用这个类,如下所示:
bot.dialog('multi-step-flow', new WaterfallWithRecognizeDialog(['pizzatype', 'ingredient', 'finish'], [
...
]));
如果我们现在运行代码,我们会得到与图 8-30 到 8-33 中相同的结果流。
练习 8-3
互动消息
在本练习中,您将创建一个多步交互式流来支持一个可以过滤服装产品的机器人。目标是利用与上一节类似的方法来指导用户完成多步数据输入过程。
-
分两步创建一个通用机器人。第一步调用一个名为 filterClothing 的对话框,第二步将对话框的结果打印到控制台并结束对话。
-
按照最新章节的结构创建一个名为 filterClothing 的多步交互式消息对话框。收集三条数据来过滤一个假设的服装集合:服装类型、尺码和颜色。独占使用菜单。
-
确保利用针对 response_url 的 HTTP 请求来更新交互消息。
现在,您已经非常熟悉为多步交互消息使用 Slack API 了,这是一个更酷的 Slack 特性。
结论
本章演示的代码只是触及了我们的 Bot Builder bots 和不同通道之间的集成可能性的表面。尽管我们有意地将重点放在松弛的用例上,但我们希望很清楚,在一系列不同的体验中,无论是一般的还是特定于平台的,都有很多机会重用我们的 bot 代码。
对话框、状态和识别器的强大抽象可以应用于所有通道,甚至在使用本机机制调用对话框时也是如此。我们还没有探索为自定义通道创建连接器。我们将在下一章对此进行研究。
九、创建新的通道连接器
现在应该很清楚,用内置的 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 客户端,并比较性能。
-
创建一个简单的机器人,它可以用文本响应几个用户话语选项。通过使用模拟器确保 bot 按预期工作。
-
将您的 bot 配置为接受 bot 通道注册通道刀片上的直接线路输入。
-
编写一个 Node 命令行应用,它侦听用户的控制台输入,并在用户按下 Return 键时将输入发送到 Direct Line。
-
对于收到的消息,编写轮询消息的代码,并在屏幕上打印出来。每 1 到 2 秒钟轮询一次。使用控制台应用向机器人发送多条消息,并查看它的响应速度。
-
作为第二个练习,编写利用 streamUrl 初始化新的 WebSocket 连接的代码。可以使用 ws Node.js 包,这里记载:
https://github.com/websockets/ws
。将收到的消息打印到屏幕上。 -
与 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>
在这个上下文中,名为 Say 和 Record 的 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 集成来调用它。
-
注册一个试用 Twilio 帐户,并获得一个测试电话号码。
-
输入您的 bot 语音端点,以便 Twilio 在您的电话号码收到来电时使用。
-
将带有直线呼叫的语音端点集成到您的机器人中。返回你从你的机器人那里收到的第一个回复。
-
探索 Twilio 的语音仪表盘。仪表板提供了关于每个呼叫的信息,更重要的是,提供了查看所有错误和警告的功能。如果你的机器人看起来工作正常,但是打电话给你的机器人失败了,“错误和警告”部分是开始调查可能发生了什么的好地方。
-
将收集动词添加到您的响应中,以便用户可以与机器人进行对话。在对一个不会说话的机器人的新鲜感消失之前,你可以进行多长时间的对话,然后你想要实现一些有意义的事情?
-
像你在练习 9-1 中做的那样,用轮询机制代替 WebSocket。对这个解决方案有帮助吗?
-
玩一玩 Twilio 的语音识别。有多好?认自己名字的能力有多强?它有多容易被打破?
-
将语音识别应用于任意的语音数据已经够有挑战性了,更不用说应用于电话质量的语音数据了。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 应用于我们在机器人领域可能遇到的任务。
十、让聊天机器人更智能
在前一章中,我们花了时间将聊天机器人的语音合成标记语言(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 集成
本练习的目标是向现有聊天机器人添加问答功能。
-
创建一个简单的 QnA Maker 知识库,其中包含关于您的一些问题的答案。姓名、出生日期和兄弟姐妹的数量是一些可能性。
-
创建一个聊天,利用bot builder-cognitive servicesnode . js 包连接到您的 QnA Maker 服务。
-
将 QnA Maker 对话框和识别器集成到一个也连接到 LUIS 的机器人中。可以用第七章的日历机器人举例。框架是否擅长区分 LUIS 查询和 QnA 查询?
-
试着用类似于你训练 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)。
-
通过使用 Azure 门户获取计算机视觉 API 的访问密钥。这个过程和其他认知服务是一样的。
-
创建一个聊天机器人,接受照片并从照片中提取文本信息。像我们在热狗聊天机器人中一样处理图片上传。
-
试着在一张纸上写一些文字,然后通过你的聊天机器人运行它。它能正确识别你的笔迹吗?
-
在 OCR 努力识别文本之前,图像中的对比度可以有多差,或者您的书写可以有多差?
现在,您已经练习了计算机视觉 API,并以特别的方式测试了它的 OCR 算法的性能。
结论
这个世界在机器学习算法的准确性方面取得了很大的进步,以至于很多功能已经通过 REST APIs 向开发人员公开。通过一个简单的 REST 端点访问其中一些算法的能力,而不需要学习新的环境和语言(如 Anaconda、Python 和 scikit-learn),刺激了一大批开发人员尝试新的想法,并在他们的应用中包含 AI 功能。大型科技公司提供的一些服务可能不像定制开发和策划的模型那样高效、经济或准确,但它们的易用性以及随着时间的推移不断提高的准确性和成本效益是生产场景中考虑的催化剂。
作为聊天机器人领域的专业人士,我们应该对可以帮助我们的聊天机器人开发的认知产品类型有所了解。使用所有这些强大的功能可以极大地改善对话体验。
十一、适配卡和自定义图形
在整本书中,我们讨论了机器人与用户交流的不同方式。机器人可以使用文本、语音、图像、按钮或传送带。这些与正确的语气和数据相结合,成为用户快速有效地完成目标的强大界面。我们可以很容易地用正确的数据构建文本,但文本可能并不总是传达某些想法的最有效的机制。让我们以股票报价为例。比如说,当用户向 Twitter 询价时,他们在寻找什么样的数据?
他们在找最后的价格吗?他们是在寻求销量吗?他们在找出价/要价吗?也许他们想看看 52 周的最高价和最低价是多少。事实是,每个用户可能都在寻找稍微不同的东西。股票的文本描述对语音助手来说可能是有意义的。我们预计 Alexa 会说,“Twitter,代码 TWTR,交易价格为 24.47 美元,交易量为 810 万。52 周的范围是 14.12 美元到 25.56 美元。当前出价为 24.46 美元,当前要价为 24.47 美元。”你能想象用机器人接收这些数据吗?坦率地说,解析文本是痛苦的。
一个吸引人的选择是将内容放在卡片内,如图 11-1 所示。这个例子来自 TD Ameritrade Messenger bot。包含在文本消息中的许多相同的数据是通过图形来传达的,然而这种格式对于人类来说更容易使用。
图 11-1
股票报价卡
一张普通的英雄卡并没有留下多少空间来创建这样的界面。标题、副标题和按钮很容易,但图像却不容易。我们如何在我们的机器人中包含这样的视觉效果?在这一章中,我们将探讨两种方法:使用无头浏览器和自适应卡的自定义图像呈现,这是一种微软的连接器可以以特定于通道的方式呈现的格式。我们将首先深入研究适配卡。
适配卡
当 Bot 框架首次发布时,微软创建了英雄卡。正如我们在第 4 和 5 章中所探讨的,英雄卡是对不同消息平台用文本和按钮呈现图像的不同方式的伟大抽象。然而,很明显英雄卡有一点局限性,因为它们只由图像、标题、副标题和可选按钮组成。
为了提供更灵活的用户界面,微软创造了自适应卡。自适应卡对象模型描述了消息传递应用中更丰富的用户界面。通道连接器负责将自适应卡定义转换成通道支持的任何形式。基本上就是英雄卡的丰富多了的版本。
自适应卡在 Build 2017 大会上公布。作为聊天机器人开发者,我们现在有一种格式来描述丰富的用户界面。这种格式本身是 JSON 格式中类似 XAML 的布局引擎和类似 HTML 的概念的混合。
以下是一张餐厅卡的示例及其在图 11-2 中的呈现:
图 11-2
餐厅卡片渲染
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": [
{
"speak": "Tom's Pie is a Pizza restaurant which is rated 9.3 by customers.",
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": 2,
"items": [
{
"type": "TextBlock",
"text": "PIZZA"
},
{
"type": "TextBlock",
"text": "Tom's Pie",
"weight": "bolder",
"size": "extraLarge",
"spacing": "none"
},
{
"type": "TextBlock",
"text": "4.2 ★★★☆ (93) · $$",
"isSubtle": true,
"spacing": "none"
},
{
"type": "TextBlock",
"text": "**Matt H. said** \"I'm compelled to give this place 5 stars due to the number of times I've chosen to eat here this past year!\"",
"size": "small",
"wrap": true
}
]
},
{
"type": "Column",
"width": 1,
"items": [
{
"type": "Image",
"url": "https://picsum.photos/300?image=882",
"size": "auto"
}
]
}
]
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "More Info",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
]
}
在自适应卡中,几乎所有东西都是一个容器,可以包含其他容器或 UI 元素。结果是一个 UI 对象树,就像任何其他标准的 UI 平台一样。在这个例子中,我们有一个包含两列的容器。第一列的宽度是第二列的两倍,包含四个 TextBlock 元素。第二列只包含一个图像。最后,卡片包括一个打开网址的动作。下面是另一个例子及其效果图(图 11-3 ):
图 11-3
数据收集模板
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": 2,
"items": [
{
"type": "TextBlock",
"text": "Tell us about yourself",
"weight": "bolder",
"size": "medium"
},
{
"type": "TextBlock",
"text": "We just need a few more details to get you booked for the trip of a lifetime!",
"isSubtle": true,
"wrap": true
},
{
"type": "TextBlock",
"text": "Don't worry, we'll never share or sell your information.",
"isSubtle": true,
"wrap": true,
"size": "small"
},
{
"type": "TextBlock",
"text": "Your name",
"wrap": true
},
{
"type": "Input.Text",
"id": "myName",
"placeholder": "Last, First"
},
{
"type": "TextBlock",
"text": "Your email",
"wrap": true
},
{
"type": "Input.Text",
"id": "myEmail",
"placeholder": "youremail@example.com",
"style": "email"
},
{
"type": "TextBlock",
"text": "Phone Number"
},
{
"type": "Input.Text",
"id": "myTel",
"placeholder": "xxx.xxx.xxxx",
"style": "tel"
}
]
},
{
"type": "Column",
"width": 1,
"items": [
{
"type": "Image",
"url": "https://upload.wikimedia.org/wikipedia/commons/b/b2/Diver_Silhouette%2C_Great_Barrier_Reef.jpg",
"size": "auto"
}
]
}
]
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
}
]
}
这是一个相似的整体布局,两列的宽度比为 2:1。第一列包含不同大小的文本以及三个输入字段。第二列包含一个图像。
我们在图 11-4 中再举一个例子,回忆一下我们对股票行情卡的讨论。
图 11-4
股票报价渲染
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"speak": "Microsoft stock is trading at $62.30 a share, which is down .32%",
"body": [
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": "Microsoft Corp (NASDAQ: MSFT)",
"size": "medium",
"isSubtle": true
},
{
"type": "TextBlock",
"text": "September 19, 4:00 PM EST",
"isSubtle": true
}
]
},
{
"type": "Container",
"spacing": "none",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "75.30",
"size": "extraLarge"
},
{
"type": "TextBlock",
"text": "▼ 0.20 (0.32%)",
"size": "small",
"color": "attention",
"spacing": "none"
}
]
},
{
"type": "Column",
"width": "auto",
"items": [
{
"type": "FactSet",
"facts": [
{
"title": "Open",
"value": "62.24"
},
{
"title": "High",
"value": "62.98"
},
{
"title": "Low",
"value": "62.20"
}
]
}
]
}
]
}
]
}
]
}
这个模板引入了更多的概念。首先,卡片有两个容器而不是列。第一个容器只显示两个文本块,其中包含公司名称/股票代码和报价日期。第二个容器包含两列。一个有最近的价格和变化数据,另一个有开盘/高/低数据。后一种数据存储在类型为 FactSet 的对象中,这是一个名称-值对的集合,呈现为一个紧密间隔的组。
Adaptive Cards 网站提供了各种丰富的示例。 1 同一个站点上,可视化者 2 明确表示,Bot 框架聊天机器人只是适配卡的一小部分。各个 Bot 框架通道以不同的保真度得到支持。模拟器如实地呈现了卡片,但是许多其他通道如 Facebook Messenger 会产生图像(图 11-5 )。
图 11-5
Messenger 将自适应卡渲染为图像
公平地说,微软的脸书连接器会向任何具有不支持功能的适配卡返回一个错误请求(400)状态代码。这真正抓住了这里的困境。拥有一个通用的富卡格式是一个积极的发展,但前提是它得到广泛的支持。在脸书这样的平台上缺乏支持是有害的。值得注意的是,可视化工具中允许的主机应用讲述了一个更广泛的自适应卡故事(图 11-6 )。
图 11-6
Adaptive Card Visualizer 中可能的呈现选项
请注意,前七项(网络聊天、Cortana 技能、Windows 时间表、Skype、Outlook 可操作消息、微软团队和 Windows 通知)都是微软控制范围内的系统。微软正在构建一种通用格式来呈现其众多资产中的卡片。
简而言之,如果您的应用面向许多微软系统,如 Windows 10、Teams 和 Skype,投资于可重复使用且一致的跨平台适配卡是一个好主意。
微软还提供了几个 SDK 来帮助你的定制应用渲染适配卡。例如,有 iOS SDK、客户端 JavaScript SDK 和 Windows SDK 每个人都可以使用 adaptive card JSON,并从中呈现一个原生 UI。
一个工作实例
我们现在来看一个例子,以便更好地理解自适应卡是如何呈现的,以及它们是如何将输入表单消息发送回机器人的。我们将使用模拟器作为我们的通道,因为它实现了所有重要的功能。我们将使用前一个示例中稍加修改的卡来收集用户的姓名、电话号码和电子邮件地址。
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": [
{
"type": "TextBlock",
"text": "Tell us about yourself",
"weight": "bolder",
"size": "medium"
},
{
"type": "TextBlock",
"text": "Don't worry, we'll never share or sell your information.",
"isSubtle": true,
"wrap": true,
"size": "small"
},
{
"type": "TextBlock",
"text": "Your name",
"wrap": true
},
{
"type": "Input.Text",
"id": "name",
"placeholder": "First Last"
},
{
"type": "TextBlock",
"text": "Your email",
"wrap": true
},
{
"type": "Input.Text",
"id": "email",
"placeholder": "youremail@example.com",
"style": "email"
},
{
"type": "TextBlock",
"text": "Phone Number"
},
{
"type": "Input.Text",
"id": "tel",
"placeholder": "xxx.xxx.xxxx",
"style": "tel"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
},
{
"type": "Action.ShowCard",
"title": "Terms and Conditions",
"card": {
"type": "AdaptiveCard",
"body": [
{
"type": "TextBlock",
"text": "We will not share your data with anyone. Ever.",
"size": "small",
}
]
}
}
]
}
我们还将允许用户单击两个项目中的任何一个:一个提交按钮来发送数据,一个条款和条件按钮在单击时显示一些额外的信息。当用户点击提交时,来自字段的数据被收集并发送给机器人,作为通过消息的值属性公开的对象。先前 JSON 中定义的自适应卡发送的对象将有三个属性:name、email 和 tel。属性名对应于字段 id 。
因此,获取这些值的代码非常简单。它可以像简单地检查值是否存在并基于它执行逻辑一样简单。如果我们发送多张名片,因为它们会留在用户的聊天记录中,所以确保一致的对话体验也很重要。
const bot = new builder.UniversalBot(connector, [
(session) => {
let incoming = session.message;
if (incoming.value) {
// this means we are getting data from an adaptive card
let o = incoming.value;
session.send('Thanks ' + o.name.split(' ')[0] + ". We'll be in touch!");
} else {
let msg = new builder.Message(session);
msg.addAttachment({
contentType: 'application/vnd.microsoft.card.adaptive',
content: adaptiveCardJson
});
session.send(msg);
}
}
]);
图 11-7 展示了这种对话会如何发展。请注意,除了一些小的验证之外,卡本身没有实际的逻辑。将来可能会有这样的能力,但目前所有这样的逻辑必须出现在机器人代码中。
图 11-7
展开条款和条件并单击提交后的输入表单自适应卡
练习 11-1
创建自定义适配卡
-
本练习的目标是创建一个功能正常的天气更新适配卡。您将集成一个天气 API,为聊天机器人的用户提供实时天气。创建一个机器人来收集用户的位置,也许只是一个邮政编码,并返回一条回显该位置的文本消息。
-
编写与 Yahoo 天气 API 集成所需的代码。你可以在
https://developer.yahoo.com/weather/
找到使用信息。 -
创建一个包含服务提供的各种数据点的自适应卡。Adaptive Cards 网站提供了两个天气样本;如果你愿意,你可以使用其中的一个。完成后,在适配卡 JSON 中切换一些 UI 元素。这样做有多容易?
-
添加图形图像元素。例如,显示不同的图形来代表晴天和阴天。您可能会使用在线图像搜索找到一些资产,或者在本地托管一些图像。如果您在本地托管它们,请确保您设置为提供静态内容。
干得好!你现在可以用自适应卡来丰富你的机器人的对话体验。
呈现自定义图形
自适应卡简化了某些类型的布局,并允许我们声明性地定义可以渲染到图像中的自定义布局。然而,我们无法控制图像的使用方式;正如我们在 Messenger 上看到的,图像是作为独立的图像发送的,没有任何上下文按钮或卡片格式的文本。除了大小、边距和布局控制的其他小限制之外,我们没有生成图形的方法。假设我们想要生成一个图表来表示一段时间内的股票价格。使用适配卡无法做到这一点。如果我们有另一种方法呢?
创建自定义图形的最佳方式是利用我们已经熟悉的技术,如 HTML、JavaScript 和 CSS!如果我们可以直接使用 HTML 和 CSS,我们就可以创建定制的、品牌化的、漂亮的布局来表示我们对话体验中的各种概念。使用 SVG 和 JavaScript,我们将能够创建令人惊叹的数据驱动的图形,使我们的机器人内容栩栩如生。
好的,我们被卖了。但是我们如何做到这一点呢?我们将稍微绕道进入一种可以用来呈现这些工件的机制:无头浏览器。
像 Firefox 或 Chrome 这样的标准普通浏览器有许多组件:网络层;符合标准的 HTML 引擎,如 Gecko、WebKit 或 Chromium 最后是允许您查看实际内容的 UI。无头浏览器是没有 UI 组件的浏览器。通常,使用命令行或脚本语言来控制这些浏览器。无头浏览器最初也是最重要的用例是在启用了 JavaScript 和 AJAX 的环境中进行功能测试等任务。例如,搜索引擎可以使用无头浏览器来索引动态网页内容。Phantom 3 是基于 WebKit 的无头浏览器的一个例子,在 AngularJS 早期被大量使用。Firefox 4 和 Chrome 5 最近在它们的浏览器中都增加了对无头模式的支持。在这个领域越来越常见的用途之一是图像渲染。所有的无头浏览器都实现了截图功能,我们可以利用它来满足图像渲染的需求。
我们将继续我们的股票报价示例,并构建一些可以以文本形式返回报价的内容。完整的工作代码示例可以在本书的 GitHub repo 中的chapter11-image-rendering-bot
文件夹下找到。为此,我们需要访问财务数据提供商。一个易于使用的提供者叫做 Intrinio,它提供免费帐户开始使用他们的 API。转到 http://intrinio.com
并点击开始免费按钮创建一个帐户来使用他们的 API。一旦我们完成了帐户创建过程,我们就可以访问我们的访问密钥,这些密钥必须通过基本的 HTTP 认证传递给 API。使用类似 https://api.intrinio.com/data_point?ticker=AAPL&item=last_price,volume
的 URL,我们得到 AAPL 的最新价格和交易量。生成的数据 JSON 如下所示:
{
"data": [
{
"identifier": "AAPL",
"item": "last_price",
"value": 174.32
},
{
"identifier": "AAPL",
"item": "volume",
"value": 20179172
}
],
"result_count": 2,
"api_call_credits": 2
}
创建一个使用这个 API 的机器人可以通过使用下面的代码来完成,导致图 11-8 中的对话:
图 11-8
文本股票报价
require('dotenv-extended').load();
const builder = require('botbuilder');
const restify = require('restify');
const request = require('request');
const moment = require('moment');
const _ = require('underscore');
const puppeteer = require('puppeteer');
const vsprintf = require('sprintf').vsprintf;
// declare all of the data points we will be interested in
const datapoints = {
last_price: 'last_price',
last_year_low: '52_week_low',
last_year_high: '52_week_high',
ask_price: 'ask_price',
ask_size: 'ask_size',
bid_price: 'bid_price',
bid_size: 'bid_size',
volume: 'volume',
name: 'name',
change: 'change',
percent_change: 'percent_change',
last_timestamp: 'last_timestamp'
};
const url = "https://api.intrinio.com/data_point?ticker=%s&item=" + _.map(Object.keys(datapoints), p => datapoints[p]).join(',');
// Setup Restify Server
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
console.log('%s listening to %s', server.name, server.url);
});
// Create chat bot and listen to messages
const connector = new builder.ChatConnector({
appId: process.env.MICROSOFT_APP_ID,
appPassword: process.env.MICROSOFT_APP_PASSWORD
});
server.post('/api/messages', connector.listen());
const bot = new builder.UniversalBot(connector, [
session => {
// get ticker and create request URL
const ticker = session.message.text.toUpperCase();
const tickerUrl = vsprintf(url, [ticker]);
// make request to get the ticker data
request.get(tickerUrl, {
auth:
{
user: process.env.INTRINIO_USER,
pass: process.env.INTRINIO_PASS
}
}, (err, response, body) => {
if (err) {
console.log('error while fetching data:\n' + err);
session.endConversation('Error while fetching data. Please try again later.');
return;
}
// parse JSON response and extract the last price
const results = JSON.parse(body).data;
const lastPrice = getval(results, ticker, datapoints.last_price).value;
// send the last price as a response
session.endConversation(vsprintf('The last price for %s is %.2f', [ ticker, lastPrice]));
});
}
]);
const getval = function(arr, ticker, data_point) {
const r = _.find(arr, p => p.identifier === ticker && p.item === data_point);
return r;
}
const inMemoryStorage = new builder.MemoryBotStorage();
bot.set('storage', inMemoryStorage);
太好了。我们现在将创建一个自适应卡,看看如何利用我们刚刚介绍的无头浏览器来呈现更丰富的图形。
对于自适应卡,我们将使用从前面的股票更新场景修改而来的模板。我们没有在 endConversation 调用中发送字符串,而是发送回一张股票卡。 renderStockCard 函数获取从 API 返回的数据,并呈现适配卡 JSON。
const cardData = renderStockCard(results, ticker);
const msg = new builder.Message(session);
msg.addAttachment({
contentType: 'application/vnd.microsoft.card.adaptive',
content: cardData
});
session.endConversation(msg);
function renderStockCard(data, ticker) {
const last_price = getval(data, ticker, datapoints.last_price).value;
const change = getval(data, ticker, datapoints.change).value;
const percent_change = getval(data, ticker, datapoints.percent_change).value;
const name = getval(data, ticker, datapoints.name).value;
const last_timestamp = getval(data, ticker, datapoints.last_timestamp).value;
const open_price = getval(data, ticker, datapoints.open_price).value;
const low_price = getval(data, ticker, datapoints.low_price).value;
const high_price = getval(data, ticker, datapoints.high_price).value;
const yearhigh = getval(data, ticker, datapoints.last_year_high).value;
const yearlow = getval(data, ticker, datapoints.last_year_low).value;
const bidsize = getval(data, ticker, datapoints.bid_size).value;
const bidprice = getval(data, ticker, datapoints.bid_price).value;
const asksize = getval(data, ticker, datapoints.ask_size).value;
const askprice = getval(data, ticker, datapoints.ask_price).value;
let color = 'default';
if (change > 0) color = 'good';
else if (change < 0) color = 'warning';
let facts = [
{ title: 'Bid', value: vsprintf('%d x %.2f', [bidsize, bidprice]) },
{ title: 'Ask', value: vsprintf('%d x %.2f', [asksize, askprice]) },
{ title: '52-Week High', value: vsprintf('%.2f', [yearhigh]) },
{ title: '52-Week Low', value: vsprintf('%.2f', [yearlow]) }
];
let card = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"speak": vsprintf("%s stock is trading at $%.2f a share, which is down %.2f%%", [name, last_price, percent_change]),
"body": [
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": vsprintf("%s ( %s)", [name, ticker]),
"size": "medium",
"isSubtle": false
},
{
"type": "TextBlock",
"text": moment(last_timestamp).format('LLL'),
"isSubtle": true
}
]
},
{
"type": "Container",
"spacing": "none",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": vsprintf("%.2f", [last_price]),
"size": "extraLarge"
},
{
"type": "TextBlock",
"text": vsprintf("%.2f (%.2f%%)", [change, percent_change]),
"size": "small",
"color": color,
"spacing": "none"
}
]
},
{
"type": "Column",
"width": "auto",
"items": [
{
"type": "FactSet",
"facts": facts
}
]
}
]
}
]
}
]
}
return card;
}
现在,如果我们向机器人发送一个股票代码,我们将得到一个自适应卡。模拟器上的渲染看起来不错(图 11-9 )。信使渲染有点断断续续和像素化(图 11-10 )。我们还发现了两个通道呈现“警告”颜色的不一致。我们当然可以做得更好。
图 11-10
股票 u-update 卡的信使渲染
图 11-9
股票更新卡的模拟器渲染
我们现在将创建我们自己的自定义 HTML 模板。现在,作为一名工程师,我不做设计,但是图 11-11 是我想出来的卡片。我们显示与之前相同的所有数据,但是我们还为最近 30 天的数据添加了迷你图。
图 11-11
我们想要支持的定制报价卡
早期模板的 HTML 和 CSS 如下所示:
<html>
<head>
<style>
body {
background-color: white;
font-family: 'Roboto', sans-serif;
margin: 0;
padding: 0;
}
.card {
color: #dddddd;
background-color: black;
width: 564px;
height: 284px;
padding: 10px;
}
.card .symbol {
font-size: 48px;
vertical-align: middle;
}
.card .companyname {
font-size: 52px;
display: inline-block;
vertical-align: middle;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 380px;
}
.card .symbol::before {
content: '(';
}
.card .symbol::after {
content: ')';
}
.card .priceline {
margin-top: 20px;
}
.card .price {
font-size: 36px;
font-weight: bold;
}
.card .change {
font-size: 28px;
}
.card .changePct {
font-size: 28px;
}
.card .positive {
color: darkgreen;
}
.card .negative {
color: darkred;
}
.card .changePct::before {
content: '(';
}
.card .changePct::after {
content: ')';
}
.card .factTable {
margin-top: 10px;
color: #dddddd;
width: 100%;
}
.card .factTable .factTitle {
width: 50%;
font-size: 24px;
padding-bottom: 5px;
}
.card .factTable .factValue {
width: 50%;
text-align: right;
font-size: 24px;
font-weight: bold;
padding-bottom: 5px;
}
.sparkline {
padding-left: 10px;
}
.sparkline embed {
width: 300px;
height: 40px;
}
</style>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
</head>
<body>
<div class="card">
<div class="header">
<span class="companyname">Microsoft</span>
<span class="symbol">MSFT</span>
</div>
<div class="priceline">
<span class="price">88.22</span>
<span class="change negative">-0.06</span>
<span class="changePct negative">-0.07%</span>
<span class="sparkline">
<embed src="http://sparksvg.me/line.svg?174.33,174.35,175,173.03,172.23,172.26,169.23,171.08,170.6,170.57,175.01,175.01,174.35,174.54,176.42,173.97,172.22,172.27,171.7,172.67,169.37,169.32,169.01,169.64,169.8,171.05,171.85,169.48,173.07,174.09&rgba:255,255,255,0.7"
type="image/svg+xml">
</span>
</div>
<table class="factTable">
<tr>
<td class="factTitle">Bid</td>
<td class="factValue">100 x 87.98</td>
</tr>
<tr>
<td class="factTitle">Ask</td>
<td class="factValue">200 x 89.21</td>
</tr>
<tr>
<td class="factTitle">52 Week Low</td>
<td class="factValue">80.22</td>
</tr>
<tr>
<td class="factTitle">52 Week High</td>
<td class="factValue">90.73</td>
</tr>
</table>
</div>
</body>
</html>
请注意,我们正在做三件事,这显然是 adaptive cards 不可能做到的:CSS 允许的对样式的细粒度控制、自定义 web 字体(在本例中是 Google 的 Roboto 字体)和绘制迷你图的 SVG 对象。此时,我们真正要做的就是在 HTML 模板中修改适当的数据并呈现出来。我们如何做到这一点?
从我们之前提到的不同选项来看,今天比较好的选项之一是 Chrome。与 headless Chrome 集成的最简单方法是使用名为 Puppeteer 的 Node.js 包。这个库可以用于许多任务,例如自动化 Chrome、截图、收集网站的时间轴数据以及运行自动化测试套件。我们将使用基本的 API 来截取一个页面的屏幕截图。
木偶样本使用 Node 版本 7.6 中引入的异步/等待 7 功能。语法等待一个承诺值在一行中返回,而不是写一串然后方法调用。呈现 HTML 片段的代码如下所示:
async function renderHtml(html, width, height) {
var browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: width, height: height });
await page.goto(`data:text/html,${html}`, { waitUntil: 'load' });
const pageResultBuffer = await page.screenshot({ omitBackground: true });
await page.close();
browser.disconnect();
return pageResultBuffer;
}
我们启动一个新的 headless chrome 实例,打开一个新页面,设置视窗的大小,加载 HTML,然后截图。 omitBackground 选项允许我们在 HTML 中有透明的背景,这导致了透明的屏幕截图背景。
结果对象是 Node.js 缓冲区。缓冲区只是二进制数据的集合,Node.js 提供了许多函数来使用这些数据。我们可以调用我们的 renderHtml 方法,将缓冲区转换成 base64 字符串。一旦有了这些,我们就可以简单地将 base64 图像作为 Bot Builder 附件的一部分发送出去。
renderHtml(html, 600, 312).then(cardData => {
const base64image = cardData.toString('base64');
const contentType = 'image/png';
const attachment = {
contentUrl: util.format('data:%s;base64,%s', contentType, base64image),
contentType: contentType,
name: ticker + '.png'
}
const msg = new builder.Message(session);
msg.addAttachment(attachment);
session.endConversation(msg);
});
构建 HTML 是字符串操作,以确保填充正确的值。我们在 HTML 中添加了一些占位符,以便于进行字符串替换调用,将数据放入适当的位置。这里显示了其中的一个片段:
<div class="priceline">
<span class="price">${last_price}</span>
<span class="change ${changeClass}">${change}</span>
<span class="changePct ${changeClass}">${percent_change}</span>
<span class="sparkline">
<embed src="http://sparksvg.me/line.svg?${sparklinedata}&rgba:255,255,255,0.7" type="image/svg+xml">
</span>
</div>
下面是从 Intrinio 端点获取数据、读取卡片模板 HTML、替换正确的值、呈现 HTML 并将其作为附件发送的完整代码。一些样本结果如图 11-12 所示。
图 11-12
自定义 HTML 图像的不同渲染
request.get(tickerUrl, opts, (quote_error, quote_response, quote_body) => {
request.get(pricesTickerUrl, opts, (prices_error, prices_response, prices_body) => {
if (quote_error) {
console.log('error while fetching data:\n' + quote_error);
session.endConversation('Error while fetching data. Please try again later.');
return;
} else if (prices_error) {
console.log('error while fetching data:\n' + prices_error);
session.endConversation('Error while fetching data. Please try again later.');
return;
}
const quoteResults = JSON.parse(quote_body).data;
const priceResults = JSON.parse(prices_body).data;
const prices = _.map(priceResults, p => p.close);
const sparklinedata = prices.join(',');
fs.readFile("cardTemplate.html", "utf8", function (err, data) {
const last_price = getval(quoteResults, ticker, datapoints.last_price).value;
const change = getval(quoteResults, ticker, datapoints.change).value;
const percent_change = getval(quoteResults, ticker, datapoints.percent_change).value;
const name = getval(quoteResults, ticker, datapoints.name).value;
const last_timestamp = getval(quoteResults, ticker, datapoints.last_timestamp).value;
const yearhigh = getval(quoteResults, ticker, datapoints.last_year_high).value;
const yearlow = getval(quoteResults, ticker, datapoints.last_year_low).value;
const bidsize = getval(quoteResults, ticker, datapoints.bid_size).value;
const bidprice = getval(quoteResults, ticker, datapoints.bid_price).value;
const asksize = getval(quoteResults, ticker, datapoints.ask_size).value;
const askprice = getval(quoteResults, ticker, datapoints.ask_price).value;
data = data.replace('${bid}', vsprintf('%d x %.2f', [bidsize, bidprice]));
data = data.replace('${ask}', vsprintf('%d x %.2f', [asksize, askprice]));
data = data.replace('${52weekhigh}', vsprintf('%.2f', [yearhigh]));
data = data.replace('${52weeklow}', vsprintf('%.2f', [yearlow]));
data = data.replace('${ticker}', ticker);
data = data.replace('${companyName}', name);
data = data.replace('${last_price}', last_price);
let changeClass = '';
if(change > 0) changeClass = 'positive';
else if(change < 0) changeClass = 'negative';
data = data.replace('${changeClass}', changeClass);
data = data.replace('${change}', vsprintf('%.2f%%', [change]));
data = data.replace('${percent_change}', vsprintf('%.2f%%', [percent_change]));
data = data.replace('${last_timestamp}', moment(last_timestamp).format('LLL'));
data = data.replace('${sparklinedata}', sparklinedata);
renderHtml(data, 584, 304).then(cardData => {
const base64image = cardData.toString('base64');
const contentType = 'image/png';
const attachment = {
contentUrl: util.format('data:%s;base64,%s', contentType, base64image),
contentType: contentType,
name: ticker + '.png'
}
const msg = new builder.Message(session);
msg.addAttachment(attachment);
session.endConversation(msg);
});
});
});
});
考虑到我们在这上面花费的时间很短,这些确实是很好的结果!该图像在 Messenger 上也呈现得很好(图 11-13 )。
图 11-13
Messenger 中的图像呈现
然而,我们设定了一个目标,那就是创建定制卡片。好,我们将代码改为如下:
const card = new builder.HeroCard(session)
.buttons([
builder.CardAction.postBack(session, ticker, 'Quote Again')])
.images([
builder.CardImage.create(session, imageUri)
])
.title(ticker + ' Quote')
.subtitle('Last Updated: ' + moment(last_timestamp).format('LLL'));
const msg = new builder.Message(session);msg.addAttachment(card.toAttachment());
session.send(msg);
这在模拟器中表现得非常好,但是我们在 Messenger 中没有得到任何结果。如果我们查看 Node 输出,我们会很快注意到脸书返回了一个 HTTP 400 ( BadRequest )响应。发生什么事了?尽管脸书支持带有嵌入式 Base64 图像的数据 URIs,但它不支持卡片图像的这种格式。我们可以在 bot 中创建一个返回图像的端点,但是脸书还有一个限制:webhook 和卡片图像的 URI 不能有相同的主机名。
解决方案是让我们的机器人在其他地方托管生成的图像。一个很好的起点是基于云的 Blob 商店,比如亚马逊的 S3 或者微软的 Azure Storage。由于我们关注的是微软的堆栈,我们将继续使用 Azure 的 Blob 存储。我们将使用相关的 Node.js 包。
npm install azure-storage --save
const blob = azureStorage.createBlobService(process.env.IMAGE_STORAGE_CONNECTION_STRING);
IMAGE _ STORAGE _ CONNECTION _ STRING是一个存储 Azure 存储连接字符串的环境变量,在创建存储帐户资源后,可以在 Azure 门户中找到该字符串。在我们将图像生成到本地文件后,我们的代码必须确保 blob 容器存在,并从我们的图像创建 blob。然后,我们使用新 blob 的 URL 作为我们图像的来源。
renderHtml(data, 584, 304).then(cardData => {
const uniqueId = uuid();
const name = uniqueId + '.png';
const pathToFile = 'img/' + name;
fs.writeFileSync(pathToFile, cardData);
const containerName = 'image-rendering-bot';
blob.createContainerIfNotExists(containerName, {
publicAccessLevel: 'blob'
}, function (error, result, response) {
if (!error) {
blob.createBlockBlobFromLocalFile(containerName, name, pathToFile, function (error, result, response) {
if (!error) {
fs.unlinkSync(pathToFile);
const imageUri = blob.getUrl(containerName, name);
const card = new builder.HeroCard(session)
.buttons([
builder.CardAction.postBack(session, ticker, 'Quote Again')])
.images([
builder.CardImage.create(session, base64Uri)
])
.title(ticker + ' Quote')
.subtitle('Last Updated: ' + moment(last_timestamp).format('LLL'));
const msg = new builder.Message(session);
msg.addAttachment(card.toAttachment());
session.send(msg);
} else {
console.error(error);
}
});
} else {
console.error(error);
}
});
});
现在卡片正在按照预期渲染,如图 11-14 所示。
图 11-14
卡片现在渲染!
练习 11-2
使用无头 Chrome 渲染图形
在本练习中,您将从练习 11-1 的天气机器人中获取代码,并添加自定义 HTML 渲染。
-
在您的自适应卡中,添加一个占位符,该占位符可以包含一个在图表中表示温度预测的图像。
-
使用 headless chrome 渲染图像,使用折线图显示预测。您可以使用与前面相同的迷你图方法。
-
将结果图像存储在 blob 存储中。
-
确保自适应卡在指定位置包含自定义渲染图像,并且可以在模拟器和 Facebook Messenger 中渲染。
现在,您已经将自定义 HTML 呈现与自适应卡混合在一起。没人说我们不能这么做,对吧?
结论
在这一章中,我们探讨了两种通过丰富的图形来传达复杂想法和聊天机器人品牌的方法。自适应卡是一种快速入门的方式,并允许与本地支持该格式的平台进行更深入的集成。基于 HTML 的自定义图像渲染允许对生成的图形进行更多的自定义和控制,在没有本地适配卡支持的情况下尤其有价值。两者都是非常吸引人的聊天机器人体验的绝佳选择。
十二、人工交接
聊天机器人几乎从不孤立生存。公司和品牌已经投入了大量的时间、精力和金钱,通过 Twitter、脸书、Instagram、Snapchat 等社交媒体与客户互动。社交媒体公司之间正在进行竞争,为企业提供与客户互动的最佳平台。这些平台中的每一个都希望连接其用户,以促进平台的使用和销售产品。此外,来自 Zendesk、LiveChat、FreshDesk 和 ServiceNow 的客户服务系统,以及 Oracle Service Cloud、Remedy 和 Salesforce Service Cloud 等科技巨头,正在构建将消费者与品牌的客户服务代表(CSR)通过短信、Messenger 和实时聊天等各种通道联系起来的系统。
如今,聊天机器人承担的工作负载可以通过自动化获得很大收益。然而,正如本书所讨论的,聊天机器人的功能有很多限制。在目前的状态下,这项技术无法处理一些人类客户服务代表可以轻松解决的请求。尽管在不同的客户服务系统、团队培训和报告上投入了大量的投资,但是将人排除在与产品用户的对话之外是短视的。在这一章中,我们将介绍客户服务系统的功能,最重要的是,在与客户服务系统集成并为 CSR 移交提供无缝聊天机器人时,我们有哪些选择。
我们仍然需要人类
聊天机器人开始处理一些企业的询问。尽管通过简单的谷歌搜索或查看该公司的 FAQ 页面,这些问题中的一些可能很容易得到回答,但一部分客户仍然会通过实时聊天或该公司的脸书页面来联系。这是一个很好的机会,可以自动完成一些工作来回答这些客户的问题。
也就是说,机器人目前不能总是优雅地处理问题。作为一项相对较新的技术,聊天机器人可能没有得到充分测试,并产生令人困惑或不一致的体验。聊天机器人本身的错误可能会导致机器人没有响应,CSR 必须介入并手动接管对话,以确保客户满意。因此,使用聊天机器人技术自动化工作负载的公司通常不会看到工作负载的立即减少。事实上,专注于使用机器人本身的一套新技能变得必要并不罕见。随着技术和我们对其用途的理解的提高,我们可能会达到人类被取代的程度,但不要指望这种情况会马上发生。人类 CSR 必须留在回路中,以便在需要时进行调解。
从客户服务的角度看聊天机器人
在客户服务行业流行的聊天机器人主要有三类。一家公司制造的机器人类型与它认为聊天机器人可以正确处理的案例数量直接相关,也与用户通过自然语言与计算机对话的意愿和悟性直接相关。
永远在线的聊天机器人
一个永远在线的聊天机器人直接连接到用户的频道,等待问题或指示。它假设自己可以处理每一个输入,即使是通过说出可怕的“我不知道”的回答。这里的关键是平衡;一个机器人可以尝试处理每一个查询,但它必须清楚自己的局限性,并能够为用户指出可能的帮助来源。当然,如果机器人不能处理请求,建议提供一种替代的联系人类的方式。如果无法实现无缝的人员升级集成,那么即使提供一个参考数字也比没有好。
有时在线聊天机器人
有时打开的聊天机器人可以处理一个较小的封闭问题集和用户输入,但如果它不确定或不知道答案,它会立即将问题转发给人工代理。这是一种有效的方式来降低用户陷入聊天机器人的循环中而无法获得任何帮助的风险。另一方面,如果一个具有前瞻性思维的客户试图探索机器人的功能,并且在几乎任何输入上都被重定向到人类,这可能会成为一种令人沮丧的体验。一个很好的妥协是建议用户,当机器人不理解用户的意图时,他们可以与人类代理交谈。同样,如果没有无缝的人工上报功能,任何联系业务的方式都比没有好。
面向企业社会责任的聊天机器人
面向企业社会责任的机器人充当企业社会责任系统的扩展,并向人类代理提供关于应该如何响应用户查询的建议。这是一个有趣的方法,因为它稍微颠倒了聊天机器人的概念。这也是一种收集数据的好方法,可以根据用户的查询和代理的响应来训练聊天机器人。这种方法是为聊天机器人构建用例及内容的有效技术。我们还观察到,这种聊天机器人在企业客户不了解技术或者更喜欢与人交谈的情况下表现良好。
典型的客户服务系统概念
客户服务系统可以是多方面的。它可以是一个知识库。它可以是一个售票系统。可以是呼叫中心系统。它可以是一个信息系统。在引言一章中提到的该领域的大公司中,所有公司都在其产品中包含了这些功能的某种组合。事实上,由于这些系统从客户那里获得了丰富的数据集,如详细的知识库和丰富的对话历史,许多参与者正在开发自己的虚拟助理解决方案。例如,一个显而易见的开始是创建一个虚拟助手,它在知识库中查询已知问题的答案。票务系统可以很好地提供一个聊天机器人,它可以检查门票状态,并对现有门票进行基本编辑。
客户服务系统通常会将用户和企业之间的每一次交互组织成一个项目,称为案例。例如,客户向企业寻求密码问题的帮助,在系统中打开一个新案例。新项目可能会进入所有活动代理在其桌面上看到的收件箱。该案例将被分配给选择该项目的任何人,或者系统可能会自动将该案例分配给一个目前有空但没有处理太多案例的 CSR。一旦代理帮助客户解决了问题,案例就结束了。代理可能已经为客户创建了一个新票据,将案例与票据关联起来。CSR 系统知道多条数据。它知道代理何时可用。它知道代理通常处理案件的速度。它知道呼叫中心的工作时间,因此可能不允许在下班时间进行任何实时聊天。
所有这些数据构成了非常丰富的报告。这些系统通常会提供详细的报告,包括聊天总数、聊天参与度、排队等待时间、结案时间、首次响应时间以及许多其他有趣的数据点。很自然,CSR 团队将根据这些措施得到评估和补偿。
作为 bot 开发者,我们不应该期望 CSR 团队改变其工作流程或数据报告结构。事实上,许多这样的系统都提供了机器人集成点,将聊天机器人视为代理。每个系统都略有不同,但它们通常遵循这种范式。这种方法的好处之一是,系统的报告功能不会因为引入聊天机器人作为虚拟 CSR 而中断。
与客户服务系统集成意味着我们需要编写代码来启动和关闭案例。当来自客户的新消息到达时,案例启动可以自动发生。当聊天机器人完成对用户查询的帮助时,案例结束。案例的定义会有所不同。案例可以被定义为从用户提问的时刻开始,直到聊天机器人给出答案为止。或者,案例可以被定义为聊天机器人和用户之间的任何交互,直到对话中有 15 分钟的活动。
整合方法
有多种方法可以将聊天机器人与客户服务系统无缝集成。我们将看看三个选项。我们选择的集成级别取决于支持团队的成熟度和可用工具。我们将在探索每种类型的集成时解决这个问题。
定制界面
定制界面可能最适合具有高度专业化工作流的团队,或者没有任何现有客户服务人员或系统的团队。此外,如果我们将机器人部署到一个没有现有的负担得起的工具的通道,我们可能别无选择,只能构建自己的工具。虽然不建议使用定制的接口,但是有些开发人员已经自己创建了接口。下面举个例子: https://ankitbko.github.io/2017/03/human-handover-bot
。一般方法是在现有 bot 功能的基础上构建一个类似客户服务的系统。显然,现在的问题是,我们的开发团队拥有客户服务接口,并负有保持系统运行的额外责任。
在站台上
如果您没有现有的客户服务系统,但打算部署到一个拥有自己的支持工具的通道,那么您很幸运。例如,脸书页面允许客户通过 Messenger 与企业互动。Pages 为页面所有者提供了许多功能,其中之一是一个时尚的收件箱(图 12-1 )。当来自客户的消息到达时,它们将出现在左侧面板上。页面主体包含聊天历史,并允许企业与用户进行交互。
图 12-1
脸书页面收件箱用户界面
可以说,用户界面是页面所有者响应多种类型的用户查询的一种强有力的方式。当然,挑战在于,如果机器人被部署到脸书以外的频道,平台上的界面将不支持这些实时聊天场景。
产品
如果一个团队已经有了一个支持实时聊天的客户服务系统,我们很可能想要开发一个与现有系统的集成。这样做的过程高度依赖于系统。这种方法中最重要的任务之一是,机器人必须是客户服务系统的好公民,并且不能破坏其他代理的体验。这意味着必须遵守案例打开和解决规则,并且必须记录用户和 bot 之间交换的所有消息。如果代理开启了一个缺少对话历史的案例,这将被证明是一次糟糕的客户体验。你想看到一个沮丧的顾客吗?多次问他们同一个问题。
如果我们天真地开始实现人工交接流程,我们可能会以图 12-2 所示的结果告终。我们将以 Facebook Messenger 为例。聊天机器人通过机器人连接器与信使通信。在正常的会话流中,机器人将所有传入的消息转发给客户服务系统并响应用户。如果一个案例还没有打开,该机器人还负责打开该案例。
图 12-2
没有人工代理的正常对话流
当对话流需要人工交接时,聊天机器人充当代理,将用户的消息发送到代理聊天中,并将代理的响应转发回用户。如图 12-3 所示。如果代理已经解决了该案例,则必须关闭该案例。
图 12-3
客户与人工代理交互
这种型号不受欢迎。主要原因是客户服务系统通常连接到现有的社会通道,如脸书。聊天机器人脸书和客户服务系统之间的连接看起来更像图 12-4 。
图 12-4
聊天机器人、脸书和客户服务系统在实践中的联系
社交平台通常不支持多个应用同时监听一个对话。因此,需要选择哪个系统拥有该连接。由于客户服务系统可以提供超越聊天集成的集成,并且通常在做出构建聊天机器人的决定之前就已经就位,因此它们最终拥有连接。
在脸书的情况下,我们可以使用一种叫做切换协议的东西,它允许我们绕过一次只有一个应用拥有连接的限制。使用这个协议,我们可以指定一个应用作为主要应用,其他任何应用都是次要的。当用户第一次与页面开始对话时,将总是联系主应用。主应用然后可以将对话线程转移到辅助应用。当应用在用户对话中不活跃时,它处于待机模式。通过实现待机通道,有一种方法可以确保应用在待机模式下接收用户的消息。您可以在 https://developers.facebook.com/docs/messenger-platform/handover-protocol
找到更多文档。图 12-5 显示了所描述的设置。
图 12-5
Facebook Messenger 上的移交协议实现。Out bot app 指定为主,我们选择的直播聊天平台为辅。
不幸的是,并不是每个通道都支持多应用范例,也不是每个客户服务系统都实现了切换协议。更不用说,我们假设一个脸书专用的机器人。增加更多的通道会给这种方法带来更多的挑战。
图 12-6 展示了集成人工交接的另一种方法。使用这种方法,客户服务系统充当打算发送给机器人的消息的代理,直到对话被转交给人类。此时,聊天机器人看不到任何对话片段。这种设置也意味着脸书通道连接器不在循环中,所以我们需要实现一个自定义的翻译器来接收 Messenger 格式的消息,将它们转换为 Bot Builder SDK 格式,并使用直线将消息转发到聊天机器人,就像我们在第九章中所做的那样。
这种方法更常见,因为与在两个系统之间共享脸书页面相比,将后端集成到客户服务系统的生态系统中更容易。这种方法在客户服务系统支持的任何系统上支持人工切换时也是有效的。
图 12-6
聊天机器人与客户服务系统集成的一种更常见的架构方法
Facebook Messenger 移交示例
很难展示一个完全集成的基于产品的人工交接场景,但是如果我们假设脸书页面是前面图中的客户服务系统,那么这样做就容易多了。在本节中,我们将把人工交接集成添加到我们在整本书中构建的日历机器人中。
我们使用的方法如下。首先,我们将创建一个新的意图来处理客户与人工代理对话的明确请求。接下来,我们将创建一个对话框来处理转移用户的逻辑。我们将指定我们的机器人作为主要应用,收件箱作为次要应用。我们将演示如何将线程控制从我们的应用转移到收件箱。最后,我们将展示如何通过脸书页面收件箱支持客户,然后将控制权发送回聊天机器人。
让我们创建一个新版本的日历机器人模型。在这个版本中,我们将创建一个名为 HumanHandover 的意图,并为其提供如下示例语句:
-
"与代理交谈"
-
“给我一个人类”
-
“我想和人类说话”
我们培训并发布 LUIS 应用。我们的聊天机器人将无法接收到这个意图并对它做些什么。
{
"query": "take me to your leader",
"topScoringIntent": {
"intent": "HumanHandover",
"score": 0.883278668
},
"intents": [
{
"intent": "HumanHandover",
"score": 0.883278668
},
{
"intent": "None",
"score": 0.3982243
},
{
"intent": "EditCalendarEntry",
"score": 0.00692663854
},
{
"intent": "Login",
"score": 0.00396537
},
{
"intent": "CheckAvailability",
"score": 0.00346317887
},
{
"intent": "AddCalendarEntry",
"score": 0.00215073861
},
{
"intent": "ShowCalendarSummary",
"score": 0.0006825995
},
{
"intent": "PrimaryCalendar",
"score": 2.43631575E-07
},
{
"intent": "DeleteCalendarEntry",
"score": 4.69401E-08
},
{
"intent": "Help",
"score": 2.26313137E-08
}
],
"entities": []
}
脸书切换协议由两个主要动作组成:传递线程控制和获取线程控制。每当新的对话开始时,主应用都会收到用户的消息。主应用确定何时将控制传递给辅助应用。主应用将知道辅助应用的硬编码标识符,或者它可以查询页面以获得辅助应用的列表,并在运行时选择一个。如果我们的页面根据功能区域有多个二级应用,聊天机器人可以根据用户的输入计算出转移的目的地。辅助应用完成后,它可以将控制权交还给主应用。
在脸书页面的上下文中,页面的收件箱可以被认为是一个辅助应用。从功能的角度来看,这意味着任何管理页面收件箱的人都不应该看到消息,除非聊天机器人已经把它交给了收件箱。我们可以在页面的 Messenger 平台设置中进行设置(图 12-7 )。
图 12-7
为脸书页设置主要和辅助接收人
接下来,我们创建负责调用移交逻辑的对话框。对脸书 API 的请求将指向这两个端点中的任何一个,尽管我们的演示只需要联系 pass_thread_control 端点。
const pass_thread_control = 'https://graph.facebook.com/v2.6/me/pass_thread_control?access_token=' + pageAccessToken;
const take_thread_control = 'https://graph.facebook.com/v2.6/me/take_thread_control?access_token=' + pageAccessToken;
无论我们调用哪个端点,都必须包含用户的 ID,并且可能包含一些元数据。 pass_thread_control 方法还需要传递一个 target_app_id 来指示线程被转移到哪个应用。脸书文档指出,移交到页面收件箱要求 target_app_id 的值为 263902037430900。接下来显示了调用脸书端点的代码。我们使用 request Node.js 包来发出新的 HTTP 请求。
function makeFacebookGraphRequest(d, psid, metadata, procedure, pageAccessToken) {
const data = Object.assign({}, d);
data.recipient = { 'id': psid };
data.metadata = metadata;
const options = {
uri: "https://graph.facebook.com/v2.6/me/" + procedure + "?access_token=" + pageAccessToken,
json: data,
method: 'POST'
};
return new Promise((resolve, reject) => {
request(options, function (error, response, body) {
if (error) {
console.log(error);
reject(error);
return;
}
console.log(body);
resolve();
});
});
}
const secondaryApp = 263902037430900; // Inbox App ID
function handover(psid, pageAccessToken) {
return makeFacebookGraphRequest({ 'target_app_id': secondaryApp }, psid, 'test', 'pass_thread_control', pageAccessToken);
}
function takeControl(psid, pageAccessToken) {
return makeFacebookGraphRequest({}, psid, 'test', 'take_thread_control', pageAccessToken);
}
该对话框的代码非常简单地调用了 handover 方法。
const builder = require('botbuilder');
const constants = require('../constants');
const request = require('request');
const libName = 'humanEscalation';
const escalateDialogName = 'escalate';
const lib = new builder.Library(libName);
let pageAccessToken = null;
exports.pageAccessToken = (val) => {
if(val) pageAccessToken = val;
return pageAccessToken;
};
exports.escalateToHuman = (session, pageAccessTokenArg, userId) => {
session.beginDialog(libName + ':' + escalateDialogName, { pageAccessToken: pageAccessTokenArg || pageAccessToken });
};
lib.dialog(escalateDialogName, (session, args, next) => {
handover(session.message.address.user.id, args.pageAccessToken || pageAccessToken);
session.endDialog('Just hold tight... getting someone for you...');
}).triggerAction({
matches: constants.intentNames.HumanHandover
});
exports.create = () => { return lib.clone(); }
让我们看看这种互动在脸书收件箱里是什么样子的。在我们运行 bot 之前,我们注意到脸书页面中的收件箱是空的(图 12-8 )。
图 12-8
清空收件箱
我们可以和日历机器人交换一些信息。图 12-9 显示了一个示例交互。
图 12-9
预热
请注意,脸书页面收件箱仍然是空的;这是有意的。由于主要应用负责处理用户的消息,因此没有必要让页面收件箱参与进来。如果我们展开界面左上方的汉堡菜单,会发现收件箱有多个文件夹(图 12-10 )。
图 12-10
我们已经找到了收件箱文件夹
瞧,如果我们点击 Done 文件夹,我们会找到我们刚刚与聊天机器人的对话(图 12-11 )。我们完全可以在响应文本框中键入我们的回复,但这只会让用户感到困惑,因为机器人和人都会响应客户,因为机器人仍然在循环中。
图 12-11
我们找到了我们的对话!
让我们回到收件箱文件夹。我们还以客户的身份返回 Messenger,并要求与人交谈(图 12-12 )。
图 12-12
我要求和她说话!
如果您刷新收件箱页面,您会注意到该对话出现在收件箱中(图 12-13 )。
图 12-13
好了,是时候和我们的客户谈谈了!
此时,聊天机器人看不到任何客户消息,从脸书页面收件箱发送的任何消息都会出现在客户的聊天中(图 12-14 )。
图 12-14
哦,哇,无缝的人员升级集成!
现在,下一步是断开与二级应用的连接。如果我们有两个脸书应用,我们将不得不使用我们编写的代码收回控制权或将控制权传递回主应用。在这种情况下,页面收件箱具有内置的功能。在任何对话的右上角,我们会发现一个标有“标记为完成”的绿色文本按钮(图 12-15 )。
图 12-15
通过点击“标记为完成”按钮将用户转移回聊天机器人
一旦对话结束,代理点击那个按钮,对话就被传送回机器人。从脸书页面收件箱的角度来看,对话被移回 Done 文件夹,机器人再次被激活(图 12-16 )!从客户的角度来看,这是完全无缝的。
图 12-16
机器人再次活跃起来
如果用户再次遇到麻烦,他可以再次请求人工代理来解决问题。
结论
我们在这一章的工作重点是无缝的人员交接。这是我们的客户和代理的关键体验要求。为双方提供的体验应该尽可能无摩擦。聊天机器人应该是一个有用的助手,这将增加聊天机器人从内部和外部各方获得支持的可能性。
虽然我们在本章中演示的示例在范围上仅限于脸书,但它说明了大多数聊天机器人与实时聊天系统集成将遵循的一般方法。当然,还有许多细节需要解决,这个问题没有单一的解决方法,但是我们在这一章所做的工作应该足以让我们的聊天机器人的人工切换功能朝着正确的方向发展。
十三、聊天机器人分析
现在,我们已经具备了为我们的客户开发出色对话体验的必要技能,很明显,你将创造出下一个杀手机器人。它将与一堆 API 集成,完成迄今为止业界闻所未闻的事情。我不擅长销售,但你懂的。你对自己的想法感到兴奋,更兴奋的是把它推向市场。该机器人已经部署,但令所有人失望的是,它并没有获得牵引力。用户不会参与其中。突然,你意识到你没有很好地理解用户在做什么,以及他们何时放弃与你的聊天机器人的对话。我们需要的是分析!
所有聊天机器人都会不断产生数据。用户和机器人之间的每一次交互,每次 NLU 平台解析用户的意图,每次用户诅咒机器人,每次机器人不知道用户在要求它做什么,都是对话中的关键点,可以洞察用户的行为,更重要的是,可以洞察如何改善对话体验。
我们有哪些方法可以获取所有这些数据?我们试图回答什么样的问题?我们如何获得这些数据?本章旨在回答其中的一些问题,并介绍如何将聊天机器人与分析平台相集成。
常见数据问题
值得研究的是,我们应该从用户与聊天机器人的互动中获得什么样的见解。我们当然对用户与机器人对话的时间感兴趣。我们还对用户发信息的主题感兴趣。当然,我们对原始输入感兴趣,但是如果我们知道被解决的确切意图,我们可能会得到更好的见解。我们还想知道我们的机器人知道如何处理用户输入的百分比,或者它应该知道如何处理。
一般来说,聊天机器人分析平台都会收集和报告类似的数据。除了一般的分析功能,许多人可以在机器人上执行特定通道的分析。例如,Dashbot 是我们将在下一节介绍的平台之一,它可以从 Slack 和 Facebook Messenger 等平台收集特定的分析数据。在 Slack 上,我们可以看到统计数据,比如有多少 Slack 频道安装了我们的 bot。毫不奇怪,分析工具应该允许我们要求特定通道的数据。在一般情况下,我们要问的问题并不新奇:网络分析平台回答了很多类似的问题。对于聊天机器人,我们接下来看几类分析。
通用数据
通用数据是原始的数字数据,例如消息数量、用户会话数量、每个会话交换的消息数量、会话持续时间、每个用户的会话数量等等。这些数据应该显示在一个按时间绘制的图表中,理想情况下,可以按任何时间段进行汇总。这些数据让我们可以看到一些简单的趋势,例如用户通常何时与机器人交互,交互多少次,持续多长时间。如果你有一百万用户,恭喜你!但是如果他们只和你的机器人交流过两条信息,那就不是成功。图 13-1 展示了谷歌聊天数据库提供的一个简单的活跃用户图表。图 13-2 是 Dashbot 用户参与度图表的一个例子。
图 13-2
Dashbot 的交战图
图 13-1
Chatbase 的活跃用户图表
人口统计数据
此类别包括位置、性别、年龄和语言等数据。此数据并不适用于所有通道。图 13-3 是来自 Dashbot 的用户语言分发示例。
图 13-3
这只猫
感情
现在我们进入了一些有趣的领域。理想情况下,我们会检查与会话持续时间和意图等其他指标相关的平均对话情绪。例如,一个功能真的会让用户感到沮丧吗?随着时间的推移,用户是否对机器人越来越失望?如果支持的话,这可能表示需要主动转移到人类实时聊天。情绪是否与我们无法控制的事情相关,比如一天中的时间?图 13-4 是 Dashbot 整体情绪可视化的一个例子。
图 13-4
整体情绪跟踪。6 月 26 日不是一个好日子。
用户保持率
作为聊天机器人开发者或产品所有者,最有趣的数据之一是用户多久回来体验一次。我们希望我们的对话体验是“粘性的”分析平台通常会包括一些可视化功能,显示每周有多少用户返回聊天机器人。当然,一个好的分析工具也可以让我们根据用户最初与聊天机器人互动的方式来探索保留指标。谷歌的聊天平台就是这样一个平台(图 13-5 )。默认情况下,我们可以看到在以任何方式与机器人互动一周后,有多少百分比的用户会再次使用机器人。我们可以将此分解,将意图作为等式的一部分,从而将意图与保持联系起来。这是一个很好的衡量标准,可以用来了解哪些功能可能会推动用户保持率,以及哪些领域需要改进。
图 13-5
用户保留表
用户会话流
可视化用户行为的方法有很多,但用户流是最常见的方法之一。通常,分析平台将显示用户在会话开始时采取的最常见操作,以及采取此操作的用户百分比。接下来,对于每个操作,它将显示用户采取的每个后续操作,包括这样做的用户的百分比和退出率。也就是说,我们了解了有多少用户一直在与机器人进行交互,通过哪些操作,以及有多少用户只是完全停止了与机器人的对话。同样,这种可视化在网络分析领域很常见,在聊天机器人中使用也很自然。图 13-6 显示了一个来自 Chatbase 的例子。我们可以从这种可视化中获得的一个见解是,团队可能会考虑支持那些指定他们今天要车的租车客户,而不是要求他们输入日期。请注意,“今天租车”路径表示不支持“今天”意图。
*
图 13-6
示例 charbase 会话流程图
分析平台
有几个聊天机器人分析平台。首先,大多数聊天机器人开发平台和一些通道都有某种分析仪表板。例如,微软的 Bot 框架包括一个分析仪表板(图 13-7 ),它提供了消息和用户的总数、基本保留表、一段时间内每个通道的用户数以及一段时间内每个通道的消息数。
图 13-7
Azure Bot 服务分析仪表板
脸书提供脸书分析(图 13-8 ),这是一个平台范围的分析仪表板,包括详细的脸书机器人数据。亚马逊提供了一个 Alexa 技能仪表盘。问题是,机器人服务分析在深度和可用性方面有些欠缺,脸书和 Alexa 仪表板都只支持一个通道。
图 13-8
机器人的脸书分析
许多客户已经投资了跨多个产品线的分析平台。例如,一个分析系统可能拥有从 web 属性、移动应用和多个聊天机器人收集的所有数据。在这样的环境中,数据和用户行为可以跨不同的平台进行关联。如果有一种方法可以识别移动设备上的用户,并将其与聊天机器人上的用户相关联(可能通过帐户链接过程),那么我们就可以更广泛地了解用户在各个平台上的行为,并相应地满足他们的需求。通常,这将涉及企业数据存储解决方案,无论是在内部还是在云中,使用微软的 Power BI(图 13-9 )或 Tableau 等工具构建自定义可视化。
图 13-9
Power BI 仪表板示例
还有灵活的第三方聊天机器人分析解决方案,提供我们可以与我们的机器人集成的 API 和 SDK。我们提到了两个我们将在本章剩余部分使用的工具:Dashbot ( https://dashbot.io
)和 Google 的 Chatbase ( https://chatbase.com
)。还有其他选项如僵尸分析( https://botanalytics.co/
)和僵尸度量( https://www.getbotmetrics.com/
)。其中许多供应商还支持对 Alexa、Cortana 和 Google Home 等语音界面的分析。我们鼓励您自己进行研究,了解各种选项,并根据他们的要求做出最佳选择。
与 dashbot 和 charbase 集成
我们选择了这两个平台来展示两种风格的分析集成以及它们提供的报告类型。我们将了解 Dashbot 的开箱即用 Node bot Builder 支持,它利用 Bot 中间件来安装传入和传出消息处理程序,以便向 Dashbot 发送分析数据。(回想一下,我们之前在第十章的多语言支持上下文中使用了 bot 中间件的概念。)这是一个很好的开始。相比之下,谷歌的聊天平台更注重确保围绕分析数据的故事更加丰富。具体地,当向分析系统报告数据时,不仅发送用户的输入而且确定输入是否符合意图、输入是否被处理以及输入是命令还是对机器人问题的简单反馈可能是有用的。这种额外的元数据,通过中间件的简单集成将被强制捕获,可以产生极其丰富的分析。正确地完成它需要努力使每个对话都具有分析意识。让我们看几个例子来说明这两种方法。
先说 Dashbot。首先,我们去 https://dashbot.io/
注册一个免费账户。一旦登录,我们将被带到一个空的机器人列表。点击添加机器人、技能或动作按钮(Dashbot 支持 Alexa 技能和谷歌动作,你能看出来吗?).界面将询问我们目标平台或频道(图 13-10 )。这是 Dashbot 基于通道提供分析优化和进一步数据集成机会的方式。
图 13-10
创建新的 Dashbot 条目
创建完成后,Dashbot 将向我们展示该机器人的分析 API 密钥。让我们将聊天机器人连接到这个 Dashbot 条目。首先,安装 Node.js 包。
npm install dashbot –-save
最后,在创建了一个 bot 之后,我们将以下代码添加到 app.js 文件中:
// setup dashbot
const dashbotApiMap = {
facebook: process.env.DASHBOT_FB_KEY
};
const dashbot = require('dashbot')(dashbotApiMap).microsoft;
// optional and recommended for Facebook Bots
dashbot.setFacebookToken(process.env.PAGE_ACCESS_TOKEN);
bot.use(dashbot);
这里发生了几件事。首先,我们指定 Dashbot API 键。在 Dashbot 中,每个平台都可以获得自己独特的仪表板,或者您可以创建多平台仪表板。如果机器人支持额外的通道,并且我们为这些通道准备了额外的 API 键,我们将在 dashbotApiMap 中设置它们。接下来,我们为 bot 框架导入 Dashbot 中间件,并使用 bot.use 将其添加到 Bot 中。当我们这样做的时候,我们也提供了脸书页面访问令牌。这不是必需的,但它为 Dashbot 提供了从脸书获取额外数据并将其集成到仪表板中的能力。
而且,就是这样!Dashbot 的 bot 框架中间件的代码非常简洁。我们在此提出以供参考:
that.receive = function (session, next) {
logDashbot(session, true, next);
};
that.send = function (session, next) {
logDashbot(session, false, next);
};
function logDashbot(session, isIncoming, next) {
if (that.debug) {
//console.log('\n*** MSFTBK Debug: ', (isIncoming ? 'incoming' : 'outgoing'), JSON.stringify(session, null, 2))
}
var data = {
is_microsoft: true,
dashbot_timestamp: new Date().getTime(),
json: session
};
var platform = session.source ? session.source : _.get(session, 'address.channelId');
// hack for facebook token
if (platform === 'facebook' && that.facebookToken != null) {
data.token = that.facebookToken;
}
var apiKey = apiKeyMap[platform]
if (!apiKey) {
console.warn('**** Warning: No Dashbot apiKey for platform:(' + platform + ') Data not saved. ')
next();
return;
}
// if the platform is not supported by us, use generic
if (_.indexOf(['facebook', 'kik', 'slack'], platform) === -1) {
platform = 'generic';
}
var url = that.urlRoot + '?apiKey=' +
apiKey + '&type=' + (isIncoming ? 'incoming' : 'outgoing') +
'&platform=' + platform + '&v=' + VERSION + '-npm';
if (that.debug) {
console.log('\n*** Dashbot MSFT Bot Framework Debug **');
console.log(' *** platform is ' + platform);
console.log(' *** Dashbot Url: ' + url);
console.log(JSON.stringify(data, null, 2));
}
makeRequest({
uri: url,
method: 'POST',
json: data
}, that.printErrors, that.config.redact);
next();
}
在与我们的机器人交谈了几分钟后,我们产生了图 13-11 中的数据。
图 13-11
一次对话的数据价值
那很容易。我们可以在 Dashbot 上查看许多其他数据点。图 13-12 显示了一个可能性列表,包括关于用户、留存率、人口统计、热门信息和意图的详细信息,甚至包括原始对话记录。自然,像意图数据这样的东西不会被填充。根据我们之前的观点,如果我们想要支持它,我们的对话框必须包含分析报告功能。
图 13-12
Dashbot 提供的不同分析
Google 的 Chatbase API 不包含预建的 Bot 框架中间件集成;然而,我们自己构建这个并不太具有挑战性。我们可以把 Dashbot 的代码作为起点。事实上,我们这样做,但只针对传出的消息。传入的消息数据将从各个对话中发送。
首先,我们通过 Add Your Bot 按钮在 https://chatbase.com
上创建一个新的机器人。我们需要输入姓名、国家、行业和商业案例。因此,我们将从 Chatbase 获得一个 API 密钥。我们首先安装 Node.js 包。
npm install @google/chatbase –-save
然后,我们编写一些助手方法来构建聊天库消息和中间件发送处理程序。我们可以将它放在自己的 Node.js 模块中。在下面的构建方法中,我们要求调用者提供消息文本、用户 ID、对话参数(从中我们可以尝试提取意图)和已处理标志。Chatbase 允许我们报告某个输入是否被处理。例如,如果有来自用户的未被识别的输入,我们将希望这样报告它。
require('dotenv-extended').load();
const chatbase = require('@google/chatbase')
.setApiKey(process.env.CHATBASE_KEY) // Your Chatbase API Key
.setAsTypeUser()
.setVersion('1.0')
.setPlatform('SAMPLE'); // The platform you are interacting with the user over
exports.chatbase = chatbase;
chatbase.build = function (text, user_id, args, handled) {
let intent = args;
if (typeof (intent) !== 'string') {
intent = args && args.intent && args.intent.intent;
}
var msg = chatbase.newMessage();
msg.setIntent(intent).setUserId(user_id).setMessage(text);
if (handled === undefined && !intent) {
msg.setAsNotHandled();
} else if (handled === true) {
msg.setAsHandled();
} else if (handled === false) {
msg.setAsNotHandled();
}
return msg;
}
exports.middleware = {
send: function (event, next) {
if (event.type === 'message') {
const msg = chatbase.newMessage()
.setAsTypeAgent()
.setUserId(event.address.user.id)
.setMessage(event.text);
if (!event.text && event.attachments) {
msg.setMessage(event.attachmentLayout);
}
msg.send()
.then(() => {
next();
})
.catch(err => {
console.error(err);
next();
});
} else {
next();
}
}
};
在我们的 app.js 中剩下要做的就是安装 Bot Builder 中间件。
const chatbase = require('./chatbase');
bot.use(chatbase.middleware); // install the sender middleware
接下来,我们需要在对话框中任何需要的地方添加分析调用。例如,在 summarize 对话框中,我们可以使用这个调用来报告成功进入该对话框。
chatbase.build(session.message.text, session.message.address.user.id, args, true).send();
这段代码已经集成到我们在整本书中一直在做的日历机器人中。回购中的分支chapter-13
已经与之前的代码集成。
图 13-13 是使用这种方法收集的数据仪表板示例。我们对聊天机器人没有处理的消息特别感兴趣。我们确实向日历机器人询问了生命的意义,这是我们不期望得到满意答案的事情。未处理的话语数据当然是我们要考虑的重要信息。图 13-14 显示处理后的输入。
图 13-14
同一会话的已处理消息
图 13-13
包含与机器人的一次对话的仪表板
同样,以前的数据很少,但随着你的聊天机器人获得使用,情况会变得更清楚,也更有价值。
结论
这一章仅仅触及了如何正确地引导聊天机器人进行分析收集的表面。不同的分析平台还没有成熟的网络分析平台丰富,但他们正在取得良好的进展。作为聊天机器人开发人员,我们的重点是熟悉系统,并能够将它们集成到我们的代码中,以便正确的数据流入分析仪表板。然后,我们的团队可以做出明智的决定,决定哪些聊天机器人功能应该改进,哪些新功能可以添加,哪些功能可能不会引起用户的共鸣。聊天机器人仍然是一个新的空间;客户对对话界面会有各种各样的反应,特别是如果部署给不精通技术或者不喜欢用计算机发消息的客户。理解这些挑战并基于分析改善对话体验对于确保未来几年的成功采用至关重要。分析将在这一变革中发挥主导作用。*
十四、运用我们的知识:Alexa 技能包
本书的目标之一是强调贯穿其中的思想、技术和技能适用于许多类型的应用。在本章中,通过创建一个简单的 Alexa 技能,我们演示了如何应用我们的意图分类、实体提取和对话构建知识来创建自然语言语音体验。我们通过使用 Node.js 的 Alexa Skills Kit SDK,以最简单的方式创建一个 Alexa 技能,因为我们已经有了一个机器人服务后端,你可能不可避免地会问我们是否可以将 Alexa 与这个后端集成。答案是响亮的是。一旦我们有了 Alexa 技能的基础,我们将展示如何通过直线和机器人框架机器人来驱动 Alexa 技能。
介绍
Alexa 是亚马逊的智能个人助理。第一个支持 Alexa 的设备是 Echo 和 Echo Dot,随后是支持屏幕的 Echo Show 和 Spot。亚马逊也在探索一个名为 Lex 的聊天机器人平台。Alexa 技能是通过声明一组意图和插槽(实体的另一个名称)并编写一个 webhook 来处理传入的 Alexa 消息而开发的。来自 Alexa 的消息将包括解析的意图和槽数据。我们的 webhook 用包含语音和用户界面元素的数据进行响应。在 Echo 和 Echo Dot 的第一次迭代中,没有物理屏幕,因此唯一的用户界面是用户手机上的 Alexa 应用。该应用的主要用户界面元素是一张卡片,与我们在 Bot Builder SDK 中遇到的英雄卡片没有太大区别。例如,从 Alexa 到我们的 webhook 的消息将如下所示。请注意,本节中介绍的消息格式是伪代码,因为实际消息要详细得多。
{
"id": "0000001",
"session": "session00001",
"type": "IntentRequest",
"intent": {
"intent": "QuoteIntent",
"slots": [
{
"type": "SymbolSlot",
"value": "apple"
}
]
}
}
响应如下所示:
{
"speech": "The latest price for AAPL is 140.61",
"card": {
"title": "AAPL",
"text": "The latest price for Apple (AAPL) is $140.61.",
"img": "https://fakebot.ngrok.io/img/d5fa618b"
}
}
我们可能希望允许额外的功能,如播放音频文件。为了与财务场景保持一致,我们可能会为用户播放音频简报内容。完成此任务的消息类似于以下内容:
{
"speech": "",
"directives": [
{
"type": "playAudio",
"parameters": {
"href": "https://fakebot.ngrok.io/audio/audiocontent1",
"type": "audio/mpeg"
}
}
]
}
此外,系统可能想要提供用户是否取消了音频回放或收听了整个剪辑的指示。更一般地说,系统可能需要一种方式将事件发送到我们的 webhook。在这些情况下,传入的消息可能如下所示:
{
"id": "0000003",
"session": "session00001",
"type": " AudioFinished"
}
如果我们获得了像 Echo Show 设备提供的屏幕的使用,更多动作和行为的潜力就会增长。例如,我们现在可以播放视频。或者我们可以向用户展示一个带有图像和按钮的用户界面。如果我们显示一个项目列表,也许我们希望设备在项目被点击时发送一个事件。然后,我们将创建一个用户界面呈现指令,因此,我们之前的报价响应现在可能会包括一个用户界面元素,如下所示:
{
"speech": "The latest price for AAPL is 140.61",
"card": {
"title": "AAPL",
"text": "The latest price for Apple (AAPL) is $140.61.",
"img": "https://fakebot.ngrok.io/img/d5fa618b"
},
"directives": [
{
"type": "render",
"template": "single_image_template",
"param": {
"title": "AAPL",
"subtitle": "Apple Corp.",
"img": "https://fakebot.ngrok.io/img/largequoteaapl"
}
}
]
}
指令的伟大之处在于它们是声明性的;由设备决定如何处理它们。例如,Echo Show 和 Echo Spot 设备可以以稍微不同但一致的方式呈现模板。当 Echo 和 Echo Dot 收到不支持的指令(如播放视频)时,它们可能会忽略或引发错误。
创造新技能
创建一个新的 Alexa 技能需要访问亚马逊开发者帐户进行技能注册,并访问亚马逊网络服务(AWS)帐户来托管技能代码。要开始,导航到 https://developer.amazon.com
并点击开发者控制台链接。如果您有帐户,请登录。否则,点击创建您的亚马逊开发者账户。我们将被要求提供电子邮件和密码、我们的联系信息以及开发人员或公司名称;我们还需要接受应用分发协议,并回答一些关于我们的技能是否会接受付款或显示广告的问题。我们可以将最后两个问题的答案都选择为否。此时,我们将被带到仪表板(图 14-1 )。
图 14-1
仪表盘上没什么
单击 Alexa 技能工具包标题项目。我们现在将被放置在 Alexa 技能工具包开发者控制台中,技能列表为空。单击创建技能后,我们必须输入技能名称。之后,我们必须选择一个模型来增加技能。有几种类型的带有预构建的自然语言模型的技能可供选择,但是在这种情况下,我们选择构建我们自己的模型,因此我们选择自定义技能。 1 选择自定义类型后,点击创建技能按钮。我们现在看到了技能仪表盘(图 14-2 )。仪表板包括创建技能语言模型的能力,以及配置、测试甚至发布技能的能力。
图 14-2
新的自定义技能仪表板
在页面右侧有一个方便的技能构建清单区域,我们将跟随它。我们将从设置技能的调用名称开始。当用户想要在他们的 Alexa 设备上调用技能时,这是用来标识技能的短语。例如,在“Alexa,请金融机器人引用苹果”话语中,金融机器人是调用名称。点击调用名称检查表项,加载屏幕进行设置(图 14-3 )。输入名称后,点击保存模型。
图 14-3
设置技能调用名称
在我们开始建立我们的自然语言模型,或交互模型之前,我们需要启用正确的接口。回想一下,我们谈到了向设备发送指令的能力,比如播放音频文件或呈现用户界面元素。我们必须在我们的技能中明确地启用这些特性。点击左侧导航窗格中的接口链接。在该界面中,启用音频播放器、显示界面和视频 App (图 14-4 )。我们将在本章练习中尝试所有这些。
图 14-4
启用 Alexa 界面
我们现在已经准备好开发 Alexa 交互模型了。
亚历克莎·NLU 和自动语音识别
您可能已经注意到,当我们第一次创建技能时,我们的技能模型中有三个内置的意图。这些显示在左侧窗格中。启用各种接口后,我们现在有大约 16 个意图。随着 Alexa 系统增加更多的功能,越来越多的意图将被添加到所有的技能中。
这突出了 Alexa 交互模型和语言理解智能服务(LUIS)之间的第一个区别,在第三章中进行了深入探讨。LUIS 是一个通用的自然语言理解(NLU)平台,几乎可以在任何自然语言应用中使用。Alexa 是一个围绕数字助理设备的特定生态系统。为了在所有 Alexa 技能之间创造一致的体验,亚马逊为所有技能提供了一套通用的内置意图,前缀为亚马逊。(图 14-5 )。为了获得最佳的用户体验,我们的技术应该尽可能多地实现这些功能,否则就会失败。亚马逊将在技能审查过程中审查所有这些。顺便说一句,我们在本书中不涉及技能审查和认证;Amazon 围绕这个过程提供了大量详细的文档。
图 14-5
内置的 Alexa 意图
如果列出的 16 个还不够,亚马逊提供了总共 133 个内置意图供我们的技能利用。熟悉 Amazon 提供的集合对我们很有用,因为这个列表会独立于我们的技能继续发展。当然,编写自定义技能意味着添加自定义意图。当我们创建一个金融机器人技能时,我们将创建一个报价意图,这将允许我们获得一个公司或一个符号的报价。要添加新的自定义意图,请单击左侧意图标题旁边的添加按钮。选中创建自定义意向复选框,输入名称,点击创建自定义意向按钮(图 14-6 )。
图 14-6
添加报价内容自定义意图
我们被带到意图屏幕,在那里我们可以输入示例话语(图 14-7 )。请注意,该意图被添加到左侧窗格中,如果我们选择从模型中删除该意图,它旁边会有一个垃圾桶按钮。
图 14-7
填充引用内容的示例话语
接下来,我们需要能够提取我们想要报价的公司或符号的名称。在路易斯,我们将为此创建一个新的实体;在 Alexa 的世界里,这被称为槽。我们将创建一个名为 QuoteItem 的自定义插槽类型,并给出一些公司名称或符号的示例。我们首先通过点击左侧窗格中插槽类型标题旁边的添加按钮来添加新的插槽类型(图 14-8 )。注意有 96 种内置插槽类型!这些包括从日期和数字到演员,体育,甚至视频游戏的一切。有一个公司插槽类型可以满足我们的目的,但我们选择继续使用自定义插槽类型作为练习。选择创建自定义插槽类型单选按钮,输入名称,点击创建自定义插槽类型按钮。
图 14-8
添加新的插槽类型
接下来,我们输入 QuoteItem 插槽类型的各种值(图 14-9 )。
图 14-9
向自定义插槽类型添加新值
当然,这是一个有限的集合,但是现在已经够用了。公司名称和股票代码的范围非常大,我们不希望在示例窗值中输入所有的公司名称和股票代码。然而,我们提供的例子越多,NLU 引擎在正确识别 QuoteItems 方面就会越好,自动语音识别(ASR)引擎也会越好。这后一点的原因是,语音识别系统,如 Alexa,Google Home 和微软的 Cortana 都可以用不同的话语进行准备。启动是 ASR 过程中的一个重要步骤,因为它向引擎提供了关于技能词汇的清晰提示。这使得 ASR 系统能够理解上下文并更好地转录用户的话语。
让我们回到报价内容。在 Alexa 的 NLU 中,我们必须明确地添加插槽类型。在示例话语下面,intent 用户界面允许我们添加槽位。为该插槽命名,然后单击+按钮。现在,我们能够分配插槽类型(图 14-10 )。
图 14-10
将 QuoteItem 插槽类型添加到 quote item
最后,我们必须正确标记每个话语中的时间段。我们可以通过在样本话语界面中选择一个单词或一组连续单词来做到这一点。我们将看到一个弹出窗口,其中显示了您可以分配给所选子字符串的目的槽。在为每个选项选择 QuoteItem 后,我们的 quote 内容将如图 14-11 所示。
图 14-11
报价内容现在准备好了
我们将增加一个意向。我们希望能够使用“获取 401k 账户信息”或“什么是罗斯个人退休帐户?”等语句来询问特定账户类型的信息我们把这个意图叫做 GetAccountTypeInfoIntent 。在创建意向之前,让我们创建支持的插槽类型。与添加 QuoteItem 插槽类型的方式相同,让我们添加一个 AccountType 自定义插槽类型。
创建后,输入一组不同的帐户类型和不同的表达方式。例如,401k 也可以称为 401(k)。注意,我们还指定了每个帐户类型的单词拼写(图 14-12 )。其原因是 ASR 系统可以将用户输入转录为单词,而不是数字。请注意,对于我们的应用来说,帐户类型集很可能是一个封闭集,因此这与我们的 Note 内容中 QuoteItem 的开放概念呈现了不同的用例。
图 14-12
使用同义词创建自定义插槽类型
现在我们可以创建一个名为GetAccountTypeInformationIntent的新的定制意图。添加 AccountType 作为意向槽。然后我们可以输入一些示例语句。结果如图 14-13 所示。
图 14-13
最终确定 GetAccountTypeInformationIntent
至此,我们已经完成了交互模型的初稿。单击保存模型按钮,然后单击构建模型按钮。建立模型将利用我们提供的所有数据来训练系统。注意,在任何时候,我们都可以使用左侧窗格中的 JSON 编辑器链接看到模型 JSON 格式。JSON 封装了添加到模型中的所有内容。图 14-14 显示了其中的一部分。共享模型最简单的方法是共享这个 JSON 内容。当然,也有命令行工具来进一步自动化这个过程。
图 14-14
我们刚刚创建的 Alexa 交互模型的摘录
为了本章的目的,这是我们将涵盖的关于 Alexa 的 NLU。明确地说,我们做得不公平。系统丰富,值得学习。
进入 Node 的 Alexa 技能包
回到仪表板,技能构建清单的最后一步是设置端点。端点是接收来自 Amazon 的传入消息并用语音、卡片和指令进行响应的代码。
这里我们可以采取两种方法。首先,我们可以自己托管一个端点,给 Amazon 提供 URL,解析每个请求,并做出相应的响应。使用这种方法,我们获得了控制权,但是必须自己实现验证和解析逻辑。我们还将拥有部署任务。
第二种选择是使用无服务器计算,这种选择目前非常普遍。 2 这让我们能够在云端创建代码,根据需求运行和扩展。在 AWS 上,这是 Lambda。在 Azure 中,等价的应该是函数。亚马逊为此提供了 Node.js 的亚马逊 Alexa 技能工具包 SDK(https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs
)。在这一节中,我们将深入探讨在 AWS Lambda 上运行 Alexa 的技巧。
使用 Alexa 技能工具包 SDK 构建的技能结构如下所示。我们在代码中注册了所有想要处理的意图。emit 函数向 Alexa 发送响应。SDK 的 GitHub 站点上记录了许多不同的 emit 重载。 3
const handlers = {
'LaunchRequest': function () {
this.emit('HelloWorldIntent');
},
'HelloWorldIntent': function () {
this.emit(':tell', 'Hello World!');
}
};
最后,我们向 Alexa SDK 注册技能和处理程序。
const Alexa = require('alexa-sdk');
exports.handler = function(event, context, callback) {
const alexa = Alexa.handler(event, context, callback);
alexa.registerHandlers(handlers);
alexa.execute();
};
这段代码足以运行一个基本技能,该技能在启动时或者当hello world ent意图匹配时以“hello world”响应。从概念上讲,我们在为我们的金融技能创建代码时将遵循相同的方法。不过,在我们继续之前,我们如何将我们的技能与 AWS Lambda 联系起来?
首先,我们需要一个 AWS 帐户。我们可以在这里创建一个 AWS 自由层账户: https://aws.amazon.com/free/
。免费层是入门和熟悉 AWS 的最佳方式。点击创建免费账户。我们将被要求提供电子邮件地址、密码和 AWS 帐户名称(图 14-15 )。
图 14-15
创建新的 AWS 帐户
接下来,我们将输入我们的个人联系信息。我们将需要输入我们的支付信息用于身份验证目的(您在免费层时不会被收费)并验证我们的电话号码。完成后,我们将进入 AWS 管理控制台。此时,我们可以在“所有服务”列表中找到 Lambda 并导航到它。
现在我们可以开始创建一个 Lambda 函数。点击“创建功能”,选择蓝图,找到并选择 alexa-skill-kit-sdk-factskill,然后点击配置按钮。我们为该功能指定一个对我们帐户的功能列表唯一的名称,将角色设置为从模板创建新角色,为角色指定一个名称,并选择简单微服务权限模板(图 14-16 )。
图 14-16
创建新的 Lambda 函数
在数据输入字段下面,我们将看到我们的 Lambda 代码。运行时应该设置为 Node.js 6.10,尽管可以肯定 Amazon 可能会随时更新。我们暂时保留代码不变。点击创建功能按钮后,将进入功能配置界面(图 14-17 )。
图 14-17
功能配置屏幕
我们可以在此屏幕上执行许多操作。首先,右上角显示了 Lambda 标识符。我们需要马上向 Alexa 展示这个技能。我们还看到该函数可以访问 CloudWatch 日志(所有 Lambda 日志都被发送到 CloudWatch)和 DynamoDB(亚马逊托管的云 NoSQL 数据库)。Alexa 技能可以使用 DynamoDB 来存储技能状态。
在 Designer 部分,我们需要设置一个触发器来调用我们的新函数。出于我们的目的,找到并点击 Alexa 技能包触发器。一旦您这样做,配置触发器部分将出现在下面。从 Alexa 技能仪表板输入技能 ID。看起来应该是amzn 1 . ask . skill . 5d 364108-7906-4612-a465-9f 560 b 0 BC 16 f。输入 ID 后,点击添加触发,保存功能配置。此时,Lambda 函数已准备好从我们的技能中调用。
在此之前,我们在设计器中选择函数(在本例中,srozga-finance-skill-function,如图 14-17);我们将会看到代码编辑器。对于如何将代码加载到 Lambda 中,我们有几种不同的选择。一种选择是在编辑器中手动编写代码;另一个选择是上传一个包含所有代码的 zip 文件。在真正的应用中做这种手工劳动很快就会变得很累;你可以利用 AWS 4 并要求 CLI 5 从命令行部署一个技能。现在,我们将简单地使用编辑器。用以下代码替换编辑器中的代码:
'use strict';
const Alexa = require('alexa-sdk');
const handlers = {
'LaunchRequest': function () {
this.emit(':tell', 'Welcome!');
},
'QuoteIntent': function () {
this.emit(':tell', 'Quote by company.');
},
'GetAccountTypeInformationIntent': function () {
this.emit(':tell', 'Getting account type.');
}
};
exports.handler = function (event, context, callback) {
const alexa = Alexa.handler(event, context, callback);
alexa.registerHandlers(handlers);
alexa.execute();
};
在我们离开之前,从屏幕的右上角复制 Lambda 函数的 Amazon 资源名称(ARN)。标识符是这样的:arn:AWS:lambda:us-east-1:526347705809:function:srozga-finance-skill-function。
让我们切换回我们的技能的 Alexa 技能配置屏幕。在右侧窗格中选择端点链接。选择AWSλARN复选框,并在默认区域文本框中输入λARN(图 14-18 )。
图 14-18
Alexa 技能λARN 端点配置
单击保存端点按钮。如果这里有问题,你可能没有正确添加 Lambda 功能的 Alexa 技能包触发器。
此时,我们可以使用顶部的导航面板导航到测试部分。默认情况下,该技能不支持测试。切换复选框。现在,我们可以从 Alexa 测试界面,任何连接到开发者帐户的 Echo 设备,或 EchoSim 等第三方工具测试技能。 6 如果您想与测试应用通话,可能会提示您允许麦克风接入。
我们可以通过说话或打字来发送输入话语,我们将收到 lambda 函数的响应,如图 14-19 所示。请确保以“询问{调用名称}”作为开场白。注意,这个接口提供了原始的输入和输出 JSON 内容。花一些时间来检查它;它包含了我们在本章前面提到的许多信息。例如,传入的请求包括来自我们的交互模型的已解析的意图和位置。输出包含回声设备要说话的 SSML。输出还指示会话应该结束。稍后我们将更深入地探讨会话。
图 14-19
成功!
现在我们看到了传入的 JSON 和插槽格式,我们可以扩展代码来提取插槽值。在意图处理程序的上下文中, this.event.request 对象包含已解析的意图和槽值。从那里,它只是一个简单的问题,提取的价值和做一些事情。以下代码提取插槽值,并将它们包含在 Alexa 语音响应中:
'use strict';
const Alexa = require('alexa-sdk');
const handlers = {
'LaunchRequest': function () {
this.emit(':tell', 'Welcome!');
},
'QuoteIntent': function () {
console.log(JSON.stringify(this.event));
let intent = this.event.request.intent;
let quoteitem = intent.slots['QuoteItem'].value;
this.emit(':tell', 'Quote for ' + quoteitem);
},
'GetAccountTypeInformationIntent': function () {
console.log(JSON.stringify(this.event));
let intent = this.event.request.intent;
let accountType = intent.slots['AccountType'].value;
this.emit(':tell', 'Getting information for account type ' + accountType);
}
};
exports.handler = function (event, context, callback) {
const alexa = Alexa.handler(event, context, callback);
alexa.registerHandlers(handlers);
alexa.execute();
};
图 14-20 显示了一个输入“询问金融机器人什么是 ira”的交互示例。如果你说出这句话,它会被理解为“问财务机器人什么是个人退休帐户”,确保“个人退休帐户”是个人退休帐户类型的同义词之一。
图 14-20
从 Alexa 请求中成功提取 AccountType 槽值
注意,如果我们向技能发送内置 Amazon 意图应该处理的东西,比如“取消”,技能可能会返回一个错误。这是因为我们还没有处理一些内置的意图。此外,我们不包括未处理的意图逻辑。通过添加以下处理程序,我们可以轻松处理这两种情况:
'AMAZON.CancelIntent': function() {
this.emit(':tell', 'Ok. Bye.');
},
'Unhandled': function() {
this.emit(':tell', "I'm not sure what you are talking about.");
}
现在,告诉技能“取消”会导致一个再见消息(图 14-21 )。
图 14-21
当要求技能取消时,我们承诺的时髦信息
太好了。这很好,但是我们如何将一个对话框模型化为一个 Alexa 技能呢?Node.js 的 SDK 包含了状态的概念。把它想象成用户当前的对话框。对于每个状态,我们为该状态支持的每个意图提供一组处理程序。本质上,我们通过使用一组状态名和处理程序来编码一个对话图。该技能的代码如下:
'use strict';
const Alexa = require('alexa-sdk');
const defaultHandlers = {
'LaunchRequest': function () {
this.emit(':ask', 'Welcome to finance skill! I can get your information about quotes or account types.', 'What can I help you with?');
},
'GetAccountTypeInformationIntent': function () {
this.handler.state = 'AccountInfo';
this.emitWithState(this.event.request.intent.name);
},
'QuoteIntent': function () {
this.handler.state = 'Quote';
this.emitWithState(this.event.request.intent.name);
},
'AMAZON.CancelIntent': function () {
this.emit(':tell', 'Ok. Bye.');
},
'Unhandled': function () {
console.log(JSON.stringify(this.event));
this.emit(':ask', "I'm not sure what you are talking about.", 'What can I help you with?');
}
};
const quoteStateHandlers = Alexa.CreateStateHandler('Quote', {
'LaunchRequest': function () {
this.handler.state = '';
this.emitWithState('LaunchRequest');
},
'AMAZON.MoreIntent': function () {
this.emit(':ask', 'More information for quote item ' + this.attributes.quoteitem, 'What else can I help you with?');
},
'AMAZON.CancelIntent': function () {
this.handler.state = '';
this.emitWithState(this.event.request.intent.name);
},
'QuoteIntent': function () {
console.log(JSON.stringify(this.event));
let intent = this.event.request.intent;
let quoteitem = null;
if (intent && intent.slots.QuoteItem) {
quoteitem = intent.slots.QuoteItem.value;
} else {
quoteitem = this.attributes.quoteitem;
}
this.attributes.quoteitem = quoteitem;
this.emit(':ask', 'Quote for ' + quoteitem, 'What else can I help you with?');
},
'GetAccountTypeInformationIntent': function () {
this.handler.state = '';
this.emitWithState(this.event.request.intent.name);
},
'Unhandled': function () {
console.log(JSON.stringify(this.event));
this.emit(':ask', "I'm not sure what you are talking about.", 'What can I help you with?');
}
});
const accountInfoStateHandlers = Alexa.CreateStateHandler('AccountInfo', {
'LaunchRequest': function () {
this.handler.state = '';
this.emitWithState('LaunchRequest');
},
'AMAZON.MoreIntent': function () {
this.emit(':ask', 'More information for account ' + this.attributes.accounttype, 'What else can I help you with?');
},
'AMAZON.CancelIntent': function () {
this.handler.state = '';
this.emitWithState(this.event.request.intent.name);
},
'GetAccountTypeInformationIntent': function () {
console.log(JSON.stringify(this.event));
let intent = this.event.request.intent;
let accounttype = null;
if (intent && intent.slots.AccountType) {
accounttype = intent.slots.AccountType.value;
} else {
accounttype = this.attributes.accounttype;
}
this.attributes.accounttype = accounttype;
this.emit(':ask', 'Information for ' + accounttype, 'What else can I help you with?');
},
'QuoteIntent': function () {
this.handler.state = '';
this.emitWithState(this.event.request.intent.name);
},
'Unhandled': function () {
console.log(JSON.stringify(this.event));
this.emit(':ask', "I'm not sure what you are talking about.", 'What can I help you with?');
}
});
exports.handler = function (event, context, callback) {
const alexa = Alexa.handler(event, context, callback);
alexa.registerHandlers(defaultHandlers, quoteStateHandlers, accountInfoStateHandlers);
alexa.execute();
};
注意,这个技能有两种状态:Quote 和 AccountInfo。在这些状态的上下文中,每个意图可能产生不同的行为。如果用户询问处于报价状态的帐户,该技能将重定向到默认状态,以决定如何处理该请求。同样,如果用户在 AccountInfo 状态下询问报价,也会发生类似的逻辑。图 14-22 给出了对话框的图示。注意,在代码中,如果我们想保持会话打开,我们使用 this.emit(':ask') ,如果我们只是想说话和回答并关闭会话,我们使用 this.emit(':tell') 。如果会话保持开放,我们就不必用 ask finance bot 为 Alexa 的每个发言做准备。“这是隐式的,因为用户和我们的技能之间的会话保持开放。 7 还有另一种方法可以利用 ResponseBuilder 构建响应。我们可以在 SDK 文档中读到它,我们将在练习 14-1 中使用它来构建带有渲染模板指令的响应。
图 14-22
我们技能中的对话和过渡的说明
继续运行这个示例,熟悉流程背后的思想。重要的是,我们利用两个字段进行状态存储: this.handler.state 作为当前状态的名称,以及 this.attributes ,它充当用户对话数据存储。将 this.attributes 视为 Bot Builder 中的privateconversiondata字典。默认情况下,当会话结束时,这些值不会保持不变,但 Node.js 的 Alexa 技能工具包支持 DynamoDB 集成状态存储。这将使我们的技能能够在用户再次调用该技能时继续与用户进行交互。
其他选项
在此过程中,我们很方便地忽略了一些其他选项。我们技能的技能开发控制台包含帐户链接和权限链接。帐户链接是通过由 Alexa 管理的 OAuth 流程将用户重定向到授权体验的过程。Alexa 存储令牌并将它们作为每个请求的一部分发送到我们的端点。以这种方式管理的部分原因是原始 Echo 没有屏幕。作为一种启示,授权是通过 Alexa 移动应用进行的,因此 Alexa 服务器需要拥有整个 OAuth 流。
权限屏幕允许我们请求访问用户设备上的某些数据,如设备地址或 Alexa 购物清单(图 14-23 )。
你可以在 Alexa 文档中找到关于这两个主题的更多信息。 8
图 14-23
Alexa 权限屏幕
练习 14-1
连接真实数据并渲染图像
在第十一章中,我们集成了一个名为 Intrinio 的服务来获取财务数据并将其呈现在图像中。本练习的目标是将您的 Alexa 技能代码连接到相同的服务,并在支持屏幕显示的 Echo 设备上呈现图像。
-
使用上一节中的代码作为起点。重温第十一章的代码,确保您的报价状态报价内容处理程序从 Intrinio 中检索报价数据,并以语音方式响应最新价格。
-
将第十一章的 HTML-to-image 生成代码整合到你的 Alexa 技能中。记得在 Lambda 函数中将必要的包添加到
package.json
文件中。 -
访问
https://developer.amazon.com/docs/custom-skills/display-interface-reference.html
来熟悉如何渲染显示模板。具体来说,您将使用 BodyTemplate 7 来呈现上一步生成的图像。 -
要使用 Node.js SDK for Alexa Skills Kit 渲染模板,您需要使用响应生成器(
https://github.com/alexa/alexa-skills-kit-sdk-fornodejs#response-vs-responsebuilder
)。SDK 有助手生成模板 JSON (https://github.com/alexa/alexa-skills-kit-sdk-fornodejs#display-interface
)。 -
测试 Alexa 测试实用程序、EchoSim 和真实 Echo 设备(如果有)的功能。在没有显示器的设备中,代码的行为是什么?
您现在应该能够在支持显示的 Echo 设备上呈现您的财务报价图像,并且您应该已经获得了使用几种方法测试 Alexa 技能的实践经验。
连接到 Bot 框架
到目前为止,我们展示的功能只是 Alexa 技能包功能的一小部分,但足以让人们对将这本书的概念应用到新兴语音平台上表示赞赏。将 Alexa 技能连接到机器人框架机器人的过程遵循类似于我们在第八章中为 Twilio 实现语音机器人的方法。我们将展示如何实现这种连接的代码,给出我们现有的 Alexa 技能工具包交互模型。在深入研究代码之前,我们将讨论解决方案的几个实现决策。
围绕 Bot 框架和 Alexa 技能包集成的实施决策
通常,我们不建议使用 Bot 框架来实现独立的 Alexa 技能。如果需求确实建议使用单一平台,那么局限于 Alexa 交互模型和运行在 AWS Lambda 函数上的 Node.js 的 Alexa Skills Kit SDK 就足够了。在我们的产品应该支持多种自然语言文本和语音接口的情况下,我们可能希望考虑一个平台来运行我们的业务逻辑,而 Bot 框架非常适合这种方法。一旦我们开始将 Alexa 技能连接到 Bot 框架,几个重要的实现决策就会随之而来。这些适用于所有类型的系统,而不仅仅是 Alexa。
自然语言理解
在我们当前努力的背景下,我们应该利用哪个 NLU 平台:LUIS 还是 Alexa 的交互模型?如果我们要使用 Alexa 的交互模型,我们必须通过直接的线路调用将 Alexa intent 和 slot 对象传递到我们的 bot 实现中。然后,我们可以构建一个自定义识别器来检测该对象的存在,并将其转换为 Bot Builder SDK 中正确的意图和实体响应对象。非常清楚地说,这就是识别器的用处所在:机器人不关心意图数据来自哪里。
另一方面,如果我们选择利用 LUIS,我们必须找到一种方法将来自 Alexa 的原始输入传递给机器人。实现这一点的方法是将整个用户输入标记为一个 AMAZON。文字插槽类型。 9 这允许开发者将原始的用户输入传递到技能代码中。这并不意味着我们的技能互动模式变得不存在。记住,Alexa 为它的 ASR 使用交互模型,所以我们想要给出尽可能多的我们期望在我们的技能词汇表中的话语和输入类型的例子。我们需要在 Alexa 交互模型中包含我们所有的 LUIS 话语。
一般来说,由于机器人可能比 Alexa 支持更多的通道,维护一个 NLU 系统,如 LUIS,是一个更容易维护的方法。没有办法完全脱离。我们仍然需要确保我们的机器人正确处理内置意图,如停止和取消。在下面的代码示例中,为了方便起见,我们将假设整个 NLU 模型都存在于 Alexa 中,并演示一个自定义识别器方法。
通道无关对话与通道特定对话
当我们开发一个处理多个通道的机器人时,我们必须决定一个对话框实现是否可以处理所有通道,或者每个通道是否应该有自己的对话框实现。每一种都有其论据,尽管如果你从模型视图控制器(MVC)模式的角度来考虑,我们可以提出一个优雅的解决方案。如果我们认为一个对话框是控制器,是我们与模型对话的 API,那么我们就有了一个问题,什么扮演视图的角色。
我们希望创建能够基于通道呈现消息的独立代码段。尽管 bot 服务试图抽象通道,我们还是会遇到特定于通道的行为。例如,我们将把 Alexa 与文本频道区别对待。一种方法是创建在对话框中使用的默认视图渲染器,并添加特定于通道的视图渲染器,以支持偏离默认的行为或图像。一种更通用的方法是简单地为语音和文本通道使用不同的视图呈现器。图 14-24 显示了来自语音信道的消息情况下该方法的示例流程。
图 14-24
来自语音通道(如 Alexa)的消息流示例,以及它通过我们的系统一直到视图呈现器的流程
Alexa 构造
Bot Builder SDK 很好地抽象了文本对话的概念,但将概念直接映射到 Alexa 并不简单。我想起了几个例子。
首先,当语音发声被发送到 Alexa 服务时,它可以包括初始语音串加上重新提示语音串。如果 Alexa 提出问题,就会向用户发出重新提示,而用户没有及时响应。Bot 生成器活动包含语音属性,但不包含重新提示属性。在我们的示例代码中,我们利用自定义通道数据字段来发送此信息。
第二个例子是 Alexa 渲染模板。虽然我们在这里没有涉及到它们,但是 Alexa 支持许多(最新统计有 7 个)模板来在支持显示的 Echo 设备上显示内容。每个模板都是一个不同的 JSON 结构,代表一个用户界面。尽管我们可以尝试使用 hero card 对象将这些模板传递给连接器,但更简单的方法是在渲染器中生成 JSON 并发送通道数据。指示 Echo 设备播放视频也面临类似的困境。
所有这些问题的解决方案是尝试尽可能多地使用 Bot Builder SDK 对象进行渲染,并仅在必要时使用通道数据。如图 14-24 所示,我们甚至可以利用 Bot Builder SDK 对象,并将它们转换为连接器层上特定于通道的结构。不过,总的来说,在 Alexa 渲染器中为每个响应生成 Alexa 通道数据更容易。
回拨支持
大多数通道可以发送与用户消息无关的事件。例如,脸书发送关于推荐、应用移交、结账和支付等事件。这些是需要在 bot 中处理的特定于通道的消息,有时在对话的结构之外。Alexa 对此类事件并不陌生。当视频或音频文件在 Echo 设备上播放时,关于进度、中断和错误的各种事件被发送到技能。由我们的机器人代码来正确解释这些事件。
进行这种交互的一个好方法是创建自定义识别器,它可以识别不同类型的消息,然后将这些消息定向到正确的对话框。对于需要 JSON 响应的事件,对话框应该使用通道数据发送有效载荷。
样本整合
让我们深入研究一下示例集成是什么样子的。我们将实现分成三个部分:连接器、识别器和机器人。完整的示例代码可以在本书的 GitHub repo 中的chapter14-alexa-skill-connector-bot
文件夹下找到。
连接器由一个 HTTP 处理程序组成,Alexa 将向该处理程序发送消息。处理程序的目标是解决对话,调用机器人,等待机器人的响应,并将消息发送回 Alexa。这里有一点代码,所以让我们一步一步来看。
消息进入处理程序。我们提取请求体和用户 ID。然后,我们创建用户 ID 的 MD5 散列。这样做的原因是 Alexa 用户 id 比 Bot 框架支持的要长。哈希帮助我们保持长度可控。
const cachedConversations = {};
exports.handler = function (req, res, next) {
const reqContents = req.body;
console.log('Incoming message', reqContents);
const userId = reqContents.session.user.userId;
const userIdHash = md5(userId);
...
};
接下来,我们要么检索该用户的缓存会话,要么创建一个新会话。请注意,我们将对话存储在内存中,因此每次服务器重启都会创建新的直线对话。在生产中,我们将使用一个持久存储,使用诸如 Cosmos DB 或 Azure Table Storage 之类的服务。Alexa 还包括一个标志,通知我们一个会话是否刚刚开始。如果我们没有缓存的会话或者会话是新的,我们创建一个新的直接线路会话并缓存它。
const cachedConv = cachedConversations[userId];
let p = Promise.resolve(cachedConv);
if (reqContents.session.new || !cachedConv) {
p = startConversation(process.env.DL_KEY).then(conv => {
cachedConversations[userId] = { id: conv.conversationId, watermark: null, lastAccessed: moment().format() };
console.log('created conversation [%s] for user [%s] hash [%s]', conv.conversationId, userId, userIdHash);
return cachedConversations[userId];
});
}
p.then(conv => {
...
});
检索到对话后,我们向机器人发布一个活动。请注意,由于我们决定传递已解析的 Alexa 交互模型意图和槽,我们只需通过 sourceEvent 属性中的通道数据传递 Alexa 消息。
postActivity(process.env.DL_KEY, conv.id, {
from: { id: userIdHash, name: userIdHash }, // required (from.name is optional)
type: 'message',
text: '',
sourceEvent: {
'directline': {
alexaMessage: reqContents
}
}
}).then(() => {
...
});
如果 Alexa 发送了 SessionEndedRequst,我们会自动回复一个 HTTP 200 状态代码。
if (reqContents.request.type === 'SessionEndedRequest') {
buildAndSendSessionEnd(req, res, next);
return;
}
function buildAndSendSessionEnd(req, res, next) {
let responseJson =
{
"version": "1.0"
};
res.send(200, responseJson);
next();
}
否则,我们使用直接线路轮询机制来尝试从 bot 获取活动响应。六秒钟后我们超时。一旦确定了响应活动,我们就从活动中提取一些特定于 Alexa 的信息,并构建对 Alexa 的响应。如果消息超时,我们将发回一个 HTTP 504 状态代码。
let timeoutAttempts = 0;
const intervalSleep = 500;
const timeoutInMs = 10000;
const maxTimeouts = timeoutInMs / intervalSleep;
const interval = setInterval(() => {
getActivities(process.env.DL_KEY, conv.id, conv.watermark).then(activitiesResponse => {
const temp = _.filter(activitiesResponse.activities, (m) => m.from.id !== userIdHash);
if (temp.length > 0) {
clearInterval(interval);
const responseActivity = temp[0];
console.log('Bot response:', responseActivity);
conv.watermark = activitiesResponse.watermark;
conv.lastAccessed = moment().format();
const keepSessionOpen = responseActivity.channelData && responseActivity.channelData.keepSessionOpen;
const reprompt = responseActivity.channelData && responseActivity.channelData.reprompt;
buildAndSendSpeech(responseActivity.speak, keepSessionOpen, reprompt, req, res, next);
} else {
// no-op
}
timeoutAttempts++;
if (timeoutAttempts >= maxTimeouts) {
clearInterval(interval);
buildTimeoutResponse(req, res, next);
}
});
}, intervalSleep);
就这样!构建响应消息的代码如下。
function buildTimeoutResponse(req, res, next) {
res.send(504);
next();
}
function buildAndSendSpeech(speak, keepSessionOpen, reprompt, req, res, next) {
let responseJson =
{
"version": "1.0",
"response": {
"outputSpeech": {
"type": "PlainText",
"text": speak
},
// TODO REPROMPT
"shouldEndSession": !keepSessionOpen
}
};
if (reprompt) {
responseJson.reprompt = {
outputSpeech: {
type: 'PlainText',
text: reprompt
}
};
}
console.log('Final response to Alexa:', responseJson);
res.send(200, responseJson);
next();
}
function buildAndSendSessionEnd(req, res, next) {
let responseJson =
{
"version": "1.0"
};
res.send(200, responseJson);
next();
}
直线功能与我们在第九章中展示的功能相同。
机器人方面的消息会发生什么?首先,它会击中我们的自定义识别器。识别器首先确保我们正在获取 Alexa 消息,并且它是 IntentRequest、LaunchRequest 或 SessionEndedRequest 请求。如果是 IntentRequest,我们将 Alexa intent 和 slots 解析为 LUIS 的 intent 和 entities。正如注释所指出的,slots 对象的格式不同于 LUIS entities 对象。如果我们要在一个机器人中混合两个 NLU 系统来使用相同的对话框,我们必须确保格式是标准化的。如果请求是 LaunchRequest 或 SessionEndedRequest,我们只需将这些字符串作为 bot 意图传递。
exports.recognizer = {
recognize: function (context, done) {
const msg = context.message;
// we only look at directline messages that include additional data
if (msg.address.channelId === 'directline' && msg.sourceEvent) {
const alexaMessage = msg.sourceEvent.directline.alexaMessage;
// skip if no alexaMessage
if (alexaMessage) {
if (alexaMessage.request.type === 'IntentRequest') {
// Pass IntentRequest into the dialogs.
// The odd thing is that the slots and entities structure is different. If we mix LUIS/Alexa
// it would make sense to normalize the format.
const alexaIntent = alexaMessage.request.intent;
const response = {
intent: alexaIntent.name,
entities: alexaIntent.slots,
score: 1.0
};
done(null, response);
return;
} else if (alexaMessage.request.type === 'LaunchRequest' || alexaMessage.request.type === 'SessionEndedRequest') {
// LaunchRequest and SessionEndedRequest are simply passed through as intents
const response = {
intent: alexaMessage.request.type,
score: 1.0
};
done(null, response);
return;
}
}
}
done(null, { score: 0 });
}
};
让我们回到机器人代码。我们首先注册我们的自定义 Alexa HTTP 处理程序、自定义识别器和默认响应。请注意我们使用的自定义直线数据。如果我们向技能询问它不支持的内容,会话将终止。
server.post('/api/alexa', (req, res, next) => {
alexaConnector.handler(req, res, next);
});
const bot = new builder.UniversalBot(connector, [
session => {
let response = 'Sorry, I am not sure how to help you on this one. Please try again.';
let msg = new builder.Message(session).text(response).speak(response).sourceEvent({
directline: {
keepSessionOpen: false
}
});
session.send(msg);
}
]);
bot.recognizer(alexaRecognizer);
接下来,我们创建 QuoteDialog 对话框。请注意以下几点:
-
它从实体中读取报价项目,就像我们的 Alexa 技能代码一样。
-
它通过 speak 属性发送响应,但也在自定义直接线路通道数据中包含重新提示。
-
在这个对话框的上下文中,如果机器人检测到亚马逊。MoreIntent,调用 MoreQuoteDialog 对话框。
-
在 MoreQuoteDialog 对话框执行后,它将控制权交还给 QuoteDialog。
bot.dialog('QuoteDialog', [
(session, args) => {
let quoteitem = args.intent.entities.QuoteItem.value;
session.privateConversationData.quoteitem = quoteitem;
let response = 'Looking up quote for ' + quoteitem;
let reprompt = 'What else can I help you with?';
let msg = new builder.Message(session).text(response).speak(response).sourceEvent({
directline: {
reprompt: reprompt,
keepSessionOpen: true
}
});
session.send(msg);
}
])
.triggerAction({ matches: 'QuoteIntent' })
.beginDialogAction('moreQuoteAction', 'MoreQuoteDialog', { matches: 'AMAZON.MoreIntent' });
bot.dialog('MoreQuoteDialog', session => {
let quoteitem = session.privateConversationData.quoteitem;
let response = 'Getting more quote information for ' + quoteitem;
let reprompt = 'What else can I help you with?';
let msg = new builder.Message(session).text(response).speak(response).sourceEvent({
directline: {
reprompt: reprompt,
keepSessionOpen: true
}
});
session.send(msg);
session.endDialog();
});
对于 GetAccountTypeInformationIntent 意图,重复了相同的模式。最后,我们添加了一些处理程序来支持诸如取消技能和处理 LaunchRequest 和 SessionEndedRequest 事件之类的事情。
bot.dialog('CloseSession', session => {
let response = 'Ok. Good bye.';
let msg = new builder.Message(session).text(response).speak(response).sourceEvent({
directline: {
keepSessionOpen: false
}
});
session.send(msg);
session.endDialog();
}).triggerAction({ matches: 'AMAZON.CancelIntent' });
bot.dialog('EndSession', session => {
session.endConversation();
}).triggerAction({ matches: 'SessionEndedRequest' });
bot.dialog('LaunchBot', session => {
let response = 'Welcome to finance skill! I can get your information about quotes or account types.';
let msg = new builder.Message(session).text(response).speak(response).sourceEvent({
directline: {
keepSessionOpen: true
}
});
session.send(msg);
session.endDialog();
}).triggerAction({ matches: 'LaunchRequest' });
这就完成了我们与 Alexa 的整合。如果我们运行代码,我们将看到与我们之前开发的 Lambda 技能类似的行为。在机器人代码和连接器代码中有许多未处理的意图和意外情况,但我们正在将 Alexa 技能工具包与微软的机器人框架集成。
练习 14-2
将数据和引用图像整合到 Bot Builder 代码中
在练习 14-1 中,我们将 Lambda 函数代码连接到数据,并生成一个图像来在支持屏幕显示的 Echo 设备上呈现报价。在本练习中,我们将把这两个组件都移植到我们的 Bot Builder 代码中。
-
利用前一节的代码作为起点。
-
从 Lambda 函数中提取适当的图像生成代码,并将其添加到您的 bot 中。确保安装了必要的 Node.js 包。
-
在对话框中生成显示模板,并将其添加到您的自定义频道数据中。您可以将 Node.js 的 Alexa Skills Kit SDK 作为依赖项来使用模板构建器类型。
-
确保连接器将通道数据模板正确地转换为最终响应,并返回给 Alexa。
-
运行你的集成 Alexa 技能和机器人框架机器人,并使用你在练习 14-2 中使用的相同方法进行测试。
-
如何修改 bot 代码,以便您可以通过 bot 框架模拟器利用您的 Bot?在您从本书中获得所有知识之后,您应该能够创建一个 LUIS 应用来完成这一体验。
让这个工作起来感觉真好!开发语音聊天机器人可能非常有趣,尤其是在 Alexa 这样丰富的生态系统上。
结论
这一章使我们能够结合这本书的知识来利用亚马逊的 Alexa 平台,另外,将它与 Bot Builder SDK 集成。现代的对话界面可以简化为 NLU 意图和实体加上一个对话引擎来驱动对话。无论是 Alexa 还是其他类似 Google Assistant 的通道,所有这些系统都有共同的核心理念。有些人会在语音和文本交流之间画出一个足够清晰的界限,以此来论证处理这两种交流的不同方式的必要性。虽然语音和文本通信确实截然不同,足以保证不同的前端体验,但处理一般对话想法的能力在 Bot Builder SDK 中得到了很好的发展。我们可以将不同的 NLU 系统连接起来,将它们自己的意图传递到我们的 Bot 框架中,这个想法非常强大。这意味着进入我们的机器人的消息可以不仅仅是文本。它可以是任何一种复杂的物体,只是受到我们想象力的限制。当然,运行一个连接到许多特定接口的通用系统总会有一定程度的开销,但是,正如我们希望在本章中所展示的那样,构建连接层所需的额外工作是我们力所能及的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?