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 <[email protected]> Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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 `<module>/output/` when you run the tests.
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm.langchain4j</groupId>
|
||||
<artifactId>langchain4j-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ai-services</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>${assertj.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String> 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<String> calls() {
|
||||
return calls;
|
||||
}
|
||||
}
|
||||
@@ -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<ChatRequest, AiMessage> script;
|
||||
private final List<ChatRequest> requests = new ArrayList<>();
|
||||
|
||||
public ScriptedChatModel(Function<ChatRequest, AiMessage> 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<ChatRequest> 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<ChatMessage> 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<TextSegment> store = new InMemoryEmbeddingStore<>();
|
||||
List<String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ankurm.langchain4j</groupId>
|
||||
<artifactId>langchain4j-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>ai-services</module>
|
||||
<module>spring-boot-ai-service</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- Verified on Maven Central 2026-10-11 -->
|
||||
<langchain4j.version>1.22.0</langchain4j.version>
|
||||
<langchain4j.beta.version>1.22.0-beta32</langchain4j.beta.version>
|
||||
<junit.version>6.1.3</junit.version>
|
||||
<assertj.version>3.27.6</assertj.version>
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-bom</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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).
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm.langchain4j</groupId>
|
||||
<artifactId>langchain4j-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>spring-boot-ai-service</artifactId>
|
||||
|
||||
<properties>
|
||||
<spring-boot.version>4.1.1</spring-boot.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ankurm.langchain4j</groupId>
|
||||
<artifactId>ai-services</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-spring-boot-starter</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user