diff --git a/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index 977aa5a20f..a76eb232b8 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -16,7 +16,9 @@ package org.springframework.ai.converter; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -234,9 +236,16 @@ protected void postProcessSchema(@NonNull JsonNode jsonNode) { @Override public T convert(String text) { try { - // Clean the text using the configured text cleaner text = this.textCleaner.clean(text); + if (text != null && isListType(this.type) && text.startsWith("{")) { + JsonNode node = this.jsonMapper.readTree(text); + if (node.isObject() && node.has("items") && node.get("items").isArray() && node.has("type") + && "array".equals(node.get("type").asText())) { + text = this.jsonMapper.writeValueAsString(node.get("items")); + } + } + return (T) this.jsonMapper.readValue(text, this.jsonMapper.constructType(this.type)); } catch (JacksonException e) { @@ -246,6 +255,25 @@ public T convert(String text) { } } + /** + * Checks whether the given type is a {@link List} or a subtype of it. + * @param type the type to check + * @return {@code true} if the type is a List type, {@code false} otherwise + */ + private static boolean isListType(Type type) { + Class rawType; + if (type instanceof ParameterizedType pt) { + rawType = (Class) pt.getRawType(); + } + else if (type instanceof Class c) { + rawType = c; + } + else { + return false; + } + return List.class.isAssignableFrom(rawType); + } + /** * Configures and returns a JSON mapper for JSON operations. * @return Configured JSON mapper. diff --git a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java index ce6fa22ce6..db593682ff 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java @@ -33,6 +33,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @@ -195,6 +196,44 @@ void convertTypeReferenceArray() { assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); } + @Test + void convertListTypeFromSchemaShapedResponse() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + + }); + + String schemaShapedResponse = """ + {"type":"array","items":[{"someString":"value1"},{"someString":"value2"}]} + """; + List result = converter.convert(schemaShapedResponse); + assertThat(result).hasSize(2); + assertThat(result.get(0).getSomeString()).isEqualTo("value1"); + assertThat(result.get(1).getSomeString()).isEqualTo("value2"); + } + + @Test + void convertListTypeFromSchemaShapedResponseWithEmptyArray() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + + }); + String schemaShapedResponse = """ + {"type":"array","items":[]} + """; + List result = converter.convert(schemaShapedResponse); + assertThat(result).isEmpty(); + } + + @Test + void convertListTypeIgnoresObjectWithItemsField() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + + }); + String objectWithItemsField = """ + {"type":"object","items":{"someString":"value1"}} + """; + assertThatThrownBy(() -> converter.convert(objectWithItemsField)).isInstanceOf(JacksonException.class); + } + @Test void convertClassTypeWithJsonAnnotations() { var converter = new BeanOutputConverter<>(TestClassWithJsonAnnotations.class);