Move template renderers to dedicated modules

- Move NoOpTemplateRenderer to spring-ai-commons
- Create spring-ai-template-st module for StringTemplate implementation
- Update dependencies in spring-ai-model
This commit is contained in:
Mark Pollack
2025-04-24 16:53:05 -04:00
parent 5628f050f2
commit b7207a0ca3
10 changed files with 259 additions and 36 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

@@ -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

@@ -29,7 +29,7 @@ import org.junit.jupiter.api.Test;
*
* @author Thomas Vitale
*/
class NoOpPromptTemplateRendererTests {
class NoOpTemplateRendererTests {
@Test
void shouldReturnUnchangedTemplate() {
@@ -114,4 +114,4 @@ class NoOpPromptTemplateRendererTests {
assertThat(result).isEqualToNormalizingNewlines(template);
}
}
}

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

@@ -25,6 +25,7 @@ import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.springframework.ai.template.NoOpTemplateRenderer;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.util.Assert;

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

@@ -21,6 +21,7 @@ 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;
@@ -127,27 +128,6 @@ public class StTemplateRenderer implements TemplateRenderer {
return inputVariables;
}
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;
}
public static Builder builder() {
return new Builder();
}
@@ -184,4 +164,4 @@ public class StTemplateRenderer implements TemplateRenderer {
}
}
}

View File

@@ -24,13 +24,14 @@ 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 STPromptTemplateRendererTests {
class StTemplateRendererTests {
@Test
void shouldNotAcceptNullValidationMode() {
@@ -46,7 +47,7 @@ class STPromptTemplateRendererTests {
assertThat(ReflectionTestUtils.getField(renderer, "startDelimiterToken")).isEqualTo('{');
assertThat(ReflectionTestUtils.getField(renderer, "endDelimiterToken")).isEqualTo('}');
assertThat(ReflectionTestUtils.getField(renderer, "validationMode"))
.isEqualTo(StTemplateRenderer.ValidationMode.THROW);
.isEqualTo(ValidationMode.THROW);
}
@Test
@@ -125,7 +126,7 @@ class STPromptTemplateRendererTests {
@Test
void shouldContinueRenderingWithMissingVariablesInWarnMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(StTemplateRenderer.ValidationMode.WARN)
.validationMode(ValidationMode.WARN)
.build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
@@ -138,7 +139,7 @@ class STPromptTemplateRendererTests {
@Test
void shouldRenderWithoutValidationInNoneMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(StTemplateRenderer.ValidationMode.NONE)
.validationMode(ValidationMode.NONE)
.build();
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
@@ -176,6 +177,10 @@ class STPromptTemplateRendererTests {
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();
@@ -200,4 +205,87 @@ class STPromptTemplateRendererTests {
""");
}
}
/**
* 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");
}
}