Merge ParametrizedTypeReferenceBeanOutputConverter into BeanOutputConverter

Add ParametrizedTypeReference constructoers along the Clas<T> such.
  Convert the Class into ParametrizedTypeReference internally.
  Update tests and docs.
This commit is contained in:
Christian Tzolov
2024-05-18 04:50:20 +02:00
parent d026614316
commit 517df45cd8
9 changed files with 227 additions and 436 deletions

View File

@@ -298,7 +298,8 @@ class OpenAiChatClientIT extends AbstractIT {
.map(AssistantMessage::getContent)
.collect(Collectors.joining());
logger.info("Response: {}", content);
assertThat(content).contains("bananas", "apple", "bowl");
assertThat(content).contains("bananas", "apple");
assertThat(content).containsAnyOf("bowl", "basket");
}
}

View File

@@ -29,7 +29,7 @@ import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.converter.ParameterizedTypeReferenceOutputConverter;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.boot.test.context.SpringBootTest;
@@ -39,10 +39,10 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends AbstractIT {
class OpenAiChatClientTypeReferenceBeanOutputConverterIT extends AbstractIT {
private static final Logger logger = LoggerFactory
.getLogger(OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.class);
.getLogger(OpenAiChatClientTypeReferenceBeanOutputConverterIT.class);
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@@ -50,7 +50,7 @@ class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends Abstra
@Test
void typeRefOutputConverterRecords() {
ParameterizedTypeReferenceOutputConverter<List<ActorsFilmsRecord>> outputConverter = new ParameterizedTypeReferenceOutputConverter<>(
BeanOutputConverter<List<ActorsFilmsRecord>> outputConverter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilmsRecord>>() {
});
@@ -75,7 +75,7 @@ class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends Abstra
@Test
void typeRefStreamOutputConverterRecords() {
ParameterizedTypeReferenceOutputConverter<List<ActorsFilmsRecord>> outputConverter = new ParameterizedTypeReferenceOutputConverter<>(
BeanOutputConverter<List<ActorsFilmsRecord>> outputConverter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilmsRecord>>() {
});

View File

@@ -68,7 +68,7 @@ public class VertexAiGeminiChatClientFunctionCallingIT {
}
@Test
// @Disabled("Google Vertex AI degraded support for parallel function calls")
@Disabled("Google Vertex AI degraded support for parallel function calls")
public void functionCallExplicitOpenApiSchema() {
UserMessage userMessage = new UserMessage(
@@ -98,8 +98,8 @@ public class VertexAiGeminiChatClientFunctionCallingIT {
""";
var promptOptions = VertexAiGeminiChatOptions.builder()
// .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO)
.withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO)
.withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO)
// .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO)
.withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService())
.withName("get_current_weather")
.withDescription("Get the current weather in a given location")
@@ -126,8 +126,8 @@ public class VertexAiGeminiChatClientFunctionCallingIT {
List<Message> messages = new ArrayList<>(List.of(userMessage));
var promptOptions = VertexAiGeminiChatOptions.builder()
.withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO)
// .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue())
// .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO)
.withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue())
.withFunctionCallbacks(List.of(
FunctionCallbackWrapper.builder(new MockWeatherService())
.withSchemaType(SchemaType.OPEN_API_SCHEMA)

View File

@@ -15,7 +15,10 @@
*/
package org.springframework.ai.converter;
import java.util.Objects;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -27,10 +30,9 @@ import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.lang.NonNull;
import java.util.Map;
import java.util.Objects;
import java.lang.reflect.Type;
import static com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON;
import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12;
@@ -38,23 +40,26 @@ import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_
/**
* An implementation of {@link StructuredOutputConverter} that transforms the LLM output
* to a specific object type using JSON schema. This parser works by generating a JSON
* schema based on a given Java class, which is then used to validate and transform the
* LLM output into the desired type.
* schema based on a given Java class or parameterized type reference, which is then used
* to validate and transform the LLM output into the desired type.
*
* @param <T> The target type to which the output will be converted.
* @author Mark Pollack
* @author Christian Tzolov
* @author Sebastian Ullrich
* @author Kirk Lund
* @author Josh Long
*/
public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
/** Holds the generated JSON schema for the target type. */
private String jsonSchema;
/** The Java class representing the target type. */
/**
* The target class type reference to which the output will be converted.
*/
@SuppressWarnings({ "FieldMayBeFinal", "rawtypes" })
private Class<T> clazz;
private TypeReference<T> typeRef;
/** The object mapper used for deserialization and other JSON operations. */
@SuppressWarnings("FieldMayBeFinal")
@@ -64,8 +69,8 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
* Constructor to initialize with the target type's class.
* @param clazz The target type's class.
*/
public BeanOutputConverter(Class<T> clazz) {
this(clazz, null);
public BeanOutputConverter(Class<T> typeClass) {
this(ParameterizedTypeReference.forType(typeClass));
}
/**
@@ -75,8 +80,53 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
* @param objectMapper Custom object mapper for JSON operations. endings.
*/
public BeanOutputConverter(Class<T> clazz, ObjectMapper objectMapper) {
Objects.requireNonNull(clazz, "Java Class cannot be null;");
this.clazz = clazz;
this(ParameterizedTypeReference.forType(clazz), objectMapper);
}
/**
* Constructor to initialize with the target class type reference.
* @param typeRef The target class type reference.
*/
public BeanOutputConverter(ParameterizedTypeReference<T> typeRef) {
this(new CustomizedTypeReference<>(typeRef), null);
}
/**
* Constructor to initialize with the target class type reference, a custom object
* mapper, and a line endings normalizer to ensure consistent line endings on any
* platform.
* @param typeRef The target class type reference.
* @param objectMapper Custom object mapper for JSON operations. endings.
*/
public BeanOutputConverter(ParameterizedTypeReference<T> typeRef, ObjectMapper objectMapper) {
this(new CustomizedTypeReference<>(typeRef), objectMapper);
}
private static class CustomizedTypeReference<T> extends TypeReference<T> {
private final Type type;
CustomizedTypeReference(ParameterizedTypeReference<T> typeRef) {
this.type = typeRef.getType();
}
@Override
public Type getType() {
return this.type;
}
}
/**
* Constructor to initialize with the target class type reference, a custom object
* mapper, and a line endings normalizer to ensure consistent line endings on any
* platform.
* @param typeRef The target class type reference.
* @param objectMapper Custom object mapper for JSON operations. endings.
*/
private BeanOutputConverter(TypeReference<T> typeRef, ObjectMapper objectMapper) {
Objects.requireNonNull(typeRef, "Type reference cannot be null;");
this.typeRef = typeRef;
this.objectMapper = objectMapper != null ? objectMapper : getObjectMapper();
generateSchema();
}
@@ -90,14 +140,14 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
.with(jacksonModule);
SchemaGeneratorConfig config = configBuilder.build();
SchemaGenerator generator = new SchemaGenerator(config);
JsonNode jsonNode = generator.generateSchema(this.clazz);
JsonNode jsonNode = generator.generateSchema(this.typeRef.getType());
ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter()
.withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator())));
try {
this.jsonSchema = objectWriter.writeValueAsString(jsonNode);
}
catch (JsonProcessingException e) {
throw new RuntimeException("Could not pretty print json schema for " + this.clazz, e);
throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e);
}
}
@@ -109,34 +159,13 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
*/
public T convert(@NonNull String text) {
try {
// If the response is a JSON Schema, extract the properties and use them as
// the response.
text = this.jsonSchemaToInstance(text);
return (T) this.objectMapper.readValue(text, this.clazz);
return (T) this.objectMapper.readValue(text, this.typeRef);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
/**
* Converts a JSON Schema to an instance based on a given text.
* @param text The JSON Schema in string format.
* @return The JSON instance generated from the JSON Schema, or the original text if
* the input is not a JSON Schema.
*/
private String jsonSchemaToInstance(String text) {
try {
Map<String, Object> map = this.objectMapper.readValue(text, Map.class);
if (map.containsKey("$schema")) {
return this.objectMapper.writeValueAsString(map.get("properties"));
}
}
catch (Exception e) {
}
return text;
}
/**
* Configures and returns an object mapper for JSON operations.
* @return Configured object mapper.

View File

@@ -1,179 +0,0 @@
/*
* Copyright 2023 - 2024 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.converter;
import java.util.Objects;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.lang.NonNull;
import java.lang.reflect.Type;
import static com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON;
import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12;
/**
* An implementation of {@link StructuredOutputConverter} that transforms the LLM output
* to a specific object type using JSON schema. This parser works by generating a JSON
* schema based on a given Java class type reference, which is then used to validate and
* transform the LLM output into the desired type.
*
* @param <T> The target type to which the output will be converted.
* @author Mark Pollack
* @author Christian Tzolov
* @author Sebastian Ullrich
* @author Kirk Lund
* @author Josh Long
*/
public class ParameterizedTypeReferenceOutputConverter<T> implements StructuredOutputConverter<T> {
/** Holds the generated JSON schema for the target type. */
private String jsonSchema;
/**
* The target class type reference to which the output will be converted.
*/
@SuppressWarnings({ "FieldMayBeFinal", "rawtypes" })
private TypeReference<T> typeRef;
/** The object mapper used for deserialization and other JSON operations. */
@SuppressWarnings("FieldMayBeFinal")
private ObjectMapper objectMapper;
/**
* Constructor to initialize with the target class type reference.
* @param typeRef The target type's class.
*/
public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference<T> typeRef) {
this(new CustomizedTypeReference<>(typeRef), null);
}
/**
* Constructor to initialize with the target class type reference, a custom object
* mapper, and a line endings normalizer to ensure consistent line endings on any
* platform.
* @param typeRef The target class type reference.
* @param objectMapper Custom object mapper for JSON operations. endings.
*/
public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference<T> typeRef, ObjectMapper objectMapper) {
this(new CustomizedTypeReference<>(typeRef), objectMapper);
}
private static class CustomizedTypeReference<T> extends TypeReference<T> {
private final Type type;
CustomizedTypeReference(ParameterizedTypeReference<T> typeRef) {
this.type = typeRef.getType();
}
@Override
public Type getType() {
return this.type;
}
}
/**
* Constructor to initialize with the target class type reference, a custom object
* mapper, and a line endings normalizer to ensure consistent line endings on any
* platform.
* @param typeRef The target class type reference.
* @param objectMapper Custom object mapper for JSON operations. endings.
*/
private ParameterizedTypeReferenceOutputConverter(TypeReference<T> typeRef, ObjectMapper objectMapper) {
Objects.requireNonNull(typeRef, "Type reference cannot be null;");
this.typeRef = typeRef;
this.objectMapper = objectMapper != null ? objectMapper : getObjectMapper();
generateSchema();
}
/**
* Generates the JSON schema for the target type.
*/
private void generateSchema() {
JacksonModule jacksonModule = new JacksonModule();
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON)
.with(jacksonModule);
SchemaGeneratorConfig config = configBuilder.build();
SchemaGenerator generator = new SchemaGenerator(config);
JsonNode jsonNode = generator.generateSchema(this.typeRef.getType());
ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter()
.withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator())));
try {
this.jsonSchema = objectWriter.writeValueAsString(jsonNode);
}
catch (JsonProcessingException e) {
throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e);
}
}
@Override
/**
* Parses the given text to transform it to the desired target type.
* @param text The LLM output in string format.
* @return The parsed output in the desired target type.
*/
public T convert(@NonNull String text) {
try {
return (T) this.objectMapper.readValue(text, this.typeRef);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
/**
* Configures and returns an object mapper for JSON operations.
* @return Configured object mapper.
*/
protected ObjectMapper getObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
/**
* Provides the expected format of the response, instructing that it should adhere to
* the generated JSON schema.
* @return The instruction format string.
*/
@Override
public String getFormat() {
String template = """
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```%s```
""";
return String.format(template, this.jsonSchema);
}
}

View File

@@ -15,9 +15,10 @@
*/
package org.springframework.ai.converter;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Nested;
@@ -26,10 +27,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.ParameterizedTypeReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* @author Sebastian Ullrich
@@ -44,43 +44,72 @@ class BeanOutputConverterTest {
@Test
public void shouldHavePreConfiguredDefaultObjectMapper() {
var converter = new BeanOutputConverter<>(TestClass.class);
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
});
var objectMapper = converter.getObjectMapper();
assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
}
@Test
public void shouldUseProvidedObjectMapperForParsing() throws JsonProcessingException {
var testClass = new TestClass("some string");
when(objectMapperMock.readValue(anyString(), eq(TestClass.class))).thenReturn(testClass);
var converter = new BeanOutputConverter<>(TestClass.class, objectMapperMock);
assertThat(converter.convert("{}")).isEqualTo(testClass);
}
@Nested
class ParserTest {
class ConverterTest {
@Test
public void shouldParseFieldNamesFromString() {
public void convertClassType() {
var converter = new BeanOutputConverter<>(TestClass.class);
var testClass = converter.convert("{ \"someString\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void shouldParseJsonPropertiesFromString() {
public void convertTypeReference() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
});
var testClass = converter.convert("{ \"someString\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void convertTypeReferenceArray() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<TestClass>>() {
});
List<TestClass> testClass = converter.convert("[{ \"someString\": \"some value\" }]");
assertThat(testClass).hasSize(1);
assertThat(testClass.get(0).getSomeString()).isEqualTo("some value");
}
@Test
public void convertClassTypeWithJsonAnnotations() {
var converter = new BeanOutputConverter<>(TestClassWithJsonAnnotations.class);
var testClass = converter.convert("{ \"string_property\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void convertTypeReferenceWithJsonAnnotations() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClassWithJsonAnnotations>() {
});
var testClass = converter.convert("{ \"string_property\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void convertTypeReferenceArrayWithJsonAnnotations() {
var converter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<TestClassWithJsonAnnotations>>() {
});
List<TestClassWithJsonAnnotations> testClass = converter
.convert("[{ \"string_property\": \"some value\" }]");
assertThat(testClass).hasSize(1);
assertThat(testClass.get(0).getSomeString()).isEqualTo("some value");
}
}
@Nested
class FormatTest {
@Test
public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() {
public void formatClassType() {
var converter = new BeanOutputConverter<>(TestClass.class);
assertThat(converter.getFormat()).isEqualTo(
"""
@@ -102,7 +131,56 @@ class BeanOutputConverterTest {
}
@Test
public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDescription() {
public void formatTypeReference() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
});
assertThat(converter.getFormat()).isEqualTo(
"""
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"someString" : {
"type" : "string"
}
}
}```
""");
}
@Test
public void formatTypeReferenceArray() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<TestClass>>() {
});
assertThat(converter.getFormat()).isEqualTo(
"""
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "array",
"items" : {
"type" : "object",
"properties" : {
"someString" : {
"type" : "string"
}
}
}
}```
""");
}
@Test
public void formatClassTypeWithAnnotations() {
var converter = new BeanOutputConverter<>(TestClassWithJsonAnnotations.class);
assertThat(converter.getFormat()).contains("""
```{
@@ -119,7 +197,25 @@ class BeanOutputConverterTest {
}
@Test
void normalizesLineEndings() {
public void formatTypeReferenceWithAnnotations() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClassWithJsonAnnotations>() {
});
assertThat(converter.getFormat()).contains("""
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"string_property" : {
"type" : "string",
"description" : "string_property_description"
}
}
}```
""");
}
@Test
void normalizesLineEndingsClassType() {
var converter = new BeanOutputConverter<>(TestClass.class);
String formatOutput = converter.getFormat();
@@ -128,6 +224,17 @@ class BeanOutputConverterTest {
assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r");
}
@Test
void normalizesLineEndingsTypeReference() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
});
String formatOutput = converter.getFormat();
// validate that output contains \n line endings
assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r");
}
}
public static class TestClass {

View File

@@ -1,190 +0,0 @@
/*
* Copyright 2023 - 2024 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.converter;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.ParameterizedTypeReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Sebastian Ullrich
* @author Kirk Lund
* @author Christian Tzolov
*/
@ExtendWith(MockitoExtension.class)
class TypeReferenceOutputConverterTest {
@Mock
private ObjectMapper objectMapperMock;
@Test
public void shouldHavePreConfiguredDefaultObjectMapper() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
});
var objectMapper = converter.getObjectMapper();
assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
}
@Nested
class ParserTest {
@Test
public void shouldParseFieldNamesFromString() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<TestClass>() {
});
var testClass = converter.convert("{ \"someString\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void shouldParseFieldNamesFromArrayString() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<List<TestClass>>() {
});
List<TestClass> testClass = converter.convert("[{ \"someString\": \"some value\" }]");
assertThat(testClass).hasSize(1);
assertThat(testClass.get(0).getSomeString()).isEqualTo("some value");
}
@Test
public void shouldParseJsonPropertiesFromString() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<TestClassWithJsonAnnotations>() {
});
var testClass = converter.convert("{ \"string_property\": \"some value\" }");
assertThat(testClass.getSomeString()).isEqualTo("some value");
}
@Test
public void shouldParseJsonPropertiesFromArrayString() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<List<TestClassWithJsonAnnotations>>() {
});
List<TestClassWithJsonAnnotations> testClass = converter
.convert("[{ \"string_property\": \"some value\" }]");
assertThat(testClass).hasSize(1);
assertThat(testClass.get(0).getSomeString()).isEqualTo("some value");
}
}
@Nested
class FormatTest {
@Test
public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<TestClass>() {
});
assertThat(converter.getFormat()).isEqualTo(
"""
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"someString" : {
"type" : "string"
}
}
}```
""");
}
@Test
public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDescription() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<TestClassWithJsonAnnotations>() {
});
assertThat(converter.getFormat()).contains("""
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"string_property" : {
"type" : "string",
"description" : "string_property_description"
}
}
}```
""");
}
@Test
void normalizesLineEndings() {
var converter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<TestClass>() {
});
String formatOutput = converter.getFormat();
// validate that output contains \n line endings
assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r");
}
}
public static class TestClass {
private String someString;
@SuppressWarnings("unused")
public TestClass() {
}
public TestClass(String someString) {
this.someString = someString;
}
public String getSomeString() {
return someString;
}
}
public static class TestClassWithJsonAnnotations {
@JsonProperty("string_property")
@JsonPropertyDescription("string_property_description")
private String someString;
public TestClassWithJsonAnnotations() {
}
public String getSomeString() {
return someString;
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View File

@@ -86,8 +86,8 @@ Currently, Spring AI provides `AbstractConversionServiceOutputConverter`, `Abstr
image::structured-output-hierarchy4.jpg[Structured Output Class Hierarchy, width=900, align="center"]
* `AbstractConversionServiceOutputConverter<T>` - Offers a pre-configured link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/convert/support/GenericConversionService.html[GenericConversionService] for transforming LLM output into the desired format. No default `FormatProvider` implementation is provided.
* `AbstractMessageOutputConverter` - Supplies a pre-configured https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jms/support/converter/MessageConverter.html[MessageConverter] for converting LLM output into the desired format. No default `FormatProvider` implementation is provided.
* `BeanOutputConverter` - Configured with a designated Java class (e.g., Bean), this converter employs a `FormatProvider` implementation that directs the AI Model to produce a JSON response compliant with a `DRAFT_2020_12`, `JSON Schema` derived from the specified Java class. Subsequently, it utilizes an `ObjectMapper` to deserialize the JSON output into a Java object instance of the target class.
* `AbstractMessageOutputConverter<T>` - Supplies a pre-configured https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jms/support/converter/MessageConverter.html[MessageConverter] for converting LLM output into the desired format. No default `FormatProvider` implementation is provided.
* `BeanOutputConverter<T>` - Configured with a designated Java class (e.g., Bean) or a link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/ParameterizedTypeReference.html[ParameterizedTypeReference], this converter employs a `FormatProvider` implementation that directs the AI Model to produce a JSON response compliant with a `DRAFT_2020_12`, `JSON Schema` derived from the specified Java class. Subsequently, it utilizes an `ObjectMapper` to deserialize the JSON output into a Java object instance of the target class.
* `MapOutputConverter` - Extends the functionality of `AbstractMessageOutputConverter` with a `FormatProvider` implementation that guides the AI Model to generate an RFC8259 compliant JSON response. Additionally, it incorporates a converter implementation that utilizes the provided `MessageConverter` to translate the JSON payload into a `java.util.Map<String, Object>` instance.
* `ListOutputConverter` - Extends the `AbstractConversionServiceOutputConverter` and includes a `FormatProvider` implementation tailored for comma-delimited list output. The converter implementation employs the provided `ConversionService` to transform the model text output into a `java.util.List`.
@@ -129,6 +129,29 @@ Generation generation = chatClient.call(
ActorsFilms actorsFilms = beanOutputConverter.convert(generation.getOutput().getContent());
----
==== Generic Bean Types
Use the `ParameterizedTypeReference` constructor to specify a more complex target class structure.
For example, to represent a list of actors and their filmographies:
[source,java]
----
BeanOutputConverter<List<ActorsFilmsRecord>> outputConverter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilmsRecord>>() { });
String format = outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks and Bill Murray.
{format}
""";
Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("format", format)).createMessage());
Generation generation = chatClient.call(prompt).getResult();
List<ActorsFilmsRecord> actorsFilms = outputConverter.convert(generation.getOutput().getContent());
----
=== Map Output Converter
Following sniped shows how to use `MapOutputConverter` to generate a list of numbers.