Implementar um armazenamento personalizado para seu bot

APLICA-SE A: SDK v4

As interações de um bot se enquadram em três áreas: a troca de atividades com o Serviço de Bot de IA do Azure, o carregamento e o salvamento do estado do bot e do diálogo com um repositório de memória e a integração com serviços de back-end.

Diagrama de interação que descreve a relação entre o Serviço de Bot de IA do Azure, um bot, um armazenamento de memória e outros serviços.

Este artigo explora como estender a semântica entre o Serviço de Bot de IA do Azure e o estado de memória e armazenamento do bot.

Observação

Os SDKs JavaScript, C# e Python do Bot Framework continuarão a ser compatíveis. No entanto, o SDK Java está sendo desativado, com o suporte final de longo prazo terminando em novembro de 2023.

Os bots existentes criados com o SDK para Java continuarão a funcionar.

Para a criação de novos bots, considere usar o Power Virtual Agents e ler sobre como escolher a solução de chatbot correta.

Para obter mais informações, confira O futuro da criação de bots.

Pré-requisitos

Este artigo se concentra na versão C# do exemplo.

Tela de fundo

O SDK do Bot Framework inclui uma implementação padrão do estado do bot e do armazenamento de memória. Essa implementação se ajusta nas necessidades de aplicativos em que as peças são usadas em junto com algumas linhas de código de inicialização, como demonstrado em muitos dos exemplos.

O SDK é uma estrutura e não um aplicativo com comportamento fixo. Em outras palavras, a implementação de muitos dos mecanismos na estrutura é uma implementação padrão e não a única implementação possível. Especificamente, a estrutura não dita a relação entre a troca de atividades com o Serviço de Bot de IA do Azure e o carregamento e o salvamento de qualquer estado de Bot.

Este artigo descreve uma maneira de modificar a semântica do estado padrão e da implementação de armazenamento quando ela não funciona perfeitamente para o aplicativo. O exemplo de expansão fornece uma implementação alternativa de estado e de armazenamento que tem uma semântica diferente do estado e do armazenamento padrão. Essa solução alternativa se situa igualmente bem na estrutura. Dependendo do seu cenário, essa solução alternativa pode ser mais apropriada para o aplicativo que você está desenvolvendo.

Comportamento do adaptador e do provedor de armazenamento padrão

Com a implementação padrão, ao receber uma atividade, o bot carrega o estado correspondente para a conversa. Em seguida, ele executa a lógica de diálogo com esse estado e a atividade de entrada. No processo de execução do diálogo, uma ou mais atividades de saída são criadas e enviadas imediatamente. Quando o processamento do diálogo está concluído, o bot salva o estado atualizado, substituindo o estado antigo.

Diagrama de sequência mostrando o comportamento padrão de um bot e seu armazenamento de memória.

No entanto, algumas coisas podem dar errado com esse comportamento.

  • Se a operação de salvamento apresenta falha por algum motivo, o estado implicitamente está fora de sincronia com o que o usuário vê no canal. O usuário viu respostas do bot e acredita que o estado avançou, mas isso não aconteceu. Esse erro pode ser pior do que se a atualização de estado fosse bem-sucedida, mas o usuário não recebesse as mensagens de resposta.

    Esses erros de estado podem ter implicações no design da conversa. Por exemplo, o diálogo pode exigir trocas de confirmação adicionais, de outro modo redundantes, com o usuário.

  • Se a implementação for implantada em expansão entre vários nós, o estado poderá ser substituído acidentalmente. Esse erro poderá ser confuso porque o diálogo provavelmente terá enviado atividades para o canal que carrega mensagens de confirmação.

    Considere o bot de um pedido de pizza, em que o bot pede ao usuário opções de cobertura e o usuário envia duas mensagens rápidas: uma para adicionar cogumelos e outra para adicionar queijo. Em um cenário expandido, várias instâncias do bot podem estar ativas e as duas mensagens do usuário podem ser manipuladas por duas instâncias separadas em computadores separados. Esse conflito é denominado condição de corrida, em que um computador pode substituir o estado gravado por outro. No entanto, como as respostas já foram enviadas, o usuário recebeu uma confirmação informando que os cogumelos e o queijo foram adicionados ao pedido. Infelizmente, quando a pizza chegar, ela conterá somente cogumelos ou queijo, mas não os dois.

Bloqueio otimista

