Configure TemplateRenderer in ChatClient

- Extend the ChatClient with a new templateRenderer() method to pass a custom TemplateRenderer object used to render user and system templates.
- Evolve the QuestionAnswerAdvisor to accept a PromptTemplate for customising the RAG prompt and templating logic while maintaining backward compatibility.
- Introduce integration tests for the QuestionAnswerAdvisor.
- Document the TemplateRenderer API and how to use it to build PromptTemplate with custom templating logic.
- Document how to customise the templating logic used internally by the ChatClient via the TemplateRendererAPI.

Add validation tests and improve PromptTemplate resource handling

Enhance robustness and reliability of the PromptTemplate class with better
resource handling and comprehensive input validation:

- Add dedicated validation tests for builder methods with null/invalid inputs
- Improve renderResource method to gracefully handle edge cases:
  - Null resources return empty string
  - ByteArrayResource handling with proper charset (UTF-8)
  - Empty resources check with proper existence test
  - Better error handling with logging instead of exception propagation
- Add input validation assertions to all Builder methods
- Fix typo in deprecated annotation comment ("fahvor" → "favor")

Update documentation to clarify template rendering in different contexts:
- Add clear notes about TemplateRenderer usage in ChatClient vs Advisors
- Document how advisor template customization differs from ChatClient template rendering
- Add comprehensive API upgrade notes for template-related deprecations
- Include detailed migration examples for PromptTemplate and QuestionAnswerAdvisor

