This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 0601aa9abca9d6fb6d3df173010e655f0a12137c Author: Luigi De Masi <[email protected]> AuthorDate: Wed Sep 30 08:47:53 2026 +0200 CAMEL-25138: Refine semantic declaration export Export registered questions through the model configurer SPI, including lazy discovery for YAML-only declarations, and omit default boolean decision policies from converted output. Cover Java filter and nested Simple expression conversion to YAML/XML, default-policy round trips, and numeric placeholders. Include the underlying error when conversion fails and document temporary-context property resolution. Validation: 549 tests passed on Java 17 across core-model, semantic and MCP. Full repository clean install with tests skipped passed on Java 21 across all 695 modules. Regenerated catalog documentation is included. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 11 ++- .../src/main/docs/semantic-language.adoc | 11 ++- .../DefaultSemanticDefinitionConfigurer.java | 15 +++- .../camel/model/SemanticDefinitionHelper.java | 29 +++++-- .../model/spi/SemanticDefinitionConfigurer.java | 5 ++ .../jbang/core/commands/mcp/TransformTools.java | 16 ++-- .../core/commands/mcp/TransformToolsTest.java | 95 +++++++++++++++++++++- 7 files changed, 160 insertions(+), 22 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc index 06e37072ac63..c1a09c185936 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc @@ -184,8 +184,15 @@ can also be selected together using `refs:name1,name2`, as described below. Direct exports of a `RoutesDefinition` containing semantic declarations preserve them. The MCP route transformation tool also preserves declarations in XML-to-YAML, YAML-to-XML, and -Java-to-YAML/XML conversions. Conversions that load a Camel context export the registered -questions with their effective numeric policies. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only +Java-to-YAML/XML conversions. YAML-to-XML and Java-to-YAML/XML conversions load a temporary +Camel context and export the registered questions with their effective numeric policies, +omitting settings that match the defaults. Numeric placeholders must resolve in that context; +the tool does not load the application's external properties. Provide a placeholder default, +such as `{{semantic.threshold:0.5}}`, or a concrete value when the property is unavailable. +Unresolved required properties fail conversion. XML-to-YAML conversion preserves placeholder +text without resolving it. + +Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only the routes and omit the question declarations. Supply the declarations separately before reloading those dumps. diff --git a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc index 06e37072ac63..c1a09c185936 100644 --- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc +++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc @@ -184,8 +184,15 @@ can also be selected together using `refs:name1,name2`, as described below. Direct exports of a `RoutesDefinition` containing semantic declarations preserve them. The MCP route transformation tool also preserves declarations in XML-to-YAML, YAML-to-XML, and -Java-to-YAML/XML conversions. Conversions that load a Camel context export the registered -questions with their effective numeric policies. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only +Java-to-YAML/XML conversions. YAML-to-XML and Java-to-YAML/XML conversions load a temporary +Camel context and export the registered questions with their effective numeric policies, +omitting settings that match the defaults. Numeric placeholders must resolve in that context; +the tool does not load the application's external properties. Provide a placeholder default, +such as `{{semantic.threshold:0.5}}`, or a concrete value when the property is unavailable. +Unresolved required properties fail conversion. XML-to-YAML conversion preserves placeholder +text without resolving it. + +Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only the routes and omit the question declarations. Supply the declarations separately before reloading those dumps. diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java index 9971fe18596d..909454e17faf 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java @@ -53,8 +53,8 @@ public class DefaultSemanticDefinitionConfigurer implements SemanticDefinitionCo SemanticQuestions.get(context).replace("model:" + source, resource, questions); } - /** Export a snapshot of the registered questions and their effective decision policies. */ - public static SemanticDefinition getDefinition(CamelContext context) { + @Override + public SemanticDefinition getDefinition(CamelContext context) { SemanticQuestions questions = context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class); if (questions == null) { return null; @@ -68,8 +68,15 @@ public class DefaultSemanticDefinitionConfigurer implements SemanticDefinitionCo question.getCriteria().forEach(target::criterion); question.getLevels().forEach(target::level); if (question.getType() == SemanticQuestion.Type.BOOLEAN) { - target.threshold(question.getThreshold()).uncertainty(question.getUncertainty()) - .uncertaintyPolicy(question.getUncertaintyPolicy().name().toLowerCase(Locale.ROOT).replace('_', '-')); + if (question.getThreshold() != 0.5) { + target.threshold(question.getThreshold()); + } + if (question.getUncertainty() != 0) { + target.uncertainty(question.getUncertainty()); + } + if (question.getUncertaintyPolicy() != SemanticQuestion.UncertaintyPolicy.FAIL) { + target.uncertaintyPolicy(question.getUncertaintyPolicy().name().toLowerCase(Locale.ROOT).replace('_', '-')); + } } }); return definition; diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java b/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java index 95f4a8b1c922..57ab5253c1e2 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java @@ -35,14 +35,33 @@ public final class SemanticDefinitionHelper { if (definition == null || definition.getQuestions().isEmpty()) { return; } + configurer = getConfigurer(context); + if (configurer == null) { + throw new IllegalArgumentException("Semantic question declarations require camel-semantic on the classpath"); + } + } + configurer.configure(context, resource, source, definition); + } + + /** Export registered questions without requiring the semantic module for ordinary routes. */ + public static SemanticDefinition getDefinition(CamelContext context) { + // YAML declarations can populate the registry before the configurer has been discovered. + SemanticDefinitionConfigurer configurer = getConfigurer(context); + return configurer != null ? configurer.getDefinition(context) : null; + } + + private static SemanticDefinitionConfigurer getConfigurer(CamelContext context) { + SemanticDefinitionConfigurer configurer + = context.getCamelContextExtension().getContextPlugin(SemanticDefinitionConfigurer.class); + if (configurer == null) { // Concurrent discovery may create equivalent instances of the stateless default configurer. // The semantic module synchronizes access to shared question state. configurer = context.getCamelContextExtension().getDefaultFactoryFinder() - .newInstance("semantic-configurer", SemanticDefinitionConfigurer.class) - .orElseThrow(() -> new IllegalArgumentException( - "Semantic question declarations require camel-semantic on the classpath")); - context.getCamelContextExtension().addContextPlugin(SemanticDefinitionConfigurer.class, configurer); + .newInstance("semantic-configurer", SemanticDefinitionConfigurer.class).orElse(null); + if (configurer != null) { + context.getCamelContextExtension().addContextPlugin(SemanticDefinitionConfigurer.class, configurer); + } } - configurer.configure(context, resource, source, definition); + return configurer; } } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java b/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java index 06485ca98106..38db8263f6b5 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java @@ -27,4 +27,9 @@ public interface SemanticDefinitionConfigurer { * the owner independently of the optional resource used to track deletion. */ void configure(CamelContext context, Resource resource, String source, SemanticDefinition definition); + + /** Export registered questions with their effective policies, or {@code null} if no definitions are available. */ + default SemanticDefinition getDefinition(CamelContext context) { + return null; + } } diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java index 92cb87bb0fd2..db658a0f65a3 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java @@ -34,11 +34,12 @@ import org.apache.camel.model.ExpressionNode; import org.apache.camel.model.ProcessorDefinitionHelper; import org.apache.camel.model.RouteDefinition; import org.apache.camel.model.RoutesDefinition; +import org.apache.camel.model.SemanticDefinitionHelper; import org.apache.camel.model.app.SemanticDefinition; -import org.apache.camel.semantic.DefaultSemanticDefinitionConfigurer; import org.apache.camel.spi.Resource; import org.apache.camel.support.PluginHelper; import org.apache.camel.support.ResourceHelper; +import org.apache.camel.util.ObjectHelper; import org.apache.camel.util.json.JsonObject; import org.apache.camel.xml.in.ModelParser; import org.apache.camel.yaml.out.YamlModelWriter; @@ -100,8 +101,13 @@ public class TransformTools { result.note = "Unsupported transformation: " + fromFormat + " to " + toFormat; } } catch (Throwable e) { + Throwable cause = ObjectHelper.createExceptionIterator(e).next(); + String message = e.getMessage(); + if (cause != e) { + message += ": " + cause.getMessage(); + } throw new ToolCallException( - "Failed to transform route (" + e.getClass().getName() + "): " + e.getMessage(), null); + "Failed to transform route (" + e.getClass().getName() + "): " + message, null); } return result; @@ -158,7 +164,7 @@ public class TransformTools { RoutesDefinition rd = new RoutesDefinition(); rd.setRoutes(routeDefs); - rd.setSemantic(DefaultSemanticDefinitionConfigurer.getDefinition(ctx)); + rd.setSemantic(SemanticDefinitionHelper.getDefinition(ctx)); StringWriter sw = new StringWriter(); new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); @@ -191,7 +197,7 @@ public class TransformTools { if ("yaml".equals(targetFormat)) { YamlModelWriter writer = new YamlModelWriter(); List<JsonObject> roots = new ArrayList<>(); - SemanticDefinition semantic = DefaultSemanticDefinitionConfigurer.getDefinition(ctx); + SemanticDefinition semantic = SemanticDefinitionHelper.getDefinition(ctx); if (semantic != null) { roots.add(writer.writeSemanticDefinition(semantic)); } @@ -202,7 +208,7 @@ public class TransformTools { } else { RoutesDefinition rd = new RoutesDefinition(); rd.setRoutes(routeDefs); - rd.setSemantic(DefaultSemanticDefinitionConfigurer.getDefinition(ctx)); + rd.setSemantic(SemanticDefinitionHelper.getDefinition(ctx)); StringWriter sw = new StringWriter(); new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java index 4c45f5b9eb78..36170287d720 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java @@ -16,15 +16,20 @@ */ package org.apache.camel.dsl.jbang.core.commands.mcp; +import io.quarkiverse.mcp.server.ToolCallException; import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.model.FilterDefinition; +import org.apache.camel.model.SetBodyDefinition; import org.apache.camel.semantic.SemanticQuestions; import org.apache.camel.support.PluginHelper; import org.apache.camel.support.ResourceHelper; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class TransformToolsTest { @@ -74,8 +79,8 @@ class TransformToolsTest { urgent: type: boolean instructions: Urgent? - threshold: 0.8 - uncertainty: 0.1 + threshold: "{{semantic.export.threshold:0.8}}" + uncertainty: "{{semantic.export.uncertainty:0.1}}" uncertaintyPolicy: non-match department: type: choice @@ -103,7 +108,8 @@ class TransformToolsTest { public class SemanticRoute extends RouteBuilder { public void configure() { semanticQuestions().question("urgent").type("boolean").instructions("Urgent?") - .threshold(0.8).uncertainty(0.1).uncertaintyPolicy("non-match"); + .threshold("{{semantic.export.threshold:0.8}}") + .uncertainty("{{semantic.export.uncertainty:0.1}}").uncertaintyPolicy("non-match"); semanticQuestions().question("department").type("choice").instructions("Which department?") .state("${header.selected}").criterion("billing", "Invoices").criterion("technical", "Bugs"); semanticQuestions().question("priority").type("score").instructions("Priority?") @@ -114,7 +120,7 @@ class TransformToolsTest { """; var result = createTools().camel_transform_route(source.equals("java") ? java : yaml, source, target); assertThat(result.supported).isTrue(); - assertThat(result.result).contains("ref:department"); + assertThat(result.result).contains("ref:department").doesNotContain("{{semantic.export."); try (var context = new DefaultCamelContext()) { context.build(); PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("result." + target, result.result)); @@ -129,6 +135,87 @@ class TransformToolsTest { } } + @ParameterizedTest + @CsvSource({ "yaml,xml", "java,xml", "java,yaml" }) + void semanticConversionOmitsDefaultPolicies(String source, String target) throws Exception { + var result = createTools().camel_transform_route(semanticRoute(source, "0.5"), source, target); + + assertThat(result.supported).isTrue(); + assertThat(result.result).contains("urgent", "Urgent?").doesNotContain("threshold", "uncertainty"); + try (var context = new DefaultCamelContext()) { + context.build(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("result." + target, result.result)); + var question = SemanticQuestions.get(context).get("urgent"); + assertThat(question.getThreshold()).isEqualTo(0.5); + assertThat(question.getUncertainty()).isZero(); + assertThat(question.getUncertaintyPolicy().name()).isEqualTo("FAIL"); + } + } + + @ParameterizedTest + @CsvSource({ "yaml,xml", "java,xml", "java,yaml" }) + void semanticConversionReportsMissingNumericProperty(String source, String target) { + String route = semanticRoute(source, "{{semantic.export.missing.threshold}}"); + + assertThatThrownBy(() -> createTools().camel_transform_route(route, source, target)) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("Property with key [semantic.export.missing.threshold] not found"); + } + + private static String semanticRoute(String source, String threshold) { + if (source.equals("java")) { + return """ + semanticQuestions().question("urgent").type("boolean").instructions("Urgent?") + .threshold("%s").uncertainty(0).uncertaintyPolicy("fail"); + from("direct:input").setBody().language("semantic", "ref:urgent"); + """.formatted(threshold); + } + return """ + - semantic: + question: + urgent: + type: boolean + instructions: Urgent? + threshold: "%s" + uncertainty: 0 + uncertaintyPolicy: fail + - route: + from: + uri: direct:input + steps: + - setBody: + expression: + language: + language: semantic + expression: ref:urgent + """.formatted(threshold); + } + + @ParameterizedTest + @ValueSource(strings = { "yaml", "xml" }) + void nonSemanticExpressionClausesSurviveConversionAndReload(String target) throws Exception { + String route = """ + from("direct:input") + .filter().simple("${body} == 'hello'") + .setBody().simple("${body.toUpperCase()}") + .end(); + """; + var result = createTools().camel_transform_route(route, "java", target); + + assertThat(result.supported).isTrue(); + assertThat(result.result).doesNotContain("semantic"); + try (var context = new DefaultCamelContext()) { + context.build(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("result." + target, result.result)); + var filter = (FilterDefinition) context.getRouteDefinitions().get(0).getOutputs().get(0); + assertThat(filter.getExpression().getLanguage()).isEqualTo("simple"); + assertThat(filter.getExpression().getExpression()).isEqualTo("${body} == 'hello'"); + var setBody = (SetBodyDefinition) filter.getOutputs().get(0); + assertThat(setBody.getExpression().getLanguage()).isEqualTo("simple"); + assertThat(setBody.getExpression().getExpression()).isEqualTo("${body.toUpperCase()}"); + } + } + @Test void transformYamlToXml() { TransformTools tools = createTools();
