アルパカのメモ

記憶の管理 (State)

Stateの保存場所

ボットは、Webアプリケーションと同じように基本的にはステートレスである。 1回目のターンでやりとりした内容は、基本的には2回目ではもう覚えていない。 しかし、より充実した機能を提供するには「今までどんな会話をしてきたか」を覚えなくてはいけない場合がある。

そういった情報を保存する場所として、Bot Framework SDKには以下の3つが用意されている:

  • Memory Storage - テスト用のインメモリのストレージ。ローカルでボットのテストをしたいときに使う。ボットが再起動すると消える。
  • Azure Blob Storage
  • Azure Cosmos DB

Stateのスコープ

Stateのスコープは3つ用意されている。

  • User state - ユーザーとボットの間でずっと有効なスコープ。
  • Conversation state - 特定の会話で有効なスコープ。ユーザーは問わない(例えばグループ会話)
  • Private conversation state - 特定の会話とユーザーで有効なスコープ。

ユーザーと会話は1つのチャネル内でのみ認識できる。同じユーザーが異なるチャネルからボットにアクセスした場合、異なるユーザーと判断する。

実装サンプル

参考ドキュメント:Save user and conversation data - Bot Service | Microsoft Docs
サンプルソース:BotBuilder-Samples/samples/csharp_dotnetcore/45.state-management at main · microsoft/BotBuilder-Samples

このサンプルでは、conversation state と user state の2種類のスコープを扱う。

データを格納するクラスの作成

まず最初に、保存したいデータを保持するためのクラスを作成する。この工程は必須ではないが、クラスを作成しておくと管理が楽になると思われる。

// user stateで保持するデータ。ユーザー名などが相当する。
public class UserProfile
{
    public string Name { get; set; }
}
// conversation state で保存するデータ。
public class ConversationData
{
    public string Timestamp { get; set; }

    public string ChannelId { get; set; }

    public bool PromptedUserForName { get; set; } = false;
}

Storageクラスなどの準備

次に、Startup.csConfigureServices メソッドに下記の処理を追加して、ストレージやStateをDIに登録する。今サンプルではテスト用の MemoryStorage を使用する。 MemoryStorage はデータをただのメモリに保持するだけなので、アプリが再起動すると消えてしまう。 本番環境では必ず Azure Blob Storage などを使うこと。

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IStorage, MemoryStorage>();
    services.AddSingleton<UserState>();
    services.AddSingleton<ConversationState>();
}

TurnContext に登録する

BotAdapter クラス (AdapterWithErrorHandler.cs) のコンストラクタに下記を追加し、TurnContext に各StateクラスとStorageクラスを登録する。 こうすると、State を DI で注入してもらわなくても、TurnContext さえあれば State などを使用できるようになる。
※ この手順は Adaptive Dialog を使えるようにする手順と重複しているため、そちらが済んでいるのなら改めて実装する必要はない。

public class AdapterWithErrorHandler : BotFrameworkHttpAdapter
{
    public AdapterWithErrorHandler(IConfiguration configuration, ILogger<BotFrameworkHttpAdapter> logger
        , IStorage storage, UserState userState, ConversationState conversationState)
        : base(configuration, logger)
    {
        this.UseStorage(storage);
        this.UseBotState(userState);
        this.UseBotState(conversationState);

        OnTurnError = async (turnContext, exception) =>
        {
            // 略
        };
    }
}

TurnContext から取得する

Bot クラス等からStateを使いたい場合は、下記のように記述する。

using Microsoft.Bot.Builder;
using System.Threading;
using System.Threading.Tasks;

public class EmptyBot : ActivityHandler
{
    public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
    {
        var storage = turnContext.TurnState.Get<IStorage>(nameof(IStorage));
        var userState = turnContext.TurnState.Get<UserState>(typeof(UserState).FullName);
    }
}

Storage クラスは nameof(IStorage) がキーになっているが、各 State クラスは typeof(UserState).FullName と、名前空間も含めたクラス名がキーになっている点に注意。

State を読み書きする

State クラスへデータを読み書きするには、IStatePropertyAccessor インターフェイスを経由して行う。 ボットクラスのサンプルは以下の通り。

private BotState _conversationState;
private BotState _userState;

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{

    // accessorを取得→accessor.GetAsyncを呼び出し で保存データを取得できる
    var conversationStateAccessors =  _conversationState.CreateProperty<ConversationData>(nameof(ConversationData));
    var conversationData = await conversationStateAccessors.GetAsync(turnContext, () => new ConversationData());

    var userStateAccessors = _userState.CreateProperty<UserProfile>(nameof(UserProfile));
    var userProfile = await userStateAccessors.GetAsync(turnContext, () => new UserProfile());

    // (略)

    // 保存する値を変えるときは、オブジェクトに値をセットすればよい
    conversationData.Timestamp = localMessageTime.ToString();
    conversationData.ChannelId = turnContext.Activity.ChannelId.ToString();
}

public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
    await base.OnTurnAsync(turnContext, cancellationToken);

    // ターンが終わる直前に、データを保存する
    await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    await _userState.SaveChangesAsync(turnContext, false, cancellationToken);
}

State はターンが終わるまでにデータを保存しないといけない。 DialogManager クラスを使用している場合は、ConversationState と UserState に限り自動的に保存される。 それ以外の場合は、AutoSaveStateMiddleware というミドルウェアの使用を検討すると良い。 このミドルウェアはコンストラクタに指定した BotState をターンの最後に自動で保存してくれる。 ミドルウェアの使い方については、こちらの記事 を参照。

Azure Blob Storage を使う

ストレージアカウントの準備

State の保存場所に Azure Blob Storage を使う場合、まずストレージアカウントをAzureに作成する。 作成後、ストレージアカウントのリソースを表示し、左側メニューの「設定」→「アクセスキー」をクリック。

key1 または key2 の接続文字列をコピーする。コピーした接続文字列は secrets.json へ貼り付ける。

{
  "storage": {
    "connectionString": "接続文字列を貼り付ける",
    "containerName": "bot-state"
  }
}

containerName は好きな名前を付ける。ボット動作時に自動的に作成されるため、コンテナをあらかじめ作成しておく必要はない。

Startup.cs の編集

NuGet パッケージ Microsoft.Bot.Builder.Azure.Blobs を追加する。
Microsoft.Bot.Builder.Azure を追加して AzureBlobStorage クラスを使おうとしたら非推奨といわれた。

ConfigureService メソッドに下記を追加する。

using Microsoft.Bot.Builder.Azure.Blobs;

// 略

var storage = new BlobsStorage(Configuration["storage:connectionString"], Configuration["storage:containerName"]);
services.AddSingleton<IStorage>(storage);
services.AddSingleton<UserState>();
services.AddSingleton<ConversationState>();