From dfa3ceb1ab6bcbc8d9b6ebfdc9e319e42382ac8f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 21 Jun 2024 16:26:18 +0200 Subject: [PATCH] GH-912: Add customizable logger advisor (#913) * Add customizable logger advisor * Address review comments * Use the OutputCaptureExtension to verify the output text Resolves #912 --- .../client/AnthropicChatClientIT.java | 320 ++++++++++++++++++ .../application-logging-test.properties | 1 + .../ai/openai/OpenAiChatModel.java | 5 + .../ai/openai/OpenAiChatOptions.java | 17 +- .../chat/client/OpenAiChatClientIT.java | 5 + .../application-logging-test.properties | 1 + .../client/advisor/SimpleLoggerAdvisor.java | 85 +++++ .../chat/client/SimpleLoggerAdvisorTests.java | 104 ++++++ .../application-logging-test.properties | 2 + spring-ai-core/src/test/resources/logback.xml | 16 + .../modules/ROOT/pages/api/chatclient.adoc | 49 +++ 11 files changed, 600 insertions(+), 5 deletions(-) create mode 100644 models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java create mode 100644 models/spring-ai-anthropic/src/test/resources/application-logging-test.properties create mode 100644 models/spring-ai-openai/src/test/resources/application-logging-test.properties create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/client/SimpleLoggerAdvisorTests.java create mode 100644 spring-ai-core/src/test/resources/application-logging-test.properties create mode 100644 spring-ai-core/src/test/resources/logback.xml diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java new file mode 100644 index 000000000..d325ddf34 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java @@ -0,0 +1,320 @@ +/* + * Copyright 2023 - 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.anthropic.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.AnthropicTestConfiguration; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.anthropic.api.tool.MockWeatherService; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ListOutputConverter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.MimeTypeUtils; + +import reactor.core.publisher.Flux; + +@SpringBootTest(classes = AnthropicTestConfiguration.class, properties = "spring.ai.retry.on-http-codes=429") +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") +@ActiveProfiles("logging-test") +class AnthropicChatClientIT { + + private static final Logger logger = LoggerFactory.getLogger(AnthropicChatClientIT.class); + + @Autowired + ChatModel chatModel; + + @Value("classpath:/prompts/system-message.st") + private Resource systemTextResource; + + record ActorsFilms(String actor, List movies) { + } + + @Test + void call() { + + // @formatter:off + ChatResponse response = ChatClient.create(chatModel).prompt() + .advisors(new SimpleLoggerAdvisor()) + .system(s -> s.text(systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + logger.info("" + response); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getContent()).contains("Blackbeard"); + } + + @Test + void listOutputConverterString() { + // @formatter:off + List collection = ChatClient.create(chatModel).prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(new ParameterizedTypeReference>() {}); + // @formatter:on + + logger.info(collection.toString()); + assertThat(collection).hasSize(5); + } + + @Test + void listOutputConverterBean() { + + // @formatter:off + List actorsFilms = ChatClient.create(chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference>() { + }); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + } + + @Test + void customOutputConverter() { + + var toStringListConverter = new ListOutputConverter(new DefaultConversionService()); + + // @formatter:off + List flavors = ChatClient.create(chatModel).prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(toStringListConverter); + // @formatter:on + + logger.info("ice cream flavors" + flavors); + assertThat(flavors).hasSize(5); + assertThat(flavors).containsAnyOf("Vanilla", "vanilla"); + } + + @Test + void mapOutputConverter() { + // @formatter:off + Map result = ChatClient.create(chatModel).prompt() + .user(u -> u.text("Provide me a List of {subject}") + .param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'")) + .call() + .entity(new ParameterizedTypeReference>() { + }); + // @formatter:on + + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + } + + @Test + void beanOutputConverter() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isNotBlank(); + } + + @Test + void beanOutputConverterRecords() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = ChatClient.create(chatModel) + .prompt() + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "{format}") + .param("format", outputConverter.getFormat())) + .stream() + .content(); + + String generationTextFromStream = chatResponse.collectList() + .block() + .stream() + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void functionCallTest() { + + // @formatter:off + String response = ChatClient.create(chatModel).prompt() + .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris? Use Celsius.")) + .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) + .call() + .content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(response).contains("30", "10", "15"); + } + + @Test + void defaultFunctionCallTest() { + + // @formatter:off + String response = ChatClient.builder(chatModel) + .defaultFunction("getCurrentWeather", "Get the weather in location", new MockWeatherService()) + .defaultUser(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris? Use Celsius.")) + .build() + .prompt().call().content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(response).contains("30", "10", "15"); + } + + @Disabled("SpringAI has not implemented streaming for function calls for Anthropic yet.") + @Test + void streamFunctionCallTest() { + + // @formatter:off + Flux response = ChatClient.create(chatModel).prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) + .stream() + .content(); + // @formatter:on + + String content = response.collectList().block().stream().collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307" }) + void multiModalityEmbeddedImage(String modelName) throws IOException { + + // @formatter:off + String response = ChatClient.create(chatModel).prompt() + .options(AnthropicChatOptions.builder().withModel(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.png"))) + .call() + .content(); + // @formatter:on + + logger.info(response); + assertThat(response).contains("bananas", "apple"); + assertThat(response).containsAnyOf("bowl", "basket"); + } + + @Disabled("Currently Anthropic API does not support external image URLs") + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307" }) + void multiModalityImageUrl(String modelName) throws IOException { + + // TODO: add url method that wrapps the checked exception. + URL url = new URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"); + + // @formatter:off + String response = ChatClient.create(chatModel).prompt() + // TODO consider adding model(...) method to ChatClient as a shortcut to + .options(AnthropicChatOptions.builder().withModel(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) + .call() + .content(); + // @formatter:on + + logger.info(response); + assertThat(response).contains("bananas", "apple"); + assertThat(response).containsAnyOf("bowl", "basket"); + } + + @Test + void streamingMultiModality() throws IOException { + + // @formatter:off + Flux response = ChatClient.create(chatModel).prompt() + .options(AnthropicChatOptions.builder().withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.png"))) + .stream() + .content(); + // @formatter:on + + String content = response.collectList().block().stream().collect(Collectors.joining()); + + logger.info("Response: {}", content); + assertThat(content).contains("bananas", "apple"); + assertThat(content).containsAnyOf("bowl", "basket"); + } + +} \ No newline at end of file diff --git a/models/spring-ai-anthropic/src/test/resources/application-logging-test.properties b/models/spring-ai-anthropic/src/test/resources/application-logging-test.properties new file mode 100644 index 000000000..8e8b3b2c3 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/resources/application-logging-test.properties @@ -0,0 +1 @@ +logging.level.org.springframework.ai.chat.client.advisor=DEBUG diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java index a2bc84b65..cd4162973 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java @@ -408,4 +408,9 @@ public class OpenAiChatModel extends return OpenAiChatOptions.fromOptions(this.defaultOptions); } + @Override + public String toString() { + return "OpenAiChatModel [defaultOptions=" + defaultOptions + "]"; + } + } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index d980c5c78..d6df88ba1 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -21,20 +21,22 @@ import java.util.List; import java.util.Map; import java.util.Set; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonProperty; - import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallingOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder; import org.springframework.ai.openai.api.OpenAiApi.FunctionTool; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * @author Christian Tzolov * @since 0.8.0 @@ -596,4 +598,9 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { .build(); } + @Override + public String toString() { + return "OpenAiChatOptions: " + ModelOptionsUtils.toJsonString(this); + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java index c6bee6a44..fa4ca8a0c 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java @@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.ListOutputConverter; @@ -45,12 +46,14 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import org.springframework.test.context.ActiveProfiles; import org.springframework.util.MimeTypeUtils; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(classes = OpenAiTestConfiguration.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +@ActiveProfiles("logging-test") class OpenAiChatClientIT extends AbstractIT { private static final Logger logger = LoggerFactory.getLogger(OpenAiChatClientIT.class); @@ -66,6 +69,7 @@ class OpenAiChatClientIT extends AbstractIT { // @formatter:off ChatResponse response = ChatClient.create(chatModel).prompt() + .advisors(new SimpleLoggerAdvisor()) .system(s -> s.text(systemTextResource) .param("name", "Bob") .param("voice", "pirate")) @@ -177,6 +181,7 @@ class OpenAiChatClientIT extends AbstractIT { // @formatter:off Flux chatResponse = ChatClient.create(chatModel) .prompt() + .advisors(new SimpleLoggerAdvisor()) .user(u -> u .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + "{format}") diff --git a/models/spring-ai-openai/src/test/resources/application-logging-test.properties b/models/spring-ai-openai/src/test/resources/application-logging-test.properties new file mode 100644 index 000000000..8e8b3b2c3 --- /dev/null +++ b/models/spring-ai-openai/src/test/resources/application-logging-test.properties @@ -0,0 +1 @@ +logging.level.org.springframework.ai.chat.client.advisor=DEBUG diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java new file mode 100644 index 000000000..d09141720 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/advisor/SimpleLoggerAdvisor.java @@ -0,0 +1,85 @@ +/* + * 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; + +import java.util.Map; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.AdvisedRequest; +import org.springframework.ai.chat.client.RequestResponseAdvisor; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.MessageAggregator; +import org.springframework.ai.model.ModelOptionsUtils; + +import reactor.core.publisher.Flux; + +/** + * A simple logger advisor that logs the request and response messages. + * + * @author Christian Tzolov + */ +public class SimpleLoggerAdvisor implements RequestResponseAdvisor { + + private static final Logger logger = LoggerFactory.getLogger(SimpleLoggerAdvisor.class); + + public static final Function DEFAULT_REQUEST_TO_STRING = (request) -> { + return request.toString(); + }; + + public static final Function DEFAULT_RESPONSE_TO_STRING = (response) -> { + return ModelOptionsUtils.toJsonString(response); + }; + + private final Function requestToString; + + private final Function responseToString; + + public SimpleLoggerAdvisor() { + this(DEFAULT_REQUEST_TO_STRING, DEFAULT_RESPONSE_TO_STRING); + } + + public SimpleLoggerAdvisor(Function requestToString, + Function responseToString) { + this.requestToString = requestToString; + this.responseToString = responseToString; + } + + @Override + public AdvisedRequest adviseRequest(AdvisedRequest request, Map context) { + logger.debug("request: {}", this.requestToString.apply(request)); + return request; + } + + @Override + public Flux adviseResponse(Flux fluxChatResponse, Map context) { + return new MessageAggregator().aggregate(fluxChatResponse, + chatResponse -> logger.debug("stream response: {}", this.responseToString.apply(chatResponse))); + } + + @Override + public ChatResponse adviseResponse(ChatResponse response, Map context) { + logger.debug("response: {}", this.responseToString.apply(response)); + return response; + } + + @Override + public String toString() { + return SimpleLoggerAdvisor.class.getSimpleName(); + } + +} 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/SimpleLoggerAdvisorTests.java new file mode 100644 index 000000000..270fd33b0 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/SimpleLoggerAdvisorTests.java @@ -0,0 +1,104 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.messages.Message; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.test.context.ActiveProfiles; + +import reactor.core.publisher.Flux; + +/** + * @author Christian Tzolov + */ +@ExtendWith({ MockitoExtension.class, OutputCaptureExtension.class }) +@ActiveProfiles("logging-test") +public class SimpleLoggerAdvisorTests { + + @Mock + ChatModel chatModel; + + @Captor + ArgumentCaptor promptCaptor; + + @Test + public void callLogging(CapturedOutput output) { + + when(chatModel.call(promptCaptor.capture())) + .thenReturn(new ChatResponse(List.of(new Generation("Your answer is ZXY")))); + + var loggerAdvisor = new SimpleLoggerAdvisor(); + + var chatClient = ChatClient.builder(chatModel).defaultAdvisors(loggerAdvisor).build(); + + var content = chatClient.prompt().user("Please answer my question XYZ").call().content(); + + validate(content, output); + } + + @Test + public void streamLogging(CapturedOutput output) { + + when(chatModel.stream(promptCaptor.capture())).thenReturn( + Flux.generate(() -> new ChatResponse(List.of(new Generation("Your answer is ZXY"))), (state, sink) -> { + sink.next(state); + sink.complete(); + return state; + })); + + var loggerAdvisor = new SimpleLoggerAdvisor(); + + var chatClient = ChatClient.builder(chatModel).defaultAdvisors(loggerAdvisor).build(); + + String content = join(chatClient.prompt().user("Please answer my question XYZ").stream().content()); + + validate(content, output); + } + + private void validate(String content, CapturedOutput output) { + assertThat(content).isEqualTo("Your answer is ZXY"); + + Message userMessage = promptCaptor.getValue().getInstructions().get(0); + assertThat(userMessage.getContent()).isEqualToIgnoringWhitespace("Please answer my question XYZ"); + + assertThat(output.getOut()).contains("request: AdvisedRequest", "userText=Please answer my question XYZ"); + assertThat(output.getOut()).contains("response:", "finishReason"); + } + + private String join(Flux fluxContent) { + return fluxContent.collectList().block().stream().collect(Collectors.joining()); + } + +} diff --git a/spring-ai-core/src/test/resources/application-logging-test.properties b/spring-ai-core/src/test/resources/application-logging-test.properties new file mode 100644 index 000000000..8ba46b8d7 --- /dev/null +++ b/spring-ai-core/src/test/resources/application-logging-test.properties @@ -0,0 +1,2 @@ +logging.level.org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor=DEBUG +logging.level.ch.qos.logback=ERROR diff --git a/spring-ai-core/src/test/resources/logback.xml b/spring-ai-core/src/test/resources/logback.xml new file mode 100644 index 000000000..7030fdba8 --- /dev/null +++ b/spring-ai-core/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 4645ccfec..39f2ff4d0 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -425,5 +425,54 @@ public Flux chat(String chatId, String userMessageContent) { .stream().content(); } } +---- + +=== Logging + +The `SimpleLoggerAdvisor` is an advisor that logs the `request` and `response` data of the ChatClient. +This can be useful for debugging and monitoring your AI interactions. + +To enable logging, add the `SimpleLoggerAdvisor` to the advisor chain when creating your ChatClient. +It's recommended to add it toward the end of the chain: + +[source,java] +---- +ChatResponse response = ChatClient.create(chatModel).prompt() + .advisors(new SimpleLoggerAdvisor()) + .user("Tell me a joke?") + .call() + .chatResponse(); +---- + +To see the logs, set the logging level for the advisor package to `DEBUG`: ---- +logging.level.org.springframework.ai.chat.client.advisor=DEBUG +---- + +Add this to your `application.properties` or `application.yaml` file. + + +You can customize what data from AdvisedRequest and ChatResponse is logged by using the following constructor: + +[source,java] +---- +SimpleLoggerAdvisor( + Function requestToString, + Function responseToString +) +---- + +Example usage: + +[source,java] +---- +javaCopySimpleLoggerAdvisor customLogger = new SimpleLoggerAdvisor( + request -> "Custom request: " + request.userText, + response -> "Custom response: " + response.getResult() +); +---- + +This allows you to tailor the logged information to your specific needs. + +TIP: Be cautious about logging sensitive information in production environments. \ No newline at end of file