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:
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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?");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 + '}';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user