diff --git a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java index dfee9f25a..d6ca554e6 100644 --- a/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java +++ b/advisors/spring-ai-advisors-vector-store/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java @@ -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 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 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); } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java index 125148b1b..46599801f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java @@ -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 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() + + "") + .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 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() + + "") + .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 outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux 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() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .stream() + .chatResponse(); + + List 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 outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux 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() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .stream() + .chatResponse(); + + List 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 movies) { } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java index 14ca4890d..a2270c445 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -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 consumer); + ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer); + CallResponseSpec call(); StreamResponseSpec stream(); @@ -282,6 +285,8 @@ public interface ChatClient { Builder defaultSystem(Consumer systemSpecConsumer); + Builder defaultTemplateRenderer(TemplateRenderer templateRenderer); + Builder defaultTools(String... toolNames); Builder defaultTools(ToolCallback... toolCallbacks); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index e0e86ffe5..590f5d017 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -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 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 toolCallbacks, List messages, List toolNames, List media, @Nullable ChatOptions chatOptions, List advisors, Map advisorParams, ObservationRegistry observationRegistry, - @Nullable ChatClientObservationConvention observationConvention, Map toolContext) { + @Nullable ChatClientObservationConvention observationConvention, Map 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(); } } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index 02b3e29f6..2c35049b3 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -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 messages) { this.defaultRequest.messages(messages); } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java index e742f2d72..dd6d0d3da 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java @@ -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 originalCallAdvisors; private final List originalStreamAdvisors; @@ -67,14 +72,17 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { private final ObservationRegistry observationRegistry; - DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, Deque callAroundAdvisors, - Deque streamAroundAdvisors) { + private final TemplateRenderer templateRenderer; + + DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, @Nullable TemplateRenderer templateRenderer, + Deque callAroundAdvisors, Deque 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 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); } } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequest.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequest.java index 9a518c955..e22545f9c 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequest.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequest.java @@ -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 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())); } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisorChain.java index 7957d48e6..4ea605cb4 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseAdvisorChain.java @@ -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(); + } + } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java index 4bb321f85..a4cb02541 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java @@ -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"); + } + } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java index 43502b6b5..6fec96a8b 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientTests.java @@ -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"); } diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequestTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequestTests.java index f63cf6bcd..806498d60 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequestTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/api/AdvisedRequestTests.java @@ -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 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 systemParams = Map.of("key", "value"); Map userParams = Map.of("key", "value"); List 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 "); + UserMessage userMessage = UserMessage.builder().text("Question ").media(mock(Media.class)).build(); + Map systemParams = Map.of("name", "Spring AI"); + Map 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 "); + UserMessage userMessage = UserMessage.builder().text("Question ").media(mock(Media.class)).build(); + Map systemParams = Map.of("name", "Spring AI"); + Map 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"); } } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 5a30c7ed5..07e27a2f2 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -165,6 +165,39 @@ String content = this.flux.collectList().block().stream().collect(Collectors.joi List 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 ") + .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. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/prompt.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/prompt.adoc index e82f98247..fc7de8d5c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/prompt.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/prompt.adoc @@ -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> { private final List 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 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> { + + @Override + String apply(String template, Map 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 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 model)`: Enhances rendering functionality to include dynamic content. It uses a `Map` 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 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 mediaList)`: Creates a `Message` o The method `Message createMessage(Map model)`: Extends message creation to integrate dynamic content, accepting a `Map` 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 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 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 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 . + """) + .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 diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/retrieval-augmented-generation.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/retrieval-augmented-generation.adoc index 8b57e3205..fdafefc3d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/retrieval-augmented-generation.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/retrieval-augmented-generation.adoc @@ -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. + + --------------------- + + --------------------- + + 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. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc index 09d7520a8..e250a32c8 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc @@ -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 variables)` and `PromptTemplate(Resource resource, Map variables)` are deprecated. +* Fields: `template` and `templateFormat` are deprecated. +* Methods: `getTemplateFormat()`, `getInputVariables()`, and `validate(Map 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 diff --git a/spring-ai-integration-tests/pom.xml b/spring-ai-integration-tests/pom.xml index 677b8ab09..80b30733b 100644 --- a/spring-ai-integration-tests/pom.xml +++ b/spring-ai-integration-tests/pom.xml @@ -61,6 +61,13 @@ test + + org.springframework.ai + spring-ai-advisors-vector-store + ${project.parent.version} + test + + org.springframework.ai spring-ai-starter-model-openai diff --git a/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/client/advisor/QuestionAnswerAdvisorIT.java b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/client/advisor/QuestionAnswerAdvisorIT.java new file mode 100644 index 000000000..8f7530b44 --- /dev/null +++ b/spring-ai-integration-tests/src/test/java/org/springframework/ai/integration/tests/client/advisor/QuestionAnswerAdvisorIT.java @@ -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 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 and 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(); + } + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/PromptTemplate.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/PromptTemplate.java index 0775b34f8..d98f9d248 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/PromptTemplate.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/PromptTemplate.java @@ -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 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 processedVariables = new HashMap<>(); + for (Entry 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 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; } diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java new file mode 100644 index 000000000..7695647c3 --- /dev/null +++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java @@ -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 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()); + } + } + +} diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateTests.java index 287339873..8712d9855 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateTests.java @@ -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 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 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 variables = new HashMap<>(); + variables.put("greeting", "Hello"); + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("{greeting} {name}!") + .variables(variables) // Set default variables via builder + .build(); + + Map 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 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 initialVars = Map.of("name", "Initial", "adj", "Good"); + Map 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 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 vars = Map.of("name", "Builder"); + + PromptTemplate promptTemplate = PromptTemplate.builder().resource(templateResource).variables(vars).build(); + + assertThat(promptTemplate.render()).isEqualTo("Hello Builder from Resource!"); + } + }