Skip to content

Commit

Permalink
Merge pull request #78 from Encamina/@mramos/V8.1.3
Browse files Browse the repository at this point in the history
Bump version `8.1.3` - Introduced `IChatHistoryProvider` interface and its corresponding implementation `ChatHistoryProvider`.
  • Loading branch information
MarioRamosEs authored Feb 13, 2024
2 parents 7c54496 + 4b8e8ec commit 950a8cc
Show file tree
Hide file tree
Showing 13 changed files with 333 additions and 155 deletions.
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,29 @@
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>
/// <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,110 @@
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/>
/// <remarks>
/// The maximum number of messages to load is configured in <c>ChatHistoryProviderOptions.HistoryMaxMessages</c>.
/// </remarks>
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

0 comments on commit 950a8cc

Please sign in to comment.