次世代コミュニケーションツール「チャットボット」の活用 〜Azure Bot ServiceでAzureのことに何でも答えてくれるLINEボットを作る 〜【LUIS編】

こんにちは、サイオステクノロジー技術部 武井です。全5回シリーズのAzure Bot Service及びBot FrameworkのDeepDive(全然深くないですが)4回目は、自然言語を解析するCognitiveサービス「LUIS」について、書きたいと思います。

  1. 概要編
  2. Azure Bot Service編
  3. QnA Maker編
  4. 今回はこちら → LUIS編
  5. Azureのことに何でも答えてくれるLINEボット作る編

本シリーズの成果物は以下のGitHubにあがっております。

https://github.com/noriyukitakei/AzureFAQBot

LUISとは?

LUISとは、マイクロソフトのサイトによりますと、以下のとおりの機能を持つサービスです

Language Understanding は会話の中から価値のある情報を特定するよう設計されており、ユーザーの目標 (インテント) を解釈して、文章から価値のある情報 (エンティティ) を抽出することで、高品質で繊細な言語モデルを構築します。Language Understanding は、音声意図判定処理を即座に実行するための 音声サービスと Azure Bot Service のどちらにもシームレスに統合されているため、高度なボットを簡単に作成できます。

ワ(*´▽`*)ケ(-_-)ワ(●`ε´●)カ(# ゚Д゚)リ(#・∀・)マ(-_-)セ(●`ε´●)ン

平たく言いますと、チャットボットで、「私は大阪までの航空券が欲しい」というと、その文章をAIが解析して、「大阪」の「航空券」を検索してくれるようなサービスです。AIなので、微妙な誤差も吸収することが出来ます(設定次第ですが)。例えば「私は大阪までの航空券が取りたい」でもOKです。

では、どのような仕組みで実現しているのか、説明していきます。

インテントとエンティティ

LUISをわかりづらくしているのが、「インテント」と「エンティティ」という2つの概念です。これがまぁややこしいのです。

例えば、指定した場所までの航空券を予約するチャットボットを考えてみましょう。そんなとき、一般的には、チャットボットにはこんな風に入力しますよね。

大阪までの航空券が取りたい。

この文章を「インテント」「エンティティ」に分けてみたのが、以下の図になります。

Screen Shot 2019-10-11 at 18.45.37

インテントは日本語に訳すと「意思」、エンティティは「物」という意味です。「大阪までの航空券が取りたい」というのが、「航空券が取りたい」というインテント(意思)です。でも「航空券が取りたい」だけだと、どこの航空券を取りたいのかわかりません。そこで、エンティティというのが必要になってきます。このエンティティに「大阪」というのが入ってきて、初めて大阪までの航空券が取りたいということをシステムに理解させることが出来ます。

でも、「航空券が取りたい」という意思一つを取ってみても、色々な表現方法があります。

大阪までの飛行機のチケットが取りたい。
大阪まで飛行機で行きたい。
大阪までの航空券を予約したい。

これらは表現の方法は異なりますが、全て「大阪までの航空券を買う」という意思にほかなりません。これらをLUISに「大阪までの航空券を買う」という意思として認識させるためには、上記のインテントとエンティティを登録しなければいけません。

インテントやエンティティの登録方法は後でご説明するとして、LUISに登録したイメージは以下のようになります。

Screen Shot 2019-10-11 at 19.13.11

いかがでしょうか?なんとなくイメージ湧いてきましたでしょうか?「大阪」の部分が「location」というものに置き換わっていますが、この時点ではこんなものだという認識で十分です。

LUISを使ったチャットボットアプリ構築の流れ

LUISの重要な概念「インテント」「エンティティ」は、私は最初はなかなか理解できませんでした。何かよい実践的な事例がないと、なかなかにわかりにくいのかもしれません(わたしだけ?)。そこで、本章では、LUISを使ったチャットボットアプリを実際にに構築する中で、「インテント」「エンティティ」の概念をわかりやすく説明できればと思います。

お客様から以下の要件があったとします。

指定の場所までの航空券を予約するチャットボットを作ってほしい。例えば、「大阪までの航空券が取りたい」という口語的な文章を入力しても、正確に認識してほしい。また、日本語のブレ(航空券が欲しい、飛行機のチケットがほしい)にも対応して欲しい。

上記の要件に対応したチャットボットを作っていきましょう。まず、全体的な流れは以下の通りとなります。

Screen Shot 2019-10-14 at 23.15.48

上図の各フェーズの内容は以下のとおりです。

1.LUISポータルでインテントとエンティティの登録
専用のポータル画面からインテントとエンティティの登録を行います。

2.LUISのSDKを利用たチャットボットアプリの開発
C#を始めとした言語向けに、LUISに登録されたインテントやエンティティの取得や操作などを行うSDKが用意されています。そのSDKを利用してチャットボットアプリを開発します。チャットボットアプリの開発については、第3回目のQnA Maker編をご参照ください。

3.Endpoint Keyの発行
LUISには、LUISで登録したインテントやエンティティ(以降LUISアプリと呼びます)にアクセスするためのキーとして、「Authoring Key」「Endpoint Key」の2つがあります。Authoring KeyはLUISポータルで行えることを全て実行可能な、非常に強力な権限を持つキーです。対して、Endpoint KeyはLUISアプリに対して読み込み権限しか持ちません。本番用途での運用は、Authoring Keyではなく、Endpoint Keyを使うべきです。ここでは、そのEndpoint Keyの発行を行います。

4.利用ログの分析
第3回目のQnA Maker編と同様に、ユーザーの利用ログを分析を行います。分析のネタになるのは「スコア」です。ここでまた「スコア」という新しい概念が登場したわけですが、これはユーザーが入力した内容が、事前に登録したインテントとどれだけ一致しているかを0〜1の範囲で表すもので、1に近いほど一致しているということになります。

例えば、「大阪までの航空券が取りたい。」というインテントとが登録されている場合、ユーザーが「大阪までの航空券が取りたい。」と入力すれば、スコアは0.9を超える高いものとなります。

しかし、ちょっと文章を変えて「大阪までの飛行機を予約したい。」とすると、スコアは0.4とかなり下がります。これは、事前に登録されているインテント「大阪までの航空券が取りたい。」とかなり異なっているとLUISが判断しているからです。

スコアがあらかじめ定めた既定値よりも低い文章をログに記録しておき、後で分析に用います。本ブログでは、ログの記録先にApplication Insightsを利用します。

5.チューニング
3で記録したログの中で、あまりにも同じような文章で低いスコアのものがあるようだと、それはインテントとして登録する必要があるのではという判断をします。例えば、「大阪までの飛行機をリザブりたい。」という内容の低スコアの文章が頻繁にログに記録されていたとします。そうなるとやはり、「リザブりたい」という言葉は、「予約する」という意思(インテント)を表すものだと判断できますので、LUISポータルでインテントとして登録する必要があるかも、、、といった分析を行います。

LUISポータルでインテントとエンティティの登録

Screen Shot 2019-10-16 at 12.32.44

大まかな流れをご説明したので、これより各フェーズの具体的な実現方法をご説明致します。

まずLUISポータルでインテントとエンティティの登録を行います。

 

以下のURLにアクセスします。

https://luis.ai

 

「Login / Sign up」をクリックします。

Screen Shot 2019-10-13 at 14.30.17

 

まず、先程の航空券を予約するためのLUISのアプリケーションを作成します。「Create new app」をクリックします。

Screen Shot 2019-10-13 at 14.29.52

 

「Name」にアプリケーションを一意に識別する任意の名称、「Culture」に「Japanese」を選択します。「Culture」はLUISで使う言語で、今回の要件においてはユーザーは日本語で発話することを想定しているのでJapaneseとなります。最後に「Done」をクリックします。

Screen Shot 2019-10-13 at 14.37.19

 

「BUILD」をクリックします。

Screen Shot 2019-10-13 at 14.42.16

 

まずはエンティティを作成します。前提として、「XXXXまでの航空券が取りたい」という文章をユーザーが入力するということを前提にインテントとエンティティを組み立てていきます。「Entities」→「Create new entity」の順にクリックします。

Screen Shot 2019-10-14 at 0.17.59

 

「Entity name」にエンティティを一意に識別する任意の名称を入力します。わかりやすい名前のほうがもちろんよいです。ここでは「location」とします。そして、もう一つ重要な「Entity type」というのがあります。「Simple」「List」などいろいろなものがあるのですが、ここでは「Simple」を選ぶこととします(Entity typeについては、後ほど詳しく背説明しますので、ここは何も考えずSimpleを選んでください)。

Screen Shot 2019-10-14 at 0.21.07

 

locationというエンティティが出来上がりました。

Screen Shot 2019-10-14 at 0.27.29

 

次にインテントを作成します。「Intents」→「Create new intent」の順にクリックします。

Screen Shot 2019-10-13 at 14.43.46

 

「Intent name」にインテントを識別する一意の名称を入力します。ここでは、「reserve_flight」としましょう。

Screen Shot 2019-10-13 at 14.45.30

 

「reserve_flight」というインテントが出来上がりました。リスト上に表示されている「reserve_flight」をクリックしてください。

Screen Shot 2019-10-14 at 0.32.17

 

「Example utterance」の下辺りに表示されているテキストボックスに「大阪までの航空券が取りたい。」と入力してエンターを押してください。

Screen Shot 2019-10-14 at 0.35.44

 

インテントが登録されました。次は、登録されたインテントの「大阪」の部分をクリックして、先程登録した「location」というエンティティを選択してクリックしてください。

Screen Shot 2019-10-14 at 0.37.34

 

以下のようになるはずです。

Screen Shot 2019-10-14 at 0.40.12

つまり、今までの部分を要約しますと、まずサンプルの例文を登録します。ここでは「大阪までの航空券が取りたい」ですが、別に「愛媛までの航空券が取りたい」でも構いません。そして次に、「大阪」の部分を、先程作成したlocationというエンティティに置き換えるという流れです。

Screen Shot 2019-10-14 at 0.47.22

 

今まで登録したインテントとエンティティをLUISに学習させるために「Train」をクリックします。

Screen Shot 2019-10-14 at 0.49.51

 

「Train」の赤丸が緑丸になれば完了です。次は、登録されたインテントとエンティティが正常に動作するかテストしてみましょう。そのためのテストツールがLUISには用意されています。「Test」をクリックします。

Screen Shot 2019-10-14 at 0.51.45

 

以下のような画面が表示されます。テキストボックスに「大阪までの航空券が取りたい。」と入力してエンターを押してください。

Screen Shot 2019-10-14 at 0.56.07

 

以下のような画面になります。より詳細な情報を表示するために「Inspect」をクリックします。

Screen Shot 2019-10-14 at 0.59.37

 

まずひとつめに重要なのが「Top scoring intent」という部分です。これは、ユーザーが入力した文章が、あらかじめLUISに登録されているインテントとどれくらいマッチするかを表したものです。以下の例は、「大阪までの航空券が取りたい。」というユーザーの文章が「reserve_flight」というインテントに0.936というスコアでマッチしたよという意味です。先程説明したように、スコアは0〜1で表され、1が最高点ですので、かなりマッチしている、つまり、ユーザーの入力した文章が「航空券を取りたい」という意図にかなり近いということを表しています。そして、「Entities」の部分にご注目ください。以下の表示は「location」というエンティティに「大阪」というものが入っているということになります。つまり、この結果は、インテントがreserve_flights、エンティティが大阪ということになりますので、ユーザーの意図は、大阪まで航空券が取りたいということになります。具体的にシステムに理解させるためには、LUISのSDKを使って、Top scoring intentとEntitiesを抽出することで実現できます。その方法については後述致します。

Screen Shot 2019-10-14 at 1.00.42

 

ここで試しに「飛行機で大阪まで旅行したい。」と入れてみましょう。むむむ、スコアも0.789と低いです。LUISからしてみると「飛行機で大阪まで旅行したい。」は飛行機を予約したいという意図として認識していないと思われます。

Screen Shot 2019-10-14 at 8.36.18

 

「飛行機で大阪まで旅行したい。」を認識させるために以下のインテントを追加で登録してみましょう。

Screen Shot 2019-10-14 at 1.16.23

 

以下のようになるはずです。1つ目に入力したインテントから、「大阪」という言葉を自動的に「location」というエンティティとして認識しているようです。先ほどと同様に「Train」をクリックして、LUISに学習させます。

Screen Shot 2019-10-14 at 8.43.30

 

改めてテストしてみますと、0.970と高いスコアになりました。

Screen Shot 2019-10-14 at 8.44.19

LUISのSDKを利用したチャットボットアプリの開発

Screen Shot 2019-10-16 at 12.35.59

次にLUISポータルで登録した情報を利用したチャットボットアプリの開発を行います。本記事では、Visual Studio 2019を用いてC#向けのLUISのSDKを用いて開発を行います。

Visual Studio 2019はインストール済みであるとします。まずこちらの記事を参考にして、ボットのソースコードのサンプルをダウンロードして、Visual Studioで開きます。このサンプルを元に、LUISのSDKを使って、先程の要件を実現していきます。

では、Visual Studioで先程ダウンロードしたボットのソースコードを開きます。そして、「ツール(T)」→「NuGetパッケージマネージャー(N)」→「ソリューションのNuGetパッケージの管理(N)」の順にクリックします。

Screen Shot 2019-10-14 at 11.22.21

 

以下のように、テキストボックスに「luis」と入力すると、「Microsoft.Bot.Builder.AI.Luis」というものが表示されますので、インストール対象のプロジェクト(EchoBot)を選択して、「インストール」をクリックします。これでLUIS用のSDKがインストールされます。

Screen Shot 2019-10-14 at 11.26.23

 

これでLUISの開発をする準備は出来ました。実際にアプリケーションを開発する前にLUISにアクセスするためのキーやAPIのエンドポイントなどが必要になりますので、それらを取得します。LUISポータルにアクセスして、「MANAGE」→「Application Information」の順にクリックして、「Application ID」をメモします。Screen Shot 2019-10-15 at 0.01.12

 

次に、「Azure Resources」をクリックして、「Starter_Key」の項目にあるPrimary Keyをメモります。また、「Endpoint Url」のホスト名の部分(下図の例であればwestus.api.cognitive.microsoft.com)をメモります。ちなみにこの「Starter_Key」というのは、先程ご説明した「Authoring Key」のことであり、本番環境に使うキーではありません。開発用として一時的に利用しているだけで、後ほど本番環境用のEndpoint Keyを作成して、そちらに差し替えます。

Screen Shot 2019-10-15 at 0.06.34

 

Visual Studioに戻りまして、以下のようにコードを修正します。ソースコードの詳細な内容については、ソースコード中にコメントしていますので、そちらを参照してください。

{
    "MicrosoftAppId": "",
    "MicrosoftAppPassword": "",
    "LuisAppId": "先程メモしたアプリケーションID",
    "LuisAPIKey": "先程メモしたPrimary Key",
    "LuisAPIHostName": "先ほどメモした「Endpoint Url」のホスト名の部分"
}
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.AI.Luis;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace Microsoft.BotBuilderSamples.Bots
{
    public class EchoBot : ActivityHandler
    {

        private readonly IConfiguration _configuration;
        private readonly ILogger _logger;

        // .NET Coreのいろんな設定を取得できるIConfigurationの実装を
        // DIコンテナから取得してます。IConfigurationの実装は.NET Coreが自動でDIコンテナに入れてくれています。
        // またILoggerインターフェースの実装をDIコンテナから取得します。後ほどご紹介する
        // Program.csで、ILoggerインターフェースを実装したApplication Insightsのログ出力の実装をDIしますので、
        // このILoggerを利用すると、Application Insightsにログが出力されます。
        public EchoBot(IConfiguration configuration, ILogger<EchoBot> logger)
        {
            _configuration = configuration;
            _logger = logger;
        }

        protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
        {
            // LUISの接続情報をIConfigurationから取得します。これは、先程
            // appsettings.jsonで定義したLUISのアプリケーションID、APIキー、
            // APIのエンドポイントです。
            var luisApplication = new LuisApplication(
                    _configuration["LuisAppId"],
                    _configuration["LuisAPIKey"],
                    "https://" + _configuration["LuisAPIHostName"]
                );

            // LUISの接続情報を元にLuisRecognizerインスタンスを作成します。
            var recognizer = new LuisRecognizer(luisApplication);

            // LUISに対してAPIをコールします。
            var recognizerResult = await recognizer.RecognizeAsync(turnContext, cancellationToken);

            // LUISに対してAPIをコールした結果の中から、インテントとスコアを取得します。
            var (intent, score) = recognizerResult.GetTopScoringIntent();

            // インテントが「reserve_flight」、スコアが0.9以上の場合、航空券を予約する処理を実施します。
            if (intent == "reserve_flight" && score > 0.9)
            {
                // LUISに対してAPIをコールした結果の中から、locationというエンティティを取得します。
                var location = recognizerResult.Entities["location"]?.FirstOrDefault().ToString();

                // locationまでの航空券を予約します(本記事では、この予約処理は本筋ではないためもちろん割愛します( ー`дー´)キリッ)。
                // ...予約処理...

                // 航空券の予約を完了した旨のメッセージを返します。
                await turnContext.SendActivityAsync(MessageFactory.Text(location + "までの航空券を予約しました。"), cancellationToken);
            }
            else {
                // ユーザーの入力した文章が、航空券を予約するインテント「reserve_flight」にマッチしなかった場合、
                // 「申し訳ございません。よくわかりません(><)」というメッセージを返します。
                await turnContext.SendActivityAsync(MessageFactory.Text("申し訳ございません。よくわかりません(><)"), cancellationToken);
            }

        }

        // こちらはチャットボットにメンバーが追加されたときに実行されるメソッドですが、今回の本筋とは
        // 関係がないので、説明を割愛致します。
        protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
        {
            foreach (var member in membersAdded)
            {
                if (member.Id != turnContext.Activity.Recipient.Id)
                {
                    await turnContext.SendActivityAsync(MessageFactory.Text($"Hello and welcome!"), cancellationToken);
                }
            }
        }
    }
}

 

Azure Bot Service編やQnA Maker編でやりましたようにBot Framework Emulatorを使ってテストをしてみます。

Screen Shot 2019-10-15 at 0.44.20

やったね!!想定通り(萌´▽`萌)

Endpoint Keyの発行

Screen Shot 2019-10-16 at 12.37.50

LUISには、ややこしいことにLUISのAPIを呼ぶためのキーが2つ存在します。「Authoring Key」と「Endpoint Key」です。詳細は以下のとおりです。

■ Authoring Key
Authoring Keyは自動的に発行されるキーで、主にLUISアプリを管理したり、テスト用のクエリを発行したりする用途で利用されます。管理にも使えるということで非常に強力な権限を持つため、本番環境用途では使われません。あくまでも、作成したアプリケーションのテストにご利用ください。また、一ヶ月あたりのAPIのコール数にも制限がありますので、そういう意味でも本番環境には向かないのです。

■ Endpoint Key
LUISアプリに対して読み込みの権限のみがあるキーです。また、Authoring KeyよりもAPIの呼び出し回数の制限が緩和されていたりと、より本番環境向けの用途になります。マイクロソフトも、本番環境ではEndpoint Keyの利用を推奨しております。

また、Endpoint KeyはAzure上にLUISリソースを作成して、そのLUISリソースをLUISポータル側で紐付けることで発行されます。ややこしいのです。これを図示すると以下のようになります。

Screen Shot 2019-10-15 at 1.38.01

では、実際にEndpoint Keyを発行してみましょう。Azureポータルにアクセスして、「リソースの作成」をクリックします。虫眼鏡があるテキストボックスに「cognitive」と入力してエンターを押しますと、「Cognitive Services」が表示されますので、それをクリックします。

Screen Shot 2019-10-15 at 1.41.58

 

「作成」をクリックします。

Screen Shot 2019-10-15 at 1.44.21

 

「名前」にはLUISのリソースを一意に識別する任意の名称、「サブスクリプション」「場所」は適宜あなたがお使いの環境に適したもの、「価格レベル」は「S0」(2019年10月15日時点S0しか選択出来ません)、「リソースグループ」は適宜あなたがお使いの環境に適したものを入力して、「作成」をクリックします。

 

Screen Shot 2019-10-15 at 1.46.20

 

次に、LUISポータルに移動して、「MANAGE」→「Azure Resources」→「Add prediction resources」の順にクリックします。

Screen Shot 2019-10-15 at 1.54.52

 

「Tenant Name」に先程LUISのリソースを作成したAzureテナント、「Subscription Name」に先程LUISのリソースを作成したAzureサブスクリプション、「LUIS resource name」に先程指定したLUISのリソースの名前のものを選択して、「Assign resource」をクリックします。

Screen Shot 2019-10-15 at 2.00.01

 

以下のように新しいキーが作成されていると思います。先程と同様にPrimary Keyをメモります。また、「Endpoint Url」のホスト名の部分(下図の例であればjapaneast.api.cognitive.microsoft.com)をメモります。

Screen Shot 2019-10-15 at 2.07.08

 

先程Starter_Keyの情報を設定したappsettings.jsonを以下のように書き換えます。

{
    "MicrosoftAppId": "",
    "MicrosoftAppPassword": "",
    "LuisAppId": "アプリケーションIDはStarter_Keyのときと変更なし",
    "LuisAPIKey": "先程メモしたPrimary Key",
    "LuisAPIHostName": "先ほどメモした「Endpoint Url」のホスト名の部分"
}

これで、もう一度Visual Studio上で動作しているBotアプリを再起動して、Bot Framework Emulatorでテストしてみると先ほどと同じ結果になるはずです。見た目にはわかりませんが、Endpoint Keyを使ってLUISにアクセスしています。

これでLUISのSDKを利用したチャットボットアプリの開発方法はご理解頂けたかと思います。

利用ログの分析

Screen Shot 2019-10-16 at 12.39.14

第3回目のQnA Maker編と同様に、ユーザーの利用ログを分析を行います。LUISは非常に賢いのですが、人間が何もしてあげなければ赤ちゃんと同様です。ユーザーの利用ログを分析して、チューニングを施してあげなければいけません。では、利用ログの分析とはどのように行うのでしょうか?色々な方法がありますが、ここでは、以下のような方法で実施いたします。

  1. ユーザーの入力した文章の中で、スコアがある一定値より低いものをApplication Insigthsに記録する
  2. Application Insigthsに記録されたログを見て、チューニングの可否を判断する。

「チューニングの可否」の判断というのは非常に難しく、色々なケースがありますので、全てを網羅することは厳しいかもしれません。なので、ここでは、ある特定のユースケースを例に上げることとします。

本記事で作成するチャットボットアプリに対する要件は「指定された場所への航空券の予約」です。先程も申し上げましたが、指定された場所への航空券を予約するための文章はたくさんあります。ある人は「大阪までの航空券を予約したい」かもしれませんし、またある人は「大阪まで飛行機で行きたい」かもしれません。

最初からユーザーの意思全てに答えるようなLUISアプリを作ることは非常に困難です。ユーザーはどんな文章を入力してくるか予測もつきませんし、流行語などに伴い日本語も変化します。

ということで、今回こんなシナリオを考えてみました。最初は「大阪までの航空券が取りたい。」というインテントのみが登録してあり、最初は順調にユーザーの意図に答えていましたが、最近「大阪への飛行機をリザブりたい。」という、スコアの低い文章が頻繁にログに記録されるようになり、なるほど、最近は「リザブる」という言葉が「予約する」という言葉を意味するのかということをログから判断し、そのインテントを追加するというシナリオです。ちなみに本当に「リザブる」という言葉があるのかどうかはわかりません。

前置きが長くなりましたが、早速コードを書いていきたいと思います。まず、Application Insigthsを利用できるようになるために、パッケージマネージャーからApplication InsigthsのパッケージをNuGetします。

Screen Shot 2019-10-14 at 11.22.21

 

以下のように、テキストボックスに「Microsoft.Extensions.Logging.ApplicationInsights」と入力すると、「Microsoft.Extensions.Logging.ApplicationInsights」というものが表示されますので、インストール対象のプロジェクト(EchoBot)を選択して、「インストール」をクリックします。これでApplication Insights用のSDKがインストールされます。

Screen Shot 2019-10-15 at 8.04.54

 

以下のようにコードを修正します。修正内容の詳細は、コード中のコメントに記載しております。

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

namespace Microsoft.BotBuilderSamples
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                // ILoggerインターフェースに特定の実装をDIするための手順になります。ConfigureLoggingメソッドで行います。
                .ConfigureLogging(builder => {
                    // AddApplicationInsightsメソッドを使うと、ILoggerインターフェースにApplication Insightsにロギングするための
                    // 実装がDIされます。引数には、Application Insightsのインストゥルメーションキーを設定します。
                    builder.AddApplicationInsights("[Application Insightsのインストゥルメーションキー]");

                    // Infoレベル以上のログのみApplication Insightsに送信されるよう設定を行います。
                    builder.AddFilter<Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider>
                                 ("", LogLevel.Information);

                });
    }
}

 

EchoBot.csについては、66〜67行目が先程のコードと比べて追加した部分になります。

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.AI.Luis;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace Microsoft.BotBuilderSamples.Bots
{
    public class EchoBot : ActivityHandler
    {

        private readonly IConfiguration _configuration;
        private readonly ILogger _logger;

        // .NET Coreのいろんな設定を取得できるIConfigurationの実装を
        // DIコンテナから取得してます。IConfigurationの実装は.NET Coreが自動でDIコンテナに入れてくれています。
        // またILoggerインターフェースの実装をDIコンテナから取得します。後ほどご紹介する
        // Program.csで、ILoggerインターフェースを実装したApplication Insightsのログ出力の実装をDIしますので、
        // このILoggerを利用すると、Application Insightsにログが出力されます。
        public EchoBot(IConfiguration configuration, ILogger<EchoBot> logger)
        {
            _configuration = configuration;
            _logger = logger;
        }

        protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
        {
            // LUISの接続情報をIConfigurationから取得します。これは、先程
            // appsettings.jsonで定義したLUISのアプリケーションID、APIキー、
            // APIのエンドポイントです。
            var luisApplication = new LuisApplication(
                    _configuration["LuisAppId"],
                    _configuration["LuisAPIKey"],
                    "https://" + _configuration["LuisAPIHostName"]
                );

            // LUISの接続情報を元にLuisRecognizerインスタンスを作成します。
            var recognizer = new LuisRecognizer(luisApplication);

            // LUISに対してAPIをコールします。
            var recognizerResult = await recognizer.RecognizeAsync(turnContext, cancellationToken);

            // LUISに対してAPIをコールした結果の中から、インテントとスコアを取得します。
            var (intent, score) = recognizerResult.GetTopScoringIntent();

            // インテントが「reserve_flight」、スコアが0.9以上の場合、航空券を予約する処理を実施します。
            if (intent == "reserve_flight" && score > 0.9)
            {
                // LUISに対してAPIをコールした結果の中から、locationというエンティティを取得します。
                var location = recognizerResult.Entities["location"]?.FirstOrDefault().ToString();

                // locationまでの航空券を予約します(本記事では、この予約処理は本筋ではないためもちろん割愛します( ー`дー´)キリッ)。
                // ...予約処理...

                // 航空券の予約を完了した旨のメッセージを返します。
                await turnContext.SendActivityAsync(MessageFactory.Text(location + "までの航空券を予約しました。"), cancellationToken);
            }
            else {
                // ユーザーに対して適切な回答を返せなかった問い合わせをApplication Insightsにロギングします。
                _logger.LogInformation(turnContext.Activity.Text);

                // ユーザーの入力した文章が、航空券を予約するインテント「reserve_flight」にマッチしなかった場合、
                // 「申し訳ございません。よくわかりません(><)」というメッセージを返します。
                await turnContext.SendActivityAsync(MessageFactory.Text("申し訳ございません。よくわかりません(><)"), cancellationToken);
            }

        }

        // こちらはチャットボットにメンバーが追加されたときに実行されるメソッドですが、今回の本筋とは
        // 関係がないので、説明を割愛致します。
        protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
        {
            foreach (var member in membersAdded)
            {
                if (member.Id != turnContext.Activity.Recipient.Id)
                {
                    await turnContext.SendActivityAsync(MessageFactory.Text($"Hello and welcome!"), cancellationToken);
                }
            }
        }
    }
}

 

