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:
committed by
Mark Pollack
parent
f561b63493
commit
2bc29dab32
1
pom.xml
1
pom.xml
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}.";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
77
spring-ai-template-st/pom.xml
Normal file
77
spring-ai-template-st/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user