kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

〜スマートスピーカーやVoiceflowの記事は右メニューのカテゴリからどうぞ。〜

日本語に対応したAmazon Lex(V1)を試してみる④ Lambdaを使う

f:id:kun432:20210427220639p:plain

Amazon Lexの第4回です。今回は受け取った発話をバックエンドのLambdaに渡してみたいと思います。

目次

LexでHello World

引き続き"BookTrip"のサンプルを使って、LexからLambdaにユーザからの入力を渡すようにしてみましょう。

まずはかんたんなHello Worldをやってみます。以下のようなLambda関数を作成します。

'use strict';

exports.handler = async (event, context, callback) => {
    console.log(JSON.stringify(event));
    const response = {
        sessionAttributes: event.sessionAttributes,
        dialogAction: {
            type: 'Close',
            fulfillmentState: 'Fulfilled',
            message: {
                contentType: 'PlainText',
                content: 'ハローワールド'
            }
        }
    };
    callback(null, response);
};

responseがLexに返すレスポンスの中身です。この中で、dialogActionというのが必須になっています。見た感じ、なんとなくわかりますよね。

  • type: 'Close' セッションが終了することを伝えて、ユーザからの入力を受けないようにします。AlexaでいうところのSessionEndedRequest: trueみたいなものですかね。
  • fulfillmentState: 'Fulfilled'で収集中のスロットが全て満たされたことを伝えます。スロットを使っていないとしてもこのステータスは必須のようです。
  • messageがLexの応答になるようです。なければLexのインテント側に設定されたレスポンスが使われるようです。

セッションアトリビュートなども使えるようですが、詳細はドキュメントを御覧ください。

関数名は"LexTest"にしました。

f:id:kun432:20210503200848p:plain

Lex側でLamdaのレスポンスを使うように設定します。「Fulfillment」で「AWS Lambda function」を選択して、Lambdaの関数名とバージョンを設定します。

f:id:kun432:20210503205121p:plain

下にスクロールして「Save Intent」をクリックして保存、「Build」をクリックして対話モデルをビルドします。

f:id:kun432:20210503205356p:plain

f:id:kun432:20210503205406p:plain

ではテストしてみましょう。

f:id:kun432:20210503205614p:plain

最後に「ハローワールド」が返ってきているのがわかりますね。

ちなみに、LexからLambdaに渡されたイベントの中身はこんな感じでした。

  • イベント
{
    "messageVersion": "1.0",
    "invocationSource": "FulfillmentCodeHook",
    "userId": "XXXXXXXXXXXXXXXXXXXXXXXXXX",
    "sessionAttributes": {},
    "requestAttributes": null,
    "bot": {
        "name": "BookTrip_jaJP",
        "alias": "$LATEST",
        "version": "$LATEST"
    },
    "outputDialogMode": "Text",
    "currentIntent": {
        "name": "BookCar_jaJP",
        "slots": {
            "PickUpDate": "2022-05-01",
            "ReturnDate": "2022-05-02",
            "DriverAge": "40",
            "CarType": "エコノミー",
            "PickUpCity": "神戸"
        },
        "slotDetails": {
            "PickUpDate": {
                "resolutions": [
                    {
                        "value": "2022-05-01"
                    }
                ],
                "originalValue": "5/1"
            },
            "ReturnDate": {
                "resolutions": [
                    {
                        "value": "2022-05-02"
                    }
                ],
                "originalValue": "5/2"
            },
            "DriverAge": {
                "resolutions": [
                    {
                        "value": "40"
                    }
                ],
                "originalValue": "40"
            },
            "CarType": {
                "resolutions": [
                    {
                        "value": "エコノミー"
                    }
                ],
                "originalValue": "エコノミー"
            },
            "PickUpCity": {
                "resolutions": [
                    {
                        "value": "神戸"
                    }
                ],
                "originalValue": "神戸"
            }
        },
        "confirmationStatus": "Confirmed",
        "nluIntentConfidenceScore": 1
    },
    "alternativeIntents": [
        {
            "name": "AMAZON.FallbackIntent",
            "slots": {},
            "slotDetails": {},
            "confirmationStatus": "None",
            "nluIntentConfidenceScore": null
        },
        {
            "name": "BookHotel_jaJP",
            "slots": {
                "RoomType": null,
                "CheckInDate": null,
                "Nights": null,
                "Location": null
            },
            "slotDetails": {
                "RoomType": null,
                "CheckInDate": null,
                "Nights": null,
                "Location": null
            },
            "confirmationStatus": "None",
            "nluIntentConfidenceScore": 0.15
        }
    ],
    "inputTranscript": "はい",
    "recentIntentSummaryView": [
        {
            "intentName": "BookCar_jaJP",
            "checkpointLabel": null,
            "slots": {
                "PickUpDate": "2022-05-01",
                "ReturnDate": "2022-05-02",
                "DriverAge": "40",
                "CarType": "エコノミー",
                "PickUpCity": "神戸"
            },
            "confirmationStatus": "None",
            "dialogActionType": "ConfirmIntent",
            "fulfillmentState": null,
            "slotToElicit": null
        }
    ],
    "sentimentResponse": {
        "sentimentLabel": "NEUTRAL",
        "sentimentScore": "{Positive: 0.052890804,Negative: 0.0028756545,Neutral: 0.9416867,Mixed: 0.002546887}"
    },
    "kendraResponse": null
}

スロットはevent.currentIntent.slots.スロット名で取れますね。nluIntentConfidenceScoreでインテントの信頼度や、sentimentResponseで感情分析が見えるのも面白いです。

スロットを使う

ということでスロットの値を使って、BookCarインテントを実装してみましょう。上に書いたとおり、スロットはevent.currentIntent.slots.スロット名で取れますので、こういう感じ。

'use strict';

exports.handler = async (event, context, callback) => {
    const slots = event.currentIntent.slots;
    
    const PickUpCity = slots.PickUpCity;
    const PickUpDate = slots.PickUpDate;
    const ReturnDate = slots.ReturnDate;
    const CarType    = slots.CarType;
    const DriverAge  = slots.DriverAge;
    
    let message;
    if (DriverAge >= 20 ) {
        message = `${PickUpCity} で ${CarType} を、${PickUpDate} から ${ReturnDate} までご予約いたしました。`;
    } else {
        message = `申し訳ございません。レンタカーのご利用は20歳以上とさせていただいております。`;
    }
    
    const response = {
        sessionAttributes: event.sessionAttributes,
        dialogAction: {
            type: 'Close',
            fulfillmentState: 'Fulfilled',
            message: {
                contentType: 'PlainText',
                content: message
            }
        }
    };
    callback(null, response);
};

すこし動的な要素を含めるために、年齢でレスポンスの内容を変えてみました。テストしてみるとこんな感じです。

f:id:kun432:20210503223451p:plain

最後まで聞いてから20歳未満はNGというのはインタフェース的にはイマイチなので、年齢を確認するインテントを別に用意して先に確認するような会話フローにすべきだと思いますが、まあ一応動いてますね。

予約完了した場合はこちらです。

f:id:kun432:20210503223502p:plain

ちゃんと動いてますね。

インテントのルーティング

上の例では1インテントに1つのLambda関数になっていました。1つのLambda関数の中でインテントごとに呼び出す処理をルーティングしてまとめて書きたいですよね。公式のサンプルによいものがありました。

上記を参考に書いてみたのがこれ。

'use strict';


function close(sessionAttributes, fulfillmentState, message) {
    return {
        sessionAttributes,
        dialogAction: {
            type: 'Close',
            fulfillmentState,
            message,
        },
    };
}

function BookCarIntent(intentRequest, callback) {
    const sessionAttributes = intentRequest.sessionAttributes || {};
    const slots = intentRequest.currentIntent.slots;
    const PickUpCity = slots.PickUpCity;
    const PickUpDate = slots.PickUpDate;
    const ReturnDate = slots.ReturnDate;
    const CarType    = slots.CarType;
    const DriverAge  = slots.DriverAge;
    
    let message;
    if (DriverAge >= 20 ) {
        message = `${PickUpCity} で ${CarType} を、${PickUpDate} から ${ReturnDate} までご予約いたしました。`;
    } else {
        message = `申し訳ございません。レンタカーのご利用は20歳以上とさせていただいております。`;
    }

    callback(close( sessionAttributes, 'Fulfilled', {
        contentType: 'PlainText',
        content: message
    }));
}

function BookHotelIntent(intentRequest, callback) {
    const sessionAttributes = intentRequest.sessionAttributes || {};
    const slots = intentRequest.currentIntent.slots;
    const Location    = slots.Location;
    const Nights      = slots.Nights;
    const CheckInDate = slots.CheckInDate;
    const RoomType    = slots.RoomType;

    
    let message = `${Location} で ${CheckInDate} から ${Nights} 泊、${RoomType} タイプのお部屋を予約しました。`;

    callback(close( sessionAttributes, 'Fulfilled', {
        contentType: 'PlainText',
        content: message
    }));
}

function dispatch(intentRequest, callback) {
    console.log(JSON.stringify(intentRequest, null, 2));
    console.log(`dispatch userId=${intentRequest.userId}, intentName=${intentRequest.currentIntent.name}`);
     
    const intentName = intentRequest.currentIntent.name;
 
    if (intentName === "BookCar_jaJP") {
        return BookCarIntent(intentRequest, callback);
    } else if (intentName === "BookHotel_jaJP") {
        return BookHotelIntent(intentRequest, callback);
    } else {
      throw new Error(`Intent with name ${intentName} not supported`);
    }
}

exports.handler = (event, context, callback) => {
    try {
        dispatch(event, (response) => callback(null, response));
    } catch (err) {
        callback(err);
    }
};

1インテント=1関数として作成して、function dispatchでインテントごとに別の関数を呼び出す形ですね。ASK-SDK v2とかに慣れているとこちらのほうがなんとなくしっくりきますが、LexのほうがレスポンスをJSONでガッツリ書かないといけない印象があります。このあたりは上のfunction closeのようにレスポンスをテンプレートとしてラップしてあげればいいかなと思います。

テストしてみましょう。LexではインテントごとにFulfilmentの設定が必要なのでそれぞれ設定変更するのをお忘れなく。

f:id:kun432:20210503231653p:plain

ちゃんと動いてますね!

まとめ

Lambdaで動的にレスポンス生成できるようになるといろいろ夢が広がりますね。ASK-SDK v2に慣れていれば、比較的違和感なく書けるのではないかと思います。

いや、もっとASK-SDKっぽく書きたい!という人は以下も試してみてはいかがでしょうか?