Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bump version 8.1.3 - Introduced IChatHistoryProvider interface and its corresponding implementation ChatHistoryProvider. #78

Merged
merged 9 commits into from
Feb 13, 2024
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ Previous classification is not required if changes are simple or all belong to t

## [8.1.3]

### Major change
### 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 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.
- 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<ChatMessageHistoryRecord>` 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.
- Updated dependencies:
- Updated `Azure.AI.OpenAI` from `1.0.0-beta12` to `1.0.0-beta13` (which provides some fixes for Function Calling).
- Updated `Azure.Data.Tables` from `12.8.2` to `12.8.3`.
Expand Down Expand Up @@ -48,6 +58,7 @@ Previous classification is not required if changes are simple or all belong to t
- `GroupNameKeyAuthorizationMiddleware` a middleware that provides key authorization for OpenAPI specifications based on the group name of an API.
- `GroupNameKeyAuthenticationOptions` an options class to configure the `GroupNameKeyAuthorizationMiddleware`.
- Extensions method on `IApplicationBuilder` to add the `GroupNameKeyAuthorizationMiddleware`. For more information, refer to the package `README.md`.
- Adjusted package references in `Encamina.Enmarcha.SemanticKernel.csproj` to include `Encamina.Enmarcha.Data.Abstractions`.

## [8.1.2]

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<PropertyGroup>
<VersionPrefix>8.1.3</VersionPrefix>
<VersionSuffix>preview-07</VersionSuffix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>

<!--
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ private static void ConfigureCosmos(IServiceCollection services)
services.TryAddSingleton<ICosmosInitializer, CosmosInitializer>();
services.TryAddSingleton<ICosmosRepositoryFactory, CosmosRepositoryFactory>();

// 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<>));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.SemanticKernel.ChatCompletion;

namespace Encamina.Enmarcha.SemanticKernel.Abstractions;

/// <summary>
/// Represents a collection of functions to work chat history.
/// </summary>
public interface IChatHistoryProvider
{
/// <summary>
/// Loads chat history messages.
/// </summary>
/// <remarks>
/// The maximum number of messages to load is configured in <c>ChatHistoryProviderOptions.HistoryMaxMessages</c>.
LuisM000 marked this conversation as resolved.
Show resolved Hide resolved
/// </remarks>
/// <param name="chatHistory">The current chat history.</param>
/// <param name="userId">The unique identifier of the user that is owner of the chat.</param>
/// <param name="remainingTokens">The total remaining tokens available for loading messages from the chat history.</param>
/// <param name="cancellationToken">A cancellation token that can be used to receive notice of cancellation.</param>
/// <returns>A <see cref="Task"/> that on completion indicates the asynchronous operation has executed.</returns>
Task LoadChatMessagesHistoryAsync(ChatHistory chatHistory, string userId, int remainingTokens, CancellationToken cancellationToken);

/// <summary>
/// Saves a chat message into the conversation history.
/// </summary>
/// <param name="userId">The user's unique identifier.</param>
/// <param name="roleName">The name of the role associated with the chat message. For example the `user`, the `assistant` or the `system`.</param>
/// <param name="message">The message.</param>
/// <param name="cancellationToken">A cancellation token that can be used to receive notice of cancellation.</param>
/// <returns>A <see cref="Task"/> that on completion indicates the asynchronous operation has executed.</returns>
Task SaveChatMessagesHistoryAsync(string userId, string roleName, string message, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using SharpToken;
using Microsoft.SemanticKernel.ChatCompletion;

using SharpToken;

namespace Encamina.Enmarcha.SemanticKernel.Abstractions;

Expand Down Expand Up @@ -30,6 +32,40 @@ public interface ILengthFunctions : AI.Abstractions.ILengthFunctions
/// <seealso href="https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb"/>
public static Func<string, string, int> LengthByTokenCountUsingEncoding => (encoding, text) => string.IsNullOrEmpty(text) ? 0 : GetCachedEncoding(encoding).Encode(text).Count;

/// <summary>
/// Calculates the length of a chat message with the specified content and author role, using a provided length function.
/// </summary>
/// <param name="content">The content of the chat message.</param>
/// <param name="authorRole">The <see cref="AuthorRole"/> of the message.</param>
/// <param name="lengthFunction">A function to calculate the length of a string.</param>
/// <returns>The total length for the chat message.</returns>
public static int LengthChatMessage(string content, AuthorRole authorRole, Func<string, int> lengthFunction)
=> InnerLengthChatMessage(content, authorRole, lengthFunction);

/// <summary>
/// Calculates the length of a chat message with the specified content, encoding and author role, using a provided length function with encoding.
/// </summary>
/// <param name="content">The content of the chat message.</param>
/// <param name="encoding">The name of the GptEncoding.</param>
/// <param name="authorRole">The <see cref="AuthorRole"/> of the message.</param>
/// <param name="lengthFunctionWithEncoding">A function to calculate the length of a string with encoding.</param>
/// <returns>The total length for the chat message.</returns>
public static int LengthChatMessageWithEncoding(string content, string encoding, AuthorRole authorRole, Func<string, string, int> lengthFunctionWithEncoding)
=> InnerLengthChatMessage(content, authorRole, s => lengthFunctionWithEncoding(encoding, s));

/// <summary>
/// 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.
/// </summary>
/// <param name="content">The content of the chat message.</param>
/// <param name="authorRole">The <see cref="AuthorRole"/> of the message.</param>
/// <param name="lengthFunction">A function to calculate the length of a string.</param>
/// <returns>The total length for the chat message.</returns>
private static int InnerLengthChatMessage(string content, AuthorRole authorRole, Func<string, int> lengthFunction)
{
var tokenCount = authorRole == AuthorRole.System ? lengthFunction("\n") : 0;
return tokenCount + lengthFunction($"role:{authorRole.Label}") + lengthFunction($"content:{content}");
}

/// <summary>
/// Gets the GptEncoding instance based on the specified encoding name, caching it for future use.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <inheritdoc/>
public class ChatHistoryProvider : IChatHistoryProvider
{
private readonly IAsyncRepository<ChatMessageHistoryRecord> chatMessagesHistoryRepository;
private readonly Func<string, int> tokensLengthFunction;

private ChatHistoryProviderOptions options;

/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryProvider"/> class.
/// </summary>
/// <param name="tokensLengthFunction">Function to calculate the length of a string (usually the chat messages) in tokens.</param>
/// <param name="chatMessagesHistoryRepository">A valid instance of an asynchronous repository pattern implementation.</param>
/// <param name="options">Configuration options for this provider.</param>
public ChatHistoryProvider(Func<string, int> tokensLengthFunction, IAsyncRepository<ChatMessageHistoryRecord> chatMessagesHistoryRepository, IOptionsMonitor<ChatHistoryProviderOptions> options)
{
this.tokensLengthFunction = tokensLengthFunction;
this.chatMessagesHistoryRepository = chatMessagesHistoryRepository;
this.options = options.CurrentValue;

options.OnChange((newOptions) => this.options = newOptions);
}

/// <inheritdoc/>
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);
}
});
}

/// <inheritdoc/>
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Extension methods for setting up services in a <see cref="IServiceCollection"/>.
/// </summary>
public static class IServiceCollectionExtensions
{
/// <summary>
/// <para>
/// Add and configures the <see cref="ChatHistoryProvider"/> type as singleton service instance of the <see cref="IChatHistoryProvider"/> service to the <see cref="IServiceCollection"/>.
/// </para>
/// <para>
/// Uses CosmosDB as the repository for chat history messages.
/// </para>
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="cosmosContainer">The name of the Cosmos DB container to store the chat history messages.</param>
/// <param name="tokensLengthFunction">
/// A function to calculate the length by tokens of the chat messages. These functions are usually available in the «mixin» interface <see cref="ILengthFunctions"/>.
/// </param>
/// <remarks>
/// This extension method uses a «Service Location» pattern provided by the <see cref="IServiceProvider"/> to resolve the following dependencies:
/// <list type="bullet">
/// <item>
/// <term>ChatHistoryProviderOptions</term>
/// <description>
/// A required dependency of type <see cref="ChatHistoryProviderOptions"/> used to retrieve the configuration options for this provider. This dependency should
/// be added using any of the <see cref="OptionsServiceCollectionExtensions.AddOptions"/> extension method.
/// </description>
/// </item>
/// <item>
/// <term>ICosmosRepositoryFactory</term>
/// <description>
/// A required dependency of type <see cref="ICosmosRepositoryFactory"/> used to create a <see cref="ICosmosRepository{T}"/> (which
/// inherits from <see cref="IAsyncRepository{T}"/>) and manage chat history messages. Use the <c>AddCosmos</c> extension method to add this dependency.
/// </description>
/// </item>
/// </list>
/// </remarks>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
public static IServiceCollection AddCosmosChatHistoryProvider(this IServiceCollection services, string cosmosContainer, Func<string, int> tokensLengthFunction)
{
Guard.IsNotNullOrWhiteSpace(cosmosContainer);
Guard.IsNotNull(tokensLengthFunction);

services.AddSingleton(sp =>
{
var chatMessagesHistoryRepository = sp.GetRequiredService<ICosmosRepositoryFactory>().Create<ChatMessageHistoryRecord>(cosmosContainer);
var chatHistoryProviderOptions = sp.GetRequiredService<IOptionsMonitor<ChatHistoryProviderOptions>>();

return new ChatHistoryProvider(tokensLengthFunction, chatMessagesHistoryRepository, chatHistoryProviderOptions);
});

services.AddSingleton<IChatHistoryProvider, ChatHistoryProvider>(sp => sp.GetRequiredService<ChatHistoryProvider>());

return services;
}
}
Loading
Loading