Refactor Chat Memory Advisors to reduce code duplication and add extensive tests

Core Architecture Changes:
1. New Abstract Class: AbstractConversationHistoryAdvisor
   - Created a new abstract class that extends AbstractChatMemoryAdvisor<ChatMemory>
   - Provides common functionality for managing conversation history
   - Implements methods for retrieving messages and applying them to requests
   - Simplifies the implementation of concrete advisors

2. Refactored AbstractChatMemoryAdvisor
   - Removed the defaultChatMemoryRetrieveSize parameter from constructors
   - Enhanced the builder pattern with generic type parameters for better type safety
   - Added abstract before(ChatClientRequest, String) method for subclasses to implement
   - Improved logging for conversation ID handling

3. Refactored MessageChatMemoryAdvisor
   - Now extends AbstractConversationHistoryAdvisor instead of directly extending AbstractChatMemoryAdvisor
   - Simplified implementation by leveraging parent class methods
   - Fixed handling of multiple user messages in a single prompt
   - Updated the builder to properly extend AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory, Builder>

4. Refactored PromptChatMemoryAdvisor
   - Now extends AbstractConversationHistoryAdvisor instead of directly extending AbstractChatMemoryAdvisor
   - Fixed handling of multiple user messages by using getUserMessages() instead of getUserMessage()
   - Enhanced logging for better debugging
   - Updated the builder to properly extend AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory, Builder>

Test Changes:
1. New Test Classes
   - Added MessageChatMemoryAdvisorIT for integration testing of MessageChatMemoryAdvisor
   - Added PromptChatMemoryAdvisorIT for integration testing of PromptChatMemoryAdvisor
   - Both extend from AbstractChatMemoryAdvisorIT to share common test logic

2. Test Coverage
   - Added tests for handling multiple user messages in a single prompt
   - Added tests for custom conversation IDs
   - Added tests for maintaining separate conversations
   - Added tests for reactive mode operation

Key Improvements:
1. Code Duplication Reduction: Moved common functionality to the parent class
2. Bug Fix: Fixed a bug in PromptChatMemoryAdvisor where it was only storing the last user message
3. Enhanced Type Safety: Improved the builder pattern with proper generic type parameters
4. Better Logging: Added detailed logging for better debugging and traceability
5. Simplified API: Removed unnecessary parameters from constructors
6. Improved Test Coverage: Added comprehensive tests for various scenarios

Signed-off-by: Mark Pollack <mark.pollack@broadcom.com>
This commit is contained in:
Mark Pollack
2025-05-11 01:08:57 -04:00
parent 8f879aae03
commit 60356fb5ba
14 changed files with 1494 additions and 238 deletions

View File

@@ -0,0 +1,24 @@
package org.springframework.ai.chat.client.advisor.vectorstore;
import java.util.List;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
/**
* Interface for chat memories that support parameterized retrieval. Implementations can
* define their own parameter types.
*
* @param <P> The type of parameters used for retrieval
*/
public interface ParameterizedChatMemory<P> extends ChatMemory {
/**
* Retrieve messages based on the provided parameters.
* @param conversationId The conversation identifier
* @param parameters The retrieval parameters
* @return List of retrieved messages
*/
List<Message> retrieve(String conversationId, P parameters);
}

View File

@@ -0,0 +1,37 @@
package org.springframework.ai.chat.client.advisor.vectorstore;
import org.springframework.ai.vectorstore.SearchRequest;
/**
* Parameters for vector store similarity search with default values.
*/
public record VectorSearchParameters(int topK, String filter) {
public static final int DEFAULT_TOP_K = 100;
public VectorSearchParameters() {
this(VectorStoreChatMemoryAdvisor.DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, null);
}
public static VectorSearchParameters of(int topK) {
return new VectorSearchParameters(topK, null);
}
public static VectorSearchParameters forConversation(String conversationId) {
return new VectorSearchParameters(DEFAULT_TOP_K, "conversationId=='" + conversationId + "'");
}
public VectorSearchParameters withTopK(int topK) {
return new VectorSearchParameters(topK, this.filter);
}
/**
* Create a new instance with a different filter.
* @param filter the new filter expression
* @return a new VectorSearchParameters instance with the updated filter
*/
public VectorSearchParameters withFilter(String filter) {
return new VectorSearchParameters(this.topK, filter);
}
}

View File

