This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12194-b0f6feddf9ed0dc1f5d485cfc69d387edfe6ac82 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 16ab3a75a9f38d7229ec9751b1c96fef79f081bc Author: Goutam Adwant <[email protected]> AuthorDate: Tue Oct 13 16:00:53 2026 +0000 [Improve][Transform-V2] Add opt-in validation for LLM boolean output (#12194) Signed-off-by: Goutam Adwant <[email protected]> --- .licenserc.yaml | 2 + docs/en/transforms/llm.md | 19 + docs/zh/transforms/llm.md | 16 + .../apache/seatunnel/e2e/transform/TestLLMIT.java | 19 + .../llm_openai_transform_boolean_strict.conf | 74 ++++ ...lm_openai_transform_boolean_strict_invalid.conf | 74 ++++ .../transform/nlpmodel/llm/LLMTransform.java | 40 ++- .../transform/nlpmodel/llm/LLMTransformConfig.java | 9 + .../nlpmodel/llm/LLMTransformFactory.java | 1 + .../transform/llm/LLMBooleanOutputTest.java | 383 +++++++++++++++++++++ .../resources/llm/GenerateLegacyLLMFixture.java | 80 +++++ .../src/test/resources/llm/README.md | 56 +++ .../test/resources/llm/generate-legacy-fixture.sh | 40 +++ .../resources/llm/legacy-boolean-wrapper.base64 | 74 ++++ 14 files changed, 884 insertions(+), 3 deletions(-) diff --git a/.licenserc.yaml b/.licenserc.yaml index 079dca2362..222c81e3c0 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -44,6 +44,8 @@ header: - '**/NOTICE' - '**/.gitkeep' - '**/com/typesafe/config/**' + # Archived serialization bytes; ASF license and provenance are in the adjacent README.md. + - 'seatunnel-transforms-v2/src/test/resources/llm/legacy-boolean-wrapper.base64' - 'seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/ConfigProvider.java' - 'seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/SeaTunnelConfigSections.java' - 'seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/YamlSeaTunnelConfigBuilder.java' diff --git a/docs/en/transforms/llm.md b/docs/en/transforms/llm.md index 62c1396318..8609e54d02 100644 --- a/docs/en/transforms/llm.md +++ b/docs/en/transforms/llm.md @@ -14,6 +14,7 @@ more. |------------------------|--------|----------|---------------| | model_provider | enum | yes | | | output_data_type | enum | no | String | +| strict_boolean_output | boolean | no | false | | output_column_name | string | no | llm_output | | prompt | string | yes | | | inference_columns | list | no | | @@ -38,6 +39,24 @@ The data type of the output data. The available options are: STRING,INT,BIGINT,DOUBLE,BOOLEAN. Default value is STRING. +### strict_boolean_output + +Optional validation for `output_data_type = BOOLEAN`. The default is `false` and preserves +the existing conversion: only `true` (case-insensitive) becomes `true`; other values, +including an empty string or a null element, become `false`. + +Set `strict_boolean_output = true` to require exactly one non-null `true` or `false` +value from the model for each input row. Matching is case-insensitive and does not trim +whitespace. Values such as `"unknown"`, `""`, `" true "`, `1`, null, an empty result, or +multiple results fail the transform instead of producing a boolean value. The validation +error does not include the input row or model response. This option does not add row +skipping or error-table routing, and has no effect on other output data types. + +```hocon +output_data_type = BOOLEAN +strict_boolean_output = true +``` + ### output_column_name Custom output data field name. It must not duplicate an existing input field name, otherwise the transform fails with the error `llm inference field name ... already exists`. diff --git a/docs/zh/transforms/llm.md b/docs/zh/transforms/llm.md index 6498450f7a..b342a83f08 100644 --- a/docs/zh/transforms/llm.md +++ b/docs/zh/transforms/llm.md @@ -12,6 +12,7 @@ LLM 转换插件利用大型语言模型(LLM)的能力处理数据,将输 |------------------------| ------ | -------- |-------------| | model_provider | enum | yes | | | output_data_type | enum | no | String | +| strict_boolean_output | boolean | no | false | | output_column_name | string | no | llm_output | | prompt | string | yes | | | inference_columns | list | no | | @@ -36,6 +37,21 @@ OPENAI,DOUBAO,DEEPSEEK,KIMIAI,MICROSOFT, ZHIPU, CUSTOM STRING,INT,BIGINT,DOUBLE,BOOLEAN. 默认值为 STRING。 +### strict_boolean_output + +`output_data_type = BOOLEAN` 时的可选校验。默认值为 `false`,保持现有转换行为: +只有 `true`(不区分大小写)转换为 `true`;其他值(包括空字符串和 null 元素)转换为 `false`。 + +设置 `strict_boolean_output = true` 后,每条输入记录对应的模型结果必须且只能包含一个非 null 的 +`true` 或 `false` 值。匹配不区分大小写,但不会去除首尾空白。`"unknown"`、`""`、`" true "`、 +`1`、null、空结果或多个结果都会使转换失败,而不是输出布尔值。校验错误不会包含输入记录或模型响应。 +该选项不增加跳过记录或路由到错误表的功能,对其他输出数据类型没有影响。 + +```hocon +output_data_type = BOOLEAN +strict_boolean_output = true +``` + ### output_column_name 自定义输出数据字段名称。该名称不能与输入数据中已有的字段名重复,否则转换插件会抛出错误:`llm inference field name ... already exists`。 diff --git a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestLLMIT.java b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestLLMIT.java index 495e2bd714..3a936a7c89 100644 --- a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestLLMIT.java +++ b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestLLMIT.java @@ -111,6 +111,25 @@ public class TestLLMIT extends TestSuiteBase implements TestResource { Assertions.assertEquals(0, execResult.getExitCode()); } + @TestTemplate + public void testLLMWithOpenAIStrictBoolean(TestContainer container) + throws IOException, InterruptedException { + Container.ExecResult execResult = + container.executeJob("/llm_openai_transform_boolean_strict.conf"); + Assertions.assertEquals(0, execResult.getExitCode()); + } + + @TestTemplate + public void testLLMWithOpenAIStrictBooleanRejectsInvalidOutput(TestContainer container) + throws IOException, InterruptedException { + Container.ExecResult execResult = + container.executeJob("/llm_openai_transform_boolean_strict_invalid.conf"); + Assertions.assertNotEquals(0, execResult.getExitCode()); + String output = execResult.getStdout() + execResult.getStderr(); + Assertions.assertTrue(output.contains("strict_boolean_output"), output); + Assertions.assertTrue(output.contains("exactly one non-null true or false"), output); + } + @TestTemplate public void testLLMWithOpenAIColumns(TestContainer container) throws IOException, InterruptedException { diff --git a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict.conf b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict.conf new file mode 100644 index 0000000000..bd2f517921 --- /dev/null +++ b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict.conf @@ -0,0 +1,74 @@ +# +# 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. +# + +env { + job.mode = "BATCH" +} + +source { + FakeSource { + row.num = 5 + schema = { + fields { + id = "int" + name = "string" + } + } + rows = [ + {fields = [1, "Jia Fan"], kind = INSERT} + {fields = [2, "Hailin Wang"], kind = INSERT} + {fields = [3, "Tomas"], kind = INSERT} + {fields = [4, "Eric"], kind = INSERT} + {fields = [5, "Guangdong Liu"], kind = INSERT} + ] + plugin_output = "fake" + } +} + +transform { + LLM { + plugin_input = "fake" + model_provider = OPENAI + model = gpt-4o-mini + api_key = sk-xxx + prompt = "Determine whether someone is Chinese or American by their name" + output_data_type = boolean + strict_boolean_output = true + api_path = "http://mockserver:1080/v2/chat/completions" + plugin_output = "llm_output" + } +} + +sink { + Assert { + plugin_input = "llm_output" + rules = + { + field_rules = [ + { + field_name = llm_output + field_type = boolean + field_value = [ + { + rule_type = NOT_NULL + } + ] + } + ] + } + } +} diff --git a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict_invalid.conf b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict_invalid.conf new file mode 100644 index 0000000000..0054d702a0 --- /dev/null +++ b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/llm_openai_transform_boolean_strict_invalid.conf @@ -0,0 +1,74 @@ +# +# 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. +# + +env { + job.mode = "BATCH" +} + +source { + FakeSource { + row.num = 5 + schema = { + fields { + id = "int" + name = "string" + } + } + rows = [ + {fields = [1, "Jia Fan"], kind = INSERT} + {fields = [2, "Hailin Wang"], kind = INSERT} + {fields = [3, "Tomas"], kind = INSERT} + {fields = [4, "Eric"], kind = INSERT} + {fields = [5, "Guangdong Liu"], kind = INSERT} + ] + plugin_output = "fake" + } +} + +transform { + LLM { + plugin_input = "fake" + model_provider = OPENAI + model = gpt-4o-mini + api_key = sk-xxx + prompt = "Determine whether someone is Chinese or American by their name" + output_data_type = boolean + strict_boolean_output = true + api_path = "http://mockserver:1080/v1/chat/completions" + plugin_output = "llm_output" + } +} + +sink { + Assert { + plugin_input = "llm_output" + rules = + { + field_rules = [ + { + field_name = llm_output + field_type = boolean + field_value = [ + { + rule_type = NOT_NULL + } + ] + } + ] + } + } +} diff --git a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransform.java b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransform.java index 8160cdc647..b1750070df 100644 --- a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransform.java +++ b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransform.java @@ -25,7 +25,9 @@ import org.apache.seatunnel.api.table.catalog.SeaTunnelDataTypeConvertorUtil; import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.api.table.type.SeaTunnelRowAccessor; +import org.apache.seatunnel.api.table.type.SqlType; import org.apache.seatunnel.transform.common.SingleFieldOutputTransform; +import org.apache.seatunnel.transform.exception.TransformCommonError; import org.apache.seatunnel.transform.nlpmodel.ModelProvider; import org.apache.seatunnel.transform.nlpmodel.ModelTransformConfig; import org.apache.seatunnel.transform.nlpmodel.llm.remote.Model; @@ -42,8 +44,13 @@ import java.util.Collections; import java.util.List; public class LLMTransform extends SingleFieldOutputTransform { + // Implicit UID at 8bea8c681cacccbd99fa642ca0853718980b9234, before strictBooleanOutput. + // Reproduce with serialver as documented in src/test/resources/llm/README.md. + private static final long serialVersionUID = 4711686225005641485L; + private final ReadonlyConfig config; private final SeaTunnelDataType<?> outputDataType; + private final boolean strictBooleanOutput; private Model model; public LLMTransform(@NonNull ReadonlyConfig config, @NonNull CatalogTable inputCatalogTable) { @@ -52,6 +59,7 @@ public class LLMTransform extends SingleFieldOutputTransform { this.outputDataType = SeaTunnelDataTypeConvertorUtil.deserializeSeaTunnelDataType( "output", config.get(LLMTransformConfig.OUTPUT_DATA_TYPE).toString()); + this.strictBooleanOutput = config.get(LLMTransformConfig.STRICT_BOOLEAN_OUTPUT); } private void tryOpen() { @@ -141,8 +149,17 @@ public class LLMTransform extends SingleFieldOutputTransform { protected Object getOutputFieldValue(SeaTunnelRowAccessor inputRow) { tryOpen(); SeaTunnelRow seaTunnelRow = new SeaTunnelRow(inputRow.getFields()); + List<String> values; + try { + values = model.inference(Collections.singletonList(seaTunnelRow)); + } catch (Exception e) { + throw inferenceFailure(seaTunnelRow, e); + } + // Keep validation outside legacy error wrapping so rejected output does not expose the row. + if (strictBooleanOutput && outputDataType.getSqlType() == SqlType.BOOLEAN) { + return parseStrictBooleanOutput(values); + } try { - List<String> values = model.inference(Collections.singletonList(seaTunnelRow)); switch (outputDataType.getSqlType()) { case STRING: return String.valueOf(values.get(0)); @@ -159,11 +176,28 @@ public class LLMTransform extends SingleFieldOutputTransform { "Unsupported output data type: " + outputDataType); } } catch (Exception e) { - throw new RuntimeException( - String.format("Failed to inference model with row %s", seaTunnelRow), e); + throw inferenceFailure(seaTunnelRow, e); } } + private boolean parseStrictBooleanOutput(List<String> values) { + String value = values != null && values.size() == 1 ? values.get(0) : null; + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + throw TransformCommonError.validationFailed( + "LLM strict_boolean_output requires exactly one non-null true or false result " + + "for output_data_type=BOOLEAN, without surrounding whitespace"); + } + + private RuntimeException inferenceFailure(SeaTunnelRow row, Exception cause) { + return new RuntimeException( + String.format("Failed to inference model with row %s", row), cause); + } + @Override protected Column getOutputColumn() { String customFieldName = config.get(LLMTransformConfig.OUTPUT_COLUMN_NAME); diff --git a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformConfig.java b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformConfig.java index b26e4791ce..e87db6e3f5 100644 --- a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformConfig.java +++ b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformConfig.java @@ -43,6 +43,15 @@ public class LLMTransformConfig extends ModelTransformConfig { .defaultValue("llm_output") .withDescription("custom field name for the llm output data"); + public static final Option<Boolean> STRICT_BOOLEAN_OUTPUT = + Options.key("strict_boolean_output") + .booleanType() + .defaultValue(false) + .withDescription( + "Require exactly one non-null true or false result for BOOLEAN output. " + + "Matching is case-insensitive and does not trim whitespace. " + + "The default false preserves legacy boolean conversion."); + public static final Option<Integer> INFERENCE_BATCH_SIZE = Options.key("inference_batch_size") .intType() diff --git a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformFactory.java b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformFactory.java index 67f855b4c6..74c7ba0632 100644 --- a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformFactory.java +++ b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/LLMTransformFactory.java @@ -46,6 +46,7 @@ public class LLMTransformFactory implements TableTransformFactory { .optional( LLMTransformConfig.API_PATH, LLMTransformConfig.OUTPUT_DATA_TYPE, + LLMTransformConfig.STRICT_BOOLEAN_OUTPUT, LLMTransformConfig.PROCESS_BATCH_SIZE) .conditional( LLMTransformConfig.MODEL_PROVIDER, diff --git a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/llm/LLMBooleanOutputTest.java b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/llm/LLMBooleanOutputTest.java new file mode 100644 index 0000000000..b341ee3d41 --- /dev/null +++ b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/llm/LLMBooleanOutputTest.java @@ -0,0 +1,383 @@ +/* + * 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.seatunnel.transform.llm; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConfigValidator; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.PhysicalColumn; +import org.apache.seatunnel.api.table.catalog.TableIdentifier; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.catalog.TableSchema; +import org.apache.seatunnel.api.table.factory.TableTransformFactoryContext; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.RowKind; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.transform.SeaTunnelMapTransform; +import org.apache.seatunnel.common.utils.SerializationUtils; +import org.apache.seatunnel.transform.exception.TransformException; +import org.apache.seatunnel.transform.nlpmodel.llm.LLMTransform; +import org.apache.seatunnel.transform.nlpmodel.llm.LLMTransformFactory; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +@Timeout(30) +class LLMBooleanOutputTest { + + private static final String INPUT_VALUE = "sensitive-row-value"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private MockWebServer server; + private LLMTransform transform; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + try { + if (transform != null) { + transform.close(); + } + } finally { + server.shutdown(); + } + } + + @ParameterizedTest + @MethodSource("legacyBooleanOutputs") + void preservesDefaultBooleanConversion(String response, boolean expected) throws IOException { + configure("BOOLEAN", null); + Assertions.assertEquals(expected, mapResponse(response).getField(1)); + } + + static Stream<Arguments> legacyBooleanOutputs() { + return Stream.of( + Arguments.of("[\"unknown\"]", false), + Arguments.of("[\"\"]", false), + Arguments.of("[null]", false), + Arguments.of("[\" true \"]", false), + Arguments.of("[\"yes\"]", false), + Arguments.of("[1]", false), + Arguments.of("[\"TRUE\"]", true), + Arguments.of("[false]", false), + Arguments.of("[false, true]", false)); + } + + @Test + void explicitlyDisabledValidationPreservesCoercion() throws IOException { + configure("BOOLEAN", false); + Assertions.assertEquals(false, mapResponse("[\"unknown\"]").getField(1)); + } + + @ParameterizedTest + @MethodSource("validBooleanOutputs") + void acceptsBooleanLiteralsAndPreservesCaseCompatibility(String response, boolean expected) + throws IOException { + configure("BOOLEAN", true); + Assertions.assertEquals(expected, mapResponse(response).getField(1)); + } + + static Stream<Arguments> validBooleanOutputs() { + return Stream.of( + Arguments.of("[true]", true), + Arguments.of("[false]", false), + Arguments.of("[True]", true), + Arguments.of("[FALSE]", false), + Arguments.of("[\"TrUe\"]", true), + Arguments.of("[\"FaLsE\"]", false)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "[\"unknown-private-response\"]", + "[\"\"]", + "[null]", + "[\" true \"]", + "[\"false \"]", + "[\"yes\"]", + "[0]", + "[]", + "null", + "[true, false]" + }) + void strictValidationFailsWithoutExposingRowOrResponse(String response) throws IOException { + configure("BOOLEAN", true); + TransformException error = + Assertions.assertThrows(TransformException.class, () -> mapResponse(response)); + Assertions.assertTrue(error.getMessage().contains("strict_boolean_output")); + Assertions.assertTrue(error.getMessage().contains("true or false")); + StringWriter stackTrace = new StringWriter(); + error.printStackTrace(new PrintWriter(stackTrace)); + Assertions.assertFalse(stackTrace.toString().contains(INPUT_VALUE)); + Assertions.assertFalse(stackTrace.toString().contains("unknown-private-response")); + Assertions.assertNull(error.getCause()); + Assertions.assertEquals(1, server.getRequestCount()); + } + + @ParameterizedTest + @MethodSource("nonBooleanOutputs") + void validationDoesNotChangeOtherOutputTypes(String type, String response, Object expected) + throws IOException { + configure(type, true); + Assertions.assertEquals(expected, mapResponse(response).getField(1)); + } + + static Stream<Arguments> nonBooleanOutputs() { + return Stream.of( + Arguments.of("STRING", "[\"unknown\"]", "unknown"), + Arguments.of("STRING", "[null]", "null"), + Arguments.of("STRING", "[\"first\", \"second\"]", "first"), + Arguments.of("INT", "[42]", 42), + Arguments.of("BIGINT", "[2147483648]", 2147483648L), + Arguments.of("DOUBLE", "[0.25]", 0.25D)); + } + + @Test + void preservesNumericConversionFailure() throws IOException { + configure("INT", true); + RuntimeException error = + Assertions.assertThrows( + RuntimeException.class, () -> mapResponse("[\"invalid-number\"]")); + Assertions.assertInstanceOf(NumberFormatException.class, error.getCause()); + } + + @Test + void preservesSchemaAndRowMetadata() throws IOException { + configure("BOOLEAN", true); + Assertions.assertEquals( + BasicType.BOOLEAN_TYPE, + transform + .getProducedCatalogTable() + .getTableSchema() + .getColumns() + .get(1) + .getDataType()); + SeaTunnelRow output = mapResponse("[true]"); + Assertions.assertEquals(INPUT_VALUE, output.getField(0)); + Assertions.assertEquals("test.input", output.getTableId()); + Assertions.assertEquals(RowKind.UPDATE_AFTER, output.getRowKind()); + } + + @Test + void exposesOptionalValidationInFactoryRule() { + Assertions.assertTrue( + new LLMTransformFactory() + .optionRule().getOptionalOptions().stream() + .anyMatch(option -> "strict_boolean_output".equals(option.key()))); + } + + @Test + void preservesLegacySerializationIdentifier() { + Assertions.assertEquals( + 4711686225005641485L, + ObjectStreamClass.lookup(LLMTransform.class).getSerialVersionUID()); + } + + @Test + @SuppressWarnings("unchecked") + void readsLegacyFactoryWrapperWithValidationDisabled() throws Exception { + try (InputStream resource = + getClass().getResourceAsStream("/llm/legacy-boolean-wrapper.base64")) { + Assertions.assertNotNull(resource); + try (ObjectInputStream input = + new ObjectInputStream(Base64.getMimeDecoder().wrap(resource)) { + { + enableResolveObject(true); + } + + @Override + protected Object resolveObject(Object value) { + if ("http://127.0.0.1:1/legacy-fixture".equals(value)) { + // Only relocate the HTTP endpoint; leave serialized fields intact. + return server.url("/chat/completions").toString(); + } + if (value instanceof LLMTransform) { + transform = (LLMTransform) value; + } + return value; + } + }) { + SeaTunnelMapTransform<SeaTunnelRow> restored = + (SeaTunnelMapTransform<SeaTunnelRow>) input.readObject(); + Assertions.assertNotNull(transform); + Assertions.assertEquals(false, mapResponse("[\"unknown\"]", restored).getField(1)); + Assertions.assertEquals(true, mapResponse("[\"TrUe\"]", restored).getField(1)); + SeaTunnelRow output = mapResponse("[\"FaLsE\"]", restored); + Assertions.assertEquals(false, output.getField(1)); + Assertions.assertEquals(INPUT_VALUE, output.getField(0)); + Assertions.assertEquals("test.input", output.getTableId()); + Assertions.assertEquals(RowKind.UPDATE_AFTER, output.getRowKind()); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @SuppressWarnings("unchecked") + void preservesValidationModeThroughFactoryWrapperSerialization(boolean strict) + throws IOException, ClassNotFoundException { + TableTransformFactoryContext context = + configuration("BOOLEAN", strict ? true : null, false); + byte[] serialized = + SerializationUtils.serialize( + new LLMTransformFactory().createTransform(context).createTransform()); + try (ObjectInputStream input = + new ObjectInputStream(new ByteArrayInputStream(serialized)) { + { + enableResolveObject(true); + } + + @Override + protected Object resolveObject(Object value) { + if (value instanceof LLMTransform) { + // Retain the child for teardown; the factory wrapper has no close hook. + transform = (LLMTransform) value; + } + return value; + } + }) { + SeaTunnelMapTransform<SeaTunnelRow> restored = + (SeaTunnelMapTransform<SeaTunnelRow>) input.readObject(); + Assertions.assertNotNull(transform); + if (strict) { + Assertions.assertThrows( + TransformException.class, () -> mapResponse("[\"unknown\"]", restored)); + } else { + Assertions.assertEquals(false, mapResponse("[\"unknown\"]", restored).getField(1)); + } + Assertions.assertEquals(true, mapResponse("[\"TrUe\"]", restored).getField(1)); + Assertions.assertEquals(false, mapResponse("[\"FaLsE\"]", restored).getField(1)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"[\"unknown\"]", "unknown"}) + void validatesCustomArrayStringsAndPlainStrings(String content) throws IOException { + configure("BOOLEAN", true, true); + Assertions.assertThrows(TransformException.class, () -> mapResponse(content)); + } + + @Test + void acceptsCustomPlainBooleanString() throws IOException { + configure("BOOLEAN", true, true); + Assertions.assertEquals(true, mapResponse("TRUE").getField(1)); + } + + private void configure(String type, Boolean strict) { + configure(type, strict, false); + } + + private void configure(String type, Boolean strict, boolean custom) { + TableTransformFactoryContext context = configuration(type, strict, custom); + transform = new LLMTransform(context.getOptions(), context.getCatalogTables().get(0)); + transform.getProducedCatalogTable(); + transform.open(); + } + + private TableTransformFactoryContext configuration( + String type, Boolean strict, boolean custom) { + Map<String, Object> options = new HashMap<>(); + options.put("model_provider", custom ? "CUSTOM" : "OPENAI"); + options.put("model", "test-model"); + options.put("api_key", "test-key"); + options.put("api_path", server.url("/chat/completions").toString()); + options.put("prompt", "Classify the input"); + options.put("output_data_type", type); + if (strict != null) { + options.put("strict_boolean_output", strict); + } + if (custom) { + Map<String, Object> customOptions = new HashMap<>(); + customOptions.put( + "custom_request_headers", + Collections.singletonMap("Content-Type", "application/json")); + customOptions.put("custom_request_body", Collections.singletonMap("input", "${input}")); + customOptions.put("custom_response_parse", "$.choices[0].message.content"); + options.put("custom_config", customOptions); + } + ReadonlyConfig config = ReadonlyConfig.fromMap(options); + ConfigValidator.of(config).validate(new LLMTransformFactory().optionRule()); + CatalogTable table = + CatalogTable.of( + TableIdentifier.of("catalog", TablePath.of("test", "input")), + TableSchema.builder() + .column( + PhysicalColumn.of( + "text", + BasicType.STRING_TYPE, + (Long) null, + true, + null, + null)) + .build(), + Collections.emptyMap(), + Collections.emptyList(), + "test input"); + return new TableTransformFactoryContext( + Collections.singletonList(table), + config, + Thread.currentThread().getContextClassLoader()); + } + + private SeaTunnelRow mapResponse(String content) throws IOException { + return mapResponse(content, transform); + } + + private SeaTunnelRow mapResponse(String content, SeaTunnelMapTransform<SeaTunnelRow> target) + throws IOException { + ObjectNode response = MAPPER.createObjectNode(); + response.putArray("choices").addObject().putObject("message").put("content", content); + server.enqueue(new MockResponse().setBody(MAPPER.writeValueAsString(response))); + SeaTunnelRow input = new SeaTunnelRow(new Object[] {INPUT_VALUE}); + input.setTableId("test.input"); + input.setRowKind(RowKind.UPDATE_AFTER); + return target.map(input); + } +} diff --git a/seatunnel-transforms-v2/src/test/resources/llm/GenerateLegacyLLMFixture.java b/seatunnel-transforms-v2/src/test/resources/llm/GenerateLegacyLLMFixture.java new file mode 100644 index 0000000000..00ca99dd23 --- /dev/null +++ b/seatunnel-transforms-v2/src/test/resources/llm/GenerateLegacyLLMFixture.java @@ -0,0 +1,80 @@ +/* + * 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 org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.PhysicalColumn; +import org.apache.seatunnel.api.table.catalog.TableIdentifier; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.catalog.TableSchema; +import org.apache.seatunnel.api.table.factory.TableTransformFactoryContext; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.common.utils.SerializationUtils; +import org.apache.seatunnel.transform.nlpmodel.llm.LLMTransform; +import org.apache.seatunnel.transform.nlpmodel.llm.LLMTransformFactory; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class GenerateLegacyLLMFixture { + public static void main(String[] args) throws Exception { + // Fail closed if accidentally invoked with the new class instead of the legacy classes. + for (Field field : LLMTransform.class.getDeclaredFields()) { + if (field.getName().equals("strictBooleanOutput") + || field.getName().equals("serialVersionUID")) { + throw new IllegalStateException("Compile and load the pre-change LLM classes first"); + } + } + Map<String, Object> options = new HashMap<>(); + options.put("model_provider", "OPENAI"); + options.put("model", "test-model"); + options.put("api_key", "test-key"); + options.put("api_path", "http://127.0.0.1:1/legacy-fixture"); + options.put("prompt", "Classify the input"); + options.put("output_data_type", "BOOLEAN"); + CatalogTable table = + CatalogTable.of( + TableIdentifier.of("catalog", TablePath.of("test", "input")), + TableSchema.builder() + .column( + PhysicalColumn.of( + "text", + BasicType.STRING_TYPE, + (Long) null, + true, + null, + null)) + .build(), + Collections.emptyMap(), + Collections.emptyList(), + "test input"); + TableTransformFactoryContext context = + new TableTransformFactoryContext( + Collections.singletonList(table), + ReadonlyConfig.fromMap(options), + Thread.currentThread().getContextClassLoader()); + byte[] bytes = + SerializationUtils.serialize( + new LLMTransformFactory().createTransform(context).createTransform()); + Files.write(Paths.get(args[0]), Base64.getMimeEncoder(76, new byte[] {'\n'}).encode(bytes)); + } +} diff --git a/seatunnel-transforms-v2/src/test/resources/llm/README.md b/seatunnel-transforms-v2/src/test/resources/llm/README.md new file mode 100644 index 0000000000..14a8f00e11 --- /dev/null +++ b/seatunnel-transforms-v2/src/test/resources/llm/README.md @@ -0,0 +1,56 @@ +<!-- +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. +--> + +# Legacy LLM Serialization Fixture + +`legacy-boolean-wrapper.base64` contains Java serialization bytes from the +factory-created `LLMMultiCatalogTransform` at commit +`8bea8c681cacccbd99fa642ca0853718980b9234`, before `strict_boolean_output`. +It contains an un-opened `LLMTransform`, with no explicit `serialVersionUID` +and no `strictBooleanOutput` field. The configuration has only dummy values. +No model request is made during generation. + +Set `JAVA_HOME` to JDK 8 or 11 and compile the transform module first. From the +repository root, reproduce the implicit UID and compare the serialized bytes: + +```shell +./mvnw -T1 -pl seatunnel-transforms-v2 -am -DskipTests verify +bash seatunnel-transforms-v2/src/test/resources/llm/generate-legacy-fixture.sh +``` + +Expected `serialver` output: + +```text +org.apache.seatunnel.transform.nlpmodel.llm.LLMTransform: private static final long serialVersionUID = 4711686225005641485L; +``` + +The script compiles the four original LLM classes directly from that commit, +ahead of the current module classes on the classpath. Their supporting classes +were unchanged by this PR; local reactor-built API and Common classes take +precedence over installed snapshots. The generator refuses the current LLM class shape. +An optional output-file argument writes regenerated bytes instead of comparing. +Do not regenerate the fixture using new production classes when changing the +serialization contract: its purpose is to retain an independently produced old +stream, not to make a failing compatibility test pass. + +`LLMBooleanOutputTest.readsLegacyFactoryWrapperWithValidationDisabled` reads +these archived bytes with current classes, redirects only the dummy HTTP URL +to a local server, and checks malformed-output coercion, boolean literals, +and row metadata. Ordinary CI needs neither Git history nor a Java compiler +for this test. A changed UID fails deserialization, and enabling strict mode +for the old payload fails the malformed-output assertion. This is an old-plan +serialization regression, not a full cluster-upgrade test. diff --git a/seatunnel-transforms-v2/src/test/resources/llm/generate-legacy-fixture.sh b/seatunnel-transforms-v2/src/test/resources/llm/generate-legacy-fixture.sh new file mode 100644 index 0000000000..5b083eb2f5 --- /dev/null +++ b/seatunnel-transforms-v2/src/test/resources/llm/generate-legacy-fixture.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail +: "${JAVA_HOME:?Set JAVA_HOME to JDK 8 or 11}" +root=$(git rev-parse --show-toplevel) +module="$root/seatunnel-transforms-v2" +resource="$module/src/test/resources/llm" +baseline=8bea8c681cacccbd99fa642ca0853718980b9234 +scratch=$(mktemp -d "$module/target/legacy-fixture.XXXXXX") +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/classes" +"$root/mvnw" -T1 -q -f "$module/pom.xml" dependency:build-classpath \ + -Dmdep.outputFile="$scratch/classpath.txt" +classpath="$module/target/classes:$root/seatunnel-api/target/classes:$root/seatunnel-common/target/classes:$(<"$scratch/classpath.txt")" +for name in LLMTransform LLMTransformConfig LLMTransformFactory LLMMultiCatalogTransform; do + git -C "$root" show "$baseline:seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/nlpmodel/llm/$name.java" > "$scratch/$name.java" +done +"$JAVA_HOME/bin/javac" -source 8 -target 8 -cp "$classpath" \ + -d "$scratch/classes" "$scratch/"*.java "$resource/GenerateLegacyLLMFixture.java" +"$JAVA_HOME/bin/serialver" -classpath "$scratch/classes:$classpath" \ + org.apache.seatunnel.transform.nlpmodel.llm.LLMTransform +"$JAVA_HOME/bin/java" -cp "$scratch/classes:$classpath" GenerateLegacyLLMFixture \ + "${1:-$scratch/legacy-boolean-wrapper.base64}" +if [ "$#" -eq 0 ]; then + cmp "$scratch/legacy-boolean-wrapper.base64" "$resource/legacy-boolean-wrapper.base64" +fi diff --git a/seatunnel-transforms-v2/src/test/resources/llm/legacy-boolean-wrapper.base64 b/seatunnel-transforms-v2/src/test/resources/llm/legacy-boolean-wrapper.base64 new file mode 100644 index 0000000000..8ffb6b1da1 --- /dev/null +++ b/seatunnel-transforms-v2/src/test/resources/llm/legacy-boolean-wrapper.base64 @@ -0,0 +1,74 @@ +rO0ABXNyAERvcmcuYXBhY2hlLnNlYXR1bm5lbC50cmFuc2Zvcm0ubmxwbW9kZWwubGxtLkxMTU11 +bHRpQ2F0YWxvZ1RyYW5zZm9ybeJvyDh95WHhAgAAeHIARm9yZy5hcGFjaGUuc2VhdHVubmVsLnRy +YW5zZm9ybS5jb21tb24uQWJzdHJhY3RNdWx0aUNhdGFsb2dNYXBUcmFuc2Zvcm2ljUyLnr2leQIA +AHhyAENvcmcuYXBhY2hlLnNlYXR1bm5lbC50cmFuc2Zvcm0uY29tbW9uLkFic3RyYWN0TXVsdGlD +YXRhbG9nVHJhbnNmb3JtYmWoJDXMnZECAANMABJpbnB1dENhdGFsb2dUYWJsZXN0ABBMamF2YS91 +dGlsL0xpc3Q7TAATb3V0cHV0Q2F0YWxvZ1RhYmxlc3EAfgADTAAMdHJhbnNmb3JtTWFwdAAPTGph +dmEvdXRpbC9NYXA7eHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQ +PKeblwIAAUwAB2VsZW1lbnR0ABJMamF2YS9sYW5nL09iamVjdDt4cHNyADNvcmcuYXBhY2hlLnNl +YXR1bm5lbC5hcGkudGFibGUuY2F0YWxvZy5DYXRhbG9nVGFibGUAAAAAAAAAAQIAB0wAC2NhdGFs +b2dOYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7TAAHY29tbWVudHEAfgAKTAAIbWV0YWRhdGF0ADdM +b3JnL2FwYWNoZS9zZWF0dW5uZWwvYXBpL3RhYmxlL2NhdGFsb2cvTWV0YWRhdGFTY2hlbWE7TAAH +b3B0aW9uc3EAfgAETAANcGFydGl0aW9uS2V5c3EAfgADTAAHdGFibGVJZHQAOExvcmcvYXBhY2hl +L3NlYXR1bm5lbC9hcGkvdGFibGUvY2F0YWxvZy9UYWJsZUlkZW50aWZpZXI7TAALdGFibGVTY2hl +bWF0ADRMb3JnL2FwYWNoZS9zZWF0dW5uZWwvYXBpL3RhYmxlL2NhdGFsb2cvVGFibGVTY2hlbWE7 +eHB0AAdjYXRhbG9ndAAKdGVzdCBpbnB1dHNyADVvcmcuYXBhY2hlLnNlYXR1bm5lbC5hcGkudGFi +bGUuY2F0YWxvZy5NZXRhZGF0YVNjaGVtYQAAAAAAAAABAgAAeHIANW9yZy5hcGFjaGUuc2VhdHVu +bmVsLmFwaS50YWJsZS5jYXRhbG9nLkFic3RyYWN0U2NoZW1hAAAAAAAAAAECAAJMAAtjb2x1bW5O +YW1lc3EAfgADTAAHY29sdW1uc3EAfgADeHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2Gd +AwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhzcQB+ABQAAAAAdwQAAAAAeHNyABFqYXZhLnV0aWwuSGFz +aE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAB3CAAAABAA +AAAAeHNxAH4AFAAAAAB3BAAAAAB4c3IANm9yZy5hcGFjaGUuc2VhdHVubmVsLmFwaS50YWJsZS5j +YXRhbG9nLlRhYmxlSWRlbnRpZmllcgAAAAAAAAABAgAETAALY2F0YWxvZ05hbWVxAH4ACkwADGRh +dGFiYXNlTmFtZXEAfgAKTAAKc2NoZW1hTmFtZXEAfgAKTAAJdGFibGVOYW1lcQB+AAp4cHEAfgAP +dAAEdGVzdHB0AAVpbnB1dHNyADJvcmcuYXBhY2hlLnNlYXR1bm5lbC5hcGkudGFibGUuY2F0YWxv +Zy5UYWJsZVNjaGVtYQAAAAAAAAABAgACTAAOY29uc3RyYWludEtleXNxAH4AA0wACnByaW1hcnlL +ZXl0ADNMb3JnL2FwYWNoZS9zZWF0dW5uZWwvYXBpL3RhYmxlL2NhdGFsb2cvUHJpbWFyeUtleTt4 +cQB+ABJzcQB+ABQAAAABdwQAAAABdAAEdGV4dHhzcQB+ABQAAAABdwQAAAABc3IANW9yZy5hcGFj +aGUuc2VhdHVubmVsLmFwaS50YWJsZS5jYXRhbG9nLlBoeXNpY2FsQ29sdW1uAAAAAAAAAAECAAB4 +cgAtb3JnLmFwYWNoZS5zZWF0dW5uZWwuYXBpLnRhYmxlLmNhdGFsb2cuQ29sdW1u//////////8C +AA5aAAppc1Vuc2lnbmVkWgAKaXNaZXJvRmlsbFoACG51bGxhYmxlTAAGYml0TGVudAAQTGphdmEv +bGFuZy9Mb25nO0wADGNvbHVtbkxlbmd0aHEAfgAmTAAHY29tbWVudHEAfgAKTAAIZGF0YVR5cGV0 +ADdMb3JnL2FwYWNoZS9zZWF0dW5uZWwvYXBpL3RhYmxlL3R5cGUvU2VhVHVubmVsRGF0YVR5cGU7 +TAAMZGVmYXVsdFZhbHVlcQB+AAdMABBsb25nQ29sdW1uTGVuZ3RocQB+ACZMAARuYW1lcQB+AApM +AAdvcHRpb25zcQB+AARMAAVzY2FsZXQAE0xqYXZhL2xhbmcvSW50ZWdlcjtMAAhzaW5rVHlwZXEA +fgAKTAAKc291cmNlVHlwZXEAfgAKeHAAAAFzcgAOamF2YS5sYW5nLkxvbmc7i+SQzI8j3wIAAUoA +BXZhbHVleHIAEGphdmEubGFuZy5OdW1iZXKGrJUdC5TgiwIAAHhwAAAAAAAAAABwcHNyAC1vcmcu +YXBhY2hlLnNlYXR1bm5lbC5hcGkudGFibGUudHlwZS5CYXNpY1R5cGUAAAAAAAAAAgIAAkwAB3Nx +bFR5cGV0AC1Mb3JnL2FwYWNoZS9zZWF0dW5uZWwvYXBpL3RhYmxlL3R5cGUvU3FsVHlwZTtMAAl0 +eXBlQ2xhc3N0ABFMamF2YS9sYW5nL0NsYXNzO3hwfnIAK29yZy5hcGFjaGUuc2VhdHVubmVsLmFw +aS50YWJsZS50eXBlLlNxbFR5cGUAAAAAAAAAABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAA +EgAAeHB0AAZTVFJJTkd2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHBwcHEAfgAicHBw +cHhzcQB+ABQAAAAAdwQAAAAAeHBzcQB+ABQAAAABdwQAAAABc3EAfgAJcQB+AA9xAH4AEHEAfgAT +c3EAfgAXP0AAAAAAAAB3CAAAABAAAAAAeHNxAH4AFAAAAAB3BAAAAAB4c3EAfgAacQB+AA9xAH4A +HHBxAH4AHXNxAH4AHnNxAH4AFAAAAAJ3BAAAAAJxAH4AInQACmxsbV9vdXRwdXR4c3EAfgAUAAAA +AncEAAAAAnNxAH4AJAAAAXEAfgAscHBxAH4AMHBwcQB+ACJwcHBwc3EAfgAkAAABcQB+ACxwdAAU +T3V0cHV0IGNvbHVtbiBvZiBMTE1zcQB+AC1+cQB+ADF0AAdCT09MRUFOdnIAEWphdmEubGFuZy5C +b29sZWFuzSBygNWc+u4CAAFaAAV2YWx1ZXhwcHBxAH4AP3BwcHB4c3EAfgAUAAAAAHcEAAAAAHhw +eHNxAH4AFz9AAAAAAAAMdwgAAAAQAAAAAXQACnRlc3QuaW5wdXRzcgA4b3JnLmFwYWNoZS5zZWF0 +dW5uZWwudHJhbnNmb3JtLm5scG1vZGVsLmxsbS5MTE1UcmFuc2Zvcm1BY0Wodo//DQIAA0wABmNv +bmZpZ3QAN0xvcmcvYXBhY2hlL3NlYXR1bm5lbC9hcGkvY29uZmlndXJhdGlvbi9SZWFkb25seUNv +bmZpZztMAAVtb2RlbHQAOkxvcmcvYXBhY2hlL3NlYXR1bm5lbC90cmFuc2Zvcm0vbmxwbW9kZWwv +bGxtL3JlbW90ZS9Nb2RlbDtMAA5vdXRwdXREYXRhVHlwZXEAfgAneHIAQG9yZy5hcGFjaGUuc2Vh +dHVubmVsLnRyYW5zZm9ybS5jb21tb24uU2luZ2xlRmllbGRPdXRwdXRUcmFuc2Zvcm2jHvtYcP9U +QAIAAkkACmZpZWxkSW5kZXhMABVyb3dDb250YWluZXJHZW5lcmF0b3J0AEZMb3JnL2FwYWNoZS9z +ZWF0dW5uZWwvdHJhbnNmb3JtL2NvbW1vbi9TZWFUdW5uZWxSb3dDb250YWluZXJHZW5lcmF0b3I7 +eHIASG9yZy5hcGFjaGUuc2VhdHVubmVsLnRyYW5zZm9ybS5jb21tb24uQWJzdHJhY3RDYXRhbG9n +U3VwcG9ydE1hcFRyYW5zZm9ybWxQVbZiwsG7AgAAeHIAQG9yZy5hcGFjaGUuc2VhdHVubmVsLnRy +YW5zZm9ybS5jb21tb24uQWJzdHJhY3RTZWFUdW5uZWxUcmFuc2Zvcm1wj/I+nfMk5gIAA0wAEWlu +cHV0Q2F0YWxvZ1RhYmxldAA1TG9yZy9hcGFjaGUvc2VhdHVubmVsL2FwaS90YWJsZS9jYXRhbG9n +L0NhdGFsb2dUYWJsZTtMABJvdXRwdXRDYXRhbG9nVGFibGVxAH4AU0wAEXJvd0Vycm9ySGFuZGxl +V2F5dAA2TG9yZy9hcGFjaGUvc2VhdHVubmVsL3RyYW5zZm9ybS9jb21tb24vRXJyb3JIYW5kbGVX +YXk7eHBxAH4ADnEAfgA5fnIANG9yZy5hcGFjaGUuc2VhdHVubmVsLnRyYW5zZm9ybS5jb21tb24u +RXJyb3JIYW5kbGVXYXkAAAAAAAAAABIAAHhxAH4AMnQABEZBSUwAAAABc3IAQm9yZy5hcGFjaGUu +c2VhdHVubmVsLnRyYW5zZm9ybS5jb21tb24uU2luZ2xlRmllbGRPdXRwdXRUcmFuc2Zvcm0kMW60 +3vythD6tAgADSQAUdmFsJGlucHV0RmllbGRMZW5ndGhJABV2YWwkb3V0cHV0RmllbGRMZW5ndGhM +AAZ0aGlzJDB0AEJMb3JnL2FwYWNoZS9zZWF0dW5uZWwvdHJhbnNmb3JtL2NvbW1vbi9TaW5nbGVG +aWVsZE91dHB1dFRyYW5zZm9ybTt4cgBEb3JnLmFwYWNoZS5zZWF0dW5uZWwudHJhbnNmb3JtLmNv +bW1vbi5TZWFUdW5uZWxSb3dDb250YWluZXJHZW5lcmF0b3JmXkon0O1X6gIAAHhwAAAAAQAAAAJx +AH4AVXNyADVvcmcuYXBhY2hlLnNlYXR1bm5lbC5hcGkuY29uZmlndXJhdGlvbi5SZWFkb25seUNv +bmZpZwAAAAAAAAABAgABTAAIY29uZkRhdGFxAH4ABHhwc3EAfgAXP0AAAAAAAAx3CAAAABAAAAAG +dAAQb3V0cHV0X2RhdGFfdHlwZXEAfgBGdAAObW9kZWxfcHJvdmlkZXJ0AAZPUEVOQUl0AAdhcGlf +a2V5dAAIdGVzdC1rZXl0AAVtb2RlbHQACnRlc3QtbW9kZWx0AAhhcGlfcGF0aHQAIWh0dHA6Ly8x +MjcuMC4wLjE6MS9sZWdhY3ktZml4dHVyZXQABnByb21wdHQAEkNsYXNzaWZ5IHRoZSBpbnB1dHhw +cQB+AER4 \ No newline at end of file