では、Bot Framework Emulatorで「大阪への飛行機をリザブりたい。」と入力してみましょう。やっぱり、スコアが想定より低いので、よくわかりませんってなりました。

Screen Shot 2019-10-15 at 9.51.31

 

Application Insigthsのログに記録されているはずです。検索してみましょう。

左部のメニューから「ログ(Analytics)」をクリックします。そしてクエリを入力するところに以下のクエリを入力します。

traces
| where customDimensions.CategoryName == "Microsoft.BotBuilderSamples.Bots.EchoBot"

Screen Shot 2019-10-15 at 9.59.24

ちっちゃくて見にくいかもしれませんが、「大阪への飛行機をリザブりたい。」というログが記録されています。これは、スコアが既定値(今回は0.9)より低い文章を記録しています。この結果を用いて次章でチューニングを行ってみます。

チューニング

Screen Shot 2019-10-16 at 12.40.14

前回の結果を用いてチューニングを行ってみます。Application Insightsには「大阪への飛行機をリザブりたい。」というログが複数記録されていました。ということは、最近の若者は「予約する」を「リザブる」というらしい、では、これをインテントとして登録しないといけない!!という判断に至ったとします(繰り返しますが、予約することをリザブるっていうのかどうかはわかりません)。

ということでLUISポータルから「大阪への飛行機をリザブりたい。」という新しいインテントを登録しましょう。結果は以下のようになるはずです。いつものように「Train」をクリックしてLUISに学習させます。

