diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java index 4fcd861e5..2718cc486 100644 --- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java +++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java @@ -83,6 +83,11 @@ public class ToolProcessor { Object.class); private static final Logger log = Logger.getLogger(ToolProcessor.class); + public static final DotName OPTIONAL = DotName.createSimple("java.util.Optional"); + public static final DotName OPTIONAL_INT = DotName.createSimple("java.util.OptionalInt"); + public static final DotName OPTIONAL_LONG = DotName.createSimple("java.util.OptionalLong"); + public static final DotName OPTIONAL_DOUBLE = DotName.createSimple("java.util.OptionalDouble"); + @BuildStep public void telemetry(Capabilities capabilities, BuildProducer additionalBeanProducer) { var addOpenTelemetrySpan = capabilities.isPresent(Capability.OPENTELEMETRY_TRACER); @@ -488,11 +493,18 @@ private Iterable toJsonSchemaProperties(Type type, IndexView ClassInfo classInfo = index.getClassByName(type.name()); List required = new ArrayList<>(); + if (classInfo != null) { for (FieldInfo field : classInfo.fields()) { String fieldName = field.name(); + Type fieldType = field.type(); - Iterable fieldSchema = toJsonSchemaProperties(field.type(), index, null); + boolean isOptional = isJavaOptionalType(fieldType); + if (isOptional) { + fieldType = unwrapOptionalType(fieldType); + } + + Iterable fieldSchema = toJsonSchemaProperties(fieldType, index, null); Map fieldDescription = new HashMap<>(); for (JsonSchemaProperty fieldProperty : fieldSchema) { @@ -506,6 +518,10 @@ private Iterable toJsonSchemaProperties(Type type, IndexView fieldDescription.put("description", String.join(",", descriptionValue)); } } + if (!isOptional) { + required.add(fieldName); + } + properties.put(fieldName, fieldDescription); } } @@ -517,10 +533,39 @@ private Iterable toJsonSchemaProperties(Type type, IndexView throw new IllegalArgumentException("Unsupported type: " + type); } + private boolean isJavaOptionalType(Type type) { + DotName typeName = type.name(); + return typeName.equals(DotName.createSimple("java.util.Optional")) + || typeName.equals(DotName.createSimple("java.util.OptionalInt")) + || typeName.equals(DotName.createSimple("java.util.OptionalLong")) + || typeName.equals(DotName.createSimple("java.util.OptionalDouble")); + } + + private Type unwrapOptionalType(Type optionalType) { + if (optionalType.kind() == Type.Kind.PARAMETERIZED_TYPE) { + ParameterizedType parameterizedType = optionalType.asParameterizedType(); + return parameterizedType.arguments().get(0); + } + return optionalType; + } + private boolean isComplexType(Type type) { return type.kind() == Type.Kind.CLASS || type.kind() == Type.Kind.PARAMETERIZED_TYPE; } + private boolean isOptionalField(FieldInfo field, IndexView index) { + Type fieldType = field.type(); + DotName fieldTypeName = fieldType.name(); + + if (OPTIONAL.equals(fieldTypeName) || OPTIONAL_INT.equals(fieldTypeName) || OPTIONAL_LONG.equals(fieldTypeName) + || OPTIONAL_DOUBLE.equals(fieldTypeName)) { + return true; + } + + return false; + + } + private Iterable removeNulls(JsonSchemaProperty... properties) { return stream(properties) .filter(Objects::nonNull) diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/QuarkusServiceOutputParser.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/QuarkusServiceOutputParser.java index d801450e8..80a7551ba 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/QuarkusServiceOutputParser.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/QuarkusServiceOutputParser.java @@ -13,7 +13,6 @@ import dev.langchain4j.service.Result; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.TypeUtils; -//import dev.langchain4j.service.output.OutputParser; import dev.langchain4j.service.output.ServiceOutputParser; import io.quarkiverse.langchain4j.QuarkusJsonCodecFactory; import io.smallrye.mutiny.Multi; @@ -23,13 +22,17 @@ public class QuarkusServiceOutputParser extends ServiceOutputParser { @Override public String outputFormatInstructions(Type returnType) { - Class rawClass = getRawClass(returnType); + boolean isOptional = isJavaOptional(returnType); + Type actualType = isOptional ? unwrapOptionalType(returnType) : returnType; + + Class rawClass = getRawClass(actualType); if (rawClass != String.class && rawClass != AiMessage.class && rawClass != TokenStream.class && rawClass != Response.class && !Multi.class.equals(rawClass)) { try { var schema = this.toJsonSchema(returnType); - return "You must answer strictly with json according to the following json schema format: " + schema; + return "You must answer strictly with json according to the following json schema format. Use description metadata to fill data properly: " + + schema; } catch (Exception e) { return ""; } @@ -77,7 +80,10 @@ private String extractJsonBlock(String text) { public String toJsonSchema(Type type) throws Exception { Map schema = new HashMap<>(); - Class rawClass = getRawClass(type); + boolean isOptional = isJavaOptional(type); + Type actualType = isOptional ? unwrapOptionalType(type) : type; + + Class rawClass = getRawClass(actualType); if (type instanceof WildcardType wildcardType) { Type boundType = wildcardType.getUpperBounds().length > 0 ? wildcardType.getUpperBounds()[0] @@ -104,22 +110,64 @@ public String toJsonSchema(Type type) throws Exception { schema.put("type", "object"); Map properties = new HashMap<>(); + List required = new ArrayList<>(); for (Field field : rawClass.getDeclaredFields()) { - field.setAccessible(true); - Map fieldSchema = toJsonSchemaMap(field.getGenericType()); - properties.put(field.getName(), fieldSchema); - if (field.isAnnotationPresent(Description.class)) { - Description description = field.getAnnotation(Description.class); - fieldSchema.put("description", description.value()); + try { + field.setAccessible(true); + Type fieldType = field.getGenericType(); + + // Check if the field is Optional and unwrap it if necessary + boolean fieldIsOptional = isJavaOptional(fieldType); + Type fieldActualType = fieldIsOptional ? unwrapOptionalType(fieldType) : fieldType; + + Map fieldSchema = toJsonSchemaMap(fieldActualType); + properties.put(field.getName(), fieldSchema); + + if (field.isAnnotationPresent(Description.class)) { + Description description = field.getAnnotation(Description.class); + fieldSchema.put("description", String.join(",", description.value())); + } + + // Only add to required if it is not Optional + if (!fieldIsOptional) { + required.add(field.getName()); + } else { + fieldSchema.put("nullable", true); // Mark as nullable in the JSON schema + } + + } catch (Exception e) { + } + } schema.put("properties", properties); + if (!required.isEmpty()) { + schema.put("required", required); + } + } + if (isOptional) { + schema.put("nullable", true); } - ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(schema); // Convert the schema map to a JSON string } + private boolean isJavaOptional(Type type) { + if (type instanceof ParameterizedType) { + Type rawType = ((ParameterizedType) type).getRawType(); + return rawType == Optional.class || rawType == OptionalInt.class || rawType == OptionalLong.class + || rawType == OptionalDouble.class; + } + return false; + } + + private Type unwrapOptionalType(Type optionalType) { + if (optionalType instanceof ParameterizedType) { + return ((ParameterizedType) optionalType).getActualTypeArguments()[0]; + } + return optionalType; + } + private Class getRawClass(Type type) { if (type instanceof Class) { return (Class) type; diff --git a/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/AssistantWithToolsResource.java b/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/AssistantWithToolsResource.java index 17b3f1602..3e949f7b6 100644 --- a/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/AssistantWithToolsResource.java +++ b/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/AssistantWithToolsResource.java @@ -2,8 +2,14 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Singleton; @@ -30,38 +36,40 @@ public AssistantWithToolsResource(Assistant assistant) { public static class TestData { @Description("Foo description for structured output") + @JsonProperty("foo") String foo; @Description("Foo description for structured output") + @JsonProperty("bar") Integer bar; @Description("Foo description for structured output") - Double baz; + @JsonProperty("baz") + Optional baz; + + + public TestData() { + } TestData(String foo, Integer bar, Double baz) { this.foo = foo; this.bar = bar; - this.baz = baz; + this.baz = Optional.of(baz); } } + @GET public String get(@RestQuery String message) { return assistant.chat(message); } - @GET - @Path("/many") - public List getMany(@RestQuery String message) { - return assistant.chats(message); - } @RegisterAiService(tools = Calculator.class, chatMemoryProviderSupplier = RegisterAiService.BeanChatMemoryProviderSupplier.class) public interface Assistant { String chat(String userMessage); - List chats(String userMessage); } @Singleton diff --git a/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/EntityMappedResource.java b/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/EntityMappedResource.java index 84e2aff77..9eefda391 100644 --- a/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/EntityMappedResource.java +++ b/integration-tests/openai/src/main/java/org/acme/example/openai/aiservices/EntityMappedResource.java @@ -2,7 +2,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -23,26 +29,41 @@ public EntityMappedResource(EntityMappedDescriber describer) { public static class TestData { @Description("Foo description for structured output") + @JsonProperty("foo") String foo; @Description("Foo description for structured output") + @JsonProperty("bar") Integer bar; @Description("Foo description for structured output") - Double baz; + @JsonProperty("baz") + Optional baz; + + + public TestData() { + } TestData(String foo, Integer bar, Double baz) { this.foo = foo; this.bar = bar; - this.baz = baz; + this.baz = Optional.of(baz); } } - @POST - public List generate(@RestQuery String message) { - var result = describer.describe(message); - - return result; + public static class MirrorModelSupplier implements Supplier { + @Override + public ChatLanguageModel get() { + return (messages) -> new Response<>(new AiMessage(""" + [ + { + "foo": "asd", + "bar": 1, + "baz": 2.0 + } + ] + """)); + } } @POST @@ -51,14 +72,16 @@ public List generateMapped(@RestQuery String message) { List inputs = new ArrayList<>(); inputs.add(new TestData(message, 100, 100.0)); - return describer.describeMapped(inputs); + var test = describer.describeMapped(inputs); + return test; } - @RegisterAiService + + + @RegisterAiService(chatLanguageModelSupplier = MirrorModelSupplier.class) public interface EntityMappedDescriber { - @UserMessage("This is a describer returning a collection of strings") - List describe(String url); + @UserMessage("This is a describer returning a collection of mapped entities") List describeMapped(List inputs); diff --git a/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithEntityMappingTest.java b/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithEntityMappingTest.java index 46612942b..1ddacb891 100644 --- a/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithEntityMappingTest.java +++ b/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithEntityMappingTest.java @@ -1,7 +1,7 @@ package org.acme.example.openai.aiservices; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.*; import java.net.URL; @@ -18,16 +18,6 @@ public class AssistantResourceWithEntityMappingTest { @TestHTTPResource URL url; - @Test - public void get() { - given() - .baseUri(url.toString()) - .queryParam("message", "This is a test") - .post() - .then() - .statusCode(200) - .body(containsString("MockGPT")); - } @Test public void getMany() { @@ -37,6 +27,9 @@ public void getMany() { .post() .then() .statusCode(200) - .body(containsString("MockGPT")); + .body("$", hasSize(1)) // Ensure that the response is an array with exactly one item + .body("[0].foo", equalTo("asd")) // Check that foo is set correctly + .body("[0].bar", equalTo(1)) // Check that bar is 100 + .body("[0].baz", equalTo(2.0F)); } } diff --git a/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithToolsTest.java b/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithToolsTest.java index ce5331bb4..524df4e25 100644 --- a/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithToolsTest.java +++ b/integration-tests/openai/src/test/java/org/acme/example/openai/aiservices/AssistantResourceWithToolsTest.java @@ -29,14 +29,4 @@ public void get() { .body(containsString("MockGPT")); } - @Test - public void getMany() { - given() - .baseUri(url.toString() + "/many") - .queryParam("message", "This is a test") - .get() - .then() - .statusCode(200) - .body(containsString("MockGPT")); - } }