From 8323c63b7972157392443fb7a943d2f5381edbd1 Mon Sep 17 00:00:00 2001 From: Mario Ramos Date: Mon, 12 Feb 2024 17:52:58 +0100 Subject: [PATCH 1/8] Add `ChatHistoryProvider` and refactor `ChatWithHistoryPlugin` --- CHANGELOG.md | 13 ++ .../IServiceCollectionExtensions.cs | 2 +- .../IChatHistoryProvider.cs | 32 ++++ .../ILengthFunctions.cs | 39 ++++- .../ChatHistoryProvider.cs | 107 +++++++++++++ .../IServiceCollectionExtensions.cs | 70 +++++++++ .../{ => Extensions}/KernelExtensions.cs | 19 +-- .../Options/ChatHistoryProviderOptions.cs | 16 ++ .../Plugins/ChatWithHistoryPlugin.cs | 145 ++++-------------- .../Plugins/ChatWithHistoryPluginOptions.cs | 7 - .../README.md | 12 +- .../Encamina.Enmarcha.SemanticKernel.csproj | 3 +- 12 files changed, 321 insertions(+), 144 deletions(-) create mode 100644 src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs create mode 100644 src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/ChatHistoryProvider.cs create mode 100644 src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/IServiceCollectionExtensions.cs rename src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/{ => Extensions}/KernelExtensions.cs (67%) create mode 100644 src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatHistoryProviderOptions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b31502a..0f169fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ Previous classification is not required if changes are simple or all belong to t ## [8.1.3] +### Breaking Changes +- Removed `HistoryMaxMessages` property from `ChatWithHistoryPluginOptions` as part of a refactor to improve the configuration management of chat history. This property is now available within a new dedicated class `ChatHistoryProviderOptions`, which is designed to configure aspects of the `IChatHistoryProvider` implementation. +- The `KernelExtensions.cs` file in `Encamina.Enmarcha.SemanticKernel.Plugins.Chat` has been moved to a new location to better align with the project's structure. +- The method `ImportChatWithHistoryPluginUsingCosmosDb` has been renamed to `ImportChatWithHistoryPlugin` to reflect its decoupling from the specific storage implementation and to align with the new `IChatHistoryProvider` abstraction. This change requires consumers to update their method calls to match the new signature, and to provide an instance of `IChatHistoryProvider` in the dependency container. You can use `AddCosmosChatHistoryProvider` to add an instance of `IChatHistoryProvider` that uses Azure Cosmos DB for storing chat histories. +- Modified the `ChatAsync` method signature in `ChatWithHistoryPlugin` by changing the order of parameters and making `userName` and `locale` optional. This change requires consumers to update their method calls to match the new signature. + +### Major Changes +- Introduced `IChatHistoryProvider` interface and its corresponding implementation `ChatHistoryProvider`. This new abstraction layer provides a more flexible and decoupled way to work with chat history. +- Added a new extension method `AddCosmosChatHistoryProvider` to the service collection extensions. This method streamlines the setup and registration of `IChatHistoryProvider` that uses Azure Cosmos DB for storing chat histories. +- Removed direct dependency on `IAsyncRepository` in `ChatWithHistoryPlugin`, now relying on `IChatHistoryProvider` for chat history management. +- Added new calculation methods `LengthChatMessage` and `LengthChatMessageWithEncoding` in `ILengthFunctions` to determine the length of chat messages considering the author's role. + ### Minor Changes - Added `Description` property in `VersionSwaggerGenOptions`. - New text prompt function for extract KeyPhrases with specified locale, `KeyPhrasesLocaled`. @@ -27,6 +39,7 @@ Previous classification is not required if changes are simple or all belong to t - Bug fix: Temporary workaround for handling Http NotFound exception in `MemoryStoreExtender`. [(#72)](https://github.com/Encamina/enmarcha/issues/72) - Added new method `ExistsMemoryAsync` in `MemoryStoreExtender`. - Added a new optional parameter `Locale` to the functions of `QuestionAnsweringPlugin`, to specify the language of the response. +- Adjusted package references in `Encamina.Enmarcha.SemanticKernel.csproj` to include `Encamina.Enmarcha.Data.Abstractions`. ## [8.1.2] diff --git a/src/Encamina.Enmarcha.Data.Cosmos/Extensions/IServiceCollectionExtensions.cs b/src/Encamina.Enmarcha.Data.Cosmos/Extensions/IServiceCollectionExtensions.cs index eccd46f..3ce2658 100644 --- a/src/Encamina.Enmarcha.Data.Cosmos/Extensions/IServiceCollectionExtensions.cs +++ b/src/Encamina.Enmarcha.Data.Cosmos/Extensions/IServiceCollectionExtensions.cs @@ -46,7 +46,7 @@ private static void ConfigureCosmos(IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); - // Repositories should be ephemeral, therefore they shoud be created as they are needed! + // Repositories should be ephemeral, therefore they should be created as they are needed! services.TryAddScoped(typeof(ICosmosRepository<>), typeof(CosmosRepository<>)); services.TryAddScoped(typeof(IAsyncRepository<>), typeof(CosmosRepository<>)); } diff --git a/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs new file mode 100644 index 0000000..f4cacfb --- /dev/null +++ b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Encamina.Enmarcha.SemanticKernel.Abstractions; + +/// +/// Represents a collection of functions to work chat history. +/// +public interface IChatHistoryProvider +{ + /// + /// Loads chat history messages. + /// + /// + /// The maximum number of messages to load is configured in ChatHistoryProviderOptions.HistoryMaxMessages. + /// + /// The current chat history. + /// The unique identifier of the user that is owner of the chat. + /// The total remaining tokens available for loading messages from the chat history. + /// A cancellation token that can be used to receive notice of cancellation. + /// A that on completion indicates the asynchronous operation has executed. + public Task LoadChatMessagesHistoryAsync(ChatHistory chatHistory, string userId, int remainingTokens, CancellationToken cancellationToken); + + /// + /// Saves a chat message into the conversation history. + /// + /// The user's unique identifier. + /// The name of the role associated with the chat message. For example the `user`, the `assistant` or the `system`. + /// The message. + /// A cancellation token that can be used to receive notice of cancellation. + /// A that on completion indicates the asynchronous operation has executed. + public Task SaveChatMessagesHistoryAsync(string userId, string roleName, string message, CancellationToken cancellationToken); +} diff --git a/src/Encamina.Enmarcha.SemanticKernel.Abstractions/ILengthFunctions.cs b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/ILengthFunctions.cs index ceb2ca4..835907c 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Abstractions/ILengthFunctions.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/ILengthFunctions.cs @@ -1,4 +1,6 @@ -using SharpToken; +using Microsoft.SemanticKernel.ChatCompletion; + +using SharpToken; namespace Encamina.Enmarcha.SemanticKernel.Abstractions; @@ -30,6 +32,41 @@ public interface ILengthFunctions : AI.Abstractions.ILengthFunctions /// public static Func LengthByTokenCountUsingEncoding => (encoding, text) => string.IsNullOrEmpty(text) ? 0 : GetCachedEncoding(encoding).Encode(text).Count; + /// + /// Calculates the length of a chat message with the specified content and author role, using a provided length function. + /// + /// The content of the chat message. + /// The of the message. + /// A function to calculate the length of a string. + /// The total length for the chat message. + public static int LengthChatMessage(string content, AuthorRole authorRole, Func lengthFunction) + => InnerLengthChatMessage(content, null, authorRole, (s, _) => lengthFunction(s)); + + /// + /// Calculates the length of a chat message with the specified content, encoding and author role, using a provided length function with encoding. + /// + /// The content of the chat message. + /// The name of the GptEncoding. + /// The of the message. + /// A function to calculate the length of a string with encoding. + /// The total length for the chat message. + public static int LengthChatMessageWithEncoding(string content, string encoding, AuthorRole authorRole, Func lengthFunctionWithEncoding) + => InnerLengthChatMessage(content, encoding, authorRole, lengthFunctionWithEncoding); + + /// + /// Internal method to calculate the length of a chat message with the specified content, encoding and author role, using a provided length function with encoding. + /// + /// The content of the chat message. + /// The name of the GptEncoding. + /// The of the message. + /// A function to calculate the length of a string. + /// The total length for the chat message. + private static int InnerLengthChatMessage(string content, string encoding, AuthorRole authorRole, Func lengthFunction) + { + var tokenCount = authorRole == AuthorRole.System ? lengthFunction(encoding, "\n") : 0; + return tokenCount + lengthFunction(encoding, $"role:{authorRole.Label}") + lengthFunction(encoding, $"content:{content}"); + } + /// /// Gets the GptEncoding instance based on the specified encoding name, caching it for future use. /// diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/ChatHistoryProvider.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/ChatHistoryProvider.cs new file mode 100644 index 0000000..14acc77 --- /dev/null +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/ChatHistoryProvider.cs @@ -0,0 +1,107 @@ +using Encamina.Enmarcha.Data.Abstractions; + +using Encamina.Enmarcha.SemanticKernel.Abstractions; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; + +using Microsoft.Extensions.Options; + +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat; + +/// +public class ChatHistoryProvider : IChatHistoryProvider +{ + private readonly IAsyncRepository chatMessagesHistoryRepository; + private readonly Func tokensLengthFunction; + + private ChatHistoryProviderOptions options; + + /// + /// Initializes a new instance of the class. + /// + /// Function to calculate the length of a string (usually the chat messages) in tokens. + /// A valid instance of an asynchronous repository pattern implementation. + /// Configuration options for this provider. + public ChatHistoryProvider(Func tokensLengthFunction, IAsyncRepository chatMessagesHistoryRepository, IOptionsMonitor options) + { + this.tokensLengthFunction = tokensLengthFunction; + this.chatMessagesHistoryRepository = chatMessagesHistoryRepository; + this.options = options.CurrentValue; + + options.OnChange((newOptions) => this.options = newOptions); + } + + /// + public async Task LoadChatMessagesHistoryAsync(ChatHistory chatHistory, string userId, int remainingTokens, CancellationToken cancellationToken) + { + if (options.HistoryMaxMessages <= 0 || remainingTokens <= 0) + { + return; + } + + // Obtain the chat history for the user, ordered by timestamps descending to get the most recent messages first, and then take 'N' messages. + // This means that the first elements in the list are the most recent or newer messages, and the last elements in the list are the oldest messages. + var result = (await chatMessagesHistoryRepository.GetAllAsync(chatMessages => chatMessages.Where(chatMessage => chatMessage.UserId == userId) + .OrderByDescending(chatMessage => chatMessage.TimestampUtc) + .Take(options.HistoryMaxMessages), cancellationToken)).ToList(); + + // Previous lines loads into `result` a list of chat history messages like this (U = stands for `User`; A = stands for `Assistant`, a message from the AI): + // Newer Messages -------> Older Messages + // A10 U10 A9 U9 A8 U8 A7 U7 A6 U6 A5 U5 A4 U4 A3 U3 A2 U2 A1 U1 + + var assistantRoleName = AuthorRole.Assistant.ToString(); // Get this here to slightly improve performance... + + result.TakeWhile(item => + { + var itemRole = item.RoleName == assistantRoleName ? AuthorRole.Assistant : AuthorRole.User; + var tokensHistoryMessage = ILengthFunctions.LengthChatMessage(item.Message, itemRole, tokensLengthFunction); + + if (tokensHistoryMessage <= remainingTokens) + { + remainingTokens -= tokensHistoryMessage; + return true; + } + + return false; + + // The `TakeWhile` will reduce the number of chat history messages, taking the most recent messages until the remaining tokens are less than the tokens of the current message. + // Newer Messages -------> Older Messages + // A10 U10 A9 U9 A8 U8 A7 U7 A6 U6 + }).Reverse().ToList().ForEach(item => + { + // The (previous) `Reverse` will invert the order of the messages, so the oldest messages are the first elements in the list. + // This is required because the `ChatHistory` class stacks messages in the order they were received. + // The oldest came first, and the newest came last in the stack of messages. + // Older Messages -------> Newer Messages + // U6 A6 U7 A7 U8 A8 U9 A9 U10 A10 + + // In some scenarios, empty messages might be retrieved as `null` and serialized as such when creating the request message for the LLM resulting in an HTTP 400 error. + // Prevent this by setting the message as an empty string if the message is `null`. + var msg = item.Message ?? string.Empty; + + if (item.RoleName == assistantRoleName) + { + chatHistory.AddAssistantMessage(msg); + } + else + { + chatHistory.AddUserMessage(msg); + } + }); + } + + /// + public async Task SaveChatMessagesHistoryAsync(string userId, string roleName, string message, CancellationToken cancellationToken) + { + await chatMessagesHistoryRepository.AddAsync(new ChatMessageHistoryRecord() + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + RoleName = roleName, + Message = message, + TimestampUtc = DateTime.UtcNow, + }, cancellationToken); + } +} diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/IServiceCollectionExtensions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..13de246 --- /dev/null +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using CommunityToolkit.Diagnostics; + +using Encamina.Enmarcha.Data.Abstractions; +using Encamina.Enmarcha.Data.Cosmos; + +using Encamina.Enmarcha.SemanticKernel.Abstractions; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Extensions; + +/// +/// Extension methods for setting up services in a . +/// +public static class IServiceCollectionExtensions +{ + /// + /// + /// Add and configures the type as singleton service instance of the service to the . + /// + /// + /// Uses CosmosDB as the repository for chat history messages. + /// + /// + /// The to add services to. + /// The name of the Cosmos DB container to store the chat history messages. + /// + /// A function to calculate the length by tokens of the chat messages. These functions are usually available in the «mixin» interface . + /// + /// + /// This extension method uses a «Service Location» pattern provided by the to resolve the following dependencies: + /// + /// + /// ChatHistoryProviderOptions + /// + /// A required dependency of type used to retrieve the configuration options for this provider. This dependency should + /// be added using any of the extension method. + /// + /// + /// + /// ICosmosRepositoryFactory + /// + /// A required dependency of type used to create a (which + /// inherits from ) and manage chat history messages. Use the AddCosmos extension method to add this dependency. + /// + /// + /// + /// + /// The so that additional calls can be chained. + public static IServiceCollection AddCosmosChatHistoryProvider(this IServiceCollection services, string cosmosContainer, Func tokensLengthFunction) + { + Guard.IsNotNullOrWhiteSpace(cosmosContainer); + Guard.IsNotNull(tokensLengthFunction); + + services.AddSingleton(sp => + { + var chatMessagesHistoryRepository = sp.GetRequiredService().Create(cosmosContainer); + var chatHistoryProviderOptions = sp.GetRequiredService>(); + + return new ChatHistoryProvider(tokensLengthFunction, chatMessagesHistoryRepository, chatHistoryProviderOptions); + }); + + services.AddSingleton(sp => sp.GetRequiredService()); + + return services; + } +} \ No newline at end of file diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/KernelExtensions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs similarity index 67% rename from src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/KernelExtensions.cs rename to src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs index f370783..ce8a184 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/KernelExtensions.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs @@ -1,9 +1,6 @@ using CommunityToolkit.Diagnostics; using Encamina.Enmarcha.AI.OpenAI.Abstractions; -using Encamina.Enmarcha.Data.Abstractions; -using Encamina.Enmarcha.Data.Cosmos; - using Encamina.Enmarcha.SemanticKernel.Abstractions; using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; @@ -12,7 +9,7 @@ using Microsoft.SemanticKernel; -namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat; +namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Extensions; /// /// Extension methods on to import and configure plugins. @@ -33,10 +30,9 @@ public static class KernelExtensions /// /// /// - /// ICosmosRepositoryFactory + /// ChatHistoryProvider /// - /// A required dependency of type used to create a (which - /// inherits from ) and manage chat history messages. Use the AddCosmos extension method to add this dependency. + /// A required dependency of type used to create and manage chat history messages. /// /// /// @@ -44,21 +40,20 @@ public static class KernelExtensions /// The instance to add this plugin. /// A to resolve the dependencies. /// Configuration options for OpenAI services. - /// The name of the Cosmos DB container to store the chat history messages. /// /// A function to calculate the length by tokens of the chat messages. These functions are usually available in the «mixin» interface . /// /// A list of all the functions found in this plugin, indexed by function name. - public static KernelPlugin ImportChatWithHistoryPluginUsingCosmosDb(this Kernel kernel, IServiceProvider serviceProvider, OpenAIOptions openAIOptions, string cosmosContainer, Func tokensLengthFunction) + public static KernelPlugin ImportChatWithHistoryPlugin(this Kernel kernel, IServiceProvider serviceProvider, OpenAIOptions openAIOptions, Func tokensLengthFunction) { Guard.IsNotNull(serviceProvider); + Guard.IsNotNull(openAIOptions); Guard.IsNotNull(tokensLengthFunction); - Guard.IsNotNullOrWhiteSpace(cosmosContainer); var chatWithHistoryPluginOptions = serviceProvider.GetRequiredService>(); - var chatMessagesHistoryRepository = serviceProvider.GetRequiredService().Create(cosmosContainer); + var chatHistoryProvider = serviceProvider.GetRequiredService(); - var chatWithHistoryPlugin = new ChatWithHistoryPlugin(kernel, openAIOptions.ChatModelName, tokensLengthFunction, chatMessagesHistoryRepository, chatWithHistoryPluginOptions); + var chatWithHistoryPlugin = new ChatWithHistoryPlugin(kernel, openAIOptions.ChatModelName, tokensLengthFunction, chatHistoryProvider, chatWithHistoryPluginOptions); return kernel.ImportPluginFromObject(chatWithHistoryPlugin, PluginsInfo.ChatWithHistoryPlugin.Name); } diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatHistoryProviderOptions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatHistoryProviderOptions.cs new file mode 100644 index 0000000..f156b4d --- /dev/null +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatHistoryProviderOptions.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; + +/// +/// Configuration options for . +/// +public sealed class ChatHistoryProviderOptions +{ + /// + /// Gets the maximum number of messages to load from the chat history. + /// + [Required] + [Range(0, int.MaxValue)] + public int HistoryMaxMessages { get; init; } +} diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs index f4d73ef..1ef02c8 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using Encamina.Enmarcha.AI.OpenAI.Abstractions; -using Encamina.Enmarcha.Data.Abstractions; +using Encamina.Enmarcha.SemanticKernel.Abstractions; using Microsoft.Extensions.Options; @@ -16,7 +16,7 @@ namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; public class ChatWithHistoryPlugin { private readonly string chatModelName; - private readonly IAsyncRepository chatMessagesHistoryRepository; + private readonly IChatHistoryProvider chatHistoryProvider; private readonly Kernel kernel; private readonly Func tokensLengthFunction; @@ -28,13 +28,13 @@ public class ChatWithHistoryPlugin /// The instance of the semantic kernel to work with in this plugin. /// The name of the chat model used by this plugin. /// Function to calculate the length of a string (usually the chat messages) in tokens. - /// A valid instance of an asynchronous repository pattern implementation. + /// A valid instance of a chat history provider. /// Configuration options for this plugin. - public ChatWithHistoryPlugin(Kernel kernel, string chatModelName, Func tokensLengthFunction, IAsyncRepository chatMessagesHistoryRepository, IOptionsMonitor options) + public ChatWithHistoryPlugin(Kernel kernel, string chatModelName, Func tokensLengthFunction, IChatHistoryProvider chatHistoryProvider, IOptionsMonitor options) { this.kernel = kernel; this.chatModelName = chatModelName; - this.chatMessagesHistoryRepository = chatMessagesHistoryRepository; + this.chatHistoryProvider = chatHistoryProvider; this.options = options.CurrentValue; this.tokensLengthFunction = tokensLengthFunction; @@ -51,26 +51,37 @@ public ChatWithHistoryPlugin(Kernel kernel, string chatModelName, Func /// Allows users to chat and ask questions to an Artificial Intelligence. /// + /// A cancellation token that can be used to receive notice of cancellation. /// What the user says or asks when chatting. /// A unique identifier for the user when chatting. /// The name of the user. /// The preferred language of the user while chatting. - /// A cancellation token that can be used to receive notice of cancellation. /// A string representing the response from the Artificial Intelligence. [KernelFunction] [Description(@"Allows users to chat and ask questions to an Artificial Intelligence.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage(@"Design", @"CA1068:CancellationToken parameters must come last", Justification = @"We want to have optional parameters")] public virtual async Task ChatAsync( + CancellationToken cancellationToken, [Description(@"What the user says or asks when chatting")] string ask, [Description(@"A unique identifier for the user when chatting")] string userId, - [Description(@"The name of the user")] string userName, - [Description(@"The preferred language of the user while chatting")] string locale, - CancellationToken cancellationToken) + [Description(@"The name of the user")] string userName = null, + [Description(@"The preferred language of the user while chatting")] string locale = null) { - var systemPrompt = $@"{SystemPrompt} The name of the user is {userName}. The user prefers responses using the language identified as {locale}. Always answer using {locale} as language."; + var systemPrompt = SystemPrompt; + + if (!string.IsNullOrWhiteSpace(userName)) + { + systemPrompt += $@" The name of the user is {userName}."; + } + + if (!string.IsNullOrWhiteSpace(locale)) + { + systemPrompt += $@" The user prefers responses using the language identified as {locale}. Always answer using {locale} as language."; + } var chatModelMaxTokens = ModelInfo.GetById(chatModelName).MaxTokens; - var askTokens = GetChatMessageTokenCount(AuthorRole.User, ask); - var systemPromptTokens = GetChatMessageTokenCount(AuthorRole.System, systemPrompt); + var askTokens = ILengthFunctions.LengthChatMessage(ask, AuthorRole.User, tokensLengthFunction); + var systemPromptTokens = ILengthFunctions.LengthChatMessage(systemPrompt, AuthorRole.System, tokensLengthFunction); var remainingTokens = chatModelMaxTokens - askTokens - systemPromptTokens - (options.ChatRequestSettings.MaxTokens ?? 0); @@ -81,15 +92,15 @@ public virtual async Task ChatAsync( return await GetErrorMessageAsync(chatHistory, locale, systemPromptTokens, askTokens, chatModelMaxTokens, cancellationToken); } - await LoadChatHistoryAsync(chatHistory, userId, remainingTokens, cancellationToken); + await chatHistoryProvider.LoadChatMessagesHistoryAsync(chatHistory, userId, remainingTokens, cancellationToken); chatHistory.AddUserMessage(ask); var chatMessage = await kernel.GetRequiredService().GetChatMessageContentAsync(chatHistory, options.ChatRequestSettings, kernel, cancellationToken); var response = chatMessage.Content; - await SaveChatMessagesHistory(userId, AuthorRole.User.ToString(), ask, cancellationToken); // Save in chat history the user message (a.k.a. ask). - await SaveChatMessagesHistory(userId, AuthorRole.Assistant.ToString(), response, cancellationToken); // Save in chat history the assistant message (a.k.a. response). + await chatHistoryProvider.SaveChatMessagesHistoryAsync(userId, AuthorRole.User.ToString(), ask, cancellationToken); // Save in chat history the user message (a.k.a. ask). + await chatHistoryProvider.SaveChatMessagesHistoryAsync(userId, AuthorRole.Assistant.ToString(), response, cancellationToken); // Save in chat history the assistant message (a.k.a. response). return response; } @@ -114,108 +125,4 @@ protected virtual async Task GetErrorMessageAsync(ChatHistory chatHistor return chatMessage.Content; } - - /// - /// Loads chat history messages. - /// - /// - /// The maximum number of messages to load is configured in . - /// - /// The current chat history. - /// The unique identifier of the user that is owner of the chat. - /// The total remaining tokens available for loading messages from the chat history. - /// A cancellation token that can be used to receive notice of cancellation. - /// A that on completion indicates the asynchronous operation has executed. - protected virtual async Task LoadChatHistoryAsync(ChatHistory chatHistory, string userId, int remainingTokens, CancellationToken cancellationToken) - { - if (options.HistoryMaxMessages <= 0 || remainingTokens <= 0) - { - return; - } - - // Obtain the chat history for the user, ordered by timestamps descending to get the most recent messages first, and then take 'N' messages. - // This means that the first elements in the list are the most recent or newer messages, and the last elements in the list are the oldest messages. - var result = (await chatMessagesHistoryRepository.GetAllAsync(chatMessages => chatMessages.Where(chatMessage => chatMessage.UserId == userId) - .OrderByDescending(chatMessage => chatMessage.TimestampUtc) - .Take(options.HistoryMaxMessages), cancellationToken)).ToList(); - - // Previous lines loads into `result` a list of chat history messages like this (U = stands for `User`; A = stands for `Assistant`, a message from the AI): - // Newer Messages -------> Older Messages - // A10 U10 A9 U9 A8 U8 A7 U7 A6 U6 A5 U5 A4 U4 A3 U3 A2 U2 A1 U1 - - var assistantRoleName = AuthorRole.Assistant.ToString(); // Get this here to slightly improve performance... - - result.TakeWhile(item => - { - var itemRole = item.RoleName == assistantRoleName ? AuthorRole.Assistant : AuthorRole.User; - var tokensHistoryMessage = GetChatMessageTokenCount(itemRole, item.Message); - - if (tokensHistoryMessage <= remainingTokens) - { - remainingTokens -= tokensHistoryMessage; - return true; - } - - return false; - - // The `TakeWhile` will reduce the number of chat history messages, taking the most recent messages until the remaining tokens are less than the tokens of the current message. - // Newer Messages -------> Older Messages - // A10 U10 A9 U9 A8 U8 A7 U7 A6 U6 - }).Reverse().ToList().ForEach(item => - { - // The (previous) `Reverse` will invert the order of the messages, so the oldest messages are the first elements in the list. - // This is required because the `ChatHistory` class stacks messages in the order they were received. - // The oldest came first, and the newest came last in the stack of messages. - // Older Messages -------> Newer Messages - // U6 A6 U7 A7 U8 A8 U9 A9 U10 A10 - - // In some scenarios, empty messages might be retrieved as `null` and serialized as such when creating the request message for the LLM resulting in an HTTP 400 error. - // Prevent this by setting the message as an empty string if the message is `null`. - var msg = item.Message ?? string.Empty; - - if (item.RoleName == assistantRoleName) - { - chatHistory.AddAssistantMessage(msg); - } - else - { - chatHistory.AddUserMessage(msg); - } - }); - } - - /// - /// Saves a chat message into the conversation history. - /// - /// The user's unique identifier. - /// The name of the role associated with the chat message. For example the `user`, the `assistant` or the `system`. - /// The message. - /// A cancellation token that can be used to receive notice of cancellation. - /// A that on completion indicates the asynchronous operation has executed. - protected virtual async Task SaveChatMessagesHistory(string userId, string roleName, string message, CancellationToken cancellationToken) - { - await chatMessagesHistoryRepository.AddAsync(new ChatMessageHistoryRecord() - { - Id = Guid.NewGuid().ToString(), - UserId = userId, - RoleName = roleName, - Message = message, - TimestampUtc = DateTime.UtcNow, - }, cancellationToken); - } - - /// - /// Gets a rough token count of a message for the by following the syntax defined by Azure OpenAI's ChatMessage object. - /// - /// - /// The code of this method is based on . - /// - /// Author role of the message. - /// Content of the message. - /// The calculated token count for the given message. - protected virtual int GetChatMessageTokenCount(AuthorRole authorRole, string content) - { - var tokenCount = authorRole == AuthorRole.System ? tokensLengthFunction("\n") : 0; - return tokenCount + tokensLengthFunction($"role:{authorRole.Label}") + tokensLengthFunction($"content:{content}"); - } } diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs index 4d21db7..54a163e 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs @@ -9,13 +9,6 @@ namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; /// public class ChatWithHistoryPluginOptions { - /// - /// Gets the maximum number of messages to load from the chat history. - /// - [Required] - [Range(0, int.MaxValue)] - public virtual int HistoryMaxMessages { get; init; } - /// /// Gets a valid instance of (from Semantic Kernel) with settings for the chat request. /// diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md index a955d1c..54ef648 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md @@ -57,6 +57,9 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions // ... +var tokenLengthFunction = ILengthFunctions.LengthByTokenCount; +string cosmosContainer = "cosmosDbContainer"; // You probably want to save this in the appsettings or similar + // Or others configuration providers... builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); @@ -67,10 +70,15 @@ builder.Services.AddOptions().Bind(builder.Configuration. builder.Services.AddOptions().Bind(builder.Configuration.GetSection(nameof(ChatWithHistoryPluginOptions))) .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services.AddOptions().Bind(builder.Configuration.GetSection(nameof(ChatHistoryProviderOptions))) + .ValidateDataAnnotations() + .ValidateOnStart(); // Requieres Encamina.Enmarcha.Data.Cosmos builder.Services.AddCosmos(builder.Configuration); +builder.Services.AddCosmosChatHistoryProvider(cosmosContainer, tokenLengthFunction); + builder.Services.AddScoped(sp => { var kernel = new KernelBuilder() @@ -81,9 +89,7 @@ builder.Services.AddScoped(sp => // ... - string cosmosContainer = "cosmosDbContainer"; // You probably want to save this in the appsettings or similar - - kernel.ImportChatWithHistoryPluginUsingCosmosDb(sp, cosmosContainer, ILengthFunctions.LengthByTokenCount); + kernel.ImportChatWithHistoryPlugin(sp, openAIOptions, tokenLengthFunction); return kernel; }); diff --git a/src/Encamina.Enmarcha.SemanticKernel/Encamina.Enmarcha.SemanticKernel.csproj b/src/Encamina.Enmarcha.SemanticKernel/Encamina.Enmarcha.SemanticKernel.csproj index bbe5229..2138681 100644 --- a/src/Encamina.Enmarcha.SemanticKernel/Encamina.Enmarcha.SemanticKernel.csproj +++ b/src/Encamina.Enmarcha.SemanticKernel/Encamina.Enmarcha.SemanticKernel.csproj @@ -25,6 +25,7 @@ + @@ -44,7 +45,7 @@ - + From ebba93a7bf358e9a76784c50735e5099578d3f6e Mon Sep 17 00:00:00 2001 From: Mario Ramos Date: Mon, 12 Feb 2024 18:05:30 +0100 Subject: [PATCH 2/8] Move ChatWithHistoryPluginOptions and change some documentation --- CHANGELOG.md | 2 +- .../Extensions/KernelExtensions.cs | 1 + .../ChatWithHistoryPluginOptions.cs | 4 +++- .../Plugins/ChatWithHistoryPlugin.cs | 1 + .../README.md | 16 ++++++++-------- 5 files changed, 14 insertions(+), 10 deletions(-) rename src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/{Plugins => Options}/ChatWithHistoryPluginOptions.cs (82%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f169fb..3ed8c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,9 @@ Previous classification is not required if changes are simple or all belong to t ### Breaking Changes - Removed `HistoryMaxMessages` property from `ChatWithHistoryPluginOptions` as part of a refactor to improve the configuration management of chat history. This property is now available within a new dedicated class `ChatHistoryProviderOptions`, which is designed to configure aspects of the `IChatHistoryProvider` implementation. -- The `KernelExtensions.cs` file in `Encamina.Enmarcha.SemanticKernel.Plugins.Chat` has been moved to a new location to better align with the project's structure. - The method `ImportChatWithHistoryPluginUsingCosmosDb` has been renamed to `ImportChatWithHistoryPlugin` to reflect its decoupling from the specific storage implementation and to align with the new `IChatHistoryProvider` abstraction. This change requires consumers to update their method calls to match the new signature, and to provide an instance of `IChatHistoryProvider` in the dependency container. You can use `AddCosmosChatHistoryProvider` to add an instance of `IChatHistoryProvider` that uses Azure Cosmos DB for storing chat histories. - Modified the `ChatAsync` method signature in `ChatWithHistoryPlugin` by changing the order of parameters and making `userName` and `locale` optional. This change requires consumers to update their method calls to match the new signature. +- The `KernelExtensions.cs` and `ChatWithHistoryPluginOptions.cs` files in `Encamina.Enmarcha.SemanticKernel.Plugins.Chat` had been moved to a new location to better align with the project's structure. ### Major Changes - Introduced `IChatHistoryProvider` interface and its corresponding implementation `ChatHistoryProvider`. This new abstraction layer provides a more flexible and decoupled way to work with chat history. diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs index ce8a184..061cd5d 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Extensions/KernelExtensions.cs @@ -2,6 +2,7 @@ using Encamina.Enmarcha.AI.OpenAI.Abstractions; using Encamina.Enmarcha.SemanticKernel.Abstractions; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatWithHistoryPluginOptions.cs similarity index 82% rename from src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs rename to src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatWithHistoryPluginOptions.cs index 54a163e..12ec336 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPluginOptions.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Options/ChatWithHistoryPluginOptions.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; + using Microsoft.SemanticKernel.Connectors.OpenAI; -namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Plugins; +namespace Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; /// /// Configuration options for the . diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs index 1ef02c8..2e38511 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/Plugins/ChatWithHistoryPlugin.cs @@ -2,6 +2,7 @@ using Encamina.Enmarcha.AI.OpenAI.Abstractions; using Encamina.Enmarcha.SemanticKernel.Abstractions; +using Encamina.Enmarcha.SemanticKernel.Plugins.Chat.Options; using Microsoft.Extensions.Options; diff --git a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md index 54ef648..7c4bde7 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md +++ b/src/Encamina.Enmarcha.SemanticKernel.Plugins.Chat/README.md @@ -20,8 +20,8 @@ First, [install .NET CLI](https://learn.microsoft.com/en-us/dotnet/core/tools/). ## How to use -To use [ChatWithHistoryPlugin](/Plugins/ChatWithHistoryPlugin.cs), the usual approach is to import it as a plugin within Semantic Kernel. The simplest way to do this is by using the extension method [ImportChatWithHistoryPluginUsingCosmosDb](/KernelExtensions.cs), which handles the import of the Plugin into Semantic Kernel. However, some previous configuration is required before importing it. -First, you need to add the [SemanticKernelOptions](../Encamina.Enmarcha.SemanticKernel.Abstractions/SemanticKernelOptions.cs) and [ChatWithHistoryPluginOptions](./Plugins/ChatWithHistoryPluginOptions.cs) to your project configuration. You can achieve this by using any [configuration provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration). The followng code is an example of how the settings should look like using the `appsettings.json` file: +To use [ChatWithHistoryPlugin](/Plugins/ChatWithHistoryPlugin.cs), the usual approach is to import it as a plugin within Semantic Kernel. The simplest way to do this is by using the extension method [ImportChatWithHistoryPlugin](/Extensions/KernelExtensions.cs), which handles the import of the Plugin into Semantic Kernel. However, some previous configuration is required before importing it. +First, you need to add the [SemanticKernelOptions](../Encamina.Enmarcha.SemanticKernel.Abstractions/SemanticKernelOptions.cs), [ChatWithHistoryPluginOptions](./Options/ChatWithHistoryPluginOptions.cs) and [ChatHistoryProviderOptions](./Options/ChatHistoryProviderOptions.cs) to your project configuration. You can achieve this by using any [configuration provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration). The followng code is an example of how the settings should look like using the `appsettings.json` file: ```json { @@ -35,13 +35,15 @@ First, you need to add the [SemanticKernelOptions](../Encamina.Enmarcha.Semantic "Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // Key credential used to authenticate to an LLM resource }, "ChatWithHistoryPluginOptions": { - "HistoryMaxMessages": "gpt-35-turbo", // Name (sort of a unique identifier) of the model to use for chat "ChatRequestSettings": { "MaxTokens": 1000, // Maximum number of tokens to generate in the completion "Temperature": 0.8, // Controls the randomness of the completion. The higher the temperature, the more random the completion "TopP": 0.5, // Controls the diversity of the completion. The higher the TopP, the more diverse the completion. } }, + "ChatHistoryProviderOptions": { + HistoryMaxMessages": 12, + } // ... } ``` @@ -124,17 +126,15 @@ public class MyClass ### Advanced configurations -Take into consideration that the above code uses a Cosmos DB implementation as IAsyncRepository as an example. You can use other implementations. - -If you want to disable chat history, simply configure the [HistoryMaxMessages de ChatWithHistoryPluginOptions](/Plugins/ChatWithHistoryPluginOptions.cs) with a value of 0. +If you want to disable chat history, simply configure the [HistoryMaxMessages of ChatHistoryProviderOptions](/Options/ChatHistoryProviderOptions.cs) with a value of 0. You can also inherit from the ChatWithHistoryPlugin class and add the customizations you need. ```csharp public class MyCustomChatWithHistoryPlugin : ChatWithHistoryPlugin { - public MyCustomChatWithHistoryPlugin(Kernel kernel, string chatModelName, Func tokensLengthFunction, IAsyncRepository chatMessagesHistoryRepository, IOptionsMonitor options) - : base(kernel, chatModelName, tokensLengthFunction, chatMessagesHistoryRepository, options) + public MyCustomChatWithHistoryPlugin(Kernel kernel, string chatModelName, Func tokensLengthFunction, IChatHistoryProvider chatHistoryProvider, IOptionsMonitor options) + : base(kernel, chatModelName, tokensLengthFunction, chatHistoryProvider, options) { } From 165a65036286da5e7239ccbbce68646dd0bc0813 Mon Sep 17 00:00:00 2001 From: Mario Ramos Date: Tue, 13 Feb 2024 08:49:09 +0100 Subject: [PATCH 3/8] Update documentation --- .../IChatHistoryProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs index f4cacfb..3c48f6b 100644 --- a/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs +++ b/src/Encamina.Enmarcha.SemanticKernel.Abstractions/IChatHistoryProvider.cs @@ -11,7 +11,7 @@ public interface IChatHistoryProvider /// Loads chat history messages. /// /// - /// The maximum number of messages to load is configured in ChatHistoryProviderOptions.HistoryMaxMessages. + /// The maximum number of messages to load is configured in ChatHistoryProviderOptions.HistoryMaxMessages. /// /// The current chat history. /// The unique identifier of the user that is owner of the chat. From f90a7d842a5070a4a52b1b33252efc3d342a303f Mon Sep 17 00:00:00 2001 From: Mario Ramos Date: Tue, 13 Feb 2024 08:56:24 +0100 Subject: [PATCH 4/8] Update Directory.Build.props --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 433cb0c..969a62c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ 8.1.3 - preview-06 +