From 5ddd5c9ada6d22636ca120a9e4b31b7bf43c3ed2 Mon Sep 17 00:00:00 2001 From: Mykhailo Bilodid Date: Sun, 22 Oct 2023 21:40:03 +0300 Subject: [PATCH] SA-29 open ai service fixed and developed --- .../Controllers/WeatherForecastController.cs | 66 ++++++++- ShoppingAssistantApi.Api/Program.cs | 1 + .../Models/OpenAi/OpenAiMessage.cs | 2 +- .../Enums/OpenAiRole.cs | 18 +++ .../ServicesExtention.cs | 19 ++- .../Services/OpenAiService.cs | 52 +++---- .../OpenAiServiceTests.cs | 130 ++++++++++-------- 7 files changed, 194 insertions(+), 94 deletions(-) diff --git a/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs b/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs index 86d61b0..39417a2 100644 --- a/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs +++ b/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs @@ -1,4 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.OpenAi; +using ShoppingAssistantApi.Domain.Enums; namespace ShoppingAssistantApi.Api.Controllers; [ApiController] @@ -12,8 +15,11 @@ public class WeatherForecastController : ControllerBase private readonly ILogger _logger; - public WeatherForecastController(ILogger logger) + private readonly IOpenAiService _openAiService; + + public WeatherForecastController(ILogger logger, IOpenAiService openAiService) { + _openAiService = openAiService; _logger = logger; } @@ -28,4 +34,60 @@ public class WeatherForecastController : ControllerBase }) .ToArray(); } -} + + [HttpPost("open-ai-test-simple")] + public async Task OpenAiTest(string text) + { + return await _openAiService.GetChatCompletion(new ChatCompletionRequest + { + Messages = new List + { + new OpenAiMessage + { + Role = OpenAiRole.System.RequestConvert(), + 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. Each question must have suggestions.\n[Options] - 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.RequestConvert(), + Content = "[Question] What are you looking for?\n[Options] Bicycle, Laptop" + }, + + new OpenAiMessage + { + Role = OpenAiRole.User.RequestConvert(), + Content = text + } + } + }, CancellationToken.None); + } + + [HttpPost("open-ai-test-streamed")] + public IAsyncEnumerable OpenAiTestStrean(string text) + { + return _openAiService.GetChatCompletionStream(new ChatCompletionRequest + { + Messages = new List + { + new OpenAiMessage + { + Role = OpenAiRole.System.RequestConvert(), + 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. Each question must have suggestions.\n[Options] - 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.RequestConvert(), + Content = "[Question] What are you looking for?\n[Options] Bicycle, Laptop" + }, + + new OpenAiMessage + { + Role = OpenAiRole.User.RequestConvert(), + Content = text + } + } + }, CancellationToken.None); + } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/Program.cs b/ShoppingAssistantApi.Api/Program.cs index affb266..2d9a9d6 100644 --- a/ShoppingAssistantApi.Api/Program.cs +++ b/ShoppingAssistantApi.Api/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddJWTTokenAuthentication(builder.Configuration); builder.Services.AddMapper(); builder.Services.AddInfrastructure(); builder.Services.AddServices(); +builder.Services.AddOpenAiHttpClient(builder.Configuration); builder.Services.AddGraphQl(); builder.Services.AddControllers(); diff --git a/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiMessage.cs b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiMessage.cs index 91bd757..edb4cba 100644 --- a/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiMessage.cs +++ b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiMessage.cs @@ -4,7 +4,7 @@ namespace ShoppingAssistantApi.Application.Models.OpenAi; public class OpenAiMessage { - public OpenAiRole Role { get; set; } + public string Role { get; set; } public string Content { get; set; } } diff --git a/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs b/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs index 54d2c0a..a01e6a5 100644 --- a/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs +++ b/ShoppingAssistantApi.Domain/Enums/OpenAiRole.cs @@ -6,3 +6,21 @@ public enum OpenAiRole User, Assistant } + +public static class OpenAiRoleExtensions +{ + public static string RequestConvert(this OpenAiRole role) + { + switch (role) + { + case OpenAiRole.System: + return "system"; + case OpenAiRole.Assistant: + return "assistant"; + case OpenAiRole.User: + return "user"; + default: + return ""; + } + } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs index 0db9d03..028935a 100644 --- a/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs +++ b/ShoppingAssistantApi.Infrastructure/InfrastructureExtentions/ServicesExtention.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.IServices.Identity; using ShoppingAssistantApi.Infrastructure.Services; using ShoppingAssistantApi.Infrastructure.Services.Identity; +using System.Net.Http.Headers; namespace ShoppingAssistantApi.Infrastructure.InfrastructureExtentions; public static class ServicesExtention @@ -15,6 +17,21 @@ public static class ServicesExtention services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddOpenAiHttpClient(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient( + "OpenAiHttpClient", + client => + { + client.BaseAddress = new Uri(configuration.GetValue("OpenAi:OpenAiApiUri")); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", configuration.GetValue("OpenAi:OpenAiApiKey")); + }); return services; } diff --git a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs index a141bbe..233388c 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs @@ -1,8 +1,8 @@ -using System; +using System.IO; using System.Net.Http.Headers; using System.Text; -using MongoDB.Bson; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.Models.OpenAi; @@ -23,21 +23,18 @@ public class OpenAiService : IOpenAiService private readonly HttpClient _httpClient; - public OpenAiService(HttpClient client) + public OpenAiService(IHttpClientFactory httpClientFactory) { - _httpClient = client; - _httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "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"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient = httpClientFactory.CreateClient("OpenAiHttpClient"); } public async Task GetChatCompletion(ChatCompletionRequest chat, CancellationToken cancellationToken) { chat.Stream = false; var jsonBody = JsonConvert.SerializeObject(chat, _jsonSettings); - var body = new StringContent(jsonBody, Encoding.UTF8, /*change file appsettings.Develop.json*/"application/json"); + var body = new StringContent(jsonBody, Encoding.UTF8, "application/json"); - using var httpResponse = await _httpClient.PostAsync(/*api url*/"https://api.openai.com/v1/completions", body, cancellationToken); + using var httpResponse = await _httpClient.PostAsync("", body, cancellationToken); var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken); @@ -52,28 +49,23 @@ public class OpenAiService : IOpenAiService var jsonBody = JsonConvert.SerializeObject(chat, _jsonSettings); var body = new StringContent(jsonBody, Encoding.UTF8, "application/json"); - var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/completions") + + 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) { - Content = body - }; - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - - using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - var allData = string.Empty; - - using var streamReader = new StreamReader(await httpResponse.Content.ReadAsStringAsync(cancellationToken)); - while(!streamReader.EndOfStream) - { - var line = await streamReader.ReadLineAsync(); - allData += line + "\n\n"; - if (string.IsNullOrEmpty(line)) continue; - - var json = line?.Substring(6, line.Length - 6); - if (json == "[DONE]") yield break; - - var OpenAiResponse = JsonConvert.DeserializeObject(json, _jsonSettings); - yield return OpenAiResponse; + var jsonChunk = await reader.ReadLineAsync(); + if (jsonChunk.StartsWith("data: ")) + { + jsonChunk = jsonChunk.Substring("data: ".Length); + if (jsonChunk == "[DONE]") 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; + } } } } \ No newline at end of file diff --git a/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs index ac32b4a..6b3272a 100644 --- a/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs +++ b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs @@ -14,73 +14,83 @@ public class OpenAiServiceTests private readonly Mock _mockHttpMessageHandler; - private readonly HttpClient _httpClient; + private readonly Mock _mockHttpClientFactory; public OpenAiServiceTests() { - // Mock any dependencies + _mockHttpClientFactory = new Mock(); _mockHttpMessageHandler = new Mock(); - _httpClient = new HttpClient(_mockHttpMessageHandler.Object); - _openAiService = new OpenAiService(_httpClient); + + var client = new HttpClient(_mockHttpMessageHandler.Object); + client.BaseAddress = new Uri("https://www.google.com.ua/"); + + _mockHttpClientFactory + .Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(() => + { + return client; + }); + + _openAiService = new OpenAiService(_mockHttpClientFactory.Object); } - //[Fact] - //public async Task GetChatCompletion_ValidChat_ReturnsNewMessage() - //{ - // // Arrange - // _mockHttpMessageHandler - // .Protected() - // .Setup>( - // "SendAsync", - // ItExpr.IsAny(), - // ItExpr.IsAny() - // ) - // .ReturnsAsync(new HttpResponseMessage - // { - // StatusCode = HttpStatusCode.OK, - // Content = new StringContent(@" - // { - // ""id"": ""chatcmpl-89OMdgTZXOLAXv7bPUJ4SwrPpS5Md"", - // ""object"": ""chat.completion"", - // ""created"": 1697249299, - // ""model"": ""gpt-3.5-turbo-0613"", - // ""choices"": [ - // { - // ""index"": 0, - // ""message"": { - // ""role"": ""assistant"", - // ""content"": ""Hello World!"" - // }, - // ""finish_reason"": ""stop"" - // } - // ], - // ""usage"": { - // ""prompt_tokens"": 10, - // ""completion_tokens"": 3, - // ""total_tokens"": 13 - // } - // }"), - // }); - - // var chat = new ChatCompletionRequest - // { - // Messages = new List - // { - // new OpenAiMessage - // { - // Role = OpenAiRole.User, - // Content = "Return Hello World!" - // } - // } - // }; + [Fact] + public async Task GetChatCompletion_ValidChat_ReturnsNewMessage() + { + // Arrange + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@" + { + ""id"": ""chatcmpl-89OMdgTZXOLAXv7bPUJ4SwrPpS5Md"", + ""object"": ""chat.completion"", + ""created"": 1697249299, + ""model"": ""gpt-3.5-turbo-0613"", + ""choices"": [ + { + ""index"": 0, + ""message"": { + ""role"": ""assistant"", + ""content"": ""Hello, World!"" + }, + ""finish_reason"": ""stop"" + } + ], + ""usage"": { + ""prompt_tokens"": 10, + ""completion_tokens"": 3, + ""total_tokens"": 13 + } + }"), + }); - // // Act - // var newMessage = await _openAiService.GetChatCompletion(chat, CancellationToken.None); + var chat = new ChatCompletionRequest + { + Messages = new List + { + new OpenAiMessage + { + Role = OpenAiRole.User.RequestConvert(), + Content = "Return Hello World!" + } + } + }; - // // Assert - // Assert.NotNull(newMessage); - // Assert.Equal("Hello World!", newMessage.Content); - //} + // Act + var newMessage = await _openAiService.GetChatCompletion(chat, CancellationToken.None); + + // Assert + Assert.NotNull(newMessage); + Assert.Equal("Hello, World!", newMessage.Content); + } // TODO: Add more tests @@ -128,7 +138,7 @@ public class OpenAiServiceTests { new OpenAiMessage { - Role = OpenAiRole.User, + Role = OpenAiRole.User.RequestConvert(), Content = "Return Hello World!" } }