O exemplo de expansão introduz alguns bloqueios em todo o estado. O exemplo implementa o bloqueio otimista, que permite que cada instância seja executada como se fosse a única em execução e, em seguida, verifique se há violações de simultaneidade. Esse bloqueio pode parecer complicado, mas existem soluções conhecidas e você pode usar tecnologias de armazenamento em nuvem e os pontos de extensão certos no Bot Framework.

O exemplo usa um mecanismo HTTP padrão baseado no cabeçalho de marca da entidade, (ETag). Entender esse mecanismo é crucial para entender o código a seguir. O diagrama a seguir ilustra a sequência.

Diagrama de sequência mostrando uma condição de corrida, com a segunda atualização falhando.

O diagrama mostra dois clientes que estão executando uma atualização em algum recurso.

  1. Quando um cliente emite uma solicitação GET e um recurso é retornado do servidor, o servidor inclui um cabeçalho ETag.

    O cabeçalho ETag é um valor opaco que representa o estado do recurso. Se um recurso for alterado, o servidor atualizará seu ETag para o recurso.

  2. Quando o cliente deseja manter uma alteração de estado, ele emite uma solicitação POST para o servidor, com o valor ETag em um cabeçalho de pré-condição If-Match.

  3. Se o valor ETag da solicitação não corresponder ao do servidor, a verificação de pré-condição apresentará falha com uma resposta 412 (Falha na pré-condição).

    Essa falha indica que o valor atual no servidor não corresponde mais ao valor original no qual o cliente estava operando.

  4. Se o cliente receber uma resposta com falha de pré-condição, normalmente obterá um novo valor para o recurso, aplicará a atualização desejada e tentará postar a atualização do recurso novamente.

    Essa segunda solicitação POST será bem-sucedida se nenhum outro cliente tiver atualizado o recurso. Caso contrário, o cliente poderá tentar novamente.

Esse processo é chamado de otimista porque o cliente, ao ter em mãos um recurso, realiza o processamento, sendo que o recurso em si não está bloqueado, pois outros clientes podem acessá-lo sem qualquer restrição. Qualquer contenção entre os clientes sobre qual o estado do recurso deve ser não é determinada até que o processamento tenha sido concluído. Em um sistema distribuído essa estratégia muitas vezes é melhor do que a abordagem pessimista oposta.

O mecanismo de bloqueio otimista, conforme descrito, pressupõe que a lógica do programa pode ser repetida com segurança. A situação ideal é aquela em que essas solicitações de serviço são idempotentes. Em ciência da computação, uma operação idempotente é aquela que não tem qualquer efeito adicional se for chamada mais de uma vez com os mesmos parâmetros de entrada. Serviços HTTP REST puros que implementam as solicitações GET, PUT e DELETE muitas vezes são idempotentes. Se uma solicitação de serviço não produzir efeitos adicionais, as solicitações poderão ser reexecutadas com segurança como parte de uma estratégia de repetição.

O exemplo de expansão e o restante deste artigo pressupõem que os serviços de back-end que seu bot usa são todos serviços HTTP REST idempotentes.

Atividades de saída de buffer

O envio de uma atividade não é uma operação idempotente. A atividade muitas vezes é uma mensagem que retransmite informações para o usuário. Repetir a mesma mensagem duas ou mais vezes pode ser confuso ou enganoso.

O bloqueio otimista implica que a lógica do bot pode precisar ser reexecutada várias vezes. Para evitar o envio de uma determinada atividade várias vezes, aguarde até que a operação de atualização de estado seja bem-sucedida antes de enviar atividades para o usuário. A lógica do bot deve ter uma aparência semelhante ao diagrama a seguir.

Diagrama de sequência com mensagens sendo enviadas após o estado da caixa de diálogo ser salvo.

Depois de criar um loop de repetição na execução do diálogo, você terá o comportamento a seguir quando houver uma falha de pré-condição na operação de salvamento.

Diagrama de sequência com mensagens sendo enviadas após uma tentativa de repetição bem-sucedida.

Com esse mecanismo implantado, o bot da pizza do exemplo anterior nunca deveria enviar uma confirmação positiva errônea da adição de uma cobertura à pizza do pedido. Mesmo com o bot implantado em várias máquinas, o esquema de bloqueio otimista serializa efetivamente as atualizações de estado. No bot da pizza, a confirmação da adição de um item agora pode até mesmo refletir o estado completo com precisão. Por exemplo, se o usuário digitar rapidamente “queijo” e, em seguida, “cogumelo” e essas mensagens forem manipuladas por duas instâncias diferentes do bot, a última instância a ser concluída poderá incluir “uma pizza com queijo e cogumelo” como parte da resposta.

