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 a50a8a180 [api][java][python] Render a flat RowTypeInfo output schema
as JSON Schema in the ReAct Agent prompt (#1270)
a50a8a180 is described below
commit a50a8a180d5ee0f6fa4f6259b098161c458b257c
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Mon Oct 19 19:55:18 2026 +0900
[api][java][python] Render a flat RowTypeInfo output schema as JSON Schema
in the ReAct Agent prompt (#1270)
Generated-by: Claude Code 2.1.296 (Claude Opus 5.5)
---
.../apache/flink/agents/api/agents/ReActAgent.java | 6 +-
.../flink/agents/api/agents/RowTypeJsonSchema.java | 102 ++++++++++++++
.../flink/agents/api/agents/ReActAgentTest.java | 24 +++-
.../agents/api/agents/RowTypeJsonSchemaTest.java | 146 ++++++++++++++++++++
.../development/calling-llms/structured-output.md | 2 +-
python/flink_agents/api/agents/react_agent.py | 10 +-
.../api/agents/tests/test_react_agent.py | 30 +++-
.../tests/test_render_row_type_json_schema.py | 151 +++++++++++++++++++++
python/flink_agents/api/agents/types.py | 53 ++++++++
9 files changed, 512 insertions(+), 12 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
index 372a80d7c..6a48bff79 100644
--- a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
+++ b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
@@ -126,8 +126,10 @@ public class ReActAgent extends Agent {
if (outputSchema != null) {
String jsonSchema;
if (outputSchema instanceof RowTypeInfo) {
- jsonSchema = outputSchema.toString();
- outputSchema = new OutputSchema((RowTypeInfo) outputSchema);
+ RowTypeInfo rowType = (RowTypeInfo) outputSchema;
+ String rowSchema = RowTypeJsonSchema.render(rowType);
+ jsonSchema = rowSchema != null ? rowSchema :
rowType.toString();
+ outputSchema = new OutputSchema(rowType);
} else if (outputSchema instanceof Class) {
Class<?> schemaClass = (Class<?>) outputSchema;
try {
diff --git
a/api/src/main/java/org/apache/flink/agents/api/agents/RowTypeJsonSchema.java
b/api/src/main/java/org/apache/flink/agents/api/agents/RowTypeJsonSchema.java
new file mode 100644
index 000000000..bbc267f23
--- /dev/null
+++
b/api/src/main/java/org/apache/flink/agents/api/agents/RowTypeJsonSchema.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.agents.api.agents;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.json.JsonWriteFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.typeutils.RowTypeInfo;
+
+import javax.annotation.Nullable;
+
+/**
+ * Renders a flat {@link RowTypeInfo} as a JSON Schema object, so a row output
schema reaches the
+ * model as JSON Schema rather than as the type's string form.
+ *
+ * <p>The Python {@code render_row_type_json_schema} produces the same text
for the same row, so
+ * both languages describe a row to the model identically.
+ */
+@Internal
+public final class RowTypeJsonSchema {
+
+ /** Writes lowercase hex digits in escapes, as Python's {@code json.dumps}
does. */
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper(
+
JsonFactory.builder().disable(JsonWriteFeature.WRITE_HEX_UPPER_CASE).build());
+
+ private RowTypeJsonSchema() {}
+
+ /**
+ * Returns the JSON Schema of {@code rowType}: every field required, with
no additional
+ * properties.
+ *
+ * @return the schema as compact JSON, or {@code null} when a field has no
supported mapping in
+ * this renderer, such as a char, date or instant field, which the
caller then describes
+ * another way.
+ */
+ @Nullable
+ public static String render(RowTypeInfo rowType) {
+ ObjectNode schema = MAPPER.createObjectNode();
+ schema.put("type", "object");
+ ObjectNode properties = schema.putObject("properties");
+ ArrayNode required = MAPPER.createArrayNode();
+ String[] names = rowType.getFieldNames();
+ for (int i = 0; i < names.length; i++) {
+ String jsonType = jsonType(rowType.getTypeAt(i));
+ if (jsonType == null) {
+ return null;
+ }
+ properties.putObject(names[i]).put("type", jsonType);
+ required.add(names[i]);
+ }
+ schema.set("required", required);
+ schema.put("additionalProperties", false);
+ try {
+ return MAPPER.writeValueAsString(schema);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Nullable
+ private static String jsonType(TypeInformation<?> type) {
+ if (BasicTypeInfo.STRING_TYPE_INFO.equals(type)) {
+ return "string";
+ } else if (BasicTypeInfo.BOOLEAN_TYPE_INFO.equals(type)) {
+ return "boolean";
+ } else if (BasicTypeInfo.BYTE_TYPE_INFO.equals(type)
+ || BasicTypeInfo.SHORT_TYPE_INFO.equals(type)
+ || BasicTypeInfo.INT_TYPE_INFO.equals(type)
+ || BasicTypeInfo.LONG_TYPE_INFO.equals(type)
+ || BasicTypeInfo.BIG_INT_TYPE_INFO.equals(type)) {
+ return "integer";
+ } else if (BasicTypeInfo.FLOAT_TYPE_INFO.equals(type)
+ || BasicTypeInfo.DOUBLE_TYPE_INFO.equals(type)
+ || BasicTypeInfo.BIG_DEC_TYPE_INFO.equals(type)) {
+ return "number";
+ }
+ return null;
+ }
+}
diff --git
a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
index b45613984..66dad94fb 100644
--- a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
+++ b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
@@ -128,10 +128,30 @@ public class ReActAgentTest {
}
@Test
- @DisplayName("An agent built on a RowTypeInfo schema registers the native
prompt with its text")
+ @DisplayName(
+ "An agent built on a RowTypeInfo schema registers the native
prompt with its JSON Schema")
public void testRowTypeSchemaRegistersNativeSchemaPrompt() {
assertThat(nativeSchemaPromptOf(agentWithSchema(rowTypeInfo())))
- .isEqualTo(NATIVE_SCHEMA_INSTRUCTION + rowTypeInfo());
+ .isEqualTo(NATIVE_SCHEMA_INSTRUCTION +
RowTypeJsonSchema.render(rowTypeInfo()));
+ }
+
+ @Test
+ @DisplayName("An agent built on a RowTypeInfo schema asks for its JSON
Schema in the prompt")
+ public void testRowTypeSchemaPromptCarriesJsonSchema() {
+ assertThat(schemaPromptOf(agentWithSchema(rowTypeInfo())))
+
.contains("{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}}");
+ }
+
+ @Test
+ @DisplayName("A RowTypeInfo schema with a field no JSON Schema type fits
keeps its string form")
+ public void testRowTypeSchemaWithoutJsonTypeKeepsItsText() {
+ RowTypeInfo row =
+ new RowTypeInfo(
+ new TypeInformation[] {BasicTypeInfo.CHAR_TYPE_INFO},
+ new String[] {"initial"});
+ ReActAgent agent = agentWithSchema(row);
+
assertThat(nativeSchemaPromptOf(agent)).isEqualTo(NATIVE_SCHEMA_INSTRUCTION +
row);
+ assertThat(schemaPromptOf(agent)).contains(row.toString());
}
@Test
diff --git
a/api/src/test/java/org/apache/flink/agents/api/agents/RowTypeJsonSchemaTest.java
b/api/src/test/java/org/apache/flink/agents/api/agents/RowTypeJsonSchemaTest.java
new file mode 100644
index 000000000..da387df18
--- /dev/null
+++
b/api/src/test/java/org/apache/flink/agents/api/agents/RowTypeJsonSchemaTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.agents.api.agents;
+
+import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.typeutils.RowTypeInfo;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link RowTypeJsonSchema}. */
+class RowTypeJsonSchemaTest {
+
+ /** The same row's schema is asserted in the Python
test_render_row_type_json_schema.py. */
+ static final String MIXED_ROW_SCHEMA =
+
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},"
+ +
"\"count\":{\"type\":\"integer\"},\"score\":{\"type\":\"number\"},"
+ +
"\"ok\":{\"type\":\"boolean\"}},\"required\":[\"name\",\"count\",\"score\","
+ + "\"ok\"],\"additionalProperties\":false}";
+
+ /** The same row's schema is asserted in the Python
test_render_row_type_json_schema.py. */
+ static final String UNNAMED_ROW_SCHEMA =
+
"{\"type\":\"object\",\"properties\":{\"f0\":{\"type\":\"string\"},"
+ +
"\"f1\":{\"type\":\"integer\"}},\"required\":[\"f0\",\"f1\"],"
+ + "\"additionalProperties\":false}";
+
+ /** The same row's schema is asserted in the Python
test_render_row_type_json_schema.py. */
+ static final String ESCAPED_ROW_SCHEMA =
+
"{\"type\":\"object\",\"properties\":{\"a\\\"b\":{\"type\":\"string\"},"
+ +
"\"c\\\\d\":{\"type\":\"string\"},\"e\\u000bf\":{\"type\":\"string\"},"
+ +
"\"g\\u001fh\":{\"type\":\"string\"},\"i\\nj\":{\"type\":\"string\"},"
+ +
"\"k/l\":{\"type\":\"string\"},\"\uC810\uC218\":{\"type\":\"string\"},"
+ +
"\"m\u2028n\":{\"type\":\"string\"},\"\uD83D\uDE00\":{\"type\":\"string\"}},"
+ +
"\"required\":[\"a\\\"b\",\"c\\\\d\",\"e\\u000bf\",\"g\\u001fh\","
+ +
"\"i\\nj\",\"k/l\",\"\uC810\uC218\",\"m\u2028n\",\"\uD83D\uDE00\"],"
+ + "\"additionalProperties\":false}";
+
+ @Test
+ @DisplayName("A flat row renders as an object schema with every field
required, in field order")
+ void rendersFlatRowInFieldOrder() {
+ RowTypeInfo row =
+ new RowTypeInfo(
+ new TypeInformation[] {
+ BasicTypeInfo.STRING_TYPE_INFO,
+ BasicTypeInfo.INT_TYPE_INFO,
+ BasicTypeInfo.DOUBLE_TYPE_INFO,
+ BasicTypeInfo.BOOLEAN_TYPE_INFO
+ },
+ new String[] {"name", "count", "score", "ok"});
+
+ assertThat(RowTypeJsonSchema.render(row)).isEqualTo(MIXED_ROW_SCHEMA);
+ }
+
+ @Test
+ @DisplayName("Integral and decimal basic types map to integer and number")
+ void mapsNumericTypes() {
+ RowTypeInfo row =
+ new RowTypeInfo(
+ new TypeInformation[] {
+ BasicTypeInfo.BYTE_TYPE_INFO,
+ BasicTypeInfo.SHORT_TYPE_INFO,
+ BasicTypeInfo.LONG_TYPE_INFO,
+ BasicTypeInfo.BIG_INT_TYPE_INFO,
+ BasicTypeInfo.FLOAT_TYPE_INFO,
+ BasicTypeInfo.BIG_DEC_TYPE_INFO
+ },
+ new String[] {"b", "s", "l", "bi", "f", "bd"});
+
+ assertThat(RowTypeJsonSchema.render(row))
+ .isEqualTo(
+
"{\"type\":\"object\",\"properties\":{\"b\":{\"type\":\"integer\"},"
+ +
"\"s\":{\"type\":\"integer\"},\"l\":{\"type\":\"integer\"},"
+ +
"\"bi\":{\"type\":\"integer\"},\"f\":{\"type\":\"number\"},"
+ +
"\"bd\":{\"type\":\"number\"}},\"required\":[\"b\",\"s\",\"l\","
+ +
"\"bi\",\"f\",\"bd\"],\"additionalProperties\":false}");
+ }
+
+ @Test
+ @DisplayName("A field with no supported mapping leaves the row unrendered")
+ void returnsNullForFieldsWithoutJsonType() {
+ for (TypeInformation<?> type :
+ new TypeInformation<?>[] {
+ BasicTypeInfo.CHAR_TYPE_INFO,
+ BasicTypeInfo.DATE_TYPE_INFO,
+ BasicTypeInfo.INSTANT_TYPE_INFO,
+ BasicTypeInfo.VOID_TYPE_INFO
+ }) {
+ RowTypeInfo row =
+ new RowTypeInfo(
+ new TypeInformation[]
{BasicTypeInfo.STRING_TYPE_INFO, type},
+ new String[] {"name", "other"});
+
+
assertThat(RowTypeJsonSchema.render(row)).as(type.toString()).isNull();
+ }
+ }
+
+ @Test
+ @DisplayName("Escaped field names render the same text as in Python")
+ void escapesFieldNamesLikePython() {
+ String[] names = {
+ "a\"b",
+ "c\\d",
+ "e\u000Bf",
+ "g\u001Fh",
+ "i\nj",
+ "k/l",
+ "\uC810\uC218",
+ "m\u2028n",
+ "\uD83D\uDE00"
+ };
+ TypeInformation<?>[] types = new TypeInformation<?>[names.length];
+ Arrays.fill(types, BasicTypeInfo.STRING_TYPE_INFO);
+
+ assertThat(RowTypeJsonSchema.render(new RowTypeInfo(types, names)))
+ .isEqualTo(ESCAPED_ROW_SCHEMA);
+ }
+
+ @Test
+ @DisplayName("A row built without names renders the default f0, f1, ...
names")
+ void rendersDefaultFieldNames() {
+ RowTypeInfo row =
+ new RowTypeInfo(BasicTypeInfo.STRING_TYPE_INFO,
BasicTypeInfo.INT_TYPE_INFO);
+
+
assertThat(RowTypeJsonSchema.render(row)).isEqualTo(UNNAMED_ROW_SCHEMA);
+ }
+}
diff --git a/docs/content/docs/development/calling-llms/structured-output.md
b/docs/content/docs/development/calling-llms/structured-output.md
index abb3a06f2..e725823ec 100644
--- a/docs/content/docs/development/calling-llms/structured-output.md
+++ b/docs/content/docs/development/calling-llms/structured-output.md
@@ -42,7 +42,7 @@ A schema is one of:
| Typed class | Pydantic `BaseModel` subclass | POJO `Class` |
| Flink row type | `RowTypeInfo` | `RowTypeInfo` |
-Only a typed class can be applied natively. A `RowTypeInfo` always uses the
prompt path.
+Only a typed class can be applied natively. A `RowTypeInfo` always uses the
prompt path. For a flat `RowTypeInfo` whose fields are all string, boolean or
numeric types, the ReAct Agent describes the schema to the model as JSON Schema
with every field required and `additionalProperties: false`. Other row types
keep their existing behavior.
Pass the schema to a [ReAct Agent]({{< ref "docs/development/react_agent" >}})
with `output_schema`, or put it on a `ChatRequestEvent` when you [send chat
requests yourself]({{< ref "docs/development/calling-llms/chat" >}}).
diff --git a/python/flink_agents/api/agents/react_agent.py
b/python/flink_agents/api/agents/react_agent.py
index c7052a949..8c43483e5 100644
--- a/python/flink_agents/api/agents/react_agent.py
+++ b/python/flink_agents/api/agents/react_agent.py
@@ -27,7 +27,11 @@ from pyflink.common import Row
from pyflink.common.typeinfo import RowTypeInfo
from flink_agents.api.agents.agent import Agent
-from flink_agents.api.agents.types import OutputSchema, render_output_schema
+from flink_agents.api.agents.types import (
+ OutputSchema,
+ render_output_schema,
+ render_row_type_json_schema,
+)
from flink_agents.api.chat_message import (
ChatMessage,
MessageRole,
@@ -236,7 +240,9 @@ class ReActAgent(Agent):
output_schema, lambda model: model.model_json_schema()
)
elif isinstance(output_schema, RowTypeInfo):
- json_schema = str(output_schema)
+ json_schema = render_row_type_json_schema(output_schema) or
str(
+ output_schema
+ )
else:
err_msg = f"Output schema {output_schema.__class__} is not
supported."
raise TypeError(err_msg)
diff --git a/python/flink_agents/api/agents/tests/test_react_agent.py
b/python/flink_agents/api/agents/tests/test_react_agent.py
index 0fc71a444..0556214b5 100644
--- a/python/flink_agents/api/agents/tests/test_react_agent.py
+++ b/python/flink_agents/api/agents/tests/test_react_agent.py
@@ -28,6 +28,7 @@ from flink_agents.api.agents.react_agent import (
_DEFAULT_SCHEMA_PROMPT,
ReActAgent,
)
+from flink_agents.api.agents.types import render_row_type_json_schema
from flink_agents.api.chat_message import MessageRole
from flink_agents.api.events.event import InputEvent
from flink_agents.api.prompts.prompt import Prompt
@@ -234,10 +235,29 @@ def
test_renderable_output_schema_keeps_the_schema_prompt() -> None:
)
-def test_row_type_info_output_schema_keeps_the_prompt_fallback() -> None:
- """A RowTypeInfo has no JSON Schema render and keeps its own prompt
text."""
+def test_row_type_info_output_schema_renders_json_schema_in_the_prompt() ->
None:
+ """A flat RowTypeInfo reaches the prompt as its JSON Schema."""
row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
- assert _schema_prompt(_agent(row_type)) == _expected_prompt(row_type)
+ assert _schema_prompt(_agent(row_type)) == _expected_prompt(
+ '{"type":"object","properties":{"name":{"type":"string"}},'
+ '"required":["name"],"additionalProperties":false}'
+ )
+
+
+def test_row_type_info_without_json_type_keeps_the_prompt_fallback() -> None:
+ """A row with a field no JSON Schema type fits keeps its own prompt
text."""
+ row_type = Types.ROW_NAMED(["initial"], [Types.CHAR()])
+ agent = _agent(row_type)
+ assert _schema_prompt(agent) == _expected_prompt(row_type)
+ assert _native_schema_prompt(agent) ==
f"{_NATIVE_SCHEMA_INSTRUCTION}{row_type}"
+
+
+def test_row_type_info_with_duplicate_names_keeps_the_prompt_fallback() ->
None:
+ """A row with duplicate field names keeps its own prompt text."""
+ row_type = Types.ROW_NAMED(["x", "x"], [Types.STRING(), Types.INT()])
+ agent = _agent(row_type)
+ assert _schema_prompt(agent) == _expected_prompt(row_type)
+ assert _native_schema_prompt(agent) ==
f"{_NATIVE_SCHEMA_INSTRUCTION}{row_type}"
def test_renderable_output_schema_registers_the_native_schema_prompt() -> None:
@@ -257,11 +277,11 @@ def
test_native_schema_prompt_shows_field_descriptions_and_required_fields() ->
def test_row_type_info_output_schema_registers_the_native_schema_prompt() ->
None:
- """A RowTypeInfo reaches the native prompt as its own text."""
+ """A flat RowTypeInfo reaches the native prompt as its JSON Schema."""
row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
assert (
_native_schema_prompt(_agent(row_type))
- == f"{_NATIVE_SCHEMA_INSTRUCTION}{row_type}"
+ ==
f"{_NATIVE_SCHEMA_INSTRUCTION}{render_row_type_json_schema(row_type)}"
)
diff --git
a/python/flink_agents/api/agents/tests/test_render_row_type_json_schema.py
b/python/flink_agents/api/agents/tests/test_render_row_type_json_schema.py
new file mode 100644
index 000000000..62efb5453
--- /dev/null
+++ b/python/flink_agents/api/agents/tests/test_render_row_type_json_schema.py
@@ -0,0 +1,151 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# http://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.
+#################################################################################
+
+import pytest
+from pyflink.common.typeinfo import RowTypeInfo, Types
+
+from flink_agents.api.agents.types import render_row_type_json_schema
+
+# The same row's schema is asserted in the Java RowTypeJsonSchemaTest.
+_MIXED_ROW_SCHEMA = (
+ '{"type":"object","properties":{"name":{"type":"string"},'
+ '"count":{"type":"integer"},"score":{"type":"number"},'
+ '"ok":{"type":"boolean"}},"required":["name","count","score",'
+ '"ok"],"additionalProperties":false}'
+)
+
+# The same row's schema is asserted in the Java RowTypeJsonSchemaTest.
+_UNNAMED_ROW_SCHEMA = (
+ '{"type":"object","properties":{"f0":{"type":"string"},'
+ '"f1":{"type":"integer"}},"required":["f0","f1"],'
+ '"additionalProperties":false}'
+)
+
+# The same row's schema is asserted in the Java RowTypeJsonSchemaTest.
+_ESCAPED_ROW_SCHEMA = (
+ '{"type":"object","properties":{"a\\"b":{"type":"string"},'
+ '"c\\\\d":{"type":"string"},"e\\u000bf":{"type":"string"},'
+ '"g\\u001fh":{"type":"string"},"i\\nj":{"type":"string"},'
+ '"k/l":{"type":"string"},"\uc810\uc218":{"type":"string"},'
+ '"m\u2028n":{"type":"string"},"\U0001f600":{"type":"string"}},'
+ '"required":["a\\"b","c\\\\d","e\\u000bf","g\\u001fh",'
+ '"i\\nj","k/l","\uc810\uc218","m\u2028n","\U0001f600"],'
+ '"additionalProperties":false}'
+)
+
+
+def test_renders_flat_row_in_field_order() -> None:
+ """A flat row renders as an object schema with every field required."""
+ row = RowTypeInfo(
+ [Types.STRING(), Types.INT(), Types.DOUBLE(), Types.BOOLEAN()],
+ ["name", "count", "score", "ok"],
+ )
+
+ assert render_row_type_json_schema(row) == _MIXED_ROW_SCHEMA
+
+
+def test_maps_numeric_types() -> None:
+ """Integral and decimal basic types map to integer and number."""
+ row = RowTypeInfo(
+ [
+ Types.BYTE(),
+ Types.SHORT(),
+ Types.LONG(),
+ Types.BIG_INT(),
+ Types.FLOAT(),
+ Types.BIG_DEC(),
+ ],
+ ["b", "s", "l", "bi", "f", "bd"],
+ )
+
+ assert render_row_type_json_schema(row) == (
+ '{"type":"object","properties":{"b":{"type":"integer"},'
+ '"s":{"type":"integer"},"l":{"type":"integer"},'
+ '"bi":{"type":"integer"},"f":{"type":"number"},'
+ '"bd":{"type":"number"}},"required":["b","s","l",'
+ '"bi","f","bd"],"additionalProperties":false}'
+ )
+
+
[email protected](
+ "field_type", [Types.CHAR(), Types.INSTANT(), Types.SQL_DATE()]
+)
+def test_returns_none_for_fields_without_json_type(field_type: object) -> None:
+ """A field with no supported mapping leaves the row unrendered."""
+ row = RowTypeInfo([Types.STRING(), field_type], ["name", "other"])
+
+ assert render_row_type_json_schema(row) is None
+
+
+def test_escapes_field_names_like_java() -> None:
+ """Escaped field names render the same text as in Java."""
+ names = [
+ 'a"b',
+ "c\\d",
+ "e\x0bf",
+ "g\x1fh",
+ "i\nj",
+ "k/l",
+ "\uc810\uc218",
+ "m\u2028n",
+ "\U0001f600",
+ ]
+ row = RowTypeInfo([Types.STRING()] * len(names), names)
+
+ assert render_row_type_json_schema(row) == _ESCAPED_ROW_SCHEMA
+
+
+def _without_jvm(row: RowTypeInfo, monkeypatch: pytest.MonkeyPatch) ->
RowTypeInfo:
+ def fail() -> None:
+ msg = "the renderer must not start the JVM"
+ raise AssertionError(msg)
+
+ monkeypatch.setattr(row, "get_java_type_info", fail)
+ return row
+
+
+def test_unnamed_row_uses_default_names_without_the_jvm(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A row built without names renders f0, f1, ... as Java does."""
+ row = _without_jvm(RowTypeInfo([Types.STRING(), Types.INT()]), monkeypatch)
+
+ assert render_row_type_json_schema(row) == _UNNAMED_ROW_SCHEMA
+
+
+def test_unnamed_unmapped_row_returns_none_without_the_jvm(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An unnamed row with an unmapped field is left unrendered."""
+ row = _without_jvm(RowTypeInfo([Types.CHAR()]), monkeypatch)
+
+ assert render_row_type_json_schema(row) is None
+
+
[email protected](
+ ("names", "types"),
+ [
+ ([], [Types.STRING()]),
+ (["a"], [Types.STRING(), Types.INT()]),
+ (["x", "x"], [Types.STRING(), Types.INT()]),
+ ],
+ ids=["empty-names", "fewer-names", "duplicate-names"],
+)
+def test_returns_none_for_names_java_rejects(names: list[str], types: list) ->
None:
+ """Names Java would reject leave the row unrendered."""
+ assert render_row_type_json_schema(RowTypeInfo(types, names)) is None
diff --git a/python/flink_agents/api/agents/types.py
b/python/flink_agents/api/agents/types.py
index 381f0da1e..cc1528356 100644
--- a/python/flink_agents/api/agents/types.py
+++ b/python/flink_agents/api/agents/types.py
@@ -16,6 +16,7 @@
# limitations under the License.
#################################################################################
import importlib
+import json
from typing import Any, Callable
from pydantic import BaseModel, ConfigDict, model_serializer, model_validator
@@ -179,3 +180,55 @@ def _admits_undeclared_keys(schema: Any) -> bool:
return _admits_undeclared_keys(schema.get("items")) or
_admits_undeclared_keys(
schema.get("not")
)
+
+
+_ROW_JSON_SCHEMA_TYPES = {
+ BasicType.STRING: "string",
+ BasicType.BOOLEAN: "boolean",
+ BasicType.BYTE: "integer",
+ BasicType.SHORT: "integer",
+ BasicType.INT: "integer",
+ BasicType.LONG: "integer",
+ BasicType.BIG_INT: "integer",
+ BasicType.FLOAT: "number",
+ BasicType.DOUBLE: "number",
+ BasicType.BIG_DEC: "number",
+}
+
+
+def render_row_type_json_schema(row_type: RowTypeInfo) -> str | None:
+ """Render a flat ``RowTypeInfo`` as a JSON Schema object.
+
+ Every field is required and no additional properties are allowed. The Java
+ ``RowTypeJsonSchema`` produces the same text for the same row, so both
+ languages describe a row to the model identically.
+
+ Returns ``None`` when a field has no supported mapping in this renderer,
+ such as a char or instant field, or when the names are duplicated or don't
+ match the types; the caller then describes the row another way.
+ """
+ json_types = [
+ _ROW_JSON_SCHEMA_TYPES.get(getattr(field_type, "_basic_type", None))
+ for field_type in row_type.get_field_types()
+ ]
+ if None in json_types:
+ return None
+ # get_field_names() starts the JVM for a row built without names; Java
names
+ # those fields f0, f1, ... too.
+ names = row_type._field_names
+ if names is None:
+ names = [f"f{i}" for i in range(len(json_types))]
+ # Java rejects these rows; Python keeps describing them another way.
+ if len(names) != len(json_types) or len(set(names)) != len(names):
+ return None
+ properties = {
+ name: {"type": json_type}
+ for name, json_type in zip(names, json_types, strict=True)
+ }
+ schema = {
+ "type": "object",
+ "properties": properties,
+ "required": list(properties),
+ "additionalProperties": False,
+ }
+ return json.dumps(schema, separators=(",", ":"), ensure_ascii=False)