From f87573fcb842c678db26191704697018af8b4d80 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sun, 11 Oct 2026 07:41:41 +0000 Subject: [PATCH] Add ai-services and spring-boot-ai-service modules: prompt templates, per-id chat memory, tools, RAG, the @MemoryId trap, the message-window probe, and @AiService on Spring Boot 4.1.1, each with a captured transcript Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk --- .gitignore | 4 + README.md | 34 +++ ai-services/output/01-prompt-template.txt | 6 + ai-services/output/02-memory.txt | 11 + ai-services/output/03-tools.txt | 10 + ai-services/output/04-rag.txt | 5 + ai-services/output/05-memoryid-trap.txt | 2 + ai-services/output/06-window-probe.txt | 8 + .../output/07-embedding-dependencies.txt | 5 + ai-services/pom.xml | 43 ++++ .../com/ankurm/lc4j/aiservices/ChatAgent.java | 15 ++ .../lc4j/aiservices/KnowledgeAgent.java | 6 + .../ankurm/lc4j/aiservices/OrderTools.java | 23 +++ .../lc4j/aiservices/ScriptedChatModel.java | 63 ++++++ .../ankurm/lc4j/aiservices/SupportAgent.java | 16 ++ .../lc4j/aiservices/AiServicesTest.java | 195 ++++++++++++++++++ .../ankurm/lc4j/aiservices/Transcript.java | 23 +++ pom.xml | 66 ++++++ .../output/01-boot-context.txt | 6 + spring-boot-ai-service/pom.xml | 52 +++++ .../java/com/ankurm/lc4j/boot/BootApp.java | 35 ++++ .../ankurm/lc4j/boot/BootAiServiceTest.java | 42 ++++ 22 files changed, 670 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ai-services/output/01-prompt-template.txt create mode 100644 ai-services/output/02-memory.txt create mode 100644 ai-services/output/03-tools.txt create mode 100644 ai-services/output/04-rag.txt create mode 100644 ai-services/output/05-memoryid-trap.txt create mode 100644 ai-services/output/06-window-probe.txt create mode 100644 ai-services/output/07-embedding-dependencies.txt create mode 100644 ai-services/pom.xml create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java create mode 100644 ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java create mode 100644 ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java create mode 100644 ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java create mode 100644 pom.xml create mode 100644 spring-boot-ai-service/output/01-boot-context.txt create mode 100644 spring-boot-ai-service/pom.xml create mode 100644 spring-boot-ai-service/src/main/java/com/ankurm/lc4j/boot/BootApp.java create mode 100644 spring-boot-ai-service/src/test/java/com/ankurm/lc4j/boot/BootAiServiceTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd9e640 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# langchain4j-demo + +Companion code for the ankurm.com LangChain4j posts. Every number and transcript quoted in a post +comes from a file in a module's `output/` folder, and the tests that write those files also assert +the same facts, so the build fails when a claim stops being true. + +| Module | Post | What it shows | +|---|---|---| +| `ai-services` | LangChain4j AI Services with Spring Boot 4: Declarative LLM Interfaces | prompt templates, chat memory, tools, RAG, and the `@MemoryId` trap | +| `spring-boot-ai-service` | same post | `@AiService` on Spring Boot 4.1.1 with the 1.22.0-beta32 starter | + +## Versions (verified on Maven Central, 2026-10-11) + +| Component | Version | Note | +|---|---|---| +| LangChain4j (`langchain4j`, `langchain4j-core`, BOM) | 1.22.0 | GA | +| LangChain4j beta modules (starters, embeddings, agentic) | 1.22.0-beta32 | beta only, no GA exists; the starter is built against Spring Boot 3.5.13 | +| Spring Boot (`spring-boot-ai-service`) | 4.1.1 | GA | +| JDK | 25 LTS | | +| JUnit | 6.1.3 | | + +## Run + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn test # everything +mvn test -pl spring-boot-ai-service -am # one module (-am builds the parent and ai-services too) +``` + +No API key is needed. The tests use `ScriptedChatModel`, a deterministic stand-in for an LLM, so +they can assert exactly which messages LangChain4j sent. Replace it with `OpenAiChatModel` or any +other `ChatModel` and the AI Service interfaces do not change. + +Each module writes its transcripts to `/output/` when you run the tests. diff --git a/ai-services/output/01-prompt-template.txt b/ai-services/output/01-prompt-template.txt new file mode 100644 index 0000000..73279f7 --- /dev/null +++ b/ai-services/output/01-prompt-template.txt @@ -0,0 +1,6 @@ +Messages LangChain4j sent to the model: +SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence. +USER: Customer asks: How do I reset my password? + +Reply: Reset it from Settings > Security. +Implementation is a java.lang.reflect.Proxy: true diff --git a/ai-services/output/02-memory.txt b/ai-services/output/02-memory.txt new file mode 100644 index 0000000..1c0fe3e --- /dev/null +++ b/ai-services/output/02-memory.txt @@ -0,0 +1,11 @@ +Messages the model saw on each call (memory window = 4 stored messages): +call 1: 1 messages +call 2: 3 messages +call 3: 1 messages +call 4: 5 messages +call 5: 5 messages + +Second call, in full: +USER: My name is Alice +AI: noted (1 messages seen) +USER: What is my name? diff --git a/ai-services/output/03-tools.txt b/ai-services/output/03-tools.txt new file mode 100644 index 0000000..007810f --- /dev/null +++ b/ai-services/output/03-tools.txt @@ -0,0 +1,10 @@ +Tool specification sent with the first request: [orderStatus - Looks up the shipping status of an order by its id] + +Second request (after the tool ran): +USER: Where is order A-1001? +AI: tool calls [orderStatus{"orderId":"A-1001"}] +TOOL_EXECUTION_RESULT: Order A-1001 shipped on 2026-10-08, arriving 2026-10-13 + +Java methods actually executed: [orderStatus(A-1001)] +Model round trips: 2 +Final reply: Your order is on its way and arrives on 2026-10-13. diff --git a/ai-services/output/04-rag.txt b/ai-services/output/04-rag.txt new file mode 100644 index 0000000..69dcbcb --- /dev/null +++ b/ai-services/output/04-rag.txt @@ -0,0 +1,5 @@ +What the model received after retrieval (maxResults=1): +USER: How long do refunds take? + +Answer using the following information: +Refunds are issued to the original payment method within 5 business days. diff --git a/ai-services/output/05-memoryid-trap.txt b/ai-services/output/05-memoryid-trap.txt new file mode 100644 index 0000000..238844d --- /dev/null +++ b/ai-services/output/05-memoryid-trap.txt @@ -0,0 +1,2 @@ +Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider: +IllegalConfigurationException: In order to use @MemoryId, please configure the ChatMemoryProvider on the 'com.ankurm.lc4j.aiservices.AiServicesTest$MemoryIdMixed'. diff --git a/ai-services/output/06-window-probe.txt b/ai-services/output/06-window-probe.txt new file mode 100644 index 0000000..8df2e16 --- /dev/null +++ b/ai-services/output/06-window-probe.txt @@ -0,0 +1,8 @@ +Memory window = 4. For each call: messages the model received, messages stored at that moment, messages stored afterwards. +call 1: model received 1, stored 1, stored afterwards 2 +call 2: model received 3, stored 3, stored afterwards 4 +call 3: model received 5, stored 4, stored afterwards 4 +call 4: model received 5, stored 4, stored afterwards 4 +call 5: model received 5, stored 4, stored afterwards 4 + +Largest number of messages the memory object held after any add, used on its own: 4 diff --git a/ai-services/output/07-embedding-dependencies.txt b/ai-services/output/07-embedding-dependencies.txt new file mode 100644 index 0000000..93c3ad8 --- /dev/null +++ b/ai-services/output/07-embedding-dependencies.txt @@ -0,0 +1,5 @@ +Command: mvn dependency:tree -pl ai-services (lines mentioning the embedding model and what it brings in) ++- dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:jar:1.22.0-beta32:compile +| +- com.microsoft.onnxruntime:onnxruntime:jar:1.20.0:compile +| +- ai.djl:api:jar:0.36.0:compile +| \- ai.djl.huggingface:tokenizers:jar:0.36.0:compile diff --git a/ai-services/pom.xml b/ai-services/pom.xml new file mode 100644 index 0000000..7151f6b --- /dev/null +++ b/ai-services/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + + + ai-services + + + + dev.langchain4j + langchain4j + + + dev.langchain4j + langchain4j-embeddings-all-minilm-l6-v2 + ${langchain4j.beta.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java new file mode 100644 index 0000000..caeff26 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ChatAgent.java @@ -0,0 +1,15 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.UserMessage; + +/** + * Kept separate from {@link SupportAgent} on purpose: a single @MemoryId parameter anywhere in an + * interface makes AiServices demand a ChatMemoryProvider for the whole interface (see the + * memoryIdTrap test and output/05-memoryid-trap.txt). + */ +public interface ChatAgent { + + /** With a ChatMemoryProvider, each @MemoryId gets its own conversation history. */ + String chat(@MemoryId String customerId, @UserMessage String message); +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java new file mode 100644 index 0000000..b3d83b8 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/KnowledgeAgent.java @@ -0,0 +1,6 @@ +package com.ankurm.lc4j.aiservices; + +/** Used for the RAG example: same idea, the interface does not mention retrieval at all. */ +public interface KnowledgeAgent { + String ask(String question); +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java new file mode 100644 index 0000000..242a006 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/OrderTools.java @@ -0,0 +1,23 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.util.ArrayList; +import java.util.List; + +/** A plain Java object. The model never runs this code; it only asks LangChain4j to run it. */ +public class OrderTools { + + private final List calls = new ArrayList<>(); + + @Tool("Looks up the shipping status of an order by its id") + public String orderStatus(@P("the order id, for example A-1001") String orderId) { + calls.add("orderStatus(" + orderId + ")"); + return "Order " + orderId + " shipped on 2026-10-08, arriving 2026-10-13"; + } + + public List calls() { + return calls; + } +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java new file mode 100644 index 0000000..66b77c9 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/ScriptedChatModel.java @@ -0,0 +1,63 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * A deterministic stand-in for a real LLM. It lets every example run offline and lets the + * tests assert exactly which messages LangChain4j sent, which is the thing worth looking at. + * Swap it for OpenAiChatModel (or any other ChatModel) and the AI Service code does not change. + */ +public class ScriptedChatModel implements ChatModel { + + private final Function script; + private final List requests = new ArrayList<>(); + + public ScriptedChatModel(Function script) { + this.script = script; + } + + @Override + public ChatResponse doChat(ChatRequest request) { + requests.add(request); + return ChatResponse.builder().aiMessage(script.apply(request)).build(); + } + + /** Every request this model has received, oldest first. */ + public List requests() { + return requests; + } + + public ChatRequest lastRequest() { + return requests.get(requests.size() - 1); + } + + /** Renders the messages of a request as one line per message, for transcripts. */ + public static String render(List messages) { + StringBuilder sb = new StringBuilder(); + for (ChatMessage m : messages) { + sb.append(m.type()).append(": ").append(text(m)).append('\n'); + } + return sb.toString(); + } + + private static String text(ChatMessage m) { + return switch (m) { + case dev.langchain4j.data.message.SystemMessage s -> s.text(); + case dev.langchain4j.data.message.UserMessage u -> u.singleText(); + case AiMessage a -> a.hasToolExecutionRequests() + ? "tool calls " + a.toolExecutionRequests().stream() + .map(r -> r.name() + r.arguments()).toList() + : a.text(); + case dev.langchain4j.data.message.ToolExecutionResultMessage t -> t.text(); + default -> m.toString(); + }; + } +} diff --git a/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java new file mode 100644 index 0000000..317dd41 --- /dev/null +++ b/ai-services/src/main/java/com/ankurm/lc4j/aiservices/SupportAgent.java @@ -0,0 +1,16 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * The whole point of an AI Service: you write an interface, LangChain4j writes the implementation. + * Annotations become the system message and the user message template. + */ +public interface SupportAgent { + + @SystemMessage("You are a concise support agent for {{product}}. Answer in one sentence.") + @UserMessage("Customer asks: {{question}}") + String answer(@V("product") String product, @V("question") String question); +} diff --git a/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java new file mode 100644 index 0000000..55da641 --- /dev/null +++ b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/AiServicesTest.java @@ -0,0 +1,195 @@ +package com.ankurm.lc4j.aiservices; + +import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiServicesTest { + + @Test + void promptTemplateBecomesSystemAndUserMessages() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("Reset it from Settings > Security.")); + SupportAgent agent = AiServices.create(SupportAgent.class, model); + + String reply = agent.answer("Acme Vault", "How do I reset my password?"); + + String sent = ScriptedChatModel.render(model.lastRequest().messages()); + Transcript.write("01-prompt-template.txt", "Messages LangChain4j sent to the model:\n" + sent + "\nReply: " + reply + + "\nImplementation is a java.lang.reflect.Proxy: " + java.lang.reflect.Proxy.isProxyClass(agent.getClass()) + "\n"); + assertThat(java.lang.reflect.Proxy.isProxyClass(agent.getClass())).isTrue(); + + assertThat(sent).contains("SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence."); + assertThat(sent).contains("USER: Customer asks: How do I reset my password?"); + assertThat(reply).isEqualTo("Reset it from Settings > Security."); + } + + @Test + void memoryReplaysEarlierMessagesAndIsKeptPerMemoryId() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("noted (" + r.messages().size() + " messages seen)")); + ChatAgent agent = AiServices.builder(ChatAgent.class) + .chatModel(model) + .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(4)) + .build(); + + agent.chat("alice", "My name is Alice"); + agent.chat("alice", "What is my name?"); + agent.chat("bob", "Hello"); + agent.chat("alice", "third alice message"); + agent.chat("alice", "fourth alice message"); + + StringBuilder sb = new StringBuilder("Messages the model saw on each call (memory window = 4 stored messages):\n"); + int i = 1; + for (var req : model.requests()) { + sb.append("call ").append(i++).append(": ").append(req.messages().size()).append(" messages\n"); + } + sb.append("\nSecond call, in full:\n").append(ScriptedChatModel.render(model.requests().get(1).messages())); + Transcript.write("02-memory.txt", sb.toString()); + + // call 2 replays call 1 (user + ai) and adds the new user message + assertThat(model.requests().get(1).messages()).hasSize(3); + // bob has his own history, so his first call sees exactly one message + assertThat(model.requests().get(2).messages()).hasSize(1); + // The window caps what is STORED (4). The request carries the stored window plus the incoming + // user message, so the model sees 5. Observed on 1.22.0; see output/02-memory.txt. + assertThat(model.requests().get(4).messages()).hasSize(5); + } + + @Test + void windowCapsStoredMessagesButTheRequestCarriesOneMore() { + MessageWindowChatMemory memory = MessageWindowChatMemory.withMaxMessages(4); + StringBuilder sb = new StringBuilder("Memory window = 4. For each call: messages the model received, messages stored at that moment, messages stored afterwards.\n"); + int[] call = {0}; + ScriptedChatModel model = new ScriptedChatModel(r -> { + sb.append("call ").append(++call[0]).append(": model received ").append(r.messages().size()) + .append(", stored ").append(memory.messages().size()); + return AiMessage.from("ok"); + }); + KnowledgeAgent agent = AiServices.builder(KnowledgeAgent.class).chatModel(model).chatMemory(memory).build(); + int maxReceived = 0; + int maxStored = 0; + for (int i = 1; i <= 5; i++) { + agent.ask("m" + i); + sb.append(", stored afterwards ").append(memory.messages().size()).append('\n'); + maxStored = Math.max(maxStored, memory.messages().size()); + maxReceived = Math.max(maxReceived, model.requests().get(i - 1).messages().size()); + } + // the memory class on its own, no AI Service involved + MessageWindowChatMemory alone = MessageWindowChatMemory.withMaxMessages(4); + int aloneMax = 0; + for (int i = 1; i <= 6; i++) { + alone.add(i % 2 == 1 ? UserMessage.from("U" + i) : AiMessage.from("A" + i)); + aloneMax = Math.max(aloneMax, alone.messages().size()); + } + sb.append("\nLargest number of messages the memory object held after any add, used on its own: ").append(aloneMax).append('\n'); + Transcript.write("06-window-probe.txt", sb.toString()); + + assertThat(maxStored).isEqualTo(4); + assertThat(maxReceived).isEqualTo(5); + assertThat(aloneMax).isEqualTo(4); + } + + @Test + void memoryIdTrap() { + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok")); + Throwable failure = org.assertj.core.api.Assertions.catchThrowable( + () -> AiServices.create(MemoryIdMixed.class, model)); + + Transcript.write("05-memoryid-trap.txt", + "Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider:\n" + + failure.getClass().getSimpleName() + ": " + failure.getMessage() + "\n"); + + assertThat(failure).hasMessageContaining("@MemoryId").hasMessageContaining("ChatMemoryProvider"); + } + + /** One method uses @MemoryId, the other does not. The build still fails for both. */ + interface MemoryIdMixed { + String plain(String message); + + String remembered(@dev.langchain4j.service.MemoryId String id, @dev.langchain4j.service.UserMessage String message); + } + + @Test + void toolCallIsRunByLangChain4jNotByTheModel() { + int[] step = {0}; + ScriptedChatModel model = new ScriptedChatModel(r -> { + if (step[0]++ == 0) { + return AiMessage.from(dev.langchain4j.agent.tool.ToolExecutionRequest.builder() + .id("call-1").name("orderStatus").arguments("{\"orderId\":\"A-1001\"}").build()); + } + return AiMessage.from("Your order is on its way and arrives on 2026-10-13."); + }); + OrderTools tools = new OrderTools(); + ChatAgent agent = AiServices.builder(ChatAgent.class) + .chatModel(model) + .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(10)) + .tools(tools) + // 1.22.0 logs a notice that the defaults will change; set both explicitly + .toolArgumentsErrorHandler(dev.langchain4j.service.tool.ToolArgumentsErrorHandler.sendExceptionMessageToLlm()) + .toolExecutionErrorHandler(dev.langchain4j.service.tool.ToolExecutionErrorHandler.failInvocationUnlessVisibleToLlm()) + .build(); + + String reply = agent.chat("carol", "Where is order A-1001?"); + + StringBuilder sb = new StringBuilder(); + sb.append("Tool specification sent with the first request: ") + .append(model.requests().get(0).toolSpecifications().stream().map(t -> t.name() + " - " + t.description()).toList()) + .append('\n'); + sb.append("\nSecond request (after the tool ran):\n").append(ScriptedChatModel.render(model.requests().get(1).messages())); + sb.append("\nJava methods actually executed: ").append(tools.calls()).append('\n'); + sb.append("Model round trips: ").append(model.requests().size()).append('\n'); + sb.append("Final reply: ").append(reply).append('\n'); + Transcript.write("03-tools.txt", sb.toString()); + + assertThat(tools.calls()).containsExactly("orderStatus(A-1001)"); + assertThat(model.requests()).hasSize(2); + assertThat(reply).contains("2026-10-13"); + } + + @Test + void ragInjectsRetrievedTextIntoTheUserMessage() { + EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + InMemoryEmbeddingStore store = new InMemoryEmbeddingStore<>(); + List facts = List.of( + "Refunds are issued to the original payment method within 5 business days.", + "The Acme Vault desktop app supports Windows 11 and macOS 15.", + "Support is available Monday to Friday, 9am to 6pm IST."); + for (String f : facts) { + TextSegment seg = TextSegment.from(f); + Embedding e = embeddingModel.embed(seg).content(); + store.add(e, seg); + } + + ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok")); + KnowledgeAgent agent = AiServices.builder(KnowledgeAgent.class) + .chatModel(model) + .contentRetriever(EmbeddingStoreContentRetriever.builder() + .embeddingStore(store) + .embeddingModel(embeddingModel) + .maxResults(1) + .build()) + .build(); + + agent.ask("How long do refunds take?"); + + String sent = ScriptedChatModel.render(model.lastRequest().messages()); + Transcript.write("04-rag.txt", "What the model received after retrieval (maxResults=1):\n" + sent); + + assertThat(sent).contains("How long do refunds take?"); + assertThat(sent).contains("Refunds are issued to the original payment method"); + assertThat(sent).doesNotContain("Windows 11"); + } +} diff --git a/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java new file mode 100644 index 0000000..30fe613 --- /dev/null +++ b/ai-services/src/test/java/com/ankurm/lc4j/aiservices/Transcript.java @@ -0,0 +1,23 @@ +package com.ankurm.lc4j.aiservices; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Writes what a test observed to output/NN-name.txt so every figure in the post comes from a file. */ +final class Transcript { + + private static final Path DIR = Path.of("output"); + + private Transcript() { + } + + static void write(String name, String content) { + try { + Files.createDirectories(DIR); + Files.writeString(DIR.resolve(name), content); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..41641fb --- /dev/null +++ b/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + pom + + + ai-services + spring-boot-ai-service + + + + 25 + UTF-8 + + 1.22.0 + 1.22.0-beta32 + 6.1.3 + 3.27.6 + 2.0.17 + + + + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + + diff --git a/spring-boot-ai-service/output/01-boot-context.txt b/spring-boot-ai-service/output/01-boot-context.txt new file mode 100644 index 0000000..c7df0c8 --- /dev/null +++ b/spring-boot-ai-service/output/01-boot-context.txt @@ -0,0 +1,6 @@ +Spring Boot version: 4.1.1 +Bean of the interface type exists: [bootApp.Greeter] +Messages sent to the model: +SYSTEM: You greet people briefly. +USER: Greet Ankur +Reply: Reply built from 2 message(s). diff --git a/spring-boot-ai-service/pom.xml b/spring-boot-ai-service/pom.xml new file mode 100644 index 0000000..d54d504 --- /dev/null +++ b/spring-boot-ai-service/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + + + spring-boot-ai-service + + + 4.1.1 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.ankurm.langchain4j + ai-services + ${project.version} + + + dev.langchain4j + langchain4j-spring-boot-starter + ${langchain4j.beta.version} + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/spring-boot-ai-service/src/main/java/com/ankurm/lc4j/boot/BootApp.java b/spring-boot-ai-service/src/main/java/com/ankurm/lc4j/boot/BootApp.java new file mode 100644 index 0000000..79c5b11 --- /dev/null +++ b/spring-boot-ai-service/src/main/java/com/ankurm/lc4j/boot/BootApp.java @@ -0,0 +1,35 @@ +package com.ankurm.lc4j.boot; + +import com.ankurm.lc4j.aiservices.ScriptedChatModel; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.service.spring.AiService; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.SystemMessage; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class BootApp { + + /** Stand-in for an OpenAI or Ollama model bean. @AiService finds the single ChatModel in the context. */ + @Bean + ScriptedChatModel chatModel() { + return new ScriptedChatModel(r -> AiMessage.from("Reply built from " + r.messages().size() + " message(s).")); + } + + @Bean + dev.langchain4j.memory.ChatMemory chatMemory() { + return MessageWindowChatMemory.withMaxMessages(10); + } + + /** The whole declaration. No @Bean method, no builder: the scanner creates and registers it. */ + @AiService + public interface Greeter { + + @SystemMessage("You greet people briefly.") + @UserMessage("Greet {{it}}") + String greet(String name); + } +} diff --git a/spring-boot-ai-service/src/test/java/com/ankurm/lc4j/boot/BootAiServiceTest.java b/spring-boot-ai-service/src/test/java/com/ankurm/lc4j/boot/BootAiServiceTest.java new file mode 100644 index 0000000..ed32c77 --- /dev/null +++ b/spring-boot-ai-service/src/test/java/com/ankurm/lc4j/boot/BootAiServiceTest.java @@ -0,0 +1,42 @@ +package com.ankurm.lc4j.boot; + +import com.ankurm.lc4j.aiservices.ScriptedChatModel; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class BootAiServiceTest { + + @Autowired + ApplicationContext context; + + @Autowired + BootApp.Greeter greeter; + + @Autowired + ScriptedChatModel model; + + @Test + void aiServiceInterfaceIsRegisteredAsABeanAndWorks() throws IOException { + String reply = greeter.greet("Ankur"); + + StringBuilder sb = new StringBuilder(); + sb.append("Spring Boot version: ").append(org.springframework.boot.SpringBootVersion.getVersion()).append('\n'); + sb.append("Bean of the interface type exists: ").append(context.getBeansOfType(BootApp.Greeter.class).keySet()).append('\n'); + sb.append("Messages sent to the model:\n").append(ScriptedChatModel.render(model.lastRequest().messages())); + sb.append("Reply: ").append(reply).append('\n'); + Files.createDirectories(Path.of("output")); + Files.writeString(Path.of("output/01-boot-context.txt"), sb.toString()); + + assertThat(reply).startsWith("Reply built from"); + assertThat(context.getBeansOfType(BootApp.Greeter.class)).hasSize(1); + } +}