This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 25d6ea16 [integrations][java] List each enum constant by its 
Jackson-serialized value in native schemas (#1260)
25d6ea16 is described below

commit 25d6ea168b31047f8d7f9d22fd2ff7916362db22
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Fri Oct 16 15:38:29 2026 +0900

    [integrations][java] List each enum constant by its Jackson-serialized 
value in native schemas (#1260)
    
    Generated-by: Claude Code 2.1.296 (Claude Opus 5.5)
---
 .../development/calling-llms/structured-output.md  |   7 +-
 .../chatmodels/common/PojoJsonSchemaGenerator.java |  88 +++++++++-
 .../common/PojoJsonSchemaGeneratorTest.java        | 190 ++++++++++++++++++++-
 3 files changed, 273 insertions(+), 12 deletions(-)

diff --git a/docs/content/docs/development/calling-llms/structured-output.md 
b/docs/content/docs/development/calling-llms/structured-output.md
index 8c0c58b5..d66b30df 100644
--- a/docs/content/docs/development/calling-llms/structured-output.md
+++ b/docs/content/docs/development/calling-llms/structured-output.md
@@ -271,7 +271,7 @@ The connection's `api_version` must be `2024-08-01` or 
later, for example `2024-
 List the values the model should return, in the form the model reads back:
 
 - **Python:** a Pydantic `Enum` lists its values. A string enum gives `"type": 
"string"` and a numeric enum gives `"type": "integer"`.
-- **Java:** the schema lists the values Jackson deserializes. Put 
`@JsonProperty` on **every** constant, or use a `@JsonValue` method that 
returns a `String`.
+- **Java:** the native schema lists each constant under the value Jackson 
serializes it to, typed as that value: a `@JsonProperty` name, a `@JsonValue` 
result, or the Java name. An integer `@JsonValue` gives `"type": "integer"`. An 
enum with `@JsonFormat(shape = OBJECT)` and no other Jackson customization is 
listed by its constant names, which a default `ObjectMapper` reads back. 
Jackson reads the values back by default when a `@JsonValue` method returns a 
`String` or an integral primiti [...]
 
 {{< tabs "Enum Fields" >}}
 
@@ -311,11 +311,6 @@ public enum Sentiment {
 
 {{< /tabs >}}
 
-Two Java cases do not work as you might expect. They behave the same on every 
Java provider:
-
-- `@JsonProperty` on only **some** constants lists the Java names for **all** 
of them (`["POSITIVE","NEGATIVE"]`). The model may reply with a Java name where 
you annotated a different value, and parsing fails. Annotate every constant or 
none.
-- A `@JsonValue` that returns a number produces 
`{"type":"string","enum":[1,-1]}`, which contradicts itself. Use a string value 
instead.
-
 ### Java POJOs
 
 - **Descriptions and required fields.** `@JsonPropertyDescription` and 
`@JsonClassDescription` are sent in the native schema, and every field is 
required (an `Optional` field is required but nullable on OpenAI, Azure OpenAI 
and Anthropic). The ReAct Agent's *prompt text* is rendered by a different 
generator, which has no descriptions, and lists a field as required only when 
it has `@JsonProperty(required = true)`. The prompt path therefore describes 
the schema less fully than the native path.
diff --git 
a/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
 
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
index 08a973ff..b849e05a 100644
--- 
a/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
+++ 
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
@@ -17,23 +17,35 @@
  */
 package org.apache.flink.agents.integrations.chatmodels.common;
 
+import com.fasterxml.classmate.ResolvedType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.github.victools.jsonschema.generator.CustomDefinition;
 import com.github.victools.jsonschema.generator.FieldScope;
 import com.github.victools.jsonschema.generator.Option;
 import com.github.victools.jsonschema.generator.OptionPreset;
+import com.github.victools.jsonschema.generator.SchemaGenerationContext;
 import com.github.victools.jsonschema.generator.SchemaGenerator;
 import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
+import com.github.victools.jsonschema.generator.SchemaKeyword;
 import com.github.victools.jsonschema.generator.SchemaVersion;
 import com.github.victools.jsonschema.generator.impl.PropertySortUtils;
 import com.github.victools.jsonschema.module.jackson.JacksonModule;
 import com.github.victools.jsonschema.module.jackson.JacksonOption;
 
+import java.util.LinkedHashSet;
 import java.util.Optional;
+import java.util.Set;
 import java.util.function.Predicate;
 
 /** Derives the JSON Schema that chat model connections send as a native 
output schema. */
 public final class PojoJsonSchemaGenerator {
 
+    private static final ObjectMapper ENUM_VALUE_MAPPER = new ObjectMapper();
+
     private PojoJsonSchemaGenerator() {}
 
     // Every fixed setting below addresses a concrete way the generated schema 
otherwise misstates
@@ -54,12 +66,17 @@ public final class PojoJsonSchemaGenerator {
     //     nullable there, so the schema still admits null for it.
     //   - The Jackson module names properties the way an ObjectMapper reads 
them, honoring
     //     @JsonProperty renames and dropping @JsonIgnore fields, which the 
required check would
-    //     otherwise mark required. It lists enum constants mapped by 
@JsonProperty or by a
-    //     @JsonValue method under those mapped values. A property or enum 
constant stated under
-    //     any other name makes a response that satisfies the schema fail to 
deserialize. An enum
-    //     annotating only some constants falls back to Java names for all of 
them, so its
-    //     annotated constants do not read back. RESPECT_JSONPROPERTY_REQUIRED 
stays off, so
+    //     otherwise mark required. A property stated under any other name 
makes a response that
+    //     satisfies the schema fail to deserialize. 
RESPECT_JSONPROPERTY_REQUIRED stays off, so
     //     @JsonProperty(required = true) does not widen the required set 
configured here.
+    //   - Enums get their own definition, registered before the Jackson 
module so it takes
+    //     precedence over the module's. It lists each constant under the 
value an ObjectMapper
+    //     writes for it, typed as that value. The module's @JsonProperty 
flattening applies only
+    //     when every constant carries @JsonProperty, so in a partly annotated 
enum it lists the
+    //     renamed constants by Java names that fail to deserialize. Its 
@JsonValue flattening
+    //     types numeric values as strings, a schema no value satisfies. An 
enum without
+    //     constants, one whose values cannot be written, or one written as 
objects or arrays
+    //     (such as @JsonFormat(shape = OBJECT)) falls back to the existing 
enum handling.
 
     /**
      * Generates the JSON Schema for {@code type}.
@@ -96,6 +113,8 @@ public final class PojoJsonSchemaGenerator {
         for (Option option : options) {
             builder.with(option);
         }
+        builder.forTypesInGeneral()
+                
.withCustomDefinitionProvider(PojoJsonSchemaGenerator::enumDefinition);
         builder.with(
                 new JacksonModule(
                         JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY,
@@ -105,4 +124,63 @@ public final class PojoJsonSchemaGenerator {
         builder.forFields().withRequiredCheck(requiredCheck);
         return new SchemaGenerator(builder.build()).generateSchema(type);
     }
+
+    private static CustomDefinition enumDefinition(
+            ResolvedType javaType, SchemaGenerationContext context) {
+        Class<?> enumType = javaType.getErasedType();
+        if (!enumType.isEnum()) {
+            return null;
+        }
+        Object[] constants = enumType.getEnumConstants();
+        if (constants.length == 0) {
+            return null;
+        }
+        ArrayNode values = context.getGeneratorConfig().createArrayNode();
+        Set<String> types = new LinkedHashSet<>();
+        for (Object constant : constants) {
+            JsonNode value;
+            try {
+                value = ENUM_VALUE_MAPPER.valueToTree(constant);
+            } catch (IllegalArgumentException e) {
+                return null;
+            }
+            if (value.isContainerNode()) {
+                // Handle only scalar values here. Object and array values 
fall back to the
+                // existing enum handling; reading them back may require a 
@JsonCreator.
+                return null;
+            }
+            if ((value.isFloat() || value.isDouble()) && 
!Double.isFinite(value.doubleValue())) {
+                // JSON has no non-finite numbers; Jackson writes these as 
strings such as "NaN".
+                value = TextNode.valueOf(value.asText());
+            }
+            values.add(value);
+            types.add(jsonType(value, context));
+        }
+        ObjectNode definition = 
context.getGeneratorConfig().createObjectNode();
+        if (types.size() == 1) {
+            definition.put(context.getKeyword(SchemaKeyword.TAG_TYPE), 
types.iterator().next());
+        } else {
+            ArrayNode typeArray = 
definition.putArray(context.getKeyword(SchemaKeyword.TAG_TYPE));
+            types.forEach(typeArray::add);
+        }
+        if (values.size() == 1) {
+            definition.set(context.getKeyword(SchemaKeyword.TAG_CONST), 
values.get(0));
+        } else {
+            definition.set(context.getKeyword(SchemaKeyword.TAG_ENUM), values);
+        }
+        return new CustomDefinition(definition);
+    }
+
+    private static String jsonType(JsonNode value, SchemaGenerationContext 
context) {
+        if (value.isIntegralNumber()) {
+            return context.getKeyword(SchemaKeyword.TAG_TYPE_INTEGER);
+        } else if (value.isNumber()) {
+            return context.getKeyword(SchemaKeyword.TAG_TYPE_NUMBER);
+        } else if (value.isBoolean()) {
+            return context.getKeyword(SchemaKeyword.TAG_TYPE_BOOLEAN);
+        } else if (value.isNull()) {
+            return context.getKeyword(SchemaKeyword.TAG_TYPE_NULL);
+        }
+        return context.getKeyword(SchemaKeyword.TAG_TYPE_STRING);
+    }
 }
diff --git 
a/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
 
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
index 1d567f6e..9bcace4a 100644
--- 
a/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
+++ 
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
@@ -17,6 +17,7 @@
  */
 package org.apache.flink.agents.integrations.chatmodels.common;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonValue;
@@ -27,6 +28,7 @@ import com.github.victools.jsonschema.generator.Option;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -103,6 +105,38 @@ class PojoJsonSchemaGeneratorTest {
         }
     }
 
+    /**
+     * Output schema fixture for enums the Jackson module's flattened enums 
misstate: one maps only
+     * some of its constants through {@code @JsonProperty}, and one maps its 
constants to numbers
+     * through a {@code @JsonValue} method.
+     */
+    public static class Order {
+        public PartlyMapped status;
+        public Sign sign;
+    }
+
+    public enum PartlyMapped {
+        @JsonProperty("in-progress")
+        IN_PROGRESS,
+        DONE
+    }
+
+    public enum Sign {
+        POSITIVE(1),
+        NEGATIVE(-1);
+
+        private final int value;
+
+        Sign(int value) {
+            this.value = value;
+        }
+
+        @JsonValue
+        public int value() {
+            return value;
+        }
+    }
+
     @Test
     @DisplayName("The generated schema names properties the way Jackson 
deserializes them")
     void namesPropertiesTheWayJacksonReadsThem() {
@@ -135,6 +169,160 @@ class PojoJsonSchemaGeneratorTest {
         assertThat(phases).containsExactlyInAnyOrder(Phase.values());
     }
 
+    @Test
+    @DisplayName("The generated schema lists a partly annotated enum the way 
Jackson reads it")
+    void listsPartlyAnnotatedEnumByItsJacksonWireValues() throws Exception {
+        JsonNode status =
+                
PojoJsonSchemaGenerator.generate(Order.class).path("properties").path("status");
+
+        // Listing the Java name of the annotated constant would let the model 
emit a value that
+        // fails to deserialize, so the schema has to name it by its 
@JsonProperty value.
+        
assertThat(textValues(status.path("enum"))).containsExactly("in-progress", 
"DONE");
+        for (JsonNode value : status.path("enum")) {
+            MAPPER.treeToValue(value, PartlyMapped.class);
+        }
+    }
+
+    @Test
+    @DisplayName("The enum definition takes precedence over the Jackson 
module's enum handling")
+    void enumDefinitionWinsOverJacksonModule() throws Exception {
+        JsonNode sign =
+                
PojoJsonSchemaGenerator.generate(Order.class).path("properties").path("sign");
+
+        // The Jackson module also handles a @JsonValue enum, typing it as a 
string even when the
+        // values are numbers, so a numeric type here comes from the 
generator's own definition.
+        
assertThat(sign.toString()).isEqualTo("{\"type\":\"integer\",\"enum\":[1,-1]}");
+        List<Sign> signs = new ArrayList<>();
+        for (JsonNode value : sign.path("enum")) {
+            signs.add(MAPPER.treeToValue(value, Sign.class));
+        }
+        assertThat(signs).containsExactly(Sign.POSITIVE, Sign.NEGATIVE);
+    }
+
+    /** Output schema fixture for enums whose written values need care in a 
schema. */
+    public static class Measures {
+        public Limit limit;
+        public Only only;
+        public Huge huge;
+    }
+
+    public enum Limit {
+        FINITE(1.5),
+        UNBOUNDED(Double.POSITIVE_INFINITY),
+        UNDEFINED(Double.NaN);
+
+        private final double value;
+
+        Limit(double value) {
+            this.value = value;
+        }
+
+        @JsonValue
+        public double value() {
+            return value;
+        }
+    }
+
+    public enum Huge {
+        BEYOND_DOUBLE(new BigDecimal("1E400")),
+        ONE(BigDecimal.ONE);
+
+        private final BigDecimal value;
+
+        Huge(BigDecimal value) {
+            this.value = value;
+        }
+
+        @JsonValue
+        public BigDecimal value() {
+            return value;
+        }
+    }
+
+    public enum Only {
+        @JsonProperty("only")
+        ONLY
+    }
+
+    @Test
+    @DisplayName("The enum definition writes non-finite numbers as strings and 
one value as const")
+    void writesNonFiniteNumbersAsStringsAndSingleValuesAsConst() {
+        for (ObjectNode schema :
+                List.of(
+                        PojoJsonSchemaGenerator.generate(Measures.class),
+                        
PojoJsonSchemaGenerator.generateAllRequired(Measures.class))) {
+            JsonNode properties = schema.path("properties");
+
+            // JSON has no NaN or Infinity, so Jackson writes them as strings, 
and the schema has
+            // to list them the same way; a single value keeps the const form 
victools uses.
+            assertThat(properties.path("limit").toString())
+                    .isEqualTo(
+                            
"{\"type\":[\"number\",\"string\"],\"enum\":[1.5,\"Infinity\",\"NaN\"]}");
+            assertThat(properties.path("only").toString())
+                    .isEqualTo("{\"type\":\"string\",\"const\":\"only\"}");
+            // A finite decimal beyond double's range is still written as a 
number.
+            
assertThat(properties.path("huge").path("type").asText()).isEqualTo("number");
+            
assertThat(properties.path("huge").path("enum").get(0).isNumber()).isTrue();
+        }
+    }
+
+    /** Output schema fixture holding an enum that declares no constants. */
+    public static class Placeholder {
+        public Optional<Empty> empty;
+    }
+
+    public enum Empty {}
+
+    @Test
+    @DisplayName("An enum without constants keeps the schema it had before")
+    void leavesEnumWithoutConstantsToJacksonModule() {
+        // An enum definition built from no values would declare an empty type 
list, which no
+        // instance satisfies, so even the null the Optional field allows 
would be rejected.
+        for (ObjectNode schema :
+                List.of(
+                        PojoJsonSchemaGenerator.generate(Placeholder.class),
+                        
PojoJsonSchemaGenerator.generateAllRequired(Placeholder.class))) {
+            JsonNode empty = schema.path("properties").path("empty");
+            assertThat(empty.toString()).doesNotContain("\"type\":[]");
+            assertThat(textValues(empty.path("type"))).contains("null");
+        }
+    }
+
+    /** Output schema fixture holding an enum that Jackson writes as an 
object. */
+    public static class Shipment {
+        public Carrier carrier;
+    }
+
+    @JsonFormat(shape = JsonFormat.Shape.OBJECT)
+    public enum Carrier {
+        GROUND(1),
+        AIR(2);
+
+        public final int code;
+
+        Carrier(int code) {
+            this.code = code;
+        }
+    }
+
+    @Test
+    @DisplayName("An enum written as an object keeps its constant names in the 
schema")
+    void listsObjectShapedEnumByItsConstantNames() throws Exception {
+        // Listed as objects, the values would sit under "type":"object" with 
no declared
+        // properties, which a strict schema rejects, and a plain mapper 
cannot read them back.
+        JsonNode carrier =
+                PojoJsonSchemaGenerator.generateAllRequired(
+                                Shipment.class, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+                        .path("properties")
+                        .path("carrier");
+        assertThat(carrier.path("type").asText()).isEqualTo("string");
+        List<Carrier> carriers = new ArrayList<>();
+        for (JsonNode value : carrier.path("enum")) {
+            carriers.add(MAPPER.treeToValue(value, Carrier.class));
+        }
+        assertThat(carriers).containsExactly(Carrier.GROUND, Carrier.AIR);
+    }
+
     @Test
     @DisplayName("The generated schema requires every field the caller did not 
make optional")
     void requiresEveryFieldExceptOptional() {
@@ -162,7 +350,7 @@ class PojoJsonSchemaGeneratorTest {
     @Test
     @DisplayName("The all-required schema differs from the default one only in 
its required set")
     void allRequiredChangesOnlyTheRequiredSet() {
-        for (Class<?> type : List.of(Report.class, Profile.class, 
Ticket.class)) {
+        for (Class<?> type : List.of(Report.class, Profile.class, 
Ticket.class, Order.class)) {
             ObjectNode allRequired =
                     PojoJsonSchemaGenerator.generateAllRequired(
                             type, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);

Reply via email to