Screen Shot 2019-10-15 at 10.11.33

 

次にProductionスロットに公開します。これをやらないと、チャットボットアプリから新しく追加したインテントが認識されません。「Publish」をクリックします。

Screen Shot 2019-10-15 at 10.13.39

 

「Production」を選択して、「Publish」をクリックします。

Screen Shot 2019-10-15 at 10.17.53

 

ではもう一度Bot Framework Emulatorから「大阪への飛行機をリザブりたい。」と入力してみます。

Screen Shot 2019-10-15 at 10.22.45

 

今度はちゃんと認識されました(^o^)

いかがでしたしょうか?LUISを用いたチャットボットアプリの開発からチューニングの流れをご理解頂けかと思います。実際はもっと色々なケースが発生して、チューニングも複雑になるかと思いますが、なんとなくイメージを掴んで頂ければ幸いです。

色々なエンティティ

本章では、LUISが持つ便利なエンティティをいくつかピックアップしていきたいと思います。適切なエンティティを使い分けることで、スコアの向上を実現することが出来ます。

Listエンティティ

先程検証した、飛行機を予約するインテントにおいて、「愛媛までの航空券が取りたい。」という文章を入力してみましょう。

Screen Shot 2019-10-15 at 10.47.41

 

むむむ、「愛媛」というエンティティが取れていません。これはインテントを登録するときに「大阪までの航空券を取りたい。」と入力したので、「大阪」はLUISが学習しているのですが、「愛媛」は知りません。これを解決するためにはListエンティティを使います。LUISポータルで「Entities」→「Create new entity」の順にクリックします。

