From d74a97f4c22a1ccc60023e2762a1a1d29d420f44 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 20 Aug 2024 10:07:44 +0200 Subject: [PATCH 1/3] Initial support --- .../AdvisorObservationContext.java | 59 ++++++++++ .../AdvisorObservationConvention.java | 33 ++++++ .../AdvisorObservationDocumentation.java | 106 ++++++++++++++++++ .../DefaultAdvisorObservationConvention.java | 75 +++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContext.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationConvention.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java 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..4f9f8787ff0 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContext.java @@ -0,0 +1,59 @@ +/* +* 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; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class AdvisorObservationContext extends Observation.Context { + + public enum Type { + + BEFORE(".before"), AFTER(".after"), AROUND(".around"); + + public final String suffix; + + Type(String string) { + this.suffix = string; + } + + }; + + private String modelClassName; + + private Type type; + + public void setModelClassName(String chatModelName) { + this.modelClassName = chatModelName; + } + + public String getModelClassName() { + return this.modelClassName; + } + + public Type getType() { + return this.type; + } + + public void setType(Type type) { + this.type = type; + } + +} \ 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..a856ba8e89b --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java @@ -0,0 +1,106 @@ +/* +* 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 new KeyName[] {}; + } + + }; + + 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 "type"; + } + }, + + /** + * Client name derived from the request URI host. + */ + CLIENT_NAME { + @Override + public String asString() { + return "client.name"; + } + }, + + /** + * Name of the exception thrown during the chat model request, or + * {@value KeyValue#NONE_VALUE} if no exception happened. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + + } + + public enum HighCardinalityKeyNames implements KeyName { + + ; + + @Override + public String asString() { + throw new UnsupportedOperationException("Unimplemented method 'asString'"); + } + + } + +} \ 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..011b20b272c --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConvention.java @@ -0,0 +1,75 @@ +/* +* 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.LowCardinalityKeyNames; +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 { + + private 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 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".formatted(CHAT_CLIENT_ADVISOR_SPRING_AI_KIND, context.getModelClassName()); + } + + @Override + public KeyValues getLowCardinalityKeyValues(AdvisorObservationContext context) { + return KeyValues.of(springAiKind(), advisorType(context)); + } + + protected KeyValue advisorType(AdvisorObservationContext context) { + if (context.getType() != null) { + return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getType().name()); + } + return ADVISOR_TYPE_NONE; + } + + protected KeyValue springAiKind() { + return KeyValue.of(AdvisorObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND, + CHAT_CLIENT_ADVISOR_SPRING_AI_KIND); + } + +} \ No newline at end of file From b68faf96133dc88f82c15b6860cd53192e2a4762 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 20 Aug 2024 10:52:05 +0200 Subject: [PATCH 2/3] second --- .../observation/AdvisorObservationContext.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 4f9f8787ff0..1fdfd0a54e3 100644 --- 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 @@ -15,6 +15,8 @@ */ package org.springframework.ai.chat.client.advisor.observation; +import org.springframework.ai.chat.client.AdvisedRequest; + import io.micrometer.observation.Observation; /** @@ -40,6 +42,8 @@ public enum Type { private Type type; + private AdvisedRequest avisorRequest; + public void setModelClassName(String chatModelName) { this.modelClassName = chatModelName; } @@ -56,4 +60,11 @@ public void setType(Type type) { this.type = type; } + public AdvisedRequest getAdvisedRequest() { + return this.avisorRequest; + } + + public void setAdvisedRequest(AdvisedRequest advisedRequest) { + this.avisorRequest = advisedRequest; + } } \ No newline at end of file From 7a1ff6c1ffd6ca7dd644dcf42e5454de85d8ad94 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 21 Aug 2024 11:49:58 +0200 Subject: [PATCH 3/3] Add advisor context, convetions and documentation Add ObservableRequestResponseAdvisor wrapper. Add Tests. --- .../ai/chat/client/DefaultChatClient.java | 21 ++- .../client/advisor/QuestionAnswerAdvisor.java | 1 - .../AdvisorObservationContext.java | 107 ++++++++++++--- .../AdvisorObservationDocumentation.java | 38 ++---- .../DefaultAdvisorObservationConvention.java | 42 +++++- .../ObservableRequestResponseAdvisor.java | 123 ++++++++++++++++++ .../springframework/ai/util/ParsingUtils.java | 94 +++++++++++++ .../QuestionAnswerAdvisorTests.java | 4 +- .../SimpleLoggerAdvisorTests.java | 5 +- .../AdvisorObservationContextTests.java | 55 ++++++++ ...aultAdvisorObservationConventionTests.java | 83 ++++++++++++ 11 files changed, 512 insertions(+), 61 deletions(-) create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/ObservableRequestResponseAdvisor.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/util/ParsingUtils.java rename spring-ai-core/src/test/java/org/springframework/ai/chat/client/{ => advisor}/QuestionAnswerAdvisorTests.java (97%) rename spring-ai-core/src/test/java/org/springframework/ai/chat/client/{ => advisor}/SimpleLoggerAdvisorTests.java (95%) create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationContextTests.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/client/advisor/observation/DefaultAdvisorObservationConventionTests.java 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 index 1fdfd0a54e3..499362e7bee 100644 --- 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 @@ -15,7 +15,12 @@ */ 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; @@ -28,36 +33,46 @@ public class AdvisorObservationContext extends Observation.Context { public enum Type { - BEFORE(".before"), AFTER(".after"), AROUND(".around"); - - public final String suffix; - - Type(String string) { - this.suffix = string; - } + BEFORE, AFTER, AROUND; }; - private String modelClassName; + private String advisorName; - private Type type; + 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; - public void setModelClassName(String chatModelName) { - this.modelClassName = chatModelName; + /** + * 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 getModelClassName() { - return this.modelClassName; + public String getAdvisorName() { + return this.advisorName; } - public Type getType() { - return this.type; + public Type getAdvisorType() { + return this.advisorType; } - public void setType(Type type) { - this.type = type; + public void setAdvisorType(Type type) { + this.advisorType = type; } public AdvisedRequest getAdvisedRequest() { @@ -67,4 +82,62 @@ public AdvisedRequest getAdvisedRequest() { 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/AdvisorObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/observation/AdvisorObservationDocumentation.java index a856ba8e89b..5bb022b0879 100644 --- 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 @@ -42,7 +42,7 @@ public KeyName[] getLowCardinalityKeyNames() { @Override public KeyName[] getHighCardinalityKeyNames() { - return new KeyName[] {}; + return HighCardinalityKeyNames.values(); } }; @@ -65,41 +65,23 @@ public String asString() { ADVISOR_TYPE { @Override public String asString() { - return "type"; + return "spring.ai.chat.client.advisor.type"; } - }, + }; - /** - * Client name derived from the request URI host. - */ - CLIENT_NAME { - @Override - public String asString() { - return "client.name"; - } - }, + } + + public enum HighCardinalityKeyNames implements KeyName { /** - * Name of the exception thrown during the chat model request, or - * {@value KeyValue#NONE_VALUE} if no exception happened. + * Chat Model name. */ - EXCEPTION { + ADVISOR_NAME { @Override public String asString() { - return "exception"; + return "spring.ai.chat.client.advisor.name"; } - }, - - } - - public enum HighCardinalityKeyNames implements KeyName { - - ; - - @Override - public String asString() { - throw new UnsupportedOperationException("Unimplemented method 'asString'"); - } + }; } 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 index 011b20b272c..e6babe01891 100644 --- 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 @@ -15,7 +15,9 @@ */ 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; @@ -28,11 +30,15 @@ public class DefaultAdvisorObservationConvention implements AdvisorObservationConvention { - private static final String DEFAULT_NAME = "spring.ai.chat.client.advisor"; + 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 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_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; @@ -52,24 +58,46 @@ public String getName() { @Override @Nullable public String getContextualName(AdvisorObservationContext context) { - return "%s %s".formatted(CHAT_CLIENT_ADVISOR_SPRING_AI_KIND, context.getModelClassName()); + 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.getType() != null) { - return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getType().name()); + if (context.getAdvisorType() != null) { + return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name()); } return ADVISOR_TYPE_NONE; } - protected KeyValue springAiKind() { + 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")); + } + +}