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:
2026-10-11 07:48:34 +00:00
co-authored by Claude Sonnet 5.5
commit f87573fcb8
22 changed files with 670 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+34
View File
@@ -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
+11
View File
@@ -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?
+10
View File
@@ -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.
+5
View File
@@ -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.
+2
View File
@@ -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'.
+8
View File
@@ -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
+43
View File
@@ -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);
}
}
}
+66
View File
@@ -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).
+52
View File
@@ -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);
}
}