Fixes gh-355, gh-1687, gh-2448, gh-1849, gh-1428

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-04-28 23:47:58 +02:00
committed by Mark Pollack
parent b0d671944a
commit 5527d037f2
20 changed files with 1005 additions and 100 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -39,6 +39,7 @@ import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionTextParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -49,6 +50,7 @@ import org.springframework.util.StringUtils;
* @author Christian Tzolov
* @author Timo Salm
* @author Ilayaperumal Gopinathan
* @author Thomas Vitale
* @since 1.0.0
*/
public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
@@ -57,7 +59,7 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
public static final String FILTER_EXPRESSION = "qa_filter_expression";
private static final String DEFAULT_USER_TEXT_ADVISE = """
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Context information is below, surrounded by ---------------------
@@ -68,13 +70,13 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.
""";
""");
private static final int DEFAULT_ORDER = 0;
private final VectorStore vectorStore;
private final String userTextAdvise;
private final PromptTemplate promptTemplate;
private final SearchRequest searchRequest;
@@ -88,7 +90,7 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
* @param vectorStore The vector store to use
*/
public QuestionAnswerAdvisor(VectorStore vectorStore) {
this(vectorStore, SearchRequest.builder().build(), DEFAULT_USER_TEXT_ADVISE);
this(vectorStore, SearchRequest.builder().build(), DEFAULT_PROMPT_TEMPLATE, true, DEFAULT_ORDER);
}
/**
@@ -97,9 +99,11 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
* @param vectorStore The vector store to use
* @param searchRequest The search request defined using the portable filter
* expression syntax
* @deprecated in favor of the builder: {@link #builder(VectorStore)}
*/
@Deprecated
public QuestionAnswerAdvisor(VectorStore vectorStore, SearchRequest searchRequest) {
this(vectorStore, searchRequest, DEFAULT_USER_TEXT_ADVISE);
this(vectorStore, searchRequest, DEFAULT_PROMPT_TEMPLATE, true, DEFAULT_ORDER);
}
/**
@@ -110,9 +114,12 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
* expression syntax
* @param userTextAdvise The user text to append to the existing user prompt. The text
* should contain a placeholder named "question_answer_context".
* @deprecated in favor of the builder: {@link #builder(VectorStore)}
*/
@Deprecated
public QuestionAnswerAdvisor(VectorStore vectorStore, SearchRequest searchRequest, String userTextAdvise) {
this(vectorStore, searchRequest, userTextAdvise, true);
this(vectorStore, searchRequest, PromptTemplate.builder().template(userTextAdvise).build(), true,
DEFAULT_ORDER);
}
/**
@@ -127,10 +134,13 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
* blocking threads. If false the advisor will not protect the execution from blocking
* threads. This is useful when the advisor is used in a non-blocking environment. It
* is true by default.
* @deprecated in favor of the builder: {@link #builder(VectorStore)}
*/
@Deprecated
public QuestionAnswerAdvisor(VectorStore vectorStore, SearchRequest searchRequest, String userTextAdvise,
boolean protectFromBlocking) {
this(vectorStore, searchRequest, userTextAdvise, protectFromBlocking, DEFAULT_ORDER);
this(vectorStore, searchRequest, PromptTemplate.builder().template(userTextAdvise).build(), protectFromBlocking,
DEFAULT_ORDER);
}
/**
@@ -146,17 +156,23 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
* threads. This is useful when the advisor is used in a non-blocking environment. It
* is true by default.
* @param order The order of the advisor.
* @deprecated in favor of the builder: {@link #builder(VectorStore)}
*/
@Deprecated
public QuestionAnswerAdvisor(VectorStore vectorStore, SearchRequest searchRequest, String userTextAdvise,
boolean protectFromBlocking, int order) {
this(vectorStore, searchRequest, PromptTemplate.builder().template(userTextAdvise).build(), protectFromBlocking,
order);
}
Assert.notNull(vectorStore, "The vectorStore must not be null!");
Assert.notNull(searchRequest, "The searchRequest must not be null!");
Assert.hasText(userTextAdvise, "The userTextAdvise must not be empty!");
QuestionAnswerAdvisor(VectorStore vectorStore, SearchRequest searchRequest, @Nullable PromptTemplate promptTemplate,
boolean protectFromBlocking, int order) {
Assert.notNull(vectorStore, "vectorStore cannot be null");
Assert.notNull(searchRequest, "searchRequest cannot be null");
this.vectorStore = vectorStore;
this.searchRequest = searchRequest;
this.userTextAdvise = userTextAdvise;
this.promptTemplate = promptTemplate != null ? promptTemplate : DEFAULT_PROMPT_TEMPLATE;
this.protectFromBlocking = protectFromBlocking;
this.order = order;
}
@@ -212,32 +228,30 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
var context = new HashMap<>(request.adviseContext());
// 1. Advise the system text.
String advisedUserText = request.userText() + System.lineSeparator() + this.userTextAdvise;
// 2. Search for similar documents in the vector store.
String query = new PromptTemplate(request.userText(), request.userParams()).render();
// 1. Search for similar documents in the vector store.
var searchRequestToUse = SearchRequest.from(this.searchRequest)
.query(query)
.query(request.userText())
.filterExpression(doGetFilterExpression(context))
.build();
List<Document> documents = this.vectorStore.similaritySearch(searchRequestToUse);
// 3. Create the context from the documents.
// 2. Create the context from the documents.
context.put(RETRIEVED_DOCUMENTS, documents);
String documentContext = documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
// 4. Advise the user parameters.
Map<String, Object> advisedUserParams = new HashMap<>(request.userParams());
advisedUserParams.put("question_answer_context", documentContext);
// 3. Augment the user prompt with the document context.
String augmentedUserText = this.promptTemplate.mutate()
.template(request.userText() + System.lineSeparator() + this.promptTemplate.getTemplate())
.variables(Map.of("question_answer_context", documentContext))
.build()
.render();
AdvisedRequest advisedRequest = AdvisedRequest.from(request)
.userText(advisedUserText)
.userParams(advisedUserParams)
.userText(augmentedUserText)
.adviseContext(context)
.build();
@@ -266,7 +280,7 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
private SearchRequest searchRequest = SearchRequest.builder().build();
private String userTextAdvise = DEFAULT_USER_TEXT_ADVISE;
private PromptTemplate promptTemplate;
private boolean protectFromBlocking = true;
@@ -277,15 +291,25 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
this.vectorStore = vectorStore;
}
public Builder promptTemplate(PromptTemplate promptTemplate) {
Assert.notNull(promptTemplate, "promptTemplate cannot be null");
this.promptTemplate = promptTemplate;
return this;
}
public Builder searchRequest(SearchRequest searchRequest) {
Assert.notNull(searchRequest, "The searchRequest must not be null!");
this.searchRequest = searchRequest;
return this;
}
/**
* @deprecated in favour of {@link #promptTemplate(PromptTemplate)}
*/
@Deprecated
public Builder userTextAdvise(String userTextAdvise) {
Assert.hasText(userTextAdvise, "The userTextAdvise must not be empty!");
this.userTextAdvise = userTextAdvise;
this.promptTemplate = PromptTemplate.builder().template(userTextAdvise).build();
return this;
}
@@ -300,7 +324,7 @@ public class QuestionAnswerAdvisor implements CallAroundAdvisor, StreamAroundAdv
}
public QuestionAnswerAdvisor build() {
return new QuestionAnswerAdvisor(this.vectorStore, this.searchRequest, this.userTextAdvise,
return new QuestionAnswerAdvisor(this.vectorStore, this.searchRequest, this.promptTemplate,
this.protectFromBlocking, this.order);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -43,6 +43,7 @@ import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters;
import org.springframework.ai.openai.api.tool.MockWeatherService;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.test.CurlyBracketEscaper;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.beans.factory.annotation.Value;
@@ -378,6 +379,124 @@ class OpenAiChatClientIT extends AbstractIT {
logger.info("Response: " + response);
}
@Test
void customTemplateRendererWithCall() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
String result = ChatClient.create(this.chatModel).prompt()
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
// @formatter:on
assertThat(result).isNotEmpty();
ActorsFilms actorsFilms = outputConverter.convert(result);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void customTemplateRendererWithCallAndAdvisor() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
String result = ChatClient.create(this.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()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
// @formatter:on
assertThat(result).isNotEmpty();
ActorsFilms actorsFilms = outputConverter.convert(result);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void customTemplateRendererWithStream() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
Flux<ChatResponse> chatResponse = ChatClient.create(this.chatModel)
.prompt()
.options(OpenAiChatOptions.builder().streamUsage(true).build())
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.stream()
.chatResponse();
List<ChatResponse> chatResponses = chatResponse.collectList()
.block()
.stream()
.toList();
String generationTextFromStream = chatResponses
.stream()
.filter(cr -> cr.getResult() != null)
.map(cr -> cr.getResult().getOutput().getText())
.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 customTemplateRendererWithStreamAndAdvisor() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
Flux<ChatResponse> chatResponse = ChatClient.create(this.chatModel)
.prompt()
.options(OpenAiChatOptions.builder().streamUsage(true).build())
.advisors(new SimpleLoggerAdvisor())
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.stream()
.chatResponse();
List<ChatResponse> chatResponses = chatResponse.collectList()
.block()
.stream()
.toList();
String generationTextFromStream = chatResponses
.stream()
.filter(cr -> cr.getResult() != null)
.map(cr -> cr.getResult().getOutput().getText())
.collect(Collectors.joining());
// @formatter:on
ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
record ActorsFilms(String actor, List<String> movies) {
}

View File

@@ -34,6 +34,7 @@ import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.converter.StructuredOutputConverter;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.core.ParameterizedTypeReference;
@@ -247,6 +248,8 @@ public interface ChatClient {
ChatClientRequestSpec user(Consumer<PromptUserSpec> consumer);
ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer);
CallResponseSpec call();
StreamResponseSpec stream();
@@ -282,6 +285,8 @@ public interface ChatClient {
Builder defaultSystem(Consumer<PromptSystemSpec> systemSpecConsumer);
Builder defaultTemplateRenderer(TemplateRenderer templateRenderer);
Builder defaultTools(String... toolNames);
Builder defaultTools(ToolCallback... toolCallbacks);

View File

@@ -57,6 +57,8 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.StructuredOutputConverter;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.ToolCallbacks;
@@ -86,6 +88,8 @@ public class DefaultChatClient implements ChatClient {
private static final ChatClientObservationConvention DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION = new DefaultChatClientObservationConvention();
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
private final DefaultChatClientRequestSpec defaultChatClientRequest;
public DefaultChatClient(DefaultChatClientRequestSpec defaultChatClientRequest) {
@@ -136,7 +140,7 @@ public class DefaultChatClient implements ChatClient {
advisedRequest.toolCallbacks(), advisedRequest.messages(), advisedRequest.toolNames(),
advisedRequest.media(), advisedRequest.chatOptions(), advisedRequest.advisors(),
advisedRequest.advisorParams(), observationRegistry, customObservationConvention,
advisedRequest.toolContext());
advisedRequest.toolContext(), null);
}
@Override
@@ -638,6 +642,8 @@ public class DefaultChatClient implements ChatClient {
private final Map<String, Object> toolContext = new HashMap<>();
private TemplateRenderer templateRenderer;
@Nullable
private String userText;
@@ -651,7 +657,7 @@ public class DefaultChatClient implements ChatClient {
DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) {
this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.systemText, ccr.systemParams, ccr.toolCallbacks,
ccr.messages, ccr.toolNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams,
ccr.observationRegistry, ccr.observationConvention, ccr.toolContext);
ccr.observationRegistry, ccr.observationConvention, ccr.toolContext, ccr.templateRenderer);
}
public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userText,
@@ -659,7 +665,8 @@ public class DefaultChatClient implements ChatClient {
List<ToolCallback> toolCallbacks, List<Message> messages, List<String> toolNames, List<Media> media,
@Nullable ChatOptions chatOptions, List<Advisor> advisors, Map<String, Object> advisorParams,
ObservationRegistry observationRegistry,
@Nullable ChatClientObservationConvention observationConvention, Map<String, Object> toolContext) {
@Nullable ChatClientObservationConvention observationConvention, Map<String, Object> toolContext,
@Nullable TemplateRenderer templateRenderer) {
Assert.notNull(chatModel, "chatModel cannot be null");
Assert.notNull(userParams, "userParams cannot be null");
@@ -692,6 +699,7 @@ public class DefaultChatClient implements ChatClient {
this.observationConvention = observationConvention != null ? observationConvention
: DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION;
this.toolContext.putAll(toolContext);
this.templateRenderer = templateRenderer != null ? templateRenderer : DEFAULT_TEMPLATE_RENDERER;
}
private ObservationRegistry getObservationRegistry() {
@@ -945,16 +953,22 @@ public class DefaultChatClient implements ChatClient {
return this;
}
public ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer) {
Assert.notNull(templateRenderer, "templateRenderer cannot be null");
this.templateRenderer = templateRenderer;
return this;
}
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
observationRegistry, observationConvention);
return new DefaultCallResponseSpec(toAdvisedRequest(this).toChatClientRequest(this.templateRenderer),
advisorChain, observationRegistry, observationConvention);
}
public StreamResponseSpec stream() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultStreamResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
observationRegistry, observationConvention);
return new DefaultStreamResponseSpec(toAdvisedRequest(this).toChatClientRequest(this.templateRenderer),
advisorChain, observationRegistry, observationConvention);
}
private BaseAdvisorChain buildAdvisorChain() {
@@ -963,7 +977,10 @@ public class DefaultChatClient implements ChatClient {
this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());
return DefaultAroundAdvisorChain.builder(this.observationRegistry).pushAll(this.advisors).build();
return DefaultAroundAdvisorChain.builder(this.observationRegistry)
.pushAll(this.advisors)
.templateRenderer(this.templateRenderer)
.build();
}
}

View File

@@ -33,6 +33,7 @@ import org.springframework.ai.chat.client.observation.ChatClientObservationConve
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.function.FunctionToolCallback;
@@ -66,7 +67,7 @@ public class DefaultChatClientBuilder implements Builder {
Assert.notNull(observationRegistry, "the " + ObservationRegistry.class.getName() + " must be non-null");
this.defaultRequest = new DefaultChatClientRequestSpec(chatModel, null, Map.of(), null, Map.of(), List.of(),
List.of(), List.of(), List.of(), null, List.of(), Map.of(), observationRegistry,
customObservationConvention, Map.of());
customObservationConvention, Map.of(), null);
}
public ChatClient build() {
@@ -190,6 +191,12 @@ public class DefaultChatClientBuilder implements Builder {
return this;
}
public Builder defaultTemplateRenderer(TemplateRenderer templateRenderer) {
Assert.notNull(templateRenderer, "templateRenderer cannot be null");
this.defaultRequest.templateRenderer(templateRenderer);
return this;
}
void addMessages(List<Message> messages) {
this.defaultRequest.messages(messages);
}

View File

@@ -33,6 +33,9 @@ import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAroundAdvisor;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationContext;
@@ -57,6 +60,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention();
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
private final List<CallAroundAdvisor> originalCallAdvisors;
private final List<StreamAroundAdvisor> originalStreamAdvisors;
@@ -67,14 +72,17 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
private final ObservationRegistry observationRegistry;
DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, Deque<CallAroundAdvisor> callAroundAdvisors,
Deque<StreamAroundAdvisor> streamAroundAdvisors) {
private final TemplateRenderer templateRenderer;
DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, @Nullable TemplateRenderer templateRenderer,
Deque<CallAroundAdvisor> callAroundAdvisors, Deque<StreamAroundAdvisor> streamAroundAdvisors) {
Assert.notNull(observationRegistry, "the observationRegistry must be non-null");
Assert.notNull(callAroundAdvisors, "the callAroundAdvisors must be non-null");
Assert.notNull(streamAroundAdvisors, "the streamAroundAdvisors must be non-null");
this.observationRegistry = observationRegistry;
this.templateRenderer = templateRenderer != null ? templateRenderer : DEFAULT_TEMPLATE_RENDERER;
this.callAroundAdvisors = callAroundAdvisors;
this.streamAroundAdvisors = streamAroundAdvisors;
this.originalCallAdvisors = List.copyOf(callAroundAdvisors);
@@ -85,6 +93,11 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
return new Builder(observationRegistry);
}
@Override
public TemplateRenderer getTemplateRenderer() {
return this.templateRenderer;
}
@Override
public ChatClientResponse nextCall(ChatClientRequest chatClientRequest) {
Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
@@ -131,7 +144,7 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
var observationContext = AdvisorObservationContext.builder()
.advisorName(advisor.getName())
.chatClientRequest(advisedRequest.toChatClientRequest())
.chatClientRequest(advisedRequest.toChatClientRequest(templateRenderer))
.order(advisor.getOrder())
.build();
@@ -140,8 +153,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
.observe(() -> {
// Supports both deprecated and new API.
if (advisor instanceof CallAdvisor callAdvisor) {
ChatClientResponse chatClientResponse = callAdvisor.adviseCall(advisedRequest.toChatClientRequest(),
this);
ChatClientResponse chatClientResponse = callAdvisor
.adviseCall(advisedRequest.toChatClientRequest(templateRenderer), this);
return AdvisedResponse.from(chatClientResponse);
}
AdvisedResponse advisedResponse = advisor.aroundCall(advisedRequest, this);
@@ -209,7 +222,7 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
AdvisorObservationContext observationContext = AdvisorObservationContext.builder()
.advisorName(advisor.getName())
.chatClientRequest(advisedRequest.toChatClientRequest())
.chatClientRequest(advisedRequest.toChatClientRequest(templateRenderer))
.order(advisor.getOrder())
.build();
@@ -222,7 +235,7 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
return Flux.defer(() -> {
// Supports both deprecated and new API.
if (advisor instanceof StreamAdvisor streamAdvisor) {
return streamAdvisor.adviseStream(advisedRequest.toChatClientRequest(), this)
return streamAdvisor.adviseStream(advisedRequest.toChatClientRequest(templateRenderer), this)
.doOnError(observation::error)
.doFinally(s -> observation.stop())
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation))
@@ -261,12 +274,19 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
private final Deque<StreamAroundAdvisor> streamAroundAdvisors;
private TemplateRenderer templateRenderer;
public Builder(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
this.callAroundAdvisors = new ConcurrentLinkedDeque<>();
this.streamAroundAdvisors = new ConcurrentLinkedDeque<>();
}
public Builder templateRenderer(TemplateRenderer templateRenderer) {
this.templateRenderer = templateRenderer;
return this;
}
public Builder push(Advisor advisor) {
Assert.notNull(advisor, "the advisor must be non-null");
return this.pushAll(List.of(advisor));
@@ -315,8 +335,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
}
public DefaultAroundAdvisorChain build() {
return new DefaultAroundAdvisorChain(this.observationRegistry, this.callAroundAdvisors,
this.streamAroundAdvisors);
return new DefaultAroundAdvisorChain(this.observationRegistry, this.templateRenderer,
this.callAroundAdvisors, this.streamAroundAdvisors);
}
}

View File

@@ -37,6 +37,8 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -199,8 +201,12 @@ public record AdvisedRequest(
}
public ChatClientRequest toChatClientRequest() {
return toChatClientRequest(StTemplateRenderer.builder().build());
}
public ChatClientRequest toChatClientRequest(TemplateRenderer templateRenderer) {
return ChatClientRequest.builder()
.prompt(toPrompt())
.prompt(toPrompt(templateRenderer))
.context(this.adviseContext)
.context(ChatClientAttributes.ADVISORS.getKey(), this.advisors)
.context(ChatClientAttributes.CHAT_MODEL.getKey(), this.chatModel)
@@ -210,12 +216,21 @@ public record AdvisedRequest(
}
public Prompt toPrompt() {
return toPrompt(StTemplateRenderer.builder().build());
}
public Prompt toPrompt(TemplateRenderer templateRenderer) {
var messages = new ArrayList<>(this.messages());
String processedSystemText = this.systemText();
if (StringUtils.hasText(processedSystemText)) {
if (!CollectionUtils.isEmpty(this.systemParams())) {
processedSystemText = new PromptTemplate(processedSystemText, this.systemParams()).render();
processedSystemText = PromptTemplate.builder()
.template(processedSystemText)
.variables(this.systemParams())
.renderer(templateRenderer)
.build()
.render();
}
messages.add(new SystemMessage(processedSystemText));
}
@@ -224,7 +239,12 @@ public record AdvisedRequest(
Map<String, Object> userParams = new HashMap<>(this.userParams());
String processedUserText = this.userText();
if (!CollectionUtils.isEmpty(userParams)) {
processedUserText = new PromptTemplate(processedUserText, userParams).render();
processedUserText = PromptTemplate.builder()
.template(processedUserText)
.variables(userParams)
.renderer(templateRenderer)
.build()
.render();
}
messages.add(new UserMessage(processedUserText, this.media()));
}

View File

@@ -16,6 +16,9 @@
package org.springframework.ai.chat.client.advisor.api;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
/**
* A base interface for advisor chains that can be used to chain multiple advisors
* together, both for call and stream advisors.
@@ -25,4 +28,8 @@ package org.springframework.ai.chat.client.advisor.api;
*/
public interface BaseAdvisorChain extends CallAdvisorChain, StreamAdvisorChain {
default TemplateRenderer getTemplateRenderer() {
return StTemplateRenderer.builder().build();
}
}

View File

@@ -95,4 +95,11 @@ class DefaultChatClientBuilderTests {
.hasMessage("charset cannot be null");
}
@Test
void whenTemplateRendererIsNullThenThrows() {
DefaultChatClientBuilder builder = new DefaultChatClientBuilder(mock(ChatModel.class));
assertThatThrownBy(() -> builder.defaultTemplateRenderer(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("templateRenderer cannot be null");
}
}

View File

@@ -1302,7 +1302,7 @@ class DefaultChatClientTests {
ChatModel chatModel = mock(ChatModel.class);
DefaultChatClient.DefaultChatClientRequestSpec spec = new DefaultChatClient.DefaultChatClientRequestSpec(
chatModel, null, Map.of(), null, Map.of(), List.of(), List.of(), List.of(), List.of(), null, List.of(),
Map.of(), ObservationRegistry.NOOP, null, Map.of());
Map.of(), ObservationRegistry.NOOP, null, Map.of(), null);
assertThat(spec).isNotNull();
}
@@ -1310,7 +1310,7 @@ class DefaultChatClientTests {
void whenChatModelIsNullThenThrow() {
assertThatThrownBy(() -> new DefaultChatClient.DefaultChatClientRequestSpec(null, null, Map.of(), null,
Map.of(), List.of(), List.of(), List.of(), List.of(), null, List.of(), Map.of(),
ObservationRegistry.NOOP, null, Map.of()))
ObservationRegistry.NOOP, null, Map.of(), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatModel cannot be null");
}
@@ -1319,7 +1319,7 @@ class DefaultChatClientTests {
void whenObservationRegistryIsNullThenThrow() {
assertThatThrownBy(() -> new DefaultChatClient.DefaultChatClientRequestSpec(mock(ChatModel.class), null,
Map.of(), null, Map.of(), List.of(), List.of(), List.of(), List.of(), null, List.of(), Map.of(), null,
null, Map.of()))
null, Map.of(), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("observationRegistry cannot be null");
}

View File

@@ -30,6 +30,8 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.tool.ToolCallback;
import static org.assertj.core.api.Assertions.assertThat;
@@ -157,12 +159,12 @@ class AdvisedRequestTests {
}
@Test
void whenConvertToAndFromChatClientRequest() {
void whenConvertToAndFromChatClientRequestWithDefaultTemplateRenderer() {
ChatModel chatModel = mock(ChatModel.class);
ChatOptions chatOptions = ToolCallingChatOptions.builder().build();
List<Message> messages = List.of(mock(UserMessage.class));
SystemMessage systemMessage = new SystemMessage("Instructions {key}");
UserMessage userMessage = new UserMessage("Question {key}", mock(Media.class));
UserMessage userMessage = UserMessage.builder().text("Question {key}").media(mock(Media.class)).build();
Map<String, Object> systemParams = Map.of("key", "value");
Map<String, Object> userParams = Map.of("key", "value");
List<String> toolNames = List.of("tool1", "tool2");
@@ -208,6 +210,70 @@ class AdvisedRequestTests {
AdvisedRequest convertedAdvisedRequest = AdvisedRequest.from(chatClientRequest);
assertThat(convertedAdvisedRequest.toPrompt()).isEqualTo(chatClientRequest.prompt());
assertThat(convertedAdvisedRequest.adviseContext()).containsAllEntriesOf(chatClientRequest.context());
assertThat(chatClientRequest.context().get(ChatClientAttributes.USER_PARAMS.getKey())).isEqualTo(userParams);
assertThat(chatClientRequest.context().get(ChatClientAttributes.SYSTEM_PARAMS.getKey()))
.isEqualTo(systemParams);
}
@Test
void whenConvertToAndFromChatClientRequestWithCustomTemplateRenderer() {
ChatModel chatModel = mock(ChatModel.class);
ChatOptions chatOptions = ToolCallingChatOptions.builder().build();
SystemMessage systemMessage = new SystemMessage("Instructions <name>");
UserMessage userMessage = UserMessage.builder().text("Question <name>").media(mock(Media.class)).build();
Map<String, Object> systemParams = Map.of("name", "Spring AI");
Map<String, Object> userParams = Map.of("name", "Spring AI");
AdvisedRequest advisedRequest = AdvisedRequest.builder()
.chatModel(chatModel)
.chatOptions(chatOptions)
.systemText(systemMessage.getText())
.systemParams(systemParams)
.userText(userMessage.getText())
.userParams(userParams)
.media(userMessage.getMedia())
.build();
TemplateRenderer customRenderer = StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build();
ChatClientRequest chatClientRequest = advisedRequest.toChatClientRequest(customRenderer);
assertThat(chatClientRequest.prompt().getInstructions()).hasSize(2);
assertThat(chatClientRequest.prompt().getInstructions().get(0)).isInstanceOf(SystemMessage.class);
assertThat(chatClientRequest.prompt().getInstructions().get(1)).isInstanceOf(UserMessage.class);
assertThat(chatClientRequest.context().get(ChatClientAttributes.USER_PARAMS.getKey())).isEqualTo(userParams);
assertThat(chatClientRequest.context().get(ChatClientAttributes.SYSTEM_PARAMS.getKey()))
.isEqualTo(systemParams);
}
@Test
void whenUsingToPromptWithCustomTemplateRenderer() {
ChatModel chatModel = mock(ChatModel.class);
SystemMessage systemMessage = new SystemMessage("Instructions <name>");
UserMessage userMessage = UserMessage.builder().text("Question <name>").media(mock(Media.class)).build();
Map<String, Object> systemParams = Map.of("name", "Spring AI");
Map<String, Object> userParams = Map.of("name", "Spring AI");
AdvisedRequest advisedRequest = AdvisedRequest.builder()
.chatModel(chatModel)
.systemText(systemMessage.getText())
.systemParams(systemParams)
.userText(userMessage.getText())
.userParams(userParams)
.media(userMessage.getMedia())
.build();
TemplateRenderer customRenderer = StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build();
var prompt = advisedRequest.toPrompt(customRenderer);
assertThat(prompt.getInstructions()).hasSize(2);
assertThat(prompt.getInstructions().get(0).getText()).isEqualTo("Instructions Spring AI");
assertThat(prompt.getInstructions().get(1).getText()).isEqualTo("Question Spring AI");
}
}

View File

@@ -165,6 +165,39 @@ String content = this.flux.collectList().block().stream().collect(Collectors.joi
List<ActorFilms> actorFilms = this.converter.convert(this.content);
----
== Prompt Templates
The `ChatClient` fluent API lets you provide user and system text as templates with variables that are replaced at runtime.
[source,java]
----
String answer = ChatClient.create(chatModel).prompt()
.user(u -> u
.text("Tell me the names of 5 movies whose soundtrack was composed by {composer}")
.param("composer", "John Williams"))
.call()
.content();
----
Internally, the ChatClient uses the `PromptTemplate` class to handle the user and system text and replace the variables with the values provided at runtime relying on a given `TemplateRenderer` implementation. By default, Spring AI uses the `StTemplateRenderer` implementation, which is based on the open-source https://www.stringtemplate.org/[StringTemplate] engine developed by Terence Parr.
NOTE: The `TemplateRenderer` configured directly on the `ChatClient` (via `.templateRenderer()`) applies only to the prompt content defined directly in the `ChatClient` builder chain (e.g., via `.user()`, `.system()`). It does *not* affect templates used internally by xref:api/retrieval-augmented-generation.adoc#_questionansweradvisor[Advisors] like `QuestionAnswerAdvisor`, which have their own template customization mechanisms (see xref:api/retrieval-augmented-generation.adoc#_custom_template[Custom Advisor Templates]).
If you'd rather use a different template engine, you can provide a custom implementation of the `TemplateRenderer` interface directly to the ChatClient. You can also keep using the default `StTemplateRenderer`, but with a custom configuration.
For example, by default, template variables are identified by the `{}` syntax. If you're planning to include JSON in your prompt, you might want to use a different syntax to avoid conflicts with JSON syntax. For example, you can use the `<` and `>` delimiters.
[source,java]
----
String answer = ChatClient.create(chatModel).prompt()
.user(u -> u
.text("Tell me the names of 5 movies whose soundtrack was composed by <composer>")
.param("composer", "John Williams"))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
----
== call() return values
After specifying the `call()` method on `ChatClient`, there are a few different options for the response type.

View File

@@ -20,7 +20,6 @@ Initially, prompts were simple strings.
Over time, they grew to include placeholders for specific inputs, like "USER:", which the AI model recognizes.
OpenAI have introduced even more structure to prompts by categorizing multiple message strings into distinct roles before they are processed by the AI model.
== API Overview
=== Prompt
@@ -34,14 +33,15 @@ This arrangement enables intricate and detailed interactions with AI models, as
Below is a truncated version of the Prompt class, with constructors and utility methods omitted for brevity:
```java
[source,java]
----
public class Prompt implements ModelRequest<List<Message>> {
private final List<Message> messages;
private ChatOptions chatOptions;
}
```
----
=== Message
@@ -49,7 +49,8 @@ The `Message` interface encapsulates a `Prompt` textual content, a collection of
The interface is defined as follows:
```java
[source,java]
----
public interface Content {
String getContent();
@@ -61,17 +62,18 @@ public interface Message extends Content {
MessageType getMessageType();
}
```
----
The multimodal message types implement also the `MediaContent` interface providing a list of `Media` content objects.
```java
[source,java]
----
public interface MediaContent extends Content {
Collection<Media> getMedia();
}
```
----
Various implementations of the `Message` interface correspond to different categories of messages that an AI model can process.
The Models distinguish between message categories based on conversational roles.
@@ -99,7 +101,8 @@ It's like a special feature in the AI, used when needed to perform specific func
Roles are represented as an enumeration in Spring AI as shown below
```java
[source,java]
----
public enum MessageType {
USER("user"),
@@ -112,20 +115,31 @@ public enum MessageType {
...
}
```
----
=== PromptTemplate
A key component for prompt templating in Spring AI is the `PromptTemplate` class.
This class uses the OSS https://www.stringtemplate.org/[StringTemplate] engine, developed by Terence Parr, for constructing and managing prompts.
The `PromptTemplate` class is designed to facilitate the creation of structured prompts that are then sent to the AI model for processing
A key component for prompt templating in Spring AI is the `PromptTemplate` class, designed to facilitate the creation of structured prompts that are then sent to the AI model for processing
```java
[source,java]
----
public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {
// Other methods to be discussed later
}
```
----
This class uses the `TemplateRenderer` API to render templates. By default, Spring AI uses the `StTemplateRenderer` implementation, which is based on the open-source https://www.stringtemplate.org/[StringTemplate] engine developed by Terence Parr. Template variables are identified by the `{}` syntax, but you can configure the delimiters to use other syntax as well.
[source,java]
----
public interface TemplateRenderer extends BiFunction<String, Map<String, Object>, String> {
@Override
String apply(String template, Map<String, Object> variables);
}
----
The interfaces implemented by this class support different aspects of prompt creation:
@@ -139,7 +153,8 @@ While these interfaces might not be used extensively in many projects, they show
The implemented interfaces are
```java
[source,java]
----
public interface PromptTemplateStringActions {
String render();
@@ -147,13 +162,14 @@ public interface PromptTemplateStringActions {
String render(Map<String, Object> model);
}
```
----
The method `String render()`: Renders a prompt template into a final string format without external input, suitable for templates without placeholders or dynamic content.
The method `String render(Map<String, Object> model)`: Enhances rendering functionality to include dynamic content. It uses a `Map<String, Object>` where map keys are placeholder names in the prompt template, and values are the dynamic content to be inserted.
```java
[source,java]
----
public interface PromptTemplateMessageActions {
Message createMessage();
@@ -163,7 +179,7 @@ public interface PromptTemplateMessageActions {
Message createMessage(Map<String, Object> model);
}
```
----
The method `Message createMessage()`: Creates a `Message` object without additional data, used for static or predefined message content.
@@ -172,7 +188,8 @@ The method `Message createMessage(List<Media> mediaList)`: Creates a `Message` o
The method `Message createMessage(Map<String, Object> model)`: Extends message creation to integrate dynamic content, accepting a `Map<String, Object>` where each entry represents a placeholder in the message template and its corresponding dynamic value.
```java
[source,java]
----
public interface PromptTemplateActions extends PromptTemplateStringActions {
Prompt create();
@@ -184,7 +201,7 @@ public interface PromptTemplateActions extends PromptTemplateStringActions {
Prompt create(Map<String, Object> model, ChatOptions modelOptions);
}
```
----
The method `Prompt create()`: Generates a `Prompt` object without external data inputs, ideal for static or predefined prompts.
@@ -198,18 +215,19 @@ The method `Prompt create(Map<String, Object> model, ChatOptions modelOptions)`:
A simple example taken from the https://github.com/Azure-Samples/spring-ai-azure-workshop/blob/main/2-README-prompt-templating.md[AI Workshop on PromptTemplates] is shown below.
```java
[source,java]
----
PromptTemplate promptTemplate = new PromptTemplate("Tell me a {adjective} joke about {topic}");
Prompt prompt = promptTemplate.create(Map.of("adjective", adjective, "topic", topic));
return chatModel.call(prompt).getResult();
```
----
Another example taken from the https://github.com/Azure-Samples/spring-ai-azure-workshop/blob/main/3-README-prompt-roles.md[AI Workshop on Roles] is shown below.
```java
[source,java]
----
String userText = """
Tell me about three famous pirates from the Golden Age of Piracy and why they did.
Write at least a sentence for each pirate.
@@ -229,28 +247,47 @@ Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", name,
Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
List<Generation> response = chatModel.call(prompt).getResults();
```
----
This shows how you can build up the `Prompt` instance by using the `SystemPromptTemplate` to create a `Message` with the system role passing in placeholder values.
The message with the role `user` is then combined with the message of the role `system` to form the prompt.
The prompt is then passed to the ChatModel to get a generative response.
=== Using a custom template renderer
You can use a custom template renderer by implementing the `TemplateRenderer` interface and passing it to the `PromptTemplate` constructor. You can also keep using the default `StTemplateRenderer`, but with a custom configuration.
By default, template variables are identified by the `{}` syntax. If you're planning to include JSON in your prompt, you might want to use a different syntax to avoid conflicts with JSON syntax. For example, you can use the `<` and `>` delimiters.
[source,java]
----
PromptTemplate promptTemplate = PromptTemplate.builder()
.renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.template("""
Tell me the names of 5 movies whose soundtrack was composed by <composer>.
""")
.build();
String prompt = promptTemplate.render(Map.of("composer", "John Williams"));
----
=== Using resources instead of raw Strings
Spring AI supports the `org.springframework.core.io.Resource` abstraction, so you can put prompt data in a file that can directly be used in a `PromptTemplate`.
For example, you can define a field in your Spring managed component to retrieve the `Resource`.
```java
[source,java]
----
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;
```
----
and then pass that resource to the `SystemPromptTemplate` directly.
```java
[source,java]
----
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);
```
----
== Prompt Engineering

View File

@@ -25,8 +25,7 @@ To use the `QuestionAnswerAdvisor` or `RetrievalAugmentationAdvisor`, you need t
=== QuestionAnswerAdvisor
A vector database stores data that the AI model is unaware of.
When a user question is sent to the AI model, a `QuestionAnswerAdvisor` queries the vector database for documents related to the user question.
A vector database stores data that the AI model is unaware of. When a user question is sent to the AI model, a `QuestionAnswerAdvisor` queries the vector database for documents related to the user question.
The response from the vector database is appended to the user text to provide context for the AI model to generate a response.
@@ -42,17 +41,17 @@ ChatResponse response = ChatClient.builder(chatModel)
.chatResponse();
----
In this example, the `QuestionAnswerAdvisor` will perform a similarity search over all documents in the Vector Database.
To restrict the types of documents that are searched, the `SearchRequest` takes an SQL like filter expression that is portable across all `VectorStores`.
In this example, the `QuestionAnswerAdvisor` will perform a similarity search over all documents in the Vector Database. To restrict the types of documents that are searched, the `SearchRequest` takes an SQL like filter expression that is portable across all `VectorStores`.
This filter expression can be configured when creating the `QuestionAnswerAdvisor` and hence will always apply to all `ChatClient` requests or it can be provided at runtime per request.
This filter expression can be configured when creating the `QuestionAnswerAdvisor` and hence will always apply to all `ChatClient` requests, or it can be provided at runtime per request.
Here is how to create an instance of `QuestionAnswerAdvisor` where the threshold is `0.8` and to return the top `6` results.
[source,java]
----
var qaAdvisor = new QuestionAnswerAdvisor(this.vectorStore,
SearchRequest.builder().similarityThreshold(0.8d).topK(6).build());
var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().similarityThreshold(0.8d).topK(6).build())
.build();
----
==== Dynamic Filter Expressions
@@ -62,7 +61,9 @@ Update the `SearchRequest` filter expression at runtime using the `FILTER_EXPRES
[source,java]
----
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder().build()))
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().build())
.build())
.build();
// Update filter expression at runtime
@@ -75,6 +76,49 @@ String content = this.chatClient.prompt()
The `FILTER_EXPRESSION` parameter allows you to dynamically filter the search results based on the provided expression.
==== Custom Template
The `QuestionAnswerAdvisor` uses a default template to augment the user question with the retrieved documents. You can customize this behavior by providing your own `PromptTemplate` object via the `.promptTemplate()` builder method.
NOTE: The `PromptTemplate` provided here customizes how the advisor merges retrieved context with the user query. This is distinct from configuring a `TemplateRenderer` on the `ChatClient` itself (using `.templateRenderer()`), which affects the rendering of the initial user/system prompt content *before* the advisor runs. See xref:api/chatclient.adoc#_prompt_templates[ChatClient Prompt Templates] for more details on client-level template rendering.
The custom `PromptTemplate` can use any `TemplateRenderer` implementation (by default, it uses `StPromptTemplate` based on the https://www.stringtemplate.org/[StringTemplate] engine). The important requirement is that the template must contain a placeholder to receive the retrieved context, which the advisor provides under the key `question_answer_context`.
[source,java]
----
PromptTemplate customPromptTemplate = PromptTemplate.builder()
.renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.template("""
Context information is below.
---------------------
<question_answer_context>
---------------------
Given the context information and no prior knowledge, answer the query.
Follow these rules:
1. If the answer is not in the context, just say that you don't know.
2. Avoid statements like "Based on the context..." or "The provided information...".
""")
.build();
String question = "Where does the adventure of Anacletus and Birba take place?";
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.promptTemplate(customPromptTemplate)
.build();
String response = ChatClient.builder(chatModel).build()
.prompt(question)
.advisors(qaAdvisor)
.call()
.content();
----
NOTE: The `QuestionAnswerAdvisor.Builder.userTextAdvise()` method is deprecated in favor of using `.promptTemplate()` for more flexible customization.
=== RetrievalAugmentationAdvisor (Incubating)
Spring AI includes a xref:api/retrieval-augmented-generation.adoc#modules[library of RAG modules] that you can use to build your own RAG flows.

View File

@@ -124,6 +124,61 @@ Prompt augmentedPrompt = originalPrompt.augmentUserMessage(userMessage ->
This approach offers more control when you need to conditionally change parts of the `UserMessage` or work with its media and metadata, rather than just replacing the text content.
=== Prompt Templating and Advisors
Several classes and methods related to prompt creation and advisor customization have been deprecated in favor of more flexible approaches using the builder pattern and the `TemplateRenderer` interface.
==== PromptTemplate Deprecations
The `PromptTemplate` class has deprecated several constructors and methods related to the older `templateFormat` enum and direct variable injection:
* Constructors: `PromptTemplate(String template, Map<String, Object> variables)` and `PromptTemplate(Resource resource, Map<String, Object> variables)` are deprecated.
* Fields: `template` and `templateFormat` are deprecated.
* Methods: `getTemplateFormat()`, `getInputVariables()`, and `validate(Map<String, Object> model)` are deprecated.
*Migration:* Use the `PromptTemplate.builder()` pattern to create instances. Provide the template string via `.template()` and optionally configure a custom `TemplateRenderer` via `.renderer()`. Pass variables using `.variables()`.
[source,java]
----
// Before (Deprecated)
PromptTemplate oldTemplate = new PromptTemplate("Hello {name}", Map.of("name", "World"));
String oldRendered = oldTemplate.render(); // Variables passed at construction
// After (Using Builder)
PromptTemplate newTemplate = PromptTemplate.builder()
.template("Hello {name}")
.variables(Map.of("name", "World")) // Variables passed during builder configuration
.build();
Prompt prompt = newTemplate.create(); // Create prompt using baked-in variables
String newRendered = prompt.getContents(); // Or use newTemplate.render()
----
==== QuestionAnswerAdvisor Deprecations
The `QuestionAnswerAdvisor` has deprecated constructors and builder methods that relied on a simple `userTextAdvise` string:
* Constructors taking a `userTextAdvise` String argument are deprecated.
* Builder method: `userTextAdvise(String userTextAdvise)` is deprecated.
*Migration:* Use the `.promptTemplate(PromptTemplate promptTemplate)` builder method to provide a fully configured `PromptTemplate` object for customizing how retrieved context is merged.
[source,java]
----
// Before (Deprecated)
QuestionAnswerAdvisor oldAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.userTextAdvise("Context: {question_answer_context} Question: {question}") // Simple string
.build();
// After (Using PromptTemplate)
PromptTemplate customTemplate = PromptTemplate.builder()
.template("Context: {question_answer_context} Question: {question}")
.build();
QuestionAnswerAdvisor newAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.promptTemplate(customTemplate) // Provide PromptTemplate object
.build();
----
=== Chat Memory
* A `ChatMemory` bean is auto-configured for you whenever using one of the Spring AI Model starters. By default, it uses the `MessageWindowChatMemory` implementation and stores the conversation history in memory.
@@ -132,6 +187,7 @@ This approach offers more control when you need to conditionally change parts of
* The `JdbcChatMemory` has been deprecated in favour of using `JdbcChatMemoryRepository` together with a `ChatMemory` implementation such `MessageWindowChatMemory`. If you were relying on an auto-configured `JdbcChatMemory` bean, you can replace that by auto-wiring a `ChatMemory` bean that is auto-configured to use the `JdbcChatMemoryRepository` internally for storing messages whenever the related dependency is in the classpath.
* The `spring.ai.chat.memory.jdbc.initialize-schema` property has been deprecated in favor of `spring.ai.chat.memory.repository.jdbc.initialize-schema`.
* Refer to the new xref:api/chat-memory.adoc[Chat Memory] documentation for more details on the new API and how to use it.
* The `MessageWindowChatMemory.get(String conversationId, int lastN)` method is deprecated. The windowing size is now managed internally based on the configuration provided during instantiation, so only `get(String conversationId)` should be used.
=== Prompt Templating

View File

@@ -61,6 +61,13 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>

View File

@@ -0,0 +1,175 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.integration.tests.client.advisor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.RelevancyEvaluator;
import org.springframework.ai.integration.tests.TestApplication;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.vectorstore.pgvector.PgVectorStore;
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.io.Resource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link QuestionAnswerAdvisor}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
public class QuestionAnswerAdvisorIT {
private List<Document> knowledgeBaseDocuments;
@Autowired
OpenAiChatModel openAiChatModel;
@Autowired
PgVectorStore pgVectorStore;
@Value("${classpath:documents/knowledge-base.md}")
Resource knowledgeBaseResource;
@BeforeEach
void setUp() {
DocumentReader markdownReader = new MarkdownDocumentReader(this.knowledgeBaseResource,
MarkdownDocumentReaderConfig.defaultConfig());
this.knowledgeBaseDocuments = markdownReader.read();
this.pgVectorStore.add(this.knowledgeBaseDocuments);
}
@AfterEach
void tearDown() {
this.pgVectorStore.delete(this.knowledgeBaseDocuments.stream().map(Document::getId).toList());
}
@Test
void qaBasic() {
String question = "Where does the adventure of Anacletus and Birba take place?";
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(this.pgVectorStore).build();
ChatResponse chatResponse = ChatClient.builder(this.openAiChatModel)
.build()
.prompt(question)
.advisors(qaAdvisor)
.call()
.chatResponse();
assertThat(chatResponse).isNotNull();
String response = chatResponse.getResult().getOutput().getText();
System.out.println(response);
assertThat(response).containsIgnoringCase("Highlands");
evaluateRelevancy(question, chatResponse);
}
@Test
void qaCustomTemplateRenderer() {
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(this.pgVectorStore).build();
ChatResponse chatResponse = ChatClient.builder(this.openAiChatModel)
.build()
.prompt()
.user(user -> user.text("Where does the adventure of <character1> and <character2> take place?")
.param("character1", "Anacletus")
.param("character2", "Birba"))
.advisors(qaAdvisor)
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.chatResponse();
assertThat(chatResponse).isNotNull();
String response = chatResponse.getResult().getOutput().getText();
System.out.println(response);
assertThat(response).containsIgnoringCase("Highlands");
evaluateRelevancy("Where does the adventure of Anacletus and Birba take place?", chatResponse);
}
@Test
void qaCustomPromptTemplate() {
PromptTemplate customPromptTemplate = PromptTemplate.builder()
.renderer(StTemplateRenderer.builder().startDelimiterToken('$').endDelimiterToken('$').build())
.template("""
Context information is below, surrounded by ---------------------
---------------------
$question_answer_context$
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.
""")
.build();
String question = "Where does the adventure of Anacletus and Birba take place?";
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(this.pgVectorStore)
.promptTemplate(customPromptTemplate)
.build();
ChatResponse chatResponse = ChatClient.builder(this.openAiChatModel)
.build()
.prompt(question)
.advisors(qaAdvisor)
.call()
.chatResponse();
assertThat(chatResponse).isNotNull();
String response = chatResponse.getResult().getOutput().getText();
System.out.println(response);
assertThat(response).containsIgnoringCase("Highlands");
evaluateRelevancy(question, chatResponse);
}
private void evaluateRelevancy(String question, ChatResponse chatResponse) {
EvaluationRequest evaluationRequest = new EvaluationRequest(question,
chatResponse.getMetadata().get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS),
chatResponse.getResult().getOutput().getText());
RelevancyEvaluator evaluator = new RelevancyEvaluator(ChatClient.builder(this.openAiChatModel));
EvaluationResponse evaluationResponse = evaluator.evaluate(evaluationRequest);
assertThat(evaluationResponse.isPass()).isTrue();
}
}

