kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

DSLでAlexaスキルを作れる"litexa"を試してみる

f:id:kun432:20191213025902p:plain

この記事はスマートスピーカー Advent Calendar 2019 15日目の記事です。

今回は、DSLでAlexaスキルが作れる"litexa"を触ってみたのでその話をします。

litexaとは?

litexaは、Alexaゲームチームが開発した、ゲームのようなマルチターンなAlexaスキルを開発することに特化したDSLドメイン固有言語)です。通常、Alexaスキルの開発は、node.jsやpythonJava等を使うのが一般的だと思いますが、それよりももっとシンプルな記述でスキル開発ができます。実際にAlexaゲームチームではこれを使って20以上のAlexaスキルをリリースしているそうです。

セットアップ

では早速やってみましょう。litexaではコードをコンパイルして最終的にはJavaScrip/Node.jsのコードを生成しますので、Node.js(8.11以上)がインストールされていることが前提になります。またデプロイも行う場合には、いろいろと追加で必要になるものがありますが、それは別途説明します。

まず、litexaのcoreパッケージをインストールします。coreパッケージは、コンパイラコマンドラインのツールセットなどを含んでいます。

$ npm install -g @litexa/core

これでlitexaコマンドが使えるようになりました。

プロジェクトの作成

ではプロジェクトの新規作成をしてみます。

$ litexa generate

質問に答えていきます。

? In which directory would you like to generate your project? (.)

プロジェクトをどこにディレクトリに生成するかを聞いてきます。デフォルトはカレントディレクトリです。今回は "litexa-sample" としました。

? Which language do you want to write your code in? (Use arrow keys)
❯ JavaScript 
  TypeScript 
  CoffeeScript 

おそらくコンパイル後に生成されるコードの言語だと思います。とりあえず今回は"JavaScript"にしておきます。

? How would you like to organize your code? (Use arrow keys)
❯ Inlined in litexa. (useful for small projects with no dependencies) 
  As modules. (useful for organizing code as npm packages) 
  As an application. (useful if you have external dependencies) 

コードをどのように構成するか?を聞いてきます。npmモジュールとして生成したりすることもできるのかな?ちょっとよくわからないので、"Inlined in litexa"を選択します。

? What would you like to name the project? (default: "litexa-sample") (litexa-sample)

プロジェクト名を聞いてきます。デフォルトはフォルダ名と同じになるようです。このまま進めます。

