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 f8ec5e5b7e3d0..c654e13feb351 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
@@ -96,9 +96,144 @@ Reloading a resource replaces its complete set of questions, including removing
no longer present. Development-mode route reload also removes definitions from deleted or renamed files
before parsing replacements. This replacement does not make the surrounding route reload transactional.
Existing expressions resolve the current definition on their next evaluation.
-Loading declarations does not perform inference. Java applications can register immutable
+Loading declarations does not perform inference.
+
+=== Java declarations
+
+Use the fluent helper from `camel-semantic` inside an ordinary `RouteBuilder`:
+
+[source,java]
+----
+import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+
+@Override
+public void configure() {
+ semanticQuestions(this)
+ .question("department")
+ .type("choice")
+ .state("${header.myState}")
+ .instructions("Which department should handle this message?")
+ .criterion("billing", "Invoices, payments, and refunds")
+ .criterion("technical", "Bugs, outages, and technical problems")
+ .criterion("other", "Everything else")
+ .register();
+
+ from("direct:tickets")
+ .setProperty("department").language("semantic", "ref:department")
+ .to("direct:dispatch");
+}
+----
+
+Call `.end().question("anotherName")` to add another question to the group, then `.register()`
+once to validate and install the entire group atomically. Register before creating expressions
+that refer to these names. When adding builders to a running context, add the builder declaring
+shared questions before builders whose routes reference them.
+
+Use `.type("boolean")` with optional `.threshold(0.8)`, `.uncertainty(0.1)` and
+`.uncertaintyPolicy("non-match")` for boolean questions. Use `.type("score")` and successive
+`.level("description")` calls for ordered score levels. Applications can also register immutable
`SemanticQuestion` definitions using `SemanticQuestions.get(context).replace(source, questions)`.
+Each Java resource owns one group. Register all of its questions together; registering again
+replaces that resource's previous group. Reloading a Java resource without the helper removes
+its declarations. Embedded builders without a resource receive distinct generated source keys.
+The helper reserves `"java:" + resource.getLocation()` as the resource source key; passing that
+key and an empty map to `replace` removes the resource's questions.
+
+=== XML declarations
+
+The XML extension belongs to `camel-semantic`. Include `camel-semantic` and
+xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel discovers the extension
+automatically at startup: use ordinary `*.xml` files without registering a loader or changing
+the Camel core model. XML documents without semantic declarations are handled by the standard
+XML loader, including its bean and route configuration support. XML loaders registered by the
+application before route resource discovery retain precedence over automatic discovery.
+Loader discovery is refreshed when the context starts or routes are reloaded.
+Filenames may contain dots, such as `my.tickets.xml`. Routes retain their original resource
+locations and line numbers for debugging and error messages.
+
+Both layouts are supported. To keep declarations alongside routes, use `tickets.xml`:
+
+[source,xml]
+----
+
+
+
+ Which department should handle this message?
+
+
+
+
+
+
+
+
+ ref:department
+
+
+
+
+----
+
+To share declarations across route files, use a standalone `questions.xml`:
+
+[source,xml]
+----
+
+
+ Which department should handle this message?
+
+
+
+
+
+----
+
+Load this together with ordinary `*.xml`, Java or YAML route resources that use `ref:department`.
+For example, with Camel Main, set
+`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`.
+Declarations are registered before consuming routes are configured. Reload replaces the source's
+questions; removing the `semantic` block, using an empty block, or deleting the resource removes
+obsolete definitions. XML source keys are the resource locations.
+
+The combined format supports a `routes` root with one optional `semantic` block and ordinary
+`route` elements. The standalone format uses a `semantic` root. Namespace-free documents are
+supported, as are documents consistently using `http://camel.apache.org/schema/semantic`,
+`http://camel.apache.org/schema/xml-io`, or `http://camel.apache.org/schema/spring`.
+A `semantic` block can also declare `xmlns="http://camel.apache.org/schema/semantic"` inside a
+standard `routes` document; its question elements inherit that namespace. The extension detects
+the declaration block or semantic root namespace automatically.
+
+These extensions do not validate against Camel's standard core XSDs. Automatic discovery applies
+to Camel's route resource loader; it does not extend Spring's XML application-context parser
+or direct JAXB unmarshalling.
+
+For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are optional question
+attributes. For score questions, replace the named `criterion` elements with ordered `level`
+elements, such as `RoutineUrgentCritical`.
+
+The numeric `threshold` and `uncertainty` options accept property placeholders in all three
+DSLs. For example, Java accepts `.threshold("{{semantic.threshold:0.5}}")`, and XML accepts
+`threshold="{{semantic.threshold:0.5}}"`. Values are resolved and validated when declarations
+are registered.
+
+Java and XML declarations use the same context-wide registry, validation, defaults and adapters
+as YAML. They do not require `camel-yaml-dsl`. Their questions can also be selected together
+using `refs:name1,name2`, as described below.
+
+=== Exporting routes
+
+Question declarations live outside Camel's core route model. Generic model exports and runtime
+route dumps such as `camel.main.dumpRoutes=yaml` contain only routes; keep declarations separately
+and load them before reloading an exported route.
+
+The MCP route conversion tool rejects Java or YAML inputs containing semantic declarations
+because a generic route export would lose those declarations. Convert the routes separately.
+Extended XML documents must also be separated into declarations and ordinary routes before
+using the generic converter.
+
+=== Selected state
+
A question's optional `state` Simple expression overrides
`camel.language.semantic.default-state`, whose default is `$\{body}`. Selectors are compiled
before evaluation; selected strings, maps and lists are passed as data and are never evaluated recursively.
diff --git a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/DocExamplesXmlSchemaTest.java b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/DocExamplesXmlSchemaTest.java
index 62e6f60d0b98b..106e8f40065f5 100644
--- a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/DocExamplesXmlSchemaTest.java
+++ b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/DocExamplesXmlSchemaTest.java
@@ -108,7 +108,8 @@ class DocExamplesXmlSchemaTest {
* tag, a Spring bean next to route fragments, and the {@code } child of an expression, which the model
* and the xml-io parser support but no generated schema can express next to the expression text (JAXB
* {@code @XmlValue}), so Spring XML declares the namespaces as {@code xmlns:} attributes instead; and the endpoint
- * page's unescaped {@code &}, which is there to show the error it causes.
+ * page's unescaped {@code &}, which is there to show the error it causes. Semantic declaration documents use a
+ * component-owned loader outside the core schemas and are covered by camel-semantic's loader tests.
*/
private static final Map EXAMPLES_SKIPPED = Map.of(
"xmlsecurity-sign-component", "");
private static CamelCatalog catalog;
private static Schema springSchema;
diff --git a/components/camel-ai/camel-semantic/pom.xml b/components/camel-ai/camel-semantic/pom.xml
index 7c3e05e0eafe7..adb89ba78764c 100644
--- a/components/camel-ai/camel-semantic/pom.xml
+++ b/components/camel-ai/camel-semantic/pom.xml
@@ -37,6 +37,16 @@
org.apache.camel
camel-core-languages
+
+ org.apache.camel
+ camel-core-model
+ true
+
+
+ org.apache.camel
+ camel-xml-io
+ true
+
org.apache.camel
camel-yaml-dsl-common
@@ -47,6 +57,11 @@
camel-test-junit6
test
+
+ org.apache.camel
+ camel-xml-io-dsl
+ test
+
org.apache.camel
camel-main
diff --git a/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/routes-loader/semantic-xml b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/routes-loader/semantic-xml
new file mode 100644
index 0000000000000..dfc2945293cc8
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/routes-loader/semantic-xml
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.semantic.SemanticXmlRoutesBuilderLoader
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 f8ec5e5b7e3d0..c654e13feb351 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
@@ -96,9 +96,144 @@ Reloading a resource replaces its complete set of questions, including removing
no longer present. Development-mode route reload also removes definitions from deleted or renamed files
before parsing replacements. This replacement does not make the surrounding route reload transactional.
Existing expressions resolve the current definition on their next evaluation.
-Loading declarations does not perform inference. Java applications can register immutable
+Loading declarations does not perform inference.
+
+=== Java declarations
+
+Use the fluent helper from `camel-semantic` inside an ordinary `RouteBuilder`:
+
+[source,java]
+----
+import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+
+@Override
+public void configure() {
+ semanticQuestions(this)
+ .question("department")
+ .type("choice")
+ .state("${header.myState}")
+ .instructions("Which department should handle this message?")
+ .criterion("billing", "Invoices, payments, and refunds")
+ .criterion("technical", "Bugs, outages, and technical problems")
+ .criterion("other", "Everything else")
+ .register();
+
+ from("direct:tickets")
+ .setProperty("department").language("semantic", "ref:department")
+ .to("direct:dispatch");
+}
+----
+
+Call `.end().question("anotherName")` to add another question to the group, then `.register()`
+once to validate and install the entire group atomically. Register before creating expressions
+that refer to these names. When adding builders to a running context, add the builder declaring
+shared questions before builders whose routes reference them.
+
+Use `.type("boolean")` with optional `.threshold(0.8)`, `.uncertainty(0.1)` and
+`.uncertaintyPolicy("non-match")` for boolean questions. Use `.type("score")` and successive
+`.level("description")` calls for ordered score levels. Applications can also register immutable
`SemanticQuestion` definitions using `SemanticQuestions.get(context).replace(source, questions)`.
+Each Java resource owns one group. Register all of its questions together; registering again
+replaces that resource's previous group. Reloading a Java resource without the helper removes
+its declarations. Embedded builders without a resource receive distinct generated source keys.
+The helper reserves `"java:" + resource.getLocation()` as the resource source key; passing that
+key and an empty map to `replace` removes the resource's questions.
+
+=== XML declarations
+
+The XML extension belongs to `camel-semantic`. Include `camel-semantic` and
+xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel discovers the extension
+automatically at startup: use ordinary `*.xml` files without registering a loader or changing
+the Camel core model. XML documents without semantic declarations are handled by the standard
+XML loader, including its bean and route configuration support. XML loaders registered by the
+application before route resource discovery retain precedence over automatic discovery.
+Loader discovery is refreshed when the context starts or routes are reloaded.
+Filenames may contain dots, such as `my.tickets.xml`. Routes retain their original resource
+locations and line numbers for debugging and error messages.
+
+Both layouts are supported. To keep declarations alongside routes, use `tickets.xml`:
+
+[source,xml]
+----
+
+
+
+ Which department should handle this message?
+
+
+
+
+
+
+
+
+ ref:department
+
+
+
+
+----
+
+To share declarations across route files, use a standalone `questions.xml`:
+
+[source,xml]
+----
+
+
+ Which department should handle this message?
+
+
+
+
+
+----
+
+Load this together with ordinary `*.xml`, Java or YAML route resources that use `ref:department`.
+For example, with Camel Main, set
+`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`.
+Declarations are registered before consuming routes are configured. Reload replaces the source's
+questions; removing the `semantic` block, using an empty block, or deleting the resource removes
+obsolete definitions. XML source keys are the resource locations.
+
+The combined format supports a `routes` root with one optional `semantic` block and ordinary
+`route` elements. The standalone format uses a `semantic` root. Namespace-free documents are
+supported, as are documents consistently using `http://camel.apache.org/schema/semantic`,
+`http://camel.apache.org/schema/xml-io`, or `http://camel.apache.org/schema/spring`.
+A `semantic` block can also declare `xmlns="http://camel.apache.org/schema/semantic"` inside a
+standard `routes` document; its question elements inherit that namespace. The extension detects
+the declaration block or semantic root namespace automatically.
+
+These extensions do not validate against Camel's standard core XSDs. Automatic discovery applies
+to Camel's route resource loader; it does not extend Spring's XML application-context parser
+or direct JAXB unmarshalling.
+
+For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are optional question
+attributes. For score questions, replace the named `criterion` elements with ordered `level`
+elements, such as `RoutineUrgentCritical`.
+
+The numeric `threshold` and `uncertainty` options accept property placeholders in all three
+DSLs. For example, Java accepts `.threshold("{{semantic.threshold:0.5}}")`, and XML accepts
+`threshold="{{semantic.threshold:0.5}}"`. Values are resolved and validated when declarations
+are registered.
+
+Java and XML declarations use the same context-wide registry, validation, defaults and adapters
+as YAML. They do not require `camel-yaml-dsl`. Their questions can also be selected together
+using `refs:name1,name2`, as described below.
+
+=== Exporting routes
+
+Question declarations live outside Camel's core route model. Generic model exports and runtime
+route dumps such as `camel.main.dumpRoutes=yaml` contain only routes; keep declarations separately
+and load them before reloading an exported route.
+
+The MCP route conversion tool rejects Java or YAML inputs containing semantic declarations
+because a generic route export would lose those declarations. Convert the routes separately.
+Extended XML documents must also be separated into declarations and ordinary routes before
+using the generic converter.
+
+=== Selected state
+
A question's optional `state` Simple expression overrides
`camel.language.semantic.default-state`, whose default is `$\{body}`. Selectors are compiled
before evaluation; selected strings, maps and lists are passed as data and are never evaluated recursively.
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java
new file mode 100644
index 0000000000000..584837ba56979
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java
@@ -0,0 +1,149 @@
+/*
+ * 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.camel.semantic;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.util.StringHelper;
+
+/** Fluent definition of a named semantic question. */
+public final class SemanticQuestionBuilder {
+ private final SemanticQuestionsBuilder parent;
+ private final Map criteria = new LinkedHashMap<>();
+ private final List levels = new ArrayList<>();
+ private String type;
+ private String instructions;
+ private String state;
+ private String threshold;
+ private String uncertainty;
+ private String uncertaintyPolicy;
+
+ SemanticQuestionBuilder(SemanticQuestionsBuilder parent) {
+ this.parent = parent;
+ }
+
+ /** The question type: boolean, choice or score. */
+ public SemanticQuestionBuilder type(String type) {
+ this.type = type;
+ return this;
+ }
+
+ /** The instructions sent to the provider. */
+ public SemanticQuestionBuilder instructions(String instructions) {
+ this.instructions = instructions;
+ return this;
+ }
+
+ /** A Simple expression selecting the state to evaluate; defaults to the message body. */
+ public SemanticQuestionBuilder state(String state) {
+ this.state = state;
+ return this;
+ }
+
+ /** Add a named choice, or a true/false criterion for a boolean question. */
+ public SemanticQuestionBuilder criterion(String name, String description) {
+ if (criteria.containsKey(name)) {
+ throw new IllegalArgumentException("Duplicate semantic criterion: " + name);
+ }
+ criteria.put(name, description);
+ return this;
+ }
+
+ /** Add an ordered score level. */
+ public SemanticQuestionBuilder level(String level) {
+ levels.add(level);
+ return this;
+ }
+
+ /** Boolean decision threshold; defaults to 0.5. */
+ public SemanticQuestionBuilder threshold(double threshold) {
+ return threshold(Double.toString(threshold));
+ }
+
+ /** Boolean decision threshold, optionally using property placeholders. */
+ public SemanticQuestionBuilder threshold(String threshold) {
+ this.threshold = threshold;
+ return this;
+ }
+
+ /** Boolean uncertainty band; defaults to zero. */
+ public SemanticQuestionBuilder uncertainty(double uncertainty) {
+ return uncertainty(Double.toString(uncertainty));
+ }
+
+ /** Boolean uncertainty band, optionally using property placeholders. */
+ public SemanticQuestionBuilder uncertainty(String uncertainty) {
+ this.uncertainty = uncertainty;
+ return this;
+ }
+
+ /** Boolean uncertainty policy: fail (default) or non-match. */
+ public SemanticQuestionBuilder uncertaintyPolicy(String uncertaintyPolicy) {
+ this.uncertaintyPolicy = uncertaintyPolicy;
+ return this;
+ }
+
+ /** Return to the group to add another question. */
+ public SemanticQuestionsBuilder end() {
+ return parent;
+ }
+
+ /** Validate and register the entire group. */
+ public void register() {
+ parent.register();
+ }
+
+ SemanticQuestion build(CamelContext context) {
+ if (type == null) {
+ throw new IllegalArgumentException("Question type is required");
+ }
+ SemanticQuestion.Type questionType = enumeration(type, SemanticQuestion.Type.class);
+ if (questionType != SemanticQuestion.Type.BOOLEAN
+ && (threshold != null || uncertainty != null || uncertaintyPolicy != null)) {
+ throw new IllegalArgumentException("Threshold and uncertainty policy require a boolean question");
+ }
+ return new SemanticQuestion(
+ questionType, instructions, state, criteria, levels,
+ threshold == null ? 0.5 : parseDouble(context, threshold, "threshold"),
+ uncertainty == null ? 0 : parseDouble(context, uncertainty, "uncertainty"),
+ uncertaintyPolicy == null
+ ? SemanticQuestion.UncertaintyPolicy.FAIL
+ : enumeration(uncertaintyPolicy, SemanticQuestion.UncertaintyPolicy.class));
+ }
+
+ private static double parseDouble(CamelContext context, String value, String field) {
+ try {
+ return Double.parseDouble(context.resolvePropertyPlaceholders(value));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(field + " must be a valid number: " + value, e);
+ }
+ }
+
+ private static > T enumeration(String value, Class type) {
+ String normalized = StringHelper.asEnumConstantValue(value);
+ for (T constant : type.getEnumConstants()) {
+ if (constant.name().equalsIgnoreCase(value) || constant.name().equalsIgnoreCase(normalized)) {
+ return constant;
+ }
+ }
+ throw new IllegalArgumentException("Invalid " + type.getSimpleName() + ": " + value);
+ }
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
index a326b3f766fb3..037e9b67df7bd 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
@@ -43,7 +43,11 @@ public static SemanticQuestions get(CamelContext context) {
}
}
- /** Replace all definitions from one source; an empty map removes obsolete declarations. */
+ /**
+ * Replace all definitions from one source; an empty map removes obsolete declarations. The Java fluent helper
+ * reserves {@code java:} followed by the resource location (or a generated key for embedded builders). XML and YAML
+ * declarations use the resource location as their source key.
+ */
public synchronized void replace(String source, Map definitions) {
Map replacement = new HashMap<>();
sources.forEach((location, entries) -> {
@@ -70,18 +74,33 @@ public synchronized void replace(String source, Map de
/** Track a route resource so deleted files can be discarded before development-mode reload. */
public synchronized void replace(Resource source, Map definitions) {
+ replace(source.getLocation(), source, definitions);
+ }
+
+ synchronized void replace(String location, Resource source, Map definitions) {
removeDeletedResources();
- replace(source.getLocation(), definitions);
- if (!definitions.isEmpty() && "file".equals(source.getScheme())) {
- resources.put(source.getLocation(), source);
+ replace(location, definitions);
+ if (source != null && !definitions.isEmpty() && "file".equals(source.getScheme())) {
+ resources.put(location, source);
}
}
synchronized void removeDeletedResources() {
- resources.values().stream().filter(resource -> !resource.exists()).map(Resource::getLocation).toList()
+ resources.entrySet().stream().filter(entry -> !entry.getValue().exists()).map(Map.Entry::getKey).toList()
.forEach(location -> replace(location, Map.of()));
}
+ synchronized void remove(String source) {
+ if (sources.containsKey(source)) {
+ replace(source, Map.of());
+ }
+ }
+
+ /** Whether any named questions have been registered. */
+ public boolean isEmpty() {
+ return questions.isEmpty();
+ }
+
public SemanticQuestion get(String name) {
SemanticQuestion question = questions.get(name);
if (question == null) {
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java
new file mode 100644
index 0000000000000..b2b122bd7bc0d
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java
@@ -0,0 +1,113 @@
+/*
+ * 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.camel.semantic;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.builder.RouteBuilderLifecycleStrategy;
+import org.apache.camel.spi.Resource;
+
+/** Fluent declarations for use inside an ordinary {@link RouteBuilder#configure()}. */
+public final class SemanticQuestionsBuilder {
+ private static final String LIFECYCLE = SemanticQuestionsBuilder.class.getName();
+ private final CamelContext context;
+ private final RouteBuilder builder;
+ private final Resource resource;
+ private final String source;
+ private final Map questions = new LinkedHashMap<>();
+
+ private SemanticQuestionsBuilder(RouteBuilder builder) {
+ this(builder.getContext(), builder.getResource(), builder.getResource() == null
+ ? "java:" + builder.getContext().getUuidGenerator().generateUuid()
+ : "java:" + builder.getResource().getLocation(),
+ builder);
+ }
+
+ SemanticQuestionsBuilder(CamelContext context, Resource resource, String source, RouteBuilder builder) {
+ this.context = context;
+ this.resource = resource;
+ this.source = source;
+ this.builder = builder;
+ }
+
+ /** Start one group of declarations, then call {@link #register()} before using its references. */
+ public static SemanticQuestionsBuilder semanticQuestions(RouteBuilder builder) {
+ return new SemanticQuestionsBuilder(builder);
+ }
+
+ /** Add a named question. Names must be unique across the context. */
+ public SemanticQuestionBuilder question(String name) {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Semantic question requires a nonblank name");
+ }
+ SemanticQuestionBuilder question = new SemanticQuestionBuilder(this);
+ if (questions.putIfAbsent(name, question) != null) {
+ throw new IllegalArgumentException("Duplicate semantic question: " + name);
+ }
+ return question;
+ }
+
+ /** Validate all declarations and atomically replace this source's questions. An empty group removes them. */
+ public void register() {
+ Map definitions = new LinkedHashMap<>();
+ questions.forEach((name, question) -> {
+ try {
+ definitions.put(name, question.build(context));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid semantic question '" + name + "': " + e.getMessage(), e);
+ }
+ });
+ SemanticQuestions registry = SemanticQuestions.get(context);
+ synchronized (registry) {
+ registry.replace(source, resource, definitions);
+ DeclarationsLifecycle lifecycle = context.getRegistry().lookupByNameAndType(LIFECYCLE, DeclarationsLifecycle.class);
+ if (lifecycle == null) {
+ lifecycle = new DeclarationsLifecycle(registry);
+ context.getRegistry().bind(LIFECYCLE, lifecycle);
+ }
+ if (builder != null) {
+ lifecycle.registered.add(builder);
+ }
+ }
+ }
+
+ // A resource may remove the helper entirely on reload. The existing builder lifecycle detects that case.
+ private static final class DeclarationsLifecycle implements RouteBuilderLifecycleStrategy {
+ private final SemanticQuestions questions;
+ private final Set registered = Collections.newSetFromMap(new WeakHashMap<>());
+
+ private DeclarationsLifecycle(SemanticQuestions questions) {
+ this.questions = questions;
+ }
+
+ @Override
+ public void afterConfigure(RouteBuilder builder) {
+ synchronized (questions) {
+ questions.removeDeletedResources();
+ if (!registered.remove(builder) && builder.getResource() != null) {
+ questions.remove("java:" + builder.getResource().getLocation());
+ }
+ }
+ }
+ }
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
index 95a4ba3d1342e..1b43741547126 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
@@ -17,17 +17,77 @@
package org.apache.camel.semantic;
import org.apache.camel.CamelContext;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.spi.ContextServicePlugin;
+import org.apache.camel.spi.LifecycleStrategy;
+import org.apache.camel.spi.RoutesBuilderLoader;
+import org.apache.camel.support.LifecycleStrategySupport;
+import org.apache.camel.support.service.ServiceHelper;
-/** Removes definitions from deleted files before the route watcher loads their replacements. */
+/** Installs optional XML declaration support and removes definitions from deleted files before route reload. */
public class SemanticReloadPlugin implements ContextServicePlugin {
+ private SemanticXmlLoader xmlLoader;
+ private LifecycleStrategy lifecycle;
+
@Override
public void load(CamelContext context) {
- // Questions are registered by the route loader or the application.
+ installXmlLoader(context);
+ lifecycle = new LifecycleStrategySupport() {
+ @Override
+ public void onContextInitializing(CamelContext camelContext) {
+ // Applications can replace the registry after the context's eager build phase.
+ installXmlLoader(camelContext);
+ if (xmlLoader != null) {
+ xmlLoader.resetLoaderDiscovery();
+ }
+ }
+ };
+ context.addLifecycleStrategy(lifecycle);
+ }
+
+ private void installXmlLoader(CamelContext context) {
+ // Keep the language usable without the optional XML/model dependencies, and preserve application overrides.
+ if (context.getClassResolver().resolveClass("org.apache.camel.xml.in.ModelParser") == null
+ || context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) != null
+ || context.getRegistry().findByType(RoutesBuilderLoader.class).stream()
+ .anyMatch(loader -> loader.isSupportedExtension("xml"))) {
+ return;
+ }
+ if (xmlLoader == null) {
+ xmlLoader = new SemanticXmlLoader();
+ xmlLoader.setCamelContext(context);
+ }
+ try {
+ ServiceHelper.startService(xmlLoader);
+ context.getRegistry().bind(SemanticXmlLoader.REGISTRY_KEY, xmlLoader);
+ } catch (Exception e) {
+ throw RuntimeCamelException.wrapRuntimeException(e);
+ }
+ }
+
+ @Override
+ public void unload(CamelContext context) {
+ context.getLifecycleStrategies().remove(lifecycle);
+ lifecycle = null;
+ if (xmlLoader != null) {
+ if (context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) == xmlLoader) {
+ context.getRegistry().unbind(SemanticXmlLoader.REGISTRY_KEY);
+ }
+ try {
+ ServiceHelper.stopAndShutdownService(xmlLoader);
+ } catch (Exception e) {
+ throw RuntimeCamelException.wrapRuntimeException(e);
+ } finally {
+ xmlLoader = null;
+ }
+ }
}
@Override
public void onReload(CamelContext context) {
+ if (xmlLoader != null) {
+ xmlLoader.resetLoaderDiscovery();
+ }
SemanticQuestions questions = context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class);
if (questions != null) {
questions.removeDeletedResources();
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
new file mode 100644
index 0000000000000..b3be7899bd4a8
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
@@ -0,0 +1,196 @@
+/*
+ * 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.camel.semantic;
+
+import java.io.InputStream;
+import java.util.List;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.builder.RouteBuilderLifecycleStrategy;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.spi.RoutesBuilderLoader;
+import org.apache.camel.support.CachedResource;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResolverHelper;
+import org.apache.camel.support.RoutesBuilderLoaderSupport;
+import org.apache.camel.support.service.ServiceHelper;
+
+/** Detects semantic declarations in ordinary XML route resources and delegates other XML to the configured loader. */
+final class SemanticXmlLoader extends RoutesBuilderLoaderSupport {
+ static final String REGISTRY_KEY = "semantic-xml-routes-loader";
+ private RoutesBuilderLoader delegate;
+ private final SemanticXmlRoutesBuilderLoader semantic = new SemanticXmlRoutesBuilderLoader();
+ private Registry loaderRegistry;
+ private List otherLoaders = List.of();
+
+ @Override
+ public String getSupportedExtension() {
+ return "xml";
+ }
+
+ @Override
+ public boolean isSupportedExtension(String extension) {
+ return ("xml".equals(extension) || extension.endsWith(".xml"))
+ && otherLoaders().stream().noneMatch(loader -> loader.isSupportedExtension(extension));
+ }
+
+ private synchronized List otherLoaders() {
+ Registry registry = getCamelContext().getRegistry();
+ if (loaderRegistry != registry) {
+ // Discover lazily: the context may build before application loaders are registered.
+ otherLoaders = registry.findByType(RoutesBuilderLoader.class).stream().filter(loader -> loader != this).toList();
+ loaderRegistry = registry;
+ }
+ return otherLoaders;
+ }
+
+ synchronized void resetLoaderDiscovery() {
+ loaderRegistry = null;
+ otherLoaders = List.of();
+ }
+
+ @Override
+ public void preParseRoute(Resource resource) throws Exception {
+ Resource snapshot = snapshot(resource);
+ if (hasDeclarations(snapshot)) {
+ semantic.setCamelContext(getCamelContext());
+ semantic.preParseRoute(snapshot);
+ } else {
+ delegate().preParseRoute(snapshot);
+ }
+ }
+
+ @Override
+ public RoutesBuilder loadRoutesBuilder(Resource resource) throws Exception {
+ Resource snapshot = snapshot(resource);
+ if (hasDeclarations(snapshot)) {
+ semantic.setCamelContext(getCamelContext());
+ return semantic.loadRoutesBuilder(snapshot);
+ }
+ RoutesBuilder answer = delegate().loadRoutesBuilder(snapshot);
+ if (answer instanceof RouteBuilder builder) {
+ // Declarations removed from a resource are discarded only after its replacement configures successfully.
+ builder.addLifecycleInterceptor(new RouteBuilderLifecycleStrategy() {
+ @Override
+ public void afterConfigure(RouteBuilder routeBuilder) {
+ SemanticQuestions questions
+ = getCamelContext().getCamelContextExtension().getContextPlugin(SemanticQuestions.class);
+ if (questions != null) {
+ questions.remove(resource.getLocation());
+ }
+ }
+ });
+ }
+ return answer;
+ }
+
+ private synchronized RoutesBuilderLoader delegate() throws Exception {
+ if (delegate == null) {
+ // Resolve the service directly: looking up "xml" through RoutesLoader would find this wrapper again.
+ var finder
+ = getCamelContext().getCamelContextExtension().getBootstrapFactoryFinder(RoutesBuilderLoader.FACTORY_PATH);
+ RoutesBuilderLoader loader = ResolverHelper
+ .resolveService(getCamelContext(), finder, "xml", RoutesBuilderLoader.class)
+ .orElseThrow(() -> new IllegalStateException("An XML routes loader such as camel-xml-io-dsl is required"));
+ loader.setCamelContext(getCamelContext());
+ PluginHelper.getRoutesLoader(getCamelContext()).initRoutesBuilderLoader(loader);
+ ServiceHelper.startService(loader);
+ delegate = loader;
+ }
+ return delegate;
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ ServiceHelper.stopAndShutdownServices(delegate, semantic);
+ delegate = null;
+ resetLoaderDiscovery();
+ super.doStop();
+ }
+
+ private static Resource snapshot(Resource resource) {
+ // Share bytes only within one call. Preserve the original scheme/existence for deletion tracking.
+ return new CachedResource(resource) {
+ @Override
+ public String getScheme() {
+ return resource.getScheme();
+ }
+ };
+ }
+
+ private static boolean hasDeclarations(Resource resource) throws Exception {
+ XMLInputFactory factory = XMLInputFactory.newFactory();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ try (InputStream stream = resource.getInputStream()) {
+ XMLStreamReader reader = factory.createXMLStreamReader(stream);
+ try {
+ int depth = 0;
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ depth++;
+ if (depth == 1) {
+ if ("semantic".equals(reader.getLocalName())
+ || SemanticXmlRoutesBuilderLoader.NAMESPACE.equals(reader.getNamespaceURI())) {
+ return true;
+ }
+ if (!"routes".equals(reader.getLocalName())) {
+ return false;
+ }
+ } else if (depth == 2) {
+ if ("semantic".equals(reader.getLocalName())) {
+ return true;
+ }
+ // Only direct children can declare questions. Consume nested route content without
+ // inspecting names or namespaces, then continue looking for declarations after routes.
+ skipSubtree(reader);
+ depth--;
+ }
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ if (--depth == 0) {
+ return false;
+ }
+ }
+ }
+ return false;
+ } finally {
+ reader.close();
+ }
+ }
+ }
+
+ private static void skipSubtree(XMLStreamReader reader) throws XMLStreamException {
+ int depth = 1;
+ while (depth != 0) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ depth++;
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ depth--;
+ }
+ }
+ }
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
new file mode 100644
index 0000000000000..fcbc0e9986b7b
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
@@ -0,0 +1,235 @@
+/*
+ * 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.camel.semantic;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.RoutesDefinition;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.spi.annotations.RoutesLoader;
+import org.apache.camel.support.RoutesBuilderLoaderSupport;
+import org.apache.camel.xml.in.ModelParser;
+import org.apache.camel.xml.io.XmlPullParserException;
+
+/** Parses standalone semantic declarations or declarations alongside XML routes. */
+@RoutesLoader("semantic.xml")
+public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport {
+ static final String NAMESPACE = "http://camel.apache.org/schema/semantic";
+ private static final Set NAMESPACES = Set.of("", NAMESPACE,
+ "http://camel.apache.org/schema/xml-io", "http://camel.apache.org/schema/spring");
+
+ @Override
+ public String getSupportedExtension() {
+ return "semantic.xml";
+ }
+
+ @Override
+ public void preParseRoute(Resource resource) throws Exception {
+ // Register before any consuming route (including another DSL/resource) is configured.
+ parse(resource);
+ }
+
+ @Override
+ public RoutesBuilder loadRoutesBuilder(Resource resource) throws Exception {
+ RoutesDefinition routes = parse(resource);
+ RouteBuilder builder = new RouteBuilder(getCamelContext()) {
+ @Override
+ public void configure() {
+ routes.getRoutes().forEach(route -> {
+ CamelContextAware.trySetCamelContext(route, getContext());
+ getRouteCollection().route(route);
+ });
+ }
+ };
+ builder.setResource(resource);
+ return builder;
+ }
+
+ private RoutesDefinition parse(Resource resource) throws Exception {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ Element root;
+ try (InputStream stream = resource.getInputStream()) {
+ root = factory.newDocumentBuilder().parse(stream).getDocumentElement();
+ }
+ String namespace = namespace(root);
+ if (!NAMESPACES.contains(namespace)) {
+ throw new IllegalArgumentException("Unsupported semantic XML namespace: " + namespace);
+ }
+ SemanticQuestionsBuilder questions
+ = new SemanticQuestionsBuilder(getCamelContext(), resource, resource.getLocation(), null);
+ RoutesDefinition routes = new RoutesDefinition();
+ if ("semantic".equals(root.getLocalName())) {
+ declarations(root, questions);
+ } else if ("routes".equals(root.getLocalName())) {
+ boolean found = false;
+ for (Element child : children(root)) {
+ if ("semantic".equals(child.getLocalName())) {
+ if (found) {
+ throw new IllegalArgumentException("Only one semantic declaration block is allowed");
+ }
+ found = true;
+ declarations(child, questions);
+ } else if (!"route".equals(child.getLocalName())) {
+ throw new IllegalArgumentException("Unexpected element in routes: " + child.getTagName());
+ }
+ }
+ try (InputStream stream = resource.getInputStream()) {
+ routes = new SemanticModelParser(resource, stream, namespace).parseRoutesDefinition()
+ .orElseThrow(() -> new IllegalArgumentException("Expected XML routes"));
+ }
+ } else {
+ throw new IllegalArgumentException("Expected semantic or routes root element");
+ }
+ // Parse the complete document and validate every question before publishing any definitions.
+ questions.register();
+ return routes;
+ }
+
+ private static final class SemanticModelParser extends ModelParser {
+ private SemanticModelParser(
+ Resource resource, InputStream stream, String namespace)
+ throws IOException,
+ XmlPullParserException {
+ super(stream, namespace);
+ this.resource = resource;
+ }
+
+ @Override
+ protected boolean handleUnexpectedElement(String namespace, String name) throws XmlPullParserException {
+ if ("semantic".equals(name) && parser.getDepth() == 2) {
+ // The DOM pass already validated this block. Keep the original route bytes and source positions.
+ try {
+ parser.skipSubTree();
+ } catch (IOException e) {
+ throw new XmlPullParserException("Cannot read semantic declaration", parser, e);
+ }
+ return true;
+ }
+ return super.handleUnexpectedElement(namespace, name);
+ }
+ }
+
+ private static void declarations(Element semantic, SemanticQuestionsBuilder questions) {
+ attributes(semantic, Set.of());
+ for (Element element : children(semantic)) {
+ if (!"question".equals(element.getLocalName())) {
+ throw new IllegalArgumentException("Unexpected semantic element: " + element.getTagName());
+ }
+ attributes(element, Set.of("name", "type", "state", "threshold", "uncertainty", "uncertaintyPolicy"));
+ SemanticQuestionBuilder question = questions.question(element.getAttribute("name"));
+ if (element.hasAttribute("type")) {
+ question.type(element.getAttribute("type"));
+ }
+ if (element.hasAttribute("state")) {
+ question.state(element.getAttribute("state"));
+ }
+ if (element.hasAttribute("threshold")) {
+ question.threshold(element.getAttribute("threshold"));
+ }
+ if (element.hasAttribute("uncertainty")) {
+ question.uncertainty(element.getAttribute("uncertainty"));
+ }
+ if (element.hasAttribute("uncertaintyPolicy")) {
+ question.uncertaintyPolicy(element.getAttribute("uncertaintyPolicy"));
+ }
+ boolean instructions = false;
+ for (Element child : children(element)) {
+ switch (child.getLocalName()) {
+ case "instructions" -> {
+ if (instructions) {
+ throw new IllegalArgumentException("Duplicate instructions for semantic question");
+ }
+ instructions = true;
+ attributes(child, Set.of());
+ question.instructions(text(child));
+ }
+ case "criterion" -> {
+ attributes(child, Set.of("key", "value"));
+ if (!children(child).isEmpty()) {
+ throw new IllegalArgumentException("Semantic criterion must not contain elements");
+ }
+ question.criterion(child.getAttribute("key"), child.getAttribute("value"));
+ }
+ case "level" -> {
+ attributes(child, Set.of());
+ question.level(text(child));
+ }
+ default -> throw new IllegalArgumentException("Unexpected question element: " + child.getTagName());
+ }
+ }
+ }
+ }
+
+ private static String text(Element element) {
+ for (Node child = element.getFirstChild(); child != null; child = child.getNextSibling()) {
+ if (child instanceof Element) {
+ throw new IllegalArgumentException("Unexpected element in " + element.getTagName());
+ }
+ }
+ return element.getTextContent();
+ }
+
+ private static List children(Element parent) {
+ List answer = new ArrayList<>();
+ for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) {
+ if (child instanceof Element element) {
+ boolean semanticBlock = "routes".equals(parent.getLocalName())
+ && "semantic".equals(element.getLocalName()) && NAMESPACE.equals(namespace(element));
+ if (!semanticBlock && !namespace(parent).equals(namespace(element))) {
+ throw new IllegalArgumentException("Unexpected namespace on " + element.getTagName());
+ }
+ answer.add(element);
+ } else if ((child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE)
+ && !child.getTextContent().isBlank()) {
+ throw new IllegalArgumentException("Unexpected text in " + parent.getTagName());
+ }
+ }
+ return answer;
+ }
+
+ private static void attributes(Element element, Set allowed) {
+ for (int i = 0; i < element.getAttributes().getLength(); i++) {
+ Node attribute = element.getAttributes().item(i);
+ if (!XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(attribute.getNamespaceURI())
+ && (attribute.getNamespaceURI() != null || !allowed.contains(attribute.getNodeName()))) {
+ throw new IllegalArgumentException("Unexpected attribute: " + attribute.getNodeName());
+ }
+ }
+ }
+
+ private static String namespace(Element element) {
+ return element.getNamespaceURI() == null ? "" : element.getNamespaceURI();
+ }
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
index b4f049f8aca5b..f2cd3ef542484 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
@@ -57,7 +57,7 @@ public ConstructNode resolve(String id) {
@Override
public Object construct(Node node) {
- read(node);
+ read(getDeserializationContext(node).getCamelContext(), node);
// Registration happens once for the entire resource, including declarations after routes.
return (CamelContextCustomizer) context -> {
};
@@ -76,7 +76,7 @@ public void preParse(YamlDeserializationContext dc, Node root) {
}
for (NodeTuple tuple : mapping.getValue()) {
if ("semantic".equals(asText(tuple.getKeyNode()))) {
- read(tuple.getValueNode()).forEach((name, question) -> {
+ read(dc.getCamelContext(), tuple.getValueNode()).forEach((name, question) -> {
if (definitions.putIfAbsent(name, question) != null) {
throw new YamlDeserializationException(
tuple.getValueNode(), "Duplicate semantic question: " + name);
@@ -98,7 +98,7 @@ public void preParse(YamlDeserializationContext dc, Node root) {
}
}
- private static Map read(Node node) {
+ private static Map read(CamelContext context, Node node) {
Map semantic = fields(node, "semantic declaration");
if (!semantic.keySet().equals(Set.of("question"))) {
throw new YamlDeserializationException(node, "Semantic declaration requires only question");
@@ -109,7 +109,7 @@ private static Map read(Node node) {
throw new YamlDeserializationException(definition, "Semantic question requires a nonblank name");
}
try {
- result.put(name, readQuestion(name, definition));
+ result.put(name, readQuestion(context, name, definition));
} catch (IllegalArgumentException e) {
throw new YamlDeserializationException(
definition, "Invalid semantic question '" + name + "': " + e.getMessage(), e);
@@ -118,7 +118,7 @@ private static Map read(Node node) {
return result;
}
- private static SemanticQuestion readQuestion(String name, Node definition) {
+ private static SemanticQuestion readQuestion(CamelContext context, String name, Node definition) {
Map values = fields(definition, "semantic question '" + name + "'");
values.forEach((field, value) -> {
if (!FIELDS.contains(field)) {
@@ -152,7 +152,8 @@ private static SemanticQuestion readQuestion(String name, Node definition) {
: SemanticQuestion.UncertaintyPolicy.FAIL;
return new SemanticQuestion(
type, asText(values.get("instructions")), asText(values.get("state")),
- criteria, levels, number(values, name, "threshold", 0.5), number(values, name, "uncertainty", 0), policy);
+ criteria, levels, number(context, values, name, "threshold", 0.5),
+ number(context, values, name, "uncertainty", 0), policy);
}
private static > T enumeration(Node node, String question, String field, Class type) {
@@ -164,14 +165,15 @@ private static > T enumeration(Node node, String question, Str
}
}
- private static double number(Map values, String question, String name, double fallback) {
+ private static double number(
+ CamelContext context, Map values, String question, String name, double fallback) {
if (!values.containsKey(name)) {
return fallback;
}
Node node = values.get(name);
String raw = asText(node);
try {
- return Double.parseDouble(raw);
+ return Double.parseDouble(context.resolvePropertyPlaceholders(raw));
} catch (NumberFormatException e) {
throw new YamlDeserializationException(
node,
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
new file mode 100644
index 0000000000000..9d56c50ff1d2f
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
@@ -0,0 +1,505 @@
+/*
+ * 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.camel.semantic;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.language.semantic.SemanticLanguage;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.RouteWatcherReloadStrategy;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+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 static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SemanticDeclarationDslTest {
+ @TempDir
+ Path directory;
+ private DefaultCamelContext context;
+ private final List
+
+ org.apache.camel
+ camel-semantic
+ test
+
+
+ org.apache.camel
+ camel-xml-io-dsl
+ test
+
io.quarkus
quarkus-junit
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 9e4b256f1b22c..d068f24032454 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
@@ -30,11 +30,14 @@
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
import org.apache.camel.impl.DefaultCamelContext;
+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.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;
@@ -96,8 +99,13 @@ public TransformResult camel_transform_route(
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;
@@ -151,6 +159,7 @@ private String transformYamlToXml(String yaml) throws Exception {
RoutesDefinition rd = new RoutesDefinition();
rd.setRoutes(routeDefs);
+ requireSeparateDeclarations(ctx);
StringWriter sw = new StringWriter();
new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd);
@@ -160,6 +169,20 @@ private String transformYamlToXml(String yaml) throws Exception {
}
}
+ private static void requireSeparateDeclarations(DefaultCamelContext context) throws ReflectiveOperationException {
+ // Semantic declarations are optional and live outside the model exported by this converter.
+ Class> type = context.getClassResolver().resolveClass("org.apache.camel.semantic.SemanticQuestions");
+ if (type == null) {
+ return;
+ }
+ Object questions = context.getCamelContextExtension().getContextPlugin(type);
+ if (questions != null && !(boolean) type.getMethod("isEmpty").invoke(questions)) {
+ throw new IllegalArgumentException(
+ "Semantic declarations cannot be exported by the generic route converter. "
+ + "Keep them in a separate declaration resource and convert only the routes.");
+ }
+ }
+
private String transformJavaToFormat(String java, String targetFormat) throws Exception {
DefaultCamelContext ctx = new DefaultCamelContext();
try {
@@ -176,9 +199,14 @@ private String transformJavaToFormat(String java, String targetFormat) throws Ex
"Could not parse Java route. Ensure it contains a valid route definition.");
}
+ // Java expression clauses are normally materialized when processors are created.
+ routeDefs.forEach(route -> ProcessorDefinitionHelper.filterTypeInOutputs(route.getOutputs(), ExpressionNode.class)
+ .forEach(ExpressionNode::preCreateProcessor));
+
if ("yaml".equals(targetFormat)) {
YamlModelWriter writer = new YamlModelWriter();
List roots = new ArrayList<>();
+ requireSeparateDeclarations(ctx);
for (RouteDefinition route : routeDefs) {
roots.add(writer.writeRouteDefinition(route));
}
@@ -186,6 +214,7 @@ private String transformJavaToFormat(String java, String targetFormat) throws Ex
} else {
RoutesDefinition rd = new RoutesDefinition();
rd.setRoutes(routeDefs);
+ requireSeparateDeclarations(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 385d19f1019d3..a5dc8ed53fb3b 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,9 +16,19 @@
*/
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.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 {
@@ -45,6 +55,109 @@ void transformXmlToYaml() {
assertThat(result.result).contains("log");
}
+ @Test
+ void genericXmlConversionRejectsComponentDeclarations() {
+ assertThatThrownBy(() -> createTools().camel_transform_route(
+ "", "xml", "yaml"))
+ .isInstanceOf(ToolCallException.class).hasMessageContaining("semantic");
+ }
+
+ @ParameterizedTest
+ @CsvSource({ "yaml,xml", "java,xml", "java,yaml" })
+ void genericConversionDoesNotSilentlyLoseSemanticDeclarations(String source, String target) {
+ assertThatThrownBy(() -> createTools().camel_transform_route(semanticRoute(source, "0.8"), source, target))
+ .isInstanceOf(ToolCallException.class).hasMessageContaining("Keep them in a separate declaration resource");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "xml", "yaml" })
+ void emptySemanticRegistryDoesNotPreventConversion(String target) {
+ String route = """
+ import org.apache.camel.builder.RouteBuilder;
+ import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+ public class EmptySemanticRoute extends RouteBuilder {
+ public void configure() {
+ semanticQuestions(this).register();
+ from("direct:input").log("Hello");
+ }
+ }
+ """;
+ var result = createTools().camel_transform_route(route, "java", target);
+
+ assertThat(result.supported).isTrue();
+ assertThat(result.result).contains("direct:input").doesNotContain("semantic");
+ }
+
+ @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 """
+ import org.apache.camel.builder.RouteBuilder;
+ import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+ public class SemanticRoute extends RouteBuilder {
+ public void configure() {
+ semanticQuestions(this).question("urgent").type("boolean").instructions("Urgent?")
+ .threshold("%s").uncertainty(0).uncertaintyPolicy("fail").register();
+ 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();
diff --git a/dsl/camel-kamelet-main/src/test/java/org/apache/camel/main/download/DependencyDownloaderPropertiesFunctionResolverTest.java b/dsl/camel-kamelet-main/src/test/java/org/apache/camel/main/download/DependencyDownloaderPropertiesFunctionResolverTest.java
index 3ec98bd9517e9..8aa48cd5a981b 100644
--- a/dsl/camel-kamelet-main/src/test/java/org/apache/camel/main/download/DependencyDownloaderPropertiesFunctionResolverTest.java
+++ b/dsl/camel-kamelet-main/src/test/java/org/apache/camel/main/download/DependencyDownloaderPropertiesFunctionResolverTest.java
@@ -122,6 +122,15 @@ private static DependencyDownloader recordingDownloader(SimpleCamelContext conte
new Class[] { DependencyDownloader.class },
(proxy, method, args) -> {
switch (method.getName()) {
+ case "hashCode" -> {
+ return System.identityHashCode(proxy);
+ }
+ case "equals" -> {
+ return proxy == args[0];
+ }
+ case "toString" -> {
+ return "RecordingDependencyDownloader";
+ }
case "downloadDependency" -> {
// every overload starts with (String groupId, String artifactId, String version, ...)
downloaded.add(args[0] + ":" + args[1]);
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java
index 7008fde6d70e8..646828db2cd4d 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java
@@ -23,6 +23,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
+import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.mock.MockEndpoint;
import org.apache.camel.dsl.yaml.common.exception.InvalidEnumException;
import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException;
@@ -43,6 +44,7 @@
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -82,6 +84,35 @@ private static String declarations(String state) {
""".formatted(state);
}
+ @Test
+ void javaDeclarationsCoexistWithUnchangedYamlDeclarations() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ semanticQuestions(this).question("javaDepartment").type("choice").state("${header.selected}")
+ .instructions("Which department?")
+ .criterion("billing", "Invoices and refunds").criterion("technical", "Bugs and outages").register();
+ }
+ });
+ loadRoutes(declarations("${header.selected}") + route());
+ context.start();
+ SemanticQuestion java = SemanticQuestions.get(context).get("javaDepartment");
+ SemanticQuestion yaml = SemanticQuestions.get(context).get("department");
+ assertThat(java).usingRecursiveComparison().isEqualTo(yaml);
+ assertThat(calls).hasValue(0);
+ try (var template = context.createProducerTemplate()) {
+ var exchange = template.request("direct:tickets", e -> {
+ e.getMessage().setBody("original");
+ e.getMessage().setHeader("selected", "invoice");
+ });
+ assertThat(exchange.getException()).isNull();
+ assertThat(context.resolveLanguage("semantic").createExpression("refs:javaDepartment,department")
+ .evaluate(exchange, Map.class)).containsEntry("javaDepartment", "billing")
+ .containsEntry("department", "billing");
+ }
+ assertThat(selected).isEqualTo("invoice");
+ }
+
@Test
void ordinaryResourceDoesNotCreateSemanticQuestionState() throws Exception {
loadRoutes("""
@@ -283,6 +314,21 @@ void schemaAcceptsBooleanAndScoreDefinitionsAndRejectsWrongCriteria() throws Exc
.hasStackTraceContaining("Node type map is invalid, expected array");
}
+ @Test
+ void numericPlaceholdersResolveBeforeValidation() throws Exception {
+ loadRoutesNoValidate("""
+ - semantic:
+ question:
+ urgent:
+ type: boolean
+ instructions: Urgent?
+ threshold: "{{threshold:0.8}}"
+ uncertainty: "{{uncertainty:0.1}}"
+ """);
+ assertThat(SemanticQuestions.get(context).get("urgent").getThreshold()).isEqualTo(0.8);
+ assertThat(SemanticQuestions.get(context).get("urgent").getUncertainty()).isEqualTo(0.1);
+ }
+
@ParameterizedTest
@ValueSource(strings = { "threshold", "uncertainty" })
void invalidNumericValuesIdentifyQuestionFieldAndLocation(String field) {