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

@@ -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.
@@ -43,6 +43,7 @@ import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters;
import org.springframework.ai.openai.api.tool.MockWeatherService;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.ai.test.CurlyBracketEscaper;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.beans.factory.annotation.Value;
@@ -378,6 +379,124 @@ class OpenAiChatClientIT extends AbstractIT {
logger.info("Response: " + response);
}
@Test
void customTemplateRendererWithCall() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
String result = ChatClient.create(this.chatModel).prompt()
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
// @formatter:on
assertThat(result).isNotEmpty();
ActorsFilms actorsFilms = outputConverter.convert(result);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void customTemplateRendererWithCallAndAdvisor() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
String result = ChatClient.create(this.chatModel).prompt()
.advisors(new SimpleLoggerAdvisor())
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.call()
.content();
// @formatter:on
assertThat(result).isNotEmpty();
ActorsFilms actorsFilms = outputConverter.convert(result);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void customTemplateRendererWithStream() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
Flux<ChatResponse> chatResponse = ChatClient.create(this.chatModel)
.prompt()
.options(OpenAiChatOptions.builder().streamUsage(true).build())
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.stream()
.chatResponse();
List<ChatResponse> chatResponses = chatResponse.collectList()
.block()
.stream()
.toList();
String generationTextFromStream = chatResponses
.stream()
.filter(cr -> cr.getResult() != null)
.map(cr -> cr.getResult().getOutput().getText())
.collect(Collectors.joining());
// @formatter:on
ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void customTemplateRendererWithStreamAndAdvisor() {
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
// @formatter:off
Flux<ChatResponse> chatResponse = ChatClient.create(this.chatModel)
.prompt()
.options(OpenAiChatOptions.builder().streamUsage(true).build())
.advisors(new SimpleLoggerAdvisor())
.user(u -> u
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
+ "<format>")
.param("format", outputConverter.getFormat()))
.templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.stream()
.chatResponse();
List<ChatResponse> chatResponses = chatResponse.collectList()
.block()
.stream()
.toList();
String generationTextFromStream = chatResponses
.stream()
.filter(cr -> cr.getResult() != null)
.map(cr -> cr.getResult().getOutput().getText())
.collect(Collectors.joining());
// @formatter:on
ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
record ActorsFilms(String actor, List<String> movies) {
}