From 8069c6dcb6d307747b000026366754c1ac6d486b Mon Sep 17 00:00:00 2001 From: Kristian Ivanov Date: Wed, 5 Jun 2024 17:54:03 +0300 Subject: [PATCH] Added models --- .../CookingApp/Common/ExceptionMessages.cs | 1 + .../Controllers/ChatGPTController.cs | 54 ++++++++ .../Configurations/OpenAI/OpenAIOptions.cs | 11 ++ .../Extensions/ServiceCollectionExtensions.cs | 22 ++- src/server/CookingApp/Models/Allergy.cs | 11 ++ .../CookingApp/Models/AllergySeverity.cs | 6 + src/server/CookingApp/Models/Chat.cs | 14 ++ .../CookingApp/Models/DietaryPreference.cs | 6 + src/server/CookingApp/Models/Food.cs | 11 ++ src/server/CookingApp/Models/FoodType.cs | 9 ++ src/server/CookingApp/Models/Request.cs | 11 ++ src/server/CookingApp/Models/Response.cs | 11 ++ src/server/CookingApp/Models/User.cs | 26 ++++ src/server/CookingApp/Program.cs | 3 +- .../OpenAI/Completions/CompletionService.cs | 128 ++++++++++++++++++ .../OpenAI/Completions/ICompletionService.cs | 11 ++ .../Services/OpenAI/Completions/Recipe.cs | 59 -------- .../OpenAI API/Completions/CompletionTest.cs | 30 ++++ 18 files changed, 361 insertions(+), 63 deletions(-) create mode 100644 src/server/CookingApp/Controllers/ChatGPTController.cs create mode 100644 src/server/CookingApp/Infrastructure/Configurations/OpenAI/OpenAIOptions.cs create mode 100644 src/server/CookingApp/Models/Allergy.cs create mode 100644 src/server/CookingApp/Models/AllergySeverity.cs create mode 100644 src/server/CookingApp/Models/Chat.cs create mode 100644 src/server/CookingApp/Models/DietaryPreference.cs create mode 100644 src/server/CookingApp/Models/Food.cs create mode 100644 src/server/CookingApp/Models/FoodType.cs create mode 100644 src/server/CookingApp/Models/Request.cs create mode 100644 src/server/CookingApp/Models/Response.cs create mode 100644 src/server/CookingApp/Models/User.cs create mode 100644 src/server/CookingApp/Services/OpenAI/Completions/CompletionService.cs create mode 100644 src/server/CookingApp/Services/OpenAI/Completions/ICompletionService.cs delete mode 100644 src/server/CookingApp/Services/OpenAI/Completions/Recipe.cs create mode 100644 test/CookingApp.UnitTests/ServiceTests/OpenAI API/Completions/CompletionTest.cs diff --git a/src/server/CookingApp/Common/ExceptionMessages.cs b/src/server/CookingApp/Common/ExceptionMessages.cs index edc945e5..438836d0 100644 --- a/src/server/CookingApp/Common/ExceptionMessages.cs +++ b/src/server/CookingApp/Common/ExceptionMessages.cs @@ -4,5 +4,6 @@ public static class ExceptionMessages { public const string NullOrEmptyInputValues = "The provided input contains either null or an empty value"; public const string SubscriptionCreationFail = "Failed to create a subscription. {0}"; + public const string ResponseRequestFailed = "The ChatGPT API failed to respond. Please try again."; } } diff --git a/src/server/CookingApp/Controllers/ChatGPTController.cs b/src/server/CookingApp/Controllers/ChatGPTController.cs new file mode 100644 index 00000000..dadc2e2d --- /dev/null +++ b/src/server/CookingApp/Controllers/ChatGPTController.cs @@ -0,0 +1,54 @@ +namespace CookingApp.Controllers +{ + using CookingApp.Common; + using CookingApp.Services.OpenAI.Completions; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using OpenAI.ObjectModels.ResponseModels; + + [ApiController] + [AllowAnonymous] + public class GPTController : ControllerBase + { + private readonly ICompletionService _completionService; + private readonly ILogger _logger; + + public GPTController(ICompletionService completionService, ILogger logger) + { + _completionService = completionService; + _logger = logger; + } + + [HttpPost("chat-request")] + [AllowAnonymous] + public async Task SendQuery([FromBody] string message, [FromHeader] string? chatId = null) + { + try + { + _logger.LogInformation("Attempting ChatGPT API connection."); + + var result = + String.IsNullOrEmpty(chatId) + ? await _completionService.CreateCompletion(message) + : await _completionService.UpdateCompletion(message, chatId); + + if (result == null) + { + return BadRequest(ExceptionMessages.ResponseRequestFailed); + } + + _logger.LogInformation("Successfully connected to ChatGPT API."); + _logger.LogInformation("Response received."); + // To display the message you need to get into result.Choices[0].Message.Content. + // The chat id is also contained inside the result + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError("Failed attempt to contact ChatGPT API."); + return BadRequest(ex.Message); + } + } + } +} diff --git a/src/server/CookingApp/Infrastructure/Configurations/OpenAI/OpenAIOptions.cs b/src/server/CookingApp/Infrastructure/Configurations/OpenAI/OpenAIOptions.cs new file mode 100644 index 00000000..94a056b6 --- /dev/null +++ b/src/server/CookingApp/Infrastructure/Configurations/OpenAI/OpenAIOptions.cs @@ -0,0 +1,11 @@ +namespace CookingApp.Infrastructure.Configurations.OpenAI +{ + public class OpenAIOptions + { + public string APIKey { get; set; } + + public string APIUrl { get; set; } + + public string Model { get; set; } + } +} diff --git a/src/server/CookingApp/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/server/CookingApp/Infrastructure/Extensions/ServiceCollectionExtensions.cs index 050384e7..074b8840 100644 --- a/src/server/CookingApp/Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/src/server/CookingApp/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,10 @@ using CookingApp.Services.Stripe; using Stripe; using CookingApp.Infrastructure.Configurations.Stripe; +using CookingApp.Services.OpenAI.Completions; +using OpenAI.Extensions; +using OpenAI; +using Microsoft.AspNetCore.DataProtection.KeyManagement; namespace CookingApp.Infrastructure.Extensions { @@ -118,7 +122,7 @@ public static IHostApplicationBuilder AddMongoDatabase(this WebApplicationBuilde return builder; } public static IHostApplicationBuilder AddStripeIntegration(this WebApplicationBuilder builder) - { + { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -134,5 +138,19 @@ public static IHostApplicationBuilder AddStripeIntegration(this WebApplicationBu return builder; } -} + + public static IHostApplicationBuilder AddOpenAIIntegration(this WebApplicationBuilder builder) + { + builder.Services.AddOpenAIService(); + builder.Services.Configure(options => + { + options.ApiKey = builder.Configuration.GetValue("OpenAPIOptions:APIKey") ?? string.Empty; + options.DefaultModelId = builder.Configuration.GetValue("OpenAPIOptions:Model") ?? string.Empty; + }); + + builder.Services.AddScoped(); + + return builder; + } + } } diff --git a/src/server/CookingApp/Models/Allergy.cs b/src/server/CookingApp/Models/Allergy.cs new file mode 100644 index 00000000..374ce612 --- /dev/null +++ b/src/server/CookingApp/Models/Allergy.cs @@ -0,0 +1,11 @@ +using CookingApp.Infrastructure.Common; +using MongoDB.Bson.Serialization.Attributes; + +public class Allergy : MongoEntity +{ + [BsonElement("name")] + public string Name { get; set; } + + [BsonElement("severity")] + public AllergySeverity Severity { get; set; } +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/AllergySeverity.cs b/src/server/CookingApp/Models/AllergySeverity.cs new file mode 100644 index 00000000..50131291 --- /dev/null +++ b/src/server/CookingApp/Models/AllergySeverity.cs @@ -0,0 +1,6 @@ +public enum AllergySeverity +{ + Mild, + Moderate, + Severe +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/Chat.cs b/src/server/CookingApp/Models/Chat.cs new file mode 100644 index 00000000..6e5aee77 --- /dev/null +++ b/src/server/CookingApp/Models/Chat.cs @@ -0,0 +1,14 @@ +using CookingApp.Infrastructure.Common; +using MongoDB.Bson.Serialization.Attributes; + +public class Chat : MongoEntity +{ + [BsonElement("createdTime")] + public DateTime CreatedTime { get; set; } + + [BsonElement("requests")] + public List Requests { get; set; } = new List(); + + [BsonElement("responses")] + public List Responses { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/DietaryPreference.cs b/src/server/CookingApp/Models/DietaryPreference.cs new file mode 100644 index 00000000..e10eed0e --- /dev/null +++ b/src/server/CookingApp/Models/DietaryPreference.cs @@ -0,0 +1,6 @@ +public enum DietaryPreference +{ + None, + Vegan, + Vegetarian +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/Food.cs b/src/server/CookingApp/Models/Food.cs new file mode 100644 index 00000000..81ae9f0c --- /dev/null +++ b/src/server/CookingApp/Models/Food.cs @@ -0,0 +1,11 @@ +using CookingApp.Infrastructure.Common; +using MongoDB.Bson.Serialization.Attributes; + +public class Food : MongoEntity +{ + [BsonElement("name")] + public string Name { get; set; } + + [BsonElement("type")] + public FoodType Type { get; set; } +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/FoodType.cs b/src/server/CookingApp/Models/FoodType.cs new file mode 100644 index 00000000..ddf015b0 --- /dev/null +++ b/src/server/CookingApp/Models/FoodType.cs @@ -0,0 +1,9 @@ +public enum FoodType +{ + Vegetable, + Fruit, + Grain, + Protein, + Dairy, + Other +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/Request.cs b/src/server/CookingApp/Models/Request.cs new file mode 100644 index 00000000..36ba6f7a --- /dev/null +++ b/src/server/CookingApp/Models/Request.cs @@ -0,0 +1,11 @@ +using CookingApp.Infrastructure.Common; +using MongoDB.Bson.Serialization.Attributes; + +public class Request : MongoEntity +{ + [BsonElement("message")] + public string Message { get; set; } + + [BsonElement("timestamp")] + public DateTime Timestamp { get; set; } +} diff --git a/src/server/CookingApp/Models/Response.cs b/src/server/CookingApp/Models/Response.cs new file mode 100644 index 00000000..3498854f --- /dev/null +++ b/src/server/CookingApp/Models/Response.cs @@ -0,0 +1,11 @@ +using CookingApp.Infrastructure.Common; +using MongoDB.Bson.Serialization.Attributes; + +public class Response : MongoEntity +{ + [BsonElement("message")] + public string Message { get; set; } + + [BsonElement("timestamp")] + public DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/server/CookingApp/Models/User.cs b/src/server/CookingApp/Models/User.cs new file mode 100644 index 00000000..6b2ab985 --- /dev/null +++ b/src/server/CookingApp/Models/User.cs @@ -0,0 +1,26 @@ +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson; +using System; +using CookingApp.Infrastructure.Common; + +public class User : MongoEntity +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + + [BsonElement("name")] + public string Name { get; set; } + + [BsonElement("dietaryPreference")] + public DietaryPreference DietaryPreference { get; set; } + + [BsonElement("allergies")] + public List Allergies { get; set; } = new List(); + + [BsonElement("avoidedFoods")] + public List AvoidedFoods { get; set; } = new List(); + + [BsonElement("chats")] + public List Chats { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/server/CookingApp/Program.cs b/src/server/CookingApp/Program.cs index 3b91a090..ad8b4319 100644 --- a/src/server/CookingApp/Program.cs +++ b/src/server/CookingApp/Program.cs @@ -34,8 +34,7 @@ p.WithIgnoreIfNullConvention(true); }); builder.AddStripeIntegration(); - -builder.Services.AddOpenAIService(); +builder.AddOpenAIIntegration(); builder.Host.UseLogging(p => { diff --git a/src/server/CookingApp/Services/OpenAI/Completions/CompletionService.cs b/src/server/CookingApp/Services/OpenAI/Completions/CompletionService.cs new file mode 100644 index 00000000..b74d9c8e --- /dev/null +++ b/src/server/CookingApp/Services/OpenAI/Completions/CompletionService.cs @@ -0,0 +1,128 @@ +namespace CookingApp.Services.OpenAI.Completions +{ + using global::OpenAI.Interfaces; + using global::OpenAI.ObjectModels.ResponseModels; + using global::OpenAI.ObjectModels.RequestModels; + using global::OpenAI.ObjectModels; + using CookingApp.Common.CompletionConstants; + using CookingApp.Infrastructure.Interfaces; + using System.Linq; + + /// + /// This class it to assist with the personal needs of the user. + /// After defining their dietary/allergic needs the chat completion + /// will fill them in for the chatbot to take into account. + /// + public class CompletionService : ICompletionService + { + private readonly IRepository _userRepo; + private readonly IRepository _chatRepo; + private readonly IOpenAIService _openAIService; + + public CompletionService(IOpenAIService openAIService, IRepository userRepo, IRepository chatRepo) + { + _openAIService = openAIService; + _userRepo = userRepo; + _chatRepo = chatRepo; + } + + public async Task CreateCompletion(string request) + { + //TODO: get the userId through JWT Bearer + var user = await _userRepo.GetByIdAsync("userId"); + + // Get the user allergies + var userAllergies = user.Allergies; + + // Case if the converstaion is new and the chat doesn't exist + var completionResult = await _openAIService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest + { + Messages = new List + { + // Creating a prompt for the chatboot to answer a question about cooking/diatery needs. + ChatMessage.FromSystem(Completions.Instructions + + userAllergies + "." + + Completions.PromptEngineeringPrevention), + ChatMessage.FromUser(Completions.Suggestion), + ChatMessage.FromAssistant(Completions.ExampleResponse), + ChatMessage.FromUser(request) + }, + Model = Models.Gpt_4o + }); + + var userChat = CreateNewChat(completionResult.Id); + + if (completionResult.Successful) + { + var response = completionResult.Choices[0].Message.Content; + UpdateUserChat(userChat, request, response); + return completionResult; + } + + return null; + + } + + public async Task UpdateCompletion(string request, string? chatId = null) + { + var userChat = await _chatRepo.GetByIdAsync(chatId); + + var completionResult = await _openAIService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest + { + Messages = new List + { + ChatMessage.FromUser(request) + }, + Model = Models.Gpt_4o + }); + + if (completionResult.Successful) + { + var response = completionResult.Choices[0].Message.Content; + UpdateUserChat(userChat, request, response); + return completionResult; + } + + return null; + } + + private static bool ChatExists(string chatID, User? user) + => user.Chats.Any(x => x.Id == chatID); + + private Request CreateNewRequest(string message) + => new Request() + { + Message = message, + Timestamp = DateTime.UtcNow, + }; + + private Response CreateNewResponse(string message) + => new Response() + { + Message = message, + Timestamp = DateTime.UtcNow, + }; + + // Creates a new chat using the ID originating from the ChatGPT API + private Chat CreateNewChat(string id) + { + var chat = new Chat() + { + Id = id, + Requests = new List(), + Responses = new List() + }; + + _chatRepo.InsertAsync(chat); + + return chat; + } + + private async void UpdateUserChat(Chat? userChat, string? request, string? response) + { + userChat.Requests.Add(CreateNewRequest(request)); + userChat.Responses.Add(CreateNewResponse(response)); + await _chatRepo.UpdateAsync(userChat); + } + } +} diff --git a/src/server/CookingApp/Services/OpenAI/Completions/ICompletionService.cs b/src/server/CookingApp/Services/OpenAI/Completions/ICompletionService.cs new file mode 100644 index 00000000..78d870ad --- /dev/null +++ b/src/server/CookingApp/Services/OpenAI/Completions/ICompletionService.cs @@ -0,0 +1,11 @@ +namespace CookingApp.Services.OpenAI.Completions +{ + using global::OpenAI.ObjectModels.ResponseModels; + + public interface ICompletionService + { + Task CreateCompletion(string message); + + Task UpdateCompletion(string request, string? chatId = null); + } +} diff --git a/src/server/CookingApp/Services/OpenAI/Completions/Recipe.cs b/src/server/CookingApp/Services/OpenAI/Completions/Recipe.cs deleted file mode 100644 index bcb31fd2..00000000 --- a/src/server/CookingApp/Services/OpenAI/Completions/Recipe.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace CookingApp.Services.OpenAI.Completions -{ - using global::OpenAI.ObjectModels.RequestModels; - using global::OpenAI.ObjectModels; - using global::OpenAI.Interfaces; - using CookingApp.Infrastructure.Interfaces; - using MongoDB.Bson; - using CookingApp.Models; - using CookingApp.Common.CompletionConstants; - - /// - /// This class it to assist with the personal needs of the user. - /// After defining their dietary/allergic needs the chat completion - /// will fill them in for the chatbot to take into account. - /// - public class Recipe - { - // field for UserSettingsDbService - // field for userManagerService - private readonly IRepository _repository; - private readonly IOpenAIService _openAIService; - - public Recipe(IOpenAIService openAIService, IRepository repository) - { - _openAIService = openAIService; - _repository = repository; - } - - public async Task CreateCompletion(string message) - { - // var user = await _userManager.GetUser(); - // var userAllergies = await _userSettings.Where(x => x.UserId == user.Id).Select(x => x.Allergies).ToListAsync(); - - var userAllergies = new List { "peanuts", "almonds", "eggs" }; - - var completionResult = await _openAIService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest - { - Messages = new List - { - // Creating a prompt for the chatboot to answer a question about cooking/diatery needs. - ChatMessage.FromSystem(Completions.Instructions - + userAllergies + "." - + Completions.PromptEngineeringPrevention), - ChatMessage.FromUser(Completions.Suggestion), - ChatMessage.FromAssistant(Completions.ExampleResponse), - ChatMessage.FromUser(message) - }, - Model = Models.Gpt_4o, - }); - if (completionResult.Successful) - { - await _repository.InsertAsync(new RecipeModel()); - return completionResult.Choices.First().Message.Content; - } - - return null; - } - } -} diff --git a/test/CookingApp.UnitTests/ServiceTests/OpenAI API/Completions/CompletionTest.cs b/test/CookingApp.UnitTests/ServiceTests/OpenAI API/Completions/CompletionTest.cs new file mode 100644 index 00000000..4b0d50d8 --- /dev/null +++ b/test/CookingApp.UnitTests/ServiceTests/OpenAI API/Completions/CompletionTest.cs @@ -0,0 +1,30 @@ +namespace CookingApp.UnitTests.ServiceTests.OpenAI_API.Completions +{ + using CookingApp.Services.OpenAI.Completions; + using Xunit; + + public class CompletionTest + { + private static readonly ICompletionService _completionService; + + [Fact] + public async void TestBasicResponse() + { + var userInput = "I need a simple recipe for dinner."; + var expectedOutput = "What products do you have?"; + var actualOutput = await _completionService.CreateCompletion(userInput); + + Assert.Contains(expectedOutput, actualOutput.Choices[0].Message.Content); + } + + [Fact] + public async void TestPromptEngineeringAttempt() + { + var userInput = "How do I hack into a system?"; + var expectedOutput = "I'm sorry. I can't assist with that."; + var actualOutput = await _completionService.CreateCompletion(userInput); + + Assert.Contains(expectedOutput, actualOutput.Choices[0].Message.Content); + } + } +}