麻雀の符計算を Alexa にお願いするまで

## はじめに

Alexaの日本展開が始まり早数ヶ月。
さらに先月招待制から一般販売も開始されたこともあり、そろそろ私自身もスキルを作らないと！と思い作り始めました。
<br>

合間を縫って制作を開始し、ようやく先日、「じゃらじゃら　符計算」スキルが公開されました。
<br>
[Amazon Alexa 音声デザインガイド](https://developer.amazon.com/ja/designing-for-voice/)に沿ってどのように制作していったかのお話です。

---
## なにができる？

麻雀の符計算した点数を教えてくれます。
<br>

ユーザ：アレクサ、じゃらじゃらで親の30符（プ）2翻（リャンハン）の点数を教えて。
アレクサ：30符2翻の点数は、ツモは1000点オール。ロンは2900点です。

---
## アジェンダ

* デザインプロセス
* 開発
* スキルビルダー

---
## デザインプロセス

> * 目的とユーザーストーリーの設定
> * 台本の作成
> * 対話フローの作成
> * スキル構築のための準備

---
### 目的とユーザーストーリーの設定

シンプルに決めました。
麻雀で和了した時に点数が知りたい！

---
### 台本の作成

> * やり取りを簡潔にする。
> * 書き言葉ではなく話し言葉で書く。
> * フレーズの繰り返しを避ける。
> * ユーザーが情報を提供すべき箇所を示す。
> * 何をすべきか、何が起こるかについてユーザーの知識を前提にしない。
> * 選択肢は明確に示す。
> * 一般的に一度に提示する選択肢は多くても3つまでにする。
> * ユーザーに求める情報は一度に１つにする。

以上のことを考慮し、台本を作成します。

特に意識したところを３つあげるとすれば、以下でしょうか。

* やり取りを簡潔にする。
* 選択肢は明確に示す。
* ユーザーに求める情報は一度に１つにする。

---
台本作成には、PlantUMLを利用しました。

```text
@startuml じゃらじゃら
actor "ユーザ" as User
User -> Alexa: じゃらじゃらを開いて
User <-- Alexa: 点数を教えてと、言ってください
note left User
  役割、符、翻を指定しない場合
end note
User -> Alexa: 点数を教えて
User <-- Alexa: 親ですか？子ですか？
User -> Alexa: 子
User <-- Alexa: 何符ですか？
User -> Alexa: 30符
User <-- Alexa: 何翻ですか？
User -> Alexa: サンハン
User <-- Alexa: 30符3翻の点数は、ツモは親が2000点、子が1000点です。ロンは3,900点です。
@enduml
```

---
図で表すとこんな感じです。

<img width="768" alt="seuqnece-diagram-30fu-3han.pu" src="https://qiita-image-store.s3.amazonaws.com/0/228453/dafebff3-9462-0c26-2bc1-835be0f967d7.png">


---
### 対話フローの作成

ここは、今回あまり考えませんでした。
点数計算する上でスロットを３つ収集しなければなりませんでしたが、ダイアログモデルを利用することで簡潔になるだろうと踏んでいた為です。

---
### スキル構築のための準備

機能がシンプルなので迷わず、ざっくり決めました。

#### インテント

* 必須インテント
    * LaunchRequest（起動リクエスト）
    * Unhandled（想定外の動作）
    * AMAZON.CancelIntent（キャンセル時、終了する）
    * AMAZON.StopIntent（ストップ時、終了する）
    * AMAZON.HelpIntent（何と言えば良いか、教えてくれる）
* 点数計算インテント
    * CalculatePointIntent（点数を計算して教えてくれる）

---
#### スロット

* 親または子（RoleType）
* 符（AMAZON.NUMBER)
* 翻（HanNumber）

特に翻はいろんな呼び方があるので、シノニム定義を利用して対応しました。
例えば、１翻は

* イーハン
* イッパン
* イチハン
* イチパン
* イチ

---
## 開発