Screen Shot 2019-10-15 at 10.50.07

 

「Entity name」 に「location_list」と入力、「Entity type」に「List」を選択して「Done」をクリックします。

Screen Shot 2019-10-15 at 10.53.43

 

「Add new sublist…」と表示されているテキストボックスに、ユーザーが入力すると考えられる地名を全て入力します。

Screen Shot 2019-10-15 at 21.57.34

 

もう一度テストしてみましょう。今度は、先程定義したListエンティティ「location_list」に「愛媛」と入りました。

Screen Shot 2019-10-15 at 22.02.13

ところで、今回新しく作成したlocation_listというエンティティはインテントに割り当てていません。それにも関わらず、location_listエンティティの中には愛媛が入っていました。これは、Listエンティティの性質として、特にインテントに割り当てなくても、文中にListエンティティで定義したものが完全一致すれば、勝手にListエンティティに入れてくれるというものがあるせいです。

Compositeエンティティ

これはごちゃごちゃ説明するよりも実例で示したほうがわかりやすいかと思います。例えば、朝ごはんのおかずとデザート、夜ごはんのおかずとデザートを注文するチャットボットアプリがあり、ユーザーは以下のように発話することで注文できるものとします。

朝ごはんのおかずはサンマでデザートはプリン、夜ごはんのおかずはステーキでデザートはパフェでお願いします。

