From 094807fb05b3a5d26031bcd56c56e6b1723ad92a Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Fri, 8 Dec 2023 17:43:25 +0200 Subject: [PATCH 1/6] change prompt to process inpus without product request --- .../Services/WishlistsService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs b/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs index ddc2ec5..b4c5ab9 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs @@ -83,7 +83,7 @@ public class WishlistsService : IWishlistsService new OpenAiMessage { Role = OpenAiRole.System.RequestConvert(), - Content = "You will be provided with a general information about some product and your task is to generate general (not specific to any company or brand) chat name where recommendations on which specific product to buy will be given. Only name he product without adverbs and adjectives\nExamples:\n - Prompt: Hub For Macbook. Answer: Macbook Hub\n - Prompt: What is the best power bank for MacBook with capacity 20000 mAh and power near 20V? Answer: Macbook Powerbank" + Content = "You will be provided with a general information about some product and your task is to generate general (not specific to any company or brand) chat name where recommendations on which specific product to buy will be given. Only name he product without adverbs and adjectives. Limit the name length to 5 words\nExamples:\n - Prompt: Hub For Macbook. Answer: Macbook Hub\n - Prompt: What is the best power bank for MacBook with capacity 20000 mAh and power near 20V? Answer: Macbook Powerbank\nIf the information tells nothing about some product answer with short generic name" }, new OpenAiMessage { From 68ab565800c4c16222edecf8eff4d5cda9ea4306 Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Mon, 11 Dec 2023 00:49:08 +0000 Subject: [PATCH 2/6] SA-197 Add suggestions to product search - Change OpenAI prompt - Update logic OpenAI response handling - Start refactoring of SearchProductAsync() - Add GetWishlistMessagesAsync() to MessagesRepository to retrieve all messages for wishlist --- .../Controllers/ProductsSearchController.cs | 15 +- .../IRepositories/IMessagerepository.cs | 3 + .../Enums/OpenAiRole.cs | 18 +-- .../Services/OpenAiService.cs | 25 ++- .../Services/ProductService.cs | 148 ++++++++---------- .../Services/WishlistsService.cs | 4 +- .../Repositories/MessagesRepository.cs | 8 + 7 files changed, 109 insertions(+), 112 deletions(-) diff --git a/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs index f00b26a..38d4f33 100644 --- a/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs +++ b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs @@ -8,7 +8,6 @@ using ShoppingAssistantApi.Domain.Enums; namespace ShoppingAssistantApi.Api.Controllers; -[Authorize] public class ProductsSearchController : BaseController { private readonly IProductService _productService; @@ -21,16 +20,10 @@ public class ProductsSearchController : BaseController _wishlistsService = wishlistsService; } + [Authorize] [HttpPost("search/{wishlistId}")] - public async Task StreamDataToClient(string wishlistId, [FromBody]MessageCreateDto message, CancellationToken cancellationToken) + public async Task StreamDataToClient(string wishlistId, [FromBody] MessageCreateDto message, CancellationToken cancellationToken) { - var dto = new MessageDto() - { - Text = message.Text, - Role = MessageRoles.User.ToString(), - }; - await _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, dto, cancellationToken); - Response.Headers.Add("Content-Type", "text/event-stream"); Response.Headers.Add("Cache-Control", "no-cache"); Response.Headers.Add("Connection", "keep-alive"); @@ -43,8 +36,8 @@ public class ProductsSearchController : BaseController var serverSentEvent = $"event: {sse.Event}\ndata: {chunk}\n\n"; - await Response.WriteAsync(serverSentEvent); - await Response.Body.FlushAsync(); + await Response.WriteAsync(serverSentEvent, cancellationToken: cancellationToken); + await Response.Body.FlushAsync(cancellationToken); } } diff --git a/ShoppingAssistantApi.Application/IRepositories/IMessagerepository.cs b/ShoppingAssistantApi.Application/IRepositories/IMessagerepository.cs index 3d0483e..f3aa9b6 100644 --- a/ShoppingAssistantApi.Application/IRepositories/IMessagerepository.cs +++ b/ShoppingAssistantApi.Application/IRepositories/IMessagerepository.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using MongoDB.Bson; using ShoppingAssistantApi.Domain.Entities; namespace ShoppingAssistantApi.Application.IRepositories; @@ -6,4 +7,6 @@ namespace ShoppingAssistantApi.Application.IRepositories; public interface IMessagesRepository : IBaseRepository { Task> GetPageStartingFromEndAsync(int pageNumber, int pageSize, Expression> predicate, CancellationToken cancellationToken); + + Task> GetWishlistMessagesAsync(ObjectId wishlistId, CancellationToken cancellationToken); } diff --git a/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs b/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs index a01e6a5..5131bce 100644 --- a/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs +++ b/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs @@ -9,18 +9,14 @@ public enum OpenAiRole public static class OpenAiRoleExtensions { - public static string RequestConvert(this OpenAiRole role) + public static string ToRequestString(this OpenAiRole role) { - switch (role) + return role switch { - case OpenAiRole.System: - return "system"; - case OpenAiRole.Assistant: - return "assistant"; - case OpenAiRole.User: - return "user"; - default: - return ""; - } + OpenAiRole.System => "system", + OpenAiRole.Assistant => "assistant", + OpenAiRole.User => "user", + _ => "", + }; } } \ No newline at end of file diff --git a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs index 233388c..02b7937 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs @@ -1,8 +1,6 @@ -using System.IO; -using System.Net.Http.Headers; using System.Text; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.Models.OpenAi; @@ -23,9 +21,14 @@ public class OpenAiService : IOpenAiService private readonly HttpClient _httpClient; - public OpenAiService(IHttpClientFactory httpClientFactory) + private readonly ILogger _logger; + + public OpenAiService( + IHttpClientFactory httpClientFactory, + ILogger logger) { _httpClient = httpClientFactory.CreateClient("OpenAiHttpClient"); + _logger = logger; } public async Task GetChatCompletion(ChatCompletionRequest chat, CancellationToken cancellationToken) @@ -45,6 +48,8 @@ public class OpenAiService : IOpenAiService public async IAsyncEnumerable GetChatCompletionStream(ChatCompletionRequest chat, CancellationToken cancellationToken) { + _logger.LogInformation($"Sending completion stream request to OpenAI."); + chat.Stream = true; var jsonBody = JsonConvert.SerializeObject(chat, _jsonSettings); @@ -58,12 +63,22 @@ public class OpenAiService : IOpenAiService while (!cancellationToken.IsCancellationRequested) { var jsonChunk = await reader.ReadLineAsync(); + + _logger.LogInformation($"Received chunk from OpenAI."); + if (jsonChunk.StartsWith("data: ")) { jsonChunk = jsonChunk.Substring("data: ".Length); - if (jsonChunk == "[DONE]") break; + if (jsonChunk == "[DONE]") + { + _logger.LogInformation($"Finished getting response from OpenAI"); + break; + } + var data = JsonConvert.DeserializeObject(jsonChunk); + if (data.Choices[0].Delta.Content == "" || data.Choices[0].Delta.Content == null) continue; + yield return data.Choices[0].Delta.Content; } } diff --git a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs index 3ccb107..5b97bc0 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using Microsoft.Extensions.Logging; using MongoDB.Bson; using ShoppingAssistantApi.Application.IRepositories; using ShoppingAssistantApi.Application.IServices; @@ -6,7 +6,6 @@ using ShoppingAssistantApi.Application.Models.CreateDtos; using ShoppingAssistantApi.Application.Models.Dtos; using ShoppingAssistantApi.Application.Models.OpenAi; using ShoppingAssistantApi.Application.Models.ProductSearch; -using ShoppingAssistantApi.Domain.Entities; using ShoppingAssistantApi.Domain.Enums; using ServerSentEvent = ShoppingAssistantApi.Application.Models.ProductSearch.ServerSentEvent; @@ -15,126 +14,113 @@ namespace ShoppingAssistantApi.Infrastructure.Services; public class ProductService : IProductService { private readonly IWishlistsService _wishlistsService; - + private readonly IOpenAiService _openAiService; private readonly IMessagesRepository _messagesRepository; - - private bool mqchecker = false; - - private SearchEventType currentDataType = SearchEventType.Wishlist; - public ProductService(IOpenAiService openAiService, IWishlistsService wishlistsService, IMessagesRepository messagesRepository) + private readonly ILogger _logger; + + public ProductService( + IOpenAiService openAiService, + IWishlistsService wishlistsService, + IMessagesRepository messagesRepository, + ILogger logger) { _openAiService = openAiService; _wishlistsService = wishlistsService; _messagesRepository = messagesRepository; + _logger = logger; } - public async IAsyncEnumerable SearchProductAsync(string wishlistId, MessageCreateDto message, CancellationToken cancellationToken) + public async IAsyncEnumerable SearchProductAsync(string wishlistId, MessageCreateDto newMessage, CancellationToken cancellationToken) { - string promptForGpt = + var systemPrompt = "You are a Shopping Assistant that helps people find product recommendations. Ask user additional questions if more context needed." + "\nYou must return data with one of the prefixes:" + - "\n[Question] - return question" + + "\n[Question] - return question. Must be followed by suggestions how to answer the question" + "\n[Suggestions] - return semicolon separated suggestion how to answer to a question" + "\n[Message] - return text" + "\n[Products] - return semicolon separated product names"; - var countOfMessage = await _messagesRepository - .GetCountAsync(message=>message.WishlistId == ObjectId.Parse((wishlistId)), cancellationToken); - - var previousMessages = await _wishlistsService - .GetMessagesPageFromPersonalWishlistAsync(wishlistId, 1, countOfMessage, cancellationToken); + var wishlistObjectId = ObjectId.Parse(wishlistId); + var messages = await _messagesRepository.GetWishlistMessagesAsync(wishlistObjectId, cancellationToken); var chatRequest = new ChatCompletionRequest { Messages = new List { - new OpenAiMessage - { - Role = OpenAiRoleExtensions.RequestConvert(OpenAiRole.System), - Content = promptForGpt + new() { + Role = OpenAiRole.System.ToRequestString(), + Content = systemPrompt } } }; - - var messagesForOpenAI = new List(); - - foreach (var item in previousMessages.Items) + for (int i = 0; i < messages.Count; i++) { - if (item.Role == "Application") + var message = messages[i]; + if (i == 0) { - messagesForOpenAI - .Add(new OpenAiMessage() - { - Role = OpenAiRole.Assistant.RequestConvert(), - Content = item.Text - }); - } - else - { - messagesForOpenAI - .Add(new OpenAiMessage() - { - Role = item.Role.ToLower(), - Content = item.Text - }); + message.Text = "[Question] " + message.Text + "\n [Suggestions] Bicycle, Laptop"; } + + chatRequest.Messages + .Add(new OpenAiMessage() + { + Role = message.Role == "Application" ? "assistant" : "user", + Content = message.Text + }); } - messagesForOpenAI.Add(new OpenAiMessage() + chatRequest.Messages.Add(new () { - Role = OpenAiRoleExtensions.RequestConvert(OpenAiRole.User), - Content = message.Text + Role = OpenAiRole.User.ToRequestString(), + Content = newMessage.Text }); - - chatRequest.Messages.AddRange(messagesForOpenAI); - + + // Don't wait for the task to finish because we dont need the result of this task + var dto = new MessageDto() + { + Text = newMessage.Text, + Role = MessageRoles.User.ToString(), + }; + var saveNewMessageTask = _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, dto, cancellationToken); + + var currentDataType = SearchEventType.Wishlist; var suggestionBuffer = new Suggestion(); var messageBuffer = new MessagePart(); var productBuffer = new ProductName(); var dataTypeHolder = string.Empty; - var counter = 0; await foreach (var data in _openAiService.GetChatCompletionStream(chatRequest, cancellationToken)) { - counter++; - if (mqchecker && currentDataType == SearchEventType.Message && messageBuffer != null) + if (data.Contains('[')) { - if (data == "[") + dataTypeHolder = data; + } + else if (data.Contains(']')) + { + if (currentDataType == SearchEventType.Message) { - _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageDto() + _ = await saveNewMessageTask; + // Don't wait for the task to finish because we dont need the result of this task + _ = _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageDto() { Text = messageBuffer.Text, Role = MessageRoles.Application.ToString(), }, cancellationToken); - mqchecker = false; } - } - - if (data.Contains("[")) - { - dataTypeHolder = string.Empty; - dataTypeHolder += data; - } - else if (data.Contains("]")) - { dataTypeHolder += data; currentDataType = DetermineDataType(dataTypeHolder); - if (currentDataType == SearchEventType.Message) - { - mqchecker = true; - } - } - else if (dataTypeHolder=="[" && !data.Contains("[")) + dataTypeHolder = string.Empty; + } + else if (dataTypeHolder.Contains('[')) { dataTypeHolder += data; } - else { switch (currentDataType) @@ -147,47 +133,44 @@ public class ProductService : IProductService }; currentDataType = SearchEventType.Message; messageBuffer.Text += data; + break; case SearchEventType.Suggestion: - if (data.Contains(";")) + if (data.Contains(';')) { yield return new ServerSentEvent { Event = SearchEventType.Suggestion, - Data = suggestionBuffer.Text + Data = suggestionBuffer.Text.Trim() }; suggestionBuffer.Text = string.Empty; break; } + suggestionBuffer.Text += data; + break; case SearchEventType.Product: - if (data.Contains(";")) + if (data.Contains(';')) { yield return new ServerSentEvent { Event = SearchEventType.Product, - Data = productBuffer.Name + Data = productBuffer.Name.Trim() }; productBuffer.Name = string.Empty; + break; } + productBuffer.Name += data; + break; } } } - if (currentDataType == SearchEventType.Message) - { - _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageDto() - { - Text = messageBuffer.Text, - Role = MessageRoles.Application.ToString(), - }, cancellationToken); - mqchecker = false; - } } private SearchEventType DetermineDataType(string dataTypeHolder) @@ -196,7 +179,7 @@ public class ProductService : IProductService { return SearchEventType.Message; } - else if (dataTypeHolder.StartsWith("[Options]")) + else if (dataTypeHolder.StartsWith("[Suggestions]")) { return SearchEventType.Suggestion; } @@ -213,5 +196,4 @@ public class ProductService : IProductService return SearchEventType.Wishlist; } } - } \ No newline at end of file diff --git a/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs b/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs index b4c5ab9..83e63c6 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/WishlistsService.cs @@ -82,12 +82,12 @@ public class WishlistsService : IWishlistsService { new OpenAiMessage { - Role = OpenAiRole.System.RequestConvert(), + Role = OpenAiRole.System.ToRequestString(), Content = "You will be provided with a general information about some product and your task is to generate general (not specific to any company or brand) chat name where recommendations on which specific product to buy will be given. Only name he product without adverbs and adjectives. Limit the name length to 5 words\nExamples:\n - Prompt: Hub For Macbook. Answer: Macbook Hub\n - Prompt: What is the best power bank for MacBook with capacity 20000 mAh and power near 20V? Answer: Macbook Powerbank\nIf the information tells nothing about some product answer with short generic name" }, new OpenAiMessage { - Role = OpenAiRole.User.RequestConvert(), + Role = OpenAiRole.User.ToRequestString(), Content = firstUserMessage.Text } } diff --git a/ShoppingAssistantApi.Persistance/Repositories/MessagesRepository.cs b/ShoppingAssistantApi.Persistance/Repositories/MessagesRepository.cs index 1c6368e..2768ce2 100644 --- a/ShoppingAssistantApi.Persistance/Repositories/MessagesRepository.cs +++ b/ShoppingAssistantApi.Persistance/Repositories/MessagesRepository.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using MongoDB.Bson; using MongoDB.Driver; using ShoppingAssistantApi.Application.IRepositories; using ShoppingAssistantApi.Domain.Entities; @@ -18,4 +19,11 @@ public class MessagesRepository : BaseRepository, IMessagesRepository .Limit(pageSize) .ToListAsync(cancellationToken); } + + public Task> GetWishlistMessagesAsync(ObjectId wishlistId, CancellationToken cancellationToken) + { + return _collection + .Find(x => !x.IsDeleted && x.WishlistId == wishlistId) + .ToListAsync(cancellationToken); + } } From 745bee93ee6c4fc18ba16cdc755a1235895517a7 Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Mon, 11 Dec 2023 01:10:46 +0000 Subject: [PATCH 3/6] SA-197 Update Unit Tests - Add Logger Mock to Unit tests - Update ProductTests to handle suggestions --- .../OpenAiServiceTests.cs | 5 +- .../ProductTests.cs | 154 ++++++------------ 2 files changed, 57 insertions(+), 102 deletions(-) diff --git a/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs index d9dffd9..00c4ead 100644 --- a/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs +++ b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.Extensions.Logging; using Moq; using Moq.Protected; using ShoppingAssistantApi.Application.IServices; @@ -31,7 +32,7 @@ public class OpenAiServiceTests return client; }); - _openAiService = new OpenAiService(_mockHttpClientFactory.Object); + _openAiService = new OpenAiService(_mockHttpClientFactory.Object, new Mock>().Object); } [Fact] @@ -78,7 +79,7 @@ public class OpenAiServiceTests { new OpenAiMessage { - Role = OpenAiRole.User.RequestConvert(), + Role = OpenAiRole.User.ToRequestString(), Content = "Return Hello World!" } } diff --git a/ShoppingAssistantApi.UnitTests/ProductTests.cs b/ShoppingAssistantApi.UnitTests/ProductTests.cs index 8a33cad..db78900 100644 --- a/ShoppingAssistantApi.UnitTests/ProductTests.cs +++ b/ShoppingAssistantApi.UnitTests/ProductTests.cs @@ -1,4 +1,6 @@ -using Moq; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using Moq; using ShoppingAssistantApi.Application.IRepositories; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.Models.CreateDtos; @@ -27,14 +29,14 @@ public class ProductTests _messagesRepositoryMock = new Mock(); _openAiServiceMock = new Mock(); _wishListServiceMock = new Mock(); - _productService = new ProductService(_openAiServiceMock.Object, _wishListServiceMock.Object, _messagesRepositoryMock.Object); + _productService = new ProductService(_openAiServiceMock.Object, _wishListServiceMock.Object, _messagesRepositoryMock.Object, new Mock>().Object); } [Fact] public async Task SearchProductAsync_WhenWishlistsWithoutMessages_ReturnsExpectedEvents() { // Arrange - string wishlistId = "existingWishlistId"; + string wishlistId = "657657677c13ae4bc95e2f41"; var message = new MessageCreateDto { Text = "Your message text here" @@ -44,47 +46,28 @@ public class ProductTests // Define your expected SSE data for the test var expectedSseData = new List { - "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", " USB-C", " ;", " Keyboard", " ultra", - " ;", "[", "Options", "]", " USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", + "[", "Message", "]", " What", " u", " want", " ?", "[", "Suggestions", "]", " USB-C", " ;", " Keyboard", " ultra", + " ;", "[", "Suggestions", "]", " USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", " 3070TI", " ;", " GTX", " 4070TI", " ;", " ?", "[", "Message", "]", " What", " u", " want", " ?" }; var expectedMessages = new List { " What", " u", " want", " ?", " What", " u", " want", " ?" }; - var expectedSuggestion = new List { " USB-C", " Keyboard ultra", " USB-C" }; + var expectedSuggestion = new List { "USB-C", "Keyboard ultra", "USB-C" }; // Mock the GetChatCompletionStream method to provide the expected SSE data _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) .Returns(expectedSseData.ToAsyncEnumerable()); - _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) - .ReturnsAsync(1); - - _wishListServiceMock.Setup(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken)) - .Verifiable(); - - _wishListServiceMock - .Setup(m => m.GetMessagesPageFromPersonalWishlistAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()) - ) - .ReturnsAsync(new PagedList( - new List + _messagesRepositoryMock.Setup(m => m.GetWishlistMessagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { - new MessageDto + new() { Text = "What are you looking for?", - Id = "3", - CreatedById = "User2", Role = "User" }, - }, - 1, - 1, - 1 - )); - + }); + // Act var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); @@ -113,7 +96,7 @@ public class ProductTests public async void SearchProductAsync_WithExistingMessageInWishlist_ReturnsExpectedEvents() { // Arrange - var wishlistId = "your_wishlist_id"; + var wishlistId = "657657677c13ae4bc95e2f41"; var message = new MessageCreateDto { Text = "Your message text" }; var cancellationToken = new CancellationToken(); @@ -121,8 +104,8 @@ public class ProductTests var expectedSseData = new List { - "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", "USB-C", " ;", "Keyboard", " ultra", - " ;", "[", "Options", "]", "USB", "-C", " ;" + "[", "Message", "]", " What", " u", " want", " ?", "[", "Suggestions", "]", "USB-C", " ;", "Keyboard", " ultra", + " ;", "[", "Suggestions", "]", "USB", "-C", " ;" }; var expectedMessages = new List { " What", " u", " want", " ?" }; @@ -131,40 +114,25 @@ public class ProductTests // Mock the GetChatCompletionStream method to provide the expected SSE data _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) .Returns(expectedSseData.ToAsyncEnumerable()); - - _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) - .ReturnsAsync(3); - _wishListServiceMock.Setup(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken)) - .Verifiable(); - - _wishListServiceMock - .Setup(w => w.GetMessagesPageFromPersonalWishlistAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new PagedList(new List - { - new MessageDto + _messagesRepositoryMock.Setup(m => m.GetWishlistMessagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { - Text = "Message 1", - Id = "1", - CreatedById = "User2", - Role = "User" - }, - new MessageDto - { - Text = "Message 2", - Id = "2", - CreatedById = "User2", - Role = "User" - }, - new MessageDto - { - Text = "Message 3", - Id = "3", - CreatedById = "User2", - Role = "User" - }, - }, 1, 3, 3)); + new() { + Text = "Message 1", + Role = "User" + }, + new Message + { + Text = "Message 2", + Role = "User" + }, + new Message + { + Text = "Message 3", + Role = "User" + }, + }); // Act var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); @@ -186,7 +154,6 @@ public class ProductTests Assert.NotNull(actualSseEvents); Assert.Equal(expectedMessages, receivedMessages); Assert.Equal(expectedSuggestions, receivedSuggestions); - _wishListServiceMock.Verify(w => w.AddMessageToPersonalWishlistAsync(wishlistId, It.IsAny(), cancellationToken), Times.Once); } @@ -194,7 +161,7 @@ public class ProductTests public async void SearchProductAsync_WithExistingMessageInWishlistAndAddProduct_ReturnsExpectedEvents() { // Arrange - var wishlistId = "your_wishlist_id"; + var wishlistId = "657657677c13ae4bc95e2f41"; var message = new MessageCreateDto { Text = "Your message text" }; var cancellationToken = new CancellationToken(); @@ -202,8 +169,8 @@ public class ProductTests var expectedSseData = new List { - "[", "Message", "]", " What", " u", " want", " ?", "[", "Options", "]", "USB-C", " ;", "Keyboard", " ultra", - " ;", "[", "Options", "]", "USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", + "[", "Message", "]", " What", " u", " want", " ?", "[", "Suggestions", "]", "USB-C", " ;", "Keyboard", " ultra", + " ;", "[", "Suggestions", "]", "USB", "-C", " ;", "[", "Products", "]", " GTX", " 3090", " ;", " GTX", " 3070TI", " ;", " GTX", " 4070TI", " ;", " ?" }; @@ -214,36 +181,25 @@ public class ProductTests _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) .Returns(expectedSseData.ToAsyncEnumerable()); - _messagesRepositoryMock.Setup(m => m.GetCountAsync(It.IsAny>>(), It.IsAny())) - .ReturnsAsync(3); - - _wishListServiceMock - .Setup(w => w.GetMessagesPageFromPersonalWishlistAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new PagedList(new List - { - new MessageDto + _messagesRepositoryMock.Setup(m => m.GetWishlistMessagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { - Text = "Message 1", - Id = "1", - CreatedById = "User2", - Role = "User" - }, - new MessageDto - { - Text = "Message 2", - Id = "2", - CreatedById = "User2", - Role = "User" - }, - new MessageDto - { - Text = "Message 3", - Id = "3", - CreatedById = "User2", - Role = "User" - }, - }, 1, 3, 3)); + new() + { + Text = "Message 1", + Role = "User" + }, + new() + { + Text = "Message 2", + Role = "User" + }, + new() + { + Text = "Message 3", + Role = "User" + }, + }); // Act var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); @@ -265,7 +221,5 @@ public class ProductTests Assert.NotNull(actualSseEvents); Assert.Equal(expectedMessages, receivedMessages); Assert.Equal(expectedSuggestions, receivedSuggestions); - _wishListServiceMock.Verify(w => w.AddMessageToPersonalWishlistAsync( - wishlistId, It.IsAny(), cancellationToken), Times.Once); } } \ No newline at end of file From 07186c305276c453996f6df210b7474a6a53fdbb Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Sat, 16 Dec 2023 20:39:05 +0000 Subject: [PATCH 4/6] SA-240 Change OpenAI SSE handling - Change SSE handling to receive chunks in realtime --- .../Services/OpenAiService.cs | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs index 02b7937..a45d423 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -54,33 +55,31 @@ public class OpenAiService : IOpenAiService var jsonBody = JsonConvert.SerializeObject(chat, _jsonSettings); var body = new StringContent(jsonBody, Encoding.UTF8, "application/json"); - - using var httpResponse = await _httpClient.PostAsync("", body, cancellationToken); - - using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(responseStream, Encoding.UTF8); - - while (!cancellationToken.IsCancellationRequested) + var request = new HttpRequestMessage(HttpMethod.Post, "") { - var jsonChunk = await reader.ReadLineAsync(); - - _logger.LogInformation($"Received chunk from OpenAI."); + Content = body + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - if (jsonChunk.StartsWith("data: ")) - { - jsonChunk = jsonChunk.Substring("data: ".Length); - if (jsonChunk == "[DONE]") - { - _logger.LogInformation($"Finished getting response from OpenAI"); - break; - } + using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - var data = JsonConvert.DeserializeObject(jsonChunk); + var allData = string.Empty; - if (data.Choices[0].Delta.Content == "" || data.Choices[0].Delta.Content == null) continue; + using var streamReader = new StreamReader(await httpResponse.Content.ReadAsStreamAsync(cancellationToken)); + while (!streamReader.EndOfStream) + { + var line = await streamReader.ReadLineAsync(cancellationToken); + allData += line + "\n\n"; + if (string.IsNullOrEmpty(line)) continue; - yield return data.Choices[0].Delta.Content; - } + var json = line?.Substring(6, line.Length - 6); + if (json == "[DONE]") yield break; + + var data = JsonConvert.DeserializeObject(json); + + if (data.Choices[0].Delta.Content == "" || data.Choices[0].Delta.Content == null) continue; + + yield return data.Choices[0].Delta.Content; } } } \ No newline at end of file From e13bb4bbedc9763a09b519c9f0aa0331c7a7a2d0 Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Sun, 17 Dec 2023 17:10:41 +0000 Subject: [PATCH 5/6] SA-243 Save message at the end of product search - Add message saving on "[DONE]" text in SearchProductAsync() --- .../Services/OpenAiService.cs | 6 +++++- .../Services/ProductService.cs | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs index a45d423..40b16ad 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs @@ -73,7 +73,11 @@ public class OpenAiService : IOpenAiService if (string.IsNullOrEmpty(line)) continue; var json = line?.Substring(6, line.Length - 6); - if (json == "[DONE]") yield break; + if (json == "[DONE]") + { + yield return json; + yield break; + } var data = JsonConvert.DeserializeObject(json); diff --git a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs index 5b97bc0..9328992 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs @@ -95,7 +95,20 @@ public class ProductService : IProductService await foreach (var data in _openAiService.GetChatCompletionStream(chatRequest, cancellationToken)) { - if (data.Contains('[')) + if (data == "[DONE]") + { + if (!string.IsNullOrEmpty(messageBuffer.Text)) + { + _ = await _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageDto() + { + Text = messageBuffer.Text, + Role = MessageRoles.Application.ToString(), + }, cancellationToken); + } + + yield break; + } + else if (data.Contains('[')) { dataTypeHolder = data; } @@ -110,6 +123,8 @@ public class ProductService : IProductService Text = messageBuffer.Text, Role = MessageRoles.Application.ToString(), }, cancellationToken); + + messageBuffer.Text = string.Empty; } dataTypeHolder += data; From a37b40ed2828972fb5832735b7ab18881affcc06 Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Sun, 17 Dec 2023 21:59:21 +0200 Subject: [PATCH 6/6] add personal account deletion --- .../Mutations/UsersMutation.cs | 11 +++++++++- .../IServices/IUsersService.cs | 4 +++- .../Services/UsersService.cs | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs b/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs index 6beaccf..9a6102c 100644 --- a/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs +++ b/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs @@ -2,6 +2,7 @@ using ShoppingAssistantApi.Application.Models.Dtos; using ShoppingAssistantApi.Application.Models.Operations; using HotChocolate.Authorization; +using ShoppingAssistantApi.Application.IServices; namespace ShoppingAssistantApi.Api.Mutations; @@ -27,4 +28,12 @@ public class UsersMutation public Task RemoveFromRoleAsync(string roleName, string userId, CancellationToken cancellationToken, [Service] IUserManager userManager) => userManager.RemoveFromRoleAsync(roleName, userId, cancellationToken); -} \ No newline at end of file + + [Authorize] + public async Task DeletePersonalUserAsync(string guestId, CancellationToken cancellationToken, + [Service] IUsersService usersService) + { + await usersService.DeletePersonalUserAsync(guestId, cancellationToken); + return true; + } +} diff --git a/ShoppingAssistantApi.Application/IServices/IUsersService.cs b/ShoppingAssistantApi.Application/IServices/IUsersService.cs index 25fcab4..0357178 100644 --- a/ShoppingAssistantApi.Application/IServices/IUsersService.cs +++ b/ShoppingAssistantApi.Application/IServices/IUsersService.cs @@ -12,4 +12,6 @@ public interface IUsersService Task GetUserAsync(string id, CancellationToken cancellationToken); Task UpdateUserAsync(UserDto dto, CancellationToken cancellationToken); -} \ No newline at end of file + + Task DeletePersonalUserAsync(string guestId, CancellationToken cancellationToken); +} diff --git a/ShoppingAssistantApi.Infrastructure/Services/UsersService.cs b/ShoppingAssistantApi.Infrastructure/Services/UsersService.cs index b3df02b..2ae7654 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/UsersService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/UsersService.cs @@ -59,4 +59,26 @@ public class UsersService : IUsersService entity.LastModifiedDateUtc = DateTime.UtcNow; await _repository.UpdateUserAsync(entity, cancellationToken); } + + public async Task DeletePersonalUserAsync(string guestId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(guestId, out var guid)) + { + throw new InvalidDataException("Provided id is invalid."); + } + + var entity = await _repository.GetUserAsync(u => u.GuestId == guid, cancellationToken); + + if (entity.Id != GlobalUser.Id) + { + throw new UnAuthorizedException(); + } + + if (entity == null) + { + throw new EntityNotFoundException(); + } + + await _repository.DeleteAsync(entity, cancellationToken); + } }