himanago

Azure・C#などのMS系技術やLINE関連技術など、好きな技術について書くブログ

クロスプラットフォームスマートスピーカースキル開発ライブラリ「XPlat.VUI」をリリースしました

はじめに

技術書典7 で本を出しました。この振り返りはまたのちほどしますが…
techbookfest.org

※電子版はここに置いてます:

Microsoft AzureでつくるクロスプラットフォームAIアシスタントスキル - himanago - BOOTH

これのなかで、C#Googleアシスタント、Alexa、Clova に対応したクロスプラットフォームなスキルを効率よく開発できるようになるライブラリを作る話を書きました。
そのライブラリを少し修正し、NuGet公開してみました。

XPlat.VUI

NuGetはこちら。

www.nuget.org

ソースコードはここ。

github.com

READMEにあるようなかんじでシンプルに実装できます。

できること

Googleアシスタント、Alexa、Clova に対応したスキル開発における処理の共通化です。
HTTPのリクエストをそのまま渡すだけで、各プラットフォームの起動およびインテントリクエストに対応したレスポンスを作って返せます。
インテントに設定されたスロット(エンティティ)も読み取れ、それに応じた処理を行うことも可能です。
また、レスポンスにはテキストとmp3などの音源(効果音など)を使えます。
GoogleアシスタントとAlexaは内部でSSMLに変換している)

実装例

おそらくもっとも有名な3プラットフォーム対応のC#+Azure Functions製スキルであろう、以下のスキルを XPlat.VUI で再実装してみました。

元ネタ

ちょまどさん(@chomado)のデモアプリです。
Tech Summit 2018 や LINE DEVELOPER DAY 2018 で披露された「最新のブログ記事」スキル。 github.com

XPlat.VUI 版

これを Fork して改造してみました。動きは同じです。

github.com

commit差分

コード内容

この「XPlat.VUI」ライブラリは、

  • AssistantBase を継承したクラスを作ってそこにスキルの処理を実装
  • それをDIしてエンドポイントの関数で呼ぶ

という流れで実装します。

なお Azure Functions で DI するには

が必要なので NuGet で入れておきましょう。

BlogAssistant

AssistantBase の継承クラスです。

この「最新のブログ記事」スキルの場合、ブログ記事の RSS を見に行く ChomadoBlogService というサービスクラスがあって、これをスキルの本体が使ってます。

また、プラットフォームが Clova の場合のみ、結果を連携するLINE Bot にも送るということをやっています。そこで使うのは LineMessagingClient
せっかくなので、これらも DI したい。コンストラクタインジェクションされるようにしておきます。

中身としては起動リクエストとインテントリクエストに対応したオーバーライドメソッドを作るだけ。
これはエンドポイント関数を指定通りにつくれば自動で呼ばれるようになります。

using Line.Messaging;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TechSummit2018.ServerlessSmartSpeaker.Services;
using XPlat.VUI;
using XPlat.VUI.Models;

namespace SmartSpeakerGetLatestArticle
{
    public class BlogAssistant : AssistantBase
    {
        private static string IntroductionMessage { get; } = "こんにちは、LINEデベロッパー・デイのデモアプリです。最新記事を教えてと聞いてください。";
        private static string HelloMessage { get; } = "こんにちは、ちょまどさん!";
        private static string ErrorMessage { get; } = "すみません、わかりませんでした!";

        private ChomadoBlogService Service { get; }
        private ILineMessagingClient MessagingClient { get; }

        public BlogAssistant(ChomadoBlogService service, ILineMessagingClient messagingClient)
        {
            Service = service;
            MessagingClient = messagingClient;
        }

        protected override Task OnLaunchRequestAsync(Dictionary<string, object> session, CancellationToken cancellationToken)
        {
            Response
                .Speak(IntroductionMessage)
                .KeepListening();
            return Task.CompletedTask;
        }

        protected override  async Task OnIntentRequestAsync(
            string intent, Dictionary<string, object> slots, Dictionary<string, object> session, CancellationToken cancellationToken)
        {
            switch (intent)
            {
                case "HelloIntent":
                    Response.Speak(HelloMessage);
                    break;

                case "AskLatestBlogTitleIntent":
                    var blog = await Service.GetLatestBlogAsync();

                    if (blog != null)
                    {
                        Response.Speak($"ちょまどさんのブログの最新記事は {blog.Title} です。");

                        // Clova の場合は LINE にプッシュ通知する
                        if (Request.CurrentPlatform == Platform.Clova)
                        {
                            _ = MessagingClient.PushMessageAsync(
                                to: Request.UserId,
                                messages: new List<ISendMessage>
                                {
                                    new TextMessage($"ちょまどさんの最新記事はこちら!"),
                                    new TextMessage($@"タイトル『{blog.Title}』
{blog.Url}"),
                                });
                        }
                    }
                    else
                    {
                        Response.Speak("ちょまどさんのブログの最新記事は、わかりませんでした。");
                    }
                    break;

                default:
                    Response.Speak(ErrorMessage);
                    break;
            }
        }
    }
}

Clova のときだけ LINE にメッセージを送る、というのは
if (Request.CurrentPlatform == Platform.Clova) という条件で書けます。
こんなかんじでプラットフォーム固有の処理も書きやすく工夫してます。

DI(Startup)

Startup で DI します。

using Line.Messaging;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using System;
using TechSummit2018.ServerlessSmartSpeaker.Services;
using XPlat.VUI;

[assembly: FunctionsStartup(typeof(SmartSpeakerGetLatestArticle.Startup))]
namespace SmartSpeakerGetLatestArticle
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services
                .AddSingleton<ILineMessagingClient, LineMessagingClient>(_ =>
                     new LineMessagingClient(Environment.GetEnvironmentVariable("LineMessagingApiSecret")))
                .AddSingleton<ChomadoBlogService>()
                .AddAssistant<IAssistant, BlogAssistant>();
        }
    }
}