ここで今までやった方法の中でぱっと思いつくのはListエンティティを使う方法です。

ということで、okazu_listというListエンティティにサンマとかステーキとかおかず系のものを、dessert_listというエンティティにパフェとかプリンとかデザート系のものを入れて以下のインテントを作ってみましょう。

Screen Shot 2019-10-16 at 0.16.07

 

これでテストしてみましょう。結果は以下ですが、、、むむむ、、、Entitiesを見るとわかるのですが、どれが朝ごはんのものか夜ごはんのものかわかりません。例えば以下の結果からでは、サンマは朝ごはんのおかずなのか夜ごはんのおかずなのかわかりません。たまたま順番がインテントで指定した順番と一致していますが、もともとEntitiesの中に入るものはインテントで指定した順番になる保証はありません。

Screen Shot 2019-10-16 at 0.18.50

 

LUISのSDKが返す結果も以下のようになっています。この結果を解析しても、サンマは朝ごはんのおかずなのか夜ごはんのおかずなのかわかりません。

{
  "$instance": {
    "dessert_list": [
      {
        "startIndex": 18,
        "endIndex": 21,
        "text": "プリン",
        "type": "dessert_list"
      },
      {
        "startIndex": 41,
        "endIndex": 44,
        "text": "パフェ",
        "type": "dessert_list"
      }
    ],
    "okazu_list": [
      {
        "startIndex": 9,
        "endIndex": 12,
        "text": "サンマ",
        "type": "okazu_list"
      },
      {
        "startIndex": 31,
        "endIndex": 35,
        "text": "ステーキ",
        "type": "okazu_list"
      }
    ]
  },
  "dessert_list": [
    [
      "パフェ"
    ],
    [
      "パフェ"
    ]
  ],
  "okazu_list": [
    [
      "サンマ"
    ],
    [
      "ステーキ"
    ]
  ]
}

 

