1 時間で Amazon Lex の掛け算九九練習ボットを作る

2021-10-05
日常で楽しむクラウドテクノロジー

Author : 伊藤 芳幸

みなさんはじめまして & こんにちは、機械学習ソリューションアーキテクトの伊藤です。

我が家には小学一年生の娘がいますが、昨今の状況で家で過ごす時間が多くなっています。なるべく遊ぶ時間は作ってあげたいけど、仕事で時間をとることが難しいこともありますよね ? そこで、AWS の AI サービスを使って、家で時間つぶしができるアプリを手軽に作ってみようと思い立ちました。ちょうど掛け算の暗記をしていて、掛け算九九の歌や動画をよくかけていたのですが、そろそろ問題を解かせてみたいところです。しかし、掛け算ドリルのような、音やレスポンスがない教材は敷居が高そうなので、音が出て双方向にコミュニケーションを取れるような教材がいいなと思い、ボットが思い浮かびました。

そこで、ボットを簡単に作ることができる Amazon Lex を活用して、掛け算九九練習ボットを作ってみることにします。

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

builders.flash メールメンバーへの登録・特典の入手はこちら »

*ハンズオン記事およびソースコードにおける免責事項 »

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


1. 完成したボット

最終的には、Amazon Lex を活用して、Webブラウザで実行できる掛け算練習ボットを作成することができました !

今回はこのアプリケーションを作るまでの手順をご紹介します。

【動画】八の段 (採点機能付き) Webブラウザ版 - 音声入力 -


2. Amazon Lex とは

Amazon Lex は、音声やテキストを使用して、任意のアプリケーションに対話型インターフェイスを構築するサービスです。Amazon Lex では、音声のテキスト変換には自動音声認識 (ASR : Automatic Speech Recognition)、テキストの意図認識には自然言語理解 (NLU : Natural Language Understanding) という高度な深層学習機能が使用できるため、ユーザーにとって使いやすく魅力的なアプリケーションや、リアルな会話を実現するアプリケーションを開発できます。Amazon Lex を使うと、すべてのデベロッパーが Amazon Alexa に採用されている深層学習技術と同じ技術を利用できるため、自然言語での高度な対話ボット (チャットボット) を短時間で簡単に構築できます。

以降では、ステップバイステップで、掛け算九九練習ボットを作っていきます !


ステップ 1 : コンソールで簡単なボットを作る

まずは、Amazon Lex V2 のコンソール上で、掛け算ボットを作成していきます。
コーディングは不要で、GUI 操作のみで作成することができます。

Amazon Lex V2 のコンソールにいき、ボットを作成 を押下します。

クリックすると拡大します

設定を入力していきます。作成 を選択し、ボット名を入力します。今回は「kakezan_bot」とします。

クリックすると拡大します

IAM アクセス許可 にて、基本的な Amzaon Lex 権限を持つロールを作成します。 を選択します。

デモアプリですが、子供 (6 歳) が利用するので、COPPAは はい を選択します。

