Bot Builder v4 でボット開発 : QnA Maker で簡単に QA ボットを作る

今回は QnA メーカーを使って作成したナレッジベースを既存のボットに追加します。

# QnA メーカーサービス

QnA メーカーを使うと、既存の QA データから簡単にボットを作ることが出来ます。裏側では Azure Search や Web App などのサービスを組み合わせて実現しています。

1\. [Azure ポータル](https://portal.azure.com) より「新しいリソース」をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/670def07-615a-1dd4-dde6-0b7dd8d2a96b.png)

2\. QnA で検索し、「QnA Maker」を選択して作成。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a2f32f08-4b23-7f63-2417-0f0b39620a77.png)

3\. 必要な設定を行い「作成」。テストのため全て無料版を利用。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2f90c264-ec10-0cde-60e1-9bdcbc0af2c8.png)

4\. 作成が完了したらリソースに移動。Keys より KEY をコピーして保存。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/db777419-2930-5a0a-7c71-2abf4b60382b.png)

5\. [QnA Maker のサイト](https://www.qnamaker.ai/) より「Create a knowledge base」をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/07c719ad-8761-d581-f6ec-05094cf2965f.png)

6\. STEP 1 の QnA メーカーのサービス作成は既に終わっているため、STEP 2 より作成したサービスを選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/1b14932b-4a19-5cd5-3135-9e4fe23baec7.png)

7\. 名前を設定。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8767e94b-d85b-29bc-8b9a-64bfe6009081.png)

8\. 任意の KB URL を指定。ここでは https://azure.microsoft.com/ja-jp/support/faq/ を利用。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/edc76681-5ba0-b73d-82a6-4f3db9ec62ae.png)

9\. 「Create your KB」をクリックして作成。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e42ff0d7-4c9e-8bac-d8c5-b7e46596d9f9.png)

10\. 作成が完了したら「Save and train」をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/099d712e-796e-0c8d-9518-71d31142b704.png)

11\. Publish タブを選択して、「Publish」をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/25d40dc2-f439-964a-9998-00cbc9f34884.png)

12\. Publish 完了後、情報を確認。Curl や Postman で動作確認。URL に含まれる ID は KB ID で後ほど利用するためコピー。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c682e7a2-9e72-3ed7-2437-69c5893f38cc.png)

# QnA メーカーをボットに追加

次に作成した QnA メーカーをボットに追加しますが、そのためには 2 つの作業が必要です。

- QnA メーカーの情報を構成ファイル (.bot) に追加
- コードの追加

## CLI を使った構成ファイルの更新

構成ファイルは手動でも更新できますが、CLI を使うとより簡単かつ確実に更新できます。

1\. 以下コマンドで必要な CLI ツールをインストール。

```shell
npm install -g msbot qnamaker
```
2\. Visual Studio Code の統合コンソールより以下のコマンドを実行。

- qnamaker get kb で KB の情報を取得
- kbId : QnA メーカーで作成された KB の ID
- subscriptionKey : Azure ポータルで作成した QnA メーカーリソースの Keys 情報
- --msbot オプションは出力結果を .bot ファイル用にフォーマット
- msbot connect qna で受け取った QnA ボットの情報を構成ファイルに追加

```shell
qnamaker get kb --kbId <KB ID> --subscriptionKey <QnA メーカーのキー> --msbot | msbot connect qna --stdin
```

3\. コマンド実行が完了したら MyBot.bot ファイルの中身を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8f75cfe9-964d-3903-9370-31c6651d4065.png)

## コードの更新

次にコードを更新して QnA ボットを使えるようにします。

1\. 以下コマンドを実行して、Microsoft.Bot.Builder.AI.QnA パッケージを追加。

```shell
dotnet add package Microsoft.Bot.Builder.AI.QnA
dotnet restore
```

2\. Startup.cs に using を追加。

```csharp
using Microsoft.Bot.Builder.AI.QnA;
```

