GH-912: Add customizable logger advisor (#913)
* Add customizable logger advisor * Address review comments * Use the OutputCaptureExtension to verify the output text Resolves #912
This commit is contained in:
@@ -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<String> 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<String> collection = ChatClient.create(chatModel).prompt()
|
||||
.user(u -> u.text("List five {subject}")
|
||||
.param("subject", "ice cream flavors"))
|
||||
.call()
|
||||
.entity(new ParameterizedTypeReference<List<String>>() {});
|
||||
// @formatter:on
|
||||
|
||||
logger.info(collection.toString());
|
||||
assertThat(collection).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listOutputConverterBean() {
|
||||
|
||||
// @formatter:off
|
||||
List<ActorsFilms> actorsFilms = ChatClient.create(chatModel).prompt()
|
||||
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
|
||||
.call()
|
||||
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {
|
||||
});
|
||||
// @formatter:on
|
||||
|
||||
logger.info("" + actorsFilms);
|
||||
assertThat(actorsFilms).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customOutputConverter() {
|
||||
|
||||
var toStringListConverter = new ListOutputConverter(new DefaultConversionService());
|
||||
|
||||
// @formatter:off
|
||||
List<String> 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<String, Object> 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<Map<String, Object>>() {
|
||||
});
|
||||
// @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<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
|
||||
|
||||
// @formatter:off
|
||||
Flux<String> 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<String> 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<String> 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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
|
||||
@@ -408,4 +408,9 @@ public class OpenAiChatModel extends
|
||||
return OpenAiChatOptions.fromOptions(this.defaultOptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "OpenAiChatModel [defaultOptions=" + defaultOptions + "]";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String> chatResponse = ChatClient.create(chatModel)
|
||||
.prompt()
|
||||
.advisors(new SimpleLoggerAdvisor())
|
||||
.user(u -> u
|
||||
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
|
||||
+ "{format}")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
|
||||
@@ -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<AdvisedRequest, String> DEFAULT_REQUEST_TO_STRING = (request) -> {
|
||||
return request.toString();
|
||||
};
|
||||
|
||||
public static final Function<ChatResponse, String> DEFAULT_RESPONSE_TO_STRING = (response) -> {
|
||||
return ModelOptionsUtils.toJsonString(response);
|
||||
};
|
||||
|
||||
private final Function<AdvisedRequest, String> requestToString;
|
||||
|
||||
private final Function<ChatResponse, String> responseToString;
|
||||
|
||||
public SimpleLoggerAdvisor() {
|
||||
this(DEFAULT_REQUEST_TO_STRING, DEFAULT_RESPONSE_TO_STRING);
|
||||
}
|
||||
|
||||
public SimpleLoggerAdvisor(Function<AdvisedRequest, String> requestToString,
|
||||
Function<ChatResponse, String> responseToString) {
|
||||
this.requestToString = requestToString;
|
||||
this.responseToString = responseToString;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
|
||||
logger.debug("request: {}", this.requestToString.apply(request));
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> adviseResponse(Flux<ChatResponse> fluxChatResponse, Map<String, Object> context) {
|
||||
return new MessageAggregator().aggregate(fluxChatResponse,
|
||||
chatResponse -> logger.debug("stream response: {}", this.responseToString.apply(chatResponse)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
|
||||
logger.debug("response: {}", this.responseToString.apply(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return SimpleLoggerAdvisor.class.getSimpleName();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Prompt> 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<String> fluxContent) {
|
||||
return fluxContent.collectList().block().stream().collect(Collectors.joining());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
logging.level.org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor=DEBUG
|
||||
logging.level.ch.qos.logback=ERROR
|
||||
16
spring-ai-core/src/test/resources/logback.xml
Normal file
16
spring-ai-core/src/test/resources/logback.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<configuration>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="debug">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
<logger name="org.springframework.ai.chat.client.advisor" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</logger>
|
||||
|
||||
</configuration>
|
||||
@@ -425,5 +425,54 @@ public Flux<String> 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<AdvisedRequest, String> requestToString,
|
||||
Function<ChatResponse, String> 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.
|
||||
Reference in New Issue
Block a user