diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java index 0366aa626..56ac4da9f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java @@ -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"); } } \ No newline at end of file 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/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java similarity index 87% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java index 146715f34..4626af1fa 100644 --- 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/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java @@ -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 movies) { } @@ -50,7 +50,7 @@ class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends Abstra @Test void typeRefOutputConverterRecords() { - ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + BeanOutputConverter> outputConverter = new BeanOutputConverter<>( new ParameterizedTypeReference>() { }); @@ -75,7 +75,7 @@ class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends Abstra @Test void typeRefStreamOutputConverterRecords() { - ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + BeanOutputConverter> outputConverter = new BeanOutputConverter<>( new ParameterizedTypeReference>() { }); diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java index d5209e82e..11b852222 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java @@ -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 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) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index a26b5e97b..aca2902b0 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -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 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 implements StructuredOutputConverter { /** 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 clazz; + private TypeReference typeRef; /** The object mapper used for deserialization and other JSON operations. */ @SuppressWarnings("FieldMayBeFinal") @@ -64,8 +69,8 @@ public class BeanOutputConverter implements StructuredOutputConverter { * Constructor to initialize with the target type's class. * @param clazz The target type's class. */ - public BeanOutputConverter(Class clazz) { - this(clazz, null); + public BeanOutputConverter(Class typeClass) { + this(ParameterizedTypeReference.forType(typeClass)); } /** @@ -75,8 +80,53 @@ public class BeanOutputConverter implements StructuredOutputConverter { * @param objectMapper Custom object mapper for JSON operations. endings. */ public BeanOutputConverter(Class 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 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 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 BeanOutputConverter(TypeReference 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 implements StructuredOutputConverter { .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 implements StructuredOutputConverter { */ 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 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. 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 deleted file mode 100644 index a0b792619..000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java +++ /dev/null @@ -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 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/BeanOutputConverterTest.java b/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java index dacad64ff..24cfd0a5c 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java @@ -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() { + }); 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() { + }); + 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 = 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() { + }); + 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 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() { + }); + 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>() { + }); + 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() { + }); + 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() { + }); + + 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 { 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 deleted file mode 100644 index abddd1567..000000000 --- a/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java +++ /dev/null @@ -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() { - }); - 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 diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg index 6aa6d138d..c2226ca33 100644 Binary files a/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg and b/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg differ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc index 906010c73..1ac816123 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc @@ -86,8 +86,8 @@ Currently, Spring AI provides `AbstractConversionServiceOutputConverter`, `Abstr image::structured-output-hierarchy4.jpg[Structured Output Class Hierarchy, width=900, align="center"] * `AbstractConversionServiceOutputConverter` - 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` - 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) 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` 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> outputConverter = new BeanOutputConverter<>( + new ParameterizedTypeReference>() { }); + +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 actorsFilms = outputConverter.convert(generation.getOutput().getContent()); +---- + === Map Output Converter Following sniped shows how to use `MapOutputConverter` to generate a list of numbers.