ここでCompositeエンティティの出番です。おかずとデザートを「朝ごはん」「夜ごはん」それぞれのグループにグルーピングしてあげましょう。イメージとしては以下のようになります。

Screen Shot 2019-10-16 at 0.47.29

つまり朝ごはんのおかずとデザートを示すokazu_listエンティティとdessert_listエンティティをbreakfastエンティティというCompositeエンティティでグループ化します。同様に、夜ごはんのおかずとデザートを示すokazu_listエンティティとdessert_listエンティティをdinnerエンティティというCompositeエンティティでグループ化します。こうすることで、それぞれのおかずとデザートが朝ごはんのものなのか夜ごはんのものなかを区別することができるようになります。では、実際にやってみましょう。今は、以下の通り、okazu_listエンティとdessert_listエンティティしかありません。「Create new entity」をクリックします。

Screen Shot 2019-10-16 at 2.02.00

 

まずは朝ごはんをグループ化するためのCompisteエンティティを作成します。「Entity name」に「breakfast」、「Entity type」に「Composite」、「+ Add a child entity」をクリックして、「okazu_list」「dessert_list」を選択して、「Done」をクリックします。

Screen Shot 2019-10-16 at 2.00.29

 

同様にして、夜ごはんのおかずとデザートをグループ化するdinnerエンティティも作成します。

Screen Shot 2019-10-16 at 2.07.18

 

 

1つ目の「okazu_list」をクリックして、「Wrap in composite entity」をクリックします。

Screen Shot 2019-10-16 at 2.08.36

 

次に「おかず」をクリックして、次に1つ目の「dessert_list」をクリックします。これでグループ化する範囲に緑の下線が引かれます。その状態で、以下のように「breakfast」のCompositeエンティティを選択します。