LineMessagingClientChomadoBlogServiceBlogAssistant へ注入され、BlogAssistant はエンドポイント関数のクラスへ注入されます。

ちなみに、AddAssistant メソッドは XPlat.VUI による拡張メソッド。

実は本を書いたときは AddAssistant の 2番目の型引数(BlogAssistantにあたる部分)にnew制約が付いていて、この拡張メソッド内で引数なしコンストラクタが呼ばれる前提でした。
つまり、このクラスへコンストラクタインジェクションできない(=引数ありコンストラクタが呼ばれない)状態だったということ。

今回みたいに LineMessagingClientChomadoBlogService を DI できたほうが絶対楽なので、new制約はないほうがいい。

この「最新のブログ記事」スキルの XPlat.VUI版を作って、かずきさん(@okazuki)にレビューいただいた1「new制約のせいで拡張性がない」ことをいまさらながら実感。
たしかにnew制約ついてたらだめだわ。CEK.CSharp も直さないと2

エンドポイント関数

ここがおそろしくシンプルになります。
DIをする場合は static じゃなくするのがポイント。

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using XPlat.VUI;
using XPlat.VUI.Models;

namespace TechSummit2018.ServerlessSmartSpeaker
{
    public class SmartSpeakerEndpoints
    {
        private IAssistant Assistant { get; }

        public SmartSpeakerEndpoints(IAssistant assistant)
        {
            Assistant = assistant;
        }

        [FunctionName("Line")]
        public async Task<IActionResult> Line(
            [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, ILogger log)
        {
            var response = await Assistant.RespondAsync(req, Platform.Clova);
            return new OkObjectResult(response.ToClovaResponse());
        }

        [FunctionName("GoogleHome")]
        public async Task<IActionResult> GoogleHome(
            [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, ILogger log)
        {
            var response = await Assistant.RespondAsync(req, Platform.GoogleAssistant);
            return new OkObjectResult(response.ToGoogleAssistantResponse());
        }

        [FunctionName("Alexa")]
        public async Task<IActionResult> Alexa(
            [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, ILogger log)
        {
            var response = await Assistant.RespondAsync(req, Platform.Alexa);
            return new OkObjectResult(response.ToAlexaResponse());
        }
    }
}

基本的にこれだけになります。

実装のまとめ

DIとエンドポイントはほぼ定型でいけるので、テンプレート化したらもっと楽できるだろうなと思っています。

なので、テンプレートから枠を作ったあとに開発者がやることは AssistantBase を継承したクラスにおける起動・インテントリクエストをさばく処理の実装だけ。
(プラットフォームごとのリクエスト・レスポンス形式の差異などは XPlat.VUI で吸収してくれるからロジック部分を書くだけでよい)

残りの課題

セッションデータの保持が実装できていないのでそこを実装したいなと思ってます。

その他サンプル

書籍執筆時に作ったスキル例。
いま見返すとちょっとうまくないな…という部分もあるのであとで直すかもです。

サイコロをいくつか転がして合計を教えてもらうスキル(スロットを使った例)

github.com

数を裏で数えるだけのスキル(Durable Functionsを使った例)

github.com

おわりに

これを使ったハンズオンなんかやってみたいなーと思い始めました。
XPlat.VUI は思い切って(最大ではない)公約数でライブラリ化したので、シンプルなスキルなら一瞬でできるようになるはず。
簡単なスキルを作りたいときの選択肢として C# + Azure Functions を挙げてもらえるようにしたいな…と思うので、もう少し育てて何かイベント企画できたらなと思います。


  1. 入稿後に見ていただいたので、書籍のおまけとして「かずきさんレビュー」を付けました。なんて自虐的なコンテンツ。でもこれがかなりウケた。

  2. もともと CEK.CSharp のほうで同じく DI させるための拡張メソッドである AddClova の型引数にnew制約をつけていた名残。これも実装方法がうまくなかった(し、まずさに気づけなかった)。