Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 131beec

Browse files
committedApr 24, 2025··
Introduce TemplateRenderer for prompt templating
- Introduce new TemplateRenderer API providing the logic for rendering an input template. - Update the PromptTemplate API to accept a TemplateRenderer object at construction time. - Move ST logic to StTemplateRenderer implementation, used by default in PromptTemplate. Additionally, make start and end delimiter character configurable. Relates to gh-2655 Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
1 parent 687dea5 commit 131beec

File tree

10 files changed

+929
-150
lines changed

10 files changed

+929
-150
lines changed
 

‎spring-ai-client-chat/src/test/java/org/springframework/ai/prompt/PromptTemplateTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public void testRenderWithList() {
118118

119119
PromptTemplate unfilledPromptTemplate = new PromptTemplate(templateString);
120120
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(unfilledPromptTemplate::render)
121-
.withMessage("Not all template variables were replaced. Missing variable names are [items]");
121+
.withMessage("Not all variables were replaced in the template. Missing variable names are: [items].");
122122
}
123123

124124
@Test

‎spring-ai-client-chat/src/test/java/org/springframework/ai/prompt/PromptTests.java

+1-40
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
import java.util.HashMap;
2020
import java.util.Map;
21-
import java.util.Set;
2221

2322
import org.assertj.core.api.Assertions;
2423
import org.junit.jupiter.api.Test;
@@ -45,7 +44,7 @@ void newApiPlaygroundTests() {
4544
// Try to render with missing value for template variable, expect exception
4645
Assertions.assertThatThrownBy(() -> pt.render(model))
4746
.isInstanceOf(IllegalStateException.class)
48-
.hasMessage("Not all template variables were replaced. Missing variable names are [lastName]");
47+
.hasMessage("Not all variables were replaced in the template. Missing variable names are: [lastName].");
4948

5049
pt.add("lastName", "Park"); // TODO investigate partial
5150
String promptString = pt.render(model);
@@ -93,44 +92,6 @@ void newApiPlaygroundTests() {
9392

9493
}
9594

96-
@Test
97-
void testSingleInputVariable() {
98-
String template = "This is a {foo} test";
99-
PromptTemplate promptTemplate = new PromptTemplate(template);
100-
Set<String> inputVariables = promptTemplate.getInputVariables();
101-
assertThat(inputVariables).isNotEmpty();
102-
assertThat(inputVariables).hasSize(1);
103-
assertThat(inputVariables).contains("foo");
104-
}
105-
106-
@Test
107-
void testMultipleInputVariables() {
108-
String template = "This {bar} is a {foo} test";
109-
PromptTemplate promptTemplate = new PromptTemplate(template);
110-
Set<String> inputVariables = promptTemplate.getInputVariables();
111-
assertThat(inputVariables).isNotEmpty();
112-
assertThat(inputVariables).hasSize(2);
113-
assertThat(inputVariables).contains("foo", "bar");
114-
}
115-
116-
@Test
117-
void testMultipleInputVariablesWithRepeats() {
118-
String template = "This {bar} is a {foo} test {foo}.";
119-
PromptTemplate promptTemplate = new PromptTemplate(template);
120-
Set<String> inputVariables = promptTemplate.getInputVariables();
121-
assertThat(inputVariables).isNotEmpty();
122-
assertThat(inputVariables).hasSize(2);
123-
assertThat(inputVariables).contains("foo", "bar");
124-
}
125-
126-
@Test
127-
void testBadFormatOfTemplateString() {
128-
String template = "This is a {foo test";
129-
Assertions.assertThatThrownBy(() -> new PromptTemplate(template))
130-
.isInstanceOf(IllegalArgumentException.class)
131-
.hasMessage("The template string is not valid.");
132-
}
133-
13495
@Test
13596
public void testPromptCopy() {
13697
String template = "Hello, {name}! Your age is {age}.";
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,128 +20,138 @@
2020
import java.io.InputStream;
2121
import java.nio.charset.Charset;
2222
import java.util.HashMap;
23-
import java.util.HashSet;
2423
import java.util.List;
2524
import java.util.Map;
2625
import java.util.Map.Entry;
2726
import java.util.Set;
2827

29-
import org.antlr.runtime.Token;
30-
import org.antlr.runtime.TokenStream;
31-
import org.stringtemplate.v4.ST;
32-
import org.stringtemplate.v4.compiler.STLexer;
28+
import org.springframework.ai.template.TemplateRenderer;
29+
import org.springframework.ai.template.st.StTemplateRenderer;
30+
import org.springframework.util.Assert;
3331

3432
import org.springframework.ai.chat.messages.Message;
3533
import org.springframework.ai.chat.messages.UserMessage;
3634
import org.springframework.ai.content.Media;
3735
import org.springframework.core.io.Resource;
3836
import org.springframework.util.StreamUtils;
3937

38+
/**
39+
* A template for creating prompts. It allows you to define a template string with
40+
* placeholders for variables, and then render the template with specific values for those
41+
* variables.
42+
*/
4043
public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {
4144

45+
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
46+
47+
/**
48+
* @deprecated will become private in the next release. If you're subclassing this
49+
* class, re-consider using the built-in implementation together with the new
50+
* PromptTemplateRenderer interface, designed to give you more flexibility and control
51+
* over the rendering process.
52+
*/
53+
@Deprecated
4254
protected String template;
4355

56+
/**
57+
* @deprecated in favor of {@link TemplateRenderer}
58+
*/
59+
@Deprecated
4460
protected TemplateFormat templateFormat = TemplateFormat.ST;
4561

46-
private ST st;
62+
private final Map<String, Object> variables = new HashMap<>();
4763

48-
private Map<String, Object> dynamicModel = new HashMap<>();
64+
private final TemplateRenderer renderer;
4965

5066
public PromptTemplate(Resource resource) {
51-
try (InputStream inputStream = resource.getInputStream()) {
52-
this.template = StreamUtils.copyToString(inputStream, Charset.defaultCharset());
53-
}
54-
catch (IOException ex) {
55-
throw new RuntimeException("Failed to read resource", ex);
56-
}
57-
try {
58-
this.st = new ST(this.template, '{', '}');
59-
}
60-
catch (Exception ex) {
61-
throw new IllegalArgumentException("The template string is not valid.", ex);
62-
}
67+
this(resource, new HashMap<>(), DEFAULT_TEMPLATE_RENDERER);
6368
}
6469

6570
public PromptTemplate(String template) {
66-
this.template = template;
67-
// If the template string is not valid, an exception will be thrown
68-
try {
69-
this.st = new ST(this.template, '{', '}');
70-
}
71-
catch (Exception ex) {
72-
throw new IllegalArgumentException("The template string is not valid.", ex);
73-
}
71+
this(template, new HashMap<>(), DEFAULT_TEMPLATE_RENDERER);
72+
}
73+
74+
/**
75+
* @deprecated in favor of {@link PromptTemplate#builder()}.
76+
*/
77+
@Deprecated
78+
public PromptTemplate(String template, Map<String, Object> variables) {
79+
this(template, variables, DEFAULT_TEMPLATE_RENDERER);
7480
}
7581

76-
public PromptTemplate(String template, Map<String, Object> model) {
82+
/**
83+
* @deprecated in favor of {@link PromptTemplate#builder()}.
84+
*/
85+
@Deprecated
86+
public PromptTemplate(Resource resource, Map<String, Object> variables) {
87+
this(resource, variables, DEFAULT_TEMPLATE_RENDERER);
88+
}
89+
90+
PromptTemplate(String template, Map<String, Object> variables, TemplateRenderer renderer) {
91+
Assert.hasText(template, "template cannot be null or empty");
92+
Assert.notNull(variables, "variables cannot be null");
93+
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
94+
Assert.notNull(renderer, "renderer cannot be null");
95+
7796
this.template = template;
78-
// If the template string is not valid, an exception will be thrown
79-
try {
80-
this.st = new ST(this.template, '{', '}');
81-
for (Entry<String, Object> entry : model.entrySet()) {
82-
add(entry.getKey(), entry.getValue());
83-
}
84-
}
85-
catch (Exception ex) {
86-
throw new IllegalArgumentException("The template string is not valid.", ex);
87-
}
97+
this.variables.putAll(variables);
98+
this.renderer = renderer;
8899
}
89100

90-
public PromptTemplate(Resource resource, Map<String, Object> model) {
101+
PromptTemplate(Resource resource, Map<String, Object> variables, TemplateRenderer renderer) {
102+
Assert.notNull(resource, "resource cannot be null");
103+
Assert.notNull(variables, "variables cannot be null");
104+
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
105+
Assert.notNull(renderer, "renderer cannot be null");
106+
91107
try (InputStream inputStream = resource.getInputStream()) {
92108
this.template = StreamUtils.copyToString(inputStream, Charset.defaultCharset());
109+
Assert.hasText(template, "template cannot be null or empty");
93110
}
94111
catch (IOException ex) {
95112
throw new RuntimeException("Failed to read resource", ex);
96113
}
97-
// If the template string is not valid, an exception will be thrown
98-
try {
99-
this.st = new ST(this.template, '{', '}');
100-
for (Entry<String, Object> entry : model.entrySet()) {
101-
this.add(entry.getKey(), entry.getValue());
102-
}
103-
}
104-
catch (Exception ex) {
105-
throw new IllegalArgumentException("The template string is not valid.", ex);
106-
}
114+
this.variables.putAll(variables);
115+
this.renderer = renderer;
107116
}
108117

109118
public void add(String name, Object value) {
110-
this.st.add(name, value);
111-
this.dynamicModel.put(name, value);
119+
this.variables.put(name, value);
112120
}
113121

114122
public String getTemplate() {
115123
return this.template;
116124
}
117125

126+
/**
127+
* @deprecated in favor of {@link TemplateRenderer}
128+
*/
129+
@Deprecated
118130
public TemplateFormat getTemplateFormat() {
119131
return this.templateFormat;
120132
}
121133

122-
// Render Methods
134+
// From PromptTemplateStringActions.
135+
123136
@Override
124137
public String render() {
125-
validate(this.dynamicModel);
126-
return this.st.render();
138+
return this.renderer.apply(template, this.variables);
127139
}
128140

129141
@Override
130-
public String render(Map<String, Object> model) {
131-
validate(model);
132-
for (Entry<String, Object> entry : model.entrySet()) {
133-
if (this.st.getAttribute(entry.getKey()) != null) {
134-
this.st.remove(entry.getKey());
135-
}
142+
public String render(Map<String, Object> additionalVariables) {
143+
Map<String, Object> combinedVariables = new HashMap<>(this.variables);
144+
145+
for (Entry<String, Object> entry : additionalVariables.entrySet()) {
136146
if (entry.getValue() instanceof Resource) {
137-
this.st.add(entry.getKey(), renderResource((Resource) entry.getValue()));
147+
combinedVariables.put(entry.getKey(), renderResource((Resource) entry.getValue()));
138148
}
139149
else {
140-
this.st.add(entry.getKey(), entry.getValue());
150+
combinedVariables.put(entry.getKey(), entry.getValue());
141151
}
142-
143152
}
144-
return this.st.render();
153+
154+
return this.renderer.apply(template, combinedVariables);
145155
}
146156

147157
private String renderResource(Resource resource) {
@@ -153,85 +163,119 @@ private String renderResource(Resource resource) {
153163
}
154164
}
155165

166+
// From PromptTemplateMessageActions.
167+
156168
@Override
157169
public Message createMessage() {
158170
return new UserMessage(render());
159171
}
160172

161173
@Override
162174
public Message createMessage(List<Media> mediaList) {
163-
return new UserMessage(render(), mediaList);
175+
return UserMessage.builder().text(render()).media(mediaList).build();
164176
}
165177

166178
@Override
167-
public Message createMessage(Map<String, Object> model) {
168-
return new UserMessage(render(model));
179+
public Message createMessage(Map<String, Object> additionalVariables) {
180+
return new UserMessage(render(additionalVariables));
169181
}
170182

183+
// From PromptTemplateActions.
184+
171185
@Override
172186
public Prompt create() {
173187
return new Prompt(render(new HashMap<>()));
174188
}
175189

176190
@Override
177191
public Prompt create(ChatOptions modelOptions) {
178-
return new Prompt(render(new HashMap<>()), modelOptions);
192+
return Prompt.builder().content(render(new HashMap<>())).chatOptions(modelOptions).build();
179193
}
180194

181195
@Override
182-
public Prompt create(Map<String, Object> model) {
183-
return new Prompt(render(model));
196+
public Prompt create(Map<String, Object> additionalVariables) {
197+
return new Prompt(render(additionalVariables));
184198
}
185199

186200
@Override
187-
public Prompt create(Map<String, Object> model, ChatOptions modelOptions) {
188-
return new Prompt(render(model), modelOptions);
201+
public Prompt create(Map<String, Object> additionalVariables, ChatOptions modelOptions) {
202+
return Prompt.builder().content(render(additionalVariables)).chatOptions(modelOptions).build();
189203
}
190204

205+
// Compatibility
206+
207+
/**
208+
* @deprecated in favor of {@link TemplateRenderer}.
209+
*/
210+
@Deprecated
191211
public Set<String> getInputVariables() {
192-
TokenStream tokens = this.st.impl.tokens;
193-
Set<String> inputVariables = new HashSet<>();
194-
boolean isInsideList = false;
195-
196-
for (int i = 0; i < tokens.size(); i++) {
197-
Token token = tokens.get(i);
198-
199-
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
200-
&& tokens.get(i + 1).getType() == STLexer.ID) {
201-
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
202-
inputVariables.add(tokens.get(i + 1).getText());
203-
isInsideList = true;
204-
}
205-
}
206-
else if (token.getType() == STLexer.RDELIM) {
207-
isInsideList = false;
208-
}
209-
else if (!isInsideList && token.getType() == STLexer.ID) {
210-
inputVariables.add(token.getText());
211-
}
212-
}
212+
throw new UnsupportedOperationException(
213+
"The template rendering logic is now provided by PromptTemplateRenderer");
214+
}
213215

214-
return inputVariables;
216+
/**
217+
* @deprecated in favor of {@link TemplateRenderer}.
218+
*/
219+
@Deprecated
220+
protected void validate(Map<String, Object> model) {
221+
throw new UnsupportedOperationException("Validation is now provided by the PromptTemplateRenderer");
215222
}
216223

217-
private Set<String> getModelKeys(Map<String, Object> model) {
218-
Set<String> dynamicVariableNames = new HashSet<>(this.dynamicModel.keySet());
219-
Set<String> modelVariables = new HashSet<>(model.keySet());
220-
modelVariables.addAll(dynamicVariableNames);
221-
return modelVariables;
224+
public Builder mutate() {
225+
return new Builder().template(this.template).variables(this.variables).renderer(this.renderer);
222226
}
223227

224-
protected void validate(Map<String, Object> model) {
228+
// Builder
229+
230+
public static Builder builder() {
231+
return new Builder();
232+
}
233+
234+
public static class Builder {
235+
236+
private String template;
237+
238+
private Resource resource;
225239

226-
Set<String> templateTokens = getInputVariables();
227-
Set<String> modelKeys = getModelKeys(model);
240+
private Map<String, Object> variables = new HashMap<>();
228241

229-
// Check if model provides all keys required by the template
230-
if (!modelKeys.containsAll(templateTokens)) {
231-
templateTokens.removeAll(modelKeys);
232-
throw new IllegalStateException(
233-
"Not all template variables were replaced. Missing variable names are " + templateTokens);
242+
private TemplateRenderer renderer = DEFAULT_TEMPLATE_RENDERER;
243+
244+
private Builder() {
245+
}
246+
247+
public Builder template(String template) {
248+
this.template = template;
249+
return this;
250+
}
251+
252+
public Builder resource(Resource resource) {
253+
this.resource = resource;
254+
return this;
255+
}
256+
257+
public Builder variables(Map<String, Object> variables) {
258+
this.variables = variables;
259+
return this;
260+
}
261+
262+
public Builder renderer(TemplateRenderer renderer) {
263+
this.renderer = renderer;
264+
return this;
265+
}
266+
267+
public PromptTemplate build() {
268+
if (this.template != null && this.resource != null) {
269+
throw new IllegalArgumentException("Only one of template or resource can be set");
270+
}
271+
else if (this.resource != null) {
272+
return new PromptTemplate(this.resource, this.variables, this.renderer);
273+
}
274+
else {
275+
return new PromptTemplate(this.template, this.variables, this.renderer);
276+
}
234277
}
278+
235279
}
236280

237281
}

‎spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/TemplateFormat.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,12 @@
1616

1717
package org.springframework.ai.chat.prompt;
1818

19+
import org.springframework.ai.template.TemplateRenderer;
20+
21+
/**
22+
* @deprecated in favor of {@link TemplateRenderer}.
23+
*/
24+
@Deprecated
1925
public enum TemplateFormat {
2026

2127
ST("ST");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template;
18+
19+
import java.util.Map;
20+
21+
import org.springframework.util.Assert;
22+
23+
/**
24+
* No-op implementation of {@link TemplateRenderer} that returns the template unchanged.
25+
*
26+
* @author Thomas Vitale
27+
* @since 1.0.0
28+
*/
29+
public class NoOpTemplateRenderer implements TemplateRenderer {
30+
31+
@Override
32+
public String apply(String template, Map<String, Object> variables) {
33+
Assert.hasText(template, "template cannot be null or empty");
34+
Assert.notNull(variables, "variables cannot be null");
35+
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
36+
return template;
37+
}
38+
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template;
18+
19+
import java.util.Map;
20+
import java.util.function.BiFunction;
21+
22+
/**
23+
* Renders a template using a given strategy.
24+
*
25+
* @author Thomas Vitale
26+
* @since 1.0.0
27+
*/
28+
public interface TemplateRenderer extends BiFunction<String, Map<String, Object>, String> {
29+
30+
@Override
31+
String apply(String template, Map<String, Object> variables);
32+
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template.st;
18+
19+
import org.antlr.runtime.Token;
20+
import org.antlr.runtime.TokenStream;
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
23+
import org.springframework.ai.template.TemplateRenderer;
24+
import org.springframework.util.Assert;
25+
import org.stringtemplate.v4.ST;
26+
import org.stringtemplate.v4.compiler.STLexer;
27+
28+
import java.util.HashSet;
29+
import java.util.Map;
30+
import java.util.Set;
31+
32+
/**
33+
* Renders a template using the StringTemplate (ST) library.
34+
*
35+
* @author Thomas Vitale
36+
* @since 1.0.0
37+
*/
38+
public class StTemplateRenderer implements TemplateRenderer {
39+
40+
private static final Logger logger = LoggerFactory.getLogger(StTemplateRenderer.class);
41+
42+
private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
43+
44+
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
45+
46+
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
47+
48+
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
49+
50+
private final char startDelimiterToken;
51+
52+
private final char endDelimiterToken;
53+
54+
private final ValidationMode validationMode;
55+
56+
StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode) {
57+
Assert.notNull(validationMode, "validationMode cannot be null");
58+
this.startDelimiterToken = startDelimiterToken;
59+
this.endDelimiterToken = endDelimiterToken;
60+
this.validationMode = validationMode;
61+
}
62+
63+
@Override
64+
public String apply(String template, Map<String, Object> variables) {
65+
Assert.hasText(template, "template cannot be null or empty");
66+
Assert.notNull(variables, "variables cannot be null");
67+
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
68+
69+
ST st = createST(template);
70+
for (Map.Entry<String, Object> entry : variables.entrySet()) {
71+
st.add(entry.getKey(), entry.getValue());
72+
}
73+
if (validationMode != ValidationMode.NONE) {
74+
validate(st, variables);
75+
}
76+
return st.render();
77+
}
78+
79+
private ST createST(String template) {
80+
try {
81+
return new ST(template, startDelimiterToken, endDelimiterToken);
82+
}
83+
catch (Exception ex) {
84+
throw new IllegalArgumentException("The template string is not valid.", ex);
85+
}
86+
}
87+
88+
private void validate(ST st, Map<String, Object> templateVariables) {
89+
Set<String> templateTokens = getInputVariables(st);
90+
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
91+
92+
// Check if model provides all keys required by the template
93+
if (!modelKeys.containsAll(templateTokens)) {
94+
templateTokens.removeAll(modelKeys);
95+
if (validationMode == ValidationMode.WARN) {
96+
logger.warn(VALIDATION_MESSAGE.formatted(templateTokens));
97+
}
98+
else if (validationMode == ValidationMode.THROW) {
99+
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens));
100+
}
101+
}
102+
}
103+
104+
private Set<String> getInputVariables(ST st) {
105+
TokenStream tokens = st.impl.tokens;
106+
Set<String> inputVariables = new HashSet<>();
107+
boolean isInsideList = false;
108+
109+
for (int i = 0; i < tokens.size(); i++) {
110+
Token token = tokens.get(i);
111+
112+
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
113+
&& tokens.get(i + 1).getType() == STLexer.ID) {
114+
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
115+
inputVariables.add(tokens.get(i + 1).getText());
116+
isInsideList = true;
117+
}
118+
}
119+
else if (token.getType() == STLexer.RDELIM) {
120+
isInsideList = false;
121+
}
122+
else if (!isInsideList && token.getType() == STLexer.ID) {
123+
inputVariables.add(token.getText());
124+
}
125+
}
126+
127+
return inputVariables;
128+
}
129+
130+
public enum ValidationMode {
131+
132+
/**
133+
* If the validation fails, an exception is thrown. This is the default mode.
134+
*/
135+
THROW,
136+
137+
/**
138+
* If the validation fails, a warning is logged. The template is rendered with the
139+
* missing placeholders/variables. This mode is not recommended for production
140+
* use.
141+
*/
142+
WARN,
143+
144+
/**
145+
* No validation is performed.
146+
*/
147+
NONE;
148+
149+
}
150+
151+
public static Builder builder() {
152+
return new Builder();
153+
}
154+
155+
public static class Builder {
156+
157+
private char startDelimiterToken = DEFAULT_START_DELIMITER_TOKEN;
158+
159+
private char endDelimiterToken = DEFAULT_END_DELIMITER_TOKEN;
160+
161+
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
162+
163+
private Builder() {
164+
}
165+
166+
public Builder startDelimiterToken(char startDelimiterToken) {
167+
this.startDelimiterToken = startDelimiterToken;
168+
return this;
169+
}
170+
171+
public Builder endDelimiterToken(char endDelimiterToken) {
172+
this.endDelimiterToken = endDelimiterToken;
173+
return this;
174+
}
175+
176+
public Builder validationMode(ValidationMode validationMode) {
177+
this.validationMode = validationMode;
178+
return this;
179+
}
180+
181+
public StTemplateRenderer build() {
182+
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode);
183+
}
184+
185+
}
186+
187+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.chat.prompt;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.springframework.ai.chat.messages.Message;
21+
import org.springframework.ai.chat.messages.UserMessage;
22+
import org.springframework.ai.template.NoOpTemplateRenderer;
23+
import org.springframework.ai.template.TemplateRenderer;
24+
import org.springframework.core.io.ByteArrayResource;
25+
import org.springframework.core.io.Resource;
26+
27+
import java.util.HashMap;
28+
import java.util.Map;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
32+
33+
/**
34+
* Unit tests for {@link PromptTemplate}.
35+
*
36+
* @author Thomas Vitale
37+
*/
38+
class PromptTemplateTests {
39+
40+
@Test
41+
void createWithValidTemplate() {
42+
String template = "Hello {name}!";
43+
PromptTemplate promptTemplate = new PromptTemplate(template);
44+
assertThat(promptTemplate.getTemplate()).isEqualTo(template);
45+
}
46+
47+
@Test
48+
void createWithEmptyTemplate() {
49+
assertThatThrownBy(() -> new PromptTemplate("")).isInstanceOf(IllegalArgumentException.class)
50+
.hasMessageContaining("template cannot be null or empty");
51+
}
52+
53+
@Test
54+
void createWithNullTemplate() {
55+
String template = null;
56+
assertThatThrownBy(() -> new PromptTemplate(template)).isInstanceOf(IllegalArgumentException.class)
57+
.hasMessageContaining("template cannot be null or empty");
58+
}
59+
60+
@Test
61+
void createWithValidResource() {
62+
String content = "Hello {name}!";
63+
Resource resource = new ByteArrayResource(content.getBytes());
64+
PromptTemplate promptTemplate = new PromptTemplate(resource);
65+
assertThat(promptTemplate.getTemplate()).isEqualTo(content);
66+
}
67+
68+
@Test
69+
void createWithNullResource() {
70+
Resource resource = null;
71+
assertThatThrownBy(() -> new PromptTemplate(resource)).isInstanceOf(IllegalArgumentException.class)
72+
.hasMessageContaining("resource cannot be null");
73+
}
74+
75+
@Test
76+
void createWithNullVariables() {
77+
String template = "Hello!";
78+
Map<String, Object> variables = null;
79+
assertThatThrownBy(() -> new PromptTemplate(template, variables)).isInstanceOf(IllegalArgumentException.class)
80+
.hasMessageContaining("variables cannot be null");
81+
}
82+
83+
@Test
84+
void createWithNullVariableKeys() {
85+
String template = "Hello!";
86+
Map<String, Object> variables = new HashMap<>();
87+
variables.put(null, "value");
88+
assertThatThrownBy(() -> new PromptTemplate(template, variables)).isInstanceOf(IllegalArgumentException.class)
89+
.hasMessageContaining("variables keys cannot be null");
90+
}
91+
92+
@Test
93+
void addVariable() {
94+
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
95+
promptTemplate.add("name", "Spring AI");
96+
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
97+
}
98+
99+
@Test
100+
void renderWithoutVariables() {
101+
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
102+
assertThat(promptTemplate.render()).isEqualTo("Hello!");
103+
}
104+
105+
@Test
106+
void renderWithVariables() {
107+
Map<String, Object> variables = new HashMap<>();
108+
variables.put("name", "Spring AI");
109+
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!", variables);
110+
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
111+
}
112+
113+
@Test
114+
void renderWithAdditionalVariables() {
115+
Map<String, Object> variables = new HashMap<>();
116+
variables.put("greeting", "Hello");
117+
PromptTemplate promptTemplate = new PromptTemplate("{greeting} {name}!", variables);
118+
119+
Map<String, Object> additionalVariables = new HashMap<>();
120+
additionalVariables.put("name", "Spring AI");
121+
assertThat(promptTemplate.render(additionalVariables)).isEqualTo("Hello Spring AI!");
122+
}
123+
124+
@Test
125+
void renderWithResourceVariable() {
126+
String resourceContent = "Spring AI";
127+
Resource resource = new ByteArrayResource(resourceContent.getBytes());
128+
Map<String, Object> variables = new HashMap<>();
129+
variables.put("content", resource);
130+
131+
PromptTemplate promptTemplate = new PromptTemplate("Hello {content}!");
132+
assertThat(promptTemplate.render(variables)).isEqualTo("Hello Spring AI!");
133+
}
134+
135+
@Test
136+
void createMessageWithoutVariables() {
137+
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
138+
Message message = promptTemplate.createMessage();
139+
assertThat(message).isInstanceOf(UserMessage.class);
140+
assertThat(message.getText()).isEqualTo("Hello!");
141+
}
142+
143+
@Test
144+
void createMessageWithVariables() {
145+
Map<String, Object> variables = new HashMap<>();
146+
variables.put("name", "Spring AI");
147+
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
148+
Message message = promptTemplate.createMessage(variables);
149+
assertThat(message).isInstanceOf(UserMessage.class);
150+
assertThat(message.getText()).isEqualTo("Hello Spring AI!");
151+
}
152+
153+
@Test
154+
void createPromptWithoutVariables() {
155+
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
156+
Prompt prompt = promptTemplate.create();
157+
assertThat(prompt.getContents()).isEqualTo("Hello!");
158+
}
159+
160+
@Test
161+
void createPromptWithVariables() {
162+
Map<String, Object> variables = new HashMap<>();
163+
variables.put("name", "Spring AI");
164+
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
165+
Prompt prompt = promptTemplate.create(variables);
166+
assertThat(prompt.getContents()).isEqualTo("Hello Spring AI!");
167+
}
168+
169+
@Test
170+
void createWithCustomRenderer() {
171+
TemplateRenderer customRenderer = new NoOpTemplateRenderer();
172+
PromptTemplate promptTemplate = PromptTemplate.builder()
173+
.template("Hello {name}!")
174+
.renderer(customRenderer)
175+
.build();
176+
assertThat(promptTemplate.render()).isEqualTo("Hello {name}!");
177+
}
178+
179+
@Test
180+
void builderShouldNotAllowBothTemplateAndResource() {
181+
String template = "Hello!";
182+
Resource resource = new ByteArrayResource(template.getBytes());
183+
184+
assertThatThrownBy(() -> PromptTemplate.builder().template(template).resource(resource).build())
185+
.isInstanceOf(IllegalArgumentException.class)
186+
.hasMessageContaining("Only one of template or resource can be set");
187+
}
188+
189+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
22+
import java.util.HashMap;
23+
import java.util.Map;
24+
25+
import org.junit.jupiter.api.Test;
26+
27+
/**
28+
* Unit tests for {@link NoOpTemplateRenderer}.
29+
*
30+
* @author Thomas Vitale
31+
*/
32+
class NoOpPromptTemplateRendererTests {
33+
34+
@Test
35+
void shouldReturnUnchangedTemplate() {
36+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
37+
Map<String, Object> variables = new HashMap<>();
38+
variables.put("name", "Spring AI");
39+
40+
String result = renderer.apply("Hello {name}!", variables);
41+
42+
assertThat(result).isEqualTo("Hello {name}!");
43+
}
44+
45+
@Test
46+
void shouldReturnUnchangedTemplateWithMultipleVariables() {
47+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
48+
Map<String, Object> variables = new HashMap<>();
49+
variables.put("greeting", "Hello");
50+
variables.put("name", "Spring AI");
51+
variables.put("punctuation", "!");
52+
53+
String result = renderer.apply("{greeting} {name}{punctuation}", variables);
54+
55+
assertThat(result).isEqualTo("{greeting} {name}{punctuation}");
56+
}
57+
58+
@Test
59+
void shouldNotAcceptEmptyTemplate() {
60+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
61+
Map<String, Object> variables = new HashMap<>();
62+
63+
assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
64+
.hasMessageContaining("template cannot be null or empty");
65+
}
66+
67+
@Test
68+
void shouldNotAcceptNullTemplate() {
69+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
70+
Map<String, Object> variables = new HashMap<>();
71+
72+
assertThatThrownBy(() -> renderer.apply(null, variables)).isInstanceOf(IllegalArgumentException.class)
73+
.hasMessageContaining("template cannot be null or empty");
74+
}
75+
76+
@Test
77+
void shouldNotAcceptNullVariables() {
78+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
79+
String template = "Hello!";
80+
81+
assertThatThrownBy(() -> renderer.apply(template, null)).isInstanceOf(IllegalArgumentException.class)
82+
.hasMessageContaining("variables cannot be null");
83+
}
84+
85+
@Test
86+
void shouldNotAcceptVariablesWithNullKeySet() {
87+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
88+
String template = "Hello!";
89+
Map<String, Object> variables = new HashMap<String, Object>();
90+
variables.put(null, "Spring AI");
91+
92+
assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
93+
.hasMessageContaining("variables keys cannot be null");
94+
}
95+
96+
@Test
97+
void shouldReturnUnchangedComplexTemplate() {
98+
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
99+
Map<String, Object> variables = new HashMap<>();
100+
variables.put("header", "Welcome");
101+
variables.put("user", "Spring AI");
102+
variables.put("items", "one, two, three");
103+
variables.put("footer", "Goodbye");
104+
105+
String template = """
106+
{header}
107+
User: {user}
108+
Items: {items}
109+
{footer}
110+
""";
111+
112+
String result = renderer.apply(template, variables);
113+
114+
assertThat(result).isEqualToNormalizingNewlines(template);
115+
}
116+
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template.st;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
22+
import java.util.HashMap;
23+
import java.util.Map;
24+
25+
import org.junit.jupiter.api.Test;
26+
import org.springframework.test.util.ReflectionTestUtils;
27+
28+
/**
29+
* Unit tests for {@link StTemplateRenderer}.
30+
*
31+
* @author Thomas Vitale
32+
*/
33+
class STPromptTemplateRendererTests {
34+
35+
@Test
36+
void shouldNotAcceptNullValidationMode() {
37+
assertThatThrownBy(() -> StTemplateRenderer.builder().validationMode(null).build())
38+
.isInstanceOf(IllegalArgumentException.class)
39+
.hasMessageContaining("validationMode cannot be null");
40+
}
41+
42+
@Test
43+
void shouldUseDefaultValuesWhenUsingBuilder() {
44+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
45+
46+
assertThat(ReflectionTestUtils.getField(renderer, "startDelimiterToken")).isEqualTo('{');
47+
assertThat(ReflectionTestUtils.getField(renderer, "endDelimiterToken")).isEqualTo('}');
48+
assertThat(ReflectionTestUtils.getField(renderer, "validationMode"))
49+
.isEqualTo(StTemplateRenderer.ValidationMode.THROW);
50+
}
51+
52+
@Test
53+
void shouldRenderTemplateWithSingleVariable() {
54+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
55+
Map<String, Object> variables = new HashMap<>();
56+
variables.put("name", "Spring AI");
57+
58+
String result = renderer.apply("Hello {name}!", variables);
59+
60+
assertThat(result).isEqualTo("Hello Spring AI!");
61+
}
62+
63+
@Test
64+
void shouldRenderTemplateWithMultipleVariables() {
65+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
66+
Map<String, Object> variables = new HashMap<>();
67+
variables.put("greeting", "Hello");
68+
variables.put("name", "Spring AI");
69+
variables.put("punctuation", "!");
70+
71+
String result = renderer.apply("{greeting} {name}{punctuation}", variables);
72+
73+
assertThat(result).isEqualTo("Hello Spring AI!");
74+
}
75+
76+
@Test
77+
void shouldNotRenderEmptyTemplate() {
78+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
79+
Map<String, Object> variables = new HashMap<>();
80+
81+
assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
82+
.hasMessageContaining("template cannot be null or empty");
83+
}
84+
85+
@Test
86+
void shouldNotAcceptNullVariables() {
87+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
88+
assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class)
89+
.hasMessageContaining("variables cannot be null");
90+
}
91+
92+
@Test
93+
void shouldNotAcceptVariablesWithNullKeySet() {
94+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
95+
String template = "Hello!";
96+
Map<String, Object> variables = new HashMap<String, Object>();
97+
variables.put(null, "Spring AI");
98+
99+
assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
100+
.hasMessageContaining("variables keys cannot be null");
101+
}
102+
103+
@Test
104+
void shouldThrowExceptionForInvalidTemplateSyntax() {
105+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
106+
Map<String, Object> variables = new HashMap<>();
107+
variables.put("name", "Spring AI");
108+
109+
assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)).isInstanceOf(IllegalArgumentException.class)
110+
.hasMessageContaining("The template string is not valid.");
111+
}
112+
113+
@Test
114+
void shouldThrowExceptionForMissingVariablesInThrowMode() {
115+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
116+
Map<String, Object> variables = new HashMap<>();
117+
variables.put("greeting", "Hello");
118+
119+
assertThatThrownBy(() -> renderer.apply("{greeting} {name}!", variables))
120+
.isInstanceOf(IllegalStateException.class)
121+
.hasMessageContaining(
122+
"Not all variables were replaced in the template. Missing variable names are: [name]");
123+
}
124+
125+
@Test
126+
void shouldContinueRenderingWithMissingVariablesInWarnMode() {
127+
StTemplateRenderer renderer = StTemplateRenderer.builder()
128+
.validationMode(StTemplateRenderer.ValidationMode.WARN)
129+
.build();
130+
Map<String, Object> variables = new HashMap<>();
131+
variables.put("greeting", "Hello");
132+
133+
String result = renderer.apply("{greeting} {name}!", variables);
134+
135+
assertThat(result).isEqualTo("Hello !");
136+
}
137+
138+
@Test
139+
void shouldRenderWithoutValidationInNoneMode() {
140+
StTemplateRenderer renderer = StTemplateRenderer.builder()
141+
.validationMode(StTemplateRenderer.ValidationMode.NONE)
142+
.build();
143+
Map<String, Object> variables = new HashMap<>();
144+
variables.put("greeting", "Hello");
145+
146+
String result = renderer.apply("{greeting} {name}!", variables);
147+
148+
assertThat(result).isEqualTo("Hello !");
149+
}
150+
151+
@Test
152+
void shouldRenderWithCustomDelimiters() {
153+
StTemplateRenderer renderer = StTemplateRenderer.builder()
154+
.startDelimiterToken('<')
155+
.endDelimiterToken('>')
156+
.build();
157+
Map<String, Object> variables = new HashMap<>();
158+
variables.put("name", "Spring AI");
159+
160+
String result = renderer.apply("Hello <name>!", variables);
161+
162+
assertThat(result).isEqualTo("Hello Spring AI!");
163+
}
164+
165+
@Test
166+
void shouldHandleSpecialCharactersAsDelimiters() {
167+
StTemplateRenderer renderer = StTemplateRenderer.builder()
168+
.startDelimiterToken('$')
169+
.endDelimiterToken('$')
170+
.build();
171+
Map<String, Object> variables = new HashMap<>();
172+
variables.put("name", "Spring AI");
173+
174+
String result = renderer.apply("Hello $name$!", variables);
175+
176+
assertThat(result).isEqualTo("Hello Spring AI!");
177+
}
178+
179+
@Test
180+
void shouldHandleComplexTemplateStructures() {
181+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
182+
Map<String, Object> variables = new HashMap<>();
183+
variables.put("header", "Welcome");
184+
variables.put("user", "Spring AI");
185+
variables.put("items", "one, two, three");
186+
variables.put("footer", "Goodbye");
187+
188+
String result = renderer.apply("""
189+
{header}
190+
User: {user}
191+
Items: {items}
192+
{footer}
193+
""", variables);
194+
195+
assertThat(result).isEqualToNormalizingNewlines("""
196+
Welcome
197+
User: Spring AI
198+
Items: one, two, three
199+
Goodbye
200+
""");
201+
}
202+
203+
}

0 commit comments

Comments
 (0)
Please sign in to comment.