diff --git a/package.json b/package.json index d0e3437..f3e52ac 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "workbox-core": "7.0.0", "workbox-precaching": "7.0.0" }, - "hash": "5e5fbd4dd53113e5d42d8c101acffb7df5c4533382e926b2c9bd8110871a61ca" + "hash": "f8066539e4cc22a3732de4ff0d7e11656534a506924d1d154b74227df0fac37c" }, "overrides": { "@vaadin/bundles": "$@vaadin/bundles", @@ -113,6 +113,7 @@ "@hilla/generator-typescript-plugin-subtypes": "$@hilla/generator-typescript-plugin-subtypes", "@hilla/react-crud": "$@hilla/react-crud", "@hilla/generator-typescript-plugin-backbone": "$@hilla/generator-typescript-plugin-backbone", - "@hilla/generator-typescript-cli": "$@hilla/generator-typescript-cli" + "@hilla/generator-typescript-cli": "$@hilla/generator-typescript-cli", + "react-markdown": "$react-markdown" } } diff --git a/pom.xml b/pom.xml index 0afc157..9c4a8ce 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,10 @@ 21 0.8.1 2.5.5 + 5.10.2 + 3.25.3 + 5.2.0 + 5.15.0 @@ -73,6 +77,33 @@ spring-boot-starter-test test + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter-api.version} + test + + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + org.mockito + mockito-inline + ${mockito.version} + test + + + org.mock-server + mockserver-netty + ${mockserver-netty.version} + test + + diff --git a/src/main/java/dev/nano/mcc/MCCAssistant.java b/src/main/java/dev/nano/mcc/MCCAssistant.java index 2b751cf..b2a42ee 100644 --- a/src/main/java/dev/nano/mcc/MCCAssistant.java +++ b/src/main/java/dev/nano/mcc/MCCAssistant.java @@ -1,42 +1,24 @@ package dev.nano.mcc; -import lombok.RequiredArgsConstructor; +import dev.nano.mcc.client.AIClientPort; import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.prompt.SystemPromptTemplate; -import org.springframework.ai.image.ImagePrompt; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.Map; - @Service -@RequiredArgsConstructor @Slf4j public class MCCAssistant { - @Value("classpath:/prompt/system-prompt.st") - private Resource systemPrompt; + private final AIClientPort aiClientPort; - private final OpenAIClient openAIClient; + public MCCAssistant(AIClientPort aiClientPort) { + this.aiClientPort = aiClientPort; + } public String getRecipes(String dishName) { - - SystemMessage systemMessage = new SystemMessage(this.systemPrompt); - UserMessage userMessage = new UserMessage("Can you provide a recipe for + " + dishName + "?"); - - Prompt prompt = new Prompt(List.of(systemMessage, userMessage)); - - return openAIClient.getOpenAiChatClient().call(prompt).getResult().getOutput().getContent(); + return aiClientPort.generateRecipe("Can you provide a recipe for " + dishName + "?"); } - public String getDishImage(String dishName) { - ImagePrompt imagePrompt = new ImagePrompt("Generate an image of a Moroccan dish called " + dishName); - return openAIClient.getOpenAiImageClient().call(imagePrompt).getResult().getOutput().getUrl(); + public String getDishImage(String dishImageRequest) { + return aiClientPort.generateDishImage("Generate an image of a Moroccan dish called " + dishImageRequest); } } diff --git a/src/main/java/dev/nano/mcc/OpenAIClient.java b/src/main/java/dev/nano/mcc/OpenAIClient.java index 3118b20..1ad3701 100644 --- a/src/main/java/dev/nano/mcc/OpenAIClient.java +++ b/src/main/java/dev/nano/mcc/OpenAIClient.java @@ -1,6 +1,10 @@ package dev.nano.mcc; -import org.springframework.ai.image.ImageOptionsBuilder; +import dev.nano.mcc.client.AIClientPort; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.openai.OpenAiChatClient; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.OpenAiImageClient; @@ -8,31 +12,48 @@ import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; import org.springframework.retry.support.RetryTemplate; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; +import org.springframework.web.client.RestClient; -@Component -public final class OpenAIClient { +import java.util.List; + +@Repository +public class OpenAIClient implements AIClientPort { @Value("${spring.ai.openai.api-key}") String apiKey; + + @Value("${spring.ai.openai.base-url}") + String baseUrl; + + private final Resource systemPrompt; + + + public OpenAIClient(@Value("classpath:/prompt/system-prompt.st") Resource systemPrompt) { + this.systemPrompt = systemPrompt; + } - public OpenAiChatClient getOpenAiChatClient() { - OpenAiApi openAiApi = new OpenAiApi(apiKey); - var options = new OpenAiChatOptions.Builder() + public String generateRecipe(String instructionRecipe) { + Prompt prompt = new Prompt(List.of(new SystemMessage(this.systemPrompt), new UserMessage(instructionRecipe))); + OpenAiApi openAiApi = new OpenAiApi(baseUrl, apiKey); + OpenAiChatOptions options = new OpenAiChatOptions.Builder() .withModel("gpt-4") .build(); - return new OpenAiChatClient(openAiApi, options); + return new OpenAiChatClient(openAiApi, options).call(prompt).getResult().getOutput().getContent(); } - public OpenAiImageClient getOpenAiImageClient() { - OpenAiImageApi openAiApi = new OpenAiImageApi(apiKey); + @Override + public String generateDishImage(String instructionDishImage) { + OpenAiImageApi openAiApi = new OpenAiImageApi(baseUrl, apiKey, RestClient.builder()); var options = OpenAiImageOptions.builder() .withQuality("hd") .withHeight(1024).withWidth(1024) .withResponseFormat("url") .withModel("dall-e-3") .build(); - return new OpenAiImageClient(openAiApi, options, RetryTemplate.builder().build()); + OpenAiImageClient openAiImageClient = new OpenAiImageClient(openAiApi, options, RetryTemplate.builder().build()); + return openAiImageClient.call(new ImagePrompt(instructionDishImage)).getResult().getOutput().getUrl(); } } diff --git a/src/main/java/dev/nano/mcc/client/AIClientPort.java b/src/main/java/dev/nano/mcc/client/AIClientPort.java new file mode 100644 index 0000000..a219ec4 --- /dev/null +++ b/src/main/java/dev/nano/mcc/client/AIClientPort.java @@ -0,0 +1,7 @@ +package dev.nano.mcc.client; + +public interface AIClientPort { + + String generateRecipe(String instructionRecipe); + String generateDishImage(String instructionDishImage); +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d3f7140..05dd1e5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,6 +5,7 @@ spring: ai: openai: api-key: ${OPENAI_API_KEY} + base-url: "https://api.openai.com" threads: virtual: enabled: true diff --git a/src/test/java/dev/nano/mcc/MCCApplicationIntegrationTests.java b/src/test/java/dev/nano/mcc/MCCApplicationIntegrationTests.java new file mode 100644 index 0000000..8397606 --- /dev/null +++ b/src/test/java/dev/nano/mcc/MCCApplicationIntegrationTests.java @@ -0,0 +1,94 @@ +package dev.nano.mcc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.netty.handler.codec.http.HttpHeaderNames; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.MediaType; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiImageApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.integration.ClientAndServer.startClientAndServer; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +@SpringBootTest(classes = MCCApplication.class) +class MCCApplicationIntegrationTests { + + @Autowired + MCCAssistant mccAssistant; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final ClientAndServer mockServer = startClientAndServer(2445); + + private static final String TEST_DISH_NAME = "couscous with seven vegetables"; + + @BeforeEach + void setup() { + mockServer.reset(); + } + + @Test + void generateDishImage() throws JsonProcessingException { + + OpenAiImageApi.OpenAiImageResponse mockedResponse = new OpenAiImageApi.OpenAiImageResponse( + 20L, + List.of(new OpenAiImageApi.Data( + "https://openai.com/image/dish_generated_url.png", + "base64_encoding_value", + "revised_prompt_value" + )) + ); + + mockOpenAiGenerativeResponses("/v1/images/generations", objectMapper.writeValueAsString(mockedResponse)); + String imageUrl = mccAssistant.getDishImage(TEST_DISH_NAME); + assertThat(imageUrl).isNotNull().isEqualTo("https://openai.com/image/dish_generated_url.png"); + System.out.println("image url: " + imageUrl); + } + + + + @Test + void generateRecipes() throws JsonProcessingException { + + OpenAiApi.ChatCompletion mockedResponse = new OpenAiApi.ChatCompletion( + "id_value", + List.of(new OpenAiApi.ChatCompletion + .Choice( + OpenAiApi.ChatCompletionFinishReason.STOP, + 1, + new OpenAiApi.ChatCompletionMessage("Detailed dish of a couscous recipe with seven vegetables", null), + null)), + 10L, + "gpt-4", + "systemFingerPrint", + null, + new OpenAiApi.Usage(1, 2, 3) + ); + mockOpenAiGenerativeResponses("/v1/chat/completions", objectMapper.writeValueAsString(mockedResponse)); + + String recipe = mccAssistant.getRecipes(TEST_DISH_NAME); + assertThat(recipe) + .isNotNull() + .isEqualTo("Detailed dish of a couscous recipe with seven vegetables"); + } + + private void mockOpenAiGenerativeResponses(String path, String objectMapper) throws JsonProcessingException { + mockServer.when(request().withMethod("POST").withPath(path)) + .respond( + response() + .withStatusCode(200) + .withHeader(HttpHeaderNames.CONTENT_TYPE.toString(), MediaType.APPLICATION_JSON.toString()) + .withBody(objectMapper)); + } + + +} diff --git a/src/test/java/dev/nano/mcc/MCCApplicationTests.java b/src/test/java/dev/nano/mcc/MCCApplicationTests.java deleted file mode 100644 index 4ab4ccf..0000000 --- a/src/test/java/dev/nano/mcc/MCCApplicationTests.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.nano.mcc; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest(classes = MCCApplication.class) -class MCCApplicationTests { - - @Autowired - MCCAssistant mccAssistant; - - private static final String TEST_DISH_NAME = "couscous with seven vegetables"; - - @Test - void generateDishImage() { - String imageUrl = mccAssistant.getDishImage(TEST_DISH_NAME); - assertThat(imageUrl).isNotNull(); - System.out.println("image url: " + imageUrl); - } - - @Test - void generateRecipes() { - String recipe = mccAssistant.getRecipes(TEST_DISH_NAME); - assertThat(recipe).isNotNull(); - System.out.println("recipe: " + recipe); - } - -} diff --git a/src/test/java/dev/nano/mcc/MCCAssistantTest.java b/src/test/java/dev/nano/mcc/MCCAssistantTest.java new file mode 100644 index 0000000..649ab24 --- /dev/null +++ b/src/test/java/dev/nano/mcc/MCCAssistantTest.java @@ -0,0 +1,50 @@ +package dev.nano.mcc; + +import dev.nano.mcc.client.AIClientPort; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class ) +class MCCAssistantTest { + @InjectMocks + MCCAssistant mccAssistant; + + @Mock + AIClientPort aiClientPort; + + + @Test + void shouldReturnDetailedRecipeWhenProvidingRecipeRequest() { + + String recipeRequest = "Couscous with seven vegetables"; + + String expected = "Detailed couscous with seven vegetables recipe"; + + given(aiClientPort.generateRecipe("Can you provide a recipe for " + recipeRequest + "?")).willReturn(expected); + + String result = mccAssistant.getRecipes(recipeRequest); + + Assertions.assertThat(result).isEqualTo(expected); + + + } + + @Test + void shouldReturnDishImageWhenProvidingDishImageRequest() { + String dishImageRequest = "Couscous with seven vegetables"; + + String expectedUrl = "https://openai.com/image/generated-dish.png"; + + given(aiClientPort.generateDishImage("Generate an image of a Moroccan dish called " + dishImageRequest)).willReturn(expectedUrl); + + String result = mccAssistant.getDishImage(dishImageRequest); + + Assertions.assertThat(result).isEqualTo(expectedUrl); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..8615869 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + ai: + openai: + api-key: "dummy-key" + base-url: "http://localhost:2445" \ No newline at end of file