はじめに
Amazon Connect を使って、音声のスケジュール予約システムを作って動かしてみました。個人の学習のために作ったので、インターネット上に公開はしていません。作りにあたって、いろいろノウハウを得たので、それを紹介します。
概要図
まず簡単に概要図を出します。
一番左の登場人物「ユーザー」を起点に、右側に利用している AWS サービスを記載しています。電話を受け付ける Amazon Connect, お客様とのコミュニケーションを支援する Amazon Lex, スケジュールを確認・予約するための Lambda 関数, データストアとして DynamoDB があります。
ユーザーは Amazon Connect で管理している電話番号にかけることで、自動音声が流れます。自動音声の中でスケジュールの希望日を伝えることや、ボタンを押すことで予約が可能です。
それぞれのサービスの設定方法を紹介しましょう。
Amazon Connect
Amazon Connect の管理画面で、新しい contact flow を作成します。
Set voice を配置し、日本語の読み上げを有効化します。
- Voice : Kazuha
- Set language attribute : ON
次に、Customer Input を配置し、ここから Amazon Lex を呼びだします。
呼び出す Lex Bot を指定します。以下の画像にある try1
と書かれているのが、LexBot の名前です。自分で作成した Bot を指定してください。
Amazon Connect の Flow はこんな感じです。
Amazon Lex
Lex Bot の作成
Amazon Lex のページを開き、Create Bot を押します。
Bot の名前を適当に指定します。
適当に IAM Role を選択して、Next を押します。
Lex 上で利用される言語や、Voice interaction を選びます。Amazon Connect と一致させるために、Kazuha を選びます。
Lex Bot が作成されました。
Lex Intent の作成
作成した Bot 上で、新たな Intent を作成します。
いきなり全体像を出しますが、このように Intents の Flow を作成します。
- utterances は「1」とする。Amazon Connect 側で「スケジュール予約を希望ですか?希望の場合は 1 を押してください」とガイドが流れる。その後ユーザーが 1 を押すことで、この Intents に紐づけがされる。
- Slot は 2 つ
- それぞれの Slot にユーザー側の入力を受け付けた時、Lambda 関数を動かす
Lex Bot と Lambda 関数を紐づけるために、Alias のページを選択します。
Japanese を選びます
ここで紐づける Lambda 関数を指定します。
Intent 側の設定で、次のチェックも必要です。
Lambda 関数
スケジュール予約を司る重要な Lambda 関数です。Lex で Lambda 関数を利用するうえで、理解する概念があります。Amazon Lex の Bot に Lambda 関数を紐づけられるのですが、基本的には 1 対 1 で紐づきがされます。Bot 側で複数の Intent, 複数の Slot が有る場合は、どの Intent, Slot で呼び出されたのかを Lambda コード内で意識する必要があります。「この Intent の、この Slot だから、この処理をしよう」といったように制御が必要です。
この記事では、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.type
を ElicitSlot
と指定し、"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 の行がスケジュールが埋まっていることを示しています。
この記事では、時間枠は固定になっています。
DynamoDB のテーブルを作成します。
Table 構成を指定します。
Create Table を押します。
実際のデータは、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)
データが格納されました。
動作確認
本来であれば Amazon Connect の電話している様子をお見せできればいいのですが、録画環境が無かったので Lex 上の文字でやりとりを行います。
DynamoDB 上の次のテーブルを予約していきます。
Lex のテストを開き、予約の希望を伝えます。すると、候補日を 3 つ教えてくれるので、「1」 を入力します。
「1」を入力すると予約が完了しました。
DynamoDB 上でも free が false となっていることがわかりました。
検証を通じてわかったこと
- 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