Screen Shot 2019-10-16 at 2.11.44

 

こんな風になるはずです。

Screen Shot 2019-10-16 at 2.13.57

 

同様にして、夜ごはんのokazu_listとdessert_listをdinnerエンティティでグループ化します。以下のようになるはずです。LUISに学習させるために「Train」をクリックします。

Screen Shot 2019-10-16 at 2.15.58

 

Testしてみます。「Composite Entities」と表示されているところを見てみます。breakfastというエンティティを展開するとその中に、サンマが入っているokazu_listエンティティ、プリンが入っているdessert_listエンティティがあるのがわかります。朝ごはんのおかずとデザートがbreakfastというCompositeエンティティでグループ化されており、サンマは朝ごはんのおかず、プリンは朝ごはんのデザートであることがわかります。

Screen Shot 2019-10-16 at 2.17.44

 

dinnerエンティティを展開した結果も同様です。

Screen Shot 2019-10-16 at 2.24.20

 

ちなみにLUISのSDKが返すJSONも以下のようになっています。breakfastというフィールドの中に配列で、朝ごはんのおかずとデザートであるサンマとプリンのエンティティが入っているのがわかります。同様にdinnerというフィールドの中に配列で、夜ごはんのおかずとデザートであるステーキとパフェが入っていることが確認出来ます。

{
  "$instance": {
    "breakfast": [
      {
        "startIndex": 5,
        "endIndex": 21,
        "text": "おかず は サンマ で デザート は プリン",
        "type": "breakfast",
        "score": 0.768030047
      }
    ],
    "dinner": [
      {
        "startIndex": 27,
        "endIndex": 44,
        "text": "おかず は ステーキ で デザート は パフェ",
        "type": "dinner",
        "score": 0.7646288
      }
    ]
  },
  "breakfast": [
    {
      "$instance": {
        "okazu_list": [
          {
            "startIndex": 9,
            "endIndex": 12,
            "text": "サンマ",
            "type": "okazu_list"
          }
        ],
        "dessert_list": [
          {
            "startIndex": 18,
            "endIndex": 21,
            "text": "プリン",
            "type": "dessert_list"
          }
        ]
      },
      "okazu_list": [
        [
          "サンマ"
        ]
      ],
      "dessert_list": [
        [
          "パフェ"
        ]
      ]
    }
  ],
  "dinner": [
    {
      "$instance": {
        "okazu_list": [
          {
            "startIndex": 31,
            "endIndex": 35,
            "text": "ステーキ",
            "type": "okazu_list"
          }
        ],
        "dessert_list": [
          {
            "startIndex": 41,
            "endIndex": 44,
            "text": "パフェ",
            "type": "dessert_list"
          }
        ]
      },
      "okazu_list": [
        [
          "ステーキ"
        ]
      ],
      "dessert_list": [
        [
          "パフェ"
        ]
      ]
    }
  ]
}

正規表現エンティティ

エンティティには正規表現を使うことが出来ます。例えば、「prn-XXXXXX」(prn-まで固定でXXXXXXは数字6桁)という形式の型番の商品があるとします。ユーザーは以下の発話をすることで、該当の型番の商品を注文できるものとします。

商品の型番はprn-123456です。

prn-123456の部分だけ正確にエンティティとして抽出したいのですが、こんな場合に正規表現エンティティを使います。prn-123456は正規表現では、prn-[0-9]{6}と表すことが出来ます。では正規表現エンティティを作っていきましょう。毎度おなじみエンティの作成画面で以下のように入力します。ポイントは「Entity type」に「Regex」を選択すること、「Regex」の部分に正規表現を入力することです。

Screen Shot 2019-10-16 at 7.53.31

 

以下のような発話を作ってみます。

Screen Shot 2019-10-16 at 7.58.46

 

先程作成した正規表現エンティティが自動的に認識されます。

Screen Shot 2019-10-16 at 8.07.58

 

Testしてみましょう。「商品の型番はprn-492082です。」と入力しますと、きちんと型番のところだけエンティティとして認識されているのがわかります。

Screen Shot 2019-10-16 at 7.59.25

Prebuiltエンティティ

LUISには、よく使う役立つエンティティが事前に登録されており、これらを簡単に使うことが出来ます。例えば、ユーザーは、注文結果を送付するメールアドレスを以下の要は発話で行うものとします。

注文結果はhoge@example.comまで送ってください。

ここでメールアドレスだけをエンティティとして抽出したいのですが、これは至難の技です。ぱっと思いつくのは、先程ご紹介した正規表現エンティティですが、メールアドレスだけを正規表現で表すのってつらみーですよね。

そこで、Prebuiltエンティティの出番です。既にメールアドレスのパターンを認識できるエンティティが用意されているのです。

いつもとちょっと作り方が違います。エンティティの一覧画面で「+Add prebuilt entity」をクリックします。

Screen Shot 2019-10-16 at 8.21.15

 

「email」をチェックして「Done」をクリックします。

Screen Shot 2019-10-16 at 8.23.17

 

以下の様な発話を登録します。

Screen Shot 2019-10-16 at 8.24.58

メールアドレスの部分が、先程追加したemailというprebuiltエンティティとして自動的に認識されます。

Screen Shot 2019-10-16 at 8.25.55

 

Testをしてみましょう。LUISに学習させるために「Train」をクリックするのを忘れないでください。

Screen Shot 2019-10-16 at 8.28.01

おお!!ちゃんとntakei@fuga.example.comがメールアドレスとして認識されてますね。

フレーズリスト

非常にListエンティティと似ているもので、Phrase listsエンティティというのがあります。Listエンティティとの最大の違いは、エンティティとして抽出させたくはないのだけど、スコアの精度向上のために、似たようなフレーズを登録するというものです。これもなかなか言葉だけでは伝わりにくいので、いつものようにお絵かきと実践で説明したいと思います。

例えば、「東京まで行きます。」というインテントがあったとします。この「東京」というのはとても重要なものです。例えば航空券を予約するチャットボットアプリの場合、「東京」という場所は予約するために必要不可欠であり、プログラムの中で抽出する必要がある、つまりエンティティとして認識させる必要があります。このような場合にListエンティティを使います。

