#AWSSummit 用にAmazon Lexのデモアプリのバックグラウンドを作ってみた。

2017.06.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

入梅の候, 梅雨入りのニュースが気になるこのごろですが、毎日お元気でご活躍のことと存じます、せーのです。
先日まで私は東京は品川、新高輪プリンスホテルにて行われていたAWS Summit Tokyo2017に参加しておりました。

今回クラメソはなんとAPN Gold Sponsorとしてブースを開いて参戦しておりました。スポンサーですって。ゴールドですって!

18738577_1531442780239669_3301878609969369224_o

「AWS Summit Tokyo 2017」クラスメソッド展示ブースでお待ちしております|クラスメソッドブログ
せっかくブースを出すのでうちで一番新しい取り組みをお見せしよう、ということで音声認識のデモをつくってみました。今回はメインアーキテクチャにAmazon Lexを使用しました。
私はバックエンドのLexの部分を担当したので、そちらについて解説致します。フロントエンドのiOSアプリの部分はSINのブログをご覧ください。

[iOS][Amazon Lex] 音声でもタップでも操作できるBotアプリ(クライアント編)|クラスメソッドブログ

どんなアプリ?

「コーヒー屋さんの注文サンプル」のアプリです。音声でコーヒーの種類や砂糖の数などを注文します。

"Can I have a coffee?" - コーヒー下さい
"Hi! Welcome to Classmethod Coffee. What can I get you today?" - クラスメソッドコーヒーへようこそ。今日は何にしますか?
"Mocha" - モカを
"Got it. How many sugars do you need?" - かしこまりました。砂糖はいくつにしますか?
"Three" - 3つで
"Mocha, 3 sugars. Is that correct?" - モカ、砂糖3つで宜しいですか?
"Yes" - はい
"Your Mocha is Ordered. Thank you!" - モカをオーダーしました。ありがとうございました。

Deep Learningが働いていて似たような意味の言葉であれば意図を読み取って会話を進めてくれます。
"Can I have a coffee?(コーヒーください)"が"I would like to have a coffee.(コーヒーほしいです)"でも意味は同じなのでアプリは注文を受け入れます。
またメニューにない注文はチェックされ、砂糖を多く頼むと「砂糖のとりすぎは健康に良くないですよ」と注意したりします。

構成

構成図は以下のようになります。

構成図とデータの流れ

フロントエンドはiOSアプリにAmazon Lex SDKを入れてLexのAPIを叩きます。会話の流れや順番の設定はLexで行い、発話内容のチェックと実行処理はLambdaで書きます。注文内容はFirehoseを通じて、発話内容やエラー回数などはCloudWatchを通じてRedshiftに送って分析フェーズに渡します。

こだわったこと

自然な会話

今までの音声インターフェースとの一番の違いは、Deep Learningを使って発話内容の「意思(Intent)」を理解するところにあります。
Deep Learningは「教師データ」と呼ばれるサンプルを元に学習します。精度をあげるために効率的なのはサンプルデータをたくさん用意することです。
このアプリでは色々なパターンのサンプルを用意しました。が、本番環境ではこの3倍は用意した方が良いでしょう。

awssummitdemo1

他にも例えばWebでのチェックのような「砂糖は3以下で入力して下さい」という機械的な受け答えをせずに「砂糖のとりすぎは健康に良くないですよ」とする、質問の前に「ようこそ」「わかりました」をつける、など人間的なレスポンスを心がけました。

Card表現

awssummitdemo2

Alexaなどの音声アシスタントを使っていると「音声の限界」を感じるようになります。電話がテレビ電話になり、ビデオチャットになったように、人間は音声での案内の方がわかりやすい場合と、視覚による情報の方が良い場合があるので、適正に合わせて組み合わせる事が大事です。

ただLexのクライアントSDKがCardを受け取れるのはテキストによる入力に対するResponseのみで、音声による入力に対するResponseの場合は取れません。そこでこの選択肢に必要な情報をSession Attributesという、一通りの注文が終わるまで消えない場所に格納しました。そうすることによりテキストであろうと音声であろうと必要な視覚情報を受け取ることが出来ます。

