diff --git a/springboot-modules/spring-ai/pom.xml b/springboot-modules/spring-ai/pom.xml index 68252bd..e703ffe 100644 --- a/springboot-modules/spring-ai/pom.xml +++ b/springboot-modules/spring-ai/pom.xml @@ -27,6 +27,10 @@ spring-ai-spring-boot-testcontainers test + + org.springframework.ai + spring-ai-vector-store + org.testcontainers junit-jupiter @@ -50,7 +54,7 @@ com.fasterxml.jackson.core jackson-core - 2.17.2 + 2.18.3 org.springframework.boot @@ -62,6 +66,12 @@ hsqldb ${hsqldb.version} + + + org.springframework.ai + spring-ai-starter-model-azure-openai + + diff --git a/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ApplicationConfiguration.java b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ApplicationConfiguration.java new file mode 100644 index 0000000..6efc37e --- /dev/null +++ b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ApplicationConfiguration.java @@ -0,0 +1,60 @@ +package com.ks.azureopenai; + +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import com.azure.ai.openai.OpenAIClientBuilder; +import com.azure.core.credential.AzureKeyCredential; + +@Configuration +@Profile("azureopenai") +public class ApplicationConfiguration { + @Value("${spring.ai.azure.openai.api-key}") + private String apiKey; + @Value("${spring.ai.azure.openai.endpoint}") + private String endpoint; + + @Bean + public ChatService chatService(AzureOpenAiChatModel azureOpenAiChatModel) { + return new ChatService(azureOpenAiChatModel); + } + + @Bean + public ChatService customChatService() { + OpenAIClientBuilder openAIClientBuilder = new OpenAIClientBuilder() + .credential(new AzureKeyCredential(getCredential())) + .endpoint(getEndpoint()); + + AzureOpenAiChatOptions openAIChatOptions = AzureOpenAiChatOptions.builder() + .deploymentName("gpt-5-nano") + .temperature(1d) + .build(); + + AzureOpenAiChatModel chatModel = AzureOpenAiChatModel.builder() + .openAIClientBuilder(openAIClientBuilder) + .defaultOptions(openAIChatOptions) + .build(); + return new ChatService(chatModel); + } + + private String getCredential() { + return apiKey; + } + + private String getEndpoint() { + return endpoint; + } + + @Bean + VectorDBService vectorDBStore(AzureOpenAiEmbeddingModel embeddingModel) { + return new VectorDBService(SimpleVectorStore.builder(embeddingModel) + .build() + ); + } +} diff --git a/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatApplication.java b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatApplication.java new file mode 100644 index 0000000..5a10149 --- /dev/null +++ b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatApplication.java @@ -0,0 +1,24 @@ +package com.ks.azureopenai; + +import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(exclude = { OpenAiAudioSpeechAutoConfiguration.class, + OpenAiAudioTranscriptionAutoConfiguration.class, + OpenAiChatAutoConfiguration.class, + OpenAiEmbeddingAutoConfiguration.class, + OpenAiImageAutoConfiguration.class, + OpenAiModerationAutoConfiguration.class +}) +public class ChatApplication { + public static void main(String[] args) { + SpringApplication.run(ChatApplication.class, args); + } + +} diff --git a/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatService.java b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatService.java new file mode 100644 index 0000000..97e3ceb --- /dev/null +++ b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/ChatService.java @@ -0,0 +1,20 @@ +package com.ks.azureopenai; + +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.chat.prompt.Prompt; + +public class ChatService { + + AzureOpenAiChatModel chatModel; + + public ChatService(AzureOpenAiChatModel chatModel) { + this.chatModel = chatModel; + } + + public String chat(Prompt prompt) { + return chatModel.call(prompt) + .getResult() + .getOutput() + .getText(); + } +} diff --git a/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/VectorDBService.java b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/VectorDBService.java new file mode 100644 index 0000000..ee901e3 --- /dev/null +++ b/springboot-modules/spring-ai/src/main/java/com/ks/azureopenai/VectorDBService.java @@ -0,0 +1,34 @@ +package com.ks.azureopenai; + +import java.util.List; + +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.SimpleVectorStore; + +public class VectorDBService { + + private SimpleVectorStore vectorStore; + + public VectorDBService(SimpleVectorStore vectorStore) { + this.vectorStore = vectorStore; + } + + public void saveDocs(List docs) { + this.vectorStore.doAdd(docs); + } + + public String fetchContextFromVectorDB(String query) { + List queryResults = vectorStore.doSimilaritySearch( + SearchRequest.builder() + .query(query).topK(2) + .build() + ); + StringBuilder contextBuilder = new StringBuilder(); + for (Document docs : queryResults) { + contextBuilder.append(docs.getText()).append(" "); + } + return contextBuilder.toString().trim(); + } + +} diff --git a/springboot-modules/spring-ai/src/main/resources/application-azureopenai.properties b/springboot-modules/spring-ai/src/main/resources/application-azureopenai.properties new file mode 100644 index 0000000..81d29f7 --- /dev/null +++ b/springboot-modules/spring-ai/src/main/resources/application-azureopenai.properties @@ -0,0 +1,9 @@ +spring.application.name=spring-ai-azureopenai +spring.ai.azure.openai.chat.options.user=springai-azureopenai-user +spring.ai.azure.openai.api-key=FSPyum-XXXXX +spring.ai.azure.openai.endpoint=https://parth-YYYY-eastus2.openai.azure.com/ +spring.ai.azure.openai.chat.options.deployment-name=gpt-5-nano +spring.ai.azure.openai.chat.options.temperature=1 + +spring.ai.azure.openai.embedding.options.deployment-name=text-embedding-ada-002 +spring.ai.azure.openai.embedding.options.model=text-embedding-ada-002 \ No newline at end of file diff --git a/springboot-modules/spring-ai/src/main/resources/puml/azureopenai-cld.puml b/springboot-modules/spring-ai/src/main/resources/puml/azureopenai-cld.puml new file mode 100644 index 0000000..1965fe8 --- /dev/null +++ b/springboot-modules/spring-ai/src/main/resources/puml/azureopenai-cld.puml @@ -0,0 +1,49 @@ +@startuml +'https://plantuml.com/class-diagram +set namespaceSeparator none +allowmixing +hide empty attributes +'skinparam Handwritten false +skinparam ClassBorderColor black +skinparam BackgroundColor #F0EDDE +skinparam ClassAttributeFontColor #222222 +skinparam ClassFontStyle bold + +skinparam class { +ArrowColor #3C88A3 +ArrowFontColor #3C88A3 +hide empty attributes +skinparam Handwritten false +skinparam ClassBorderColor black +BackgroundColor #FFFFFF +} +together { + class "AzureOpenAIChatModel" as acm { + +call(Prompt prompt): ChatResponse + +call(String prompt): String + +getDefaultOptions(): AzureOpenAiChatOptions + } + class "AzureOpenAiChatModel.Builder" as ab { + +defaultOptions(AzureOpenAiChatOptions options): AzureOpenAiChatModel.Builder + +build(): AzureOpenAIChatModel + } + class "AzureOpenAiChatOptions" as ao { + +getTemperature(): Double + +getMaxTokens(): Integer + +getModel(): String + +builder(): AzureOpenAiChatOptions.Builder + } + +} + +class "AzureOpenAiChatOptions.Builder" as aob { + +temperature(Double temperature): AzureOpenAiChatOptions.Builder + +maxTokens(Integer maxTokens): AzureOpenAiChatOptions.Builder + +model(String model): AzureOpenAiChatOptions.Builder + +build(): AzureOpenAiChatOptions +} +aob -down[hidden]- acm : dummy +acm <-down- ab : static nested +aob -left-> ao : static nested +acm .up.> ao : uses +@enduml \ No newline at end of file diff --git a/springboot-modules/spring-ai/src/test/java/com/ks/azureopenai/SpringAIAzureOpenAiLiveTest.java b/springboot-modules/spring-ai/src/test/java/com/ks/azureopenai/SpringAIAzureOpenAiLiveTest.java new file mode 100644 index 0000000..b5f90dd --- /dev/null +++ b/springboot-modules/spring-ai/src/test/java/com/ks/azureopenai/SpringAIAzureOpenAiLiveTest.java @@ -0,0 +1,115 @@ +package com.ks.azureopenai; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.document.Document; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ActiveProfiles("azureopenai") +public class SpringAIAzureOpenAiLiveTest { + final Logger logger = LoggerFactory.getLogger(SpringAIAzureOpenAiLiveTest.class); + @Autowired + private ChatService chatService; + + @Autowired + private ChatService customChatService; + + @Autowired + private VectorDBService vectorDBStore; + + @BeforeAll + void setup() { + Document doc1 = new Document(""" + TechVerse Solutions provides tailor-made + cloud integration services for finance and retail companies. + """); + Document doc2 = new Document(""" + The company’s AI-powered analytics platform delivers actionable + insights to help customers optimize their business operations. + """); + Document doc3 = new Document(""" + At TechVerse Solutions, dedicated experts support clients with 24/7 + monitoring and rapid troubleshooting. + """); + + List docs = List.of(doc1, doc2, doc3); + vectorDBStore.saveDocs(docs); + logger.info("Documents added to vector store"); + } + + @Test + void givenProgrammaticallyConfiguredClient_whenQueryLLM_thenRespond() { + String query = "TechVerse Solutions provides cloud " + + "integration services for what type of companies?"; + String context = vectorDBStore.fetchContextFromVectorDB(query); + logger.info("context fetched: {}", context); + String prompt = """ + Context: {context} + Question: {question} + Instructions: + Using the provided context, answer the question in a concise manner. + If the context does not contain the answer, respond with "I don't know". + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(prompt) + .variables(Map.of("context", context, "question", query)) + .build(); + Prompt finalPrompt = promptTemplate.create(); + + logger.info("finalPrompt: {}", finalPrompt.getContents()); + + String response = customChatService.chat(finalPrompt); + + logger.info("response: {}", response); + + assertThat(response) + .isNotNull() + .containsIgnoringCase("Finance") + .containsIgnoringCase("Retail"); + } + + @Test + void givenAutoConfiguredClient_whenQueryLLM_thenRespond() { + String query = "TechVerse Solutions provides cloud " + + "integration services for what type of companies?"; + String context = vectorDBStore.fetchContextFromVectorDB(query); + logger.info("context fetched: {}", context); + String prompt = """ + Context: {context} + Question: {question} + Instructions: + Using the provided context, answer the question in a concise manner. + If the context does not contain the answer, respond with "I don't know". + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(prompt) + .variables(Map.of("context", context, "question", query)) + .build(); + Prompt finalPrompt = promptTemplate.create(); + + logger.info("finalPrompt: {}", finalPrompt.getContents()); + + String response = chatService.chat(finalPrompt); + + logger.info("response: {}", response); + + assertThat(response) + .isNotNull() + .containsIgnoringCase("Finance") + .containsIgnoringCase("Retail"); + } +}