*児童オンラインプライバシー保護法 (COPPA:Children's Online Privacy Protection Act) は、オンラインで子どもから収集される情報を保護者が管理できるようにするために設けられた米国連邦法です。COPPA により、13 歳未満の子どもの個人情報を収集、使用、開示する際に、保護者に通知し保護者の同意を得ることが義務付けられています (いずれの場合も、COPPA の要件を満たしている必要があります)。保護者の同意は検証可能であることが必要です (例 : 保護者に同意書を書かせる、保護者の身分証明書を確認する)。COPPA の遵守方法および違反した場合の影響については、次のウェブサイト (英語のみ) をご確認ください。

Amazon Lex における COPPA に従う場合の説明はこちら »

クリックすると拡大します

コンソールに戻ります。
次へ を押下します。

クリックすると拡大します

ボットに言語を追加 画面に推移します。
言語を選択 にて、日本語 (JP) を設定し、完了 を押下します。

クリックすると拡大します

ボットが作成されました。続いて、インテントを設定していきます。

インテント とは、ユーザーが実行したいアクションを表します。掛け算ボットでは、「一の段の練習」、「ニの段の練習」など、各段の練習を、それぞれ 1 インテントとします。

クリックすると拡大します

まずは、「一の段」インテントを作成します。インテント名は「1」(何の段かを表す) とします。

クリックすると拡大します

サンプル発話を設定します。Lex は、登録されたサンプル発話に基づいて、インテントを推定します。

プレーンテキスト に、以下のテキストを入力します。

ーーーーーーーー
1の段
いちのだん
1
いち
ーーーーーーーー

クリックすると拡大します

プレビュータブ を押下すると、入力したテキストを確認することができます。

クリックすると拡大します

サンプル発話の設定は完了です。次に、スロットの設定をします。

スロット は、インテント達成のために必要な値を格納する箱、変数のことです。掛け算ボットの場合、「一の段練習・採点」というインテントを達成するために、「1 かける 1 の解答」、「1 かける 2 の解答」〜「1 かける 9 の解答」という 9 つのスロットを設定します。

スロットを追加 をクリックします。

クリックすると拡大します

スロット名は、「1 かける 1」の場合は、「11」とします。こうすることでスロット名からどの問題なのかも把握することができるからです。

スロットタイプは、Amazon.Number を選択します。こうすることで、このスロットには数字が格納されます。プロンプトは、「いんいちが ?」などの掛け算九九のフレーズを入力します。

追加 を押下して、スロットが作成されます。

クリックすると拡大します

以上の操作を「1 かける 9」まで繰り返し、9 個のスロットを設定しました。

クリックすると拡大します

インテントが完了したときの出力メッセージも設定しておきます。

応答を閉じる にて、メッセージ 「おわり」を設定します。

クリックすると拡大します

最下段に表示されている、インテントを保存 を押した後、構築 を押すと、数分で作成したボットがビルドされます。

構築完了後、テスト を押すと、コンソール上で簡易テストができます。

クリックすると拡大します

【動画】一の段の Lex コンソール上のテスト

入力はテキストで行っています。入力欄の左にあるマイクのアイコンをクリックすることで、音声入力も可能です。

※動画に音声はありません。

掛け算ボットを Lex のコンソール画面上の操作だけで作成し、実際に動かすことができました !


4. ステップ 2 : 採点機能を AWS Lambda で実装する

このままではせっかく解答したのに得点がわからないので、最後に採点結果を出力する機能を追加しましょう。集計には、AWS Lambda を使います。

Amazon Lex では、スロットの値が全て埋まった後のインテントを履行する段階 (フルフィルメント) で、Lambda 関数を呼び出す ことができます。

では、この機能を使って、採点機能を実現していきましょう。

インテント設定の コードフック にて、フルフィルメントに Lambda 関数を使用 にチェックを入れます。

これによって、スロットが全て埋まり、インテントを履行する段階で、指定した Lambda 関数を実行することができます。

インテントを保存 を押下し、構築 を押下します。

次に、実行する Lambda 関数を作成します。AWS Lambda のコンソールに移動します。

関数の作成 を押下し、設定していきます。

関数の作成にて、一から作成 を選択します。

lex_kakezan」という関数を作成します。
ランタイムは 「Python 3.9」を選択します。

関数の作成 をクリックすると、コードソース画面に進みます。

コードソース画面にて、lambda_function.py に以下のソースコードを入力します。

lambda_function.py

def get_slots(intent_request):
    return intent_request['sessionState']['intent']['slots']


def get_slot(intent_request, slotName):
    slots = get_slots(intent_request)
    if slots is not None and slotName in slots and slots[slotName] is not None:
        return slots[slotName]['value']['interpretedValue']
    else:
        return None    


def get_session_attributes(intent_request):
    sessionState = intent_request['sessionState']
    if 'sessionAttributes' in sessionState:
        return sessionState['sessionAttributes']
    return {}


def close(intent_request, session_attributes, fulfillment_state, message):
    intent_request['sessionState']['intent']['state'] = fulfillment_state
    return {
        'sessionState': {
            'sessionAttributes': session_attributes,
            'dialogAction': {
                'type': 'Close'
            },
            'intent': intent_request['sessionState']['intent']
        },
        'messages': [message],
        'sessionId': intent_request['sessionId'],
        'requestAttributes': intent_request['requestAttributes'] if 'requestAttributes' in intent_request else None
    }


def lambda_handler(event, context):
    intent_name = event['sessionState']['intent']['name']
    text = ''
    n_correct = 0
    for i in range(1, len(get_slots(event))+1):
        ### ユーザーの解答を取得
        ans = int(get_slot(event, str(intent_name) + str(i)))
        if int(intent_name) * i == ans:  # 正解
            text = text + f'{intent_name} x {i} = {ans} ◯ '
            n_correct += 1
        else: # 不正解
            text = text + f'{intent_name} x {i} = {ans} × せいかいは {int(intent_name) * i} だよ。'
    text = f'せいかいは {n_correct} こでした!!!!! ' + text
    
    message =  {
            'contentType': 'PlainText',
            'content': text
        }
    fulfillment_state = "Fulfilled"    
    session_attributes = get_session_attributes(event)
    return close(event, session_attributes, fulfillment_state, message)

Lex から、Lambda に処理が渡され、lambda_handler 関数が実行されますので、
インテント名を取得 (event に格納されている) して、格納された slot 値 (ユーザー解答) をインテント名 *1 から順に合っているか判定していきます。

正解値は intent 名 * i なので、これがスロットに格納されている解答と等しいか見ていきます。正解、不正解に応じたメッセージを text 変数に格納していきます。

最後に close 関数でL ex 側への応答を作成します。

書き換えたら、Deploy を押下して、Lambda 関数をデプロイします。

Lex のコンソール画面に戻り、作成した Lambda 関数を指定します。

エイリアス画面から 言語 Japanese (Japan) を選択すると、Lambda 関数を指定する画面に遷移します。

ソース に、先ほど作成した Lambda 関数「lex_kakezan」を指定します。

保存 を押下します。

以上で Lex が呼び出す Lambda 関数を指定することができました。

ステップ 1 と同様に、Lex のコンソール画面上のテストから、動作確認をしてみましょう。

【動画】二の段 (採点機能付き) Lex コンソール上のテスト

※音声はありません。前半は早送りしています。

AWS Lambda と連携することで、採点結果を出力することができるようになりました !


5. ステップ 3 : Web ブラウザから掛け算ボットを利用する

Amazon Lex のコンソール画面では味気ないので、Web ブラウザから利用できるアプリケーションを構築してみましょう。

こちらのブログ で紹介されている、Amazon Lex の Web Interface のサンプル が GitHub で公開されていますので、今回はこちらを活用していきます。

以下のような特徴が挙げられています (2021/9/12 時点) :

  • フルページまたは埋め込み可能なウィジェットモードを備えたモバイル対応のレスポンシブ UI
  • 音声とテキストをサポートし、シームレスに切り替えることが可能
  • 音声サポートでは、無音部分の自動検出、トランスクリプト、応答の中断、録音の再生が可能
  • Lex レスポンスカードの表示
  • JavaScript を用いてチャットボットの UI をプログラムで設定・操作する機能

AWS CloudFormation のテンプレートも用意されていますので、利用してみます。

Tokyo リージョンの Launch Stack をクリックします。

クリックすると拡大します

事前準備

Lex のコンソール画面にて、構築に必要な情報である、ボット ID とエイリアス ID を記録しておきます。

ボット ID は、kakezan_bot の設定画面で確認することができます。

クリックすると拡大します

エイリアス ID は、kakezan_bot の項目の デプロイ  > エイリアス にて確認することができます。

クリックすると拡大します

記録したら、CloudFormation のテンプレートを使って構築していきます。

CloudFormation のテンプレート入力

CloudFormation のテンプレート入力に戻ります。

赤枠の箇所が、変更する部分です。
スタックの名前 はデフォルトの「lex-web-ui」のままとしています。

クリックすると拡大します

LexV2BotId に、先ほどメモした botID を記入します。

LexV2BotAliasID に、先ほどメモしたエイリアス ID を記入します。

LexV2Bot_Located に、ja_JP と入力します。

クリックすると拡大します

WebAppConfBotInitialText に「なんのだんをやりますか?」と入力します。

WebAppConfBotInitialSpeech に「なんのだんをやりますか?」と入力します。

これを設定することで、ボットがインテント特定のための会話で、「なんのだんをやりますか?」と質問します。

WebAppConfNegativeFeedback と、WebAppConfPositiveFeedback は空白にします。
これを設定すると、採点後 (インスタンス完了後) の Good ボタン、Bad ボタンのフィードバック表示がなくなります。掛け算ボットにはフィードバック機能は不要なので非表示にします。

WebAppConfToolbarTitle は、「かけざんのれんしゅう」と設定します。これで、タイトルに「かけざんのれんしゅう」と表示されます。

クリックすると拡大します

最下段の 機能 にて、2 項目にチェックをいれます。

最後に、スタックの作成 を押下します。

クリックすると拡大します

数分後、CloudFormation のコンソール画面で確認すると、3 つのネストされたスタックとともに、構築が完了します。

lex-web-ui スタックの、出力タブの一番下 WebAppUrl に記載されている URL をクリックすると Web ブラウザアプリにアクセスすることができます。

クリックすると拡大します

Lambda 関数の変更をします。

このアプリケーションは、メッセージの改行に対応しているので、Lambda 関数の text 変数への代入部分を変更します。<br/> タグを入れて、改行されるようにしています。また、message 変数の contentType の値を CustomPayload に変更します。(改行されるようになりますが、読み上げはされません。)

lambda_function.py で変更した関数

def lambda_handler(event, context):
    intent_name = event['sessionState']['intent']['name']
    text = ''
    n_correct = 0
    for i in range(1, len(get_slots(event))+1):
        ### ユーザーの解答を取得
        ans = int(get_slot(event, str(intent_name) + str(i)))
        if int(intent_name) * i == ans:  # 正解
            text = text + f'{intent_name} x {i} = {ans} ◯ <br/>'
            n_correct += 1
        else: # 不正解
            text = text + f'{intent_name} x {i} = {ans} × せいかいは {int(intent_name) * i} だよ。<br/>'
    text = f'せいかいは {n_correct} こでした!!!!! <br/>' + text
    
    message =  {
            'contentType': 'CustomPayload',
            'content': text
        }
    fulfillment_state = "Fulfilled"    
    session_attributes = get_session_attributes(event)
    return close(event, session_attributes, fulfillment_state, message)

Lambda 関数をデプロイしたら、Web ブラウザアプリを実行してみましょう !

【動画】八の段 (採点機能付き) Web ブラウザ版

※音声はありません。前半は早送りしています。

できました !
マイクボタンを押すことで、音声入力モードになり、質問文も読み上げるようになります。

採点結果を読み上げると冗長に感じてしまったため、娘への提供の時は正解個数の読み上げのみにしました。以下が Lambda 関数の変更部分です。

lambda_function.py で変更した関数

def lambda_handler(event, context):
    intent_name = event['sessionState']['intent']['name']
    n_correct = 0
    for i in range(1, len(get_slots(event))+1):
        ### ユーザー解答を取得
        ans = int(get_slot(event, str(intent_name) + str(i)))
        if int(intent_name) * i == ans:  # 正解
            n_correct += 1
        else: # 不正解
            pass
    text = f'せいかいは {n_correct} こでした!!!!!'
    
    message =  {
            'contentType': 'PlainText',
            'content': text
        }
    fulfillment_state = "Fulfilled"    
    session_attributes = get_session_attributes(event)
    return close(event, session_attributes, fulfillment_state, message)

最後に正解数のみ text 変数に格納しています。message 変数の contentType の値を PlainText に戻しました。

この状態が冒頭で娘が遊んでいたものです。

【動画】八の段 (採点機能付き) Web ブラウザ版

※音が出ます

【動画】八の段 (採点機能付き) Web ブラウザ版 - 音声入力 - (画面)

※音声はありません。

完成です。(数時間くらいは) 面白がって他の段を練習してくれたりしました。次にリビングに顔を出したら携帯ゲーム機に変わっていましたが・・・。開始から 1 時間程度で Web ブラウザで遊べる簡単なボットアプリケーションを作ることができました !


6. ステップ 4 : ランダム出題を Lambda 関数で実装する

しかし真の掛け算マスターになるには、順番に出題された問題を解くだけでは不十分です。Lambda 関数を編集して、ランダムに出題するようにしましょう。Lambda 関数で、どのスロットを埋めるのか、Lex に指示を出すことができます。

まずは、スロットに値が格納されるたびに Lambda 関数を実行するようにします。
Lex のコンソールにて、各インテントの コードフック 初期化と検証に Lambda 関数を使用 にチェックを入れ、インテントを保存します。

これによって、インテントが確定した直後 (初期化) と、スロットに値が格納された直後 (検証) のタイミングで、Lambda 関数が実行されるようになります。

クリックすると拡大します

あとは Lambda 関数側で、値が埋まっていないスロットをランダムで選択して、Lex に聞いてもらうようにします。

Lambda 関数はこちら。ランタイムは Python 3.9。

lambda_function.py (ランダム出題版、全体ソース)

import random


def get_slots(intent_request):
    return intent_request['sessionState']['intent']['slots']


def get_slot(intent_request, slotName):
    slots = get_slots(intent_request)
    if slots is not None and slotName in slots and slots[slotName] is not None:
        return slots[slotName]['value']['interpretedValue']
    else:
        return None   


def get_none_slot_list(d):
    return [k for k, v in d.items() if v == None]


def get_session_attributes(intent_request):
    sessionState = intent_request['sessionState']
    if 'sessionAttributes' in sessionState:
        return sessionState['sessionAttributes']
    return {}


def elicit_slot(intent_request, session_attributes, slot):
    return {
        'sessionState': {
            "activeContexts": [
                {
                    "name": "slot",
                    "contextAttributes": {
                        "last": slot
                    },
                    "timeToLive": {
                        "timeToLiveInSeconds": 20,
                        "turnsToLive": 20
                    }
                }
            ],
            'dialogAction': {
                'slotToElicit': slot,
                'type': 'ElicitSlot'
            },
            "intent": {
            "name": intent_request['sessionState']['intent']['name'],
            "slots": intent_request['sessionState']['intent']['slots']
            },
            'sessionAttributes': session_attributes
        },
        #'messages': [ message ] if message != None else None, ### ユーザーへの応答はLEXに委譲
        'requestAttributes': intent_request['requestAttributes'] if 'requestAttributes' in intent_request else None
    }


def close(intent_request, session_attributes, fulfillment_state, message):
    intent_request['sessionState']['intent']['state'] = fulfillment_state
    return {
        'sessionState': {
            'sessionAttributes': session_attributes,
            'dialogAction': {
                'type': 'Close'
            },
            'intent': intent_request['sessionState']['intent']
        },
        'messages': [message],
        'sessionId': intent_request['sessionId'],
        'requestAttributes': intent_request['requestAttributes'] if 'requestAttributes' in intent_request else None
    }


def lambda_handler(event, context):
    print(event)
    # 何の段か(インテント名)を取得
    intent_name = event['sessionState']['intent']['name']
    slots = get_slots(event)
    none_list = get_none_slot_list(slots)
    
    if none_list != []: # Noneのslotがまだ残っている場合(Initialize or validation)、ランダムで出題する
        slot = random.choice(none_list)
        ### initialだったら、slotをランダムに選択して、Lexに聞いてもらう
        session_attributes = get_session_attributes(event)
        return elicit_slot(event, session_attributes, slot)
    else:
        ### Nullのスロットがない(fulfilled)場合、集計スクリプト
        text = ''
        n_correct = 0
        for i in range(1, len(get_slots(event))+1):
            ### ユーザー解答を取得
            ans = int(get_slot(event, str(intent_name) + str(i)))
            if int(intent_name) * i == ans: # 正解
                text = text + f'{intent_name} かける {i} は {ans} ◯ <br/>'
                n_correct += 1
            else: # 不正解
                text = text + f'{intent_name} かける {i} は {ans} × せいかいは{int(intent_name) * i} <br/>'
        text = f'せいかいは {n_correct} こでした!!!!! <br/>' + text
        
        message =  {
                'contentType': 'CustomPayload',
                'content': text
            }
        fulfillment_state = "Fulfilled"    
        session_attributes = get_session_attributes(event)
        return close(event, session_attributes, fulfillment_state, message)

elicit_slot 関数では、Lex にスロットを埋めるための質問をしてもらうための応答を返す関数です。

lambda_handler 関数の中で、まだ埋まっていないスロットをランダムで選択して、ElicitSlot アクションをLexに返します。

以上で完成です !

【動画】八の段 (採点機能付き) Webブラウザ版 - ランダム出題

※音声はありません。

Lambda 関数を使うことで、柔軟に制御することができました。

Lex 側ではインテントとスロットが準備され、ユーザーの書き込みや音声から、インテントの判断、スロットを埋めるという作業を担当するようになっています。Lambda のデプロイは瞬時にされるため、テストが効率よく行えるという利点があります。


7. まとめ : Amazon Lex で手軽にボット開発を !

Amazon Lex を活用して、簡単なボットアプリケーションを作成することができました ! 今回は AWS Lambda に連携するだけで制御が完結しましたが、Lambda から Amazon DynamoDB と連携することで ピザ注文ボット を作ったり、Amazon OpenSearch Service  と Amazon Kendra を活用することで 質問応答ボット (QnABot) を作ったり、Amazon Connectと連携 してコールセンターのチャットボットを作るなど、様々なソリューションを作ることができます。

こちら のブログには Amazon Lex の様々なソリューション事例が掲載されています。

AWS のサービスを活用すれば、様々なアプリケーションを手軽に作ることができます ! 自作アプリケーションを作って、おうち時間を楽しんでみてはいかがでしょうか ?


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

伊藤 芳幸
アマゾン ウェブ サービス ジャパン合同会社
機械学習スペシャリスト ソリューションアーキテクト

インフラエンジニア、コンサルタント、データストラテジストを経て、アマゾン ウェブ サービス ジャパンに入社。
余暇は二人の子供達に遊んでもらったり、今はフットサルを控えて、ジムで運動不足解消に励んでいる。

AWS のベストプラクティスを毎月無料でお試しいただけます

さらに最新記事・デベロッパー向けイベントを検索

下記の項目で絞り込む
1

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する