From 654e22be9d174b43e3bc51a6d1457ea2adcec17a Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 11 May 2024 15:36:38 +0300 Subject: [PATCH] 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 --- ...trizedTypeReferencefOutputConverterIT.java | 109 ++++++++++ ...meterizedTypeReferenceOutputConverter.java | 179 +++++++++++++++++ .../TypeReferenceOutputConverterTest.java | 190 ++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java new file mode 100644 index 000000000..146715f34 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java @@ -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 movies) { + } + + @Test + void typeRefOutputConverterRecords() { + + ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + + 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 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> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + + 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 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); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java new file mode 100644 index 000000000..a0b792619 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java @@ -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 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 implements StructuredOutputConverter { + + /** 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 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 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 typeRef, ObjectMapper objectMapper) { + this(new CustomizedTypeReference<>(typeRef), objectMapper); + } + + private static class CustomizedTypeReference extends TypeReference { + + private final Type type; + + CustomizedTypeReference(ParameterizedTypeReference 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 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); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java b/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java new file mode 100644 index 000000000..abddd1567 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java @@ -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() { + }); + 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() { + }); + 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 = 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() { + }); + 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 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() { + }); + 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() { + }); + 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() { + }); + + 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; + } + + } + +} \ No newline at end of file