diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 74c6853f70b..c1cd2074464 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -28,6 +28,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; +import org.springframework.ai.chat.client.advisor.observation.ObservableRequestResponseAdvisor; import org.springframework.ai.chat.client.observation.ChatClientObservationContext; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation; @@ -654,22 +656,35 @@ public ChatClientRequestSpec advisors(Consumer consumer) var as = new DefaultAdvisorSpec(); consumer.accept(as); this.advisorParams.putAll(as.getParams()); - this.advisors.addAll(as.getAdvisors()); + this.advisors.addAll(toObservableAdvisors(as.getAdvisors(), this.observationRegistry, null)); return this; } public ChatClientRequestSpec advisors(RequestResponseAdvisor... advisors) { Assert.notNull(advisors, "the advisors must be non-null"); - this.advisors.addAll(List.of(advisors)); + this.advisors.addAll(toObservableAdvisors(List.of(advisors), this.observationRegistry, null)); return this; } public ChatClientRequestSpec advisors(List advisors) { Assert.notNull(advisors, "the advisors must be non-null"); - this.advisors.addAll(advisors); + this.advisors.addAll(toObservableAdvisors(advisors, this.observationRegistry, null)); return this; } + private List toObservableAdvisors(List advisors, + ObservationRegistry observationRegistry, AdvisorObservationConvention customObservationConvention) { + if (CollectionUtils.isEmpty(advisors)) { + return advisors; + } + List observableAdvisors = new ArrayList<>(); + for (RequestResponseAdvisor advisor : advisors) { + observableAdvisors.add(new ObservableRequestResponseAdvisor(advisor, observationRegistry, + customObservationConvention)); + } + return observableAdvisors; + } + public ChatClientRequestSpec messages(Message... messages) { Assert.notNull(messages, "the messages must be non-null"); this.messages.addAll(List.of(messages)); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java index d9391c420e8..dc8747fff5c 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisor.java @@ -23,7 +23,6 @@ import org.springframework.ai.chat.client.AdvisedRequest; import org.springframework.ai.chat.client.RequestResponseAdvisor; -import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.document.Document; import org.springframework.ai.model.Content; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContext.java new file mode 100644 index 00000000000..499362e7bee --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContext.java @@ -0,0 +1,143 @@ +/* +* Copyright 2024 - 2024 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.observation; + +import java.util.Map; + +import org.springframework.ai.chat.client.AdvisedRequest; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.util.Assert; + +import io.micrometer.observation.Observation; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class AdvisorObservationContext extends Observation.Context { + + public enum Type { + + BEFORE, AFTER, AROUND; + + }; + + private String advisorName; + + private Type advisorType; + + /** + * The {@link AdvisedRequest} data to be advised. Represents the row + * {@link ChatClient.ChatClientRequestSpec} data before sealed into a {@link Prompt}. + */ + private AdvisedRequest avisorRequest; + + /** + * The shared data between the advisors in the chain. It is shared between all request + * and response advising points of all advisors in the chain. + */ + private Map advisorRequestContext; + + /** + * the shared data between the advisors in the chain. It is shared between all request + * and response advising points of all advisors in the chain. + */ + private Map advisorResponseContext; + + public void setAdvisorName(String advisorName) { + this.advisorName = advisorName; + } + + public String getAdvisorName() { + return this.advisorName; + } + + public Type getAdvisorType() { + return this.advisorType; + } + + public void setAdvisorType(Type type) { + this.advisorType = type; + } + + public AdvisedRequest getAdvisedRequest() { + return this.avisorRequest; + } + + public void setAdvisedRequest(AdvisedRequest advisedRequest) { + this.avisorRequest = advisedRequest; + } + + public Map getAdvisorRequestContext() { + return this.advisorRequestContext; + } + + public void setAdvisorRequestContext(Map advisorRequestContext) { + this.advisorRequestContext = advisorRequestContext; + } + + public Map getAdvisorResponseContext() { + return this.advisorResponseContext; + } + + public void setAdvisorResponseContext(Map advisorResponseContext) { + this.advisorResponseContext = advisorResponseContext; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private final AdvisorObservationContext context = new AdvisorObservationContext(); + + public Builder withAdvisorName(String advisorName) { + this.context.setAdvisorName(advisorName); + return this; + } + + public Builder withAdvisorType(Type advisorType) { + this.context.setAdvisorType(advisorType); + return this; + } + + public Builder withAdvisedRequest(AdvisedRequest advisedRequest) { + this.context.setAdvisedRequest(advisedRequest); + return this; + } + + public Builder withAdvisorRequestContext(Map advisorRequestContext) { + this.context.setAdvisorRequestContext(advisorRequestContext); + return this; + } + + public Builder withAdvisorResponseContext(Map advisorResponseContext) { + this.context.setAdvisorResponseContext(advisorResponseContext); + return this; + } + + public AdvisorObservationContext build() { + Assert.hasText(this.context.advisorName, "The advisorName must not be empty!"); + Assert.notNull(this.context.advisorType, "The advisorType must not be null!"); + return this.context; + } + + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationConvention.java new file mode 100644 index 00000000000..450a8499a2a --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationConvention.java @@ -0,0 +1,33 @@ +/* +* Copyright 2024 - 2024 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.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public interface AdvisorObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof AdvisorObservationContext; + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java new file mode 100644 index 00000000000..5bb022b0879 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java @@ -0,0 +1,88 @@ +/* +* Copyright 2024 - 2024 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.observation; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public enum AdvisorObservationDocumentation implements ObservationDocumentation { + + /** + * AI Chat Client observations + */ + AI_ADVISOR { + @Override + public Class> getDefaultConvention() { + return DefaultAdvisorObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * Spring AI kind. + */ + SPRING_AI_KIND { + @Override + public String asString() { + return "spring.ai.kind"; + } + }, + + /** + * Advisor type: Before, After or Around. + */ + ADVISOR_TYPE { + @Override + public String asString() { + return "spring.ai.chat.client.advisor.type"; + } + }; + + } + + public enum HighCardinalityKeyNames implements KeyName { + + /** + * Chat Model name. + */ + ADVISOR_NAME { + @Override + public String asString() { + return "spring.ai.chat.client.advisor.name"; + } + }; + + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java new file mode 100644 index 00000000000..e6babe01891 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java @@ -0,0 +1,103 @@ +/* +* Copyright 2024 - 2024 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.observation; + +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.util.ParsingUtils; +import org.springframework.lang.Nullable; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class DefaultAdvisorObservationConvention implements AdvisorObservationConvention { + + public static final String DEFAULT_NAME = "spring.ai.chat.client.advisor"; + + private static final String CHAT_CLIENT_ADVISOR_SPRING_AI_KIND = "chat_client_advisor"; + + private static final KeyValue ADVISOR_TYPE_NONE = KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, + KeyValue.NONE_VALUE); + + private static final KeyValue ADVISOR_NAME_NONE = KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME, + KeyValue.NONE_VALUE); + + private final String name; + + public DefaultAdvisorObservationConvention() { + this(DEFAULT_NAME); + } + + public DefaultAdvisorObservationConvention(String name) { + this.name = name; + } + + @Override + public String getName() { + return this.name; + } + + @Override + @Nullable + public String getContextualName(AdvisorObservationContext context) { + return "%s %s_%s".formatted(CHAT_CLIENT_ADVISOR_SPRING_AI_KIND, + ParsingUtils.reconcatenateCamelCase(context.getAdvisorName(), "_"), + context.getAdvisorType().name().toLowerCase()); + } + + // ------------------------ + // Low cardinality keys + // ------------------------ + + @Override + public KeyValues getLowCardinalityKeyValues(AdvisorObservationContext context) { + return KeyValues.of(springAiKind(), advisorType(context)); + } + + protected KeyValue advisorType(AdvisorObservationContext context) { + if (context.getAdvisorType() != null) { + return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name()); + } + return ADVISOR_TYPE_NONE; + } + + protected KeyValue springAiKind() { + return KeyValue.of(AdvisorObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND, + CHAT_CLIENT_ADVISOR_SPRING_AI_KIND); + } + + // ------------------------ + // High Cardinality keys + // ------------------------ + + @Override + public KeyValues getHighCardinalityKeyValues(AdvisorObservationContext context) { + return KeyValues.of(advisorName(context)); + } + + protected KeyValue advisorName(AdvisorObservationContext context) { + if (context.getAdvisorType() != null) { + return KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME, context.getAdvisorName()); + } + return ADVISOR_NAME_NONE; + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/ObservableRequestResponseAdvisor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/ObservableRequestResponseAdvisor.java new file mode 100644 index 00000000000..909b74dc497 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/ObservableRequestResponseAdvisor.java @@ -0,0 +1,123 @@ +/* +* Copyright 2024 - 2024 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.observation; + +import java.util.Map; + +import org.springframework.ai.chat.client.AdvisedRequest; +import org.springframework.ai.chat.client.RequestResponseAdvisor; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import reactor.core.publisher.Flux; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class ObservableRequestResponseAdvisor implements RequestResponseAdvisor { + + private static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention(); + + private final RequestResponseAdvisor targetAdvisor; + + private final ObservationRegistry observationRegistry; + + private final AdvisorObservationConvention customObservationConvention; + + public ObservableRequestResponseAdvisor(RequestResponseAdvisor targetAdvisor, + ObservationRegistry observationRegistry, + @Nullable AdvisorObservationConvention customObservationConvention) { + + Assert.notNull(targetAdvisor, "TargetAdvisor must not be null"); + Assert.notNull(observationRegistry, "ObservationRegistry must not be null"); + + this.targetAdvisor = targetAdvisor; + this.observationRegistry = observationRegistry; + this.customObservationConvention = customObservationConvention; + } + + @Override + public AdvisedRequest adviseRequest(AdvisedRequest request, Map advisorRequestContext) { + + var observationContext = this.doCreateObservationContextBuilder(AdvisorObservationContext.Type.BEFORE) + .withAdvisedRequest(request) + .withAdvisorRequestContext(advisorRequestContext) + .build(); + + return AdvisorObservationDocumentation.AI_ADVISOR + .observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> this.targetAdvisor.adviseRequest(request, advisorRequestContext)); + } + + @Override + public ChatResponse adviseResponse(ChatResponse response, Map advisorResponseContext) { + + var observationContext = this.doCreateObservationContextBuilder(AdvisorObservationContext.Type.AFTER) + .withAdvisorRequestContext(advisorResponseContext) + .build(); + + return AdvisorObservationDocumentation.AI_ADVISOR + .observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> this.targetAdvisor.adviseResponse(response, advisorResponseContext)); + } + + @Override + public Flux adviseResponse(Flux fluxResponse, Map context) { + + return Flux.deferContextual(contextView -> { + var observationContext = this.doCreateObservationContextBuilder(AdvisorObservationContext.Type.AFTER) + .withAdvisorResponseContext(context) + .build(); + + Observation observation = AdvisorObservationDocumentation.AI_ADVISOR.observation( + this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); + + // @formatter:off + return this.targetAdvisor.adviseResponse(fluxResponse, context) + .doOnError(observation::error) + .doFinally(s -> { + observation.stop(); + }) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on + }); + } + + /** + * Create the AdvisorObservationContext.Builder for the given advisorType. Can be + * overridden by the concrete advisor to provide additional context information. + * @param advisorType the advisor type. + * @return the AdvisorObservationContext.Builder instance. + */ + public AdvisorObservationContext.Builder doCreateObservationContextBuilder( + AdvisorObservationContext.Type advisorType) { + + return AdvisorObservationContext.builder() + .withAdvisorName(this.targetAdvisor.getName()) + .withAdvisorType(advisorType); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/util/ParsingUtils.java b/spring-ai-core/src/main/java/org/springframework/ai/util/ParsingUtils.java new file mode 100644 index 00000000000..e356ca80c29 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/util/ParsingUtils.java @@ -0,0 +1,94 @@ +/* + * Copyright 2014-2024 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.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Utility methods for {@link String} parsing. + * + * @author Oliver Gierke + * @since 1.5 + */ +public abstract class ParsingUtils { + + private static final String UPPER = "\\p{Lu}|\\P{InBASIC_LATIN}"; + + private static final String LOWER = "\\p{Ll}"; + + private static final String CAMEL_CASE_REGEX = "(? splitCamelCase(String source) { + return split(source, false); + } + + /** + * Splits up the given camel-case {@link String} and returns the parts in lower case. + * @param source must not be {@literal null}. + * @return + */ + public static List splitCamelCaseToLower(String source) { + return split(source, true); + } + + /** + * Reconcatenates the given camel-case source {@link String} using the given + * delimiter. Will split up the camel-case {@link String} and use an uncapitalized + * version of the parts. + * @param source must not be {@literal null}. + * @param delimiter must not be {@literal null}. + * @return + */ + public static String reconcatenateCamelCase(String source, String delimiter) { + + Assert.notNull(source, "Source string must not be null"); + Assert.notNull(delimiter, "Delimiter must not be null"); + + return StringUtils.collectionToDelimitedString(splitCamelCaseToLower(source), delimiter); + } + + private static List split(String source, boolean toLower) { + + Assert.notNull(source, "Source string must not be null"); + + String[] parts = CAMEL_CASE.split(source); + List result = new ArrayList<>(parts.length); + + for (String part : parts) { + result.add(toLower ? part.toLowerCase() : part); + } + + return Collections.unmodifiableList(result); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisorTests.java similarity index 97% rename from spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java rename to spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisorTests.java index 4edda19a4d9..76851330780 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/QuestionAnswerAdvisorTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/QuestionAnswerAdvisorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.client; +package org.springframework.ai.chat.client.advisor; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -27,7 +27,7 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; +import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.model.ChatModel; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/SimpleLoggerAdvisorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisorTests.java similarity index 95% rename from spring-ai-core/src/test/java/org/springframework/ai/chat/client/SimpleLoggerAdvisorTests.java rename to spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisorTests.java index f4aefb18af8..c0ec6ce318b 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/SimpleLoggerAdvisorTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.client; +package org.springframework.ai.chat.client.advisor; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -28,9 +28,8 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContextTests.java new file mode 100644 index 00000000000..9b843972346 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContextTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 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.observation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AdvisorObservationContext}. + * + * @author Christian Tzolov + */ +class AdvisorObservationContextTests { + + @Test + void whenMandatoryOptionsThenReturn() { + AdvisorObservationContext observationContext = AdvisorObservationContext.builder() + .withAdvisorName("MyName") + .withAdvisorType(AdvisorObservationContext.Type.BEFORE) + .build(); + + assertThat(observationContext).isNotNull(); + } + + @Test + void missingAdvisorName() { + assertThatThrownBy(() -> AdvisorObservationContext.builder() + .withAdvisorType(AdvisorObservationContext.Type.BEFORE) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("The advisorName must not be empty!"); + } + + @Test + void missingAdvisorType() { + assertThatThrownBy(() -> AdvisorObservationContext.builder().withAdvisorName("MyName").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("The advisorType must not be null!"); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConventionTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConventionTests.java new file mode 100644 index 00000000000..e56219f8e64 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConventionTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024 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.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.LowCardinalityKeyNames; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; + +/** + * Unit tests for {@link DefaultAdvisorObservationConvention}. + * + * @author Christian Tzolov + */ +class DefaultAdvisorObservationConventionTests { + + private final DefaultAdvisorObservationConvention observationConvention = new DefaultAdvisorObservationConvention(); + + @Test + void shouldHaveName() { + assertThat(this.observationConvention.getName()).isEqualTo(DefaultAdvisorObservationConvention.DEFAULT_NAME); + } + + @Test + void contextualName() { + AdvisorObservationContext observationContext = AdvisorObservationContext.builder() + .withAdvisorName("MyName") + .withAdvisorType(AdvisorObservationContext.Type.BEFORE) + .build(); + assertThat(this.observationConvention.getContextualName(observationContext)) + .isEqualTo("chat_client_advisor my_name_before"); + } + + @Test + void supportsAdvisorObservationContext() { + AdvisorObservationContext observationContext = AdvisorObservationContext.builder() + .withAdvisorName("MyName") + .withAdvisorType(AdvisorObservationContext.Type.BEFORE) + .build(); + assertThat(this.observationConvention.supportsContext(observationContext)).isTrue(); + assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void shouldHaveLowCardinalityKeyValuesWhenDefined() { + AdvisorObservationContext observationContext = AdvisorObservationContext.builder() + .withAdvisorName("MyName") + .withAdvisorType(AdvisorObservationContext.Type.AFTER) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains( + KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE.asString(), "AFTER"), + KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), "chat_client_advisor")); + } + + @Test + void shouldHaveKeyValuesWhenDefinedAndResponse() { + AdvisorObservationContext observationContext = AdvisorObservationContext.builder() + .withAdvisorName("MyName") + .withAdvisorType(AdvisorObservationContext.Type.AFTER) + .build(); + + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)) + .contains(KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME.asString(), "MyName")); + } + +}