Add TypeReference StructuredOutputConverter

A ParameterizedTypeReference alternative of BeanOutputConverter that can cover list of beans as well.
  Based on suggestion: https://twitter.com/kisco_/status/1788862522998546440
This commit is contained in:
Christian Tzolov
2024-05-11 15:36:38 +03:00
parent 14d620e2ca
commit 654e22be9d
3 changed files with 478 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
/*
* 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.openai.chat;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.ChatResponse;
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.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.ParameterizedTypeReference;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends AbstractIT {
private static final Logger logger = LoggerFactory
.getLogger(OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.class);
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@Test
void typeRefOutputConverterRecords() {
ParameterizedTypeReferenceOutputConverter<List<ActorsFilmsRecord>> outputConverter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilmsRecord>>() {
});
String format = outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks and Bill Murray.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
List<ActorsFilmsRecord> actorsFilms = outputConverter.convert(generation.getOutput().getContent());
logger.info("" + actorsFilms);
assertThat(actorsFilms).hasSize(2);
assertThat(actorsFilms.get(0).actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.get(0).movies()).hasSize(5);
assertThat(actorsFilms.get(1).actor()).isEqualTo("Bill Murray");
assertThat(actorsFilms.get(1).movies()).hasSize(5);
}
@Test
void typeRefStreamOutputConverterRecords() {
ParameterizedTypeReferenceOutputConverter<List<ActorsFilmsRecord>> outputConverter = new ParameterizedTypeReferenceOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilmsRecord>>() {
});
String format = outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks and Bill Murray.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
String generationTextFromStream = streamingChatClient.stream(prompt)
.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(AssistantMessage::getContent)
.collect(Collectors.joining());
List<ActorsFilmsRecord> actorsFilms = outputConverter.convert(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms).hasSize(2);
assertThat(actorsFilms.get(0).actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.get(0).movies()).hasSize(5);
assertThat(actorsFilms.get(1).actor()).isEqualTo("Bill Murray");
assertThat(actorsFilms.get(1).movies()).hasSize(5);
}
}

View File

@@ -0,0 +1,179 @@
/*
* 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

@@ -0,0 +1,190 @@
/*
* 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;
}
}
}