From 09ac9ce23d1117e0dd1660dde4222c983ef6a8a4 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 27 Jul 2023 15:10:33 -0700 Subject: [PATCH] Add persona tab (#35) ### Motivation and Context Add a persona tab to show/enable the following: 1. Meta prompt. 2. Meta prompt editing, 3. Memory (long term & short term) content. 4. Memory bias slider. ### Description 1. Webapi support for editing the meta prompt. 2. Webapi support for retrieving memory content (ChatMemoryController). 3. Webapi support for setting memory bias. 4. Webapp support for showing and enabling the features. 5. Update the initial bot greeting message. ![image](https://github.com/microsoft/chat-copilot/assets/12570346/8ac7f817-bcab-4b71-98c7-03f3afc3b8f9) ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [Contribution Guidelines](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- .../CopilotChat/Controllers/BotController.cs | 11 +- .../Controllers/ChatHistoryController.cs | 4 +- .../Controllers/ChatMemoryController.cs | 106 ++++++ .../Controllers/DocumentImportController.cs | 3 +- webapi/CopilotChat/Models/Bot.cs | 5 + webapi/CopilotChat/Models/ChatSession.cs | 20 +- webapi/CopilotChat/Options/PromptsOptions.cs | 18 +- .../Skills/ChatSkills/ChatSkill.cs | 25 +- .../ChatSkills/SemanticChatMemoryExtractor.cs | 3 +- .../ChatSkills/SemanticChatMemorySkill.cs | 64 +++- webapi/appsettings.json | 3 +- .../src/components/chat/ChatResourceList.tsx | 360 ------------------ webapp/src/components/chat/ChatWindow.tsx | 2 +- .../ChatHistoryDocumentContent.tsx | 2 +- .../chat/persona/MemoryBiasSlider.tsx | 98 +++++ .../components/chat/persona/PromptEditor.tsx | 94 +++++ .../components/chat/shared/EditChatName.tsx | 8 +- .../src/components/chat/tabs/DocumentsTab.tsx | 351 ++++++++++++++++- .../src/components/chat/tabs/PersonaTab.tsx | 71 +++- webapp/src/components/shared/BundledIcons.tsx | 2 +- webapp/src/libs/hooks/useChat.ts | 45 ++- webapp/src/libs/models/ChatSession.ts | 2 + webapp/src/libs/services/ChatService.ts | 22 +- .../redux/features/conversations/ChatState.ts | 2 + .../conversations/ConversationsState.ts | 5 + .../conversations/conversationsSlice.ts | 16 + 26 files changed, 936 insertions(+), 406 deletions(-) create mode 100644 webapi/CopilotChat/Controllers/ChatMemoryController.cs delete mode 100644 webapp/src/components/chat/ChatResourceList.tsx create mode 100644 webapp/src/components/chat/persona/MemoryBiasSlider.tsx create mode 100644 webapp/src/components/chat/persona/PromptEditor.tsx diff --git a/webapi/CopilotChat/Controllers/BotController.cs b/webapi/CopilotChat/Controllers/BotController.cs index 60c178944..5e8b17510 100644 --- a/webapi/CopilotChat/Controllers/BotController.cs +++ b/webapi/CopilotChat/Controllers/BotController.cs @@ -115,15 +115,15 @@ public async Task> UploadAsync( // Upload chat history into chat repository and embeddings into memory. - // 1. Create a new chat and get the chat id. - newChat = new ChatSession(chatTitle); + // Create a new chat and get the chat id. + newChat = new ChatSession(chatTitle, bot.SystemDescription); await this._chatRepository.CreateAsync(newChat); await this._chatParticipantRepository.CreateAsync(new ChatParticipant(userId, newChat.Id)); chatId = newChat.Id; string oldChatId = bot.ChatHistory.First().ChatId; - // 2. Update the app's chat storage. + // Update the app's chat storage. foreach (var message in bot.ChatHistory) { var chatMessage = new ChatMessage( @@ -139,7 +139,7 @@ public async Task> UploadAsync( await this._chatMessageRepository.CreateAsync(chatMessage); } - // 3. Update the memory. + // Update the memory. await this.BulkUpsertMemoryRecordsAsync(oldChatId, chatId, bot.Embeddings, cancellationToken); // TODO: [Issue #47] Revert changes if any of the actions failed @@ -254,6 +254,9 @@ private async Task CreateBotAsync(IKernel kernel, Guid chatId) ChatSession chat = await this._chatRepository.FindByIdAsync(chatIdString); bot.ChatTitle = chat.Title; + // get the system description + bot.SystemDescription = chat.SystemDescription; + // get the chat history bot.ChatHistory = await this.GetAllChatMessagesAsync(chatIdString); diff --git a/webapi/CopilotChat/Controllers/ChatHistoryController.cs b/webapi/CopilotChat/Controllers/ChatHistoryController.cs index 5b384815f..b01799eed 100644 --- a/webapi/CopilotChat/Controllers/ChatHistoryController.cs +++ b/webapi/CopilotChat/Controllers/ChatHistoryController.cs @@ -77,7 +77,7 @@ public async Task CreateChatSessionAsync([FromBody] CreateChatPar } // Create a new chat session - var newChat = new ChatSession(chatParameter.Title); + var newChat = new ChatSession(chatParameter.Title, this._promptOptions.SystemDescription); await this._sessionRepository.CreateAsync(newChat); var initialBotMessage = this._promptOptions.InitialBotMessage; @@ -201,6 +201,8 @@ public async Task EditChatSessionAsync( if (await this._sessionRepository.TryFindByIdAsync(chatId, v => chat = v)) { chat!.Title = chatParameters.Title; + chat!.SystemDescription = chatParameters.SystemDescription; + chat!.MemoryBalance = chatParameters.MemoryBalance; await this._sessionRepository.UpsertAsync(chat); await messageRelayHubContext.Clients.Group(chatId).SendAsync(ChatEditedClientCall, chat); return this.Ok(chat); diff --git a/webapi/CopilotChat/Controllers/ChatMemoryController.cs b/webapi/CopilotChat/Controllers/ChatMemoryController.cs new file mode 100644 index 000000000..51d309310 --- /dev/null +++ b/webapi/CopilotChat/Controllers/ChatMemoryController.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.SemanticKernel.Memory; +using SemanticKernel.Service.CopilotChat.Options; +using SemanticKernel.Service.CopilotChat.Skills.ChatSkills; +using SemanticKernel.Service.CopilotChat.Storage; + +namespace SemanticKernel.Service.CopilotChat.Controllers; + +/// +/// Controller for retrieving semantic memory data of chat sessions. +/// +[ApiController] +[Authorize] +public class ChatMemoryController : ControllerBase +{ + private readonly ILogger _logger; + + private readonly PromptsOptions _promptOptions; + + private readonly ChatSessionRepository _chatSessionRepository; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The prompts options. + /// The chat session repository. + public ChatMemoryController( + ILogger logger, + IOptions promptsOptions, + ChatSessionRepository chatSessionRepository) + { + this._logger = logger; + this._promptOptions = promptsOptions.Value; + this._chatSessionRepository = chatSessionRepository; + } + + /// + /// Gets the semantic memory for the chat session. + /// + /// The semantic text memory instance. + /// The chat id. + /// Name of the memory type. + [HttpGet] + [Route("chatMemory/{chatId:guid}/{memoryName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task GetSemanticMemoriesAsync( + [FromServices] ISemanticTextMemory semanticTextMemory, + [FromRoute] string chatId, + [FromRoute] string memoryName) + { + // Make sure the chat session exists. + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => _ = v)) + { + this._logger.LogWarning("Chat session: {0} does not exist.", chatId); + return this.BadRequest($"Chat session: {chatId} does not exist."); + } + + // Make sure the memory name is valid. + if (!this.ValidateMemoryName(memoryName)) + { + this._logger.LogWarning("Memory name: {0} is invalid.", memoryName); + return this.BadRequest($"Memory name: {memoryName} is invalid."); + } + + // Gather the requested semantic memory. + // ISemanticTextMemory doesn't support retrieving all memories. + // Will use a dummy query since we don't care about relevance. An empty string will cause exception. + // minRelevanceScore is set to 0.0 to return all memories. + List memories = new(); + var results = semanticTextMemory.SearchAsync( + SemanticChatMemoryExtractor.MemoryCollectionName(chatId, memoryName), + "abc", + limit: 100, + minRelevanceScore: 0.0); + await foreach (var memory in results) + { + memories.Add(memory.Metadata.Text); + } + + return this.Ok(memories); + } + + #region Private + + /// + /// Validates the memory name. + /// + /// Name of the memory requested. + /// True if the memory name is valid. + private bool ValidateMemoryName(string memoryName) + { + return this._promptOptions.MemoryMap.ContainsKey(memoryName); + } + + # endregion +} \ No newline at end of file diff --git a/webapi/CopilotChat/Controllers/DocumentImportController.cs b/webapi/CopilotChat/Controllers/DocumentImportController.cs index de9b2ac43..7b665d036 100644 --- a/webapi/CopilotChat/Controllers/DocumentImportController.cs +++ b/webapi/CopilotChat/Controllers/DocumentImportController.cs @@ -155,8 +155,9 @@ public async Task ImportDocumentsAsync( } var chatId = documentImportForm.ChatId.ToString(); + var userId = documentImportForm.UserId; await messageRelayHubContext.Clients.Group(chatId) - .SendAsync(ReceiveMessageClientCall, chatMessage, chatId); + .SendAsync(ReceiveMessageClientCall, chatId, userId, chatMessage); return this.Ok(chatMessage); } diff --git a/webapi/CopilotChat/Models/Bot.cs b/webapi/CopilotChat/Models/Bot.cs index f0130daef..a5c760ffc 100644 --- a/webapi/CopilotChat/Models/Bot.cs +++ b/webapi/CopilotChat/Models/Bot.cs @@ -26,6 +26,11 @@ public class Bot /// public string ChatTitle { get; set; } = string.Empty; + /// + /// The system description of the chat that is used to generate responses. + /// + public string SystemDescription { get; set; } = string.Empty; + /// /// The chat history. It contains all the messages in the conversation with the bot. /// diff --git a/webapi/CopilotChat/Models/ChatSession.cs b/webapi/CopilotChat/Models/ChatSession.cs index 9cb58ca86..25d315564 100644 --- a/webapi/CopilotChat/Models/ChatSession.cs +++ b/webapi/CopilotChat/Models/ChatSession.cs @@ -29,10 +29,28 @@ public class ChatSession : IStorageEntity [JsonPropertyName("createdOn")] public DateTimeOffset CreatedOn { get; set; } - public ChatSession(string title) + /// + /// System description of the chat that is used to generate responses. + /// + public string SystemDescription { get; set; } + + /// + /// The balance between long term memory and working term memory. + /// The higher this value, the more the system will rely on long term memory by lowering + /// the relevance threshold of long term memory and increasing the threshold score of working memory. + /// + public double MemoryBalance { get; set; } = 0.5; + + /// + /// Initializes a new instance of the class. + /// + /// The title of the chat. + /// The system description of the chat. + public ChatSession(string title, string systemDescription) { this.Id = Guid.NewGuid().ToString(); this.Title = title; this.CreatedOn = DateTimeOffset.Now; + this.SystemDescription = systemDescription; } } diff --git a/webapi/CopilotChat/Options/PromptsOptions.cs b/webapi/CopilotChat/Options/PromptsOptions.cs index 3b83b30ad..a093fb35f 100644 --- a/webapi/CopilotChat/Options/PromptsOptions.cs +++ b/webapi/CopilotChat/Options/PromptsOptions.cs @@ -43,10 +43,16 @@ public class PromptsOptions internal double ExternalInformationContextWeight { get; } = 0.3; /// - /// Minimum relevance of a semantic memory to be included in the final prompt. - /// The higher the value, the answer will be more relevant to the user intent. + /// Upper bound of the relevancy score of a semantic memory to be included in the final prompt. + /// The actual relevancy score is determined by the memory balance. + /// + internal double SemanticMemoryRelevanceUpper { get; } = 0.9; + + /// + /// Lower bound of the relevancy score of a semantic memory to be included in the final prompt. + /// The actual relevancy score is determined by the memory balance. /// - internal double SemanticMemoryMinRelevance { get; } = 0.8; + internal double SemanticMemoryRelevanceLower { get; } = 0.6; /// /// Minimum relevance of a document memory to be included in the final prompt. @@ -156,4 +162,10 @@ public class PromptsOptions internal double IntentTopP { get; } = 1; internal double IntentPresencePenalty { get; } = 0.5; internal double IntentFrequencyPenalty { get; } = 0.5; + + /// + /// Copy the options in case they need to be modified per chat. + /// + /// A shallow copy of the options. + internal PromptsOptions Copy() => (PromptsOptions)this.MemberwiseClone(); } diff --git a/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs b/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs index 1b168824f..b88d5335f 100644 --- a/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs +++ b/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs @@ -88,11 +88,13 @@ public ChatSkill( this._kernel = kernel; this._chatMessageRepository = chatMessageRepository; this._chatSessionRepository = chatSessionRepository; - this._promptOptions = promptOptions.Value; this._messageRelayHubContext = messageRelayHubContext; + // Clone the prompt options to avoid modifying the original prompt options. + this._promptOptions = promptOptions.Value.Copy(); this._semanticChatMemorySkill = new SemanticChatMemorySkill( - promptOptions); + promptOptions, + chatSessionRepository); this._documentMemorySkill = new DocumentMemorySkill( promptOptions, documentImportOptions); @@ -259,6 +261,9 @@ public async Task ChatAsync( [Description("ID of the response message for planner"), DefaultValue(null), SKName("responseMessageId")] string? messageId, SKContext context) { + // Set the system description in the prompt options + await SetSystemDescriptionAsync(chatId); + // Save this new message to memory such that subsequent chat responses can use it await this.UpdateBotResponseStatusOnClient(chatId, "Saving user message to chat history"); await this.SaveNewMessageAsync(message, userId, userName, chatId, messageType); @@ -721,5 +726,21 @@ private async Task UpdateBotResponseStatusOnClient(string chatId, string status) await this._messageRelayHubContext.Clients.Group(chatId).SendAsync("ReceiveBotResponseStatus", chatId, status); } + /// + /// Set the system description in the prompt options. + /// + /// Id of the chat session + /// Throw if the chat session does not exist. + private async Task SetSystemDescriptionAsync(string chatId) + { + ChatSession? chatSession = null; + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => chatSession = v)) + { + throw new ArgumentException("Chat session does not exist."); + } + + this._promptOptions.SystemDescription = chatSession!.SystemDescription; + } + # endregion } diff --git a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs index e08bd64f0..a2a2461c7 100644 --- a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs +++ b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs @@ -123,11 +123,12 @@ internal static async Task CreateMemoryAsync( { var memoryCollectionName = SemanticChatMemoryExtractor.MemoryCollectionName(chatId, memoryName); + // Search if there is already a memory item that has a high similarity score with the new item. var memories = await context.Memory.SearchAsync( collection: memoryCollectionName, query: item.ToFormattedString(), limit: 1, - minRelevanceScore: options.SemanticMemoryMinRelevance, + minRelevanceScore: options.SemanticMemoryRelevanceUpper, cancellationToken: context.CancellationToken ) .ToListAsync() diff --git a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs index b9d7efaeb..8f15d88f5 100644 --- a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs +++ b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -7,7 +8,9 @@ using Microsoft.Extensions.Options; using Microsoft.SemanticKernel.Memory; using Microsoft.SemanticKernel.SkillDefinition; +using SemanticKernel.Service.CopilotChat.Models; using SemanticKernel.Service.CopilotChat.Options; +using SemanticKernel.Service.CopilotChat.Storage; namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; @@ -16,18 +19,19 @@ namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; /// public class SemanticChatMemorySkill { - /// - /// Prompt settings. - /// private readonly PromptsOptions _promptOptions; + private readonly ChatSessionRepository _chatSessionRepository; + /// /// Create a new instance of SemanticChatMemorySkill. /// public SemanticChatMemorySkill( - IOptions promptOptions) + IOptions promptOptions, + ChatSessionRepository chatSessionRepository) { this._promptOptions = promptOptions.Value; + this._chatSessionRepository = chatSessionRepository; } /// @@ -43,6 +47,12 @@ public async Task QueryMemoriesAsync( [Description("Maximum number of tokens")] int tokenLimit, ISemanticTextMemory textMemory) { + ChatSession? chatSession = null; + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => chatSession = v)) + { + throw new ArgumentException($"Chat session {chatId} not found."); + } + var remainingToken = tokenLimit; // Search for relevant memories. @@ -53,7 +63,7 @@ public async Task QueryMemoriesAsync( SemanticChatMemoryExtractor.MemoryCollectionName(chatId, memoryName), query, limit: 100, - minRelevanceScore: this._promptOptions.SemanticMemoryMinRelevance); + minRelevanceScore: this.CalculateRelevanceThreshold(memoryName, chatSession!.MemoryBalance)); await foreach (var memory in results) { relevantMemories.Add(memory); @@ -85,4 +95,48 @@ public async Task QueryMemoriesAsync( return $"Past memories (format: [memory type]