十一、适配卡和自定义图形
在整本书中,我们讨论了机器人与用户交流的不同方式。机器人可以使用文本、语音、图像、按钮或传送带。这些与正确的语气和数据相结合,成为用户快速有效地完成目标的强大界面。我们可以很容易地用正确的数据构建文本,但文本可能并不总是传达某些想法的最有效的机制。让我们以股票报价为例。比如说,当用户向 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 的自定义图像渲染允许对生成的图形进行更多的自定义和控制,在没有本地适配卡支持的情况下尤其有价值。两者都是非常吸引人的聊天机器人体验的绝佳选择。
适配卡样本: http://adaptivecards.io/samples/
2
适配卡可视化器: http://adaptivecards.io/visualizer/index.html
3
幻像 Js: http://phantomjs.org/
4
火狐无头模式: https://developer.mozilla.org/en-US/Firefox/Headless_mode
5
无头 Chrome 入门: https://developers.google.com/web/updates/2017/04/headless-chrome
6
木偶师,无头 Chrome Node.js API: https://github.com/GoogleChrome/puppeteer
7
Mozilla 开发者网络等待文档: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await
十二、人工交接
聊天机器人几乎从不孤立生存。公司和品牌已经投入了大量的时间、精力和金钱,通过 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 框架中,这个想法非常强大。这意味着进入我们的机器人的消息可以不仅仅是文本。它可以是任何一种复杂的物体,只是受到我们想象力的限制。当然,运行一个连接到许多特定接口的通用系统总会有一定程度的开销,但是,正如我们希望在本章中所展示的那样,构建连接层所需的额外工作是我们力所能及的。
了解不同类型的 Alexa 技能: https://developer.amazon.com/docs/ask-overviews/understanding-the-different-types-of-skills.html
2
无服务器计算的真正含义: https://www.infoworld.com/article/3093508/cloud-computing/what-serverless-computing-really-means.html
3
Node.js 的 Alexa 技能包:Response vs . Response builder:https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs#response-vs-responsebuilder
4
AWS CLI: https://aws.amazon.com/cli/
5
Alexa 技能工具包(ASK) CLI: https://developer.amazon.com/docs/smapi/quick-start-alexa-skills-kit-command-line-interface.html
6
EchoSim 是基于浏览器的 Alexa 界面。它有助于测试开发技能。随着 Alexa 测试工具在最近几个月有了实质性的改进,EchoSim 工具的有效性还有待观察; https://echosim.io
见。
7
Alexa 会话是一个有趣的话题,值得更多的检查。更多信息可在线查询 https://developer.amazon.com/alexa-skills-kit/big-nerd-ranch/alexa-voice-user-interfaces-and-sessions
。
8
账户关联文档: https://developer.amazon.com/docs/custom-skills/link-an-alexa-user-with-a-user-in-your-system.html
。使用设备地址 API: https://developer.amazon.com/docs/custom-skills/device-address-api.html
。使用 Alexa 的待办事项和购物清单: https://developer.amazon.com/docs/custom-skills/access-the-alexa-shopping-and-to-do-lists.html
。
9
围绕字面槽类型及其使用有很多争论。一段时间以来,亚马逊一直试图反对这种插槽类型。很容易理解为什么。自然语言模型和 Alexa 通过使用模型来启动自动语音识别引擎的能力只与模型的内容一样好。如果一些 NLU 被卸载到一个单独的系统,NLU 和语音识别就会受到影响。也就是说,即使亚马逊支持替代方案,插槽类型仍未被移除。 https://developer.amazon.com/post/Tx3IHSFQSUF3RQP/Why-a-Custom-Slot-is-the-Literal-Solution
见。
10
模型视图控制器: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller