1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Amazon Connect と Amazon Lex で電話スケジュール予約を学習のためにつくってみた

Last updated at Posted at 2023-04-02

はじめに

Amazon Connect を使って、音声のスケジュール予約システムを作って動かしてみました。個人の学習のために作ったので、インターネット上に公開はしていません。作りにあたって、いろいろノウハウを得たので、それを紹介します。

概要図

まず簡単に概要図を出します。

image-20230402122308719.png

一番左の登場人物「ユーザー」を起点に、右側に利用している AWS サービスを記載しています。電話を受け付ける Amazon Connect, お客様とのコミュニケーションを支援する Amazon Lex, スケジュールを確認・予約するための Lambda 関数, データストアとして DynamoDB があります。

ユーザーは Amazon Connect で管理している電話番号にかけることで、自動音声が流れます。自動音声の中でスケジュールの希望日を伝えることや、ボタンを押すことで予約が可能です。

それぞれのサービスの設定方法を紹介しましょう。

Amazon Connect

Amazon Connect の管理画面で、新しい contact flow を作成します。

image-20230326165726913.png

Set voice を配置し、日本語の読み上げを有効化します。

  • Voice : Kazuha
  • Set language attribute : ON

image-20230402120236101.png

次に、Customer Input を配置し、ここから Amazon Lex を呼びだします。

image-20230402120428136.png

呼び出す Lex Bot を指定します。以下の画像にある try1 と書かれているのが、LexBot の名前です。自分で作成した Bot を指定してください。

image-20230402120557537.png

Amazon Connect の Flow はこんな感じです。

image-20230402120703136.png

Amazon Lex

Lex Bot の作成

Amazon Lex のページを開き、Create Bot を押します。

image-20230326134742975.png

Bot の名前を適当に指定します。

image-20230402120851594.png

適当に IAM Role を選択して、Next を押します。

image-20230326134942466.png

Lex 上で利用される言語や、Voice interaction を選びます。Amazon Connect と一致させるために、Kazuha を選びます。

image-20230326135058640.png

Lex Bot が作成されました。

image-20230326135125398.png

Lex Intent の作成

作成した Bot 上で、新たな Intent を作成します。

image-20230402121227158.png

いきなり全体像を出しますが、このように Intents の Flow を作成します。

  • utterances は「1」とする。Amazon Connect 側で「スケジュール予約を希望ですか?希望の場合は 1 を押してください」とガイドが流れる。その後ユーザーが 1 を押すことで、この Intents に紐づけがされる。
  • Slot は 2 つ
  • それぞれの Slot にユーザー側の入力を受け付けた時、Lambda 関数を動かす

image-20230402121357238.png

Lex Bot と Lambda 関数を紐づけるために、Alias のページを選択します。

image-20230402122749505.png

Japanese を選びます

image-20230402122813312.png

ここで紐づける Lambda 関数を指定します。

image-20230402122843254.png

Intent 側の設定で、次のチェックも必要です。

image-20230326190151011.png

Lambda 関数

スケジュール予約を司る重要な Lambda 関数です。Lex で Lambda 関数を利用するうえで、理解する概念があります。Amazon Lex の Bot に Lambda 関数を紐づけられるのですが、基本的には 1 対 1 で紐づきがされます。Bot 側で複数の Intent, 複数の Slot が有る場合は、どの Intent, Slot で呼び出されたのかを Lambda コード内で意識する必要があります。「この Intent の、この Slot だから、この処理をしよう」といったように制御が必要です。

image-20230402123846202.png

この記事では、Intent が 1 個なので Intent については固定にしています。Slot は複数の Slot があるため、Lambda 関数内の sessionAttributes.nextSlot で管理をしています。Lex から呼び出された Lambda 関数内で sessionAttributes を指定することで、次に呼び出される Lambda に指定した文字列を受け渡すことが出来ます。sessionAttributes.nextSlotに次処理するべき Slot 名を指定することで、呼び出された Lambda 関数側で適切な処理が出来るようにしています。

 

sessionAttributes.nextSlot に関係する部分を一部抜粋します。Lambda 関数が受け取った event の中に、sessionAttributes.nextSlot が格納されています。空白の場合は初回呼び出しだと判断して、「Date」スロットを処理します。sessionAttributes.nextSlotが「Schedule」のときは、「Schedule」スロットの処理に移ります。

def router(event):
    # sessionAttributes の nextSlot を取得。
    # sessionAttributes の nextSlot は、この Lambda 関数が処理するべき Slot が示されている。
    # Lambda 関数内で sesstionAttributes を設定すると、次に呼び出される Lambda 関数に引き継がれる。
    # 空白の場合は、この Intent が初めて呼びだされた実行なり、「Date」Slot を処理するべきものと識別する。(想定外なものがあるかもしれない)
    if "nextSlot" in event['sessionState']['sessionAttributes']:
        nextSlot = event['sessionState']['sessionAttributes']['nextSlot']
    else:
        nextSlot = date_slotname

    # 処理すべき Slot が 「Date」のとき
    if nextSlot == date_slotname:
        # 省略
        # sessionAttributes の nextSlot に次処理すべき Slot の名前を入れる。
        sessionAttributes = {
            "nextSlot": schedule_slotname
        }
        # 省略
    # 処理すべき Slot が 「Schedule」のとき
    if nextSlot == schedule_slotname:
        # 省略

def lambda_handler(event, context):
    print("============ print event ============")
    logger.info(json.dumps(event))

    response = router(event)
    return response

お客様の予約希望日を電話で教えてもらったあとに、具体的な予約可能な候補日時をレスポンスするためのコードが以下です。お客様の音声から認識された日付文字列が event['sessionState']['intent']['slots']['Date']['value']['interpretedValue'] にはいっています。これを取得して DynamoDB 上で検索をします。

検索して取得してきた日付のうち、free 列が true のものを実際の候補としてお客様に読み上げます。

    # 処理すべき Slot が 「Date」のとき
    if nextSlot == date_slotname:
        slots = event["sessionState"]["intent"]["slots"]
        name = event["sessionState"]["intent"]["name"]

        # お客様の希望のスケジュールを取得
        nextSlot = event['sessionState']['intent']['slots']['Date']['value']['interpretedValue']

        # DynamoDB から候補スケジュールを取得
        response = table.query(
            KeyConditionExpression=Key('busyoid').eq('busyo1')
            & Key('date-start').between(nextSlot + ' 00:00:00', nextSlot + ' 23:59:59')
        )

        items = response['Items']
        free_schedule_dict = {}

        # 候補スケジュールの読み上げテキストを生成
        if len(items) == 0:
            messagesContent = "候補の日は空きのスケジュールがありませんでした"
        else:
            messagesContent = "空きスケジュールを音声で読み上げます。希望の番号を押してください。" + SSML_break
            guidenumber = 1
            for item in items:
                if item['free']:
                    free_schedule_dict[guidenumber] = item['date-start']
                    messagesContent += str(guidenumber) + \
                        SSML_break + item['date-start'] + SSML_break
                    guidenumber = guidenumber + 1

Lambda が return するときに、重要なのが以下のポイントです。Lambda が return する値によって、Lex 側の動作指定ができます。dialogAction.typeElicitSlot と指定し、"slotToElicit": "Schedule" を return しています。こうすることで、この Lambda 関数の処理実行後に Lex Bot 側の動作を指定できます。「Schedule というスロット名を、つぎに処理せよ」という命令になっています。これによって、例えば正常時にはスロット A に移動して、異常時にはもう一度繰り返す、みたいな制御が可能です。

詳細が気になる方は、こちらの Document を参照してください。

        session_state = {
            "dialogAction": {
                "type": "ElicitSlot",
                "slotToElicit": "Schedule"                
            },
            "intent": {
                "confirmationState": "Confirmed",
                "slots": slots,
                "name": name
            },
            "sessionAttributes": sessionAttributes
        }
        # 省略
        # return response
        response = {
            "sessionState": session_state,
            "messages": messages,
            "requestAttributes": request_attributes,
            "sessionId": sessionid
        }

        print("============ print response ============")
        logger.info(json.dumps(response))

        return response

Lambda 関数が「Schedule」スロットの時に呼ばれるときの処理です。お客様の希望スケジュールを event['sessionState']['intent']['slots']['Schedule']['value']['interpretedValue'] から受け取り、実際に DynamoDB へ予約を行います。

    # 処理すべき Slot が 「Schedule」のとき
    if nextSlot == schedule_slotname:
        slots = event["sessionState"]["intent"]["slots"]
        name = event["sessionState"]["intent"]["name"]

        # お客様の希望のスケジュールを取得
        guidenumber = event['sessionState']['intent']['slots']['Schedule']['value']['interpretedValue']
        startdate = event['sessionState']['sessionAttributes'][guidenumber]

        # DynamoDB のデータを更新
        key = {
            'busyoid': 'busyo1',
            'date-start': startdate
        }

        # 更新する項目の設定を指定
        update_expression = 'SET #f = :val1'
        expression_attribute_names = {'#f': 'free'}
        expression_attribute_values = {':val1': False}

        response = table.update_item(
            Key=key,
            UpdateExpression=update_expression,
            ExpressionAttributeNames=expression_attribute_names,
            ExpressionAttributeValues=expression_attribute_values
        )

DynamoDB

DynamoDB のテーブル構成を以下のように組みます。簡易的な動作試験なので、6/7 と 6/8 のデータしか入れません。free が true の行がスケジュールが空いていて、false の行がスケジュールが埋まっていることを示しています。

この記事では、時間枠は固定になっています。

image-20230402130443342.png

DynamoDB のテーブルを作成します。

image-20230330125603255.png

Table 構成を指定します。

image-20230330214814626.png

Create Table を押します。

image-20230330214824536.png

実際のデータは、Python コードから書き込みます。以下のコードを実行すれば、DynamoDB テーブル上に item が作成されます。

import sys
import os
import boto3
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('connect-lex-schedule-booking')


def main():
    truncate_dynamo_items(table)
    create_dynamo_items(table)


def create_dynamo_items(dynamodb_table):
    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-07 10:00:00',
            'date-end': '2023-06-07 12:00:00',
            'personid': 'suzuki',
            'free': False
        }
    )

    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-07 13:00:00',
            'date-end': '2023-06-07 15:00:00',
            'personid': 'suzuki',
            'free': True
        }
    )

    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-07 15:00:00',
            'date-end': '2023-06-07 17:00:00',
            'personid': 'suzuki',
            'free': True
        }
    )

    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-08 10:00:00',
            'date-end': '2023-06-08 12:00:00',
            'personid': 'suzuki',
            'free': True
        }
    )

    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-08 13:00:00',
            'date-end': '2023-06-08 15:00:00',
            'personid': 'suzuki',
            'free': True
        }
    )

    response = table.put_item(
    Item={
            'busyoid': 'busyo1',
            'date-start': '2023-06-08 15:00:00',
            'date-end': '2023-06-08 17:00:00',
            'personid': 'suzuki',
            'free': True
        }
    )

def truncate_dynamo_items(dynamodb_table):
    
    # データ全件取得
    delete_items = []
    parameters   = {}
    while True:
        response = dynamodb_table.scan(**parameters)
        delete_items.extend(response["Items"])
        if ( "LastEvaluatedKey" in response ):
            parameters["ExclusiveStartKey"] = response["LastEvaluatedKey"]
        else:
            break

    # キー抽出
    key_names = [ x["AttributeName"] for x in dynamodb_table.key_schema ]
    delete_keys = [ { k:v for k,v in x.items() if k in key_names } for x in delete_items ]

    # データ削除
    with dynamodb_table.batch_writer() as batch:
        for key in delete_keys:
            batch.delete_item(Key = key)

    return 0


if __name__ == '__main__':
    ret = main()
    sys.exit(ret)

データが格納されました。

image-20230330220406237.png

動作確認

本来であれば Amazon Connect の電話している様子をお見せできればいいのですが、録画環境が無かったので Lex 上の文字でやりとりを行います。

DynamoDB 上の次のテーブルを予約していきます。

image-20230402131034436.png

Lex のテストを開き、予約の希望を伝えます。すると、候補日を 3 つ教えてくれるので、「1」 を入力します。

image-20230402133052202.png

「1」を入力すると予約が完了しました。

image-20230402133108872.png

DynamoDB 上でも free が false となっていることがわかりました。

image-20230402133134352.png

検証を通じてわかったこと

  • Amazon Connect から Lex を呼びだすときに、utterances をお客様に入力してもらう必要がある。「スケジュール予約の場合は 1 を押してください」といったガイドを出すことで、スムーズな utterances を引き出すことが可能
  • Amazon Connect から Lambda 関数を呼び出すときのタイムアウトは最大 8 秒。Lambda 関数側で処理が遅くならないよう注意が必要。データベースの読み書きなど。
  • 1 個の Lex Alias に、1 個の Lambda 関数が紐づく。Lambda 関数の実装面で「いまはどの Intent なのか」「いまはどの Slot なのか」を理解しながら処理のルーティングを制御しないといけない。Intent や Slot ごとに Lambda 関数を紐づけられるわけではないのが、ちょっと注意。
  • Lexv2 から呼び出される Lambda 関数で生成したメッセージを Lexv2 側に表示したいときは、dialogAction Type が Delegate だと動作しなかった。ElicitSlot だと動作した。

付録1 : Lambda のソースコード

import json
from logging import getLogger, INFO
import boto3
from boto3.dynamodb.conditions import Key

logger = getLogger(__name__)
logger.setLevel(INFO)

booking_schedule_intentname = "Booking-Schedule-Intent"
date_slotname = "Date"
schedule_slotname = "Schedule"
SSML_break = "<break time=\"500ms\"/>"

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('connect-lex-schedule-booking')

def router(event):
    # Lambda が起動された Intent 名を取得
    intent_name = event['sessionState']['intent']['name']

    # 想定外の Intent の場合は、なにもせず終了
    if intent_name != booking_schedule_intentname:
        return

    # sessionAttributes の nextSlot を取得。
    # sessionAttributes の nextSlot は、この Lambda 関数が処理するべき Slot が示されている。
    # Lambda 関数内で sesstionAttributes を設定すると、次に呼び出される Lambda 関数に引き継がれる。
    # 空白の場合は、この Intent が初めて呼びだされた実行なり、「Date」Slot を処理するべきものと識別する。(想定外なものがあるかもしれない)
    if "nextSlot" in event['sessionState']['sessionAttributes']:
        nextSlot = event['sessionState']['sessionAttributes']['nextSlot']
    else:
        nextSlot = date_slotname

    # 処理すべき Slot が 「Date」のとき
    if nextSlot == date_slotname:
        slots = event["sessionState"]["intent"]["slots"]
        name = event["sessionState"]["intent"]["name"]

        # お客様の希望のスケジュールを取得
        nextSlot = event['sessionState']['intent']['slots']['Date']['value']['interpretedValue']

        # DynamoDB から候補スケジュールを取得
        response = table.query(
            KeyConditionExpression=Key('busyoid').eq('busyo1')
            & Key('date-start').between(nextSlot + ' 00:00:00', nextSlot + ' 23:59:59')
        )

        items = response['Items']
        free_schedule_dict = {}

        # 候補スケジュールの読み上げテキストを生成
        if len(items) == 0:
            messagesContent = "候補の日は空きのスケジュールがありませんでした"
        else:
            messagesContent = "空きスケジュールを音声で読み上げます。希望の番号を押してください。" + SSML_break
            guidenumber = 1
            for item in items:
                if item['free']:
                    free_schedule_dict[guidenumber] = item['date-start']
                    messagesContent += str(guidenumber) + \
                        SSML_break + item['date-start'] + SSML_break
                    guidenumber = guidenumber + 1

        # Lex に渡すメッセージを生成する。この content が Lex で認識されて読み上げられる。 
        messages = [
            {
                "contentType": "SSML",
                "content": "<speak>" + messagesContent + "</speak>"
            }
        ]

        # sessionAttributes の nextSlot に次処理すべき Slot の名前を入れる。
        sessionAttributes = {
            "nextSlot": schedule_slotname
        }

        for guidenumber, datestart in free_schedule_dict.items():
            sessionAttributes[guidenumber] = datestart
            

        session_state = {
            "dialogAction": {
                "type": "ElicitSlot",
                "slotToElicit": "Schedule"                
            },
            "intent": {
                "confirmationState": "Confirmed",
                "slots": slots,
                "name": name
            },
            "sessionAttributes": sessionAttributes
        }

        request_attributes = event["requestAttributes"] if "requestAttributes" in event else {
        }

        sessionid = event["sessionId"] if "sessionId" in event else {
        }



        # return response
        response = {
            "sessionState": session_state,
            "messages": messages,
            "requestAttributes": request_attributes,
            "sessionId": sessionid
        }

        print("============ print response ============")
        logger.info(json.dumps(response))

        return response
    
    # 処理すべき Slot が 「Schedule」のとき
    if nextSlot == schedule_slotname:
        slots = event["sessionState"]["intent"]["slots"]
        name = event["sessionState"]["intent"]["name"]

        # お客様の希望のスケジュールを取得
        guidenumber = event['sessionState']['intent']['slots']['Schedule']['value']['interpretedValue']
        startdate = event['sessionState']['sessionAttributes'][guidenumber]

        # DynamoDB のデータを更新
        key = {
            'busyoid': 'busyo1',
            'date-start': startdate
        }

        # 更新する項目の設定を指定
        update_expression = 'SET #f = :val1'
        expression_attribute_names = {'#f': 'free'}
        expression_attribute_values = {':val1': False}

        response = table.update_item(
            Key=key,
            UpdateExpression=update_expression,
            ExpressionAttributeNames=expression_attribute_names,
            ExpressionAttributeValues=expression_attribute_values
        )

        # Lex に渡すメッセージを生成する。この content が Lex で認識されて読み上げられる。
        messages = [
            {
                "contentType": "PlainText",
                "content": "無視されるメッセージ。dialogAction.Delegate の場合は、この messages は読み上げられない"
            }
        ]

        session_state = {
            "dialogAction": {
                "type": "Delegate"
            },
            "intent": {
                "confirmationState": "Confirmed",
                "slots": slots,
                "name": name
            },
            "state": "Fulfilled"
        }

        request_attributes = event["requestAttributes"] if "requestAttributes" in event else {
        }

        sessionid = event["sessionId"] if "sessionId" in event else {
        }

        # return response
        response = {
            "sessionState": session_state,
            "messages": messages,
            "requestAttributes": request_attributes,
            "sessionId": sessionid
        }

        print("============ print response ============")
        logger.info(json.dumps(response))

        return response
    # 想定外の sessionAttributes.nextSlot
    else:
        return None



def lambda_handler(event, context):
    print("============ print event ============")
    logger.info(json.dumps(event))

    response = router(event)
    return response

参考 URL

Lambda 関数で Amazon Connect の変数を指定
https://qiita.com/novelworks/items/8b5cd9e0dae9a472c966

[Amazon Lex] Amazon Lexが日本語対応となったので、Amazon Connectから使用して居酒屋の電話予約をボット化してみました
https://dev.classmethod.jp/articles/amazon-lex-with-amazon-connect/

【Amazon Connect+LexV2】Botによる自動注文受付実装例
https://blog.serverworks.co.jp/amazon-connect-lex-order

【Amazon Connect+LexV2】Lambdaを使用した動的な自動応答実装例
https://blog.serverworks.co.jp/amazon-connect-lex-dynamic-content

Lex + Lambda で年齢のバリデーション
https://qiita.com/hirai-11/items/58f524be7cc5d9ca2093

曖昧な時間の確認
https://qiita.com/hirai-11/items/6caa99f83b85d7863d86

電話番号を Lambda 関数に渡す
https://qiita.com/hirai-11/items/3d81a9a5a9131d270d4d

予約可能時間のバリデーション
https://dev.classmethod.jp/articles/slots-value-validation-on-lex/

Amazon Lexv2 と Lambda 関数
https://docs.aws.amazon.com/ja_jp/lexv2/latest/dg/lambda.html
https://docs.aws.amazon.com/ja_jp/lexv2/latest/dg/paths-code-hook.html

Lex から Lambda を呼びだす
https://repost.aws/ja/knowledge-center/lex-dialogflow-fulfillment-lambda

Amazon Lex Workshop
https://catalog.us-east-1.prod.workshops.aws/workshops/94f60d43-15b7-45f4-bbbc-17889ae64ea0/en-US/banker-bot/create-first-lambda

初期のインテント指定できそう
https://blog.usize-tech.com/visual-builder-on-amazon-lex/

Amazon Connect + Amazon Lex + Lambda を連携する
https://ac.geekfeed.co.jp/amazon-lex-lambda-data-link/

Lex から呼び出される Lambda のサンプルコード
https://github.com/aws-samples/amazon-lex-v2-lambdahook-for-booktripbot/blob/main/lambda_function.py

Lexv1 の Document : ElicitSlot の詳細な説明が載っている。Lexv2 でも考え方は参考になりそうかも
https://docs.aws.amazon.com/ja_jp/lex/latest/dg/lambda-input-response-format.html

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?