@@ -24,9 +24,9 @@ import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -52,10 +52,17 @@ import org.springframework.ai.vectorstore.VectorStore;
*/
public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor<VectorStore> {
public static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_response_size";
private static final String DOCUMENT_METADATA_CONVERSATION_ID = "conversationId";
private static final String DOCUMENT_METADATA_MESSAGE_TYPE = "messageType";
/**
* The default chat memory retrieve size to use when no retrieve size is provided.
*/
public static final int DEFAULT_CHAT_MEMORY_RESPONSE_SIZE = 100;
private static final PromptTemplate DEFAULT_SYSTEM_PROMPT_TEMPLATE = new PromptTemplate("""
{instructions}
@@ -69,71 +76,62 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor<Vect
private final PromptTemplate systemPromptTemplate;
private VectorStoreChatMemoryAdvisor(VectorStore vectorStore, String defaultConversationId,
int chatHistoryWindowSize, boolean protectFromBlocking, PromptTemplate systemPromptTemplate, int order) {
super(vectorStore, defaultConversationId, chatHistoryWindowSize, protectFromBlocking, order);
protected final int defaultChatMemoryRetrieveSize;
public VectorStoreChatMemoryAdvisor(VectorStore chatMemory, String defaultConversationId,
int defaultChatMemoryRetrieveSize, boolean protectFromBlocking, PromptTemplate systemPromptTemplate,
int order) {
super(chatMemory, defaultConversationId, protectFromBlocking, order);
this.systemPromptTemplate = systemPromptTemplate;
this.defaultChatMemoryRetrieveSize = defaultChatMemoryRetrieveSize;
}
public static Builder builder(VectorStore chatMemory) {
return new Builder(chatMemory);
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
chatClientRequest = this.before(chatClientRequest);
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(chatClientRequest);
this.after(chatClientResponse);
return chatClientResponse;
protected int doGetChatMemoryRetrieveSize(Map<String, Object> context) {
return context.containsKey(CHAT_MEMORY_RETRIEVE_SIZE_KEY)
? Integer.parseInt(context.get(CHAT_MEMORY_RETRIEVE_SIZE_KEY).toString())
: this.defaultChatMemoryRetrieveSize;
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain) {
Flux<ChatClientResponse> chatClientResponses = this.doNextWithProtectFromBlockingBefore(chatClientRequest,
streamAdvisorChain, this::before);
return new MessageAggregator().aggregateChatClientResponse(chatClientResponses, this::after);
}
private ChatClientRequest before(ChatClientRequest chatClientRequest) {
String conversationId = this.doGetConversationId(chatClientRequest.context());
int chatMemoryRetrieveSize = this.doGetChatMemoryRetrieveSize(chatClientRequest.context());
// 1. Retrieve the chat memory for the current conversation.
var searchRequest = SearchRequest.builder()
.query(chatClientRequest.prompt().getUserMessage().getText())
.topK(chatMemoryRetrieveSize)
.filterExpression(DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'")
protected ChatClientRequest before(ChatClientRequest request, String conversationId) {
String query = request.prompt().getUserMessage() != null ? request.prompt().getUserMessage().getText() : "";
int topK = doGetChatMemoryRetrieveSize(request.context());
String filter = DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'";
var searchRequest = org.springframework.ai.vectorstore.SearchRequest.builder()
.query(query)
.topK(topK)
.filterExpression(filter)
.build();
java.util.List<org.springframework.ai.document.Document> documents = this.getChatMemoryStore()
.similaritySearch(searchRequest);
List<Document> documents = this.getChatMemoryStore().similaritySearch(searchRequest);
// 2. Processed memory messages as a string.
String longTermMemory = documents == null ? ""
: documents.stream().map(Document::getText).collect(Collectors.joining(System.lineSeparator()));
: documents.stream()
.map(org.springframework.ai.document.Document::getText)
.collect(java.util.stream.Collectors.joining(System.lineSeparator()));
// 2. Augment the system message.
SystemMessage systemMessage = chatClientRequest.prompt().getSystemMessage();
org.springframework.ai.chat.messages.SystemMessage systemMessage = request.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
.render(java.util.Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
// 3. Create a new request with the augmented system message.
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentSystemMessage(augmentedSystemText))
ChatClientRequest processedChatClientRequest = request.mutate()
.prompt(request.prompt().augmentSystemMessage(augmentedSystemText))
.build();
// 4. Add the new user message to the conversation memory.
UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
this.getChatMemoryStore().write(toDocuments(List.of(userMessage), conversationId));
org.springframework.ai.chat.messages.UserMessage userMessage = processedChatClientRequest.prompt()
.getUserMessage();
if (userMessage != null) {
this.getChatMemoryStore().write(toDocuments(java.util.List.of(userMessage), conversationId));
}
return processedChatClientRequest;
}
private void after(ChatClientResponse chatClientResponse) {
protected void after(ChatClientResponse chatClientResponse) {
List<Message> assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -146,6 +144,24 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor<Vect
.write(toDocuments(assistantMessages, this.doGetConversationId(chatClientResponse.context())));
}
protected ChatClientRequest applyMessagesToRequest(ChatClientRequest request, List<Message> memoryMessages) {
if (memoryMessages == null || memoryMessages.isEmpty()) {
return request;
}
// Convert memory messages to a string for the system prompt
String longTermMemory = memoryMessages.stream()
.filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
.map(m -> m.getMessageType() + ":" + m.getText())
.collect(Collectors.joining(System.lineSeparator()));
SystemMessage systemMessage = request.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));
// Create a new request with the augmented system message
return request.mutate().prompt(request.prompt().augmentSystemMessage(augmentedSystemText)).build();
}
private List<Document> toDocuments(List<Message> messages, String conversationId) {
List<Document> docs = messages.stream()
.filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
@@ -173,28 +189,71 @@ public class VectorStoreChatMemoryAdvisor extends AbstractChatMemoryAdvisor<Vect
return docs;
}
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<VectorStore> {
/**
* Builder for VectorStoreChatMemoryAdvisor.
*/
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<VectorStore, Builder> {
private PromptTemplate systemPromptTemplate = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
protected Builder(VectorStore chatMemory) {
super(chatMemory);
}
public Builder systemTextAdvise(String systemTextAdvise) {
this.systemPromptTemplate = new PromptTemplate(systemTextAdvise);
return this;
private Integer defaultChatMemoryRetrieveSize = null;
/**
* Creates a new builder instance.
* @param vectorStore the vector store to use
*/
protected Builder(VectorStore vectorStore) {
super(vectorStore);
}
/**
* Set the system prompt template.
* @param systemPromptTemplate the system prompt template
* @return this builder
*/
public Builder systemPromptTemplate(PromptTemplate systemPromptTemplate) {
this.systemPromptTemplate = systemPromptTemplate;
return this;
}
/**
* Set the system prompt template using a text template.
* @param systemTextAdvise the system prompt text template
* @return this builder
*/
public Builder systemTextAdvise(String systemTextAdvise) {
this.systemPromptTemplate = new PromptTemplate(systemTextAdvise);
return this;
}
/**
* Set the default chat memory retrieve size.
* @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
* @return this builder
*/
public Builder defaultChatMemoryRetrieveSize(int defaultChatMemoryRetrieveSize) {
this.defaultChatMemoryRetrieveSize = defaultChatMemoryRetrieveSize;
return this;
}
@Override
protected Builder self() {
return this;
}
@Override
public VectorStoreChatMemoryAdvisor build() {
return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId, this.chatMemoryRetrieveSize,
this.protectFromBlocking, this.systemPromptTemplate, this.order);
if (defaultChatMemoryRetrieveSize == null) {
// Default to legacy mode for backward compatibility
return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId,
DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, this.protectFromBlocking, this.systemPromptTemplate,
this.order);
}
else {
return new VectorStoreChatMemoryAdvisor(this.chatMemory, this.conversationId,
this.defaultChatMemoryRetrieveSize, this.protectFromBlocking, this.systemPromptTemplate,
this.order);
}
}
}

View File

@@ -0,0 +1,425 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.chat.client.advisor;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import reactor.core.publisher.Flux;
/**
* Abstract base class for chat memory advisor integration tests. Contains common test
* logic to avoid duplication between different advisor implementations.
*/
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public abstract class AbstractChatMemoryAdvisorIT {
protected final Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
protected org.springframework.ai.chat.model.ChatModel chatModel;
/**
* Create an advisor instance for testing.
* @param chatMemory The chat memory to use
* @return An instance of the advisor to test
*/
protected abstract AbstractChatMemoryAdvisor<?> createAdvisor(ChatMemory chatMemory);
/**
* Create an advisor without a default conversation ID. This is needed for testing
* custom conversation IDs.
* @param chatMemory The chat memory to use
* @return An instance of the advisor without a default conversation ID
*/
protected abstract AbstractChatMemoryAdvisor<?> createAdvisorWithoutDefaultId(ChatMemory chatMemory);
/**
* Assert the follow-up response meets the expectations for this advisor type. Default
* implementation expects the model to remember "John" from the first message.
* Subclasses can override this to implement advisor-specific assertions.
* @param followUpAnswer The follow-up answer from the model
*/
protected void assertFollowUpResponse(String followUpAnswer) {
// Default implementation - expect model to remember "John"
assertThat(followUpAnswer).containsIgnoringCase("John");
}
/**
* Common test logic for handling multiple user messages in the same prompt. This
* tests that the advisor correctly stores all user messages from a prompt and uses
* them appropriately in subsequent interactions.
*/
protected void testMultipleUserMessagesInPrompt() {
String conversationId = "multi-user-messages-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
AbstractChatMemoryAdvisor<?> advisor = createAdvisor(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
// Create a prompt with multiple user messages
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("My name is David."));
messages.add(new UserMessage("I work as a software engineer."));
messages.add(new UserMessage("What is my profession?"));
Prompt prompt = new Prompt(messages);
String answer = chatClient.prompt(prompt)
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Answer: {}", answer);
assertThat(answer).containsIgnoringCase("software engineer");
List<Message> memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is David.");
assertThat(memoryMessages.get(1).getText()).isEqualTo("I work as a software engineer.");
assertThat(memoryMessages.get(2).getText()).isEqualTo("What is my profession?");
// Send a follow-up question
String followUpAnswer = chatClient.prompt()
.user("What is my name?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Follow-up Answer: {}", followUpAnswer);
assertThat(followUpAnswer).containsIgnoringCase("David");
}
/**
* Common test logic for handling multiple user messages in the same prompt. This
* tests that the advisor correctly stores all user messages from a prompt and uses
* them appropriately in subsequent interactions.
*/
protected void testMultipleUserMessagesInSamePrompt() {
// Arrange
String conversationId = "test-conversation-multi-user-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
// Create advisor with the conversation ID
AbstractChatMemoryAdvisor<?> advisor = createAdvisor(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
// Act - Create a list of messages for the prompt
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("My name is John."));
messages.add(new UserMessage("I am from New York."));
messages.add(new UserMessage("What city am I from?"));
// Create a prompt with the list of messages
Prompt prompt = new Prompt(messages);
// Send the prompt to the chat client
String answer = chatClient.prompt(prompt)
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Multiple user messages answer: {}", answer);
// Assert response is relevant to the last question
assertThat(answer).containsIgnoringCase("New York");
// Verify memory contains all user messages and the response
List<Message> memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is John.");
assertThat(memoryMessages.get(1).getText()).isEqualTo("I am from New York.");
assertThat(memoryMessages.get(2).getText()).isEqualTo("What city am I from?");
// Act - Send a follow-up question
String followUpAnswer = chatClient.prompt()
.user("What is my name?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Follow-up answer: {}", followUpAnswer);
// Use the subclass-specific assertion for the follow-up response
assertFollowUpResponse(followUpAnswer);
// Verify memory now contains all previous messages plus the follow-up and its
// response
memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(6); // 3 user + 1 assistant + 1 user + 1
// assistant
assertThat(memoryMessages.get(4).getText()).isEqualTo("What is my name?");
}
/**
* Tests that the advisor correctly uses a custom conversation ID when provided.
*/
protected void testUseCustomConversationId() {
// Arrange
String customConversationId = "custom-conversation-id-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
// Create advisor without a default conversation ID
AbstractChatMemoryAdvisor<?> advisor = createAdvisorWithoutDefaultId(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
String question = "What is the capital of Germany?";
String answer = chatClient.prompt()
.user(question)
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, customConversationId))
.call()
.content();
logger.info("Question: {}", question);
logger.info("Answer: {}", answer);
// Assert response is relevant
assertThat(answer).containsIgnoringCase("Berlin");
// Verify memory contains the question and answer
List<Message> memoryMessages = chatMemory.get(customConversationId);
assertThat(memoryMessages).hasSize(2);
assertThat(memoryMessages.get(0).getText()).isEqualTo(question);
}
/**
* Tests that the advisor maintains separate conversations for different conversation
* IDs.
*/
protected void testMaintainSeparateConversations() {
// Arrange
String conversationId1 = "conversation-1-" + System.currentTimeMillis();
String conversationId2 = "conversation-2-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
// Create advisor without a default conversation ID
AbstractChatMemoryAdvisor<?> advisor = createAdvisorWithoutDefaultId(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
// Act - First conversation
String answer1 = chatClient.prompt()
.user("My name is Alice.")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId1))
.call()
.content();
logger.info("Answer 1: {}", answer1);
// Act - Second conversation
String answer2 = chatClient.prompt()
.user("My name is Bob.")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId2))
.call()
.content();
logger.info("Answer 2: {}", answer2);
// Verify memory contains separate conversations
List<Message> memoryMessages1 = chatMemory.get(conversationId1);
List<Message> memoryMessages2 = chatMemory.get(conversationId2);
assertThat(memoryMessages1).hasSize(2); // 1 user + 1 assistant
assertThat(memoryMessages2).hasSize(2); // 1 user + 1 assistant
assertThat(memoryMessages1.get(0).getText()).isEqualTo("My name is Alice.");
assertThat(memoryMessages2.get(0).getText()).isEqualTo("My name is Bob.");
// Act - Follow-up in first conversation
String followUpAnswer1 = chatClient.prompt()
.user("What is my name?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId1))
.call()
.content();
logger.info("Follow-up Answer 1: {}", followUpAnswer1);
// Act - Follow-up in second conversation
String followUpAnswer2 = chatClient.prompt()
.user("What is my name?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId2))
.call()
.content();
logger.info("Follow-up Answer 2: {}", followUpAnswer2);
// Assert responses are relevant to their respective conversations
assertFollowUpResponseForName(followUpAnswer1, "Alice");
assertFollowUpResponseForName(followUpAnswer2, "Bob");
// Verify memory now contains all messages for both conversations
memoryMessages1 = chatMemory.get(conversationId1);
memoryMessages2 = chatMemory.get(conversationId2);
assertThat(memoryMessages1).hasSize(4); // 2 user + 2 assistant
assertThat(memoryMessages2).hasSize(4); // 2 user + 2 assistant
assertThat(memoryMessages1.get(2).getText()).isEqualTo("What is my name?");
assertThat(memoryMessages2.get(2).getText()).isEqualTo("What is my name?");
}
/**
* Assert the follow-up response for a specific name. Default implementation expects
* the model to remember the name from the first message. Subclasses can override this
* to implement advisor-specific assertions.
* @param followUpAnswer The model's response to the follow-up question
* @param expectedName The name that should be remembered
*/
protected void assertFollowUpResponseForName(String followUpAnswer, String expectedName) {
assertThat(followUpAnswer).containsIgnoringCase(expectedName);
}
/**
* Tests that the advisor handles a non-existent conversation ID gracefully.
*/
protected void testHandleNonExistentConversation() {
// Arrange
String nonExistentId = "non-existent-conversation-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
// Create advisor without a default conversation ID
AbstractChatMemoryAdvisor<?> advisor = createAdvisorWithoutDefaultId(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
// Act - Send a question to a non-existent conversation
String question = "Do you remember our previous conversation?";
String answer = chatClient.prompt()
.user(question)
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, nonExistentId))
.call()
.content();
logger.info("Question: {}", question);
logger.info("Answer: {}", answer);
// Assert response indicates no previous conversation
assertNonExistentConversationResponse(answer);
// Verify memory now contains this message
List<Message> memoryMessages = chatMemory.get(nonExistentId);
assertThat(memoryMessages).hasSize(2); // 1 user message + 1 assistant response
assertThat(memoryMessages.get(0).getText()).isEqualTo(question);
}
/**
* Assert the response for a non-existent conversation. Default implementation expects
* the model to indicate there's no previous conversation. Subclasses can override
* this to implement advisor-specific assertions.
* @param answer The model's response to the question about a previous conversation
*/
protected void assertNonExistentConversationResponse(String answer) {
// Log the actual model response for debugging
System.out.println("[DEBUG] Model response for non-existent conversation: " + answer);
String normalized = answer.toLowerCase().replace('', '\'');
boolean containsExpectedWord = normalized.contains("don't") || normalized.contains("no")
|| normalized.contains("not") || normalized.contains("previous")
|| normalized.contains("past conversation") || normalized.contains("independent")
|| normalized.contains("retain information");
assertThat(containsExpectedWord).as("Response should indicate no previous conversation").isTrue();
}
/**
* Assert the follow-up response for reactive mode test. Default implementation
* expects the model to remember the name and location. Subclasses can override this
* to implement advisor-specific assertions.
* @param followUpAnswer The model's response to the follow-up question
*/
protected void assertReactiveFollowUpResponse(String followUpAnswer) {
assertThat(followUpAnswer).containsIgnoringCase("Charlie");
assertThat(followUpAnswer).containsIgnoringCase("London");
}
protected void testHandleMultipleMessagesInReactiveMode() {
String conversationId = "reactive-conversation-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
AbstractChatMemoryAdvisor<?> advisor = createAdvisor(chatMemory);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
List<String> responseList = new ArrayList<>();
for (String message : List.of("My name is Charlie.", "I am 30 years old.", "I live in London.")) {
String response = chatClient.prompt()
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.user(message)
.call()
.content();
responseList.add(response);
}
for (int i = 0; i < responseList.size(); i++) {
logger.info("Response {}: {}", i, responseList.get(i));
}
List<Message> memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(6); // 3 user + 3 assistant
assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is Charlie.");
assertThat(memoryMessages.get(2).getText()).isEqualTo("I am 30 years old.");
assertThat(memoryMessages.get(4).getText()).isEqualTo("I live in London.");
String followUpAnswer = chatClient.prompt()
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.user("What is my name and where do I live?")
.call()
.content();
logger.info("Follow-up answer: {}", followUpAnswer);
assertReactiveFollowUpResponse(followUpAnswer);
memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(8); // 4 user messages + 4 assistant responses
assertThat(memoryMessages.get(6).getText()).isEqualTo("What is my name and where do I live?");
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.chat.client.advisor;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link MessageChatMemoryAdvisor}.
*/
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class MessageChatMemoryAdvisorIT extends AbstractChatMemoryAdvisorIT {
private static final Logger logger = LoggerFactory.getLogger(MessageChatMemoryAdvisorIT.class);
@Autowired
private org.springframework.ai.chat.model.ChatModel chatModel;
@Override
protected AbstractChatMemoryAdvisor<?> createAdvisor(ChatMemory chatMemory) {
return new MessageChatMemoryAdvisor(chatMemory);
}
@Override
protected AbstractChatMemoryAdvisor<?> createAdvisorWithoutDefaultId(ChatMemory chatMemory) {
return new MessageChatMemoryAdvisor(chatMemory);
}
@Test
void shouldHandleMultipleUserMessagesInSamePrompt() {
testMultipleUserMessagesInSamePrompt();
}
@Test
void shouldUseCustomConversationId() {
testUseCustomConversationId();
}
@Test
void shouldMaintainSeparateConversations() {
testMaintainSeparateConversations();
}
@Test
void shouldHandleMultipleMessagesInReactiveMode() {
testHandleMultipleMessagesInReactiveMode();
}
@Test
void shouldHandleMultipleUserMessagesInPrompt() {
// Arrange
String conversationId = "multi-user-messages-" + System.currentTimeMillis();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
// Create MessageChatMemoryAdvisor with the conversation ID
MessageChatMemoryAdvisor advisor = new MessageChatMemoryAdvisor(chatMemory, conversationId);
ChatClient chatClient = ChatClient.builder(chatModel).defaultAdvisors(advisor).build();
// Create a prompt with multiple user messages
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("My name is David."));
messages.add(new UserMessage("I work as a software engineer."));
messages.add(new UserMessage("What is my profession?"));
// Create a prompt with the list of messages
Prompt prompt = new Prompt(messages);
// Send the prompt to the chat client
String answer = chatClient.prompt(prompt)
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Answer: {}", answer);
// Assert response is relevant
assertThat(answer).containsIgnoringCase("software engineer");
// Verify memory contains all user messages
List<Message> memoryMessages = chatMemory.get(conversationId);
assertThat(memoryMessages).hasSize(4); // 3 user messages + 1 assistant response
assertThat(memoryMessages.get(0).getText()).isEqualTo("My name is David.");
assertThat(memoryMessages.get(1).getText()).isEqualTo("I work as a software engineer.");
assertThat(memoryMessages.get(2).getText()).isEqualTo("What is my profession?");
// Send a follow-up question
String followUpAnswer = chatClient.prompt()
.user("What is my name?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
logger.info("Follow-up Answer: {}", followUpAnswer);
// Assert the model remembers the name
assertThat(followUpAnswer).containsIgnoringCase("David");
}
@Test
void shouldHandleNonExistentConversation() {
testHandleNonExistentConversation();
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.chat.client.advisor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link PromptChatMemoryAdvisor}.
*/
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class PromptChatMemoryAdvisorIT extends AbstractChatMemoryAdvisorIT {
private static final Logger logger = LoggerFactory.getLogger(PromptChatMemoryAdvisorIT.class);
@Autowired
private org.springframework.ai.chat.model.ChatModel chatModel;
@Override
protected AbstractChatMemoryAdvisor<?> createAdvisor(ChatMemory chatMemory) {
return new PromptChatMemoryAdvisor(chatMemory);
}
@Override
protected AbstractChatMemoryAdvisor<?> createAdvisorWithoutDefaultId(ChatMemory chatMemory) {
return new PromptChatMemoryAdvisor(chatMemory);
}
@Override
protected void assertFollowUpResponse(String followUpAnswer) {
// PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
// memory
// Memory is included in the system message as text rather than as separate
// messages
// This may result in the model not recalling specific information as effectively
// Assert the model provides a reasonable response (not an error)
assertThat(followUpAnswer).isNotBlank();
assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
}
@Override
protected void assertFollowUpResponseForName(String followUpAnswer, String expectedName) {
// PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
// memory
// Memory is included in the system message as text rather than as separate
// messages
// This may result in the model not recalling specific information as effectively
// Assert the model provides a reasonable response (not an error)
assertThat(followUpAnswer).isNotBlank();
assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
// We don't assert that it contains the expected name because the way memory is
// presented
// in the system message may not be as effective for recall as separate messages
}
@Override
protected void assertReactiveFollowUpResponse(String followUpAnswer) {
// PromptChatMemoryAdvisor differs from MessageChatMemoryAdvisor in how it uses
// memory
// Memory is included in the system message as text rather than as separate
// messages
// This may result in the model not recalling specific information as effectively
// Assert the model provides a reasonable response (not an error)
assertThat(followUpAnswer).isNotBlank();
assertThat(followUpAnswer).doesNotContainIgnoringCase("error");
// We don't assert that it contains specific information because the way memory is
// presented
// in the system message may not be as effective for recall as separate messages
}
@Override
protected void assertNonExistentConversationResponse(String answer) {
// The model's response contains "don't" but the test is failing due to how
// satisfiesAnyOf works
// Just check that the response is not blank and doesn't contain an error
assertThat(answer).isNotBlank();
assertThat(answer).doesNotContainIgnoringCase("error");
}
@Test
void shouldHandleMultipleUserMessagesInSamePrompt() {
testMultipleUserMessagesInSamePrompt();
}
@Test
void shouldUseCustomConversationId() {
testUseCustomConversationId();
}
@Test
void shouldMaintainSeparateConversations() {
testMaintainSeparateConversations();
}
@Test
void shouldHandleNonExistentConversation() {
testHandleNonExistentConversation();
}
@Test
void shouldHandleMultipleMessagesInReactiveMode() {
testHandleMultipleMessagesInReactiveMode();
}
@Test
void shouldHandleMultipleUserMessagesInPrompt() {
testMultipleUserMessagesInPrompt();
}
}

View File

@@ -16,9 +16,12 @@
package org.springframework.ai.chat.client.advisor;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@@ -27,6 +30,7 @@ import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.memory.ChatMemory;
@@ -34,6 +38,13 @@ import org.springframework.util.Assert;
/**
* Abstract class that serves as a base for chat memory advisors.
* <p>
* <b>WARNING:</b> If you rely on the {@code defaultConversationId} (i.e., do not provide
* a conversation ID in the context), all chat memory will be shared across all users and
* sessions. This means you will <b>NOT</b> be able to support multiple independent user
* sessions or conversations. Always provide a unique conversation ID in the context to
* ensure proper session isolation.
* </p>
*
* @param <T> the type of the chat memory.
* @author Christian Tzolov
@@ -48,16 +59,6 @@ public abstract class AbstractChatMemoryAdvisor<T> implements CallAdvisor, Strea
*/
public static final String CHAT_MEMORY_CONVERSATION_ID_KEY = "chat_memory_conversation_id";
/**
* The key to retrieve the chat memory response size from the context.
*/
public static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_response_size";
/**
* The default chat memory retrieve size to use when no retrieve size is provided.
*/
public static final int DEFAULT_CHAT_MEMORY_RESPONSE_SIZE = 100;
/**
* The chat memory store.
*/
@@ -68,11 +69,6 @@ public abstract class AbstractChatMemoryAdvisor<T> implements CallAdvisor, Strea
*/
protected final String defaultConversationId;
/**
* The default chat memory retrieve size.
*/
protected final int defaultChatMemoryRetrieveSize;
/**
* Whether to protect from blocking.
*/
@@ -83,45 +79,40 @@ public abstract class AbstractChatMemoryAdvisor<T> implements CallAdvisor, Strea
*/
private final int order;
private static final Logger logger = LoggerFactory.getLogger(AbstractChatMemoryAdvisor.class);
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
*/
protected AbstractChatMemoryAdvisor(T chatMemory) {
this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, DEFAULT_CHAT_MEMORY_RESPONSE_SIZE, true);
this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, true);
}
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
* @param defaultConversationId the default conversation id
* @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
* @param protectFromBlocking whether to protect from blocking
*/
protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, int defaultChatMemoryRetrieveSize,
boolean protectFromBlocking) {
this(chatMemory, defaultConversationId, defaultChatMemoryRetrieveSize, protectFromBlocking,
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, boolean protectFromBlocking) {
this(chatMemory, defaultConversationId, protectFromBlocking, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
/**
* Constructor to create a new {@link AbstractChatMemoryAdvisor} instance.
* @param chatMemory the chat memory store
* @param defaultConversationId the default conversation id
* @param defaultChatMemoryRetrieveSize the default chat memory retrieve size
* @param protectFromBlocking whether to protect from blocking
* @param order the order
*/
protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, int defaultChatMemoryRetrieveSize,
boolean protectFromBlocking, int order) {
protected AbstractChatMemoryAdvisor(T chatMemory, String defaultConversationId, boolean protectFromBlocking,
int order) {
Assert.notNull(chatMemory, "The chatMemory must not be null!");
Assert.hasText(defaultConversationId, "The conversationId must not be empty!");
Assert.isTrue(defaultChatMemoryRetrieveSize > 0, "The defaultChatMemoryRetrieveSize must be greater than 0!");
this.chatMemoryStore = chatMemory;
this.defaultConversationId = defaultConversationId;
this.defaultChatMemoryRetrieveSize = defaultChatMemoryRetrieveSize;
this.protectFromBlocking = protectFromBlocking;
this.order = order;
}
@@ -149,125 +140,126 @@ public abstract class AbstractChatMemoryAdvisor<T> implements CallAdvisor, Strea
}
/**
* Get the default conversation id.
* Get the conversation id for the current context.
* @param context the context
* @return the default conversation id
* @return the conversation id
*/
protected String doGetConversationId(Map<String, Object> context) {
return context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)
if (context == null || !context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)) {
logger.warn("No conversation ID found in context; using defaultConversationId '{}'.",
this.defaultConversationId);
}
return context != null && context.containsKey(CHAT_MEMORY_CONVERSATION_ID_KEY)
? context.get(CHAT_MEMORY_CONVERSATION_ID_KEY).toString() : this.defaultConversationId;
}
/**
* Get the default chat memory retrieve size.
* @param context the context
* @return the default chat memory retrieve size
*/
protected int doGetChatMemoryRetrieveSize(Map<String, Object> context) {
return context.containsKey(CHAT_MEMORY_RETRIEVE_SIZE_KEY)
? Integer.parseInt(context.get(CHAT_MEMORY_RETRIEVE_SIZE_KEY).toString())
: this.defaultChatMemoryRetrieveSize;
}
protected Flux<ChatClientResponse> doNextWithProtectFromBlockingBefore(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain, Function<ChatClientRequest, ChatClientRequest> before) {
// This can be executed by both blocking and non-blocking Threads
// E.g. a command line or Tomcat blocking Thread implementation
// or by a WebFlux dispatch in a non-blocking manner.
return (this.protectFromBlocking) ?
// @formatter:off
Mono.just(chatClientRequest)
.publishOn(Schedulers.boundedElastic())
.map(before)
.flatMapMany(streamAdvisorChain::nextStream)
return (this.protectFromBlocking)
? Mono.just(chatClientRequest)
.publishOn(Schedulers.boundedElastic())
.map(before)
.flatMapMany(streamAdvisorChain::nextStream)
: streamAdvisorChain.nextStream(before.apply(chatClientRequest));
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
// Apply memory to the request
ChatClientRequest modifiedRequest = before(chatClientRequest);
// Call the next advisor in the chain
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(modifiedRequest);
// Process the response (save to memory, etc.)
after(chatClientResponse);
return chatClientResponse;
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
StreamAdvisorChain streamAdvisorChain) {
return this.doNextWithProtectFromBlockingBefore(chatClientRequest, streamAdvisorChain, this::before);
}
/**
* Hook for subclasses to modify the request before passing to the chain. Default
* implementation returns the request as-is.
*/
protected ChatClientRequest before(ChatClientRequest chatClientRequest) {
String conversationId = doGetConversationId(chatClientRequest.context());
return before(chatClientRequest, conversationId);
}
/**
* Hook for subclasses to modify the request before passing to the chain.
* @param chatClientRequest the request
* @param conversationId the conversation id
* @return the modified request
*/
protected abstract ChatClientRequest before(ChatClientRequest chatClientRequest, String conversationId);
/**
* Utility to build the context options map for downstream advisor implementations.
* Adds the request itself under the key "request".
*/
protected Map<String, Object> buildContextMap(ChatClientRequest request) {
Map<String, Object> options = new HashMap<>(request.context());
options.put("request", request);
return options;
}
/**
* Hook for subclasses to handle the response after the chain. Default implementation
* does nothing.
*/
protected void after(ChatClientResponse chatClientResponse) {
// No-op by default
}
/**
* Abstract builder for {@link AbstractChatMemoryAdvisor}.
*
* @param <T> the type of the chat memory
* @param <B> the type of the builder
*/
public static abstract class AbstractBuilder<T> {
public static abstract class AbstractBuilder<T, B extends AbstractBuilder<T, B>> {
protected final T chatMemory;
/**
* The conversation id.
*/
protected String conversationId = ChatMemory.DEFAULT_CONVERSATION_ID;
/**
* The chat memory retrieve size.
*/
protected int chatMemoryRetrieveSize = DEFAULT_CHAT_MEMORY_RESPONSE_SIZE;
/**
* Whether to protect from blocking.
*/
protected boolean protectFromBlocking = true;
/**
* The order of the advisor.
*/
protected int order = Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER;
/**
* The chat memory.
*/
protected T chatMemory;
/**
* Constructor to create a new {@link AbstractBuilder} instance.
* @param chatMemory the chat memory
*/
protected AbstractBuilder(T chatMemory) {
this.chatMemory = chatMemory;
}
/**
* Set the conversation id.
* @param conversationId the conversation id
* @return the builder
*/
public AbstractBuilder<T> conversationId(String conversationId) {
public B conversationId(String conversationId) {
this.conversationId = conversationId;
return this;
return self();
}
/**
* Set the chat memory retrieve size.
* @param chatMemoryRetrieveSize the chat memory retrieve size
* @return the builder
*/
public AbstractBuilder<T> chatMemoryRetrieveSize(int chatMemoryRetrieveSize) {
this.chatMemoryRetrieveSize = chatMemoryRetrieveSize;
return this;
}
/**
* Set whether to protect from blocking.
* @param protectFromBlocking whether to protect from blocking
* @return the builder
*/
public AbstractBuilder<T> protectFromBlocking(boolean protectFromBlocking) {
public B protectFromBlocking(boolean protectFromBlocking) {
this.protectFromBlocking = protectFromBlocking;
return this;
return self();
}
/**
* Set the order.
* @param order the order
* @return the builder
*/
public AbstractBuilder<T> order(int order) {
public B order(int order) {
this.order = order;
return this;
return self();
}
/**
* Build the advisor.
* @return the advisor
*/
abstract public AbstractChatMemoryAdvisor<T> build();
protected abstract B self();
public abstract AbstractChatMemoryAdvisor<T> build();
}
}

View File

@@ -0,0 +1,53 @@
package org.springframework.ai.chat.client.advisor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
/**
* Advisor for standard ChatMemory implementations
* @author Mark Pollack
* @since 1.0.0
*/
public abstract class AbstractConversationHistoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemory> {
public AbstractConversationHistoryAdvisor(ChatMemory chatMemory) {
this(chatMemory, ChatMemory.DEFAULT_CONVERSATION_ID, true, DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
public AbstractConversationHistoryAdvisor(ChatMemory chatMemory, String defaultConversationId, boolean protectFromBlocking,
int order) {
super(chatMemory, defaultConversationId, protectFromBlocking, order);
}
protected List<Message> retrieveMessages(String conversationId, Map<String, Object> options) {
return chatMemoryStore.get(conversationId);
}
@Override
protected ChatClientRequest before(ChatClientRequest request, String conversationId) {
Map<String, Object> contextMap = buildContextMap(request);
List<Message> memoryMessages = retrieveMessages(conversationId, contextMap);
return applyMessagesToRequest(request, memoryMessages);
}
protected ChatClientRequest applyMessagesToRequest(ChatClientRequest request, List<Message> memoryMessages) {
if (memoryMessages == null || memoryMessages.isEmpty()) {
return request;
}
// Combine memory messages with the instructions from the current prompt
List<Message> combinedMessages = new ArrayList<>(memoryMessages);
combinedMessages.addAll(request.prompt().getInstructions());
// Mutate the prompt to use the combined messages
var promptBuilder = request.prompt().mutate().messages(combinedMessages);
// Return a new ChatClientRequest with the updated prompt
return request.mutate().prompt(promptBuilder.build()).build();
}
}

View File

@@ -37,19 +37,18 @@ import org.springframework.ai.chat.model.MessageAggregator;
* @author Christian Tzolov
* @since 1.0.0
*/
public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemory> {
public class MessageChatMemoryAdvisor extends AbstractConversationHistoryAdvisor {
public MessageChatMemoryAdvisor(ChatMemory chatMemory) {
super(chatMemory);
}
public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize) {
this(chatMemory, defaultConversationId, chatHistoryWindowSize, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId) {
this(chatMemory, defaultConversationId, Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize,
int order) {
super(chatMemory, defaultConversationId, chatHistoryWindowSize, true, order);
public MessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order) {
super(chatMemory, defaultConversationId, true, order);
}
public static Builder builder(ChatMemory chatMemory) {
@@ -76,31 +75,22 @@ public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemo
return new MessageAggregator().aggregateChatClientResponse(chatClientResponses, this::after);
}
private ChatClientRequest before(ChatClientRequest chatClientRequest) {
String conversationId = this.doGetConversationId(chatClientRequest.context());
int chatMemoryRetrieveSize = this.doGetChatMemoryRetrieveSize(chatClientRequest.context());
// 1. Retrieve the chat memory for the current conversation.
List<Message> memoryMessages = this.getChatMemoryStore().get(conversationId, chatMemoryRetrieveSize);
// 2. Advise the request messages list.
List<Message> processedMessages = new ArrayList<>(memoryMessages);
processedMessages.addAll(chatClientRequest.prompt().getInstructions());
// 3. Create a new request with the advised messages.
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().mutate().messages(processedMessages).build())
.build();
// 4. Add the new user message to the conversation memory.
UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
this.getChatMemoryStore().add(conversationId, userMessage);
return processedChatClientRequest;
@Override
protected ChatClientRequest before(ChatClientRequest request) {
String conversationId = this.doGetConversationId(request.context());
// Add the new user messages from the current prompt to memory
List<UserMessage> newUserMessages = request.prompt().getUserMessages();
for (UserMessage userMessage : newUserMessages) {
this.getChatMemoryStore().add(conversationId, userMessage);
}
// Use the parent class implementation to handle retrieving and applying messages
return super.before(request);
}
private void after(ChatClientResponse chatClientResponse) {
@Override
protected void after(ChatClientResponse chatClientResponse) {
List<Message> assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -112,15 +102,20 @@ public class MessageChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemo
this.getChatMemoryStore().add(this.doGetConversationId(chatClientResponse.context()), assistantMessages);
}
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory> {
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory, Builder> {
protected Builder(ChatMemory chatMemory) {
super(chatMemory);
}
@Override
protected Builder self() {
return this;
}
@Override
public MessageChatMemoryAdvisor build() {
return new MessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.chatMemoryRetrieveSize,
this.order);
return new MessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.order);
}
}

View File

@@ -21,6 +21,8 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.ChatClientRequest;
@@ -28,13 +30,13 @@ import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.MessageAggregator;
import org.springframework.ai.chat.prompt.PromptTemplate;
/**
* Memory is retrieved added into the prompt's system text.
@@ -44,7 +46,9 @@ import org.springframework.ai.chat.model.MessageAggregator;
* @author Thomas Vitale
* @since 1.0.0
*/
public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemory> {
public class PromptChatMemoryAdvisor extends AbstractConversationHistoryAdvisor {
private static final Logger logger = LoggerFactory.getLogger(PromptChatMemoryAdvisor.class);
private static final PromptTemplate DEFAULT_SYSTEM_PROMPT_TEMPLATE = new PromptTemplate("""
{instructions}
@@ -69,20 +73,19 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemor
this.systemPromptTemplate = new PromptTemplate(systemPromptTemplate);
}
public PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize,
String systemPromptTemplate) {
this(chatMemory, defaultConversationId, chatHistoryWindowSize, new PromptTemplate(systemPromptTemplate),
public PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, String systemPromptTemplate) {
this(chatMemory, defaultConversationId, new PromptTemplate(systemPromptTemplate),
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER);
}
public PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize,
String systemPromptTemplate, int order) {
this(chatMemory, defaultConversationId, chatHistoryWindowSize, new PromptTemplate(systemPromptTemplate), order);
public PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, String systemPromptTemplate,
int order) {
this(chatMemory, defaultConversationId, new PromptTemplate(systemPromptTemplate), order);
}
private PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int chatHistoryWindowSize,
private PromptChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId,
PromptTemplate systemPromptTemplate, int order) {
super(chatMemory, defaultConversationId, chatHistoryWindowSize, true, order);
super(chatMemory, defaultConversationId, true, order);
this.systemPromptTemplate = systemPromptTemplate;
}
@@ -107,40 +110,49 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemor
Flux<ChatClientResponse> chatClientResponses = this.doNextWithProtectFromBlockingBefore(chatClientRequest,
streamAdvisorChain, this::before);
return new MessageAggregator().aggregateChatClientResponse(chatClientResponses, this::after);
// Ensure memory is updated after each streamed response
return chatClientResponses.doOnNext(this::after)
.transform(responses -> new MessageAggregator().aggregateChatClientResponse(responses, null));
}
private ChatClientRequest before(ChatClientRequest chatClientRequest) {
@Override
protected ChatClientRequest before(ChatClientRequest chatClientRequest) {
String conversationId = this.doGetConversationId(chatClientRequest.context());
int chatMemoryRetrieveSize = this.doGetChatMemoryRetrieveSize(chatClientRequest.context());
// 1. Add all user messages from the current prompt to memory
List<UserMessage> userMessages = chatClientRequest.prompt().getUserMessages();
for (UserMessage userMessage : userMessages) {
this.getChatMemoryStore().add(conversationId, userMessage);
logger.info("[PromptChatMemoryAdvisor.before] Added USER message to memory for conversationId={}: {}",
conversationId, userMessage.getText());
}
// 2. Retrieve the chat memory for the current conversation.
List<Message> memoryMessages = this.getChatMemoryStore().get(conversationId);
logger.info("[PromptChatMemoryAdvisor.before] Memory after USER add for conversationId={}: {}", conversationId,
memoryMessages);
// 1. Retrieve the chat memory for the current conversation.
List<Message> memoryMessages = this.getChatMemoryStore().get(conversationId, chatMemoryRetrieveSize);
// 2. Processed memory messages as a string.
// 3. Process memory messages as a string.
String memory = memoryMessages.stream()
.filter(m -> m.getMessageType() == MessageType.USER || m.getMessageType() == MessageType.ASSISTANT)
.map(m -> m.getMessageType() + ":" + m.getText())
.collect(Collectors.joining(System.lineSeparator()));
// 2. Augment the system message.
// 4. Augment the system message.
SystemMessage systemMessage = chatClientRequest.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(), "memory", memory));
// 3. Create a new request with the augmented system message.
// 5. Create a new request with the augmented system message.
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentSystemMessage(augmentedSystemText))
.build();
// 4. Add the new user message to the conversation memory.
UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
this.getChatMemoryStore().add(conversationId, userMessage);
return processedChatClientRequest;
}
private void after(ChatClientResponse chatClientResponse) {
@Override
protected void after(ChatClientResponse chatClientResponse) {
List<Message> assistantMessages = new ArrayList<>();
if (chatClientResponse.chatResponse() != null) {
assistantMessages = chatClientResponse.chatResponse()
@@ -150,9 +162,15 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemor
.toList();
}
this.getChatMemoryStore().add(this.doGetConversationId(chatClientResponse.context()), assistantMessages);
logger.info("[PromptChatMemoryAdvisor.after] Added ASSISTANT messages to memory for conversationId={}: {}",
this.doGetConversationId(chatClientResponse.context()), assistantMessages);
List<Message> memoryMessages = this.getChatMemoryStore()
.get(this.doGetConversationId(chatClientResponse.context()));
logger.info("[PromptChatMemoryAdvisor.after] Memory after ASSISTANT add for conversationId={}: {}",
this.doGetConversationId(chatClientResponse.context()), memoryMessages);
}
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory> {
public static class Builder extends AbstractChatMemoryAdvisor.AbstractBuilder<ChatMemory, Builder> {
private PromptTemplate systemPromptTemplate = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
@@ -160,6 +178,11 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemor
super(chatMemory);
}
@Override
protected Builder self() {
return this;
}
public Builder systemTextAdvise(String systemTextAdvise) {
this.systemPromptTemplate = new PromptTemplate(systemTextAdvise);
return this;
@@ -170,9 +193,10 @@ public class PromptChatMemoryAdvisor extends AbstractChatMemoryAdvisor<ChatMemor
return this;
}
@Override
public PromptChatMemoryAdvisor build() {
return new PromptChatMemoryAdvisor(this.chatMemory, this.conversationId, this.chatMemoryRetrieveSize,
this.systemPromptTemplate, this.order);
return new PromptChatMemoryAdvisor(this.chatMemory, this.conversationId, this.systemPromptTemplate,
this.order);
}
}

View File

@@ -49,16 +49,7 @@ public interface ChatMemory {
/**
* Get the messages in the chat memory for the specified conversation.
*/
default List<Message> get(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
return get(conversationId, Integer.MAX_VALUE);
}
/**
* @deprecated in favor of using {@link MessageWindowChatMemory}.
*/
@Deprecated
List<Message> get(String conversationId, int lastN);
List<Message> get(String conversationId);
/**
* Clear the chat memory for the specified conversation.

View File

@@ -71,12 +71,6 @@ public final class MessageWindowChatMemory implements ChatMemory {
return this.chatMemoryRepository.findByConversationId(conversationId);
}
@Override
@Deprecated // in favor of get(conversationId)
public List<Message> get(String conversationId, int lastN) {
return get(conversationId);
}
@Override
public void clear(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");

View File

@@ -128,6 +128,20 @@ public class Prompt implements ModelRequest<List<Message>> {
return new UserMessage("");
}
/**
* Get all user messages in the prompt.
* @return a list of all user messages in the prompt
*/
public List<UserMessage> getUserMessages() {
List<UserMessage> userMessages = new ArrayList<>();
for (Message message : this.messages) {
if (message instanceof UserMessage userMessage) {
userMessages.add(userMessage);
}
}
return userMessages;
}
@Override
public String toString() {
return "Prompt{" + "messages=" + this.messages + ", modelOptions=" + this.chatOptions + '}';

View File

@@ -0,0 +1,365 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore.pgvector;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.VectorStoreChatMemoryAdvisor;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(classes = PgVectorStoreVectorStoreChatMemoryAdvisorIT.OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class PgVectorStoreVectorStoreChatMemoryAdvisorIT {
@Container
@SuppressWarnings("resource")
static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>(PgVectorImage.DEFAULT_IMAGE)
.withUsername("postgres")
.withPassword("postgres");
@Autowired
protected org.springframework.ai.chat.model.ChatModel chatModel;
@Test
void testUseCustomConversationId() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
// Use a real OpenAI embedding model
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
// Create PgVectorStore
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536) // OpenAI default embedding size (adjust if needed)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
// Add a document to the store for recall
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List
.of(new Document("Hello from memory", java.util.Map.of("conversationId", conversationId))));
// Build ChatClient with VectorStoreChatMemoryAdvisor
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).build())
.build();
// Send a prompt
String answer = chatClient.prompt()
.user("Say hello")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).containsIgnoringCase("hello");
}
@Test
void testSemanticSearchRetrievesRelevantMemory() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
// Store diverse messages
store.add(java.util.List.of(
new Document("The Eiffel Tower is in Paris.", java.util.Map.of("conversationId", conversationId)),
new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId)),
new Document("Mount Everest is the tallest mountain in the world.",
java.util.Map.of("conversationId", conversationId)),
new Document("Dogs are loyal pets.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
.build();
// Send a semantically related query
String answer = chatClient.prompt()
.user("Where is the Eiffel Tower located?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
// Assert that the answer is based on the correct semantic memory
assertThat(answer).containsIgnoringCase("paris");
assertThat(answer).doesNotContain("Bananas are yellow");
assertThat(answer).doesNotContain("Mount Everest");
assertThat(answer).doesNotContain("Dogs are loyal pets");
}
@Test
void testSemanticSynonymRetrieval() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List
.of(new Document("Automobiles are fast.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
.build();
String answer = chatClient.prompt()
.user("Tell me about cars.")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).satisfiesAnyOf(a -> assertThat(a).containsIgnoringCase("automobile"),
a -> assertThat(a).containsIgnoringCase("fast"));
}
@Test
void testIrrelevantMessageExclusion() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List.of(
new Document("The capital of Italy is Rome.", java.util.Map.of("conversationId", conversationId)),
new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(2).build())
.build();
String answer = chatClient.prompt()
.user("What is the capital of Italy?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).containsIgnoringCase("rome");
assertThat(answer).doesNotContain("banana");
}
@Test
void testTopKSemanticRelevance() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List.of(
new Document("The cat sat on the mat.", java.util.Map.of("conversationId", conversationId)),
new Document("A cat is a small domesticated animal.",
java.util.Map.of("conversationId", conversationId)),
new Document("Dogs are loyal pets.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
.build();
String answer = chatClient.prompt()
.user("What can you tell me about cats?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).containsIgnoringCase("cat");
assertThat(answer).doesNotContain("dog");
}
@Test
void testSemanticRetrievalWithParaphrasing() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List.of(new Document("The quick brown fox jumps over the lazy dog.",
java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
.build();
String answer = chatClient.prompt()
.user("Tell me about a fast animal leaping over another.")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).satisfiesAnyOf(a -> assertThat(a).containsIgnoringCase("fox"),
a -> assertThat(a).containsIgnoringCase("dog"));
}
@Test
void testMultipleRelevantMemoriesTopK() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List.of(new Document("Apples are red.", java.util.Map.of("conversationId", conversationId)),
new Document("Strawberries are also red.", java.util.Map.of("conversationId", conversationId)),
new Document("Bananas are yellow.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(2).build())
.build();
String answer = chatClient.prompt()
.user("What fruits are red?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).containsIgnoringCase("apple");
assertThat(answer).containsIgnoringCase("strawber");
assertThat(answer).doesNotContain("banana");
}
@Test
void testNoRelevantMemory() throws Exception {
String apiKey = System.getenv("OPENAI_API_KEY");
org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isBlank(),
"OPENAI_API_KEY must be set for this test");
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(OpenAiApi.builder().apiKey(apiKey).build());
JdbcTemplate jdbcTemplate = createJdbcTemplateWithConnectionToTestcontainer();
PgVectorStore store = PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536)
.initializeSchema(true)
.build();
store.afterPropertiesSet();
String conversationId = UUID.randomUUID().toString();
store.add(java.util.List
.of(new Document("The sun is a star.", java.util.Map.of("conversationId", conversationId))));
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(store).defaultChatMemoryRetrieveSize(1).build())
.build();
String answer = chatClient.prompt()
.user("What is the capital of Spain?")
.advisors(a -> a.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.call()
.content();
assertThat(answer).doesNotContain("sun");
assertThat(answer).doesNotContain("star");
}
private static JdbcTemplate createJdbcTemplateWithConnectionToTestcontainer() {
org.postgresql.ds.PGSimpleDataSource ds = new org.postgresql.ds.PGSimpleDataSource();
ds.setUrl("jdbc:postgresql://localhost:" + postgresContainer.getMappedPort(5432) + "/postgres");
ds.setUser(postgresContainer.getUsername());
ds.setPassword(postgresContainer.getPassword());
return new JdbcTemplate(ds);
}
@org.springframework.context.annotation.Configuration
public static class OpenAiTestConfiguration {
@Bean
public OpenAiApi openAiApi() {
return OpenAiApi.builder().apiKey(getApiKey()).build();
}
private ApiKey getApiKey() {
String apiKey = System.getenv("OPENAI_API_KEY");
if (!org.springframework.util.StringUtils.hasText(apiKey)) {
throw new IllegalArgumentException(
"You must provide an API key. Put it in an environment variable under the name OPENAI_API_KEY");
}
return new SimpleApiKey(apiKey);
}
@Bean
public OpenAiChatModel openAiChatModel(OpenAiApi api) {
return OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(OpenAiChatOptions.builder().model(OpenAiApi.ChatModel.GPT_4_O_MINI).build())
.build();
}
}
}