Essa nova solução de armazenamento personalizada faz três coisas que a implementação padrão no SDK não faz:

  1. Ela usa ETags para detectar contenção.
  2. Ela repete o processamento quando uma falha de ETag é detectada.
  3. Ela aguarda para enviar atividades de saída até ter o estado salvo com sucesso.

O restante deste artigo descreve a implementação dessas três partes.

Implementar suporte de ETag

Primeiro, defina uma interface para nosso novo repositório que inclua suporte de ETag. A interface ajuda a usar os mecanismos de injeção de dependência em ASP.NET. Começar pela interface permite que você implemente versões separadas para testes de unidade e para produção. Por exemplo, a versão de teste de unidade pode ser executada na memória e não exigir uma conexão de rede.

A interface consiste nos métodos carregar e salvar. Ambos os métodos usarão um parâmetro de chave para identificar o estado de carregar ou salvar no armazenamento.

  • Carregar retornará o valor do estado e o ETag associado.
  • Salvar terá parâmetros para o valor do estado e o ETag associado e retornará um valor booliano indicando se a operação foi bem-sucedida. O valor retornado não servirá como um indicador de erro geral, mas como um indicador específico de falha de pré-condição. A verificação do código de retorno fará parte da lógica do loop de repetição.

Para tornar a implementação de armazenamento amplamente aplicável, evite colocar requisitos de serialização nela. No entanto, muitos serviços de armazenamento modernos oferecem suporte a JSON como o tipo de conteúdo. Em C#, você pode usar o tipo JObject para representar um objeto JSON. Em JavaScript ou TypeScript, JSON é um objeto nativo regular.

Veja a definição da interface personalizada.

IStore.cs

public interface IStore
{
    Task<(JObject content, string etag)> LoadAsync(string key);

    Task<bool> SaveAsync(string key, JObject content, string etag);
}

Veja uma implementação para o Armazenamento de Blobs do Azure.

BlobStore.cs

public class BlobStore : IStore
{
    private readonly CloudBlobContainer _container;

    public BlobStore(string accountName, string accountKey, string containerName)
    {
        if (string.IsNullOrWhiteSpace(accountName))
        {
            throw new ArgumentException(nameof(accountName));
        }

        if (string.IsNullOrWhiteSpace(accountKey))
        {
            throw new ArgumentException(nameof(accountKey));
        }

        if (string.IsNullOrWhiteSpace(containerName))
        {
            throw new ArgumentException(nameof(containerName));
        }

        var storageCredentials = new StorageCredentials(accountName, accountKey);
        var cloudStorageAccount = new CloudStorageAccount(storageCredentials, useHttps: true);
        var client = cloudStorageAccount.CreateCloudBlobClient();
        _container = client.GetContainerReference(containerName);
    }

    public async Task<(JObject content, string etag)> LoadAsync(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        var blob = _container.GetBlockBlobReference(key);
        try
        {
            var content = await blob.DownloadTextAsync();
            var obj = JObject.Parse(content);
            var etag = blob.Properties.ETag;
            return (obj, etag);
        }
        catch (StorageException e)
            when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.NotFound)
        {
            return (new JObject(), null);
        }
    }

    public async Task<bool> SaveAsync(string key, JObject obj, string etag)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        if (obj == null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        var blob = _container.GetBlockBlobReference(key);
        blob.Properties.ContentType = "application/json";
        var content = obj.ToString();
        if (etag != null)
        {
            try
            {
                await blob.UploadTextAsync(content, Encoding.UTF8, new AccessCondition { IfMatchETag = etag }, new BlobRequestOptions(), new OperationContext());
            }
            catch (StorageException e)
                when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.PreconditionFailed)
            {
                return false;
            }
        }
        else
        {
            await blob.UploadTextAsync(content);
        }

        return true;
    }
}

O Armazenamento de Blobs do Azure faz grande parte do trabalho. Cada método verifica a existência de uma exceção específica para atender às expectativas do código de chamada.

  • O método LoadAsync, em resposta a uma exceção de armazenamento com um código de status não encontrado, retorna um valor nulo.
  • O método SaveAsync, em resposta a uma exceção de armazenamento com um código de falha de pré-condição, retorna false.

