Skip to content

Commit 2bc29da

Browse files
ThomasVitalemarkpollack
authored andcommitted
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 <[email protected]>
1 parent f561b63 commit 2bc29da

File tree

17 files changed

+1160
-156
lines changed

17 files changed

+1160
-156
lines changed

pom.xml

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<module>spring-ai-docs</module>
3434
<module>spring-ai-bom</module>
3535
<module>spring-ai-commons</module>
36+
<module>spring-ai-template-st</module>
3637
<module>spring-ai-client-chat</module>
3738
<module>spring-ai-model</module>
3839
<module>spring-ai-test</module>

spring-ai-bom/pom.xml

+6
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@
5050
<version>${project.version}</version>
5151
</dependency>
5252

53+
<dependency>
54+
<groupId>org.springframework.ai</groupId>
55+
<artifactId>spring-ai-template-st</artifactId>
56+
<version>${project.version}</version>
57+
</dependency>
58+
5359
<!-- Spring AI model -->
5460

5561
<dependency>

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
@@ -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,43 @@
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+
/**
20+
* Validation modes for template renderers.
21+
*
22+
* @author Thomas Vitale
23+
* @since 1.0.0
24+
*/
25+
public enum ValidationMode {
26+
27+
/**
28+
* If the validation fails, an exception is thrown. This is the default mode.
29+
*/
30+
THROW,
31+
32+
/**
33+
* If the validation fails, a warning is logged. The template is rendered with the
34+
* missing placeholders/variables. This mode is not recommended for production use.
35+
*/
36+
WARN,
37+
38+
/**
39+
* No validation is performed.
40+
*/
41+
NONE;
42+
43+
}
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 NoOpTemplateRendererTests {
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+
}

spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ To use this automation:
4343

4444
This approach can save time and reduce the chance of errors when upgrading multiple projects or complex codebases.
4545

46+
[[upgrading-to-1-0-0-m8]]
47+
== Upgrading to 1.0.0-M8
48+
49+
* The `PromptTemplate` API has been redesigned to support a more flexible and extensible way of templating prompts, relying on a new `TemplateRenderer` API. As part of this change, the `getInputVariables()` and `validate()` methods have been deprecated and will throw an `UnsupportedOperationException` if called. Any logic specific to a template engine should be available through the `TemplateRenderer` API.
50+
4651
[[upgrading-to-1-0-0-m7]]
4752
== Upgrading to 1.0.0-M7
4853

spring-ai-model/pom.xml

+6-6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
<version>${project.parent.version}</version>
4848
</dependency>
4949

50+
<dependency>
51+
<groupId>org.springframework.ai</groupId>
52+
<artifactId>spring-ai-template-st</artifactId>
53+
<version>${project.parent.version}</version>
54+
</dependency>
55+
5056
<dependency>
5157
<groupId>io.micrometer</groupId>
5258
<artifactId>micrometer-observation</artifactId>
@@ -63,12 +69,6 @@
6369
<artifactId>reactor-core</artifactId>
6470
</dependency>
6571

66-
<dependency>
67-
<groupId>org.antlr</groupId>
68-
<artifactId>ST4</artifactId>
69-
<version>${ST4.version}</version>
70-
</dependency>
71-
7272
<!-- ANTLR for Filter Expression Parsing -->
7373
<dependency>
7474
<groupId>org.antlr</groupId>

0 commit comments

Comments
 (0)