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:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user