Implementar um loop de repetição

O design do loop de repetição implementa o comportamento mostrado nos diagramas de sequência.

  1. Ao receber uma atividade, crie uma chave para o estado da conversa.

    A relação entre uma atividade e o estado da conversa é a mesma para o armazenamento personalizado e para a implementação padrão. Portanto, você pode criar a chave da mesma maneira usada para a implementação do estado padrão.

  2. Tente carregar o estado da conversa.

  3. Execute o diálogo do bot e capture as atividades de saída a serem enviadas.

  4. Tente salvar o estado da conversa.

    • Em caso de sucesso, envie as atividades de saída e saia.

    • Em caso de falha, repita esse processo a partir da etapa de carregar o estado da conversa.

      O novo carregamento do estado da conversa obtém um ETag e um estado da conversa novos e atuais. O diálogo é reexecutado e a etapa de estado de salvamento tem uma probabilidade de sucesso.

Veja uma implementação para o manipulador de atividade de mensagem.

ScaleoutBot.cs

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    // Create the storage key for this conversation.
    var key = $"{turnContext.Activity.ChannelId}/conversations/{turnContext.Activity.Conversation?.Id}";

    // The execution sits in a loop because there might be a retry if the save operation fails.
    while (true)
    {
        // Load any existing state associated with this key
        var (oldState, etag) = await _store.LoadAsync(key);

        // Run the dialog system with the old state and inbound activity, the result is a new state and outbound activities.
        var (activities, newState) = await DialogHost.RunAsync(_dialog, turnContext.Activity, oldState, cancellationToken);

        // Save the updated state associated with this key.
        var success = await _store.SaveAsync(key, newState, etag);

        // Following a successful save, send any outbound Activities, otherwise retry everything.
        if (success)
        {
            if (activities.Any())
            {
                // This is an actual send on the TurnContext we were given and so will actual do a send this time.
                await turnContext.SendActivitiesAsync(activities, cancellationToken);
            }

            break;
        }
    }
}

Observação

O exemplo implementa a execução do diálogo como uma chamada de função. Uma abordagem mais sofisticada pode ser a definição de uma interface e o uso de injeção de dependência. Para este exemplo, no entanto, a função estática enfatiza a natureza funcional dessa abordagem otimista de bloqueio. Em geral, quando você implementa as partes cruciais do código de forma funcional, aumenta a probabilidade de trabalhar com sucesso em redes.

Implementar um buffer de atividade de saída

O próximo requisito é armazenar em buffer as atividades de saída até que ocorra uma operação de salvamento bem-sucedida, o que requer a implementação de um adaptador personalizado. O método personalizado SendActivitiesAsync não deve enviar as atividades para o uso e sim adicionar as atividades a uma lista. O código do diálogo não precisará de modificação.

  • Nesse cenário específico, as operações de atividade de atualização e exclusão de atividade não são compatíveis e os métodos associados lançarão exceções não implementadas.
  • O valor retornado da operação de atividades de envio é usado por alguns canais para permitir que um bot modifique ou exclua uma mensagem enviada anteriormente, por exemplo, para desabilitar botões em cartões exibidos no canal. Essas trocas de mensagens podem ficar complicadas, especialmente quando o estado é obrigatório. Essas trocas estão fora do escopo deste artigo.
  • O diálogo cria e usa esse adaptador personalizado para que ele possa armazenar em buffer as atividades.
  • O manipulador de turnos do bot usará um AdapterWithErrorHandler mais padrão para enviar as atividades ao usuário.

Veja uma implementação do adaptador personalizado.

DialogHostAdapter.cs

public class DialogHostAdapter : BotAdapter
{
    private List<Activity> _response = new List<Activity>();

    public IEnumerable<Activity> Activities => _response;

    public override Task<ResourceResponse[]> SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken)
    {
        foreach (var activity in activities)
        {
            _response.Add(activity);
        }

        return Task.FromResult(new ResourceResponse[0]);
    }

    #region Not Implemented
    public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public override Task<ResourceResponse> UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
    #endregion
}

Usar o armazenamento personalizado em um bot

