diff --git a/pom.xml b/pom.xml index 4d0b9c0bc8..dcc929bd14 100644 --- a/pom.xml +++ b/pom.xml @@ -841,6 +841,7 @@ spring-5 spring-activiti spring-actuator + spring-ai spring-aop-2 spring-batch-2 @@ -1093,6 +1094,7 @@ spring-5 spring-activiti spring-actuator + spring-ai spring-aop-2 spring-batch-2 diff --git a/spring-ai/.gitignore b/spring-ai/.gitignore new file mode 100644 index 0000000000..f475e4f82b --- /dev/null +++ b/spring-ai/.gitignore @@ -0,0 +1,12 @@ +#folders# +.idea +/target +/neoDb* +/data +/src/main/webapp/WEB-INF/classes +*/META-INF/* + +# Packaged files # +*.jar +*.war +*.ear diff --git a/spring-ai/pom.xml b/spring-ai/pom.xml new file mode 100644 index 0000000000..d0e8ab2d1b --- /dev/null +++ b/spring-ai/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + spring-ai + spring-ai + war + + + com.baeldung + parent-boot-3 + 0.0.1-SNAPSHOT + ../parent-boot-3 + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + + + org.springframework.experimental.ai + spring-ai-openai-spring-boot-starter + 0.7.1-SNAPSHOT + + + org.springframework.boot + spring-boot-starter-web + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.baeldung.spring.ai.SpringAIProjectApplication + JAR + + + + org.apache.maven.plugins + maven-compiler-plugin + + 16 + 16 + + **/*ManualTest.java + + + + + + diff --git a/spring-ai/postman/Spring_AI_Poetry.postman_environment.json b/spring-ai/postman/Spring_AI_Poetry.postman_environment.json new file mode 100644 index 0000000000..b3d4a00bee --- /dev/null +++ b/spring-ai/postman/Spring_AI_Poetry.postman_environment.json @@ -0,0 +1,25 @@ +{ + "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 new file mode 100644 index 0000000000..b26652bb4e --- /dev/null +++ b/spring-ai/postman/spring-ai.postman_collection.json @@ -0,0 +1,56 @@ +{ + "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/spring/ai/SpringAIProjectApplication.java b/spring-ai/src/main/java/com/baeldung/spring/ai/SpringAIProjectApplication.java new file mode 100644 index 0000000000..3dfa800d26 --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/SpringAIProjectApplication.java @@ -0,0 +1,9 @@ +package com.baeldung.spring.ai; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringAIProjectApplication { + public static void main(String[] args) { SpringApplication.run(SpringAIProjectApplication.class, args);} +} diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java b/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java new file mode 100644 index 0000000000..229d2b13f0 --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java @@ -0,0 +1,3 @@ +package com.baeldung.spring.ai.dto; + +public record PoetryDto (String title, String poetry, String genre, String theme) {} diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java b/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java new file mode 100644 index 0000000000..e2760b8950 --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java @@ -0,0 +1,10 @@ +package com.baeldung.spring.ai.service; + +import com.baeldung.spring.ai.dto.PoetryDto; + +public interface PoetryService { + + String getCatHaiku(); + + PoetryDto getPoetryByGenreAndTheme(String genre, String theme); +} diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java b/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java new file mode 100644 index 0000000000..791dba372e --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java @@ -0,0 +1,50 @@ +package com.baeldung.spring.ai.service.impl; + +import com.baeldung.spring.ai.dto.PoetryDto; +import com.baeldung.spring.ai.service.PoetryService; +import org.springframework.ai.client.AiClient; +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.parser.BeanOutputParser; +import org.springframework.ai.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.GetMapping; + +@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 AiClient aiClient; + + @Autowired + public PoetryServiceImpl(AiClient aiClient) { + this.aiClient = aiClient; + } + @Override + public String getCatHaiku() { + return aiClient.generate(WRITE_ME_HAIKU_ABOUT_CAT); + } + + @Override + public PoetryDto getPoetryByGenreAndTheme(String genre, String theme) { + BeanOutputParser poetryDtoBeanOutputParser = new BeanOutputParser<>(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", poetryDtoBeanOutputParser.getFormat()); + + promptTemplate.setOutputParser(poetryDtoBeanOutputParser); + + AiResponse response = aiClient.generate(promptTemplate.create()); + return poetryDtoBeanOutputParser.parse(response.getGeneration().getText()); + } +} diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java b/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java new file mode 100644 index 0000000000..55ba383fa6 --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java @@ -0,0 +1,27 @@ +package com.baeldung.spring.ai.web; + +import com.theokanning.openai.OpenAiHttpException; +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(OpenAiHttpException.class) + ProblemDetail handleOpenAiHttpException(OpenAiHttpException ex) { + HttpStatus status = Optional + .ofNullable(HttpStatus.resolve(ex.statusCode)) + .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/spring/ai/web/PoetryController.java b/spring-ai/src/main/java/com/baeldung/spring/ai/web/PoetryController.java new file mode 100644 index 0000000000..27c1bad6e2 --- /dev/null +++ b/spring-ai/src/main/java/com/baeldung/spring/ai/web/PoetryController.java @@ -0,0 +1,39 @@ +package com.baeldung.spring.ai.web; + +import com.baeldung.spring.ai.dto.PoetryDto; +import com.baeldung.spring.ai.service.PoetryService; +import org.springframework.ai.client.AiClient; +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.parser.BeanOutputParser; +import org.springframework.ai.prompt.PromptTemplate; +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/main/resources/application.yml b/spring-ai/src/main/resources/application.yml new file mode 100644 index 0000000000..ec3b936adc --- /dev/null +++ b/spring-ai/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + ai: + openai.api-key: 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 diff --git a/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java b/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java new file mode 100644 index 0000000000..23202e6bce --- /dev/null +++ b/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java @@ -0,0 +1,50 @@ +package com.baeldung.spring.ai.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.ai.client.AiClient; +import org.springframework.beans.factory.annotation.Autowired; +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 + private AiClient 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()); + } +} + diff --git a/spring-ai/src/test/resources/application.yml b/spring-ai/src/test/resources/application.yml new file mode 100644 index 0000000000..813348fec5 --- /dev/null +++ b/spring-ai/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + ai: + openai.api-key: "mock-key-token"