3\. Startup.cs の ConfigureServices メソッドに、以下コードを追加して QnaMaker を IoC コンテナに追加。

```csharp
// 構成ファイルより QnAMakerService を取得
var qnaMakerService = (QnAMakerService)botConfig.Services.Where(x => x.Type == "qna").First();
var qnaEndpoint = new QnAMakerEndpoint()
{
    KnowledgeBaseId = qnaMakerService.KbId,
    EndpointKey = qnaMakerService.EndpointKey,
    Host = qnaMakerService.Hostname,
};
var qnaMaker = new QnAMaker(qnaEndpoint);
services.AddSingleton(sp => qnaMaker);
```

4\. Dialogs フォルダに QnADialog.cs を追加して、以下のコードに差し替え。

```csharp
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Extensions.Localization;
using Microsoft.Bot.Builder.AI.QnA;
using CognitiveServices.Translator;
using CognitiveServices.Translator.Translate;

public class QnADialog : ComponentDialog
{
    private MyStateAccessors accessors;
    private QnAMaker qnaMaker;
    private ITranslateClient translator;
    private IStringLocalizer<QnADialog> localizer;

    public QnADialog(MyStateAccessors accessors, QnAMaker qnaMaker, ITranslateClient translator, IStringLocalizer<QnADialog> localizer) : base(nameof(QnADialog))
    {
        this.accessors = accessors;
        this.qnaMaker = qnaMaker;
        this.translator = translator;
        this.localizer = localizer;
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            AskQuestionAsync,
            ReplyAnswerAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("qna", waterfallSteps));
        AddDialog(new TextPrompt("question"));
    }

    private async Task<DialogTurnResult> AskQuestionAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // 戻り値は文字型のため、TextPrompt ダイアログを使って送信
        return await stepContext.PromptAsync("question", new PromptOptions
        {
            Prompt = MessageFactory.Text(localizer["whatisquestion"])
        },
        cancellationToken: cancellationToken);
    }

    private async Task<DialogTurnResult> ReplyAnswerAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);

        // 日本語ではない場合、日本語に翻訳
        if (userProfile.Language != "ja-JP")
        {
            var translateParams = new RequestParameter
            {
                From = userProfile.Language,
                To = new[] { "ja" },
                IncludeAlignment = true,
            };

            // LUIS に投げるために、元のインプットを変換したものに差し替え。
            stepContext.Context.Activity.Text =
            (await translator.TranslateAsync(
                new RequestContent(stepContext.Context.Activity.Text), translateParams)
            ).First().Translations.First().Text;
        }

        var answer = await qnaMaker.GetAnswersAsync(stepContext.Context);
        if (answer.FirstOrDefault() == null || answer.First().Score == 0)
            await stepContext.Context.SendActivityAsync(localizer["noanswer"]);
        else
        {
            var answerText = answer.First().Answer;
            // 日本語以外の場合は元の言語に戻す
            if (userProfile.Language != "ja-JP")
            {
                var translateParams = new RequestParameter
                {
                    From = "ja-JP",
                    To = new[] { userProfile.Language },
                    IncludeAlignment = true,
                };

                // LUIS に投げるために、元のインプットを変換したものに差し替え。
                answerText =
                (await translator.TranslateAsync(
                    new RequestContent(answerText), translateParams)
                ).First().Translations.First().Text;
            }
            // 答えを返す
            await stepContext.Context.SendActivityAsync(answerText);
        }
        return await stepContext.EndDialogAsync(true, cancellationToken);
    }
}
```

5\. 対応するリソースファイルを追加します。

```xml:QnADialog.ja.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
   <data name="whatisquestion" xml:space="preserve">
    <value>質問を教えてください。</value>
  </data>
  <data name="noanswer" xml:space="preserve">
    <value>適した答えが見つかりませんでした。</value>
  </data>
</root>
```
```xml:QnADialog.en.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
   <data name="whatisquestion" xml:space="preserve">
    <value>What is your question?</value>
  </data>
  <data name="noanswer" xml:space="preserve">
    <value>No good match found in KB.</value>
  </data>
</root>
```

