Configure TemplateRenderer in ChatClient

- Extend the ChatClient with a new templateRenderer() method to pass a custom TemplateRenderer object used to render user and system templates.
- Evolve the QuestionAnswerAdvisor to accept a PromptTemplate for customising the RAG prompt and templating logic while maintaining backward compatibility.
- Introduce integration tests for the QuestionAnswerAdvisor.
- Document the TemplateRenderer API and how to use it to build PromptTemplate with custom templating logic.
- Document how to customise the templating logic used internally by the ChatClient via the TemplateRendererAPI.

Add validation tests and improve PromptTemplate resource handling

Enhance robustness and reliability of the PromptTemplate class with better
resource handling and comprehensive input validation:

- Add dedicated validation tests for builder methods with null/invalid inputs
- Improve renderResource method to gracefully handle edge cases:
  - Null resources return empty string
  - ByteArrayResource handling with proper charset (UTF-8)
  - Empty resources check with proper existence test
  - Better error handling with logging instead of exception propagation
- Add input validation assertions to all Builder methods
- Fix typo in deprecated annotation comment ("fahvor" → "favor")

Update documentation to clarify template rendering in different contexts:
- Add clear notes about TemplateRenderer usage in ChatClient vs Advisors
- Document how advisor template customization differs from ChatClient template rendering
- Add comprehensive API upgrade notes for template-related deprecations
- Include detailed migration examples for PromptTemplate and QuestionAnswerAdvisor

Fixes gh-355, gh-1687, gh-2448, gh-1849, gh-1428

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-04-28 23:47:58 +02:00
committed by Mark Pollack
parent b0d671944a
commit 5527d037f2
20 changed files with 1005 additions and 100 deletions

View File

@@ -19,6 +19,7 @@ package org.springframework.ai.chat.prompt;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -33,8 +34,12 @@ import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.StreamUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 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
@@ -42,6 +47,8 @@ import org.springframework.util.StreamUtils;
*/
public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {
private static final Logger log = LoggerFactory.getLogger(PromptTemplate.class);
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
/**
@@ -80,7 +87,7 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
/**
* @deprecated in favor of {@link PromptTemplate#builder()}.
* @deprecated in fahvor of {@link PromptTemplate#builder()}.
*/
@Deprecated
public PromptTemplate(Resource resource, Map<String, Object> variables) {
@@ -135,7 +142,17 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
@Override
public String render() {
return this.renderer.apply(template, this.variables);
// Process internal variables to handle Resources before rendering
Map<String, Object> processedVariables = new HashMap<>();
for (Entry<String, Object> entry : this.variables.entrySet()) {
if (entry.getValue() instanceof Resource) {
processedVariables.put(entry.getKey(), renderResource((Resource) entry.getValue()));
}
else {
processedVariables.put(entry.getKey(), entry.getValue());
}
}
return this.renderer.apply(template, processedVariables);
}
@Override
@@ -155,11 +172,25 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
private String renderResource(Resource resource) {
if (resource == null) {
return "";
}
try {
return resource.getContentAsString(Charset.defaultCharset());
// Handle ByteArrayResource specially
if (resource instanceof ByteArrayResource byteArrayResource) {
return new String(byteArrayResource.getByteArray(), StandardCharsets.UTF_8);
}
// If the resource exists but is empty
if (!resource.exists() || resource.contentLength() == 0) {
return "";
}
// For other Resource types or as fallback
return resource.getContentAsString(StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e);
log.warn("Failed to render resource: {}", resource.getDescription(), e);
return "[Unable to render resource: " + resource.getDescription() + "]";
}
}
@@ -245,21 +276,26 @@ public class PromptTemplate implements PromptTemplateActions, PromptTemplateMess
}
public Builder template(String template) {
Assert.hasText(template, "template cannot be null or empty");
this.template = template;
return this;
}
public Builder resource(Resource resource) {
Assert.notNull(resource, "resource cannot be null");
this.resource = resource;
return this;
}
public Builder variables(Map<String, Object> variables) {
Assert.notNull(variables, "variables cannot be null");
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
this.variables = variables;
return this;
}
public Builder renderer(TemplateRenderer renderer) {
Assert.notNull(renderer, "renderer cannot be null");
this.renderer = renderer;
return this;
}

View File

@@ -0,0 +1,98 @@
/*
* 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 java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests focused on the {@link PromptTemplate.Builder} input validation and edge
* cases.
*/
class PromptTemplateBuilderTests {
@Test
void builderNullTemplateShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().template(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void builderEmptyTemplateShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().template("")).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("template cannot be null or empty");
}
@Test
void builderNullResourceShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().resource(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("resource cannot be null");
}
@Test
void builderNullVariablesShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().variables(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables cannot be null");
}
@Test
void builderNullVariableKeyShouldThrow() {
Map<String, Object> variables = new HashMap<>();
variables.put(null, "value");
assertThatThrownBy(() -> PromptTemplate.builder().variables(variables))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("variables keys cannot be null");
}
@Test
void builderNullRendererShouldThrow() {
assertThatThrownBy(() -> PromptTemplate.builder().renderer(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("renderer cannot be null");
}
@Test
void renderWithMissingVariableShouldThrow() {
// Using the default ST4 template renderer
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
// No variables provided
.build();
// Expecting an exception because 'name' is required by the template but not
// supplied
try {
promptTemplate.render();
// If render() doesn't throw, fail the test
Assertions.fail("Expected IllegalStateException was not thrown.");
}
catch (IllegalStateException e) {
// Assert that the message is exactly the expected string
assertThat(e.getMessage())
.isEqualTo("Not all variables were replaced in the template. Missing variable names are: [name].");
}
catch (Exception e) {
// Fail if any other unexpected exception is caught
Assertions.fail("Caught unexpected exception: " + e.getClass().getName());
}
}
}

View File

@@ -16,7 +16,11 @@
package org.springframework.ai.chat.prompt;
import java.util.HashMap;
import java.util.Map;
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;
@@ -24,9 +28,6 @@ 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;
@@ -161,7 +162,7 @@ class PromptTemplateTests {
void createPromptWithVariables() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!");
PromptTemplate promptTemplate = new PromptTemplate("Hello {name}!", variables);
Prompt prompt = promptTemplate.create(variables);
assertThat(prompt.getContents()).isEqualTo("Hello Spring AI!");
}
@@ -186,4 +187,130 @@ class PromptTemplateTests {
.hasMessageContaining("Only one of template or resource can be set");
}
// --- Builder Pattern Tests ---
@Test
void createWithValidTemplate_Builder() {
String template = "Hello {name}!";
PromptTemplate promptTemplate = PromptTemplate.builder().template(template).build();
// Render with the required variable to check the template string was set
// correctly
assertThat(promptTemplate.render(Map.of("name", "Test"))).isEqualTo("Hello Test!");
}
@Test
void renderWithVariables_Builder() {
Map<String, Object> variables = new HashMap<>();
variables.put("name", "Spring AI");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
.variables(variables) // Use builder's variable method
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void createWithValidResource_Builder() {
String content = "Hello {name}!";
Resource resource = new ByteArrayResource(content.getBytes());
PromptTemplate promptTemplate = PromptTemplate.builder().resource(resource).build();
// Render with the required variable to check the resource was read correctly
assertThat(promptTemplate.render(Map.of("name", "Resource"))).isEqualTo("Hello Resource!");
}
@Test
void addVariable_Builder() {
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name}!")
.variables(Map.of("name", "Spring AI")) // Use variables() method
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithoutVariables_Builder() {
PromptTemplate promptTemplate = PromptTemplate.builder().template("Hello!").build();
assertThat(promptTemplate.render()).isEqualTo("Hello!");
}
@Test
void renderWithAdditionalVariables_Builder() {
Map<String, Object> variables = new HashMap<>();
variables.put("greeting", "Hello");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("{greeting} {name}!")
.variables(variables) // Set default variables via builder
.build();
Map<String, Object> additionalVariables = new HashMap<>();
additionalVariables.put("name", "Spring AI");
// Pass additional variables during render - should merge with defaults
assertThat(promptTemplate.render(additionalVariables)).isEqualTo("Hello Spring AI!");
}
@Test
void renderWithResourceVariable_Builder() {
String resourceContent = "Spring AI";
Resource resource = new ByteArrayResource(resourceContent.getBytes());
Map<String, Object> variables = new HashMap<>();
variables.put("content", resource);
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {content}!")
.variables(variables) // Set resource variable via builder
.build();
assertThat(promptTemplate.render()).isEqualTo("Hello Spring AI!");
}
@Test
void variablesOverwriting_Builder() {
Map<String, Object> initialVars = Map.of("name", "Initial", "adj", "Good");
Map<String, Object> overwriteVars = Map.of("name", "Overwritten", "noun", "Day");
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("Hello {name} {noun}!")
.variables(initialVars) // Set initial variables
.variables(overwriteVars) // Overwrite with new variables
.build();
// Expect only variables from the last call to be present
assertThat(promptTemplate.render()).isEqualTo("Hello Overwritten Day!");
}
// Helper Custom Renderer for testing
private static class CustomTestRenderer implements TemplateRenderer {
@Override
public String apply(String template, Map<String, Object> model) {
// Simple renderer that just appends a marker
// Note: This simple renderer ignores the model map for test purposes.
return template + " (Rendered by Custom)";
}
}
@Test
void customRenderer_Builder() {
String template = "This is a test.";
TemplateRenderer customRenderer = new CustomTestRenderer();
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.renderer(customRenderer) // Set custom renderer
.build();
assertThat(promptTemplate.render()).isEqualTo(template + " (Rendered by Custom)");
}
@Test
void resource_Builder() {
String templateContent = "Hello {name} from Resource!";
Resource templateResource = new ByteArrayResource(templateContent.getBytes());
Map<String, Object> vars = Map.of("name", "Builder");
PromptTemplate promptTemplate = PromptTemplate.builder().resource(templateResource).variables(vars).build();
assertThat(promptTemplate.render()).isEqualTo("Hello Builder from Resource!");
}
}