diff --git a/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs b/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index 86d61b0..0000000 --- a/ShoppingAssistantApi.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace ShoppingAssistantApi.Api.Controllers; -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/ShoppingAssistantApi.Api/Program.cs b/ShoppingAssistantApi.Api/Program.cs index 4a1e95b..e7f574b 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.AddHttpClient(builder.Configuration); builder.Services.AddGraphQl(); builder.Services.AddControllers(); diff --git a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj index 33cec29..50dd57a 100644 --- a/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj +++ b/ShoppingAssistantApi.Api/ShoppingAssistantApi.Api.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/ShoppingAssistantApi.Api/WeatherForecast.cs b/ShoppingAssistantApi.Api/WeatherForecast.cs deleted file mode 100644 index 360f533..0000000 --- a/ShoppingAssistantApi.Api/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ShoppingAssistantApi.Api; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} diff --git a/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiChoice.cs b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiChoice.cs new file mode 100644 index 0000000..3ce06dc --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiChoice.cs @@ -0,0 +1,12 @@ +namespace ShoppingAssistantApi.Application.Models.OpenAi; + +public class OpenAiChoice +{ + public OpenAiMessage Message { get; set; } + + public OpenAiDelta Delta { get; set; } + + public string FinishReason { get; set; } + + public int Index { get; set; } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiDelta.cs b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiDelta.cs new file mode 100644 index 0000000..c9b7dbc --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiDelta.cs @@ -0,0 +1,8 @@ +namespace ShoppingAssistantApi.Application.Models.OpenAi; + +public class OpenAiDelta +{ + public string Role { get; set; } + + public string Content { get; set; } +} \ No newline at end of file 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.Application/Models/OpenAi/OpenAiResponse.cs b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiResponse.cs new file mode 100644 index 0000000..991b854 --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiResponse.cs @@ -0,0 +1,16 @@ +namespace ShoppingAssistantApi.Application.Models.OpenAi; + +public class OpenAiResponse +{ + public string Id { get; set; } + + public string Object { get; set; } + + public int Created { get; set; } + + public string Model { get; set; } + + public OpenAiUsage Usage { get; set; } + + public List Choices { get; set; } +} \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiUsage.cs b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiUsage.cs new file mode 100644 index 0000000..6789eac --- /dev/null +++ b/ShoppingAssistantApi.Application/Models/OpenAi/OpenAiUsage.cs @@ -0,0 +1,10 @@ +namespace ShoppingAssistantApi.Application.Models.OpenAi; + +public class OpenAiUsage +{ + public int PromptTokens { get; set; } + + public int CompletionTokens { get; set; } + + public int TotalTokens { get; set; } +} \ No newline at end of file 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..cbc7b65 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 AddHttpClient(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient( + "OpenAiHttpClient", + client => + { + client.BaseAddress = new Uri(configuration.GetValue("ApiUri")); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", configuration.GetValue("ApiKey")); + }); return services; } diff --git a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs index 9ed750a..233388c 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/OpenAiService.cs @@ -1,3 +1,9 @@ +using System.IO; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.Models.OpenAi; @@ -5,20 +11,61 @@ namespace ShoppingAssistantApi.Infrastructure.Services; public class OpenAiService : IOpenAiService { + + private readonly JsonSerializerSettings _jsonSettings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + }, + NullValueHandling = NullValueHandling.Ignore, + }; + private readonly HttpClient _httpClient; - public OpenAiService(HttpClient client) + public OpenAiService(IHttpClientFactory httpClientFactory) { - _httpClient = client; + _httpClient = httpClientFactory.CreateClient("OpenAiHttpClient"); } - public Task GetChatCompletion(ChatCompletionRequest chat, CancellationToken cancellationToken) + public async Task GetChatCompletion(ChatCompletionRequest chat, CancellationToken cancellationToken) { - throw new NotImplementedException(); + chat.Stream = false; + var jsonBody = JsonConvert.SerializeObject(chat, _jsonSettings); + var body = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + using var httpResponse = await _httpClient.PostAsync("", body, cancellationToken); + + var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + var data = JsonConvert.DeserializeObject(responseBody); + + return data.Choices[0].Message; } - public IAsyncEnumerable GetChatCompletionStream(ChatCompletionRequest chat, CancellationToken cancellationToken) + public async IAsyncEnumerable GetChatCompletionStream(ChatCompletionRequest chat, CancellationToken cancellationToken) { - throw new NotImplementedException(); + chat.Stream = true; + 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 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.Infrastructure/ShoppingAssistantApi.Infrastructure.csproj b/ShoppingAssistantApi.Infrastructure/ShoppingAssistantApi.Infrastructure.csproj index 6b6f722..ab6f623 100644 --- a/ShoppingAssistantApi.Infrastructure/ShoppingAssistantApi.Infrastructure.csproj +++ b/ShoppingAssistantApi.Infrastructure/ShoppingAssistantApi.Infrastructure.csproj @@ -11,6 +11,7 @@ + diff --git a/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs new file mode 100644 index 0000000..d9dffd9 --- /dev/null +++ b/ShoppingAssistantApi.UnitTests/OpenAiServiceTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using Moq; +using Moq.Protected; +using ShoppingAssistantApi.Application.IServices; +using ShoppingAssistantApi.Application.Models.OpenAi; +using ShoppingAssistantApi.Domain.Enums; +using ShoppingAssistantApi.Infrastructure.Services; + +namespace ShoppingAssistantApi.UnitTests; + +public class OpenAiServiceTests +{ + private readonly IOpenAiService _openAiService; + + private readonly Mock _mockHttpMessageHandler; + + private readonly Mock _mockHttpClientFactory; + + public OpenAiServiceTests() + { + _mockHttpClientFactory = new Mock(); + _mockHttpMessageHandler = new Mock(); + + 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.RequestConvert(), + Content = "Return Hello World!" + } + } + }; + + // Act + var newMessage = await _openAiService.GetChatCompletion(chat, CancellationToken.None); + + // Assert + Assert.NotNull(newMessage); + Assert.Equal("Hello, World!", newMessage.Content); + } + + // TODO: Add more tests +/* + [Fact] + public async Task GetChatCompletionStream_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.RequestConvert(), + Content = "Return Hello World!" + } + } + }; + + // Act + var newMessage = _openAiService.GetChatCompletionStream(chat, CancellationToken.None); + + // Assert + Assert.NotNull(newMessage); + Assert.Equal("Hello World!", newMessage.ToString()); + } +*/ +} \ No newline at end of file