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 states = new ArrayList<>(); + + @BeforeEach + void setup() throws Exception { + context = new DefaultCamelContext(); + context.getRegistry().bind("adapter", new SemanticAdapter() { + @Override + public void validate(SemanticQuestion question) { + } + + @Override + public SemanticResult evaluate(SemanticQuestion question, Object state) { + states.add(state); + return switch (question.getType()) { + case BOOLEAN -> new SemanticResult(null, 0.82, null, null, null); + case CHOICE -> new SemanticResult("billing", null, null, null, null); + case SCORE -> new SemanticResult(1.2, null, null, null, null); + }; + } + }); + ((SemanticLanguage) context.resolveLanguage("semantic")).setAdapter("adapter"); + context.start(); + } + + @AfterEach + void close() throws Exception { + context.close(); + } + + private static void questions(SemanticQuestionsBuilder semantic) { + semantic.question("urgent").type("boolean").instructions("Is this urgent?") + .state("${header.myState}").threshold(0.8).uncertainty(0.1).uncertaintyPolicy("non-match"); + semantic.question("department").type("choice").instructions("Which department?") + .state("${header.myState}").criterion("billing", "Invoices and refunds") + .criterion("technical", "Bugs and outages"); + semantic.question("priority").type("score").instructions("How urgent?") + .state("${header.myState}").level("Routine").level("Urgent").level("Critical"); + semantic.register(); + } + + private static String xmlQuestions() { + return """ + + + Is this urgent? + + + Which department? + + + + + How urgent? + RoutineUrgentCritical + + + """; + } + + private static String document(String root, String contents) { + return "<" + root + " xmlns=\"http://camel.apache.org/schema/spring\">" + contents + ""; + } + + @ParameterizedTest + @ValueSource(strings = { + "java", "routes", "standalone", "no-namespace", "component-namespace", "xml-io-namespace", "semantic-extension" }) + void componentDeclarationsEvaluateSingleQuestionsAndMixedBatches(String dsl) throws Exception { + if (dsl.equals("java")) { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:batch").setProperty("decision").language("semantic", "refs:urgent,department,priority") + .setHeader("department", simple("${exchangeProperty.decision[department]}")); + // Declarations are collected before route initialization, including those after the route. + questions(semanticQuestions(this)); + } + }); + } else { + String route = """ + + + + refs:urgent,department,priority + + ${exchangeProperty.decision[department]} + + """; + if (dsl.equals("standalone")) { + PluginHelper.getRoutesLoader(context).loadRoutes(List.of( + ResourceHelper.fromString("use.xml", document("routes", route)), + ResourceHelper.fromString("questions.xml", xmlQuestions()))); + } else { + String xml = document("routes", route + xmlQuestions()); + if (dsl.equals("no-namespace")) { + xml = xml.replace(" xmlns=\"http://camel.apache.org/schema/spring\"", ""); + } else if (dsl.equals("component-namespace")) { + xml = xml.replace("schema/spring", "schema/semantic"); + } else if (dsl.equals("xml-io-namespace")) { + xml = xml.replace("schema/spring", "schema/xml-io"); + } + load(dsl.equals("semantic-extension") ? "questions.semantic.xml" : "questions.xml", xml); + } + } + assertThat(states).isEmpty(); + SemanticQuestion urgent = SemanticQuestions.get(context).get("urgent"); + assertThat(urgent.getThreshold()).isEqualTo(0.8); + assertThat(urgent.getUncertaintyPolicy()).isEqualTo(SemanticQuestion.UncertaintyPolicy.NON_MATCH); + assertThat(SemanticQuestions.get(context).get("priority").getLevels()).containsExactly("Routine", "Urgent", "Critical"); + try (var template = context.createProducerTemplate()) { + Exchange exchange = template.request("direct:batch", e -> { + e.getMessage().setBody("original"); + e.getMessage().setHeader("myState", "invoice"); + }); + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getProperty("decision", Map.class)) + .containsEntry("urgent", false).containsEntry("department", "billing").containsEntry("priority", 1.2); + assertThat(exchange.getMessage().getHeader("department")).isEqualTo("billing"); + assertThat(exchange.getMessage().getBody()).isEqualTo("original"); + assertThat(exchange.getProperty(SemanticLanguage.RESULTS, Map.class)).hasSize(3); + for (String name : List.of("urgent", "department", "priority")) { + Object value + = context.resolveLanguage("semantic").createExpression("ref:" + name).evaluate(exchange, Object.class); + assertThat(value).isEqualTo(exchange.getProperty("decision", Map.class).get(name)); + } + assertThat(states).containsExactly("invoice", "invoice", "invoice", "invoice", "invoice", "invoice"); + } + } + + @ParameterizedTest + @MethodSource("invalidXml") + void invalidDeclarationsLeaveThePreviousDefinitionsIntact(String declaration, String message) throws Exception { + load("questions.xml", document("routes", xmlQuestions())); + SemanticQuestion previous = SemanticQuestions.get(context).get("department"); + assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context).updateRoutes( + ResourceHelper.fromString("questions.xml", + document("routes", "" + declaration + "")))) + .hasMessageContaining(message); + assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); + assertThat(states).isEmpty(); + PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml", + document("routes", xmlQuestions().replace("header.myState", "header.corrected")))); + assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.corrected}"); + } + + static Stream invalidXml() { + String question = "Valid?"; + return Stream.of( + Arguments.of(question + question, "Duplicate semantic question"), + Arguments.of(question.replace("name=\"q\"", "name=\" \""), "nonblank name"), + Arguments.of(question.replace("type=\"boolean\"", ""), "type is required"), + Arguments.of(question.replace("boolean", "unknown"), "Invalid semantic question"), + Arguments.of(question.replace("Valid?", " "), "instructions must not be blank"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"choice\" threshold=\"0.8\""), "require a boolean"), + Arguments.of( + question.replace("", + ""), + "Duplicate semantic criterion"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"score\""), "score needs ordered levels"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"boolean\" threshold=\"NaN\""), "within [0,1]"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"boolean\" threshold=\"abc\""), + "Invalid semantic question 'q': threshold must be a valid number: abc"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"boolean\" uncertainty=\"abc\""), + "Invalid semantic question 'q': uncertainty must be a valid number: abc"), + Arguments.of(question.replace("type=\"boolean\"", "type=\"boolean\" state=\" \""), + "state selector must not be blank"), + Arguments.of(question.replace("name=\"q\"", "unknown=\"q\""), "Unexpected attribute")); + } + + @Test + void javaResourceReloadReplacesAndRemovesDeclarations() throws Exception { + class Questions extends RouteBuilder { + private final String name; + + Questions(String name) { + this.name = name; + setResource(ResourceHelper.fromString("Questions.java", "")); + } + + @Override + public void configure() { + if (name != null) { + semanticQuestions(this).question(name).type("boolean").instructions("Valid?").register(); + } + } + } + context.addRoutes(new Questions("first")); + context.addRoutes(new Questions("second")); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("first")).hasMessageContaining("Unknown"); + assertThat(SemanticQuestions.get(context).get("second")).isNotNull(); + context.addRoutes(new Questions(null)); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("second")).hasMessageContaining("Unknown"); + assertThat(states).isEmpty(); + } + + @Test + void embeddedBuilderInstancesOwnSeparateDeclarations() throws Exception { + class Questions extends RouteBuilder { + private final String name; + + Questions(String name) { + this.name = name; + } + + @Override + public void configure() { + if (name != null) { + semanticQuestions(this).question(name).type("boolean").instructions("Valid?").register(); + } + } + } + context.addRoutes(new Questions("first")); + context.addRoutes(new Questions("second")); + context.addRoutes(new Questions(null)); + assertThat(SemanticQuestions.get(context).get(List.of("first", "second"))).hasSize(2); + assertThatThrownBy(() -> context.addRoutes(new Questions("first"))).hasMessageContaining("Duplicate semantic question"); + assertThat(states).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "non-match", "nonMatch", "NON_MATCH", "NonMatch" }) + void policySpellingsMatchYaml(String policy) throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + semanticQuestions(this).question("urgent").type("BOOLEAN").instructions("Urgent?").uncertaintyPolicy(policy) + .register(); + } + }); + assertThat(SemanticQuestions.get(context).get("urgent").getUncertaintyPolicy()) + .isEqualTo(SemanticQuestion.UncertaintyPolicy.NON_MATCH); + } + + @Test + void duplicateNamesAcrossJavaAndXmlAreRejected() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + questions(semanticQuestions(this)); + } + }); + assertThatThrownBy(() -> load("duplicate.xml", document("routes", xmlQuestions()))) + .hasMessageContaining("Duplicate semantic question"); + assertThat(states).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "java", "xml" }) + void numericPlaceholdersResolveBeforeValidation(String dsl) throws Exception { + if (dsl.equals("java")) { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + semanticQuestions(this).question("urgent").type("boolean").instructions("Urgent?") + .threshold("{{threshold:0.8}}").uncertainty("{{uncertainty:0.1}}").register(); + } + }); + } else { + load("questions.xml", document("routes", xmlQuestions() + .replace("threshold=\"0.8\"", "threshold=\"{{threshold:0.8}}\"") + .replace("uncertainty=\"0.1\"", "uncertainty=\"{{uncertainty:0.1}}\""))); + } + assertThat(SemanticQuestions.get(context).get("urgent").getThreshold()).isEqualTo(0.8); + assertThat(SemanticQuestions.get(context).get("urgent").getUncertainty()).isEqualTo(0.1); + assertThatThrownBy(() -> load("invalid.xml", document("routes", """ + + Urgent? + + """))) + .hasMessageContaining("Invalid semantic question 'invalid': threshold must be a valid number") + .hasRootCauseInstanceOf(NumberFormatException.class); + } + + @Test + void failedResourceBatchDoesNotReuseEarlierCachedDeclarationsOnRetry() throws Exception { + String second = "" + + "Valid?"; + var loader = PluginHelper.getRoutesLoader(context); + assertThatThrownBy(() -> loader.loadRoutes(List.of( + ResourceHelper.fromString("first.xml", document("routes", xmlQuestions())), + ResourceHelper.fromString("second.xml", document("routes", second.replace("boolean", "unknown")))))) + .hasMessageContaining("Invalid semantic question"); + loader.loadRoutes(List.of( + ResourceHelper.fromString("first.xml", + document("routes", xmlQuestions().replace("header.myState", "header.new"))), + ResourceHelper.fromString("second.xml", document("routes", second)))); + assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.new}"); + assertThat(SemanticQuestions.get(context).get("second")).isNotNull(); + assertThat(states).isEmpty(); + } + + @Test + void declarationsFromAnotherResourceAreInstalledBeforeReferences() throws Exception { + PluginHelper.getRoutesLoader(context).loadRoutes(List.of( + ResourceHelper.fromString("use.xml", document("routes", """ + + ref:department + + """)), + ResourceHelper.fromString("definitions.xml", document("routes", xmlQuestions())))); + assertThat(states).isEmpty(); + try (var template = context.createProducerTemplate()) { + assertThat(template.requestBodyAndHeader("direct:use", "original", "myState", "invoice")).isEqualTo("billing"); + } + } + + @Test + void unknownReferenceFailsAtRouteInitialization() { + assertThatThrownBy(() -> load("unknown.xml", document("routes", """ + + ref:missing + + """))).hasRootCauseMessage("Unknown semantic question: missing"); + assertThat(states).isEmpty(); + } + + @Test + void reloadingReplacesAndRemovesDeclarationsUsedByExistingExpressions() throws Exception { + load("questions.xml", document("routes", xmlQuestions())); + var expression = context.resolveLanguage("semantic").createExpression("ref:department"); + var exchange = new DefaultExchange(context); + exchange.getMessage().setHeader("myState", "old"); + assertThat(expression.evaluate(exchange, String.class)).isEqualTo("billing"); + PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml", + document("routes", xmlQuestions().replace("header.myState", "header.updated")))); + exchange.getMessage().setHeader("updated", "new"); + assertThat(expression.evaluate(exchange, String.class)).isEqualTo("billing"); + assertThat(states).containsExactly("old", "new"); + PluginHelper.getRoutesLoader(context) + .updateRoutes(ResourceHelper.fromString("questions.xml", document("routes", ""))); + assertThatThrownBy(() -> expression.evaluate(exchange, Object.class)).hasMessageContaining("Unknown semantic question"); + } + + @Test + void watcherRemovesDeletedAndRenamedDeclarationResources() throws Exception { + Path original = directory.resolve("questions.xml"); + Files.writeString(original, document("routes", xmlQuestions())); + Resource source = ResourceHelper.resolveResource(context, original.toUri().toString()); + PluginHelper.getRoutesLoader(context).loadRoutes(source); + TestWatcher watcher = new TestWatcher(); + watcher.setCamelContext(context); + Path renamed = Files.move(original, directory.resolve("renamed.xml")); + watcher.reload(source); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown"); + Resource replacement = ResourceHelper.resolveResource(context, renamed.toUri().toString()); + watcher.reload(replacement); + assertThat(watcher.getLastError()).isNull(); + assertThat(SemanticQuestions.get(context).get("department")).isNotNull(); + Files.delete(renamed); + watcher.reload(replacement); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown"); + } + + @ParameterizedTest + @ValueSource(strings = { "", "http://camel.apache.org/schema/semantic", "http://camel.apache.org/schema/xml-io" }) + void standaloneXmlSupportsOptionalNamespace(String namespace) throws Exception { + load("questions.xml", xmlQuestions().replace("", "")); + assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing"); + load("questions.xml", ""); + assertThat(SemanticQuestions.get(context).isEmpty()).isTrue(); + } + + @Test + void failedJavaReplacementKeepsPreviousDefinitions() throws Exception { + class Questions extends RouteBuilder { + private final String instructions; + + Questions(String instructions) { + this.instructions = instructions; + setResource(ResourceHelper.fromString("Questions.java", "")); + } + + @Override + public void configure() { + semanticQuestions(this).question("urgent").type("boolean").instructions(instructions).end() + .question("other").type("boolean").instructions("Other?").register(); + } + } + context.addRoutes(new Questions("Urgent?")); + var previous = SemanticQuestions.get(context).get("urgent"); + assertThatThrownBy(() -> context.addRoutes(new Questions(" "))).hasMessageContaining("instructions must not be blank"); + assertThat(SemanticQuestions.get(context).get("urgent")).isSameAs(previous); + assertThat(SemanticQuestions.get(context).get("other")).isNotNull(); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "", + "Hi", + "]>&external;", + "Hi" + }) + void malformedXmlDoesNotReplacePreviousDefinitions(String xml) throws Exception { + load("questions.xml", xmlQuestions()); + var previous = SemanticQuestions.get(context).get("department"); + assertThatThrownBy(() -> load("questions.xml", xml)).isInstanceOf(Exception.class); + assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); + } + + @ParameterizedTest + @ValueSource(strings = { "java", "xml" }) + void loadingPlainBuilderDiscardsDeletedDeclarationResources(String dsl) throws Exception { + Path file = directory.resolve(dsl.equals("java") ? "Questions.java" : "questions.xml"); + Files.writeString(file, xmlQuestions()); + Resource resource = ResourceHelper.resolveResource(context, file.toUri().toString()); + if (dsl.equals("java")) { + RouteBuilder builder = new RouteBuilder() { + @Override + public void configure() { + semanticQuestions(this).question("urgent").type("boolean").instructions("Urgent?").register(); + } + }; + builder.setResource(resource); + context.addRoutes(builder); + } else { + PluginHelper.getRoutesLoader(context).loadRoutes(resource); + } + assertThat(SemanticQuestions.get(context).isEmpty()).isFalse(); + Files.delete(file); + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + } + }); + assertThat(SemanticQuestions.get(context).isEmpty()).isTrue(); + } + + @Test + void semanticNamespaceCanBeUsedInsideStandardXmlRoutes() throws Exception { + load("questions.xml", document("routes", xmlQuestions().replace("", + ""))); + assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing"); + } + + @Test + void failedOrdinaryReplacementKeepsPreviousDeclarations() throws Exception { + load("questions.xml", xmlQuestions()); + var previous = SemanticQuestions.get(context).get("department"); + assertThatThrownBy(() -> load("questions.xml", "")) + .isInstanceOf(Exception.class); + assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); + } + + private void load(String location, String xml) throws Exception { + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(location, xml)); + } + + private static class TestWatcher extends RouteWatcherReloadStrategy { + void reload(Resource resource) { + onRouteReload(List.of(resource), false); + } + } +} diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java new file mode 100644 index 0000000000000..8a3a3f5fcb5c9 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java @@ -0,0 +1,253 @@ +/* + * 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.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.camel.dsl.xml.io.XmlRoutesBuilderLoader; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.impl.engine.DefaultClassResolver; +import org.apache.camel.impl.engine.DefaultRoutesLoader; +import org.apache.camel.main.Main; +import org.apache.camel.spi.RoutesBuilderLoader; +import org.apache.camel.spi.RoutesLoader; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResourceHelper; +import org.apache.camel.support.SimpleRegistry; +import org.apache.camel.support.service.ServiceSupport; +import org.apache.camel.xml.io.XmlPullParserLocationException; +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.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 SemanticXmlAutoDiscoveryTest { + private static final String LOCATED_ROUTES = """ + + + + + Urgent? + + + + + + ${body} != null + + + + + """; + + @TempDir + Path directory; + + @ParameterizedTest + @CsvSource({ + "false,routes.xml", "true,routes.xml", "false,routes.camel.xml", "true,routes.camel.xml", + "false,my.tickets.xml", "true,my.tickets.xml", "false,my.tickets.semantic.xml", "true,my.tickets.semantic.xml" }) + void mainLoadsOrdinaryXmlWithoutLoaderRegistration(boolean standalone, String filename) throws Exception { + String declarations = """ + + + Which department? + + + + """; + String route = """ + + ref:department + + """; + Path routes = directory.resolve(filename); + String files; + if (standalone) { + Path questions = directory.resolve("my.questions.xml"); + Files.writeString(questions, declarations); + Files.writeString(routes, "" + route + ""); + // The consumer is deliberately listed first. + files = routes.toUri() + "," + questions.toUri(); + } else { + Files.writeString(routes, "" + declarations + route + ""); + files = routes.toUri().toString(); + } + Main main = new Main(); + main.bind("classifier", new SemanticLanguageTest.LabelAdapter()); + main.addProperty("camel.language.semantic.adapter", "classifier"); + main.configure().setRoutesIncludePattern(files); + try { + main.start(); + try (var template = main.getCamelContext().createProducerTemplate()) { + assertThat(template.requestBody("direct:tickets", "invoice", String.class)).isEqualTo("billing"); + } + } finally { + main.stop(); + } + } + + @ParameterizedTest + @ValueSource(strings = { "tickets.xml", "tickets.semantic.xml", "my.tickets.xml", "my.tickets.semantic.xml" }) + void declarationsPreserveOriginalRouteSourceLocations(String filename) throws Exception { + try (var context = new DefaultCamelContext()) { + context.setSourceLocationEnabled(true); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(filename, LOCATED_ROUTES)); + var route = context.getRouteDefinitions().get(0); + assertThat(route.getLocation()).isEqualTo(filename); + assertThat(route.getLineNumber()).isEqualTo(8); + assertThat(route.getInput().getLocation()).isEqualTo(filename); + assertThat(route.getInput().getLineNumber()).isEqualTo(9); + var filter = route.getOutputs().get(0); + assertThat(filter.getLocation()).isEqualTo(filename); + assertThat(filter.getLineNumber()).isEqualTo(10); + assertThat(filter.getOutputs().get(0).getLocation()).isEqualTo(filename); + assertThat(filter.getOutputs().get(0).getLineNumber()).isEqualTo(12); + } + } + + @Test + void nestedSemanticElementIsRejectedWithOriginalSourceLocation() throws Exception { + try (var context = new DefaultCamelContext()) { + context.setSourceLocationEnabled(true); + String xml = LOCATED_ROUTES.replace("", ""); + assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context) + .loadRoutes(ResourceHelper.fromString("invalid.tickets.xml", xml))) + .isInstanceOfSatisfying(XmlPullParserLocationException.class, error -> { + assertThat(error.getResource().getLocation()).isEqualTo("invalid.tickets.xml"); + assertThat(error.getLineNumber()).isEqualTo(12); + assertThat(error).hasMessageContaining("invalid.tickets.xml, line 12") + .hasMessageContaining(""); + }); + assertThat(SemanticQuestions.get(context).isEmpty()).isTrue(); + } + } + + @Test + void applicationLoaderForDottedExtensionTakesPrecedence() throws Exception { + XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader() { + @Override + public boolean isSupportedExtension(String extension) { + return "tickets.xml".equals(extension); + } + }; + try (var context = new DefaultCamelContext()) { + context.getRegistry().bind("ticketsLoader", custom); + assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("tickets.xml")).isSameAs(custom); + } + } + + @Test + void ordinaryXmlRetainsBeansRouteConfigurationsAndDelegateLifecycle() throws Exception { + AtomicReference delegate = new AtomicReference<>(); + try (var context = new DefaultCamelContext()) { + context.getCamelContextExtension().addContextPlugin(RoutesLoader.class, new DefaultRoutesLoader(context) { + @Override + public void initRoutesBuilderLoader(RoutesBuilderLoader loader) { + delegate.set(loader); + } + }); + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("ordinary.xml", """ + + + + java.lang.IllegalArgumentException + true + handled + + + + + + + + """)); + assertThat(context.getRouteConfigurationDefinitions()).hasSize(1); + assertThat(context.getRegistry().lookupByName("counter")).isInstanceOf(AtomicInteger.class); + assertThat(context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class)).isNull(); + try (var template = context.createProducerTemplate()) { + assertThat(template.requestBody("direct:ordinary", "test", Integer.class)).isEqualTo(1); + } + assertThat(delegate.get()).isInstanceOf(XmlRoutesBuilderLoader.class); + assertThat(((ServiceSupport) delegate.get()).isStarted()).isTrue(); + } + assertThat(((ServiceSupport) delegate.get()).isStopped()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "routes-builder-loader-xml", "customXmlLoader" }) + void applicationRegisteredXmlLoaderTakesPrecedence(String name) throws Exception { + XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader(); + try (var context = new DefaultCamelContext()) { + context.getRegistry().bind(name, custom); + context.build(); + assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("xml")).isSameAs(custom); + assertThat(context.getRegistry().findByType(SemanticXmlLoader.class)) + .allMatch(loader -> !loader.isSupportedExtension("xml")); + } + } + + @Test + void contextWithoutOptionalXmlSupportDoesNotInstallWrapper() throws Exception { + try (var context = new DefaultCamelContext(false)) { + context.setClassResolver(new DefaultClassResolver(context) { + @Override + public Class resolveClass(String name) { + return "org.apache.camel.xml.in.ModelParser".equals(name) ? null : super.resolveClass(name); + } + }); + context.build(); + assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull(); + assertThat(context.resolveLanguage("semantic")).isNotNull(); + } + } + + @Test + void contextWithApplicationRegistryDiscoversXmlDeclarations() throws Exception { + try (var context = new DefaultCamelContext(new SimpleRegistry())) { + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml", """ + Urgent? + """)); + assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?"); + } + } + + @Test + void contextRestartReinstallsAutomaticLoader() throws Exception { + try (var context = new DefaultCamelContext()) { + for (int i = 0; i < 2; i++) { + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml", + """ + Urgent? + """)); + assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?"); + context.stop(); + assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull(); + } + } + } +} diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlLoaderTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlLoaderTest.java new file mode 100644 index 0000000000000..2829377894d75 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlLoaderTest.java @@ -0,0 +1,124 @@ +/* + * 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.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.dsl.xml.io.XmlRoutesBuilderLoader; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.spi.ContextServiceLoaderPluginResolver; +import org.apache.camel.spi.RoutesBuilderLoader; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResourceHelper; +import org.apache.camel.support.SimpleRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SemanticXmlLoaderTest { + @Test + void loaderDiscoveryIsReusedAndRefreshedOnReload() throws Exception { + AtomicInteger lookups = new AtomicInteger(); + SimpleRegistry registry = new SimpleRegistry() { + @Override + public Set findByType(Class type) { + if (type == RoutesBuilderLoader.class) { + lookups.incrementAndGet(); + } + return super.findByType(type); + } + }; + try (var context = new DefaultCamelContext(registry)) { + context.start(); + SemanticXmlLoader loader = registry.lookupByNameAndType(SemanticXmlLoader.REGISTRY_KEY, SemanticXmlLoader.class); + lookups.set(0); + for (String extension : new String[] { "xml", "tickets.xml", "semantic.xml", "xml" }) { + assertThat(loader.isSupportedExtension(extension)).isTrue(); + } + assertThat(loader.isSupportedExtension("yaml")).isFalse(); + assertThat(lookups).hasValue(1); + + registry.bind("customXml", new XmlRoutesBuilderLoader()); + var plugins = context.getCamelContextExtension().getContextPlugin(ContextServiceLoaderPluginResolver.class); + plugins.onReload(); + assertThat(loader.isSupportedExtension("xml")).isFalse(); + assertThat(loader.isSupportedExtension("xml")).isFalse(); + assertThat(lookups).hasValue(2); + + registry.unbind("customXml"); + plugins.onReload(); + assertThat(loader.isSupportedExtension("xml")).isTrue(); + assertThat(loader.isSupportedExtension("tickets.xml")).isTrue(); + assertThat(lookups).hasValue(3); + } + } + + @Test + void startupRefreshesDiscoveryAfterApplicationRegistersLoader() throws Exception { + try (var context = new DefaultCamelContext()) { + SemanticXmlLoader loader = context.getRegistry() + .lookupByNameAndType(SemanticXmlLoader.REGISTRY_KEY, SemanticXmlLoader.class); + assertThat(loader.isSupportedExtension("xml")).isTrue(); + XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader(); + context.getRegistry().bind("customXml", custom); + + context.start(); + + assertThat(loader.isSupportedExtension("xml")).isFalse(); + assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("xml")).isSameAs(custom); + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void nestedRoutesDoNotHideFollowingDeclarations(boolean declarations) throws Exception { + String routes = """ + + + + + ${body} != null + ]]> + + + + + %s + + """.formatted(declarations ? """ + + Urgent? + + """ : ""); + try (var context = new DefaultCamelContext()) { + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("nested.routes.xml", routes)); + context.start(); + assertThat(context.getRouteDefinitions()).hasSize(2); + if (declarations) { + assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?"); + } else { + assertThat(context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class)).isNull(); + } + try (var template = context.createProducerTemplate()) { + assertThat(template.requestBody("direct:nested", "hello", String.class)).isEqualTo(""); + } + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml index 1cff396141364..40786dbc0ab90 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -147,6 +147,16 @@ + + 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) {