6\. myfirstbot.csproj にリソースアイテムを追加。

```xml
<EmbeddedResource Update="Resources\QnADialog.en.resx">
  <Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resources\QnADialog.ja.resx">
  <Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
```

7\. Startup.cs の ConfigureServices 内で QnADialog を IoC コンテナに追加。

```csharp
services.AddScoped<QnADialog, QnADialog>();
```

8\. MenuDialog.cs で QnADialog を使えるようにコードを変更。

```csharp
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Choices;
using System.Linq;
using System;
using Microsoft.Extensions.Localization;

public class MenuDialog : ComponentDialog
{
    static private Dictionary<string, string> menus;
    static private IList<Choice> choices;
    private IStringLocalizer<MenuDialog> localizer;

    public MenuDialog(IServiceProvider serviceProvider, IStringLocalizer<MenuDialog> localizer) : base(nameof(MenuDialog))
    {
        this.localizer = localizer;
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            InitializeAsync,
            ShowMenuAsync,
            ProcessInputAsync,
            LoopMenu
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("menu", waterfallSteps));
        AddDialog(new ChoicePrompt("choice"));
        AddDialog((WeatherDialog)serviceProvider.GetService(typeof(WeatherDialog)));
        AddDialog((ScheduleDialog)serviceProvider.GetService(typeof(ScheduleDialog)));
        AddDialog((QnADialog)serviceProvider.GetService(typeof(QnADialog)));
    }

    public async Task<DialogTurnResult> InitializeAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // メニューの作成。表示される文字と子ダイアログの名称をセットで登録
        menus = new Dictionary<string, string>()
        {
            { localizer["checkweather"], nameof(WeatherDialog) },
            { localizer["checkschedule"], nameof(ScheduleDialog) },
            { localizer["checkqa"], nameof(QnADialog) }
        };

        // ChoiceFactory で選択肢に設定する IList<Choice> を作成
        choices = ChoiceFactory.ToChoices(menus.Select(x => x.Key).ToList());
        return await stepContext.NextAsync();
    }

    public async Task<DialogTurnResult> ShowMenuAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // Choice プロンプトでメニューを表示
        return await stepContext.PromptAsync(
            "choice",
            new PromptOptions
            {
                Prompt = MessageFactory.Text(localizer["choicemenu"]),
                Choices = choices,
            },
            cancellationToken);
    }

    private async Task<DialogTurnResult> ProcessInputAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // 答えを確認して次のダイアログ名を取得
        var choice = (FoundChoice)stepContext.Result;
        var dialogId = menus[choice.Value];
        // 子ダイアログの実行
        return await stepContext.BeginDialogAsync(dialogId, null, cancellationToken);
    }

    private async Task<DialogTurnResult> LoopMenu(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // スタックに乗せないように、Replace でメニューを再表示
        return await stepContext.ReplaceDialogAsync("menu", null, cancellationToken);
    }
}
```

9\. MenuDialog のリソースに checkqa を追加。

```xml
<data name="checkqa" xml:space="preserve">
  <value>KB を確認</value>
</data>
```

## 動作テスト

1\. 英語でまず動作を確認。回答も翻訳されている。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a6d0d959-ed1b-b35d-8429-3ceb77e5ffa9.png)


2\. 日本語でも確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f16dccdf-1582-a61a-7276-e34f1d664b13.png)

# まとめ

QnA ボットは結構前から使えますが、CLI ツールにより統合が非常に簡単になりました。翻訳ではうまく表現されないものは、言語ごとに QA を用意するなど工夫は必要そうです。

[次の記事へ](https://qiita.com/kenakamu/items/988c64788a9682104314)
[目次へ戻る](https://qiita.com/kenakamu/items/6dc043cfc1f199032883)

[この記事のサンプルコード](https://github.com/kenakamu/botbuilderv4completeguide/tree/article18)