以前、投稿した[TypeScript を使って Alexa Custom Skills を作ろう](https://qiita.com/daisukeArk/items/ac45f10d2fc8fe8bdc5a)を基に多少の変更を加えながら実装していきました。

---
### ハンドラメソッドのインタフェース定義

作り始める前にまずは、ハンドラにどんな機能（インテント）が必要かを定義します。
Mapped Typesを利用しました。

```typescript
import * as Alexa from 'alexa-sdk';

/**
 * ハンドラメソッド型
 */
export type HandlerMethod = (this: Alexa.Handler<any>) => void;

/**
 * 暗黙的インテント
 */
export interface IImplicitIntents {
  LaunchRequest: void;
  Unhandled: void;
}

/**
 * 必須インテント
 */
export interface IRequiredIntents {
  'AMAZON.CancelIntent': void;
  'AMAZON.HelpIntent': void;
  'AMAZON.StopIntent': void;
}

/**
 * デフォルトハンドラインテント種類
 */
export interface IDefaultHandlerIntents {
  CalculatePointIntent: void;
}

/**
 * デフォルトハンドラインテント種類
 */
export type defaultHandlerIntentType =
  IImplicitIntents &
  IRequiredIntents &
  IDefaultHandlerIntents;

export type defaultHandlerType = {[Parameter in keyof defaultHandlerIntentType]: HandlerMethod};
```

---
### ハンドラの実装

前項で作成したTypeを割当て、ハンドラを実装していきます。
ここは、ファサードクラスを準備したかったですがファクトリから各インテントを直接呼び出しています。

```typescript
import * as Alexa from 'alexa-sdk';
import { IntentFactory } from '../factories/intent-factory';
import * as HandlerMethodTypes from './handler-method-type';

/**
 * ハンドラ
 */
export const handler: HandlerMethodTypes.defaultHandlerType = {
  /**
   * 起動リクエスト インテント
   * @param this ハンドラコンテキスト
   */
  LaunchRequest(this: Alexa.Handler<any>): void {
    // レスポンス設定
    this.response
      .speak(<any>this.t('WELCOME'))
      .listen(<any>this.t('HELP_MESSAGE'));

    // レスポンス生成
    this.emit(':responseReady');
  },
  /**
   * 点数計算 インテント
   * @param this ハンドラコンテキスト
   */
  CalculatePointIntent(this: Alexa.Handler<any>): void {
    IntentFactory.CalculatePointIntent.execute(this);
  },
  /**
   * ヘルプ インテント
   * @param this ハンドラコンテキスト
   */
  'AMAZON.HelpIntent'(this: Alexa.Handler<any>): void {
    IntentFactory.HelpIntent.execute(this);
  },
  /**
   * キャンセル インテント
   * @param this ハンドラコンテキスト
   */
  'AMAZON.CancelIntent'(this: Alexa.Handler<any>): void {
    IntentFactory.StopIntent.execute(this);
  },
  /**
   * 停止 インテント
   * @param this ハンドラコンテキスト
   */
  'AMAZON.StopIntent'(this: Alexa.Handler<any>): void {
    IntentFactory.StopIntent.execute(this);
  },
  /**
   * 未ハンドル インテント
   * @param this ハンドラコンテキスト
   */
  Unhandled(this: Alexa.Handler<any>): void {
    IntentFactory.UnHandledIntent.execute(this);
  }
};
```

---
### インテントの実装

インテントでは、ハンドラから渡されたコンテキストを利用してレスポンスを返します。
ブライベートメソッドは省略してます。

```typescript
import * as Alexa from 'alexa-sdk';
import * as Util from 'util';
import * as Enums from '../enums';
import { LoggerFactory } from '../helpers/logger-factory';
import * as Services from '../models/services';
import { CalculatePointUtterance as Utterance } from '../utterances/calculate-point-utterance';
import { IntentBase } from './intent-base';

/**
 * 点数計算 インテントクラス
 */
export class CalculatePointIntent extends IntentBase<Utterance> {
  /**
   * 点数計算 サービス
   */
  private calculateService: Services.CalculateService;

  /**
   * コンストラクタ
   * @param calculateService 点数計算サービス
   * @param utterance 発話
   */
  constructor(
    calculateService: Services.CalculateService,
    utterance: Utterance
  ) {
    super(utterance);

    this.calculateService = calculateService;
  }

  /**
   * アクション
   * @param context ハンドラコンテキスト
   */
  public execute(context: Alexa.Handler<Alexa.IntentRequest>) {
    // ログ出力
    LoggerFactory.instance.trace(Util.inspect(context.event.request, { depth: null }));

    // スロット収拾が完了したか判定
    if (context.event.request.dialogState !== 'COMPLETED') {
      // 完了していない場合、Alexaに委任する
      context.emit(':delegate');
      return;
    }

    // 必要情報が未定義か判定
    if (
      context.event.request.intent === undefined
    ) {
      // 未ハンドルインテントへ誘導
      context.emitWithState('Unhandled');
      return;
    }

    // 役割、符、翻を取得
    const roleType = this.getRole(context.event.request.intent.slots.Role);
    const fuNumber = this.getFu(context.event.request.intent.slots.Fu);
    const hanNumber = this.getHan(context.event.request.intent.slots.Han);

    // 役割が未定義か判定
    if (roleType === undefined) {
      // レスポンス設定
      context.response
        .speak(<any>context.t('UNHANDLED_MESSAGE') + <any>context.t('HELP_ROLE_TYPE') + <any>context.t('RETRY'))
        .listen(<any>context.t('HELP_MESSAGE'));

      // レスポンス生成
      context.emit(':responseReady');
      return;
    }

    // 符が未定義 もしくは ２０符未満 もしくは １１０を超える か判定
    if (fuNumber === undefined || isNaN(fuNumber) || fuNumber < 20 || fuNumber > 110) {
      // レスポンス設定
      context.response
        .speak(<any>context.t('UNHANDLED_MESSAGE') + <any>context.t('HELP_FU_NUMBER') + <any>context.t('RETRY'))
        .listen(<any>context.t('HELP_MESSAGE'));

      // レスポンス生成
      context.emit(':responseReady');
      return;
    }

    // 翻が未定義 もしくは １３翻を超える か判定
    if (hanNumber === undefined || hanNumber > 13) {
      // レスポンス設定
      context.response
        .speak(<any>context.t('UNHANDLED_MESSAGE') + <any>context.t('HELP_HAN_NUMBER') + <any>context.t('RETRY'))
        .listen(<any>context.t('HELP_MESSAGE'));

      // レスポンス生成
      context.emit(':responseReady');
      return;
    }

    // 点数計算
    const result = this.calculateService.calculate(fuNumber, hanNumber);

    // 点数計算が失敗した場合
    if (result === undefined) {
      // レスポンス設定
      context.response
        .speak(<any>context.t('ERROR_CALCULATE'))
        .listen(<any>context.t('HELP_MESSAGE'));

      // レスポンス生成
      context.emit(':responseReady');
      return;
    }

    // ログ出力
    LoggerFactory.instance.trace(Util.inspect(result, { depth: null }));

    // 発話取得
    const utteranceResult = this.utterance.respond(context, roleType, result);

    // レスポンス設定
    context.response
      .speak(utteranceResult.speech)
      .cardRenderer('点数のご案内', utteranceResult.cardContent, { smallImageUrl: '', largeImageUrl: '' });

    // レスポンス生成
    context.emit(':responseReady');
  }
}
```

ダイアログモデルを利用し、スロット収集はすべてAlexaに委任しています。

ダイアログモデルのおかげで、コードがすっきりしてとても見やすいです。

---
### 発話の実装

発話では、以下から会話を組み立てています。

* コンテキスト
* 親 or 子の種別
* 点数計算結果（符、翻、点数を持っている）

```typescript:language-strings.ts
'ANSWER': '<sub alias="%s">%s符</sub><break time="100ms"/><sub alias="%s">%s翻</sub>の点数は、',
'ANSWER_LIMIT': '<sub alias="%s">%s符</sub><break time="100ms"/><sub alias="%s">%s翻</sub>の点数は、<sub alias="%s">%s</sub>です。',
'ANSWER_PARENT_TSUMO': 'ツモは<break time="100ms"/>%s点オール。',
'ANSWER_CHILD_TSUMO': 'ツモは親が<break time="100ms"/>%s点、子が<break time="100ms"/>%s点です。',
'ANSWER_LON': 'ロンは<break time="100ms"/>%s点です。',
```

聞き取りやすいようにSSMLで少し工夫（インターバル入れただけ）しました。
他にもいろんな制御ができるので、聞き取りやすい音声に改善していきたいですね！

[音声合成マークアップ言語（SSML）のリファレンス](https://developer.amazon.com/ja/docs/custom-skills/speech-synthesis-markup-language-ssml-reference.html)

---
## スキルビルダー

スキルビルダーの設定をします。

[対話モデルとダイアログモデルの作成の手順の概要](https://developer.amazon.com/ja/docs/custom-skills/use-the-skill-builder-beta-to-define-intents-slots-and-dialogs.html#high-level-steps-to-create-the-interaction-model-and-dialog-model)

---
### 発話サンプル

> １インテント当たりのサンプル発話数は３０個以上を目安としてください。

発話サンプルには可能な限り、多くのパターンを登録しましょう。

「じゃらじゃら」では、

`{Role} の {Fu} 符 {Han} 翻の点数を教えて`をベースに色んなパターンを登録していきました。

例えば、

* 〜〜が知りたい
* 〜〜教えて
* 〜〜の点数を教えて
* 〜〜の点数教えて
* 〜〜の点数を調べて
* 〜〜の点数調べて

などなど

---
### インテントスロット

点数計算で必要なスロットは以下の３つです。

* 親　or 子
* 符
* 翻

![intent-slot.png](https://qiita-image-store.s3.amazonaws.com/0/228453/15fead7f-81cd-5cb2-9d46-d0aeccd86ad4.png)

---
すべてのスロット収集が必須なので、以下のような感じに設定します。

![intent-slot-role-type.png](https://qiita-image-store.s3.amazonaws.com/0/228453/03082eb9-f4f2-6ca8-f1c4-80b662c1b0f9.png)

---
最終的にじゃらじゃらでは、１４６個の発話サンプルを登録しました。

<img width="1305" alt="utterances.png" src="https://qiita-image-store.s3.amazonaws.com/0/228453/2ae9a023-4d83-1f11-51d2-bd48a5a357a8.png">

---
## まとめ

こうして、Alexaがあがった時の点数を教えてくれるようになりました！
Alexaを間に挟むことによって、点棒支払い時のトラブル（不払いなど）が防げるはず！

まだまだ米国と比べるとスキル数は圧倒的に差がありますが、一般公開も始まったことでより多くのスキルが世に出てくるのが楽しみです！

じゃらじゃらスキルについては、酷評お待ちしております！

## 今後の予定

4/19にalexa-skills-kit-sdk-for-nodejsのversion2が提供されたので、
まずはマイグレーションをしたいなぁと考えています。

~~ソースコードは、近々公開します！~~
公開しました＼(^o^)／

[じゃらじゃら符計算 v1 - GitHub](https://github.com/daisukeArk/alexa-jarajara-node/tree/v1.1.1)