Bot Builder v4 でボット開発 : スキルを開発する - その 3 ソリューションのコード詳細

[前回](https://qiita.com/kenakamu/items/cc2b97e9a7b07d73cb6c)の記事ではテンプレートで作成されるスクリプトについて確認しました。この記事ではコードを確認してきます。

# スキル

シリーズの初めでも軽く触れた通り、スキルは複数のアクションが定義でき、以下の 2 つの種類が使えます。

- メッセージタイプ: ユーザー入力を使って呼び出す。アクションはスキル側で判定して呼び出す
- イベント: 事前に決まった入出力を使って、明示的にアクションを呼び出す

連携のパターンは Bot Framework で開発したボットが Dispatch 機能経由で連携する場合と、Power Virtual Agents (PVA) と連携する 2 つのパターンがあります。

## Dispatch 連携する場合

通常のボットのようにユーザーメッセージをそのまま転送することも、特定のアクションを明示的に呼び出すこともできますが、メッセージを転送するパターンをよく見ます。

メッセージをそのまま転送する場合は LUIS を使って後続処理をすることが多いです。

- 呼び出し元の Dispatch LUIS アプリで得られたインテントと元に呼び出すアクションがきまる
- 呼び出されたアクション側でも別途用意した LUIS でさらに意図を細かく確認して処理する

## PVA と連携する場合

PVA からもメッセージとイベントの両方が呼び出せますが、GUI を使ってフローを組み上げる性質上、任意のアクションを明示的に呼び出すことが多いです。

# 実態はボット+α

スキルの実態は Bot Framework で開発するボットとほぼ同じで、一部スキル特有のコードが追加されるだけです。
よっていつも通り ASP.NET Core Web API ベースのソリューションとなっていますが、構成要素が多い分、見ておくコードが増えます。

ボットの起動の仕組みの詳細は、[ボットが起動する仕組みを理解する](https://qiita.com/kenakamu/items/ea4f1a6ab6e8421b99c9)を参照してください。

# Startup.cs

一番基礎となる Startup.cs から見ていきます。やっていることはいつも通り以下の 2 点です。

- 構成ファイル読み込み
- IoC 作成

## 構成ファイルの読み込み

以下構成ファイルを読み込みます。すべて存在する場合のみ読み込まれます。

- appsettings.json: アプリ構成ファイル
- cognitivemodels.json: LUIS や QnA メーカーの構成ファイル
- skill.json: スキルの構成ファイル (テンプレートでは作成されない)

```csharp
public Startup(IWebHostEnvironment env)
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(env.ContentRootPath)
        .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
        .AddJsonFile("cognitivemodels.json", optional: true)
        .AddJsonFile($"cognitivemodels.{env.EnvironmentName}.json", optional: true)
        .AddJsonFile("skills.json", optional: true)
        .AddJsonFile($"skills.{env.EnvironmentName}.json", optional: true)
        .AddEnvironmentVariables();

    Configuration = builder.Build();
}
```
## IoC 

多くのモジュールが登録されています。スキルに関連しそうなものだけピックアップします。

**プロアクティブ用バックグラウンドタスク**

時間がきたらリマインドするなどの用途に使うバックグラウンドタスク用モジュールで、Bot Framework Solutions 固有の実装です。
使い方は今後見ていきます。

```csharp
// Configure proactive
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
services.AddHostedService<QueuedHostedService>();
```

**多言語対応**

プレビューの [Language Generation](https://github.com/microsoft/BotBuilder-Samples/tree/master/experimental/language-generation) モジュールで多言語対応を行っています。個人的には resx より扱いやすくて好きです。

- 対応するテンプレートファイルの指定
- 対応する言語の指定
- ファイルのパス (Responses/<テンプレート>.lg)

```csharp
// Configure localized responses
var localizedTemplates = new Dictionary<string, List<string>>();
var templateFiles = new List<string>() { "MainResponses", "SampleResponses" };
var supportedLocales = new List<string>() { "en-us", "de-de", "es-es", "fr-fr", "it-it", "zh-cn" };

foreach (var locale in supportedLocales)
{
    var localeTemplateFiles = new List<string>();
    foreach (var template in templateFiles)
    {
        // LG template for en-us does not include locale in file extension.
        if (locale.Equals("en-us"))
        {
            localeTemplateFiles.Add(Path.Combine(".", "Responses", $"{template}.lg"));
        }
        else
        {
            localeTemplateFiles.Add(Path.Combine(".", "Responses", $"{template}.{locale}.lg"));
        }
    }

    localizedTemplates.Add(locale, localeTemplateFiles);
}

services.AddSingleton(new LocaleTemplateEngineManager(localizedTemplates, settings.DefaultLocale ?? "en-us"));
```
# Adapters/DefaultAdapter.cs

次にボットが呼び出される際に実行されるアダプターを確認します。
ここでは全体のエラーハンドラーとミドルウェアの登録が行われています。

## エラーハンドラー

スキルに対応している場合、turnContext.IsSkill 拡張メソッドでスキル呼び出しされたものかを判定し、呼び出し元にエラーを返す処理を実装しています。

```csharp
OnTurnError = async (turnContext, exception) =>
{
    await turnContext.SendActivityAsync(new Activity(type: ActivityTypes.Trace, text: $"Exception Message: {exception.Message}, Stack: {exception.StackTrace}"));
    await turnContext.SendActivityAsync(templateEngine.GenerateActivityForLocale("ErrorMessage"));
    telemetryClient.TrackException(exception);

    if (turnContext.IsSkill())
    {
        // Send and EndOfConversation activity to the skill caller with the error to end the conversation
        // and let the caller decide what to do.
        var endOfConversation = Activity.CreateEndOfConversationActivity();
        endOfConversation.Code = "SkillError";
        endOfConversation.Text = exception.Message;
        await turnContext.SendActivityAsync(endOfConversation);
    }
};
```

尚、IsSkill メソッドは [Extensions/ITurnContextEx.cs](https://github.com/microsoft/botframework-solutions/blob/master/templates/csharp/Skill/Skill/Extensions/ITurnContextEx.cs) に実装があります。

## ミドルウェア

複数のミドルウェアが構成されています。標準のテンプレートにないものを紹介します。

**トランスクリプトロガー**

既定でストレージアカウントに保存されますが、ローカル開発中はコメントを入れかえてローカルメモリストレージを利用できます。
トランスクリプトとはエミュレーターでも右側の表示されている、ユーザーとボットの間で送受信されたデータを json 形式で保存したものです。

```csharp
// Uncomment the following line for local development without Azure Storage
// Use(new TranscriptLoggerMiddleware(new MemoryTranscriptStore()));
Use(new TranscriptLoggerMiddleware(new AzureBlobTranscriptStore(settings.BlobStorage.ConnectionString, settings.BlobStorage.Container)));            
```

**テレメトリ―**

ApplicationInsight の登録です。logPersonalInformation を有効にするとユーザーの名前なども記録されます。
[GitHub: TelemetryLoggerMiddleware.cs](https://github.com/microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder/TelemetryLoggerMiddleware.cs) 参照

```csharp
Use(new TelemetryLoggerMiddleware(telemetryClient, logPersonalInformation: true));
```

**イベントデバッガー**

PVA から送信されたリクエストを「イベント」タイプに変換するミドルウェアです。

```csharp
Use(new EventDebuggerMiddleware());
```
[GitHub:EventDebuggerMiddleware.cs](https://github.com/microsoft/botframework-solutions/blob/master/sdk/csharp/libraries/microsoft.bot.solutions/Middleware/EventDebuggerMiddleware.cs) 参照

以下のように送られてきたメッセージが決まった形式であれば、イベントとして変換します。やり方が少しハックっぽいので、将来のネイティブ対応に期待です。

```csharp:EventDebuggerMiddleware.cs
if (!string.IsNullOrEmpty(text) && text.StartsWith("/event:"))
{
    var json = text.Split(new string[] { "/event:" }, StringSplitOptions.None)[1];
var body = JsonConvert.DeserializeObject<Activity>(json);

turnContext.Activity.Type = ActivityTypes.Event;

    turnContext.Activity.Name = body.Name ?? turnContext.Activity.Name;
    turnContext.Activity.Text = body.Text ?? turnContext.Activity.Text;
    turnContext.Activity.Value = body.Value ?? turnContext.Activity.Value;
}
```

**スキルミドルウェア**

スキルで EndOfConversation が来た際にデータを削除するミドルウェアです。EventDebuggerMiddleware と合わせてスキルに欠かせないものです。将来的に他のステートにも対応する可能性もあると思います。

[GitHub: SkillMiddleware.cs](https://github.com/microsoft/botframework-solutions/blob/master/sdk/csharp/libraries/microsoft.bot.solutions/Skills/SkillMiddleware.cs)

# MainDialog.cs

処理の中心となる MainDialog.cs ではスキルに関する処理以外もいくつか機能が追加されているため、順番に見ていきます。

- OnBeginDialogAsync: ダイアログの初めに実行
- OnContinueDialogAsync: ダイアログが継続している場合、前処理として実行
- InterruptDialogAsync: OnContinueDialogAsync から呼ばれる中断処理ハンドラー
- IntroStepAsync、RouteStepAsync、FinalStepAsync: ウォーターフォールで順次実行

## OnBeginDialogAsync、OnContinueDialogAsync

呼び出し元より、ユーザーメッセージがそのまま送られてきた場合、OnBeginDialogAsync と OnContinueDialogAsync でLUIS を使って意図を確認し処理を行います。イベントとして送られてきたものはそのままメイン処理に流れていきます。

- GetCognitiveModels よりコグニティブモデルを取得
- 取得したモデルより自分のスキルと一般(General) LUIS に対してユーザーの入力を渡し、結果をそれぞれ保存
- 「キャンセル」や「ヘルプ」など中断処理でないかを確認するため InterruptDialogAsync を実行

General の結果は保存していますが、それ以降取り出されているコードは今のところなさそうです。

```csharp
if (innerDc.Context.Activity.Type == ActivityTypes.Message)
{
    // Get cognitive models for the current locale.
    var localizedServices = _services.GetCognitiveModels();

    // Run LUIS recognition on Skill model and store result in turn state.
    var skillResult = await localizedServices.LuisServices["HelloSkill"].RecognizeAsync<HelloSkillLuis>(innerDc.Context, cancellationToken);
    innerDc.Context.TurnState.Add(StateProperties.SkillLuisResult, skillResult);

    // Run LUIS recognition on General model and store result in turn state.
    var generalResult = await localizedServices.LuisServices["General"].RecognizeAsync<GeneralLuis>(innerDc.Context, cancellationToken);
    innerDc.Context.TurnState.Add(StateProperties.GeneralLuisResult, generalResult);

    // Check for any interruptions
    var interrupted = await InterruptDialogAsync(innerDc, cancellationToken);

    if (interrupted)
    {
        // If dialog was interrupted, return EndOfTurn
        return EndOfTurn;
    }
}

return await base.OnBeginDialogAsync(innerDc, options, cancellationToken);
```
## IntroStepAsync

ウォーターフローダイアログの初めに処理されます。イベントタイプであった場合は、なにもせずに次の処理の振り分けに行きますが、メッセージタイプの場合は応答メッセージとして FirstPromptMessage をユーザー言語にあわせて返しています。

### Language Generation

LocaleTemplateEngineManager で多言語の文字列を解決します。ユーザー言語が英語の場合、MainDialog で実行しているので、Resources/MainResponses.lg が使われます。英語が既定のためルートのファイルが使われますが、他の言語の場合は 対応するサブファイルが読み取られます。

```csharp
_templateEngine.GenerateActivityForLocale("FirstPromptMessage")
```

FirstPromptMessage は FirstPromptTest 関数を実行して文字列を取得します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/ac6479f8-eab8-7efb-3f39-b4dcaeace7f0.png)

関数が返す文字列は複数のバリエーションが指定でき、ランダムで選択されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/d7a0d77d-42bc-0ea9-5dd4-ac7a0d02183f.png)

テンプレートは単純な文字列だけでなく、HeroCard などより高度なメッセージも定義できます。

## RouteStepAsync

このメソッドで呼び出すスキルやダイアログを決定します。

```csharp
if (activity.Type == ActivityTypes.Message && !string.IsNullOrEmpty(activity.Text))
{
    ...
}
else if (activity.Type == ActivityTypes.Event)
{
    ...
}
```
メッセージタイプで来た場合、保存しておいたスキル用 LUIS の結果を取り出して、処理を決めます。イベントタイプの場合はイベント情報に対象アクション名があるため、そちらを利用します。

```csharp:メッセージタイプの場合
// Get skill LUIS model from configuration.
localizedServices.LuisServices.TryGetValue("HelloSkill", out var luisService);

if (luisService != null)
{
    var result = stepContext.Context.TurnState.Get<HelloSkillLuis>(StateProperties.SkillLuisResult);
    var intent = result?.TopIntent().intent;

    switch (intent)
    {
        case HelloSkillLuis.Intent.Sample:
            {
                return await stepContext.BeginDialogAsync(_sampleDialog.Id);
            }
```
```csharp:イベントタイプの場合
var ev = activity.AsEventActivity();

if (!string.IsNullOrEmpty(ev.Name))
{
    switch (ev.Name)
    {
        case "SampleAction":
            {
                SampleActionInput actionData = null;

                if (ev.Value is JObject eventValue)
                {
                    actionData = eventValue.ToObject<SampleActionInput>();
                }

                // Invoke the SampleAction dialog passing input data if available
                return await stepContext.BeginDialogAsync(nameof(SampleAction), actionData);
            }
...
```

## FinalStepAsync

ここでもタイプによって処理が分かれます。

```csharp
private async Task<DialogTurnResult> FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    if (stepContext.Context.IsSkill())
    {
        // EndOfConversation activity should be passed back to indicate that VA should resume control of the conversation
        var endOfConversation = new Activity(ActivityTypes.EndOfConversation)
        {
            Code = EndOfConversationCodes.CompletedSuccessfully,
            Value = stepContext.Result,
        };

        await stepContext.Context.SendActivityAsync(endOfConversation, cancellationToken);
        return await stepContext.EndDialogAsync();
    }
    else
    {
        return await stepContext.ReplaceDialogAsync(this.Id, _templateEngine.GenerateActivityForLocale("CompletedMessage"), cancellationToken);
    }
}
```

# Sample 

以下 2 つのダイアログがサンプルとして作成されます。

- SampleDialog.cs: メッセージタイプ
- SampleAction.cs: イベントタイプ

## SampleDialog.cs

メッセージタイプの場合、ダイアログは通常のボットと全く同じです。よって既存のボットをスキルとして再利用することが容易です。

## SampleAction.cs

イベントタイプも通常のダイアログとほぼ同じ実装ですが、入出力の扱いなど、多少異なる点があります。

### 入出力の型

イベントタイプは任意で入力と出力を定義できるため、入出力それぞれで型を指定します。フィールドの名前を型はマニフェストの definitions にあるものと一致している必要があります。個人的には Models フォルダ配下に置きたいのですが、テンプレートでは SampleAction.cs に含まれます。

```csharp
public class SampleActionInput
{
    [JsonProperty("name")]
    public string Name { get; set; }
}

public class SampleActionOutput
{
    [JsonProperty("customerId")]
    public int CustomerId { get; set; }
}
```

### 入力データの取得

ウォーターフォールダイアログの初めのメソッドである PromptForName では、以下コードで入力を取得します。

```csharp
var actionInput = stepContext.Options as SampleActionInput;
```
他の処理は通常のダイアログと同じです。

### 出力データの作成

ウォーターフォール最後の End では、出力用のインスタンスを作成し、結果として渡します。

```csharp
private async Task<DialogTurnResult> End(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // Simulate a response object payload
    var actionResponse = new SampleActionOutput();
    actionResponse.CustomerId = new Random().Next();

    // We end the dialog (generating an EndOfConversation event) which will serialize the result object in the Value field of the Activity
    return await stepContext.EndDialogAsync(actionResponse);
}
```

この結果が MainDialog で EndOfConversation アクティビティの Value に渡されます。

# manifest-1.1.json

C# のコードは拍子抜けするくらい通常のダイアログと変わらず、それがいいところでもあります。最大の違いはマニフェストによるスキルの定義が必要という部分です。

[参照:マニフェスト](https://microsoft.github.io/botframework-solutions/skills/handbook/manifest/)

json の中身を順番に見ていきましょう。

## スキルの情報

- $schema: 現在は [2.1 プレビュー](https://github.com/microsoft/botframework-sdk/blob/master/schemas/skills/skill-manifest-2.1.preview-0.json)
- endpoint: スキルをホストするアドレスと Application Id を指定

```json
{
  "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.1.preview-0.json",
  "$id": "HelloSkill",
  "name": "HelloSkill",
  "description": "HelloSkill description",
  "publisherName": "Your Company",
  "version": "1.1",
  "iconUrl": "https://kenakamuhelloskill-om7pvdw.azurewebsites.net/HelloSkill.png",
  "copyright": "Copyright (c) Microsoft Corporation. All rights reserved.",
  "license": "",
  "privacyUrl": "https://kenakamuhelloskill-om7pvdw.azurewebsites.net/privacy.html",
  "tags": [
    "sample",
    "skill"
  ],
  "endpoints": [
    {
      "name": "production",
      "protocol": "BotFrameworkV3",
      "description": "Production endpoint for the HelloSkill",
      "endpointUrl": "https://kenakamuhelloskill-om7pvdw.azurewebsites.net/api/messages",
      "msAppId": "c40bd45f-4633-4696-9567-3a28450651a6"
    }
  ],
```

## モデル

コグニティブモデルの情報です。

- languages: サポートする言語と LU ファイルのパスを指定
- intents: 期待するインテントと対応するアクションの指定

```json

 "dispatchModels": {
    "languages": {
      "en-us": [
        {
          "id": "HelloSkillLuModel-en",
          "name": "HelloSkill LU (English)",
          "contentType": "application/lu",
          "url": "file://HelloSkill.lu",
          "description": "English language model for the skill"
        }
      ],
      "de-de": [
        {
          "id": "HelloSkillLuModel-de",
          "name": "HelloSkill LU (German)",
          "contentType": "application/lu",
          "url": "file://HelloSkill.lu",
          "description": "German language model for the skill"
        }
      ],
      ...
    },
    "intents": {
      "Sample": "#/activities/message",
      "*": "#/activities/message"
    }
  }
```
上記の場合 Sample インテントもその他全てのインテントも message アクションに渡されます。

## アクティビティ

メッセージタイプとイベントタイプのアクションを定義します。アクションには入出力の情報も指定します。

- value: 入力
- resultValue: 出力
- type: Dispatch 連携は message, PVA 経由は event

```json
"activities": {
    "sampleAction": {
      "description": "Sample action which accepts an input object and returns an object back.",
      "type": "event",
      "name": "SampleAction",
      "value": {
        "$ref": "#/definitions/inputObject"
      },
      "resultValue": {
        "$ref": "#/definitions/responseObject"
      }
    },
    "message": {
      "type": "message",
      "description": "Receives the users utterance and attempts to resolve it using the skill's LU models"
    }
  },
```

## 入出力

最後のセクションでは入出力の情報を定義します。

```json
"definitions": {
    "inputObject": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "description": "The users name."
        }
      }
    },
    "responseObject": {
      "type": "object",
      "properties": {
        "customerId": {
          "type": "integer",
          "description": "A customer identifier."
        }
      }
    }
  }
}
```

上記マニフェストを他のボットが読み取り、連携して使う事になります。ただ PVA は [マニフェスト 2.0](https://github.com/microsoft/botframework-sdk/blob/master/schemas/skills/skill-manifest-2.0.0.json)しかサポートしていません。そのため PVA 用の定義は manifest-1.0.json に改めて設定します。

# まとめ

スキルにはメッセージタイプとイベントタイプの呼び出しがある点と、PVA はマニフェストが異なる点。および実装が少しだけ違う点さえ理解しておけば大丈夫です。次回はメッセージタイプのアクションを追加してみます。

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