? What would you like the skill store title of the project to be? (default: "litexa-s
ample") (litexa-sample) 

スキルストアでの名前を聞いてきます。残念ながら日本語は通らないようです。このまま進めましょう。これで終わりです。

こんな感じでファイルが生成されます。

litexa-sample/
├── README.md
├── artifacts.json
├── litexa
│   ├── assets
│   │   ├── icon-108.png
│   │   └── icon-512.png
│   ├── main.litexa
│   ├── main.test.litexa
│   ├── utils.js
│   └── utils.test.js
├── litexa.config.js
└── skill.js

サンプルのコードが入った状態になっていますので、少しだけコードを見てみましょう。litexa/main.litexaを開きます。

# start with a launch state
launch
  # greet the user, with their name if we have it
  if @name
    say "Hello again, @name.
         Wait a minute... you could be someone else."
  else
    say "Hi there, human."
  # move on to the askForName state
  -> askForName

askForName
  # add this question to our next response
  say "What's your name?"
  # add an automatic re-prompt, in case the user says nothing
  reprompt "Please tell me your name?"
  -> waitForName

(snip)

なんとなく雰囲気はわかると思うのですが、一番上がlaunch requestです。名前があれば名前を言う、なければ聞く、という流れになっているので、永続セッションみたいなのもできそうです。Alexaスキルのやり取りを知っていると、シンプルで直感的ナノがわかると思います。

この状態でテストが実行できます。

$ litexa test
2019-12-12 2:02:14 AM running tests in /Users/kun432/repository/litexa-sample with no filter
test step 1/3 +0ms: happy path
test step 2/3 +28ms: asking for help
test step 3/3 +10ms: utils.test.js
test steps complete 3/3 40ms total
 
2019-12-12 2:02:15 AM
✔ 3 tests run, all passed (40ms)

Testing in region en-US, language default out of ["default"]
✔ test: happy path
2.  ❢   LaunchRequest     @ 3:01:05 PM
     ◖----------------◗ "Hi there, human. What's your name?" ... "Please tell me your name?"
4.  ❢  MY_NAME_IS_NAME   $name=Dude @ 3:02:10 PM
     ◖----------------◗ "Nice to meet you, Dude. It's a fine Thursday, isn't it? Bye now!" ... NO REPROMPT
  ◣  Voice session ended
8.  ❢   LaunchRequest     @ 3:03:15 PM
     ◖----------------◗ "Hello again, Dude. Wait a minute... you could be someone else. What's your name?" ... "Please tell me your name?"
10.  ❢  MY_NAME_IS_NAME   $name=Rose @ 3:04:20 PM
     ◖----------------◗ "Nice to meet you, Rose. It's a fine Thursday, isn't it? Bye now!" ... NO REPROMPT
  ◣  Voice session ended


✔ test: asking for help
16.  ❢   LaunchRequest     @ 3:01:05 PM
     ◖----------------◗ "Hi there, human. What's your name?" ... "Please tell me your name?"
18.  ❢ AMAZON.HelpIntent   @ 3:02:10 PM
     ◖----------------◗ "Just tell me your name please. I'd like to know it." ... "Please? I'm really curious to know what your name is."
20.  ❢  MY_NAME_IS_NAME   $name=Jimbo @ 3:03:15 PM
     ◖----------------◗ "Nice to meet you, Jimbo. It's a fine Thursday, isn't it? Bye now!" ... NO REPROMPT
  ◣  Voice session ended


✔ utils.test.js, 1 tests passed
  ✔ utils.test.js 'stuff to work'
  c! the arguments are 1,2,3
  t! today is Thursday

✔ 3 tests run, all passed (40ms)

テストは問題ないようです。ハッピーパスのテスト、ヘルプのテスト、あとは自分でユーティリティ的なヘルパーを作った場合などのユニットテストができるようです。

デプロイ

コードをいじりたいところですが、先にデプロイを試してみましょう。デプロイには、

  • Alexa開発者アカウント
  • AWSアカウント
  • AWS CLI
  • ASK CLI

が必要になりますので事前に準備しておいてください。IAMの設定とかも必要です。

litexaでは、デプロイ用のパッケージはcoreとは別になっていますので、npmインストールします。

$ npm install -g @litexa/deploy-aws

では、デプロイ用の設定を行います。litexa.config.jsを開いてください。

'use strict';

module.exports = {
    name: 'litexa-sample',
    deployments: {
        development: {
            module: '@litexa/deploy-aws',
            S3BucketName: null,
            askProfile: null,
            awsProfile: null
        }
    },
    extensionOptions: {}
};

上記のうち、S3BucketNameにS3のバケット名、askProfileにASK CLIのプロファイル名、awsProfileAWS CLIのプロファイル名を設定します。S3のバケットはなければ作成してくれます(ということはIAMでその権限が必要だということになります)。AWS CLIのプロファイル、ASK CLIのプロファイルは以下で確認できます。

AWS CLIのプロファイル確認。プロファイルを設定している方は--profileでプロファイル名を指定する必要があります。

$ aws configure list

ASK CLIのプロファイル確認。

$ ask init -l

私の環境だとこうなりました。

            S3BucketName: 'litexa-sample',
            askProfile: 'default',
            awsProfile: 'ask-cli-user'

では、デプロイしてみます。

$ litexa deploy

こんな感じで処理が行われます。

[deploy] +340ms skill build complete in 340ms
[deploy] +2ms beginning deployment of /Users/kun432/repository/litexa-sample
[deploy] +1140ms loaded deployment module from /Users/kun432/.nodebrew/node/v10.16.0/lib/node_modules/@litexa/deploy-aws
[lambda] +0ms deploying lambda
[lambda] +20ms loaded AWS profile ask-cli-user
[lambda] +350ms considering node_modules
[lambda] +0ms no package.json found at /Users/kun432/repository/litexa-sample/litexa/package.json, skipping node_modules
[lambda] +5ms fetching dynamoDB information
[assets] +413ms all asset conversion complete
[assets] +0ms deploying assets
[assets] +1ms loaded AWS profile ask-cli-user
[lambda] +52ms wrote readme
[lambda] +0ms wrote index.js
[lambda] +777ms dynamoDB table not found, creating litexa-sample_development_litexa_handler_state
[assets] +1992ms created S3 bucket litexa-sample
[assets] +1ms scanning assets, preparing hashes
[assets] +7ms scanned 2 assets in project
[assets] +1ms fetching S3 object metadata [0-1000]
[lambda] +1358ms verified dynamoDB table exists
[lambda] +0ms beginning zip archive
[lambda] +27ms Platform: darwin
[lambda] +1ms zip archiving complete in 28ms
[lambda] +2ms zip SHA: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
[lambda] +0ms fetching Lambda litexa-sample_development_litexa_handler configuration
[assets] +793ms 2 assets need uploading
[assets] +1ms uploading icon-108.png -> litexa-sample/development/default/icon-108.png [9edf69cc]
[assets] +1ms uploading icon-512.png -> litexa-sample/development/default/icon-512.png [c5618ad1]
[assets] +1ms 0 assets remaining in queue
[lambda] +779ms creating Lambda function litexa-sample_development_litexa_handler
[lambda] +74ms ensuring IAM role litexa_handler_lambda
[assets] +1077ms segment uploaded in 1077ms
[assets] +0ms asset deployment complete in 3874ms
[lambda] +876ms creating IAM role litexa_handler_lambda
[lambda] +1684ms pulling attached policies for IAM role litexa_handler_lambda
[lambda] +913ms adding policy AWSLambdaBasicExecutionRole
[lambda] +2ms adding policy AmazonDynamoDBFullAccess
[lambda] +2ms adding policy CloudWatchFullAccess
[lambda] +1ms reconciling IAM role policy differences
[lambda] +10860ms waiting for IAM role to be ready
[lambda] +807ms IAM Role litexa_handler_lambda ready
[lambda] +1947ms creating LIVE alias for Lambda function litexa-sample_development_litexa_handler
[lambda] +2087ms pulling existing policies
[lambda] +547ms adding policies to Lambda litexa-sample_development_litexa_handler
[lambda] +2252ms Created Cloudwatch log group for lambda
[lambda] +846ms Updated CloudWatch retention policy to 30 days
[lambda] +1ms lambda deployment complete in 26270ms
[manifest] +0ms beginning manifest deployment
[manifest] +0ms loading skill.json
[manifest] +2ms building skill manifest
[manifest] +2ms no skillId found in artifacts, creating new skill
[manifest] +4594ms in progress skill id amzn1.ask.skill.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[manifest] +0ms waiting for skill status after create-skill
[manifest] +3080ms waiting for skill status after create-skill
[manifest] +1991ms create-skill succeeded
[manifest] +2015ms en-US model mismatch
[manifest] +0ms en-US model update beginning
[manifest] +2471ms waiting for model en-US status after update-model
・・・
[manifest] +3128ms waiting for model en-US status after update-model
[manifest] +2793ms model update-model succeeded
[manifest] +0ms en-US model update complete, total time 35659ms
[manifest] +0ms ensuring skill is enabled for testing
[manifest] +2963ms manifest deployment complete, 48298ms
deploying model
[deploy] +74744ms deployment complete in 76226ms

DynamoDBのテーブル作成、lambdaへのデプロイ、IAMロールの設定、S3バケットへのアップロード、そしてAlexa開発者コンソールへの登録、などが一気に行われているようです。AWSとADCの設定がどうなっているか見てみましょう。

まずADC側。

お、スキルができていますね。特に設定がなければデフォルトは英語のようです。

f:id:kun432:20191212152328p:plain

対話モデルもできています。

f:id:kun432:20191212152340p:plain

エンドポイントの設定もされていますね。us-eastにlambda の関数が作成されているようです。

f:id:kun432:20191212152349p:plain

次はAWS側。まず、DynamoDB。ユーザのセッション情報を保持しておくためのもののようです。

f:id:kun432:20191212183156p:plain

次にS3。とりあえずスキルアイコンが入ってます。

f:id:kun432:20191212183248p:plain

Lambda。ランタイムがNode.8.10なのはいただけないですが、どっかで設定できるのかな?

f:id:kun432:20191212183300p:plain

Lamdbaのコードはこんな感じです。ask-sdkとは全然違いますね。ざっと見た限り、いろいろ実装されてて、スキル内課金とかもできるように見えます。

f:id:kun432:20191212183311p:plain

では実際に動かしてみましょう。Alexa開発者コンソールのシミュレータで試してみます。ロケールは英語なのでその点にご注意ください。

f:id:kun432:20191212183712p:plain

はい、ちゃんと永続セッションも動いてます。

コード

改めて少しコードを見てみます。スキルのコードは、main.litexaになります。

# start with a launch state
launch
  # greet the user, with their name if we have it
  if @name
    say "Hello again, @name.
         Wait a minute... you could be someone else."
  else
    say "Hi there, human."
  # move on to the askForName state
  -> askForName

askForName
  # add this question to our next response
  say "What's your name?"
  # add an automatic re-prompt, in case the user says nothing
  reprompt "Please tell me your name?"
  -> waitForName

waitForName
  # do nothing when we start this state, and go nowhere; this ends the handler,
  # sends our response, and opens the microphone to listen

  when "my name is $name"
    or "call me $name"
    or "$name"
    with $name = AMAZON.US_FIRST_NAME
    # if user answers with a name from our names list

    # save the name in the permanent database
    @name = $name

    say "Nice to meet you, $name. It's a fine {todayName()}, isn't it?"
    -> goodbye

  when AMAZON.HelpIntent
    # if user says something that maps to the built-in help intent
    say "Just tell me your name please. I'd like to know it."
    reprompt "Please? I'm really curious to know what your name is."
    # loop back to waiting for a name
    -> waitForName

  otherwise
    # if user says something that maps to neither of the above intents
    say "Sorry, I didn't understand that."
    # loop back to asking for the name
    -> askForName

goodbye
  say "Bye now!"
  # we're done with the skill; end the skill session
  END

litexaでは、"state" という単位で処理を書いて、stateからstateに行き来することで、フローを作るような感じになっています。上記の例だと、こんな感じの処理になっています。

  • launch
    • 起動時に実行される。
    • 名前がすでに保存されていればその名前で挨拶する。
    • 保存されていなければ名前を聞くためにaskForNameに遷移する。
    • 名前があってもaskForNameに遷移する。
  • askForName
    • 名前を聞く。
    • 名前が言われなければ再度聞く(reprompt)。
    • waitForNameに遷移する。
  • waitForName
    • 名前を受け取り、永続セッションに保存する。
    • 名前を受け取ったらgoodbyに遷移する。
    • Help.Intentが呼ばれたらヘルプメッセージを発話して、再度waitForNameに遷移する。
    • どれにも合致しなければ再度名前を聞くためにaskForNameに遷移する。
  • goodby
    • さよならの挨拶をして終わる。

とてもわかり易いですね。ざっくり見ただけでもこんな感じでかけることがわかります。

  • sayでAlexaが発話、repromptで再度発話
  • -> でstateを遷移
  • @が永続セッションの変数、$がスロット
  • インデントでブロックが定義される。If else
  • Whenでインテント。サンプル発話を条件っぽく並べて、$でスロット、withでスロットタイプを定義。$を@に代入するだけで永続セッションとして扱える。
  • OtherwiseがFallbackIntentっぽい感じ。
  • ENDでセッション終了

日本語

せっかくなので日本語で動かせるかいろいろと試してみたのですが、どうやら日本語はうまく扱えないようで、SMAPIの登録でことごとくエラーになります・・・

  • skill.jsの中で日本語のロケール設定を追加するのですが、日本語がうまく処理できないため、呼び出し名の登録でエラーになる。
  • ja-JP用のmain.litexaを別途用意するも、インテント名はサンプル発話から自動で生成するようになっているため、日本語のサンプル発話だとうまくインテント名を作れない。
  • インテント名を個別に設定することで回避するも、日本語のサンプル発話をそもそもうまく処理できない。

このあたりは今後に期待ですね。ちなみに、ドキュメントを参照しながら、もし正しく動作するならばこうなるだろう、という仮定で、日本語にする場合の手順を以下にまとめておきます。

(もし日本語を正しく扱えるようになった場合の)多言語スキルの設定の仕方

litexaでは複数のロケールを一度で扱えるようになっています。まず、skill.jsを開いてみます。

'use strict';

module.exports = {
    manifest: {
        publishingInformation: {
            isAvailableWorldwide: false,
            distributionCountries: ['US'],
            distributionMode: 'PUBLIC',
            category: 'GAMES',
            testingInstructions: 'replace with testing instructions',
            locales: {
                'en-US': {
                    name: 'Litexa Sample',
                    invocation: 'litexa sample',
                    summary: 'replace with brief description, no longer than 120 characters',
                    description: 'Longer description, goes to the skill store. Line breaks are supported.',
                    examplePhrases: [
                        'Alexa, launch Litexa Sample',
                        'Alexa, open Litexa Sample',
                        'Alexa, play Litexa Sample',
                    ],
                    keywords: [
                        'game',
                        'fun',
                        'single player',
                        'modify this list as appropriate'
                    ]
                }
            }
        },
        privacyAndCompliance: {
            allowsPurchases: false,
            usesPersonalInfo: false,
            isChildDirected: false,
            isExportCompliant: true,
            containsAds: false,
            locales: {
                'en-US': {
                    privacyPolicyUrl: 'https://www.example.com/privacy.html',
                    termsOfUseUrl: 'https://www.example.com/terms.html'
                }
            }
        }
    }
};

これがスキルのマニフェストになりますのでこれを編集します。

distributionCountriesにJPを追加。

            distributionCountries: ['US', 'JP'],

localesに日本語の設定を追加。

            locales: {
                'en-US': {
                  ・・・
                },
                'ja-JP': {
                    name: 'Litexaのサンプル',
                    invocation: 'リテクサのサンプル',
                    summary: 'litexaのサンプルです。',
                    description: 'litexaのサンプルです。',
                    examplePhrases: [
                        'アレクサ, リテクサのサンプルを起動して',
                        'アレクサ, リテクサのサンプルを開いて',
                        'アレクサ, リテクサのサンプルをスタートして',
                    ],
                    keywords: [
                        'litexa',
                        'サンプル',
                    ]
                }
            }

プライバシーポリシーの箇所も同じく。

        privacyAndCompliance: {
            locales: {
                'en-US': {
                    privacyPolicyUrl: 'https://www.example.com/privacy.html',
                    termsOfUseUrl: 'https://www.example.com/terms.html'
                },
                'ja-JP': {
                    privacyPolicyUrl: 'https://www.example.com/privacy.html',
                    termsOfUseUrl: 'https://www.example.com/terms.html'
                }
            }
        }

次にスキル。デフォルトのen-US以外は個別にディレクトリを作成してその中にファイルを置いていきます。これにより各ロケールごとにデフォルトのen-USがオーバーライドされます。

litexa-sample/
├── README.md
├── artifacts.json
├── litexa
│   ├── assets
│   │   ├── icon-108.png
│   │   └── icon-512.png
│   ├── languages ★作成
│   │   ├── ja-JP ★この配下が日本語用になる
│   │   │   ├── assets 
│   │   │   │   └── icon-512.png
│   │   │   │   └── icon-512.png
│   │   │   ├── main.litexa
│   │   │   └── utils.js
│   ├── main.litexa
│   ├── main.test.litexa
│   ├── utils.js
│   └── utils.test.js
├── litexa.config.js
└── skill.js

日本語のmain.litexaはきっとこんな感じです。

# start with a launch state
launch
  if @name
    say "こんにちは、またお会いしましたね、@name さん。
         ちょっと待って下さい。もしかして別の人ですか?"
  else
    say "こんにちは、初めてお会いしますね。"
  -> askForName

askForName
  say "あなたの名前を教えてもらえますか?"
  reprompt "あなたの名前を教えてもらえますか?"
  -> waitForName

waitForName
  when MY_NAME_IS_INTENT
    or "私の名前は $name です。"
    or "$name と呼んでください。"
    or "$name"
    with $name = AMAZON.FirstName

    @name = $name

    say "はじめまして、 $name さん。今日は気持ちのいい {todayName()} 、ですね。"
    -> goodbye

  when AMAZON.HelpIntent
    say "あなたの名前を教えて下さい。"
    reprompt "あなたの名前を教えて下さい。"
    -> waitForName

  otherwise
    say "ごめんなさい、うまく聞き取れませんでした。"
    -> askForName

goodbye
  say "バイバイ。"
  END

もう一箇所、これはあくまでもサンプルコードに限った修正ですが、utils.jsも修正します。この部分はJavaScriptですが、main.litexaから{todayName()}で呼び出されています。自分で直接書いたコードを呼び出すこともきるんですね。

function todayName() {
  let day;
  day = (new Date).getDay();
  return ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'][day];
}

こんな感じに多分なるだろうという予測ですのであしからずご了承ください。日本語の不具合についてはissue立てておきたいと思います。

まとめ

コードを見てもらうと分かる通り、非常に直感的に書けます。スロットや永続セッションなんかはめちゃめちゃ楽ちんです。逆に複雑なことをやりたい場合も、直接コードを書いて拡張できるし、ドキュメントにはスキル内課金やAPL、ガジェット連携あたりも記載されていて、Alexaでできることは結構カバーされている印象です。

こういうやりたいことだけをなるだけシンプルに・かんたんに実現する、という考え方は、Skill Flow Builderあたりもそうですし、Voiceflowなどのノンコーディングツールにも通じるところがあります。日本語で動かない、などまだこなれてない感はありますが、こういった取り組みでスキル開発のハードルが下がればいいなと思います。

興味がある方はドキュメントを御覧ください。