もう一方で、例えば、「パフェを食べたい。」というエンティティがあったとします。ここで「パフェを必要としています。」「パフェを所望します。」も同じ意味です。でも「パフェを食べたい。」というインテントだけ登録してあった場合、「パフェを必要としています。」「パフェを所望します。」は、スコアとして低くなり、パフェが食べたいというユーザーの意思として認識されない場合があります。このような場合、「食べたい」「所望します」「必要としています」というフレーズリストを登録しておけば、全て同じ意志としてLUISが認識をして、スコアを高くすることが出来ます。先程も言いましたが、「食べたい」「所望します」「必要としています」はエンティティとして抽出する必要はありません。あくまでもユーザーの「パフェを食べたい」という意思を正確に把握するためだけに必要なものです。

上記の話をイメージにすると以下のような感じになります。

 

Screen Shot 2019-10-16 at 11.27.09

では早速実践してみましょう。「パフェを食べたい。」というインテントを登録して、Testで「パフェを食べたい。」「パフェをはむはむしたい。」と入力してみましょう。

Screen Shot 2019-10-16 at 11.36.27

「パフェを食べたい。」はスコアが高いですが、「パフェをはむはむしたい。」はスコアが低いです。でも「パフェを食べたい。」も「パフェをはむはむしたい。」も、同じ「パフェを食べたい」という意思にほかなりません。「食べたい」と「はむはむしたい」をフレーズリストに登録して、同じ意味を持つフレーズとして認識させます。

「Phrase lists」→「+ Create new phrase list」の順にクリックします。

Screen Shot 2019-10-16 at 10.25.41

 

「Name」のところにフレーズリストを一意に識別する名称として「want」を入力し、「Value」のところに「食べたい」「はむはむしたい」を入力して「Done」をクリックします。

Screen Shot 2019-10-16 at 11.41.19

 

Testをしてみましょう。LUISに学習させるために「Train」をクリックするのを忘れないでください。

Screen Shot 2019-10-16 at 11.42.42

「パフェをはむはむしたい。」のスコアが、0.317から0.854に向上したのがわかりますでしょうか。

これで「パフェをはむはむしたい。」は、ユーザーがパフェを食べたいという意志だということをLUISに理解させることができました。

Role

最後にご説明するのはエンティティではないのですが、Roleというものになります。これは、エンティティに役割を与えるものです。これも説明するより実演したほうが早いと思いますので、早速やってみます。

例えば、スケジュールを登録できるチャットボットアプリがあって、出発地と目的地を登録するために以下の様は発話をするケースを考えてみます。

東京から名古屋まで行きます。

ぱっと思いつくのはListエンティティを使った方法です。東京、名古屋、大阪などの地名を登録したListエンティティを使って、出発地と目的地をエンティティとして認識させようとしてみます。

以下のように、東京、名古屋、大阪などの地名を登録したlocationというListエンティティを作成します。

Screen Shot 2019-10-16 at 8.41.01

 

以下のようなインテントを登録します。

Screen Shot 2019-10-16 at 8.42.48

 

地名の部分が自動的に先程作成したlocationというListエンティティに置き換わります。

Screen Shot 2019-10-16 at 8.45.00

 

Testをしてみましょう。LUISに学習させるために「Train」をクリックするのを忘れないでください。

Screen Shot 2019-10-16 at 8.46.45

むむむ、、、「東京」も「名古屋」も同じ「location」というListエンティティとして認識されています。これではどちらが出発地でどちらが目的地なのかわかりません。

LUISのSDKが返すJSONからも、どちらが出発地でどちらが目的地なのかわかりません。

{
  "$instance": {
    "location": [
      {
        "startIndex": 0,
        "endIndex": 2,
        "text": "東京",
        "type": "location"
      },
      {
        "startIndex": 4,
        "endIndex": 7,
        "text": "名古屋",
        "type": "location"
      }
    ]
  },
  "location": [
    [
      "東京"
    ],
    [
      "名古屋"
    ]
  ]
}

 

ここで2つのlocationというListエンティティにそれぞれ「出発地」「目的地」というRoleを割り当ててみたいと思います。イメージとしては以下のような感じです。

Screen Shot 2019-10-16 at 8.56.46

 

では、早速Roleを割り当ててみましょう。先程作成したlocationというListエンティティの画面のちょっと下の方にRoleという項目があります。「Create new role…」と表示されているテキストボックスに「departure」と入力してエンターを押します。同様の方法で「destination」も作ります。

Screen Shot 2019-10-16 at 8.58.41

 

以下のようになればOKです。

Screen Shot 2019-10-16 at 9.08.32

では先程のインテントの画面に戻ります。locationをクリックすると以下のように先程作成したRoleが出てきます。出発地(1つ目)のlocationエンティティはdepatureを選択、目的地(2つ目)のlocationエンティティはdestinationを選択してください。

Screen Shot 2019-10-16 at 9.09.51

 

以下のようになればOKです。

Screen Shot 2019-10-16 at 9.11.21

 

Testをしてみましょう。LUISに学習させるために「Train」をクリックするのを忘れないでください。

Screen Shot 2019-10-16 at 9.14.04

おおー!!Entitiesの項目を見るとわかりますが、出発地の東京が「location:departure」となっており、目的地の名古屋が「location:destination」となっていることがわかります。

LUISのSDKが返すJSONも見てみます。先程とは異なり、「departure」「destination」というフィールドの中に「東京」「名古屋」があり、出発地と目的地を明確に区別できていることがわかります。

{
  "$instance": {
    "departure": [
      {
        "startIndex": 0,
        "endIndex": 2,
        "text": "東京",
        "type": "location"
      }
    ],
    "destination": [
      {
        "startIndex": 4,
        "endIndex": 7,
        "text": "名古屋",
        "type": "location"
      }
    ]
  },
  "departure": [
    [
      "東京"
    ]
  ],
  "destination": [
    [
      "名古屋"
    ]
  ]
}

まとめ

いかがでしょうか?すごいですね、LUIS!!もうLUISなしではいけていけません。No LUIS, No Life!!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です