From 68ab565800c4c16222edecf8eff4d5cda9ea4306 Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Mon, 11 Dec 2023 00:49:08 +0000 Subject: [PATCH] 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); + } }