View File

@@ -19,6 +19,7 @@ package org.springframework.ai.chat.prompt;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -33,8 +34,12 @@ import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.StreamUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A template for creating prompts. It allows you to define a template string with
* placeholders for variables, and then render the template with specific values for those
@@ -42,6 +47,8 @@ import org.springframework.util.StreamUtils;
*/
public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {
private static final Logger log = LoggerFactory.getLogger(PromptTemplate.class);
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
/**
@@ -80,7 +87,7 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
/**
* @deprecated in favor of {@link PromptTemplate#builder()}.
* @deprecated in fahvor of {@link PromptTemplate#builder()}.
*/
@Deprecated
public PromptTemplate(Resource resource, Map<String, Object> variables) {
@@ -135,7 +142,17 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
@Override
public String render() {
return this.renderer.apply(template, this.variables);
// Process internal variables to handle Resources before rendering
Map<String, Object> processedVariables = new HashMap<>();
for (Entry<String, Object> entry : this.variables.entrySet()) {
if (entry.getValue() instanceof Resource) {
processedVariables.put(entry.getKey(), renderResource((Resource) entry.getValue()));
}
else {
processedVariables.put(entry.getKey(), entry.getValue());
}
}
return this.renderer.apply(template, processedVariables);
}
@Override
@@ -155,11 +172,25 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
private String renderResource(Resource resource) {
if (resource == null) {
return "";
}
try {
return resource.getContentAsString(Charset.defaultCharset());
// Handle ByteArrayResource specially
if (resource instanceof ByteArrayResource byteArrayResource) {
return new String(byteArrayResource.getByteArray(), StandardCharsets.UTF_8);
}
// If the resource exists but is empty
if (!resource.exists() || resource.contentLength() == 0) {
return "";
}
// For other Resource types or as fallback
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
log.warn("Failed to render resource: {}", resource.getDescription(), e);
return "[Unable to render resource: " + resource.getDescription() + "]";
}
}
@@ -245,21 +276,26 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
public Builder template(String template) {
Assert.hasText(template, "template cannot be null or empty");
this.template = template;
return this;
}
public Builder resource(Resource resource) {
Assert.notNull(resource, "resource cannot be null");
this.resource = resource;
return this;
}
public Builder variables(Map<String, Object> variables) {
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
this.variables = variables;
return this;
}
public Builder renderer(TemplateRenderer renderer) {
Assert.notNull(renderer, "renderer cannot be null");
this.renderer = renderer;
return this;
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.prompt;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests focused on the {@link PromptTemplate.Builder} input validation and edge
* cases.
*/
class PromptTemplateBuilderTests {
@Test
void builderNullTemplateShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().template(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void builderEmptyTemplateShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().template("")).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void builderNullResourceShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().resource(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("resource cannot be null");
}
@Test
void builderNullVariablesShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().variables(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables cannot be null");
}
@Test
void builderNullVariableKeyShouldThrow() {
Map<String, Object> variables = new HashMap<>();
variables.put(null, "value");
assertThatThrownBy(() -> PromptTemplate.builder().variables(variables))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables keys cannot be null");
}
@Test
void builderNullRendererShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().renderer(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("renderer cannot be null");
}
@Test
void renderWithMissingVariableShouldThrow() {
// Using the default ST4 template renderer
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
// No variables provided
.build();
// Expecting an exception because 'name' is required by the template but not
// supplied
try {
promptTemplate.render();
// If render() doesn't throw, fail the test
Assertions.fail("Expected IllegalStateException was not thrown.");
}
catch (IllegalStateException e) {
// Assert that the message is exactly the expected string
assertThat(e.getMessage())
.isEqualTo("Not all variables were replaced in the template. Missing variable names are: [name].");
}
catch (Exception e) {
// Fail if any other unexpected exception is caught
Assertions.fail("Caught unexpected exception: " + e.getClass().getName());
}
}
}

View File

@@ -16,7 +16,11 @@
package org.springframework.ai.chat.prompt;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.template.NoOpTemplateRenderer;
@@ -24,9 +28,6 @@ import org.springframework.ai.template.TemplateRenderer;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -161,7 +162,7 @@ class PromptTemplateTests {
void createPromptWithVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!", variables);
Prompt prompt = promptTemplate.create(variables);
assertThat(prompt.getContents()).isEqualTo("Hello Spring AI!");
}
@@ -186,4 +187,130 @@ class PromptTemplateTests {
.hasMessageContaining("Only one of template or resource can be set");
}
// --- Builder Pattern Tests ---
@Test
void createWithValidTemplate_Builder() {
String template = "Hello {name}!";
PromptTemplate promptTemplate = PromptTemplate.builder().template(template).build();
// Render with the required variable to check the template string was set
// correctly
assertThat(promptTemplate.render(Map.of("name", "Test"))).isEqualTo("Hello Test!");
}
@Test
void renderWithVariables_Builder() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
.variables(variables) // Use builder's variable method
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void createWithValidResource_Builder() {
String content = "Hello {name}!";
Resource resource = new ByteArrayResource(content.getBytes());
PromptTemplate promptTemplate = PromptTemplate.builder().resource(resource).build();
// Render with the required variable to check the resource was read correctly
assertThat(promptTemplate.render(Map.of("name", "Resource"))).isEqualTo("Hello Resource!");
}
@Test
void addVariable_Builder() {
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
.variables(Map.of("name", "Spring AI")) // Use variables() method
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithoutVariables_Builder() {
PromptTemplate promptTemplate = PromptTemplate.builder().template("Hello!").build();
assertThat(promptTemplate.render()).isEqualTo("Hello!");
}
@Test
void renderWithAdditionalVariables_Builder() {
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("{greeting} {name}!")
.variables(variables) // Set default variables via builder
.build();
Map<String, Object> additionalVariables = new HashMap<>();
additionalVariables.put("name", "Spring AI");
// Pass additional variables during render - should merge with defaults
assertThat(promptTemplate.render(additionalVariables)).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithResourceVariable_Builder() {
String resourceContent = "Spring AI";
Resource resource = new ByteArrayResource(resourceContent.getBytes());
Map<String, Object> variables = new HashMap<>();
variables.put("content", resource);
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {content}!")
.variables(variables) // Set resource variable via builder
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void variablesOverwriting_Builder() {
Map<String, Object> initialVars = Map.of("name", "Initial", "adj", "Good");
Map<String, Object> overwriteVars = Map.of("name", "Overwritten", "noun", "Day");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name} {noun}!")
.variables(initialVars) // Set initial variables
.variables(overwriteVars) // Overwrite with new variables
.build();
// Expect only variables from the last call to be present
assertThat(promptTemplate.render()).isEqualTo("Hello Overwritten Day!");
}
// Helper Custom Renderer for testing
private static class CustomTestRenderer implements TemplateRenderer {
@Override
public String apply(String template, Map<String, Object> model) {
// Simple renderer that just appends a marker
// Note: This simple renderer ignores the model map for test purposes.
return template + " (Rendered by Custom)";
}
}
@Test
void customRenderer_Builder() {
String template = "This is a test.";
TemplateRenderer customRenderer = new CustomTestRenderer();
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.renderer(customRenderer) // Set custom renderer
.build();
assertThat(promptTemplate.render()).isEqualTo(template + " (Rendered by Custom)");
}
@Test
void resource_Builder() {
String templateContent = "Hello {name} from Resource!";
Resource templateResource = new ByteArrayResource(templateContent.getBytes());
Map<String, Object> vars = Map.of("name", "Builder");
PromptTemplate promptTemplate = PromptTemplate.builder().resource(templateResource).variables(vars).build();
assertThat(promptTemplate.render()).isEqualTo("Hello Builder from Resource!");
}
}