かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Bot Builder SDK v4 を使って QA ボットを作ってみよう

とりあえず、スタート地点は Bot Builder SDK v4 のテンプレートの中の Echo ボットを使います。

テンプレートを Visual Studio に追加するにはこの拡張機能を入れます。

marketplace.visualstudio.com

Echo のテンプレートを使って作成したら、とりあえず EchoWithCounterBot.csMyQABot.cs とかにリネームして、その時に出てくるダイアログでクラス名も変更するようにします。

そして https://qnamaker.ai にアクセスして適当なナレッジベースを作ります。 出来たらトレーニングとかさせて公開しておきましょう。

ここらへんのドキュメントが参考になります。

docs.microsoft.com

BotConfiguration.bot に対して QnA Maker 向けの設定を追加します。 msbot コマンドでこんな感じで出来ます。

msbot コマンドは node.js を入れて以下のコマンドで入ります。

npm i -g msbot
msbot connect qna --name "QnA" --kbId <your kbid> --subscriptionKey <your subscription key> --endpointKey <your endpoint key> --hostname <hostname>

--subscriptionKey が Azure ポータルに作成された QnA Maker のリソースの Keys からとれる値で、--kbid は PUBLISH したあとに出てくる URL の中の /knowledgebases/<ここ>/generateAnswer の値で、--endpointKey が PUBLISH した後の Authorization: EndpointKey <ここ> になります。--hostname は Azure ポータルに作成されている QnA Maker のデプロイ先の Web App の概要ページにある URL の値です。

コマンドを BotConfiguration.bot のあるフォルダで打つと QnA Maker の定義が追加されます。

じゃぁ QnA Maker を呼びます。NuGet で Microsoft.Bot.Builder.AI.QnA を追加します。追加の際に Bot Builder SDK のバージョンが古いと言われたら NuGet パッケージマネージャーからさくっと Bot Builder SDK 関連のものをアップデートしましょう。

次に、QnAMaker の設定を読み込んでアプリから使えるようにします。Bot Builder SDK のサンプルでは、BotServices クラスを定義してそこに各種サービスを突っ込むのがセオリーっぽいです。ここでは QnAMaker だけしか使わないのと他の煩わしい部分を省くために BotServices クラスを省いて QnAMaker クラスを直接使います。

まず、Startup.cs に以下のメソッドを追加します。

private QnAMaker InitQnAMaker(BotConfiguration config)
{
    var qna = (QnAMakerService)config.Services.Single(x => x.Type == ServiceTypes.QnA);
    return new QnAMaker(new QnAMakerEndpoint
    {
        KnowledgeBaseId = qna.KbId,
        EndpointKey = qna.EndpointKey,
        Host = qna.Hostname,
    });
}

そして、Startup.cs の ConfigureServices メソッドの AddBot メソッドの呼び出しの後ろらへんに以下のコードを追加して QnAMaker クラスも services に登録しておきます。QnAMaker を作るには BotConfiguration が必要なので、それの生成を AddBot から ConfigureServices の先頭に移動します。

var secretKey = Configuration.GetSection("botFileSecret")?.Value;
var botFilePath = Configuration.GetSection("botFilePath")?.Value;
var botConfig = BotConfiguration.Load(botFilePath ?? @".\BotConfiguration.bot", secretKey);

そして AddBot の後当たりに以下の行を追加します。

services.AddSingleton(sp =>
{
    var qna = (QnAMakerService)botConfig.Services.Single(x => x.Type == ServiceTypes.QnA);
    return new QnAMaker(new QnAMakerEndpoint
    {
        KnowledgeBaseId = qna.KbId,
        EndpointKey = qna.EndpointKey,
        Host = qna.Hostname,
    });
});

MyQnABot のコンストラクタで、この QnAMaker クラスも受け取るようにします。

public class MyQnABot : IBot
{
    private readonly EchoBotAccessors _accessors;
    private readonly QnAMaker _qnaMaker; // 追加
    private readonly ILogger _logger;

    /// <summary>
    /// Initializes a new instance of the <see cref="MyQnABot"/> class.
    /// </summary>
    /// <param name="accessors">A class containing <see cref="IStatePropertyAccessor{T}"/> used to manage state.</param>
    /// <param name="loggerFactory">A <see cref="ILoggerFactory"/> that is hooked to the Azure App Service provider.</param>
    /// <seealso cref="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging/?view=aspnetcore-2.1#windows-eventlog-provider"/>
    public MyQnABot(EchoBotAccessors accessors, QnAMaker qnaMaker, ILoggerFactory loggerFactory) // 引数追加
    {
        if (loggerFactory == null)
        {
            throw new System.ArgumentNullException(nameof(loggerFactory));
        }

        _logger = loggerFactory.CreateLogger<MyQnABot>();
        _logger.LogTrace("EchoBot turn start.");
        _accessors = accessors ?? throw new System.ArgumentNullException(nameof(accessors));
        _qnaMaker = qnaMaker; // 追加
    }

あとは OnTurnAsync で呼ぶだけ。

public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
    if (turnContext.Activity.Type == ActivityTypes.Message)
    {
        var answers = await _qnaMaker.GetAnswersAsync(turnContext);
        if (answers?.Any() ?? false)
        {
            // 答えがあった
            await turnContext.SendActivityAsync(answers[0].Answer, cancellationToken: cancellationToken);
        }
        else
        {
            // 答えがなかった
            await turnContext.SendActivityAsync("ごめんね…もっと勉強するわ…", cancellationToken: cancellationToken);
        }
    }
    else
    {
        await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
    }
}

動かしてみましょう。

f:id:okazuki:20181029154110p:plain

元にした QA が 一般法人向け Office 365 とは | よく寄せられる質問 なので、それっぽくなってます。

おまけ

わからない QA に対して何かしてみましょう。例えばわからない質問がきたら Microsoft Teams に飛ばすとか。MS Teams の投稿先にしたいチャネルでコネクタを追加します。Incoming Webhook があるので追加します。作成すると URL が貰えるのでコピーします。

本番では appsettings.json とか Azure にデプロイする場合はアプリケーション設定とかに URL を追加してそこから読むのがいいのですが、今回はさぼってハードコードします。マネしないでね。

public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
    if (turnContext.Activity.Type == ActivityTypes.Message)
    {
        var answers = await _qnaMaker.GetAnswersAsync(turnContext);
        if (answers?.Any() ?? false)
        {
            // 答えがあった
            await turnContext.SendActivityAsync(answers[0].Answer, cancellationToken: cancellationToken);
        }
        else
        {
            // 答えがなかった
            await turnContext.SendActivityAsync("ごめんね…もっと勉強するわ…", cancellationToken: cancellationToken);
            using (var c = new HttpClient())
            {
                await c.PostAsync("さっきゲットした URL をここに",
                    new StringContent(JsonConvert.SerializeObject(MessageFactory.Text($"{turnContext.Activity.From.Name} さんからわからない質問がありました。質問内容「{turnContext.Activity.Text}」")), Encoding.UTF8, "application/json"));
            }
        }
    }
    else
    {
        await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
    }
}

ちゃんとわからない質問がきたら Teams にメッセージが出るようになりました。ばっちり。

f:id:okazuki:20181029155049p:plain