A última etapa é usar essas classes e métodos personalizados com classes e métodos de estrutura existentes.

  • O loop de repetição principal torna-se parte do método ActivityHandler.OnMessageActivityAsync do bot e inclui o armazenamento personalizado por meio de injeção de dependência.
  • O código de hospedagem de diálogo é adicionado à classe DialogHost que expõe um método RunAsync estático. O host de diálogo:
    • Assume a atividade de entrada e o antigo estado e, em seguida, retorna as atividades resultantes e o novo estado.
    • Cria o adaptador personalizado e, além disso, executa o diálogo da mesma maneira que o SDK.
    • Cria um acessador de propriedade de estado personalizado, um shim que passa o estado do diálogo para o sistema de diálogo. O acessador usa semântica de referência para passar um identificador de acessador para o sistema de diálogo.

Dica

A serialização JSON é adicionada embutida ao código de hospedagem para mantê-lo fora da camada de armazenamento conectável, para que diferentes implementações possam ser serializadas de forma diferente.

Veja uma implementação do host de diálogo.

DialogHost.cs

public static class DialogHost
{
    // The serializer to use. Moving the serialization to this layer will make the storage layer more pluggable.
    private static readonly JsonSerializer StateJsonSerializer = new JsonSerializer() { TypeNameHandling = TypeNameHandling.All };

    /// <summary>
    /// A function to run a dialog while buffering the outbound Activities.
    /// </summary>
    /// <param name="dialog">THe dialog to run.</param>
    /// <param name="activity">The inbound Activity to run it with.</param>
    /// <param name="oldState">Th eexisting or old state.</param>
    /// <returns>An array of Activities 'sent' from the dialog as it executed. And the updated or new state.</returns>
    public static async Task<(Activity[], JObject)> RunAsync(Dialog dialog, IMessageActivity activity, JObject oldState, CancellationToken cancellationToken)
    {
        // A custom adapter and corresponding TurnContext that buffers any messages sent.
        var adapter = new DialogHostAdapter();
        var turnContext = new TurnContext(adapter, (Activity)activity);

        // Run the dialog using this TurnContext with the existing state.
        var newState = await RunTurnAsync(dialog, turnContext, oldState, cancellationToken);

        // The result is a set of activities to send and a replacement state.
        return (adapter.Activities.ToArray(), newState);
    }

    /// <summary>
    /// Execute the turn of the bot. The functionality here closely resembles that which is found in the
    /// IBot.OnTurnAsync method in an implementation that is using the regular BotFrameworkAdapter.
    /// Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted
    /// to other conversation modeling abstractions.
    /// </summary>
    /// <param name="dialog">The dialog to be run.</param>
    /// <param name="turnContext">The ITurnContext instance to use. Note this is not the one passed into the IBot OnTurnAsync.</param>
    /// <param name="state">The existing or old state of the dialog.</param>
    /// <returns>The updated or new state of the dialog.</returns>
    private static async Task<JObject> RunTurnAsync(Dialog dialog, ITurnContext turnContext, JObject state, CancellationToken cancellationToken)
    {
        // If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.)
        var dialogStateProperty = state?[nameof(DialogState)];
        var dialogState = dialogStateProperty?.ToObject<DialogState>(StateJsonSerializer);

        // A custom accessor is used to pass a handle on the state to the dialog system.
        var accessor = new RefAccessor<DialogState>(dialogState);

        // Run the dialog.
        await dialog.RunAsync(turnContext, accessor, cancellationToken);

        // Serialize the result (available as Value on the accessor), and put its value back into a new JObject.
        return new JObject { { nameof(DialogState), JObject.FromObject(accessor.Value, StateJsonSerializer) } };
    }
}

E, por fim, veja está uma implementação do acessador de propriedade de estado personalizado.

RefAccessor.cs

public class RefAccessor<T> : IStatePropertyAccessor<T>
    where T : class
{
    public RefAccessor(T value)
    {
        Value = value;
    }

    public T Value { get; private set; }

    public string Name => nameof(T);

    public Task<T> GetAsync(ITurnContext turnContext, Func<T> defaultValueFactory = null, CancellationToken cancellationToken = default(CancellationToken))
    {
        if (Value == null)
        {
            if (defaultValueFactory == null)
            {
                throw new KeyNotFoundException();
            }

            Value = defaultValueFactory();
        }

        return Task.FromResult(Value);
    }

    #region Not Implemented
    public Task DeleteAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(ITurnContext turnContext, T value, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }
    #endregion
}

Informações adicionais

O exemplo de expansão está disponível no repositório de exemplos do Bot Framework no GitHub em C#, Python e Java.