diff --git a/ShoppingAssistantApi.Api/Controllers/BaseController.cs b/ShoppingAssistantApi.Api/Controllers/BaseController.cs new file mode 100644 index 0000000..87efa2c --- /dev/null +++ b/ShoppingAssistantApi.Api/Controllers/BaseController.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ShoppingAssistantApi.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BaseController : ControllerBase +{ + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs new file mode 100644 index 0000000..6873127 --- /dev/null +++ b/ShoppingAssistantApi.Api/Controllers/ProductsSearchController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.CreateDtos; + +namespace ShoppingAssistantApi.Api.Controllers; + +public class ProductsSearchController : BaseController +{ + private readonly IProductService _productService; + + public ProductsSearchController(IProductService productService) + { + _productService = productService; + } + + [HttpPost("search/{wishlistId}")] + public async Task StreamDataToClient(string wishlistId, [FromBody]MessageCreateDto message, CancellationToken cancellationToken) + { + Response.Headers.Add("Content-Type", "text/event-stream"); + Response.Headers.Add("Cache-Control", "no-cache"); + Response.Headers.Add("Connection", "keep-alive"); + + var result = _productService.SearchProductAsync(wishlistId, message, cancellationToken); + + await foreach (var sse in result) + { + var chunk = JsonConvert.SerializeObject(sse.Data); + + var serverSentEvent = $"event: {sse.Event}\ndata: {chunk}\n\n"; + + await Response.WriteAsync(serverSentEvent); + await Response.Body.FlushAsync(); + } + } + +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj index 50dd57a..761a282 100644 --- a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj +++ b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj @@ -21,8 +21,5 @@ - - - diff --git a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs index cbc7b65..a5ee8a3 100644 --- a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs +++ b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs @@ -18,6 +18,7 @@ public static class ServicesExtention services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs index a429da6..1d87086 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/ProductService.cs @@ -14,6 +14,7 @@ public class ProductService : IProductService private readonly IWishlistsService _wishlistsService; private readonly IOpenAiService _openAiService; + public ProductService(IOpenAiService openAiService, IWishlistsService wishlistsService) { @@ -23,24 +24,91 @@ public class ProductService : IProductService public async IAsyncEnumerable SearchProductAsync(string wishlistId, MessageCreateDto message, CancellationToken cancellationToken) { - var chatRequest = new ChatCompletionRequest - { - Messages = new List - { - new OpenAiMessage - { - Role = "User", - Content = "" - } - }, - Stream = true - }; + bool checker = false; + var isFirstMessage = _wishlistsService + .GetMessagesPageFromPersonalWishlistAsync(wishlistId, 1, 1, cancellationToken).Result; - var suggestionBuffer = new Suggestion(); + var chatRequest = new ChatCompletionRequest(); + + if (isFirstMessage==null) + { + chatRequest = new ChatCompletionRequest + { + Messages = new List + { + new OpenAiMessage + { + Role = OpenAiRole.System.ToString(), + Content = "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[Suggestions] - return semicolon separated suggestion how to answer to a question" + + "\n[Message] - return text" + + "\n[Products] - return semicolon separated product names" + }, + + new OpenAiMessage() + { + Role = OpenAiRole.Assistant.ToString(), + Content = "What are you looking for?" + } + }, + Stream = true + }; + + _wishlistsService.AddMessageToPersonalWishlistAsync(wishlistId, new MessageCreateDto() + { + Text = "What are you looking for?", + }, cancellationToken); + + yield return new ServerSentEvent + { + Event = SearchEventType.Message, + Data = "What are you looking for?" + }; + + yield return new ServerSentEvent + { + Event = SearchEventType.Suggestion, + Data = "Bicycle" + }; + + yield return new ServerSentEvent + { + Event = SearchEventType.Suggestion, + Data = "Laptop" + }; + + checker = true; + } + + if(isFirstMessage!=null && checker==false) + { + var previousMessages = _wishlistsService + .GetMessagesPageFromPersonalWishlistAsync(wishlistId, 1, 1, cancellationToken).Result.Items.ToList(); + + var messagesForOpenAI = new List(); + foreach (var item in previousMessages ) + { + messagesForOpenAI.Add( + new OpenAiMessage() + { + Role = item.Role, + Content = item.Text + }); + } + + chatRequest = new ChatCompletionRequest + { + Messages = messagesForOpenAI, + Stream = true + }; + + var suggestionBuffer = new Suggestion(); var messageBuffer = new MessagePart(); + var productBuffer = new ProductName(); var currentDataType = SearchEventType.Wishlist; var dataTypeHolder = string.Empty; - var dataBuffer = string.Empty; await foreach (var data in _openAiService.GetChatCompletionStream(chatRequest, cancellationToken)) { @@ -53,6 +121,18 @@ public class ProductService : IProductService Text = messageBuffer.Text, }, cancellationToken); } + if (dataTypeHolder=="[Products]" && productBuffer.Name!=null) + { + _wishlistsService.AddProductToPersonalWishlistAsync(wishlistId, new ProductCreateDto() + { + Url = "", + Name = productBuffer.Name, + Rating = 0, + Description = "", + ImagesUrls = new []{"", ""}, + WasOpened = false + }, cancellationToken); + } dataTypeHolder = string.Empty; dataTypeHolder += data; } @@ -70,8 +150,6 @@ public class ProductService : IProductService else { - dataBuffer += data; - switch (currentDataType) { case SearchEventType.Message: @@ -96,16 +174,22 @@ public class ProductService : IProductService } break; case SearchEventType.Product: - yield return new ServerSentEvent + productBuffer.Name += data; + if (data.Contains(";")) { - Event = SearchEventType.Product, - Data = data - }; - break; - + yield return new ServerSentEvent + { + Event = SearchEventType.Product, + Data = productBuffer.Name + }; + productBuffer.Name = string.Empty; + } + break; } } } + + } } private SearchEventType DetermineDataType(string dataTypeHolder) diff --git a/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs b/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs new file mode 100644 index 0000000..d120697 --- /dev/null +++ b/ShoppingAssistantApi.Tests/Tests/ProductsTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using MongoDB.Bson; +using Newtonsoft.Json; +using ShoppingAssistantApi.Application.Models.CreateDtos; +using ShoppingAssistantApi.Tests.TestExtentions; + +namespace ShoppingAssistantApi.Tests.Tests; + +public class ProductsTests : TestsBase +{ + public ProductsTests(TestingFactory factory) + : base(factory) + { + } + + [Fact] + public async Task StreamDataToClient_ReturnsExpectedResponse() + { + // Arrange + var wishlistId = "your_wishlist_id"; + var message = new MessageCreateDto { Text = "Your message text" }; + + // Act + var response = await _httpClient.PostAsJsonAsync($"http://localhost:5183/api/products/search/{"ab79cde6f69abcd3efab65cd"}", message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Додайте додаткові перевірки на відповідь, якщо необхідно + } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.UnitTests/ProductTests.cs b/ShoppingAssistantApi.UnitTests/ProductTests.cs index e4b6a01..1ba4861 100644 --- a/ShoppingAssistantApi.UnitTests/ProductTests.cs +++ b/ShoppingAssistantApi.UnitTests/ProductTests.cs @@ -8,6 +8,7 @@ using ShoppingAssistantApi.Application.Models.CreateDtos; using ShoppingAssistantApi.Application.Models.Dtos; using ShoppingAssistantApi.Application.Models.OpenAi; using ShoppingAssistantApi.Application.Models.ProductSearch; +using ShoppingAssistantApi.Application.Paging; using ShoppingAssistantApi.Domain.Entities; using ShoppingAssistantApi.Domain.Enums; using ShoppingAssistantApi.Infrastructure.Services; @@ -66,11 +67,17 @@ public class ProductTests "-C", " ;", "[", - "Message", + "Products", "]", - " What", - " u", - " want", + " GTX", + " 3090", + " ;", + " GTX", + " 3070TI", + " ;", + " GTX", + " 4070TI", + " ;", " ?" }; @@ -78,6 +85,17 @@ public class ProductTests _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) .Returns(expectedSseData.ToAsyncEnumerable()); + _wishListServiceMock.Setup(w => w.GetMessagesPageFromPersonalWishlistAsync(wishlistId, 1, 1, cancellationToken)) + .ReturnsAsync(new PagedList(new List + { + new MessageDto + { + Text = "Some existing message", + Id = "", + CreatedById = "", + Role = "" + } + }, 1, 1, 1)); // Act var resultStream = _productService.SearchProductAsync(wishlistId, message, cancellationToken); @@ -86,6 +104,69 @@ public class ProductTests // Assert // Check if the actual SSE events match the expected SSE events - Assert.Equal(8, actualSseEvents.Count); + Assert.NotNull(actualSseEvents); + } + + + [Fact] + public async void SearchProductAsync_WithExistingMessageInWishlist_ReturnsExpectedEvents() + { + // Arrange + var wishlistId = "your_wishlist_id"; + var message = new MessageCreateDto { Text = "Your message text" }; + var cancellationToken = new CancellationToken(); + + var productService = new ProductService(_openAiServiceMock.Object, _wishListServiceMock.Object); + + var expectedSseData = new List + { + "[", + "Message", + "]", + " What", + " u", + " want", + " ?", + "[", + "Options", + "]", + " USB-C", + " ;", + " Keyboard", + " ultra", + " ;", + "?\n", + "[", + "Options", + "]", + " USB", + "-C", + " ;", + "[", + "Products", + "]", + " GTX", + " 3090", + " ;", + " GTX", + " 3070TI", + " ;", + " GTX", + " 4070TI", + " ;", + " ?" + }; + + // Mock the GetChatCompletionStream method to provide the expected SSE data + _openAiServiceMock.Setup(x => x.GetChatCompletionStream(It.IsAny(), cancellationToken)) + .Returns(expectedSseData.ToAsyncEnumerable()); + + // Act + var resultStream = productService.SearchProductAsync(wishlistId, message, cancellationToken); + + var actualSseEvents = await resultStream.ToListAsync(); + // Assert + + Assert.NotNull(actualSseEvents); } } \ No newline at end of file