diff --git a/00-use-cases/chatbot/README.md b/00-use-cases/chatbot/README.md new file mode 100644 index 0000000..4ed163e --- /dev/null +++ b/00-use-cases/chatbot/README.md @@ -0,0 +1,43 @@ +# Chatbot + +Chat with LLMs via Ollama. + +## Running the application + +The application relies on Ollama for providing LLMs. You can either run Ollama locally on your laptop, or rely on the Testcontainers support in Spring Boot to spin up an Ollama service automatically. + +### Ollama as a native application + +First, make sure you have [Ollama](https://ollama.ai) installed on your laptop. +Then, use Ollama to run the _llama3_ large language model. + +```shell +ollama run llama3 +``` + +Finally, run the Spring Boot application. + +```shell +./gradlew bootRun +``` + +### Ollama as a dev service with Testcontainers + +The application relies on the native Testcontainers support in Spring Boot to spin up an Ollama service with a _llama3_ model at startup time. + +```shell +./gradlew bootTestRun +``` + +## Calling the application + +You can now call the application that will use Ollama and llama3 to answer your questions. +This example uses [httpie](https://httpie.io) to send HTTP requests. + +```shell +http --raw "Where did Saruman got the wood to build a weapon factory?" :8080/chat +``` + +```shell +http --raw "Can a wolf destroy a house?" :8080/chat +``` diff --git a/00-use-cases/chatbot/build.gradle b/00-use-cases/chatbot/build.gradle new file mode 100644 index 0000000..4875f8a --- /dev/null +++ b/00-use-cases/chatbot/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.thomasvitale' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } +} + +dependencies { + implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}") + + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter' + + testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webflux' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.ai:spring-ai-spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:ollama' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/Chatbot.java b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/Chatbot.java new file mode 100644 index 0000000..75f18ae --- /dev/null +++ b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/Chatbot.java @@ -0,0 +1,28 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; +import org.springframework.ai.tokenizer.TokenCountEstimator; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class Chatbot { + + public static void main(String[] args) { + SpringApplication.run(Chatbot.class, args); + } + + @Bean + ChatMemory chatHistory() { + return new InMemoryChatMemory(); + } + + @Bean + TokenCountEstimator tokenCountEstimator() { + return new JTokkitTokenCountEstimator(); + } + +} diff --git a/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotController.java b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotController.java new file mode 100644 index 0000000..72e13c1 --- /dev/null +++ b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotController.java @@ -0,0 +1,21 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ChatbotController { + + private final ChatbotService chatbotService; + + ChatbotController(ChatbotService chatbotService) { + this.chatbotService = chatbotService; + } + + @PostMapping("/chat") + String chat(@RequestBody String input) { + return chatbotService.chat(input); + } + +} diff --git a/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotService.java b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotService.java new file mode 100644 index 0000000..8cb6046 --- /dev/null +++ b/00-use-cases/chatbot/src/main/java/com/thomasvitale/ai/spring/ChatbotService.java @@ -0,0 +1,35 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.ai.chat.ChatModel; +import org.springframework.ai.chat.memory.*; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.PromptTransformingChatService; +import org.springframework.ai.tokenizer.TokenCountEstimator; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +class ChatbotService { + + private final ChatService chatService; + + ChatbotService(ChatModel chatModel, ChatMemory chatMemory, TokenCountEstimator tokenCountEstimator) { + this.chatService = PromptTransformingChatService.builder(chatModel) + .withRetrievers(List.of(new ChatMemoryRetriever(chatMemory))) + .withContentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) + .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatMemory))) + .build(); + } + + String chat(String message) { + var prompt = new Prompt(new UserMessage(message)); + var chatServiceResponse = this.chatService.call(new ChatServiceContext(prompt)); + return chatServiceResponse.getChatResponse().getResult().getOutput().getContent(); + } + +} diff --git a/00-use-cases/chatbot/src/main/resources/application.yml b/00-use-cases/chatbot/src/main/resources/application.yml new file mode 100644 index 0000000..5b2bd3a --- /dev/null +++ b/00-use-cases/chatbot/src/main/resources/application.yml @@ -0,0 +1,9 @@ +spring: + ai: + ollama: + chat: + options: + model: llama3 + threads: + virtual: + enabled: true diff --git a/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/ChatbotTests.java b/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/ChatbotTests.java new file mode 100644 index 0000000..f1d1021 --- /dev/null +++ b/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/ChatbotTests.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ChatbotTests { + + @Test + void contextLoads() { + } + +} diff --git a/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/TestChatbot.java b/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/TestChatbot.java new file mode 100644 index 0000000..f8909ea --- /dev/null +++ b/00-use-cases/chatbot/src/test/java/com/thomasvitale/ai/spring/TestChatbot.java @@ -0,0 +1,26 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.devtools.restart.RestartScope; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.ollama.OllamaContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration(proxyBeanMethods = false) +public class TestChatbot { + + @Bean + @RestartScope + @ServiceConnection + OllamaContainer ollama() { + return new OllamaContainer(DockerImageName.parse("ghcr.io/thomasvitale/ollama-llama3") + .asCompatibleSubstituteFor("ollama/ollama")); + } + + public static void main(String[] args) { + SpringApplication.from(Chatbot::main).with(TestChatbot.class).run(args); + } + +} diff --git a/02-prompts/prompts-multimodality-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java b/02-prompts/prompts-multimodality-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java index 46332b5..c99a765 100644 --- a/02-prompts/prompts-multimodality-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java +++ b/02-prompts/prompts-multimodality-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java @@ -2,14 +2,14 @@ import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.messages.Media; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.util.MimeTypeUtils; import java.io.IOException; -import java.util.List; +import java.net.MalformedURLException; +import java.net.URI; @Service class ChatService { @@ -33,12 +33,14 @@ String chatFromImageFile(String message) throws IOException { .content(); } - String chatFromImageUrl(String message) { + String chatFromImageUrl(String message) throws MalformedURLException { var imageUrl = "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"; - var userMessage = new UserMessage(message, - List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageUrl))); + var url = URI.create(imageUrl).toURL(); return chatClient.prompt() - .messages(userMessage) + .user(userSpec -> userSpec + .text(message) + .media(new Media(MimeTypeUtils.IMAGE_PNG, url)) + ) .call() .content(); } diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 289a639..8d6a4a2 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,8 +7,8 @@ repositories { } ext { - set("springBootVersion", '3.2.5') - set("dependencyManagementVersion", '1.1.4') + set("springBootVersion", '3.3.0') + set("dependencyManagementVersion", '1.1.5') } dependencies { diff --git a/settings.gradle b/settings.gradle index 7daa1de..f9b2ac1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,7 @@ plugins { rootProject.name = 'llm-apps-java-spring-ai' +include '00-use-cases:chatbot' include '00-use-cases:question-answering-with-documents' include '01-chat-models:chat-models-mistral-ai'