diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java index 89d790a9f..6f2880880 100644 --- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java +++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -24,6 +24,7 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClientCustomizer; import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter; import org.springframework.ai.chat.model.ChatModel; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -79,14 +80,28 @@ public class ChatClientAutoConfiguration { return chatClientBuilderConfigurer.configure(builder); } + /** + * @deprecated in favour of {@link #chatClientPromptContentObservationFilter()}. + */ @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input", havingValue = "true") + @Deprecated ChatClientInputContentObservationFilter chatClientInputContentObservationFilter() { logger.warn( "You have enabled the inclusion of the input content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); return new ChatClientInputContentObservationFilter(); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", + name = "include-prompt", havingValue = "true") + ChatClientPromptContentObservationFilter chatClientPromptContentObservationFilter() { + logger.warn( + "You have enabled the inclusion of the ChatClient prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ChatClientPromptContentObservationFilter(); + } + } diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java index 0ca9023d7..84c2869ae 100644 --- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java +++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -17,6 +17,7 @@ package org.springframework.ai.model.chat.client.autoconfigure; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; /** * Configuration properties for the chat client builder. @@ -25,6 +26,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @author Mark Pollack * @author Josh Long * @author Arjen Poutsma + * @author Thomas Vitale * @since 1.0.0 */ @ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX) @@ -37,7 +39,7 @@ public class ChatClientBuilderProperties { */ private boolean enabled = true; - private Observations observations = new Observations(); + private final Observations observations = new Observations(); public Observations getObservations() { return this.observations; @@ -55,9 +57,17 @@ public class ChatClientBuilderProperties { /** * Whether to include the input content in the observations. + * @deprecated Use {@link #includePrompt} instead. */ + @Deprecated private boolean includeInput = false; + /** + * Whether to include the prompt content in the observations. + */ + private boolean includePrompt = false; + + @DeprecatedConfigurationProperty(replacement = "spring.ai.chat.observations.include-prompt") public boolean isIncludeInput() { return this.includeInput; } @@ -66,6 +76,14 @@ public class ChatClientBuilderProperties { this.includeInput = includeCompletion; } + public boolean isIncludePrompt() { + return this.includePrompt; + } + + public void setIncludePrompt(boolean includePrompt) { + this.includePrompt = includePrompt; + } + } } diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java index 7fd0ee9f8..9e646cdac 100644 --- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java +++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -19,6 +19,7 @@ package org.springframework.ai.model.chat.client.autoconfigure; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; +import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -28,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Unit tests for {@link ChatClientAutoConfiguration} observability support. * * @author Christian Tzolov + * @author Thomas Vitale */ class ChatClientObservationAutoConfigurationTests { @@ -46,4 +48,16 @@ class ChatClientObservationAutoConfigurationTests { .run(context -> assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class)); } + @Test + void promptContentFilterDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationFilter.class)); + } + + @Test + void promptContentFilterEnabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationFilter.class)); + } + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java index e87078ce4..bd8ee2c51 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java @@ -21,7 +21,10 @@ package org.springframework.ai.chat.client; * * @author Thomas Vitale * @since 1.0.0 + * @deprecated only introduced to smooth the transition to the new APIs and ensure + * backward compatibility */ +@Deprecated public enum ChatClientAttributes { //@formatter:off diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java index c7d60b00b..958cde32c 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java @@ -39,8 +39,12 @@ public record ChatClientRequest(Prompt prompt, Map context) { Assert.noNullElements(context.keySet(), "context keys cannot be null"); } + public ChatClientRequest copy() { + return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context)); + } + public Builder mutate() { - return new Builder().prompt(this.prompt).context(this.context); + return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context)); } public static Builder builder() { diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientResponse.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientResponse.java index eae44a517..9a0db3ae4 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientResponse.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientResponse.java @@ -38,6 +38,14 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map(this.context)); + } + + public Builder mutate() { + return new Builder().chatResponse(this.chatResponse).context(new HashMap<>(this.context)); + } + public static Builder builder() { return new Builder(); } @@ -51,7 +59,7 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map originalMessages = chatClientRequest.prompt().getInstructions(); - - if (CollectionUtils.isEmpty(originalMessages)) { - return chatClientRequest; - } - - // Create a copy of the message list to avoid modifying the original. - List modifiedMessages = new ArrayList<>(originalMessages); - - // Get the last message (without removing it from original list) - Message lastMessage = modifiedMessages.get(modifiedMessages.size() - 1); - - // If the last message is a UserMessage, replace it with the modified version - if (lastMessage instanceof UserMessage userMessage) { - // Remove last message - modifiedMessages.remove(modifiedMessages.size() - 1); - - // Create new user message with format instructions - UserMessage userMessageWithFormat = userMessage.mutate() + Prompt augmentedPrompt = chatClientRequest.prompt() + .augmentUserMessage(userMessage -> userMessage.mutate() .text(userMessage.getText() + System.lineSeparator() + outputFormat) - .build(); - - // Add modified message back - modifiedMessages.add(userMessageWithFormat); - - // Build new ChatClientRequest preserving all properties but with modified - // prompt - return ChatClientRequest.builder() - .prompt(chatClientRequest.prompt().mutate().messages(modifiedMessages).build()) - .context(Map.copyOf(chatClientRequest.context())) - .build(); - } - - return chatClientRequest; + .build()); + return ChatClientRequest.builder() + .prompt(augmentedPrompt) + .context(Map.copyOf(chatClientRequest.context())) + .build(); } @Nullable @@ -588,6 +563,7 @@ public class DefaultChatClient implements ChatClient { ChatClientObservationContext observationContext = ChatClientObservationContext.builder() .request(chatClientRequest) + .advisors(advisorChain.getStreamAdvisors()) .stream(true) .build(); @@ -660,8 +636,6 @@ public class DefaultChatClient implements ChatClient { private final Map advisorParams = new HashMap<>(); - private final DefaultAroundAdvisorChain.Builder aroundAdvisorChainBuilder; - private final Map toolContext = new HashMap<>(); @Nullable @@ -718,14 +692,6 @@ public class DefaultChatClient implements ChatClient { this.observationConvention = observationConvention != null ? observationConvention : DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION; this.toolContext.putAll(toolContext); - - // At the stack bottom add the model call advisors. - // They play the role of the last advisors in the advisor chain. - this.advisors.add(new ChatModelCallAdvisor(chatModel)); - this.advisors.add(new ChatModelStreamAdvisor(chatModel)); - - this.aroundAdvisorChainBuilder = DefaultAroundAdvisorChain.builder(observationRegistry) - .pushAll(this.advisors); } private ObservationRegistry getObservationRegistry() { @@ -822,7 +788,6 @@ public class DefaultChatClient implements ChatClient { consumer.accept(advisorSpec); this.advisorParams.putAll(advisorSpec.getParams()); this.advisors.addAll(advisorSpec.getAdvisors()); - this.aroundAdvisorChainBuilder.pushAll(advisorSpec.getAdvisors()); return this; } @@ -830,7 +795,6 @@ public class DefaultChatClient implements ChatClient { Assert.notNull(advisors, "advisors cannot be null"); Assert.noNullElements(advisors, "advisors cannot contain null elements"); this.advisors.addAll(Arrays.asList(advisors)); - this.aroundAdvisorChainBuilder.pushAll(Arrays.asList(advisors)); return this; } @@ -838,7 +802,6 @@ public class DefaultChatClient implements ChatClient { Assert.notNull(advisors, "advisors cannot be null"); Assert.noNullElements(advisors, "advisors cannot contain null elements"); this.advisors.addAll(advisors); - this.aroundAdvisorChainBuilder.pushAll(advisors); return this; } @@ -983,17 +946,26 @@ public class DefaultChatClient implements ChatClient { } public CallResponseSpec call() { - BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build(); + BaseAdvisorChain advisorChain = buildAdvisorChain(); return new DefaultCallResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain, observationRegistry, observationConvention); } public StreamResponseSpec stream() { - BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build(); + BaseAdvisorChain advisorChain = buildAdvisorChain(); return new DefaultStreamResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain, observationRegistry, observationConvention); } + private BaseAdvisorChain buildAdvisorChain() { + // At the stack bottom add the model call advisors. + // They play the role of the last advisors in the advisor chain. + this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build()); + this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build()); + + return DefaultAroundAdvisorChain.builder(this.observationRegistry).pushAll(this.advisors).build(); + } + } // Prompt diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java new file mode 100644 index 000000000..2059ceda5 --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/AdvisorUtils.java @@ -0,0 +1,49 @@ +/* + * 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.chat.client.advisor; + +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.util.StringUtils; + +import java.util.function.Predicate; + +/** + * Utilities to work with advisors. + */ +public final class AdvisorUtils { + + private AdvisorUtils() { + } + + /** + * Checks whether the provided {@link ChatClientResponse} contains a + * {@link ChatResponse} with at least one result having a non-empty finish reason in + * its metadata. + */ + public static Predicate onFinishReason() { + return chatClientResponse -> { + ChatResponse chatResponse = chatClientResponse.chatResponse(); + return chatResponse != null && chatResponse.getResults() != null + && chatResponse.getResults() + .stream() + .anyMatch(result -> result != null && result.getMetadata() != null + && StringUtils.hasText(result.getMetadata().getFinishReason())); + }; + } + +} diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java index 7d3311289..68ccd8cb8 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java @@ -37,7 +37,8 @@ public final class ChatModelCallAdvisor implements CallAdvisor { private final ChatModel chatModel; - public ChatModelCallAdvisor(ChatModel chatModel) { + private ChatModelCallAdvisor(ChatModel chatModel) { + Assert.notNull(chatModel, "chatModel cannot be null"); this.chatModel = chatModel; } @@ -62,4 +63,26 @@ public final class ChatModelCallAdvisor implements CallAdvisor { return Ordered.LOWEST_PRECEDENCE; } + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private ChatModel chatModel; + + private Builder() { + } + + public Builder chatModel(ChatModel chatModel) { + this.chatModel = chatModel; + return this; + } + + public ChatModelCallAdvisor build() { + return new ChatModelCallAdvisor(this.chatModel); + } + + } + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisor.java index 5743b69c6..f9b04c23f 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisor.java @@ -37,7 +37,8 @@ public final class ChatModelStreamAdvisor implements StreamAdvisor { private final ChatModel chatModel; - public ChatModelStreamAdvisor(ChatModel chatModel) { + private ChatModelStreamAdvisor(ChatModel chatModel) { + Assert.notNull(chatModel, "chatModel cannot be null"); this.chatModel = chatModel; } @@ -63,4 +64,26 @@ public final class ChatModelStreamAdvisor implements StreamAdvisor { return Ordered.LOWEST_PRECEDENCE; } + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private ChatModel chatModel; + + private Builder() { + } + + public Builder chatModel(ChatModel chatModel) { + this.chatModel = chatModel; + return this; + } + + public ChatModelStreamAdvisor build() { + return new ChatModelStreamAdvisor(this.chatModel); + } + + } + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java index 6ee2aedb3..e742f2d72 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java @@ -57,6 +57,10 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention(); + private final List originalCallAdvisors; + + private final List originalStreamAdvisors; + private final Deque callAroundAdvisors; private final Deque streamAroundAdvisors; @@ -73,6 +77,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { this.observationRegistry = observationRegistry; this.callAroundAdvisors = callAroundAdvisors; this.streamAroundAdvisors = streamAroundAdvisors; + this.originalCallAdvisors = List.copyOf(callAroundAdvisors); + this.originalStreamAdvisors = List.copyOf(streamAroundAdvisors); } public static Builder builder(ObservationRegistry observationRegistry) { @@ -232,6 +238,21 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { }); } + @Override + public List getCallAdvisors() { + return this.originalCallAdvisors; + } + + @Override + public List getStreamAdvisors() { + return this.originalStreamAdvisors; + } + + @Override + public ObservationRegistry getObservationRegistry() { + return this.observationRegistry; + } + public static class Builder { private final ObservationRegistry observationRegistry; @@ -246,13 +267,14 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { this.streamAroundAdvisors = new ConcurrentLinkedDeque<>(); } - public Builder push(Advisor aroundAdvisor) { - Assert.notNull(aroundAdvisor, "the aroundAdvisor must be non-null"); - return this.pushAll(List.of(aroundAdvisor)); + public Builder push(Advisor advisor) { + Assert.notNull(advisor, "the advisor must be non-null"); + return this.pushAll(List.of(advisor)); } public Builder pushAll(List advisors) { Assert.notNull(advisors, "the advisors must be non-null"); + Assert.noNullElements(advisors, "the advisors must not contain null elements"); if (!CollectionUtils.isEmpty(advisors)) { List callAroundAdvisorList = advisors.stream() .filter(a -> a instanceof CallAroundAdvisor) diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedResponseStreamUtils.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedResponseStreamUtils.java index a88837561..169c8c4e5 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedResponseStreamUtils.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedResponseStreamUtils.java @@ -18,12 +18,16 @@ package org.springframework.ai.chat.client.advisor.api; import java.util.function.Predicate; +import org.springframework.ai.chat.client.advisor.AdvisorUtils; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.util.StringUtils; /** * A stream utility class to provide support methods handling {@link AdvisedResponse}. + * + * @deprecated in favour of {@link AdvisorUtils}. */ +@Deprecated public final class AdvisedResponseStreamUtils { private AdvisedResponseStreamUtils() { diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisorChain.java new file mode 100644 index 000000000..373549f9f --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisorChain.java @@ -0,0 +1,34 @@ +/* + * 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.chat.client.advisor.api; + +import io.micrometer.observation.ObservationRegistry; + +/** + * Defines the context for executing a chain of advisors as part of processing a chat + * request. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public interface AdvisorChain { + + default ObservationRegistry getObservationRegistry() { + return ObservationRegistry.NOOP; + } + +} diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java index 477787870..5ce25bb4a 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisor.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -21,25 +21,47 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.AdvisorUtils; import org.springframework.util.Assert; /** - * Base advisor that implements common aspects of the {@link CallAroundAdvisor} and - * {@link StreamAroundAdvisor}, reducing the boilerplate code needed to implement an - * advisor. It provides default implementations for the - * {@link #aroundCall(AdvisedRequest, CallAroundAdvisorChain)} and - * {@link #aroundStream(AdvisedRequest, StreamAroundAdvisorChain)} methods, delegating the - * actual logic to the {@link #before(AdvisedRequest)} and {@link #after(AdvisedResponse)} - * methods. + * Base advisor that implements common aspects of the {@link CallAdvisor} and + * {@link StreamAdvisor}, reducing the boilerplate code needed to implement an advisor. + *

+ * It provides default implementations for the + * {@link #adviseCall(ChatClientRequest, CallAroundAdvisorChain)} and + * {@link #adviseStream(ChatClientRequest, StreamAroundAdvisorChain)} methods, delegating + * the actual logic to the {@link #before(ChatClientRequest, AdvisorChain advisorChain)} + * and {@link #after(ChatClientResponse, AdvisorChain advisorChain)} methods. * * @author Thomas Vitale * @since 1.0.0 */ -public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor { +public interface BaseAdvisor extends CallAdvisor, StreamAdvisor { Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic(); @Override + default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAroundAdvisorChain chain) { + Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); + Assert.notNull(chain, "chain cannot be null"); + + ChatClientRequest processedChatClientRequest = before(chatClientRequest, chain); + ChatClientResponse chatClientResponse; + if (chain instanceof CallAdvisorChain callAdvisorChain) { + chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest); + } + else { + chatClientResponse = chain.nextAroundCall(AdvisedRequest.from(processedChatClientRequest)) + .toChatClientResponse(); + } + return after(chatClientResponse, chain); + } + + @Override + @Deprecated default AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { Assert.notNull(advisedRequest, "advisedRequest cannot be null"); Assert.notNull(chain, "chain cannot be null"); @@ -50,6 +72,36 @@ public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor { } @Override + default Flux adviseStream(ChatClientRequest chatClientRequest, StreamAroundAdvisorChain chain) { + Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); + Assert.notNull(chain, "chain cannot be null"); + Assert.notNull(getScheduler(), "scheduler cannot be null"); + + Flux chatClientResponseFlux; + if (chain instanceof StreamAdvisorChain streamAdvisorChain) { + chatClientResponseFlux = Mono.just(chatClientRequest) + .publishOn(getScheduler()) + .map(request -> this.before(request, streamAdvisorChain)) + .flatMapMany(streamAdvisorChain::nextStream); + } + else { + chatClientResponseFlux = Mono.just(AdvisedRequest.from(chatClientRequest)) + .publishOn(getScheduler()) + .map(this::before) + .flatMapMany(chain::nextAroundStream) + .map(AdvisedResponse::toChatClientResponse); + } + + return chatClientResponseFlux.map(response -> { + if (AdvisorUtils.onFinishReason().test(response)) { + response = after(response, chain); + } + return response; + }).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error))); + } + + @Override + @Deprecated default Flux aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { Assert.notNull(advisedRequest, "advisedRequest cannot be null"); Assert.notNull(chain, "chain cannot be null"); @@ -76,11 +128,31 @@ public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor { /** * Logic to be executed before the rest of the advisor chain is called. */ - AdvisedRequest before(AdvisedRequest request); + default ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { + Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); + return before(AdvisedRequest.from(chatClientRequest)).toChatClientRequest(); + } /** * Logic to be executed after the rest of the advisor chain is called. */ + default ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) { + Assert.notNull(chatClientResponse, "chatClientResponse cannot be null"); + return after(AdvisedResponse.from(chatClientResponse)).toChatClientResponse(); + } + + /** + * Logic to be executed before the rest of the advisor chain is called. + * @deprecated in favor of {@link #before(ChatClientRequest,AdvisorChain)} + */ + @Deprecated + AdvisedRequest before(AdvisedRequest request); + + /** + * Logic to be executed after the rest of the advisor chain is called. + * @deprecated in favor of {@link #after(ChatClientResponse,AdvisorChain)} + */ + @Deprecated AdvisedResponse after(AdvisedResponse advisedResponse); /** diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java index c9de67b99..e6d17d2fd 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAdvisorChain.java @@ -19,6 +19,8 @@ package org.springframework.ai.chat.client.advisor.api; import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; +import java.util.List; + /** * A chain of {@link CallAdvisor} instances orchestrating the execution of a * {@link ChatClientRequest} on the next {@link CallAdvisor} in the chain. @@ -39,4 +41,6 @@ public interface CallAdvisorChain extends CallAroundAdvisorChain { ChatClientResponse nextCall(ChatClientRequest chatClientRequest); + List getCallAdvisors(); + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAroundAdvisorChain.java index 8f4a62825..e768f45e1 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/CallAroundAdvisorChain.java @@ -28,7 +28,7 @@ import org.springframework.ai.chat.client.ChatClientRequest; * @deprecated in favor of {@link CallAdvisorChain} */ @Deprecated -public interface CallAroundAdvisorChain { +public interface CallAroundAdvisorChain extends AdvisorChain { /** * Invokes the next Around Advisor in the CallAroundAdvisorChain with the given diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAdvisorChain.java index 7ee12bbbd..66e60f4c7 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAdvisorChain.java @@ -20,6 +20,8 @@ import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; import reactor.core.publisher.Flux; +import java.util.List; + /** * A chain of {@link StreamAdvisor} instances orchestrating the execution of a * {@link ChatClientRequest} on the next {@link StreamAdvisor} in the chain. @@ -40,4 +42,6 @@ public interface StreamAdvisorChain extends StreamAroundAdvisorChain { Flux nextStream(ChatClientRequest chatClientRequest); + List getStreamAdvisors(); + } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAroundAdvisorChain.java index 7ab963178..abb4a62e6 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/StreamAroundAdvisorChain.java @@ -29,7 +29,7 @@ import reactor.core.publisher.Flux; * @deprecated in favor of {@link StreamAdvisorChain} */ @Deprecated -public interface StreamAroundAdvisorChain { +public interface StreamAroundAdvisorChain extends AdvisorChain { /** * This method delegates the call to the next StreamAroundAdvisor in the chain and is diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java index 3249304b4..f332db04c 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java @@ -89,7 +89,9 @@ public enum AdvisorObservationDocumentation implements ObservationDocumentation /** * Advisor type: Before, After or Around. + * @deprecated advisors don't have types anymore, they're all "around" */ + @Deprecated ADVISOR_TYPE { @Override public String asString() { diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java index cfbb15eff..4f1e51362 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java @@ -81,6 +81,9 @@ public class DefaultAdvisorObservationConvention implements AdvisorObservationCo return KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER, AiProvider.SPRING_AI.value()); } + /** + * @deprecated advisors don't have types anymore, they're all "around" + */ @Deprecated protected KeyValue advisorType(AdvisorObservationContext context) { return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name()); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java index f8a231b56..fdd504260 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java @@ -35,7 +35,9 @@ import java.util.Map; * * @author Christian Tzolov * @since 1.0.0 + * @deprecated in favor of {@link ChatClientPromptContentObservationFilter}. */ +@Deprecated public class ChatClientInputContentObservationFilter implements ObservationFilter { @Override @@ -43,8 +45,6 @@ public class ChatClientInputContentObservationFilter implements ObservationFilte if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) { return context; } - // TODO: we really want these? Should probably align with same format as chat - // model observation chatClientSystemText(chatClientObservationContext); chatClientSystemParams(chatClientObservationContext); chatClientUserText(chatClientObservationContext); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java index f6400e708..41d8afa71 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java @@ -20,6 +20,7 @@ import io.micrometer.observation.Observation; import org.springframework.ai.chat.client.ChatClientAttributes; import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.observation.AiOperationMetadata; import org.springframework.ai.observation.conventions.AiOperationType; import org.springframework.ai.observation.conventions.AiProvider; @@ -27,6 +28,8 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import java.util.List; + /** * Context used to store metadata for chat client workflows. * @@ -41,11 +44,17 @@ public class ChatClientObservationContext extends Observation.Context { private final AiOperationMetadata operationMetadata = new AiOperationMetadata(AiOperationType.FRAMEWORK.value(), AiProvider.SPRING_AI.value()); + private final List advisors; + private final boolean stream; - ChatClientObservationContext(ChatClientRequest chatClientRequest, boolean isStream) { + ChatClientObservationContext(ChatClientRequest chatClientRequest, List advisors, + boolean isStream) { Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); + Assert.notNull(advisors, "advisors cannot be null"); + Assert.noNullElements(advisors, "advisors cannot contain null elements"); this.request = chatClientRequest; + this.advisors = advisors; this.stream = isStream; } @@ -61,6 +70,10 @@ public class ChatClientObservationContext extends Observation.Context { return this.operationMetadata; } + public List getAdvisors() { + return this.advisors; + } + public boolean isStream() { return this.stream; } @@ -91,6 +104,8 @@ public class ChatClientObservationContext extends Observation.Context { private ChatClientRequest chatClientRequest; + private List advisors = List.of(); + private String format; private boolean isStream = false; @@ -118,6 +133,11 @@ public class ChatClientObservationContext extends Observation.Context { return this; } + public Builder advisors(List advisors) { + this.advisors = advisors; + return this; + } + public Builder stream(boolean isStream) { this.isStream = isStream; return this; @@ -132,7 +152,7 @@ public class ChatClientObservationContext extends Observation.Context { if (StringUtils.hasText(format)) { this.chatClientRequest.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), format); } - return new ChatClientObservationContext(this.chatClientRequest, this.isStream); + return new ChatClientObservationContext(this.chatClientRequest, this.advisors, this.isStream); } } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java index c39f3ffd4..7eb536edd 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java @@ -20,6 +20,7 @@ import io.micrometer.common.docs.KeyName; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationConvention; import io.micrometer.observation.docs.ObservationDocumentation; +import org.springframework.ai.observation.conventions.AiObservationAttributes; /** * Documented conventions for chat client observations. @@ -76,25 +77,6 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati public enum HighCardinalityKeyNames implements KeyName { - /** - * Enabled tool function names. - */ - CHAT_CLIENT_TOOL_FUNCTION_NAMES { - @Override - public String asString() { - return "spring.ai.chat.client.tool.function.names"; - } - }, - /** - * List of configured chat client function callbacks. - */ - CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS { - @Override - public String asString() { - return "spring.ai.chat.client.tool.function.callbacks"; - } - }, - /** * List of configured chat client advisors. */ @@ -104,51 +86,128 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati return "spring.ai.chat.client.advisors"; } }, + + /** + * The identifier of the conversation. + */ + CHAT_CLIENT_CONVERSATION_ID { + @Override + public String asString() { + return "spring.ai.chat.client.conversation.id"; + } + }, + + // Request + + /** + * Names of the tools made available to the chat client. + */ + CHAT_CLIENT_TOOL_NAMES { + @Override + public String asString() { + return "spring.ai.chat.client.tool.names"; + } + }, + + /** + * Enabled tool function names. + * @deprecated replaced by {@link #CHAT_CLIENT_TOOL_NAMES} + */ + @Deprecated + CHAT_CLIENT_TOOL_FUNCTION_NAMES { + @Override + public String asString() { + return "spring.ai.chat.client.tool.function.names"; + } + }, + + /** + * List of configured chat client function callbacks. + * @deprecated replaced by {@link #CHAT_CLIENT_TOOL_NAMES} + */ + @Deprecated + CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS { + @Override + public String asString() { + return "spring.ai.chat.client.tool.function.callbacks"; + } + }, + /** * Map of advisor parameters. + * @deprecated risk to expose sensitive information or break the instrumentation + * since the advisor context map is used to pass arbitrary Java objects between + * advisors and not necessarily serializable. The conversation ID, previously part + * of this, is already included in the {@link #CHAT_CLIENT_CONVERSATION_ID} + * method. */ + @Deprecated CHAT_CLIENT_ADVISOR_PARAMS { @Override public String asString() { return "spring.ai.chat.client.advisor.params"; } }, + /** * Chat client user text. + * @deprecated replaced by {@link #PROMPT} */ + @Deprecated CHAT_CLIENT_USER_TEXT { @Override public String asString() { return "spring.ai.chat.client.user.text"; } }, + /** * Chat client user parameters. + * @deprecated replaced by {@link #PROMPT} */ + @Deprecated CHAT_CLIENT_USER_PARAMS { @Override public String asString() { return "spring.ai.chat.client.user.params"; } }, + /** * Chat client system text. + * @deprecated replaced by {@link #PROMPT} */ + @Deprecated CHAT_CLIENT_SYSTEM_TEXT { @Override public String asString() { return "spring.ai.chat.client.system.text"; } }, + /** * Chat client system parameters. + * @deprecated replaced by {@link #PROMPT} */ + @Deprecated CHAT_CLIENT_SYSTEM_PARAM { @Override public String asString() { return "spring.ai.chat.client.system.params"; } - } + }, + + // Content + + /** + * The full prompt requested to be sent to the model. + */ + PROMPT { + @Override + public String asString() { + return AiObservationAttributes.PROMPT.value(); + } + }, } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java new file mode 100644 index 000000000..eb683886b --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java @@ -0,0 +1,65 @@ +/* + * 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.chat.client.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.observation.tracing.TracingHelper; +import org.springframework.util.CollectionUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * An {@link ObservationFilter} to include the chat client prompt content in the + * observation. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public final class ChatClientPromptContentObservationFilter implements ObservationFilter { + + @Override + public Observation.Context map(Observation.Context context) { + if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) { + return context; + } + + var prompts = processPrompt(chatClientObservationContext); + + chatClientObservationContext + .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT + .withValue(TracingHelper.concatenateMaps(prompts))); + + return chatClientObservationContext; + } + + private Map processPrompt(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().prompt().getInstructions())) { + return Map.of(); + } + + var messages = new HashMap(); + context.getRequest() + .prompt() + .getInstructions() + .forEach(message -> messages.put(message.getMessageType().getValue(), message.getText())); + return messages; + } + +} diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java index e05fb2f17..0bfdfc5ae 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java @@ -17,13 +17,14 @@ package org.springframework.ai.chat.client.observation; import java.util.Arrays; +import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import io.micrometer.common.KeyValue; import io.micrometer.common.KeyValues; import org.springframework.ai.chat.client.ChatClientAttributes; +import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames; import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; @@ -32,6 +33,7 @@ import org.springframework.ai.observation.conventions.SpringAiKind; import org.springframework.ai.observation.tracing.TracingHelper; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; /** * Default conventions to populate observations for chat client workflows. @@ -92,26 +94,74 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva @Override public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) { var keyValues = KeyValues.empty(); - keyValues = chatClientAdvisorNames(keyValues, context); - // TODO: rename attribute? any sensitive data here? + keyValues = advisors(keyValues, context); + keyValues = conversationId(keyValues, context); + keyValues = tools(keyValues, context); + // @deprecated remove before 1.0.0-RC1. keyValues = chatClientAdvisorParams(keyValues, context); - // TODO: remove this? Already included in chat model observation + // @deprecated remove before 1.0.0-RC1. keyValues = toolNames(keyValues, context); - // TODO: remove this? Already included in chat model observation + // @deprecated remove before 1.0.0-RC1. keyValues = toolCallbacks(keyValues, context); return keyValues; } - @SuppressWarnings("unchecked") - protected KeyValues chatClientAdvisorNames(KeyValues keyValues, ChatClientObservationContext context) { - if (!(context.getRequest().context().get(ChatClientAttributes.ADVISORS.getKey()) instanceof List advisors)) { + protected KeyValues advisors(KeyValues keyValues, ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getAdvisors())) { return keyValues; } - var advisorNames = ((List) advisors).stream().map(Advisor::getName).toList(); + var advisorNames = context.getAdvisors().stream().map(Advisor::getName).toList(); return keyValues.and(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), TracingHelper.concatenateStrings(advisorNames)); } + protected KeyValues conversationId(KeyValues keyValues, ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().context())) { + return keyValues; + } + + var conversationIdValue = context.getRequest() + .context() + .get(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY); + if (!(conversationIdValue instanceof String conversationId) || StringUtils.isEmpty(conversationId)) { + return keyValues; + } + + return keyValues.and( + ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_CONVERSATION_ID.asString(), + conversationId); + } + + protected KeyValues tools(KeyValues keyValues, ChatClientObservationContext context) { + if (context.getRequest().prompt().getOptions() == null) { + return keyValues; + } + if (!(context.getRequest().prompt().getOptions() instanceof ToolCallingChatOptions options)) { + return keyValues; + } + + var toolNames = new ArrayList<>(options.getToolNames()); + var toolCallbacks = options.getToolCallbacks(); + + if (CollectionUtils.isEmpty(toolNames) && CollectionUtils.isEmpty(toolCallbacks)) { + return keyValues; + } + + toolCallbacks.forEach(toolCallback -> toolNames.add(toolCallback.getToolDefinition().name())); + + return keyValues.and( + ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_NAMES.asString(), + TracingHelper.concatenateStrings(toolNames.stream().sorted().toList())); + } + + /** + * @deprecated risk to expose sensitive information or break the instrumentation since + * the advisor context map is used to pass arbitrary Java objects between advisors and + * not necessarily serializable. The conversation ID, previously part of this, is + * already included in the + * {@link #conversationId(KeyValues, ChatClientObservationContext)} method. + */ + @Deprecated protected KeyValues chatClientAdvisorParams(KeyValues keyValues, ChatClientObservationContext context) { if (CollectionUtils.isEmpty(context.getRequest().context())) { return keyValues; @@ -123,6 +173,10 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva TracingHelper.concatenateMaps(chatClientContext)); } + /** + * @deprecated in favor of {@link #tools(KeyValues, ChatClientObservationContext)} + */ + @Deprecated protected KeyValues toolNames(KeyValues keyValues, ChatClientObservationContext context) { if (context.getRequest().prompt().getOptions() == null) { return keyValues; @@ -141,6 +195,10 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva TracingHelper.concatenateStrings(toolNames.stream().sorted().toList())); } + /** + * @deprecated in favor of {@link #tools(KeyValues, ChatClientObservationContext)} + */ + @Deprecated protected KeyValues toolCallbacks(KeyValues keyValues, ChatClientObservationContext context) { if (context.getRequest().prompt().getOptions() == null) { return keyValues; diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientRequestTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientRequestTests.java index 071e07721..708f86a51 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientRequestTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientRequestTests.java @@ -22,6 +22,7 @@ import org.springframework.ai.chat.prompt.Prompt; import java.util.HashMap; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -60,4 +61,28 @@ class ChatClientRequestTests { .hasMessage("context keys cannot be null"); } + @Test + void whenCopyThenImmutableContext() { + Map context = new HashMap<>(); + context.put("key", "value"); + ChatClientRequest request = ChatClientRequest.builder().prompt(new Prompt()).context(context).build(); + + ChatClientRequest copy = request.copy(); + + copy.context().put("key", "newValue"); + assertThat(request.context()).isEqualTo(Map.of("key", "value")); + } + + @Test + void whenMutateThenImmutableContext() { + Map context = new HashMap<>(); + context.put("key", "value"); + ChatClientRequest request = ChatClientRequest.builder().prompt(new Prompt()).context(context).build(); + + ChatClientRequest copy = request.mutate().context("key", "newValue").build(); + + assertThat(request.context()).isEqualTo(Map.of("key", "value")); + assertThat(copy.context()).isEqualTo(Map.of("key", "newValue")); + } + } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientResponseTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientResponseTests.java index aa9076cd9..d4a3c96ba 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientResponseTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientResponseTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -48,4 +49,37 @@ class ChatClientResponseTests { .hasMessage("context keys cannot be null"); } + @Test + void whenCopyThenImmutableContext() { + Map context = new HashMap<>(); + context.put("key", "value"); + ChatClientResponse response = ChatClientResponse.builder().chatResponse(null).context(context).build(); + + ChatClientResponse copy = response.copy(); + + copy.context().put("key2", "value2"); + assertThat(response.context()).doesNotContainKey("key2"); + assertThat(copy.context()).containsKey("key2"); + + copy.context().put("key", "newValue"); + assertThat(copy.context()).containsEntry("key", "newValue"); + assertThat(response.context()).containsEntry("key", "value"); + } + + @Test + void whenMutateThenImmutableContext() { + Map context = new HashMap<>(); + context.put("key", "value"); + ChatClientResponse response = ChatClientResponse.builder().chatResponse(null).context(context).build(); + + ChatClientResponse copy = response.mutate().context(Map.of("key2", "value2")).build(); + + assertThat(response.context()).doesNotContainKey("key2"); + assertThat(copy.context()).containsKey("key2"); + + copy.context().put("key", "newValue"); + assertThat(copy.context()).containsEntry("key", "newValue"); + assertThat(response.context()).containsEntry("key", "value"); + } + } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisorTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisorTests.java new file mode 100644 index 000000000..d0c6024d9 --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisorTests.java @@ -0,0 +1,37 @@ +/* + * 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.chat.client.advisor; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ChatModelCallAdvisor}. + * + * @author Thomas Vitale + */ +class ChatModelCallAdvisorTests { + + @Test + void whenChatModelIsNullThenThrow() { + assertThatThrownBy(() -> ChatModelCallAdvisor.builder().chatModel(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("chatModel cannot be null"); + } + +} diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisorTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisorTests.java new file mode 100644 index 000000000..f613e2a3b --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ChatModelStreamAdvisorTests.java @@ -0,0 +1,37 @@ +/* + * 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.chat.client.advisor; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ChatModelStreamAdvisor}. + * + * @author Thomas Vitale + */ +class ChatModelStreamAdvisorTests { + + @Test + void whenChatModelIsNullThenThrow() { + assertThatThrownBy(() -> ChatModelStreamAdvisor.builder().chatModel(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("chatModel cannot be null"); + } + +} diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java new file mode 100644 index 000000000..4b9e14e67 --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java @@ -0,0 +1,122 @@ +/* + * 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.chat.client.advisor; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.*; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DefaultAroundAdvisorChain}. + * + * @author Thomas Vitale + */ +class DefaultAroundAdvisorChainTests { + + @Test + void whenObservationRegistryIsNullThenThrow() { + assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("the observationRegistry must be non-null"); + } + + @Test + void whenAdvisorIsNullThenThrow() { + assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).push(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("the advisor must be non-null"); + } + + @Test + void whenAdvisorListIsNullThenThrow() { + assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("the advisors must be non-null"); + } + + @Test + void whenAdvisorListContainsNullElementsThenThrow() { + List advisors = new ArrayList<>(); + advisors.add(null); + assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(advisors).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("the advisors must not contain null elements"); + } + + @Test + void getObservationRegistry() { + ObservationRegistry observationRegistry = ObservationRegistry.create(); + AdvisorChain chain = DefaultAroundAdvisorChain.builder(observationRegistry).build(); + assertThat(chain.getObservationRegistry()).isEqualTo(observationRegistry); + } + + @Test + void getCallAdvisors() { + CallAdvisor mockAdvisor1 = mock(CallAdvisor.class); + when(mockAdvisor1.getName()).thenReturn("advisor1"); + when(mockAdvisor1.adviseCall(any(), any())).thenReturn(ChatClientResponse.builder().build()); + CallAdvisor mockAdvisor2 = mock(CallAdvisor.class); + when(mockAdvisor2.getName()).thenReturn("advisor2"); + when(mockAdvisor2.adviseCall(any(), any())).thenReturn(ChatClientResponse.builder().build()); + + List advisors = List.of(mockAdvisor1, mockAdvisor2); + CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(advisors).build(); + assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0])); + + chain.nextCall(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()); + assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0])); + + chain.nextCall(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()); + assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0])); + } + + @Test + void getStreamAdvisors() { + StreamAdvisor mockAdvisor1 = mock(StreamAdvisor.class); + when(mockAdvisor1.getName()).thenReturn("advisor1"); + when(mockAdvisor1.adviseStream(any(), any())).thenReturn(Flux.just(ChatClientResponse.builder().build())); + StreamAdvisor mockAdvisor2 = mock(StreamAdvisor.class); + when(mockAdvisor2.getName()).thenReturn("advisor2"); + when(mockAdvisor2.adviseStream(any(), any())).thenReturn(Flux.just(ChatClientResponse.builder().build())); + + List advisors = List.of(mockAdvisor1, mockAdvisor2); + StreamAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP) + .pushAll(advisors) + .build(); + assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0])); + + chain.nextStream(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()).blockLast(); + assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0])); + + chain.nextStream(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()).blockLast(); + assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0])); + } + +} diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java index 810c1abd8..66efa14c4 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java @@ -22,10 +22,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; +import java.util.ArrayList; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link ChatClientObservationContext}. @@ -43,10 +49,29 @@ class ChatClientObservationContextTests { void whenMandatoryRequestOptionsThenReturn() { var observationContext = ChatClientObservationContext.builder() .request(ChatClientRequest.builder().prompt(new Prompt()).build()) - .stream(true) .build(); assertThat(observationContext).isNotNull(); } + @Test + void whenNullAdvisorsThenReturn() { + assertThatThrownBy(() -> ChatClientObservationContext.builder() + .request(ChatClientRequest.builder().prompt(new Prompt()).build()) + .advisors(null) + .build()).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("advisors cannot be null"); + } + + @Test + void whenAdvisorsWithNullElementsThenReturn() { + List advisors = new ArrayList<>(); + advisors.add(mock(Advisor.class)); + advisors.add(null); + assertThatThrownBy(() -> ChatClientObservationContext.builder() + .request(ChatClientRequest.builder().prompt(new Prompt()).build()) + .advisors(advisors) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("advisors cannot contain null elements"); + } + } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java new file mode 100644 index 000000000..fac92843f --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java @@ -0,0 +1,88 @@ +/* + * 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.chat.client.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ChatClientPromptContentObservationFilter}. + * + * @author Thomas Vitale + */ +class ChatClientPromptContentObservationFilterTests { + + private final ChatClientPromptContentObservationFilter observationFilter = new ChatClientPromptContentObservationFilter(); + + @Test + void whenNotSupportedObservationContextThenReturnOriginalContext() { + var expectedContext = new Observation.Context(); + var actualContext = this.observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenEmptyPromptThenReturnOriginalContext() { + var expectedContext = ChatClientObservationContext.builder() + .request(ChatClientRequest.builder().prompt(new Prompt(List.of())).build()) + .build(); + var actualContext = this.observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenPromptWithTextThenAugmentContext() { + var originalContext = ChatClientObservationContext.builder() + .request(ChatClientRequest.builder().prompt(new Prompt("supercalifragilisticexpialidocious")).build()) + .build(); + + var augmentedContext = this.observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """ + ["user":"supercalifragilisticexpialidocious"]""")); + } + + @Test + void whenPromptWithMessagesThenAugmentContext() { + var originalContext = ChatClientObservationContext.builder() + .request(ChatClientRequest.builder() + .prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"), + new UserMessage("supercalifragilisticexpialidocious")))) + .build()) + .build(); + + var augmentedContext = this.observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """ + ["system":"you're a chimney sweep", "user":"supercalifragilisticexpialidocious"]""")); + } + +} diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java index 3e8da9122..a9d52e82b 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java @@ -29,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.client.ChatClientAttributes; import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor; @@ -150,25 +151,28 @@ class DefaultChatClientObservationConventionTests { .toolNames("tool1", "tool2") .toolCallbacks(dummyFunction("toolCallback1"), dummyFunction("toolCallback2")) .build())) - .context("advParam1", "advisorParam1Value") - .context(ChatClientAttributes.ADVISORS.getKey(), - List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2"))) + .context(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, "007") .build(); ChatClientObservationContext observationContext = ChatClientObservationContext.builder() .request(request) .withFormat("json") + .advisors(List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2"))) .stream(true) .build(); assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( - KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), "[\"advisor1\", \"advisor2\"]"), - KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(), - "[\"advParam1\":\"advisorParam1Value\"]"), - KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(), - "[\"tool1\", \"tool2\"]"), - KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(), - "[\"toolCallback1\", \"toolCallback2\"]")); + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), """ + ["advisor1", "advisor2"]"""), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_CONVERSATION_ID.asString(), "007"), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_NAMES.asString(), """ + ["tool1", "tool2", "toolCallback1", "toolCallback2"]"""), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(), """ + ["chat_memory_conversation_id":"007"]"""), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(), """ + ["tool1", "tool2"]"""), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(), """ + ["toolCallback1", "toolCallback2"]""")); } @Test diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index bbe03c1a5..5a30c7ed5 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -171,6 +171,7 @@ After specifying the `call()` method on `ChatClient`, there are a few different * `String content()`: returns the String content of the response * `ChatResponse chatResponse()`: returns the `ChatResponse` object that contains multiple generations and also metadata about the response, for example how many token were used to create the response. +* `ChatClientResponse chatClientResponse()`: returns a `ChatClientResponse` object that contains the `ChatResponse` object and the ChatClient execution context, giving you access to additional data used during the execution of advisors (e.g. the relevant documents retrieved in a RAG flow). * `entity()` to return a Java type ** `entity(ParameterizedTypeReference type)`: used to return a `Collection` of entity types. ** `entity(Class type)`: used to return a specific entity type. @@ -184,6 +185,7 @@ After specifying the `stream()` method on `ChatClient`, there are a few options * `Flux content()`: Returns a `Flux` of the string being generated by the AI model. * `Flux chatResponse()`: Returns a `Flux` of the `ChatResponse` object, which contains additional metadata about the response. +* `Flux chatClientResponse()`: returns a `Flux` of the `ChatClientResponse` object that contains the `ChatResponse` object and the ChatClient execution context, giving you access to additional data used during the execution of advisors (e.g. the relevant documents retrieved in a RAG flow). == Using Defaults diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc index d5203569f..346ca12ed 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc @@ -28,22 +28,43 @@ They measure the time spent performing the invocation and propagate the related |=== |Name | Description -|`spring.ai.chat.client.advisor.params` | Map of advisor parameters. +|`gen_ai.prompt` | The content of the prompt sent via the chat client. Optional. +|`spring.ai.chat.client.advisor.params` (deprecated) | Map of advisor parameters. The conversation ID is now included in `spring.ai.chat.client.conversation.id`. |`spring.ai.chat.client.advisors` | List of configured chat client advisors. -|`spring.ai.chat.client.system.params` |Chat client system parameters. Optional. -|`spring.ai.chat.client.system.text` |Chat client system text. Optional. -|`spring.ai.chat.client.tool.function.names` | Enabled tool function names. -|`spring.ai.chat.client.tool.function.callbacks` |List of configured chat client function callbacks. -|`spring.ai.chat.client.user.params` | Chat client user parameters. Optional. -|`spring.ai.chat.client.user.text` | Chat client user text. Optional. +|`spring.ai.chat.client.conversation.id` | Identifier of the conversation when using the chat memory. +|`spring.ai.chat.client.system.params` (deprecated) |Chat client system parameters. Optional. Superseded by `gen_ai.prompt`. +|`spring.ai.chat.client.system.text` (deprecated) |Chat client system text. Optional. Superseded by `gen_ai.prompt`. +|`spring.ai.chat.client.tool.function.names` (deprecated) | Enabled tool function names. Superseded by `spring.ai.chat.client.tool.names`. +|`spring.ai.chat.client.tool.function.callbacks` (deprecated) |List of configured chat client function callbacks. Superseded by `spring.ai.chat.client.tool.names`. +|`spring.ai.chat.client.tool.names` | Names of the tools passed to the chat client. +|`spring.ai.chat.client.user.params` (deprecated) | Chat client user parameters. Optional. Superseded by `gen_ai.prompt`. +|`spring.ai.chat.client.user.text` (deprecated) | Chat client user text. Optional. Superseded by `gen_ai.prompt`. |=== -=== Input Data +=== Prompt Content + +The `ChatClient` prompt content is typically big and possibly containing sensitive information. +For those reasons, it is not exported by default. + +Spring AI supports exporting the prompt content as span attributes/events across all tracing backends. + +[cols="6,3,1", stripes=even] +|==== +| Property | Description | Default + +| `spring.ai.chat.client.observations.include-prompt` | Whether to include the chat client prompt content in the observations. | `false` +|==== + +WARNING: If you enable the inclusion of the chat client prompt content in the observations, there's a risk of exposing sensitive or private information. Please, be careful! + +=== Input Data (Deprecated) + +WARNING: The `spring.ai.chat.client.observations.include-input` property is deprecated, replaced by `spring.ai.chat.client.observations.include-prompt`. See xref:_prompt_content[Prompt Content]. The `ChatClient` input data is typically big and possibly containing sensitive information. For those reasons, it is not exported by default. -Spring AI supports exporting input data as span attributes across all tracing backends. +Spring AI supports exporting input data as span attributes/events across all tracing backends. [cols="6,3,1", stripes=even] |==== @@ -56,7 +77,7 @@ WARNING: If you enable the inclusion of the input content in the observations, t === Chat Client Advisors -The `spring.ai.advisor` observations are recorded when a call or stream around advisors is performed. +The `spring.ai.advisor` observations are recorded when an advisor is executed. They measure the time spent in the advisor (including the time spend on the inner advisors) and propagate the related tracing information. .Low Cardinality Keys @@ -66,7 +87,7 @@ They measure the time spent in the advisor (including the time spend on the inne |`gen_ai.operation.name` | Always `framework`. |`gen_ai.system` | Always `spring_ai`. -|`spring.ai.advisor.type` | Where the advisor applies it's logic in the request processing, one of `BEFORE`, `AFTER`, or `AROUND`. +|`spring.ai.advisor.type` (deprecated) | Where the advisor applies it's logic in the request processing, one of `BEFORE`, `AFTER`, or `AROUND`. This distinction doesn't apply anymore since all Advisors are always of the same type. |`spring.ai.kind` | The kind of framework API in Spring AI: `advisor`. |=== diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc index e9cecc0d4..3ff3daf31 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc @@ -46,6 +46,84 @@ This approach can save time and reduce the chance of errors when upgrading multi [[upgrading-to-1-0-0-m8]] == Upgrading to 1.0.0-M8 +=== Chat Client + +* The `ChatClient` has been enhanced to solve some inconsistencies or unwanted behaviour whenever user and system prompts were not rendered before using them in an advisor. The new behavior ensures that the user and system prompts are always rendered before executing the chain of advisors. As part of this enhancement, the `AdvisedRequest` and `AdvisedResponse` APIs have been deprecated, replaced by `ChatClientRequest` and `ChatClientResponse`. Advisors now act on a fully built `Prompt` object included in a `ChatClientRequest` instead of the destructured format used in `AdvisedRequest`, guaranteeing consistency and completeness. + +For example, if you had a custom advisor that modified the request prompt in the `before` method, you would refactor it as follows: + +[source,java,subs="verbatim,quotes"] +---- +// --- Before (using AdvisedRequest) --- +@Override +public AdvisedRequest before(AdvisedRequest advisedRequest) { + // Access original user text and parameters directly from AdvisedRequest + String originalUserText = new PromptTemplate(advisedRequest.userText(), advisedRequest.userParams()).render(); + + // ... retrieve documents, create augmented prompt text ... + List retrievedDocuments = ...; + String augmentedPromptText = ...; // create augmented text from originalUserText and retrievedDocuments + + // Copy existing context and add advisor-specific data + Map context = new HashMap<>(advisedRequest.adviseContext()); + context.put("retrievedDocuments", retrievedDocuments); // Example key + + // Use the AdvisedRequest builder pattern to return the modified request + return AdvisedRequest.from(advisedRequest) + .userText(augmentedPromptText) // Set the augmented user text + .adviseContext(context) // Set the updated context + .build(); +} + +// --- After (using ChatClientRequest) --- +@Override +public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain chain) { + String originalUserText = chatClientRequest.prompt().getUserMessage().getText(); // Access prompt directly + + // ... retrieve documents ... + List retrievedDocuments = ...; + String augmentedQueryText = ...; // create augmented text + + // Initialize context with existing data and add advisor-specific data + Map context = new HashMap<>(chatClientRequest.context()); // <1> + context.put("retrievedDocuments", retrievedDocuments); // Example key + context.put("originalUserQuery", originalUserText); // Example key + + // Use immutable operations + return chatClientRequest.mutate() + .prompt(chatClientRequest.prompt() + .augmentUserMessage(augmentedQueryText) // <2> + ) + .context(context) // <3> + .build(); +} +---- +<1> Initialize the context map with data from the incoming request (`chatClientRequest.context()`) to preserve context from previous advisors, then add new data. +<2> Use methods like `prompt.augmentUserMessage()` to modify the prompt content safely. +<3> Pass the updated context map. This map becomes part of the `ChatClientRequest` and is accessible later via `ChatClientResponse.responseContext()` in the `after` method. + +* The chain of advisors can populate the execution context with useful data. For example, when performing retrieval augmented generation, the retrieved documents can be added to the context. It's now possible to return a `ChatClientResponse` object from a `ChatClient` call, which contains the execution context. So, besides the `content()` and `chatResponse()` methods, you can now terminate a `ChatClient` call with `chatClientResponse()` which gives you access to both the `ChatResponse` and the execution context. + +In addition to directly replacing the user message text with `augmentUserMessage(String)`, you can provide a function to modify the existing `UserMessage` more granularly: + +[source,java,subs="verbatim,quotes"] +---- +Prompt originalPrompt = new Prompt(new UserMessage("Tell me about Large Language Models.")); + +// Example: Append context or modify properties using a Function +Prompt augmentedPrompt = originalPrompt.augmentUserMessage(userMessage -> + userMessage.mutate() + .text(userMessage.getText() + "\n\nFocus on their applications in software development.") + // .media(...) // Potentially add/modify media + // .metadata(...) // Potentially add/modify metadata + .build() +); + +// 'augmentedPrompt' now contains the modified UserMessage +---- + +This approach offers more control when you need to conditionally change parts of the `UserMessage` or work with its media and metadata, rather than just replacing the text content. + === Chat Memory * A `ChatMemory` bean is auto-configured for you whenever using one of the Spring AI Model starters. By default, it uses the `MessageWindowChatMemory` implementation and stores the conversation history in memory. @@ -59,6 +137,15 @@ This approach can save time and reduce the chance of errors when upgrading multi * The `PromptTemplate` API has been redesigned to support a more flexible and extensible way of templating prompts, relying on a new `TemplateRenderer` API. As part of this change, the `getInputVariables()` and `validate()` methods have been deprecated and will throw an `UnsupportedOperationException` if called. Any logic specific to a template engine should be available through the `TemplateRenderer` API. +=== Observability + +* Changes to the `spring.ai.client` observation: +** The `spring.ai.chat.client.tool.function.names` and `spring.ai.chat.client.tool.function.callbacks` attributes have been deprecated, replaced by a new `spring.ai.chat.client.tool.names` attribute that includes the names of all the tools passed to a ChatClient, regardless of the underlying mechanism used to define them. +** The `spring.ai.chat.client.advisor.params` attribute has been deprecated and will not have a replacement. The reason is that there is a risk to expose sensitive information or break the instrumentation since the entries in the advisor context are used to pass arbitrary Java objects between advisors and are not necessarily serializable. The conversation ID that was previously exported here is now available via the dedicated `spring.ai.chat.client.conversation.id` attribute. If you need to export some of the other parameters in the advisor context to the observability system, you can do so by defining an `ObservationFilter` and making an explicit decision on which parameters to export. For inspiration, you can refer to the `ChatClientPromptContentObservationFilter`. +** The content of a prompt as specified via a ChatClient API was included optionally in the `spring.ai.client` observation, broken down in a few attributes: `spring.ai.chat.client.user.text`, `spring.ai.chat.client.user.params`, `spring.ai.chat.client.system.text`, `spring.ai.chat.client.system.params`. All those attributes are now deprecated, replaced by a single `gen_ai.prompt` attribute that contains all the messages in the prompt, solving the problem affecting the deprecated attributes where part of the prompt was not included in the observation, and aligning with the observations used in the ChatModel API. This new attribute can be enabled via the `spring.ai.chat.observations.include-prompt` configuration property, whereas the previous `spring.ai.chat.observations.include-input` configuration property is deprecated. +* Changes to the `spring.ai.advisor` observation: +** The `spring.ai.advisor.type` attribute has been deprecated. In previous releases, the Advisor API was categorized based on the type of advisor (`before`, `after`, `around`). That distinction doesn't apply anymore meaning that all Advisors are now of the same type (`around`). + [[upgrading-to-1-0-0-m7]] == Upgrading to 1.0.0-M7 @@ -803,9 +890,3 @@ You can access `0.7.1-SNAPSHOT` artifacts as before and still access https://mar 0.7.1-SNAPSHOT ---- - -== Upgrading to 1.0.0.M4 - -* PaLM API support removal - -As a follow up to the announcement to https://ai.google.dev/palm_docs/deprecation[deprecate PaLM API], the PaLM API support is removed. diff --git a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/FunctionToolCallbackTests.java b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/FunctionToolCallbackTests.java index 62728d6d8..927fc5095 100644 --- a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/FunctionToolCallbackTests.java +++ b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/FunctionToolCallbackTests.java @@ -38,6 +38,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Description; import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; import static org.assertj.core.api.Assertions.assertThat; @@ -49,6 +50,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(classes = TestApplication.class) @Import(FunctionToolCallbackTests.Tools.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public class FunctionToolCallbackTests { // @formatter:off diff --git a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/MethodToolCallbackTests.java b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/MethodToolCallbackTests.java index 544afcb98..cda509210 100644 --- a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/MethodToolCallbackTests.java +++ b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/tool/MethodToolCallbackTests.java @@ -34,6 +34,7 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.method.MethodToolCallback; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; import static org.assertj.core.api.Assertions.assertThat; @@ -44,6 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat; */ @SpringBootTest(classes = TestApplication.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public class MethodToolCallbackTests { @Autowired diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java index 891b9997c..8d3d4f831 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/Prompt.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Objects; +import java.util.function.Function; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; @@ -30,6 +31,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.model.ModelRequest; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -45,7 +47,7 @@ public class Prompt implements ModelRequest> { private final List messages; - private ChatOptions chatOptions; + private final ChatOptions chatOptions; public Prompt(String contents) { this(new UserMessage(contents)); @@ -72,6 +74,8 @@ public class Prompt implements ModelRequest> { } public Prompt(List messages, ChatOptions chatOptions) { + Assert.notNull(messages, "messages cannot be null"); + Assert.noNullElements(messages, "messages cannot contain null elements"); this.messages = messages; this.chatOptions = (chatOptions != null) ? chatOptions : ChatOptions.builder().build(); } @@ -94,6 +98,20 @@ public class Prompt implements ModelRequest> { return this.messages; } + /** + * Get the last user message in the prompt. If no user message is found, an empty + * UserMessage is returned. + */ + public UserMessage getUserMessage() { + for (int i = this.messages.size() - 1; i >= 0; i--) { + Message message = this.messages.get(i); + if (message instanceof UserMessage userMessage) { + return userMessage; + } + } + return new UserMessage(""); + } + @Override public String toString() { return "Prompt{" + "messages=" + this.messages + ", modelOptions=" + this.chatOptions + '}'; @@ -123,17 +141,10 @@ public class Prompt implements ModelRequest> { List messagesCopy = new ArrayList<>(); this.messages.forEach(message -> { if (message instanceof UserMessage userMessage) { - messagesCopy.add(UserMessage.builder() - .text(userMessage.getText()) - .media(userMessage.getMedia()) - .metadata(message.getMetadata()) - .build()); + messagesCopy.add(userMessage.copy()); } else if (message instanceof SystemMessage systemMessage) { - messagesCopy.add(SystemMessage.builder() - .text(systemMessage.getText()) - .metadata(systemMessage.getMetadata()) - .build()); + messagesCopy.add(systemMessage.copy()); } else if (message instanceof AssistantMessage assistantMessage) { messagesCopy.add(new AssistantMessage(assistantMessage.getText(), assistantMessage.getMetadata(), @@ -151,6 +162,38 @@ public class Prompt implements ModelRequest> { return messagesCopy; } + /** + * @param userMessageAugmenter the function to augment the last user message. + * @return a new prompt instance with the augmented user message. + */ + public Prompt augmentUserMessage(Function userMessageAugmenter) { + + var messagesCopy = new ArrayList<>(this.messages); + for (int i = messagesCopy.size() - 1; i >= 0; i--) { + Message message = messagesCopy.get(i); + if (message instanceof UserMessage userMessage) { + messagesCopy.set(i, userMessageAugmenter.apply(userMessage)); + break; + } + if (i == 0) { + messagesCopy.add(userMessageAugmenter.apply(new UserMessage(""))); + } + } + + return new Prompt(messagesCopy, null == this.chatOptions ? null : this.chatOptions.copy()); + } + + /** + * Creates a copy of the prompt, replacing the text content of the last UserMessage + * with the provided text. If no UserMessage exists, a new one with the given text is + * added. + * @param newUserText The new text content for the last user message. + * @return A new Prompt instance with the augmented user message text. + */ + public Prompt augmentUserMessage(String newUserText) { + return augmentUserMessage(userMessage -> userMessage.mutate().text(newUserText).build()); + } + public Builder mutate() { Builder builder = new Builder().messages(instructionsCopy()); builder.chatOptions(this.chatOptions.copy()); @@ -167,7 +210,7 @@ public class Prompt implements ModelRequest> { private String content; @Nullable - private List messages = new ArrayList<>(); + private List messages; @Nullable private ChatOptions chatOptions; diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTests.java new file mode 100644 index 000000000..5668fc161 --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTests.java @@ -0,0 +1,161 @@ +/* + * 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.chat.prompt; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link Prompt}. + * + * @author Thomas Vitale + */ +class PromptTests { + + @Test + void whenContentIsNullThenThrow() { + assertThatThrownBy(() -> new Prompt((String) null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Content must not be null for SYSTEM or USER messages"); + + assertThatThrownBy(() -> new Prompt((String) null, ChatOptions.builder().build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Content must not be null for SYSTEM or USER messages"); + } + + @Test + void whenContentIsEmptyThenReturn() { + Prompt prompt = new Prompt(""); + assertThat(prompt).isNotNull(); + + prompt = new Prompt("", ChatOptions.builder().build()); + assertThat(prompt).isNotNull(); + } + + @Test + void whenMessageIsNullThenThrow() { + assertThatThrownBy(() -> new Prompt((Message) null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messages cannot contain null elements"); + + assertThatThrownBy(() -> new Prompt((Message) null, ChatOptions.builder().build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messages cannot contain null elements"); + } + + @Test + void whenMessageListIsNullThenThrow() { + assertThatThrownBy(() -> new Prompt((List) null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messages cannot be null"); + + assertThatThrownBy(() -> new Prompt((List) null, ChatOptions.builder().build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messages cannot be null"); + } + + @Test + void whenMessageArrayIsNullThenThrow() { + assertThatThrownBy(() -> new Prompt((Message[]) null)).isInstanceOf(NullPointerException.class); + } + + @Test + void whenContentAndMessageAreBothDefinedThenThrow() { + assertThatThrownBy(() -> Prompt.builder().content("Something").messages(new UserMessage("Else")).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("content and messages cannot be set at the same time"); + } + + @Test + void getUserMessageWhenSingle() { + Prompt prompt = Prompt.builder().messages(new UserMessage("Hello")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello"); + } + + @Test + void getUserMessageWhenMultiple() { + Prompt prompt = Prompt.builder().messages(new UserMessage("Hello"), new UserMessage("How are you?")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?"); + } + + @Test + void getUserMessageWhenNone() { + Prompt prompt = Prompt.builder().messages(new SystemMessage("You'll be back!")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo(""); + + prompt = Prompt.builder().messages(List.of()).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo(""); + } + + @Test + void augmentUserMessageWhenSingle() { + Prompt prompt = Prompt.builder().messages(new UserMessage("Hello")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello"); + + Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("How are you?").build()); + + assertThat(copy.getUserMessage()).isNotNull(); + assertThat(copy.getUserMessage().getText()).isEqualTo("How are you?"); + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello"); + } + + @Test + void augmentUserMessageWhenMultiple() { + Prompt prompt = Prompt.builder().messages(new UserMessage("Hello"), new UserMessage("How are you?")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?"); + + Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("What about you?").build()); + + assertThat(copy.getUserMessage()).isNotNull(); + assertThat(copy.getUserMessage().getText()).isEqualTo("What about you?"); + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?"); + } + + @Test + void augmentUserMessageWhenNone() { + Prompt prompt = Prompt.builder().messages(new SystemMessage("You'll be back!")).build(); + + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo(""); + + Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("How are you?").build()); + + assertThat(copy.getUserMessage()).isNotNull(); + assertThat(copy.getUserMessage().getText()).isEqualTo("How are you?"); + assertThat(prompt.getUserMessage()).isNotNull(); + assertThat(prompt.getUserMessage().getText()).isEqualTo(""); + } + +} diff --git a/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java b/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java index 5e06833e5..ac6107183 100644 --- a/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java +++ b/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -28,8 +28,10 @@ import reactor.core.scheduler.Scheduler; import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.AdvisorChain; import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.ai.rag.Query; import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter; @@ -78,7 +80,7 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor { private final int order; - public RetrievalAugmentationAdvisor(@Nullable List queryTransformers, + private RetrievalAugmentationAdvisor(@Nullable List queryTransformers, @Nullable QueryExpander queryExpander, DocumentRetriever documentRetriever, @Nullable DocumentJoiner documentJoiner, @Nullable QueryAugmenter queryAugmenter, @Nullable TaskExecutor taskExecutor, @Nullable Scheduler scheduler, @Nullable Integer order) { @@ -98,14 +100,24 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor { return new Builder(); } + /** + * @deprecated in favour of {@link #before(ChatClientRequest, AdvisorChain)} + */ @Override - public AdvisedRequest before(AdvisedRequest request) { - Map context = new HashMap<>(request.adviseContext()); + @Deprecated + public AdvisedRequest before(AdvisedRequest advisedRequest) { + ChatClientRequest chatClientRequest = advisedRequest.toChatClientRequest(); + return AdvisedRequest.from(before(chatClientRequest, null)); + } + + @Override + public ChatClientRequest before(ChatClientRequest chatClientRequest, @Nullable AdvisorChain advisorChain) { + Map context = new HashMap<>(chatClientRequest.context()); // 0. Create a query from the user text, parameters, and conversation history. Query originalQuery = Query.builder() - .text(new PromptTemplate(request.userText(), request.userParams()).render()) - .history(request.messages()) + .text(chatClientRequest.prompt().getUserMessage().getText()) + .history(chatClientRequest.prompt().getInstructions()) .context(context) .build(); @@ -135,8 +147,11 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor { // 5. Augment user query with the document contextual data. Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents); - // 6. Update advised request with augmented prompt. - return AdvisedRequest.from(request).userText(augmentedQuery.text()).adviseContext(context).build(); + // 6. Update ChatClientRequest with augmented prompt. + return chatClientRequest.mutate() + .prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text())) + .context(context) + .build(); } /** @@ -148,17 +163,30 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor { return Map.entry(query, documents); } + /** + * @deprecated in favour of {@link #after(ChatClientResponse, AdvisorChain)} + */ @Override + @Deprecated public AdvisedResponse after(AdvisedResponse advisedResponse) { + ChatClientResponse chatClientResponse = advisedResponse.toChatClientResponse(); + return AdvisedResponse.from(after(chatClientResponse, null)); + } + + @Override + public ChatClientResponse after(ChatClientResponse chatClientResponse, @Nullable AdvisorChain advisorChain) { ChatResponse.Builder chatResponseBuilder; - if (advisedResponse.response() == null) { + if (chatClientResponse.chatResponse() == null) { chatResponseBuilder = ChatResponse.builder(); } else { - chatResponseBuilder = ChatResponse.builder().from(advisedResponse.response()); + chatResponseBuilder = ChatResponse.builder().from(chatClientResponse.chatResponse()); } - chatResponseBuilder.metadata(DOCUMENT_CONTEXT, advisedResponse.adviseContext().get(DOCUMENT_CONTEXT)); - return new AdvisedResponse(chatResponseBuilder.build(), advisedResponse.adviseContext()); + chatResponseBuilder.metadata(DOCUMENT_CONTEXT, chatClientResponse.context().get(DOCUMENT_CONTEXT)); + return ChatClientResponse.builder() + .chatResponse(chatResponseBuilder.build()) + .context(chatClientResponse.context()) + .build(); } @Override diff --git a/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/package-info.java b/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/package-info.java new file mode 100644 index 000000000..a0d58a67c --- /dev/null +++ b/spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.rag.advisor; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields;