diff --git a/spring-ai-modules/pom.xml b/spring-ai-modules/pom.xml index 44c133498d10..a8a9b050cb30 100644 --- a/spring-ai-modules/pom.xml +++ b/spring-ai-modules/pom.xml @@ -16,6 +16,7 @@ + spring-ai-introduction spring-ai-mcp spring-ai-text-to-sql spring-ai-vector-stores diff --git a/spring-ai-modules/spring-ai-introduction/pom.xml b/spring-ai-modules/spring-ai-introduction/pom.xml new file mode 100644 index 000000000000..0542efa87b5f --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + + com.baeldung + spring-ai-modules + 0.0.1 + ../pom.xml + + + com.baeldung + spring-ai-introduction + 0.0.1 + spring-ai-introduction + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-starter-model-openai + ${spring-ai.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + 21 + 1.0.1 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/APIExceptionHandler.java b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/APIExceptionHandler.java new file mode 100644 index 000000000000..5bda8b643034 --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/APIExceptionHandler.java @@ -0,0 +1,24 @@ +package com.baeldung.springai; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.openai.api.common.OpenAiApiClientErrorException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +class APIExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(APIExceptionHandler.class); + private static final String LLM_COMMUNICATION_ERROR = + "Unable to communicate with the configured LLM. Please try again later."; + + @ExceptionHandler(OpenAiApiClientErrorException.class) + ProblemDetail handle(OpenAiApiClientErrorException exception) { + logger.error("OpenAI returned an error.", exception); + return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, LLM_COMMUNICATION_ERROR); + } + +} \ No newline at end of file diff --git a/spring-ai/src/main/java/com/baeldung/SpringAIProjectApplication.java b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Application.java similarity index 58% rename from spring-ai/src/main/java/com/baeldung/SpringAIProjectApplication.java rename to spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Application.java index cee154f61869..f62d3d42ec66 100644 --- a/spring-ai/src/main/java/com/baeldung/SpringAIProjectApplication.java +++ b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Application.java @@ -1,12 +1,13 @@ -package com.baeldung; +package com.baeldung.springai; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class SpringAIProjectApplication { +class Application { + public static void main(String[] args) { - SpringApplication.run(SpringAIProjectApplication.class, args); + SpringApplication.run(Application.class, args); } -} +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Poem.java b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Poem.java new file mode 100644 index 000000000000..e6ceb25bb396 --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/Poem.java @@ -0,0 +1,8 @@ +package com.baeldung.springai; + +record Poem( + String title, + String content, + String genre, + String theme) { +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryController.java b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryController.java new file mode 100644 index 000000000000..b2b616ff98be --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryController.java @@ -0,0 +1,25 @@ +package com.baeldung.springai; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class PoetryController { + + private final PoetryService poetryService; + + PoetryController(PoetryService poetryService) { + this.poetryService = poetryService; + } + + @PostMapping("/poems") + ResponseEntity generate(@RequestBody PoemGenerationRequest request) { + Poem response = poetryService.generate(request.genre, request.theme); + return ResponseEntity.ok(response); + } + + record PoemGenerationRequest(String genre, String theme) {} + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryService.java b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryService.java new file mode 100644 index 000000000000..480f33a4eab6 --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/main/java/com/baeldung/springai/PoetryService.java @@ -0,0 +1,33 @@ +package com.baeldung.springai; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +class PoetryService { + + private final static PromptTemplate PROMPT_TEMPLATE + = new PromptTemplate("Write a {genre} haiku about {theme} following the traditional 5-7-5 syllable structure."); + + private final ChatClient chatClient; + + PoetryService(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + Poem generate(String genre, String theme) { + Prompt prompt = PROMPT_TEMPLATE + .create(Map.of( + "genre", genre, + "theme", theme)); + return chatClient + .prompt(prompt) + .call() + .entity(Poem.class); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/main/resources/application.yaml b/spring-ai-modules/spring-ai-introduction/src/main/resources/application.yaml new file mode 100644 index 000000000000..da3167761826 --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/main/resources/application.yaml @@ -0,0 +1,8 @@ +spring: + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-5 + temperature: 1 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-introduction/src/test/java/com/baeldung/springai/PoetryServiceLiveTest.java b/spring-ai-modules/spring-ai-introduction/src/test/java/com/baeldung/springai/PoetryServiceLiveTest.java new file mode 100644 index 000000000000..d6dd4028d48b --- /dev/null +++ b/spring-ai-modules/spring-ai-introduction/src/test/java/com/baeldung/springai/PoetryServiceLiveTest.java @@ -0,0 +1,33 @@ +package com.baeldung.springai; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class PoetryServiceLiveTest { + + @Autowired + private PoetryService poetryService; + + @Test + void whenPoemGenerationRequested_thenCorrectResponseReturned() { + String genre = "playful"; + String theme = "morning coffee"; + + Poem poem = poetryService.generate(genre, theme); + + assertThat(poem) + .hasNoNullFieldsOrProperties() + .satisfies(p -> { + String[] lines = p.content().trim().split("\\n"); + assertThat(lines) + .hasSize(3); + }); + } + +} \ No newline at end of file diff --git a/spring-ai/postman/Spring_AI_Poetry.postman_environment.json b/spring-ai/postman/Spring_AI_Poetry.postman_environment.json deleted file mode 100644 index b3d4a00bee70..000000000000 --- a/spring-ai/postman/Spring_AI_Poetry.postman_environment.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "df61838b-eb6f-4243-87ee-ca02c77e8646", - "name": "Spring_AI_Poetry", - "values": [ - { - "key": "baseUrl", - "value": "localhost:8080", - "type": "default", - "enabled": true - }, - { - "key": "genre", - "value": "liric", - "enabled": true - }, - { - "key": "theme", - "value": "flames", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2023-11-26T19:49:21.755Z", - "_postman_exported_using": "Postman/10.20.3" -} \ No newline at end of file diff --git a/spring-ai/postman/spring-ai.postman_collection.json b/spring-ai/postman/spring-ai.postman_collection.json deleted file mode 100644 index b26652bb4eff..000000000000 --- a/spring-ai/postman/spring-ai.postman_collection.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "info": { - "_postman_id": "f4282fac-bfe5-45b9-aae6-5ea7c43528ee", - "name": "spring-ai", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "9576856" - }, - "item": [ - { - "name": "Generate poetry with genre and theme", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/ai/poetry?genre={{genre}}&theme={{theme}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "ai", - "poetry" - ], - "query": [ - { - "key": "genre", - "value": "{{genre}}" - }, - { - "key": "theme", - "value": "{{theme}}" - } - ] - } - }, - "response": [] - }, - { - "name": "Generate haiku about cats", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/ai/cathaiku", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "ai", - "cathaiku" - ] - } - }, - "response": [] - } - ] -} \ No newline at end of file diff --git a/spring-ai/src/main/java/com/baeldung/springai/dto/PoetryDto.java b/spring-ai/src/main/java/com/baeldung/springai/dto/PoetryDto.java deleted file mode 100644 index f75a9c01cf0b..000000000000 --- a/spring-ai/src/main/java/com/baeldung/springai/dto/PoetryDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.baeldung.springai.dto; - -public record PoetryDto (String title, String poetry, String genre, String theme) { -} diff --git a/spring-ai/src/main/java/com/baeldung/springai/service/PoetryService.java b/spring-ai/src/main/java/com/baeldung/springai/service/PoetryService.java deleted file mode 100644 index 17091df5bace..000000000000 --- a/spring-ai/src/main/java/com/baeldung/springai/service/PoetryService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baeldung.springai.service; - -import com.baeldung.springai.dto.PoetryDto; - -public interface PoetryService { - - String getCatHaiku(); - - PoetryDto getPoetryByGenreAndTheme(String genre, String theme); -} diff --git a/spring-ai/src/main/java/com/baeldung/springai/service/impl/PoetryServiceImpl.java b/spring-ai/src/main/java/com/baeldung/springai/service/impl/PoetryServiceImpl.java deleted file mode 100644 index 8c9b1062c518..000000000000 --- a/spring-ai/src/main/java/com/baeldung/springai/service/impl/PoetryServiceImpl.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.baeldung.springai.service.impl; - -import com.baeldung.springai.dto.PoetryDto; -import com.baeldung.springai.service.PoetryService; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.ai.converter.BeanOutputConverter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; - -@Service -public class PoetryServiceImpl implements PoetryService { - - public static final String WRITE_ME_HAIKU_ABOUT_CAT = """ - Write me Haiku about cat, - haiku should start with the word cat obligatory - """; - private final ChatModel aiClient; - - @Autowired - public PoetryServiceImpl(@Qualifier("openAiChatModel") ChatModel aiClient) { - this.aiClient = aiClient; - } - @Override - public String getCatHaiku() { - return aiClient.call(WRITE_ME_HAIKU_ABOUT_CAT); - } - - @Override - public PoetryDto getPoetryByGenreAndTheme(String genre, String theme) { - BeanOutputConverter outputConverter = new BeanOutputConverter<>(PoetryDto.class); - - String promptString = """ - Write me {genre} poetry about {theme} - {format} - """; - - PromptTemplate promptTemplate = new PromptTemplate(promptString); - promptTemplate.add("genre", genre); - promptTemplate.add("theme", theme); - promptTemplate.add("format", outputConverter.getFormat()); - - ChatResponse response = aiClient.call(promptTemplate.create()); - return outputConverter.convert(response.getResult().getOutput().getText()); - } -} diff --git a/spring-ai/src/main/java/com/baeldung/springai/web/ExceptionTranslator.java b/spring-ai/src/main/java/com/baeldung/springai/web/ExceptionTranslator.java deleted file mode 100644 index 08086519ecda..000000000000 --- a/spring-ai/src/main/java/com/baeldung/springai/web/ExceptionTranslator.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.baeldung.springai.web; - -import org.springframework.ai.openai.api.common.OpenAiApiClientErrorException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -import java.util.Optional; - -@RestControllerAdvice -public class ExceptionTranslator extends ResponseEntityExceptionHandler { - - public static final String OPEN_AI_CLIENT_RAISED_EXCEPTION = "Open AI client raised exception"; - - @ExceptionHandler(OpenAiApiClientErrorException.class) - ProblemDetail handleOpenAiHttpException(OpenAiApiClientErrorException ex) { - HttpStatus status = Optional - .ofNullable(HttpStatus.resolve(400)) - .orElse(HttpStatus.BAD_REQUEST); - - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, ex.getMessage()); - problemDetail.setTitle(OPEN_AI_CLIENT_RAISED_EXCEPTION); - return problemDetail; - } -} diff --git a/spring-ai/src/main/java/com/baeldung/springai/web/PoetryController.java b/spring-ai/src/main/java/com/baeldung/springai/web/PoetryController.java deleted file mode 100644 index 1702da19e7ac..000000000000 --- a/spring-ai/src/main/java/com/baeldung/springai/web/PoetryController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.baeldung.springai.web; - -import com.baeldung.springai.dto.PoetryDto; -import com.baeldung.springai.service.PoetryService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("ai") -public class PoetryController { - - private final PoetryService poetryService; - - @Autowired - public PoetryController(PoetryService poetryService) { - this.poetryService = poetryService; - } - - @GetMapping("/cathaiku") - public ResponseEntity generateHaiku() { - return ResponseEntity.ok(poetryService.getCatHaiku()); - } - - @GetMapping("/poetry") - public ResponseEntity generatePoetry(@RequestParam("genre") String genre, @RequestParam("theme") String theme) { - return ResponseEntity.ok(poetryService.getPoetryByGenreAndTheme(genre, theme)); - } -} diff --git a/spring-ai/src/test/java/com/baeldung/springai/web/PoetryControllerManualTest.java b/spring-ai/src/test/java/com/baeldung/springai/web/PoetryControllerManualTest.java deleted file mode 100644 index 6079d092dd2c..000000000000 --- a/spring-ai/src/test/java/com/baeldung/springai/web/PoetryControllerManualTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.baeldung.springai.web; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; -import org.springframework.test.web.servlet.MockMvc; - -import static org.hamcrest.Matchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@AutoConfigureMockMvc -@RunWith(SpringRunner.class) -@SpringBootTest -public class PoetryControllerManualTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - @Qualifier("openAiChatModel") - private ChatModel aiClient; - - @Test - public void givenGetCatHaiku_whenCallingAiClient_thenCorrect() throws Exception { - mockMvc.perform(get("/ai/cathaiku")) - .andExpect(status().isOk()) - .andExpect(content().string(containsStringIgnoringCase("cat"))); - } - - @Test - public void givenGetPoetryWithGenreAndTheme_whenCallingAiClient_thenCorrect() throws Exception { - String genre = "lyric"; - String theme = "coffee"; - mockMvc.perform(get("/ai/poetry?genre={genre}&theme={theme}", genre, theme)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.genre").value(containsStringIgnoringCase(genre))) - .andExpect(jsonPath("$.theme").value(containsStringIgnoringCase(theme))) - .andExpect(jsonPath("$.poetry").isNotEmpty()) - .andExpect(jsonPath("$.title").exists()); - } -} -