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>
This commit is contained in:
Thomas Vitale
2025-04-24 23:13:06 +02:00
committed by Mark Pollack
parent f561b63493
commit 2bc29dab32
17 changed files with 1163 additions and 159 deletions

View File

@@ -33,6 +33,7 @@
<module>spring-ai-docs</module>
<module>spring-ai-bom</module>
<module>spring-ai-commons</module>
<module>spring-ai-template-st</module>
<module>spring-ai-client-chat</module>
<module>spring-ai-model</module>
<module>spring-ai-test</module>

View File

@@ -50,6 +50,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-template-st</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Spring AI model -->
<dependency>

View File

@@ -118,7 +118,7 @@ public class PromptTemplateTest {
PromptTemplate unfilledPromptTemplate = new PromptTemplate(templateString);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(unfilledPromptTemplate::render)
.withMessage("Not all template variables were replaced. Missing variable names are [items]");
.withMessage("Not all variables were replaced in the template. Missing variable names are: [items].");
}
@Test

View File

@@ -18,7 +18,6 @@ package org.springframework.ai.prompt;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -45,7 +44,7 @@ class PromptTests {
// Try to render with missing value for template variable, expect exception
Assertions.assertThatThrownBy(() -> pt.render(model))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Not all template variables were replaced. Missing variable names are [lastName]");
.hasMessage("Not all variables were replaced in the template. Missing variable names are: [lastName].");
pt.add("lastName", "Park"); // TODO investigate partial
String promptString = pt.render(model);
@@ -93,44 +92,6 @@ class PromptTests {
}
@Test
void testSingleInputVariable() {
String template = "This is a {foo} test";
PromptTemplate promptTemplate = new PromptTemplate(template);
Set<String> inputVariables = promptTemplate.getInputVariables();
assertThat(inputVariables).isNotEmpty();
assertThat(inputVariables).hasSize(1);
assertThat(inputVariables).contains("foo");
}
@Test
void testMultipleInputVariables() {
String template = "This {bar} is a {foo} test";
PromptTemplate promptTemplate = new PromptTemplate(template);
Set<String> inputVariables = promptTemplate.getInputVariables();
assertThat(inputVariables).isNotEmpty();
assertThat(inputVariables).hasSize(2);
assertThat(inputVariables).contains("foo", "bar");
}
@Test
void testMultipleInputVariablesWithRepeats() {
String template = "This {bar} is a {foo} test {foo}.";
PromptTemplate promptTemplate = new PromptTemplate(template);
Set<String> inputVariables = promptTemplate.getInputVariables();
assertThat(inputVariables).isNotEmpty();
assertThat(inputVariables).hasSize(2);
assertThat(inputVariables).contains("foo", "bar");
}
@Test
void testBadFormatOfTemplateString() {
String template = "This is a {foo test";
Assertions.assertThatThrownBy(() -> new PromptTemplate(template))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("The template string is not valid.");
}
@Test
public void testPromptCopy() {
String template = "Hello, {name}! Your age is {age}.";

View File

@@ -0,0 +1,39 @@
/*
* 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.template;
import java.util.Map;
import org.springframework.util.Assert;
/**
* No-op implementation of {@link TemplateRenderer} that returns the template unchanged.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class NoOpTemplateRenderer implements TemplateRenderer {
@Override
public String apply(String template, Map<String, Object> variables) {
Assert.hasText(template, "template cannot be null or empty");
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
return template;
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.template;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Renders a template using a given strategy.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface TemplateRenderer extends BiFunction<String, Map<String, Object>, String> {
@Override
String apply(String template, Map<String, Object> variables);
}

View File

@@ -0,0 +1,43 @@
/*
* 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.template;
/**
* Validation modes for template renderers.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public enum ValidationMode {
/**
* If the validation fails, an exception is thrown. This is the default mode.
*/
THROW,
/**
* If the validation fails, a warning is logged. The template is rendered with the
* missing placeholders/variables. This mode is not recommended for production use.
*/
WARN,
/**
* No validation is performed.
*/
NONE;
}

View File

@@ -0,0 +1,117 @@
/*
* 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.template;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link NoOpTemplateRenderer}.
*
* @author Thomas Vitale
*/
class NoOpTemplateRendererTests {
@Test
void shouldReturnUnchangedTemplate() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
String result = renderer.apply("Hello {name}!", variables);
assertThat(result).isEqualTo("Hello {name}!");
}
@Test
void shouldReturnUnchangedTemplateWithMultipleVariables() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
variables.put("name", "Spring AI");
variables.put("punctuation", "!");
String result = renderer.apply("{greeting} {name}{punctuation}", variables);
assertThat(result).isEqualTo("{greeting} {name}{punctuation}");
}
@Test
void shouldNotAcceptEmptyTemplate() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
Map<String, Object> variables = new HashMap<>();
assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void shouldNotAcceptNullTemplate() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
Map<String, Object> variables = new HashMap<>();
assertThatThrownBy(() -> renderer.apply(null, variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void shouldNotAcceptNullVariables() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
String template = "Hello!";
assertThatThrownBy(() -> renderer.apply(template, null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables cannot be null");
}
@Test
void shouldNotAcceptVariablesWithNullKeySet() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
String template = "Hello!";
Map<String, Object> variables = new HashMap<String, Object>();
variables.put(null, "Spring AI");
assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables keys cannot be null");
}
@Test
void shouldReturnUnchangedComplexTemplate() {
NoOpTemplateRenderer renderer = new NoOpTemplateRenderer();
Map<String, Object> variables = new HashMap<>();
variables.put("header", "Welcome");
variables.put("user", "Spring AI");
variables.put("items", "one, two, three");
variables.put("footer", "Goodbye");
String template = """
{header}
User: {user}
Items: {items}
{footer}
""";
String result = renderer.apply(template, variables);
assertThat(result).isEqualToNormalizingNewlines(template);
}
}

View File

@@ -43,6 +43,11 @@ To use this automation:
This approach can save time and reduce the chance of errors when upgrading multiple projects or complex codebases.
[[upgrading-to-1-0-0-m8]]
== Upgrading to 1.0.0-M8
* 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.
[[upgrading-to-1-0-0-m7]]
== Upgrading to 1.0.0-M7

View File

@@ -47,6 +47,12 @@
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-template-st</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation</artifactId>
@@ -63,12 +69,6 @@
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>ST4</artifactId>
<version>${ST4.version}</version>
</dependency>
<!-- ANTLR for Filter Expression Parsing -->
<dependency>
<groupId>org.antlr</groupId>

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,16 +20,14 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.antlr.runtime.Token;
import org.antlr.runtime.TokenStream;
import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.compiler.STLexer;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.util.Assert;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
@@ -37,111 +35,123 @@ import org.springframework.ai.content.Media;
import org.springframework.core.io.Resource;
import org.springframework.util.StreamUtils;
/**
* 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
* variables.
*/
public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
/**
* @deprecated will become private in the next release. If you're subclassing this
* class, re-consider using the built-in implementation together with the new
* PromptTemplateRenderer interface, designed to give you more flexibility and control
* over the rendering process.
*/
@Deprecated
protected String template;
/**
* @deprecated in favor of {@link TemplateRenderer}
*/
@Deprecated
protected TemplateFormat templateFormat = TemplateFormat.ST;
private ST st;
private final Map<String, Object> variables = new HashMap<>();
private Map<String, Object> dynamicModel = new HashMap<>();
private final TemplateRenderer renderer;
public PromptTemplate(Resource resource) {
try (InputStream inputStream = resource.getInputStream()) {
this.template = StreamUtils.copyToString(inputStream, Charset.defaultCharset());
}
catch (IOException ex) {
throw new RuntimeException("Failed to read resource", ex);
}
try {
this.st = new ST(this.template, '{', '}');
}
catch (Exception ex) {
throw new IllegalArgumentException("The template string is not valid.", ex);
}
this(resource, new HashMap<>(), DEFAULT_TEMPLATE_RENDERER);
}
public PromptTemplate(String template) {
this.template = template;
// If the template string is not valid, an exception will be thrown
try {
this.st = new ST(this.template, '{', '}');
}
catch (Exception ex) {
throw new IllegalArgumentException("The template string is not valid.", ex);
}
this(template, new HashMap<>(), DEFAULT_TEMPLATE_RENDERER);
}
public PromptTemplate(String template, Map<String, Object> model) {
this.template = template;
// If the template string is not valid, an exception will be thrown
try {
this.st = new ST(this.template, '{', '}');
for (Entry<String, Object> entry : model.entrySet()) {
add(entry.getKey(), entry.getValue());
}
}
catch (Exception ex) {
throw new IllegalArgumentException("The template string is not valid.", ex);
}
/**
* @deprecated in favor of {@link PromptTemplate#builder()}.
*/
@Deprecated
public PromptTemplate(String template, Map<String, Object> variables) {
this(template, variables, DEFAULT_TEMPLATE_RENDERER);
}
public PromptTemplate(Resource resource, Map<String, Object> model) {
/**
* @deprecated in favor of {@link PromptTemplate#builder()}.
*/
@Deprecated
public PromptTemplate(Resource resource, Map<String, Object> variables) {
this(resource, variables, DEFAULT_TEMPLATE_RENDERER);
}
PromptTemplate(String template, Map<String, Object> variables, TemplateRenderer renderer) {
Assert.hasText(template, "template cannot be null or empty");
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
Assert.notNull(renderer, "renderer cannot be null");
this.template = template;
this.variables.putAll(variables);
this.renderer = renderer;
}
PromptTemplate(Resource resource, Map<String, Object> variables, TemplateRenderer renderer) {
Assert.notNull(resource, "resource cannot be null");
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
Assert.notNull(renderer, "renderer cannot be null");
try (InputStream inputStream = resource.getInputStream()) {
this.template = StreamUtils.copyToString(inputStream, Charset.defaultCharset());
Assert.hasText(template, "template cannot be null or empty");
}
catch (IOException ex) {
throw new RuntimeException("Failed to read resource", ex);
}
// If the template string is not valid, an exception will be thrown
try {
this.st = new ST(this.template, '{', '}');
for (Entry<String, Object> entry : model.entrySet()) {
this.add(entry.getKey(), entry.getValue());
}
}
catch (Exception ex) {
throw new IllegalArgumentException("The template string is not valid.", ex);
}
this.variables.putAll(variables);
this.renderer = renderer;
}
public void add(String name, Object value) {
this.st.add(name, value);
this.dynamicModel.put(name, value);
this.variables.put(name, value);
}
public String getTemplate() {
return this.template;
}
/**
* @deprecated in favor of {@link TemplateRenderer}
*/
@Deprecated
public TemplateFormat getTemplateFormat() {
return this.templateFormat;
}
// Render Methods
// From PromptTemplateStringActions.
@Override
public String render() {
validate(this.dynamicModel);
return this.st.render();
return this.renderer.apply(template, this.variables);
}
@Override
public String render(Map<String, Object> model) {
validate(model);
for (Entry<String, Object> entry : model.entrySet()) {
if (this.st.getAttribute(entry.getKey()) != null) {
this.st.remove(entry.getKey());
}
public String render(Map<String, Object> additionalVariables) {
Map<String, Object> combinedVariables = new HashMap<>(this.variables);
for (Entry<String, Object> entry : additionalVariables.entrySet()) {
if (entry.getValue() instanceof Resource) {
this.st.add(entry.getKey(), renderResource((Resource) entry.getValue()));
combinedVariables.put(entry.getKey(), renderResource((Resource) entry.getValue()));
}
else {
this.st.add(entry.getKey(), entry.getValue());
combinedVariables.put(entry.getKey(), entry.getValue());
}
}
return this.st.render();
return this.renderer.apply(template, combinedVariables);
}
private String renderResource(Resource resource) {
@@ -153,6 +163,8 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
}
// From PromptTemplateMessageActions.
@Override
public Message createMessage() {
return new UserMessage(render());
@@ -160,14 +172,16 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
@Override
public Message createMessage(List<Media> mediaList) {
return new UserMessage(render(), mediaList);
return UserMessage.builder().text(render()).media(mediaList).build();
}
@Override
public Message createMessage(Map<String, Object> model) {
return new UserMessage(render(model));
public Message createMessage(Map<String, Object> additionalVariables) {
return new UserMessage(render(additionalVariables));
}
// From PromptTemplateActions.
@Override
public Prompt create() {
return new Prompt(render(new HashMap<>()));
@@ -175,63 +189,93 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
@Override
public Prompt create(ChatOptions modelOptions) {
return new Prompt(render(new HashMap<>()), modelOptions);
return Prompt.builder().content(render(new HashMap<>())).chatOptions(modelOptions).build();
}
@Override
public Prompt create(Map<String, Object> model) {
return new Prompt(render(model));
public Prompt create(Map<String, Object> additionalVariables) {
return new Prompt(render(additionalVariables));
}
@Override
public Prompt create(Map<String, Object> model, ChatOptions modelOptions) {
return new Prompt(render(model), modelOptions);
public Prompt create(Map<String, Object> additionalVariables, ChatOptions modelOptions) {
return Prompt.builder().content(render(additionalVariables)).chatOptions(modelOptions).build();
}
// Compatibility
/**
* @deprecated in favor of {@link TemplateRenderer}.
*/
@Deprecated
public Set<String> getInputVariables() {
TokenStream tokens = this.st.impl.tokens;
Set<String> inputVariables = new HashSet<>();
boolean isInsideList = false;
for (int i = 0; i < tokens.size(); i++) {
Token token = tokens.get(i);
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
&& tokens.get(i + 1).getType() == STLexer.ID) {
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
inputVariables.add(tokens.get(i + 1).getText());
isInsideList = true;
}
}
else if (token.getType() == STLexer.RDELIM) {
isInsideList = false;
}
else if (!isInsideList && token.getType() == STLexer.ID) {
inputVariables.add(token.getText());
}
}
return inputVariables;
}
private Set<String> getModelKeys(Map<String, Object> model) {
Set<String> dynamicVariableNames = new HashSet<>(this.dynamicModel.keySet());
Set<String> modelVariables = new HashSet<>(model.keySet());
modelVariables.addAll(dynamicVariableNames);
return modelVariables;
throw new UnsupportedOperationException(
"The template rendering logic is now provided by PromptTemplateRenderer");
}
/**
* @deprecated in favor of {@link TemplateRenderer}.
*/
@Deprecated
protected void validate(Map<String, Object> model) {
throw new UnsupportedOperationException("Validation is now provided by the PromptTemplateRenderer");
}
Set<String> templateTokens = getInputVariables();
Set<String> modelKeys = getModelKeys(model);
public Builder mutate() {
return new Builder().template(this.template).variables(this.variables).renderer(this.renderer);
}
// Check if model provides all keys required by the template
if (!modelKeys.containsAll(templateTokens)) {
templateTokens.removeAll(modelKeys);
throw new IllegalStateException(
"Not all template variables were replaced. Missing variable names are " + templateTokens);
// Builder
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String template;
private Resource resource;
private Map<String, Object> variables = new HashMap<>();
private TemplateRenderer renderer = DEFAULT_TEMPLATE_RENDERER;
private Builder() {
}
public Builder template(String template) {
this.template = template;
return this;
}
public Builder resource(Resource resource) {
this.resource = resource;
return this;
}
public Builder variables(Map<String, Object> variables) {
this.variables = variables;
return this;
}
public Builder renderer(TemplateRenderer renderer) {
this.renderer = renderer;
return this;
}
public PromptTemplate build() {
if (this.template != null && this.resource != null) {
throw new IllegalArgumentException("Only one of template or resource can be set");
}
else if (this.resource != null) {
return new PromptTemplate(this.resource, this.variables, this.renderer);
}
else {
return new PromptTemplate(this.template, this.variables, this.renderer);
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,12 @@
package org.springframework.ai.chat.prompt;
import org.springframework.ai.template.TemplateRenderer;
/**
* @deprecated in favor of {@link TemplateRenderer}.
*/
@Deprecated
public enum TemplateFormat {
ST("ST");

View File

@@ -0,0 +1,33 @@
/*
* 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.template;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Renders a template using a given strategy.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface TemplateRenderer extends BiFunction<String, Map<String, Object>, String> {
@Override
String apply(String template, Map<String, Object> variables);
}

View File

@@ -0,0 +1,189 @@
/*
* 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 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;
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;
/**
* Unit tests for {@link PromptTemplate}.
*
* @author Thomas Vitale
*/
class PromptTemplateTests {
@Test
void createWithValidTemplate() {
String template = "Hello {name}!";
PromptTemplate promptTemplate = new PromptTemplate(template);
assertThat(promptTemplate.getTemplate()).isEqualTo(template);
}
@Test
void createWithEmptyTemplate() {
assertThatThrownBy(() -> new PromptTemplate("")).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void createWithNullTemplate() {
String template = null;
assertThatThrownBy(() -> new PromptTemplate(template)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void createWithValidResource() {
String content = "Hello {name}!";
Resource resource = new ByteArrayResource(content.getBytes());
PromptTemplate promptTemplate = new PromptTemplate(resource);
assertThat(promptTemplate.getTemplate()).isEqualTo(content);
}
@Test
void createWithNullResource() {
Resource resource = null;
assertThatThrownBy(() -> new PromptTemplate(resource)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("resource cannot be null");
}
@Test
void createWithNullVariables() {
String template = "Hello!";
Map<String, Object> variables = null;
assertThatThrownBy(() -> new PromptTemplate(template, variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables cannot be null");
}
@Test
void createWithNullVariableKeys() {
String template = "Hello!";
Map<String, Object> variables = new HashMap<>();
variables.put(null, "value");
assertThatThrownBy(() -> new PromptTemplate(template, variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables keys cannot be null");
}
@Test
void addVariable() {
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
promptTemplate.add("name", "Spring AI");
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithoutVariables() {
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
assertThat(promptTemplate.render()).isEqualTo("Hello!");
}
@Test
void renderWithVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!", variables);
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithAdditionalVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
PromptTemplate promptTemplate = new PromptTemplate("{greeting} {name}!", variables);
Map<String, Object> additionalVariables = new HashMap<>();
additionalVariables.put("name", "Spring AI");
assertThat(promptTemplate.render(additionalVariables)).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithResourceVariable() {
String resourceContent = "Spring AI";
Resource resource = new ByteArrayResource(resourceContent.getBytes());
Map<String, Object> variables = new HashMap<>();
variables.put("content", resource);
PromptTemplate promptTemplate = new PromptTemplate("Hello {content}!");
assertThat(promptTemplate.render(variables)).isEqualTo("Hello Spring AI!");
}
@Test
void createMessageWithoutVariables() {
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
Message message = promptTemplate.createMessage();
assertThat(message).isInstanceOf(UserMessage.class);
assertThat(message.getText()).isEqualTo("Hello!");
}
@Test
void createMessageWithVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
Message message = promptTemplate.createMessage(variables);
assertThat(message).isInstanceOf(UserMessage.class);
assertThat(message.getText()).isEqualTo("Hello Spring AI!");
}
@Test
void createPromptWithoutVariables() {
PromptTemplate promptTemplate = new PromptTemplate("Hello!");
Prompt prompt = promptTemplate.create();
assertThat(prompt.getContents()).isEqualTo("Hello!");
}
@Test
void createPromptWithVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
Prompt prompt = promptTemplate.create(variables);
assertThat(prompt.getContents()).isEqualTo("Hello Spring AI!");
}
@Test
void createWithCustomRenderer() {
TemplateRenderer customRenderer = new NoOpTemplateRenderer();
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
.renderer(customRenderer)
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello {name}!");
}
@Test
void builderShouldNotAllowBothTemplateAndResource() {
String template = "Hello!";
Resource resource = new ByteArrayResource(template.getBytes());
assertThatThrownBy(() -> PromptTemplate.builder().template(template).resource(resource).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Only one of template or resource can be set");
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>spring-ai-template-st</artifactId>
<packaging>jar</packaging>
<name>Spring AI Template StringTemplate</name>
<description>StringTemplate implementation for Spring AI templating</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-commons</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>ST4</artifactId>
<version>${ST4.version}</version>
</dependency>
<!-- ANTLR for token parsing -->
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>${antlr.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,167 @@
/*
* 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.template.st;
import org.antlr.runtime.Token;
import org.antlr.runtime.TokenStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.ValidationMode;
import org.springframework.util.Assert;
import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.compiler.STLexer;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Renders a template using the StringTemplate (ST) library.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class StTemplateRenderer implements TemplateRenderer {
private static final Logger logger = LoggerFactory.getLogger(StTemplateRenderer.class);
private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
private final char startDelimiterToken;
private final char endDelimiterToken;
private final ValidationMode validationMode;
StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode) {
Assert.notNull(validationMode, "validationMode cannot be null");
this.startDelimiterToken = startDelimiterToken;
this.endDelimiterToken = endDelimiterToken;
this.validationMode = validationMode;
}
@Override
public String apply(String template, Map<String, Object> variables) {
Assert.hasText(template, "template cannot be null or empty");
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
ST st = createST(template);
for (Map.Entry<String, Object> entry : variables.entrySet()) {
st.add(entry.getKey(), entry.getValue());
}
if (validationMode != ValidationMode.NONE) {
validate(st, variables);
}
return st.render();
}
private ST createST(String template) {
try {
return new ST(template, startDelimiterToken, endDelimiterToken);
}
catch (Exception ex) {
throw new IllegalArgumentException("The template string is not valid.", ex);
}
}
private void validate(ST st, Map<String, Object> templateVariables) {
Set<String> templateTokens = getInputVariables(st);
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
// Check if model provides all keys required by the template
if (!modelKeys.containsAll(templateTokens)) {
templateTokens.removeAll(modelKeys);
if (validationMode == ValidationMode.WARN) {
logger.warn(VALIDATION_MESSAGE.formatted(templateTokens));
}
else if (validationMode == ValidationMode.THROW) {
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens));
}
}
}
private Set<String> getInputVariables(ST st) {
TokenStream tokens = st.impl.tokens;
Set<String> inputVariables = new HashSet<>();
boolean isInsideList = false;
for (int i = 0; i < tokens.size(); i++) {
Token token = tokens.get(i);
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
&& tokens.get(i + 1).getType() == STLexer.ID) {
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
inputVariables.add(tokens.get(i + 1).getText());
isInsideList = true;
}
}
else if (token.getType() == STLexer.RDELIM) {
isInsideList = false;
}
else if (!isInsideList && token.getType() == STLexer.ID) {
inputVariables.add(token.getText());
}
}
return inputVariables;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private char startDelimiterToken = DEFAULT_START_DELIMITER_TOKEN;
private char endDelimiterToken = DEFAULT_END_DELIMITER_TOKEN;
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
private Builder() {
}
public Builder startDelimiterToken(char startDelimiterToken) {
this.startDelimiterToken = startDelimiterToken;
return this;
}
public Builder endDelimiterToken(char endDelimiterToken) {
this.endDelimiterToken = endDelimiterToken;
return this;
}
public Builder validationMode(ValidationMode validationMode) {
this.validationMode = validationMode;
return this;
}
public StTemplateRenderer build() {
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode);
}
}
}

View File

@@ -0,0 +1,283 @@
/*
* 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.template.st;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.ai.template.ValidationMode;
/**
* Unit tests for {@link StTemplateRenderer}.
*
* @author Thomas Vitale
*/
class StTemplateRendererTests {
@Test
void shouldNotAcceptNullValidationMode() {
assertThatThrownBy(() -> StTemplateRenderer.builder().validationMode(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("validationMode cannot be null");
}
@Test
void shouldUseDefaultValuesWhenUsingBuilder() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
assertThat(ReflectionTestUtils.getField(renderer, "startDelimiterToken")).isEqualTo('{');
assertThat(ReflectionTestUtils.getField(renderer, "endDelimiterToken")).isEqualTo('}');
assertThat(ReflectionTestUtils.getField(renderer, "validationMode")).isEqualTo(ValidationMode.THROW);
}
@Test
void shouldRenderTemplateWithSingleVariable() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
String result = renderer.apply("Hello {name}!", variables);
assertThat(result).isEqualTo("Hello Spring AI!");
}
@Test
void shouldRenderTemplateWithMultipleVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
variables.put("name", "Spring AI");
variables.put("punctuation", "!");
String result = renderer.apply("{greeting} {name}{punctuation}", variables);
assertThat(result).isEqualTo("Hello Spring AI!");
}
@Test
void shouldNotRenderEmptyTemplate() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void shouldNotAcceptNullVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables cannot be null");
}
@Test
void shouldNotAcceptVariablesWithNullKeySet() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
String template = "Hello!";
Map<String, Object> variables = new HashMap<String, Object>();
variables.put(null, "Spring AI");
assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables keys cannot be null");
}
@Test
void shouldThrowExceptionForInvalidTemplateSyntax() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The template string is not valid.");
}
@Test
void shouldThrowExceptionForMissingVariablesInThrowMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
assertThatThrownBy(() -> renderer.apply("{greeting} {name}!", variables))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining(
"Not all variables were replaced in the template. Missing variable names are: [name]");
}
@Test
void shouldContinueRenderingWithMissingVariablesInWarnMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.WARN).build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
String result = renderer.apply("{greeting} {name}!", variables);
assertThat(result).isEqualTo("Hello !");
}
@Test
void shouldRenderWithoutValidationInNoneMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
String result = renderer.apply("{greeting} {name}!", variables);
assertThat(result).isEqualTo("Hello !");
}
@Test
void shouldRenderWithCustomDelimiters() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build();
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
String result = renderer.apply("Hello <name>!", variables);
assertThat(result).isEqualTo("Hello Spring AI!");
}
@Test
void shouldHandleSpecialCharactersAsDelimiters() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.startDelimiterToken('$')
.endDelimiterToken('$')
.build();
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
String result = renderer.apply("Hello $name$!", variables);
assertThat(result).isEqualTo("Hello Spring AI!");
}
/**
* Tests that complex multi-line template structures with multiple variables are
* rendered correctly with proper whitespace and newline handling.
*/
@Test
void shouldHandleComplexTemplateStructures() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("header", "Welcome");
variables.put("user", "Spring AI");
variables.put("items", "one, two, three");
variables.put("footer", "Goodbye");
String result = renderer.apply("""
{header}
User: {user}
Items: {items}
{footer}
""", variables);
assertThat(result).isEqualToNormalizingNewlines("""
Welcome
User: Spring AI
Items: one, two, three
Goodbye
""");
}
/**
* Tests that StringTemplate list variables with separators are correctly handled.
* Note: Uses NONE validation mode because the current implementation of
* getInputVariables incorrectly treats template options like 'separator' as variables
* to be resolved.
*/
@Test
void shouldHandleListVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
Map<String, Object> variables = new HashMap<>();
variables.put("items", new String[] { "apple", "banana", "cherry" });
String result = renderer.apply("Items: {items; separator=\", \"}", variables);
assertThat(result).isEqualTo("Items: apple, banana, cherry");
}
/**
* Tests rendering with StringTemplate options. Note: This uses NONE validation mode
* because the current implementation of getInputVariables incorrectly treats template
* options like 'separator' as variables to be resolved.
*/
@Test
void shouldRenderTemplateWithOptions() {
// Use NONE validation mode to bypass the issue with option detection
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
Map<String, Object> variables = new HashMap<>();
variables.put("fruits", new String[] { "apple", "banana", "cherry" });
variables.put("count", 3);
// Template with separator option for list formatting
String result = renderer.apply("Fruits: {fruits; separator=\", \"}, Count: {count}", variables);
// Verify the template was rendered correctly
assertThat(result).isEqualTo("Fruits: apple, banana, cherry, Count: 3");
// Verify specific elements to ensure the list was processed
assertThat(result).contains("apple");
assertThat(result).contains("banana");
assertThat(result).contains("cherry");
}
/**
* Tests that numeric variables (both integer and floating-point) are correctly
* converted to strings during template rendering.
*/
@Test
void shouldHandleNumericVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("integer", 42);
variables.put("float", 3.14);
String result = renderer.apply("Integer: {integer}, Float: {float}", variables);
assertThat(result).isEqualTo("Integer: 42, Float: 3.14");
}
/**
* Tests handling of object variables using StringTemplate's map access syntax. Since
* ST4 doesn't support direct property access like "person.name", we test both flat
* properties and alternative methods of accessing nested properties.
*/
@Test
void shouldHandleObjectVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
// Add flattened properties directly
variables.put("name", "John");
variables.put("age", 30);
// StringTemplate doesn't support person.name direct access
// so we use flat properties instead
String result = renderer.apply("Person: {name}, Age: {age}", variables);
assertThat(result).isEqualTo("Person: John, Age: 30");
}
}