Make ChatClient and Advisor APIs more robust - Part 2
* ChatClient observations now include the full prompt content instead of just the userText and systemText. Furthermore, they include consistent telemetry for the tools passed via the ChatClient and a first-class conversation ID when using memory advisors. Incomplete or unsafe attributes have been deprecated. * Adopted the new robust Advisor APIs for BaseAdvisor and RetrievalAugmentationAdvisor. * Improved the prompt augmentation facilities in ChatClientRequest and Prompt for performance and immutability. * Fixed integration test racing condition. * Updated the documentation for ChatClient and Observability accordingly. * Documented changes in upgrade notes. * Introduced `prompt.augmentUserMessage(String text)` to directly replace the user message content. * Added `prompt.augmentUserMessage(Function<UserMessage, UserMessage> augmenter)` for more granular updates using the `userMessage.mutate()` pattern, allowing modification of text, media, and metadata. Relates to gh-2655 Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
committed by
Mark Pollack
parent
0c0787b849
commit
faa8778b6a
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,8 +39,12 @@ public record ChatClientRequest(Prompt prompt, Map<String, Object> 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() {
|
||||
|
||||
@@ -38,6 +38,14 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String
|
||||
Assert.noNullElements(context.keySet(), "context keys cannot be null");
|
||||
}
|
||||
|
||||
public ChatClientResponse copy() {
|
||||
return new ChatClientResponse(this.chatResponse, new HashMap<>(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<String
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
public Builder chatResponse(ChatResponse chatResponse) {
|
||||
public Builder chatResponse(@Nullable ChatResponse chatResponse) {
|
||||
this.chatResponse = chatResponse;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -493,10 +493,11 @@ public class DefaultChatClient implements ChatClient {
|
||||
private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest,
|
||||
@Nullable String outputFormat) {
|
||||
ChatClientRequest formattedChatClientRequest = StringUtils.hasText(outputFormat)
|
||||
? addFormatInstructionsToPrompt(chatClientRequest, outputFormat) : chatClientRequest;
|
||||
? augmentPromptWithFormatInstructions(chatClientRequest, outputFormat) : chatClientRequest;
|
||||
|
||||
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
|
||||
.request(formattedChatClientRequest)
|
||||
.advisors(advisorChain.getCallAdvisors())
|
||||
.stream(false)
|
||||
.withFormat(outputFormat)
|
||||
.build();
|
||||
@@ -511,42 +512,16 @@ public class DefaultChatClient implements ChatClient {
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static ChatClientRequest addFormatInstructionsToPrompt(ChatClientRequest chatClientRequest,
|
||||
private static ChatClientRequest augmentPromptWithFormatInstructions(ChatClientRequest chatClientRequest,
|
||||
String outputFormat) {
|
||||
List<Message> originalMessages = chatClientRequest.prompt().getInstructions();
|
||||
|
||||
if (CollectionUtils.isEmpty(originalMessages)) {
|
||||
return chatClientRequest;
|
||||
}
|
||||
|
||||
// Create a copy of the message list to avoid modifying the original.
|
||||
List<Message> 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<String, Object> advisorParams = new HashMap<>();
|
||||
|
||||
private final DefaultAroundAdvisorChain.Builder aroundAdvisorChainBuilder;
|
||||
|
||||
private final Map<String, Object> 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
|
||||
|
||||
@@ -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<ChatClientResponse> 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()));
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
|
||||
|
||||
public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention();
|
||||
|
||||
private final List<CallAroundAdvisor> originalCallAdvisors;
|
||||
|
||||
private final List<StreamAroundAdvisor> originalStreamAdvisors;
|
||||
|
||||
private final Deque<CallAroundAdvisor> callAroundAdvisors;
|
||||
|
||||
private final Deque<StreamAroundAdvisor> 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<CallAroundAdvisor> getCallAdvisors() {
|
||||
return this.originalCallAdvisors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StreamAroundAdvisor> 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<? extends Advisor> 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<CallAroundAdvisor> callAroundAdvisorList = advisors.stream()
|
||||
.filter(a -> a instanceof CallAroundAdvisor)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<ChatClientResponse> 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<ChatClientResponse> 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<AdvisedResponse> 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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<CallAroundAdvisor> getCallAdvisors();
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChatClientResponse> nextStream(ChatClientRequest chatClientRequest);
|
||||
|
||||
List<StreamAroundAdvisor> getStreamAdvisors();
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<? extends Advisor> advisors;
|
||||
|
||||
private final boolean stream;
|
||||
|
||||
ChatClientObservationContext(ChatClientRequest chatClientRequest, boolean isStream) {
|
||||
ChatClientObservationContext(ChatClientRequest chatClientRequest, List<? extends Advisor> 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<? extends Advisor> 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<? extends Advisor> 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<? extends Advisor> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Object> processPrompt(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().prompt().getInstructions())) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
var messages = new HashMap<String, Object>();
|
||||
context.getRequest()
|
||||
.prompt()
|
||||
.getInstructions()
|
||||
.forEach(message -> messages.put(message.getMessageType().getValue(), message.getText()));
|
||||
return messages;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Advisor>) 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;
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Advisor> 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<CallAdvisor> 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<StreamAdvisor> 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]));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Advisor> 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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"]"""));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<T> type)`: used to return a `Collection` of entity types.
|
||||
** `entity(Class<T> 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<String> content()`: Returns a `Flux` of the string being generated by the AI model.
|
||||
* `Flux<ChatResponse> chatResponse()`: Returns a `Flux` of the `ChatResponse` object, which contains additional metadata about the response.
|
||||
* `Flux<ChatClientResponse> 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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|===
|
||||
|
||||
|
||||
@@ -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<Document> retrievedDocuments = ...;
|
||||
String augmentedPromptText = ...; // create augmented text from originalUserText and retrievedDocuments
|
||||
|
||||
// Copy existing context and add advisor-specific data
|
||||
Map<String, Object> 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<Document> retrievedDocuments = ...;
|
||||
String augmentedQueryText = ...; // create augmented text
|
||||
|
||||
// Initialize context with existing data and add advisor-specific data
|
||||
Map<String, Object> 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
|
||||
<version>0.7.1-SNAPSHOT</version>
|
||||
</dependency>
|
||||
----
|
||||
|
||||
== 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<Message>> {
|
||||
|
||||
private final List<Message> 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<List<Message>> {
|
||||
}
|
||||
|
||||
public Prompt(List<Message> 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<List<Message>> {
|
||||
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<Message>> {
|
||||
List<Message> 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<List<Message>> {
|
||||
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<UserMessage, UserMessage> 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<List<Message>> {
|
||||
private String content;
|
||||
|
||||
@Nullable
|
||||
private List<Message> messages = new ArrayList<>();
|
||||
private List<Message> messages;
|
||||
|
||||
@Nullable
|
||||
private ChatOptions chatOptions;
|
||||
|
||||
@@ -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<Message>) null)).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("messages cannot be null");
|
||||
|
||||
assertThatThrownBy(() -> new Prompt((List<Message>) 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("");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<QueryTransformer> queryTransformers,
|
||||
private RetrievalAugmentationAdvisor(@Nullable List<QueryTransformer> 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<String, Object> 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<String, Object> 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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user