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:
committed by
Mark Pollack
parent
b0d671944a
commit
5527d037f2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user