{
  "dialogAction": {
    "type": "ElicitIntent",
    "message": {
      "contentType": "PlainText or SSML",
      "content": "Message to convey to the user. For example, What can I help you with?"
    },
   "responseCard": {  // <- このレスポンスが音声だと取れない
      "version": integer-value,
      "contentType": "application/vnd.amazonaws.card.generic",
      "genericAttachments": [
          {
             "title":"card-title",
             "subTitle":"card-sub-title",
             "imageUrl":"URL of the image to be shown",
             "attachmentLinkUrl":"URL of the attachment to be associated with the card",
             "buttons":[ 
                 {
                    "text":"button-text",
                    "value":"Value sent to server on button click"
                 }
              ]
           } 
       ] 
     }
  }
{
    "sessionAttributes": { // <-ここに入れる
    "key1": "value1",
    "key2": "value2"
    ...
  },
  "dialogAction": {
    "type": "ElicitIntent, ElicitSlot, ConfirmIntent, Delegate, or Close",
    Full structure based on the type field. See below for details.
  }
}

UX

これが音声インターフェースだ、という事を一度離れて、自分が例えばスターバックスでコーヒーを頼むことをイメージします。そうすると「全てきちんと言葉で説明していない」ということに気が付きます。
特に商品を注文する時、レジに立つ前から何を頼むか決まっている時は言葉で「ラテをトールで。砂糖多めでお願いします」とそのまま伝えます。ですがオーダーが決まってない時はどうでしょう。メニューを見ながら色々考えて、メニューを指で指しながら「このイングリッシュブレックファーストティーラテ、というのをください」みたいになるのではないでしょうか。読みにくかったり長かったりすると「これください」で済ませることも多いです。つまり「普通にレジ越しに注文するような自然さ」を目指していくと「音声でもメニューを指差しても、どちらでも注文できる」インターフェースであることが重要となります。音声だけではむしろ不自然になるのです。

しかしLexのインターフェースは音声とテキストが[PostText][PostContent]と明確に分かれています。これをシームレスに違和感なく切り替えられるような実装をクライアント側にお願いしました。SINさんは素晴らしいアイデアを出してこの課題を解決してくれました。詳しくはブログを御覧ください。

他に音声インターフェースは基本音声がクリックやタップの代わりを果たすので、何も話さない場合、言っている内容の意味が伝わらなかった場合のエラーハンドリング、注文最中でも全てをやめたい時はすぐに止められるようにする、注文が予め決まっている人にはまどろっこしい説明や質問をスキップする等、Webやアプリのシステム構築のようなハンドリングと音声コマンドならではの要件をミックスしつつ設計に加えていきました。

実装

Lex部分

Lexは基本設定のみで終わりです。Sample Utteranceを「ユーザーが言いそうな言葉」で複数用意し「コーヒーの種類(CoffeeType)」と「砂糖の数(Sugars)」をSlotの欄につくります。promptにはそのSlotを埋めるような答えを促す質問を書いておきます。
SlotにはAmazonが予め用意してある「Built-In Slot」と「カスタムSlot」があります。基本はBuilt-In Slotをなるべく使います。というのもSlotの種類にはDeep Learningが働いており、既にBuilt-In Slotにある種類をわざわざカスタムで作っても車輪の再構築でしかないからです。しかも相手はAmazon、膨大なデータからモデルを作りテストを重ねた結果のSlotです。同じものを作っても劣化版になってしまうのは目に見えているので、あるものは使いましょう。

といいながら、今回「コーヒーの種類」のSlotはカスタムSlotを作りました。

awssummitDemo3

最初は[AMAZON.Drink]というBuilt-In Slotを使っていました。これでほぼ全ての飲み物を網羅しているからです。しかしこれではいくつかの不具合がでてきました。

  • "Can I have a coffee?"の[coffee]を飲み物と捉えて注文に入れてしまう
  • [Mocha]を飲み物として認知してくれない

そこで選択肢である4つのコーヒーのメニューと少しコーヒーの種類を足してカスタムSlotを作りました。

Slotが埋まった時の確認のセリフと注文が終わった時の最後のセリフを設定します。今回は確認のセリフをLambdaに移してLambdaの処理の中で言うようにLambda Functionを設定しました。

awssummitDemo4

そしてSlotの値のValidationにLambdaを使うため、こちらにもLambda Functionを設定します。

awssummitDemo5

これでLexの設定は完了です。

Lambda部分

次に確認やSlotの値のチェックに設定したLambda Functionの中を設定します。今回はBlueprintにてLexのBlueprintである「Lex-Order-Flowers」をセットし、中身を改変しました。

awssummitDemo6

Lambdaでは[sessionAttributes]と[dialogAction]という値をJSON形式で返します。sessionAttributesにはアプリに表示するカードの情報を、dialogActionには次にLexが起こすActionを指定します。sessionAttributesは任意で何でも入るのでクライアント側が使いやすい形でJSONを作ります。dialogActionはTypeによって返す内容が微妙に違うので少し詳しく解説していきます。

dialogAction

dialogActionで返すTypeとその内容は以下になります。

Type 内容
ElicitIntent Intentを話すように促す
ElicitSlot Slotを話すように促す
ConfirmIntent Yes/Noを促す
Delegate 次のActionをLexにまかせる
Close 会話を終了させる

ここで注意しなければいけないことは「Lambdaで流れを全て管理するのか、流れはLexに任せるのかを決める」ということです。
例えばLambdaからCloseを返せば会話が途中だろうとなんだろうと処理は終わってしまいます。どのような発話がきたらどのように返して次に何を聞くのかをLambdaで決めるのであれば様々なパターンを考え、事前に設計しないと破綻します。ただ綺麗にできるとUXは格段に上がります。例えばピザの種類を決めたら今までの注文状況から機械学習させた結果を元にサイドメニューを割り込みで聞いたりできます。上級者向け、ですね。
通常はDelegateで返してLexに決めてもらうのがいいでしょう。Intentを複数作る場合はConfirmIntentがOKの場合にだけElicitIntentで別のIntentを指定して返してあげると綺麗につながります。

DialogActionはTypeによって返す属性が違うのでJSONをfunctionで固めて値を埋める形が一番簡単です。

function elicitSlot(cards, intentName, slots, slotToElicit, message) {
    return {
        "sessionAttributes": cards,
        dialogAction: {
            type: 'ElicitSlot',
            intentName,
            slots,
            slotToElicit,
            message: { contentType: 'PlainText', content: message },
        },
    };
}


function delegate(cards, slots) {
    return {
        "sessionAttributes": makeSessionAttributes(cards, slots),
        dialogAction: {
            type: 'Delegate',
            slots,
        },
    };
}

Node.js 6.1はクラスが作れるのでクライアントに返すカードの情報をクラスで作ってみました。

class Cards {
    constructor(){
        this._cards = [];
    }
    
    Add(card){
        this._cards.push(card);
    }

    Show(){
        return JSON.stringify(this._cards);
    }
}

class Card {
    constructor() { }
}

class CoffeeCard extends Card {
    constructor(coffeeType) {
        super();
        this.title = CoffeeTypes[coffeeType];
        this.slotName = "CoffeeType";
        this.slotValue = CoffeeTypes[coffeeType];
        this.subtitle = Subtitles[coffeeType];
        this.body = Prices[coffeeType];
        this.imageUrl = ImageUrls[coffeeType];
  }
}

class SugarCard extends Card {
    constructor(num){
        super();
        this.title = numToAlphabets[num];
        this.slotName = "Sugars";
        this.slotValue = String(num);
    }
}

class ConfirmCard extends Card {
    constructor(answer){
        super();
        this.title = answer;
        this.slotName = "Confirm";
        this.slotValue = YesorNo[answer];
    }
}

class CurrentOrder {
    constructor(slots){
        this.type = slots.CoffeeType;
        this.sugar = slots.Sugars;
        this.cream = slots.Cream;
    }

    Show(){
        return JSON.stringify(this);
    }
}

チェック自体は通常のWebとほぼ変わりません。エラーになるパターンは2つあります。「Slotに入ってる値がチェックに引っかかる」というパターンと「Slotに値が入らない」というパターンです。

上で説明したようにSlotにはSlot Typeという、プログラムでいう「型」のようなものを使って格納しています。NUMBERであれば数字以外は入りませんし、場所であれば場所以外のワードははいりません。
その結果Slotに入らないフレーズが出てきます。うまく拾ってあげることが大事です。

 switch (this.Compare()){
     case COFFEETYPE:
         if (this.slots_type === null){
             this.message = "Sorry?";
             this._isValid = false;
             break;
         }
         if (this.slots_type.toLowerCase() in arrCoffeeTypes === false) {

             this.message = "We do not have " + this.slots_type + ", would you like a different type of coffee?  Our most popular coffee in Mocha.";
             this._slot.CoffeeType = null;
             this._isValid = false;
             break;
         }
         break;
     case SUGARS:
         console.log("validate to sugars");
         if (this.slots_sugar === null){
             this.message = "Sorry?";
             this._isValid = false;
             break;
         }

         if (this.slots_sugar > 3) {
             this.message = "Too much sugar is not good for your health. How many sugars do you need?";
             this._slot.Sugars = null;
             this._isValid = false;
             break;
         }
         break;
     case "" :

         break;
 }

まとめ

いかがでしたでしょうか。今回はサンプルということでBlueprintを改変する形で作ってみました。意外と簡単だと思いますので是非みなさんもお試しください。

参考リンク