diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/agent/OpenAiDefaultChatAgentIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/agent/OpenAiDefaultChatAgentIT.java new file mode 100644 index 000000000..dcddf2520 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/agent/OpenAiDefaultChatAgentIT.java @@ -0,0 +1,108 @@ +package org.springframework.ai.openai.chat.agent; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.agent.DefaultChatAgent; +import org.springframework.ai.chat.agent.PromptContext; + +import org.springframework.ai.chat.agent.transformer.QAPromptContextTransformer; + +import org.springframework.ai.chat.agent.transformer.VectorStorePromptContextTransformer; + +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.openai.OpenAiChatClient; +import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.reader.JsonReader; +import org.springframework.ai.transformer.splitter.TokenTextSplitter; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; + +import java.util.List; + +@SpringBootTest(classes = OpenAiDefaultChatAgentIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiDefaultChatAgentIT { + + private final ChatClient chatClient; + + private final VectorStore vectorStore; + + @Value("classpath:/data/acme/bikes.json") + private Resource bikesResource; + + @Autowired + public OpenAiDefaultChatAgentIT(ChatClient chatClient, VectorStore vectorStore) { + this.chatClient = chatClient; + this.vectorStore = vectorStore; + } + + @Test + void simpleChat() { + loadData(); + var chatAgent = DefaultChatAgent.builder(chatClient) + .withRetrievers(List.of(new VectorStorePromptContextTransformer(vectorStore, SearchRequest.defaults()))) + .withAugmentors(List.of(new QAPromptContextTransformer())) + .build(); + + Prompt prompt = new Prompt(new UserMessage("What bike is good for city commuting?")); + PromptContext promptContext = new PromptContext(prompt); + + var agentResponse = chatAgent.call(promptContext); + System.out.println(agentResponse.getChatResponse().getResult().getOutput().getContent()); + + // RelevancyEvaluator relevancyEvaluator = new + // RelevancyEvaluator(this.chatClient); + // EvaluationRequest evaluationRequest = new EvaluationRequest( + // agentResponse.getPromptContext().getOriginalPrompt(), + // agentResponse.getPromptContext().getDataList(), + // agentResponse.getChatResponse()); + // + // EvaluationResponse evaluationResponse = + // relevancyEvaluator.evaluate(evaluationRequest); + // System.out.println(evaluationResponse); + + } + + void loadData() { + JsonReader jsonReader = new JsonReader(bikesResource, "name", "price", "shortDescription", "description"); + var textSplitter = new TokenTextSplitter(); + vectorStore.accept(textSplitter.apply(jsonReader.get())); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public OpenAiApi chatCompletionApi() { + return new OpenAiApi(System.getenv("OPENAI_API_KEY")); + } + + @Bean + public ChatClient openAiClient(OpenAiApi openAiApi) { + return new OpenAiChatClient(openAiApi); + } + + @Bean + public EmbeddingClient embeddingClient(OpenAiApi openAiApi) { + return new OpenAiEmbeddingClient(openAiApi); + } + + @Bean + public VectorStore vectorStore(EmbeddingClient embeddingClient) { + return new SimpleVectorStore(embeddingClient); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/AgentResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/AgentResponse.java new file mode 100644 index 000000000..0098f79b4 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/AgentResponse.java @@ -0,0 +1,52 @@ +package org.springframework.ai.chat.agent; + +import org.springframework.ai.chat.ChatResponse; + +import java.util.Objects; + +/** + * Encapsulates the response from the ChatAgent. Contains the most up-to-date + * PromptContext and the final ChatResponse + * + * @author Mark Pollack + * @since 1.0 M1 + */ +public class AgentResponse { + + private final PromptContext promptContext; + + private final ChatResponse chatResponse; + + public AgentResponse(PromptContext promptContext, ChatResponse chatResponse) { + this.promptContext = promptContext; + this.chatResponse = chatResponse; + } + + public PromptContext getPromptContext() { + return promptContext; + } + + public ChatResponse getChatResponse() { + return chatResponse; + } + + @Override + public String toString() { + return "AgentResponse{" + "promptContext=" + promptContext + ", chatResponse=" + chatResponse + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof AgentResponse that)) + return false; + return Objects.equals(promptContext, that.promptContext) && Objects.equals(chatResponse, that.chatResponse); + } + + @Override + public int hashCode() { + return Objects.hash(promptContext, chatResponse); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgent.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgent.java new file mode 100644 index 000000000..c68166b22 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgent.java @@ -0,0 +1,21 @@ +package org.springframework.ai.chat.agent; + +/** + * A ChatAgent encapsulates common AI workflows such as Retrieval Augmented Generation. + * + * @author Mark Pollack + * @since 1.0 M1 + */ +public interface ChatAgent { + + /** + * Call the chat agent to execute a workflow + * @param promptContext A shared data structure that can be used in components that + * implement the workflow. Contains the initial Prompt and a conversation ID at the + * start of the workflow. + * @return the AgentResponse that contains the ChatResponse and the latest + * PromptContext + */ + AgentResponse call(PromptContext promptContext); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgentListener.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgentListener.java new file mode 100644 index 000000000..53822c59e --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/ChatAgentListener.java @@ -0,0 +1,7 @@ +package org.springframework.ai.chat.agent; + +public interface ChatAgentListener { + + void onComplete(AgentResponse agentResponse); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/DefaultChatAgent.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/DefaultChatAgent.java new file mode 100644 index 000000000..860706711 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/DefaultChatAgent.java @@ -0,0 +1,111 @@ +package org.springframework.ai.chat.agent; + +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.agent.transformer.PromptContextTransformer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class DefaultChatAgent implements ChatAgent { + + private ChatClient chatClient; + + private List retrievers; + + private List documentPostProcessors; + + private List augmentors; + + private List chatAgentListeners; + + public DefaultChatAgent(ChatClient chatClient, List retrievers, + List documentPostProcessors, List augmentors, + List chatAgentListeners) { + Objects.requireNonNull(chatClient, "chatClient must not be null"); + this.chatClient = chatClient; + this.retrievers = retrievers; + this.documentPostProcessors = documentPostProcessors; + this.augmentors = augmentors; + this.chatAgentListeners = chatAgentListeners; + } + + public static DefaultChatAgentBuilder builder(ChatClient chatClient) { + return new DefaultChatAgentBuilder().withChatClient(chatClient); + } + + @Override + public AgentResponse call(PromptContext promptContext) { + + // Perform retrieval of documents and messages + for (PromptContextTransformer retriever : retrievers) { + promptContext = retriever.transform(promptContext); + } + + // Perform post procesing of all retrieved documents and messages + for (PromptContextTransformer documentPostProcessor : documentPostProcessors) { + promptContext = documentPostProcessor.transform(promptContext); + } + + // Perform prompt augmentation + for (PromptContextTransformer augmentor : augmentors) { + promptContext = augmentor.transform(promptContext); + } + + // Perform generation + ChatResponse chatResponse = chatClient.call(promptContext.getPrompt()); + + // Invoke Listeners onComplete + AgentResponse agentResponse = new AgentResponse(promptContext, chatResponse); + for (ChatAgentListener listener : chatAgentListeners) { + listener.onComplete(agentResponse); + } + return agentResponse; + } + + public static class DefaultChatAgentBuilder { + + private ChatClient chatClient; + + private List retrievers = new ArrayList<>(); + + private List documentPostProcessors = new ArrayList<>(); + + private List augmentors = new ArrayList<>(); + + private List chatAgentListeners = new ArrayList<>(); + + public DefaultChatAgentBuilder withChatClient(ChatClient chatClient) { + this.chatClient = chatClient; + return this; + } + + public DefaultChatAgentBuilder withRetrievers(List retrievers) { + this.retrievers = retrievers; + return this; + } + + public DefaultChatAgentBuilder withDocumentPostProcessors( + List documentPostProcessors) { + this.documentPostProcessors = documentPostProcessors; + return this; + } + + public DefaultChatAgentBuilder withAugmentors(List augmentors) { + this.augmentors = augmentors; + return this; + } + + public DefaultChatAgentBuilder withChatAgentListeners(List chatAgentListeners) { + this.chatAgentListeners = chatAgentListeners; + return this; + } + + public DefaultChatAgent build() { + return new DefaultChatAgent(chatClient, retrievers, documentPostProcessors, augmentors, chatAgentListeners); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/PromptContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/PromptContext.java new file mode 100644 index 000000000..0e5e19acc --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/PromptContext.java @@ -0,0 +1,104 @@ +package org.springframework.ai.chat.agent; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.node.Node; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The shared, at the moment, mutable, data structure that can be used to implement + * ChatAgent functionality. + * + * @author Mark Pollack + * @since 1.0 M1 + */ +public class PromptContext { + + private Prompt prompt; // The most up-to-date prompt to use + + List> nodes; // The most up-to-date data to use + + private List promptHistory; + + private String conversationId; + + private Map metadata; + + public PromptContext(Prompt prompt) { + this(prompt, new ArrayList<>()); + } + + public PromptContext(Prompt prompt, String conversationId) { + this(prompt, new ArrayList<>()); + this.conversationId = conversationId; + } + + public PromptContext(Prompt prompt, List> nodes) { + this.prompt = prompt; + this.promptHistory = new ArrayList<>(); + this.promptHistory.add(prompt); + this.nodes = nodes; + } + + public Prompt getPrompt() { + return prompt; + } + + public void setPrompt(Prompt prompt) { + this.prompt = prompt; + } + + public void addData(Node datum) { + this.nodes.add(datum); + } + + public List> getNodes() { + return nodes; + } + + public void setNodes(List> nodes) { + this.nodes = nodes; + } + + public void addPromptHistory(Prompt prompt) { + this.promptHistory.add(prompt); + } + + public List getPromptHistory() { + return promptHistory; + } + + public String getConversationId() { + return conversationId; + } + + public Map getMetadata() { + return metadata; + } + + @Override + public String toString() { + return "PromptContext{" + "prompt=" + prompt + ", nodes=" + nodes + ", promptHistory=" + promptHistory + + ", conversationId='" + conversationId + '\'' + ", metadata=" + metadata + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof PromptContext that)) + return false; + return Objects.equals(prompt, that.prompt) && Objects.equals(nodes, that.nodes) + && Objects.equals(promptHistory, that.promptHistory) + && Objects.equals(conversationId, that.conversationId) && Objects.equals(metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(prompt, nodes, promptHistory, conversationId, metadata); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/PromptContextTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/PromptContextTransformer.java new file mode 100644 index 000000000..2c76f089f --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/PromptContextTransformer.java @@ -0,0 +1,22 @@ +package org.springframework.ai.chat.agent.transformer; + +import org.springframework.ai.chat.agent.PromptContext; + +/** + * Transforms the PromptContext. Implementations may retrieve data and modify the Prompt + * as needed. + * + * @author Mark Pollack + * @since 1.0 M1 + */ +@FunctionalInterface +public interface PromptContextTransformer { + + /** + * Transforms the given PromptContext. + * @param context the PromptContext to transform + * @return the transformed PromptContext + */ + PromptContext transform(PromptContext context); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/QAPromptContextTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/QAPromptContextTransformer.java new file mode 100644 index 000000000..a7edebc46 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/QAPromptContextTransformer.java @@ -0,0 +1,74 @@ +package org.springframework.ai.chat.agent.transformer; + +import org.springframework.ai.chat.agent.PromptContext; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.document.Document; +import org.springframework.ai.node.Node; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Transforms the PromptContext by adding to the prompt a Question and Answer text that + * contains the placeholder names "context" and "question". + */ +public class QAPromptContextTransformer implements PromptContextTransformer { + + private static final String DEFAULT_USER_PROMPT_TEXT = """ + "Context information is below.\\n" + "---------------------\\n" + "{context}\\n" + "---------------------\\n" + "Given the context information and not prior knowledge, " + "answer the question. If the answer is not in the context, inform " + "the user that you can't answer the question.\\n" + "Question: {question}\\n" + "Answer: " + """; + + @Override + public PromptContext transform(PromptContext promptContext) { + String context = doCreateContext(promptContext.getNodes()); + Map contextMap = doCreateContextMap(promptContext.getPrompt(), context); + Prompt prompt = doCreatePrompt(promptContext.getPrompt(), contextMap); + promptContext.setPrompt(prompt); + promptContext.addPromptHistory(prompt); + // For now return the modified instance instead of a copy + return promptContext; + } + + protected String doCreateContext(List> data) { + return data.stream() + .filter(node -> node instanceof Document) + .map(node -> (Document) node) + .map(Node::getContent) + .collect(Collectors.joining(System.lineSeparator())); + } + + private Map doCreateContextMap(Prompt prompt, String context) { + String originalUserMessage = prompt.getInstructions() + .stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(m -> m.getContent()) + .collect(Collectors.joining(System.lineSeparator())); + + return Map.of("context", context, "question", originalUserMessage); + } + + protected Prompt doCreatePrompt(Prompt originalPrompt, Map contextMap) { + PromptTemplate promptTemplate = new PromptTemplate(DEFAULT_USER_PROMPT_TEXT); + Message userMessageToAppend = promptTemplate.createMessage(contextMap); + List messageList = originalPrompt.getInstructions() + .stream() + .filter(m -> m.getMessageType() != MessageType.USER) + .collect(Collectors.toList()); + messageList.add(userMessageToAppend); + return new Prompt(messageList, (ChatOptions) originalPrompt.getOptions()); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/VectorStorePromptContextTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/VectorStorePromptContextTransformer.java new file mode 100644 index 000000000..c8717e3ff --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/agent/transformer/VectorStorePromptContextTransformer.java @@ -0,0 +1,70 @@ +package org.springframework.ai.chat.agent.transformer; + +import org.springframework.ai.chat.agent.PromptContext; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Transforms the PromptContext by retrieving documents from a VectorStore + */ +public class VectorStorePromptContextTransformer implements PromptContextTransformer { + + private final VectorStore vectorStore; + + private final SearchRequest searchRequest; + + public VectorStorePromptContextTransformer(VectorStore vectorStore, SearchRequest searchRequest) { + this.vectorStore = vectorStore; + this.searchRequest = searchRequest; + } + + public VectorStore getVectorStore() { + return vectorStore; + } + + public SearchRequest getSearchRequest() { + return searchRequest; + } + + @Override + public PromptContext transform(PromptContext promptContext) { + List instructions = promptContext.getPrompt().getInstructions(); + String userMessage = instructions.stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(m -> m.getContent()) + .collect(Collectors.joining(System.lineSeparator())); + List documents = vectorStore.similaritySearch(searchRequest.withQuery(userMessage)); + for (Document document : documents) { + promptContext.addData(document); + } + return promptContext; + } + + @Override + public String toString() { + return "VectorStorePromptContextTransformer{" + "vectorStore=" + vectorStore + ", searchRequest=" + + searchRequest + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof VectorStorePromptContextTransformer that)) + return false; + return Objects.equals(vectorStore, that.vectorStore) && Objects.equals(searchRequest, that.searchRequest); + } + + @Override + public int hashCode() { + return Objects.hash(vectorStore, searchRequest); + } + +}