This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 6a81a3d8f30e2b1435fa4f59ea9904f8498333a0 Author: Luigi De Masi <[email protected]> AuthorDate: Thu Sep 24 08:53:16 2026 +0200 CAMEL-24977: Fix semantic reload and address review feedback Remove definitions from deleted question resources before development reload. Initialize adapter transport during evaluation and publish evaluation fields with explicit visibility. Preserve Camel-owned probability decision policy. Fix AsciiDoc expressions, complete named-question editor metadata, remove the unsupported YAML alias, and add JAXB/YAML round-trip and error-path coverage. Regenerate documentation and completion catalog mirrors. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 11 ++-- .../camel/catalog/schemas/camelYamlDsl-model.json | 39 +++++++++++--- components/camel-ai/camel-semantic/pom.xml | 5 -- .../src/main/docs/semantic-language.adoc | 11 ++-- .../camel/language/semantic/SemanticLanguage.java | 16 ++++-- .../apache/camel/semantic/SemanticQuestions.java | 23 ++++++++- .../camel/semantic/SemanticReloadPlugin.java | 36 +++++++++++++ .../org.apache.camel.spi.ContextServicePlugin | 1 + .../camel/semantic/SemanticLanguageTest.java | 40 ++++++++++++++- .../camel/semantic/SemanticPropertiesTest.java | 5 +- .../apache/camel/semantic/SemanticResultTest.java | 53 +++++++++++++++++++ .../typesafeai/TypeSafeAiSemanticAdapter.java | 10 +++- .../typesafeai/TypeSafeAiSemanticAdapterTest.java | 34 ++++++++++++ .../camel/spring/ChoiceSelectorJaxbTest.java | 55 ++++++++++++++++++++ .../apache/camel/processor/ChoiceSelectorTest.java | 18 +++++++ .../jbang/core/commands/tui/SourceEditAssist.java | 33 +++++++++++- .../dsl/jbang/core/commands/tui/SourceViewer.java | 5 +- .../jbang/core/commands/tui/YamlSourceContext.java | 22 ++++++++ .../core/commands/tui/SemanticCompletionTest.java | 60 ++++++++++++++++++++++ .../SemanticDefinitionDeserializer.java | 8 +-- .../maven/dsl/yaml/GenerateYamlCompletionMojo.java | 53 +++++++++++++++++-- .../dsl/yaml/validator/SemanticSchemaTest.java | 11 ++-- dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml | 5 ++ .../resources/schema/camelYamlDsl-model.json | 39 +++++++++++--- .../camel/dsl/yaml/YamlRoutesBuilderLoader.java | 4 +- .../camel/dsl/yaml/ChoiceSelectorYamlDumpTest.java | 46 +++++++++++++++++ .../camel/dsl/yaml/SemanticQuestionTest.java | 48 +++++++++++++++-- 27 files changed, 631 insertions(+), 60 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc index 033656bf5df9..f55e366bb74d 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 @@ -23,7 +23,7 @@ concurrency limits and transport resources. TypeSafe AI uses One advertised adapter is selected automatically. No provider, or multiple distinct providers, is an error. Select an existing bean with -`camel.language.semantic.adapter=#bean:myAdapter`, or a class using its fully qualified name +`camel.language.semantic.adapter=#bean:myAdapter` (or `#myAdapter`), or a class using its fully qualified name (with an optional `#class:` prefix). Class selection uses Camel's class resolver and injector. Created adapters are registered as `camelSemanticAdapter` and managed by the context. A collision at that name is an error. Referenced beans retain their existing lifecycle owner. @@ -85,18 +85,23 @@ The YAML DSL supports declarations alongside routes, including declarations afte Names are context-wide. Duplicate declarations across resources and unknown references fail. Reloading a resource replaces its complete set of questions, including removing declarations -no longer present. This replacement does not make the surrounding route reload transactional. +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 `SemanticQuestion` definitions using `SemanticQuestions.get(context).replace(source, questions)`. A question's optional `state` Simple expression overrides -`camel.language.semantic.default-state`, whose default is `${body}`. Selectors are compiled +`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. A missing selected header fails instead of falling back to the body. Blank or invalid selectors fail. The original message is preserved. `CamelSemanticResult` contains the latest successful normalized result and is cleared before each evaluation, including one that fails. +State must be a string, map or list. For byte arrays or stream bodies, explicitly select +`$\{bodyAs(String)}`. Enable stream caching before evaluating a stream when later processors +also need to read it. Unsupported state types fail instead of being implicitly converted. + == Results and policy Only boolean questions can be predicates. A category string is never implicitly a boolean. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json index 9a2023971bc9..7f813a5f8ecf 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json @@ -863,13 +863,11 @@ } ] }, "semantic" : { - "title" : "Semantic", - "description" : "Evaluate named semantic questions through a provider adapter", - "label" : "language,ai", "isListItem" : true, "children" : [ { "name" : "question", - "type" : "object", + "type" : "map", + "ref" : "semanticDefinitionDeserializer$QuestionSchema", "required" : true } ] }, @@ -921,7 +919,32 @@ } ] }, "semanticDefinitionDeserializer$QuestionSchema" : { - "children" : [ ] + "children" : [ { + "name" : "criteria", + "type" : "any" + }, { + "name" : "instructions", + "type" : "string", + "required" : true + }, { + "name" : "state", + "type" : "string" + }, { + "name" : "threshold", + "type" : "number" + }, { + "name" : "type", + "type" : "enum", + "enum" : [ "boolean", "choice", "score" ], + "required" : true + }, { + "name" : "uncertainty", + "type" : "number" + }, { + "name" : "uncertaintyPolicy", + "type" : "enum", + "enum" : [ "fail", "non-match" ] + } ] }, "semanticDefinitionDeserializer$ScoreSchema" : { "children" : [ { @@ -1374,9 +1397,9 @@ } ] }, "bean" : { - "title" : "Bean Method", - "description" : "Calls a Java bean method", - "label" : "language,core,java", + "title" : "Bean", + "description" : "Invokes a method on a Java bean, with automatic parameter binding from the message body, headers, and exchange properties", + "label" : "eip,endpoint", "input" : true, "isListItem" : true, "children" : [ { diff --git a/components/camel-ai/camel-semantic/pom.xml b/components/camel-ai/camel-semantic/pom.xml index e04a54bed4bd..0efc5678a0a9 100644 --- a/components/camel-ai/camel-semantic/pom.xml +++ b/components/camel-ai/camel-semantic/pom.xml @@ -47,11 +47,6 @@ <artifactId>camel-main</artifactId> <scope>test</scope> </dependency> - <dependency> - <groupId>org.apache.camel</groupId> - <artifactId>camel-xml-io-dsl</artifactId> - <scope>test</scope> - </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> 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 033656bf5df9..f55e366bb74d 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 @@ -23,7 +23,7 @@ concurrency limits and transport resources. TypeSafe AI uses One advertised adapter is selected automatically. No provider, or multiple distinct providers, is an error. Select an existing bean with -`camel.language.semantic.adapter=#bean:myAdapter`, or a class using its fully qualified name +`camel.language.semantic.adapter=#bean:myAdapter` (or `#myAdapter`), or a class using its fully qualified name (with an optional `#class:` prefix). Class selection uses Camel's class resolver and injector. Created adapters are registered as `camelSemanticAdapter` and managed by the context. A collision at that name is an error. Referenced beans retain their existing lifecycle owner. @@ -85,18 +85,23 @@ The YAML DSL supports declarations alongside routes, including declarations afte Names are context-wide. Duplicate declarations across resources and unknown references fail. Reloading a resource replaces its complete set of questions, including removing declarations -no longer present. This replacement does not make the surrounding route reload transactional. +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 `SemanticQuestion` definitions using `SemanticQuestions.get(context).replace(source, questions)`. A question's optional `state` Simple expression overrides -`camel.language.semantic.default-state`, whose default is `${body}`. Selectors are compiled +`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. A missing selected header fails instead of falling back to the body. Blank or invalid selectors fail. The original message is preserved. `CamelSemanticResult` contains the latest successful normalized result and is cleared before each evaluation, including one that fails. +State must be a string, map or list. For byte arrays or stream bodies, explicitly select +`$\{bodyAs(String)}`. Enable stream caching before evaluating a stream when later processors +also need to read it. Unsupported state types fail instead of being implicitly converted. + == Results and policy Only boolean questions can be predicates. A category string is never implicitly a boolean. diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java index a940c6be673b..9eefad836867 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java @@ -113,8 +113,12 @@ public class SemanticLanguage extends LanguageSupport { } CamelContext context = getCamelContext(); String configured = adapter == null ? null : context.resolvePropertyPlaceholders(adapter); - if (configured != null && configured.startsWith("#bean:")) { - Object bean = context.getRegistry().lookupByName(configured.substring(6)); + if (configured != null && configured.startsWith("#") && !configured.startsWith("#class:")) { + String name = configured.startsWith("#bean:") ? configured.substring(6) : configured.substring(1); + if (name.contains(":")) { + throw new IllegalArgumentException("Semantic adapter reference must use #bean:name, #name or #class:FQCN"); + } + Object bean = context.getRegistry().lookupByName(name); if (!(bean instanceof SemanticAdapter found)) { throw new IllegalArgumentException("Semantic adapter bean is missing or does not implement SemanticAdapter"); } @@ -212,8 +216,8 @@ public class SemanticLanguage extends LanguageSupport { private final String name; private final boolean predicate; private volatile Compiled compiled; - private SemanticQuestions questions; - private SemanticAdapter provider; + private volatile SemanticQuestions questions; + private volatile SemanticAdapter provider; private Evaluation(String name, boolean predicate) { this.name = name; @@ -264,7 +268,9 @@ public class SemanticLanguage extends LanguageSupport { throw new IllegalArgumentException("Missing selected state for semantic question: " + name); } if (!(state instanceof String || state instanceof Map<?, ?> || state instanceof List<?>)) { - throw new IllegalArgumentException("Unsupported state type for semantic question: " + name); + throw new IllegalArgumentException( + "Unsupported state type for semantic question: " + name + + ". Select strings, maps or lists explicitly"); } SemanticResult result = provider.evaluate(question, state); if (result == null) { 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 b35a698eff31..5a67f7b63e15 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 @@ -20,10 +20,12 @@ import java.util.HashMap; import java.util.Map; import org.apache.camel.CamelContext; +import org.apache.camel.spi.Resource; /** Context-local named questions, replaced atomically per source when a route resource is reloaded. */ public final class SemanticQuestions { private final Map<String, Map<String, SemanticQuestion>> sources = new HashMap<>(); + private final Map<String, Resource> resources = new HashMap<>(); private volatile Map<String, SemanticQuestion> questions = Map.of(); public static SemanticQuestions get(CamelContext context) { @@ -53,10 +55,29 @@ public final class SemanticQuestions { throw new IllegalArgumentException("Duplicate semantic question: " + name); } }); - sources.put(source, Map.copyOf(definitions)); + if (definitions.isEmpty()) { + sources.remove(source); + } else { + sources.put(source, Map.copyOf(definitions)); + } + resources.remove(source); questions = Map.copyOf(replacement); } + /** Track a route resource so deleted files can be discarded before development-mode reload. */ + public synchronized void replace(Resource source, Map<String, SemanticQuestion> definitions) { + removeDeletedResources(); + replace(source.getLocation(), definitions); + if (!definitions.isEmpty() && "file".equals(source.getScheme())) { + resources.put(source.getLocation(), source); + } + } + + synchronized void removeDeletedResources() { + resources.values().stream().filter(resource -> !resource.exists()).map(Resource::getLocation).toList() + .forEach(location -> replace(location, Map.of())); + } + 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/SemanticReloadPlugin.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java new file mode 100644 index 000000000000..95a4ba3d1342 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java @@ -0,0 +1,36 @@ +/* + * 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 org.apache.camel.CamelContext; +import org.apache.camel.spi.ContextServicePlugin; + +/** Removes definitions from deleted files before the route watcher loads their replacements. */ +public class SemanticReloadPlugin implements ContextServicePlugin { + @Override + public void load(CamelContext context) { + // Questions are registered by the route loader or the application. + } + + @Override + public void onReload(CamelContext context) { + SemanticQuestions questions = context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class); + if (questions != null) { + questions.removeDeletedResources(); + } + } +} diff --git a/components/camel-ai/camel-semantic/src/main/resources/META-INF/services/org.apache.camel.spi.ContextServicePlugin b/components/camel-ai/camel-semantic/src/main/resources/META-INF/services/org.apache.camel.spi.ContextServicePlugin new file mode 100644 index 000000000000..6279fb409835 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/main/resources/META-INF/services/org.apache.camel.spi.ContextServicePlugin @@ -0,0 +1 @@ +org.apache.camel.semantic.SemanticReloadPlugin diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java index 6dcc4379eb4d..98e1723f5f02 100644 --- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java @@ -17,6 +17,7 @@ package org.apache.camel.semantic; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; @@ -27,6 +28,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.Expression; import org.apache.camel.Predicate; +import org.apache.camel.RuntimeCamelException; import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.impl.engine.DefaultClassResolver; import org.apache.camel.language.semantic.SemanticLanguage; @@ -144,9 +146,14 @@ class SemanticLanguageTest { @Test void classAndBeanTypeErrorsAndRegistryCollisionDoNotFallBack() { language.setAdapter(String.class.getName()); - assertThatThrownBy(() -> language.createExpression("ref:q")).isInstanceOf(Exception.class); + assertThatThrownBy(() -> language.createExpression("ref:q")).isInstanceOf(RuntimeCamelException.class) + .hasCauseInstanceOf(ClassCastException.class); language.setAdapter("#bean:missing"); assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("missing"); + context.getRegistry().bind("wrong", "not an adapter"); + language.setAdapter("#bean:wrong"); + assertThatThrownBy(() -> language.createExpression("ref:q")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("does not implement SemanticAdapter"); language.setAdapter(CountingAdapter.class.getName()); context.getRegistry().bind(SemanticLanguage.ADAPTER_NAME, "occupied"); assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("already bound"); @@ -189,7 +196,36 @@ class SemanticLanguageTest { assertThatThrownBy(() -> language.createExpression("ref:unknown")).hasMessageContaining("Unknown"); questions( question(SemanticQuestion.Type.BOOLEAN, "${invalidFunction}", 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL)); - assertThatThrownBy(() -> language.createExpression("ref:q")).isInstanceOf(Exception.class); + assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("Unknown function: invalidFunction"); + } + + @Test + void shorthandBeanReferenceRetainsExistingInstance() { + CountingAdapter bean = new CountingAdapter(); + context.getRegistry().bind("custom", bean); + language.setAdapter("#custom"); + language.createExpression("ref:q"); + assertThat(CountingAdapter.constructed).hasValue(1); + assertThat(CountingAdapter.started).hasValue(0); + assertThat(context.getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME)).isNull(); + } + + @Test + void byteStateRequiresExplicitConversionAndPreservesBody() { + Expression expression = language.createExpression("ref:q"); + var exchange = new DefaultExchange(context); + byte[] body = "invoice".getBytes(StandardCharsets.UTF_8); + exchange.getMessage().setBody(body); + assertThatThrownBy(() -> expression.evaluate(exchange, Object.class)) + .hasCauseInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported state type"); + assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); + questions(question(SemanticQuestion.Type.BOOLEAN, "${bodyAs(String)}", 0.5, 0, + SemanticQuestion.UncertaintyPolicy.FAIL)); + assertThat(expression.evaluate(exchange, Boolean.class)).isTrue(); + CountingAdapter adapter + = context.getRegistry().lookupByNameAndType(SemanticLanguage.ADAPTER_NAME, CountingAdapter.class); + assertThat(adapter.state).isEqualTo("invoice"); + assertThat(exchange.getMessage().getBody()).isSameAs(body); } @Test diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java index 228ce9566b42..d8a829c16850 100644 --- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java @@ -31,13 +31,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class SemanticPropertiesTest { @ParameterizedTest - @ValueSource(strings = { "bean", "class", "plain" }) + @ValueSource(strings = { "bean", "shorthand", "class", "plain" }) void camelMainBindsAdapterAndDefaultState(String selection) throws Exception { Main main = new Main(); SemanticLanguageTest.LabelAdapter bean = new SemanticLanguageTest.LabelAdapter(); main.bind("classifier", bean); String adapter = switch (selection) { case "bean" -> "#bean:classifier"; + case "shorthand" -> "#classifier"; case "class" -> "#class:" + SemanticLanguageTest.LabelAdapter.class.getName(); default -> SemanticLanguageTest.LabelAdapter.class.getName(); }; @@ -53,7 +54,7 @@ class SemanticPropertiesTest { }); try { main.start(); - if (selection.equals("bean")) { + if (selection.equals("bean") || selection.equals("shorthand")) { assertThat(main.getCamelContext().getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME)).isNull(); } else { assertThat(main.getCamelContext().getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME)) diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticResultTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticResultTest.java new file mode 100644 index 000000000000..8d1536aae935 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticResultTest.java @@ -0,0 +1,53 @@ +/* + * 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.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SemanticResultTest { + @Test + void booleanOnlyProviderCannotSilentlyIgnoreProbabilityPolicy() { + SemanticResult result = new SemanticResult(true, null, null, null, null); + SemanticQuestion defaults = SemanticLanguageTest.question(SemanticQuestion.Type.BOOLEAN, null, 0.5, 0, + SemanticQuestion.UncertaintyPolicy.FAIL); + assertThat(result.decision(defaults)).isEqualTo(true); + SemanticQuestion threshold = SemanticLanguageTest.question(SemanticQuestion.Type.BOOLEAN, null, 0.7, 0, + SemanticQuestion.UncertaintyPolicy.FAIL); + assertThatThrownBy(() -> result.decision(threshold)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("decision policy"); + SemanticQuestion uncertain = SemanticLanguageTest.question(SemanticQuestion.Type.BOOLEAN, null, 0.5, 0.1, + SemanticQuestion.UncertaintyPolicy.NON_MATCH); + assertThatThrownBy(() -> result.decision(uncertain)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("decision policy"); + } + + @Test + void choiceProbabilitiesMustCoverExactlyTheDeclaredCriteria() { + SemanticQuestion question = SemanticLanguageTest.question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, + SemanticQuestion.UncertaintyPolicy.FAIL); + SemanticResult incomplete = new SemanticResult("billing", null, Map.of("billing", 0.8), null, null); + assertThatThrownBy(() -> incomplete.decision(question)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cover every criterion"); + SemanticResult complete = new SemanticResult("billing", null, Map.of("billing", 0.8, "technical", 0.2), null, null); + assertThat(complete.decision(question)).isEqualTo("billing"); + } +} diff --git a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java index 0fde44ed43f7..404d6dd6d355 100644 --- a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java +++ b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java @@ -47,17 +47,25 @@ public class TypeSafeAiSemanticAdapter implements SemanticAdapter, CamelContextA || question.getType() == SemanticQuestion.Type.SCORE && question.getLevels().size() > 10) { throw new IllegalArgumentException("TypeSafe AI supports at most 255 choice criteria or 10 score levels"); } + } + + private TypeSafeAiEndpoint endpoint() { if (endpoint == null) { synchronized (this) { if (endpoint == null) { + if (camelContext == null) { + throw new IllegalStateException("TypeSafe AI semantic adapter requires a CamelContext"); + } endpoint = camelContext.getEndpoint("typesafe-ai:semantic", TypeSafeAiEndpoint.class); } } } + return endpoint; } @Override public SemanticResult evaluate(SemanticQuestion question, Object state) throws Exception { + validate(question); Map<String, Object> definition = new HashMap<>(); definition.put("instructions", question.getInstructions()); String type = switch (question.getType()) { @@ -71,7 +79,7 @@ public class TypeSafeAiSemanticAdapter implements SemanticAdapter, CamelContextA } else if (!question.getCriteria().isEmpty()) { definition.put("criteria", question.getCriteria()); } - JsonObject response = endpoint.evaluate(Map.of("state", state, "questions", Map.of("question", definition))); + JsonObject response = endpoint().evaluate(Map.of("state", state, "questions", Map.of("question", definition))); JsonObject answer = response.getJsonObject("answers").getJsonObject("question"); Map<String, Double> probabilities = new HashMap<>(); if (answer.get("probabilities") instanceof Map<?, ?> values) { diff --git a/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java b/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java index d4dd2a2c599f..ac90da5eb6e9 100644 --- a/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java +++ b/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java @@ -18,6 +18,8 @@ package org.apache.camel.component.typesafeai; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.apache.camel.Expression; import org.apache.camel.language.semantic.SemanticLanguage; @@ -31,6 +33,38 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class TypeSafeAiSemanticAdapterTest extends TypeSafeAiTestSupport { + @Test + void directEvaluationInitializesTransportWithoutPriorValidation() throws Exception { + respond = request -> result(Map.of("question", Map.of("type", "noul", "noul", 0.9))); + TypeSafeAiSemanticAdapter adapter = new TypeSafeAiSemanticAdapter(); + adapter.setCamelContext(context); + SemanticQuestion question = new SemanticQuestion( + SemanticQuestion.Type.BOOLEAN, "Classify", null, + Map.of(), List.of(), 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL); + assertThat(adapter.evaluate(question, "original").decision(question)).isEqualTo(true); + assertThat(requests).hasSize(1); + assertThat(authorization).containsExactly("Bearer test-key"); + } + + @Test + void rejectsCapabilityLimitsWithoutInitializingTransport() { + TypeSafeAiSemanticAdapter adapter = new TypeSafeAiSemanticAdapter(); + Map<String, String> criteria = IntStream.range(0, 256).boxed() + .collect(Collectors.toMap(Object::toString, i -> "Criterion " + i)); + SemanticQuestion choice = new SemanticQuestion( + SemanticQuestion.Type.CHOICE, "Classify", null, + criteria, List.of(), 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL); + SemanticQuestion score = new SemanticQuestion( + SemanticQuestion.Type.SCORE, "Score", null, + Map.of(), IntStream.range(0, 11).mapToObj(i -> "Level " + i).toList(), 0.5, 0, + SemanticQuestion.UncertaintyPolicy.FAIL); + assertThatThrownBy(() -> adapter.validate(choice)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("255 choice criteria"); + assertThatThrownBy(() -> adapter.evaluate(score, "original")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("10 score levels"); + assertThat(requests).isEmpty(); + } + private Expression expression(SemanticQuestion.Type type) { SemanticQuestion question = new SemanticQuestion( type, "Classify", null, diff --git a/components/camel-spring-parent/camel-spring-xml/src/test/java/org/apache/camel/spring/ChoiceSelectorJaxbTest.java b/components/camel-spring-parent/camel-spring-xml/src/test/java/org/apache/camel/spring/ChoiceSelectorJaxbTest.java new file mode 100644 index 000000000000..2a3d591b06d7 --- /dev/null +++ b/components/camel-spring-parent/camel-spring-xml/src/test/java/org/apache/camel/spring/ChoiceSelectorJaxbTest.java @@ -0,0 +1,55 @@ +/* + * 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.spring; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.model.ChoiceDefinition; +import org.apache.camel.model.RoutesDefinition; +import org.apache.camel.model.language.XPathExpression; +import org.apache.camel.xml.jaxb.JaxbHelper; +import org.apache.camel.xml.jaxb.JaxbModelToXMLDumper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ChoiceSelectorJaxbTest { + @Test + void selectorNamespacesAndLiteralBranchesSurviveJaxbRoundTrip() throws Exception { + try (var context = new DefaultCamelContext()) { + XPathExpression selector = new XPathExpression("/t:ticket/t:department/text()"); + selector.setNamespaces(Map.of("t", "urn:tickets")); + RoutesDefinition routes = new RoutesDefinition(); + routes.from("direct:start").routeId("selector").choice(selector) + .when("billing").to("mock:billing").otherwise().to("mock:other"); + String xml = new JaxbModelToXMLDumper().dumpModelAsXml(context, routes); + try (var input = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) { + RoutesDefinition restored = JaxbHelper.loadRoutesDefinition(context, input); + ChoiceDefinition choice = (ChoiceDefinition) restored.getRoutes().get(0).getOutputs().get(0); + XPathExpression xpath = (XPathExpression) choice.getSelector().getExpressionType(); + assertEquals("/t:ticket/t:department/text()", xpath.getExpression()); + assertEquals("urn:tickets", xpath.getNamespaces().get("t")); + assertEquals("billing", choice.getWhenClauses().get(0).getValue()); + assertNull(choice.getWhenClauses().get(0).getExpression()); + } + } + } +} diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/ChoiceSelectorTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/ChoiceSelectorTest.java index 3e567356fd57..9ac8ab89a238 100644 --- a/core/camel-core/src/test/java/org/apache/camel/processor/ChoiceSelectorTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/processor/ChoiceSelectorTest.java @@ -140,6 +140,24 @@ class ChoiceSelectorTest extends ContextTestSupport { invalid("without predicates", choice -> choice.when(exchange -> true).to("mock:mixed")); } + @Test + void emptyLiteralMatchesAndUnmatchedWithoutOtherwiseContinues() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("direct:start").choice(selector()).when("").to("mock:empty") + .end().to("mock:after"); + } + }); + context.start(); + getMockEndpoint("mock:empty").expectedBodiesReceived("empty"); + getMockEndpoint("mock:after").expectedBodiesReceived("empty", "unmatched", "null"); + template.sendBodyAndHeader("direct:start", "empty", "department", ""); + template.sendBodyAndHeader("direct:start", "unmatched", "department", "other"); + template.sendBody("direct:start", "null"); + assertMockEndpointsSatisfied(); + assertEquals(3, calls.get()); + } + private void invalid(String message, Consumer<ChoiceDefinition> configure) throws Exception { context.addRoutes(new RouteBuilder() { public void configure() { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java index 247d49a96e00..8b7085f8f401 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java @@ -17,6 +17,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.io.IOException; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -1028,7 +1029,37 @@ final class SourceEditAssist { if (nodes == null) { return null; } - return (JsonObject) nodes.get(nodeName); + if (!nodeName.startsWith("/")) { + return (JsonObject) nodes.get(nodeName); + } + JsonObject node = (JsonObject) nodes.get("root"); + String[] path = nodeName.substring(1).split("/"); + for (String part : path) { + if (node != null && "map".equals(node.get("type"))) { + node = (JsonObject) nodes.get(node.get("ref")); + continue; + } + JsonObject child = null; + if (node != null && node.get("children") instanceof JsonArray children) { + String key = URLDecoder.decode(part, StandardCharsets.UTF_8); + for (Object item : children) { + JsonObject candidate = (JsonObject) item; + if (key.equals(candidate.get("name"))) { + child = candidate; + break; + } + } + } + node = null; + if (child != null) { + node = "map".equals(child.get("type")) ? child : (JsonObject) nodes.get(child.get("ref")); + } + if (node == null) { + // Keep the existing lookup for positions whose ancestors are not represented by the tree. + return (JsonObject) nodes.get(URLDecoder.decode(path[path.length - 1], StandardCharsets.UTF_8)); + } + } + return node; } List<AutocompletePopup.CompletionItem> provideTreeCompletions(String contextAfterPrefix) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java index b489a9666b04..61427ee054d2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java @@ -1107,6 +1107,7 @@ class SourceViewer { // tree-driven completion — walk up to find parent key, use completion tree if (autocompleteProvider != null) { String parentKey = yaml.findParentYamlKey(row); + String parentPath = yaml.findParentYamlPath(row); int colonIdx = trimmed.indexOf(':'); if (colonIdx > 0) { @@ -1120,7 +1121,7 @@ class SourceViewer { valueText = valueText.substring(0, valueText.length() - 1); } if (autocompleteValueProvider != null) { - String context = "yaml-tree-value:" + parentKey + ":" + optionName; + String context = "yaml-tree-value:" + parentPath + ":" + optionName; List<AutocompletePopup.CompletionItem> values = autocompleteValueProvider.provide(context); if (values != null && !values.isEmpty()) { autocompletePopup = new AutocompletePopup(values, "", valueText, true); @@ -1130,7 +1131,7 @@ class SourceViewer { // key completion String filter = trimmed; java.util.Set<String> existing = yaml.collectExistingSiblingKeys(row); - String context = "yaml-tree:" + parentKey; + String context = "yaml-tree:" + parentPath; if (!existing.isEmpty()) { context += ":" + String.join(",", existing); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlSourceContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlSourceContext.java index 7d31de1840c6..148c915868c5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlSourceContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlSourceContext.java @@ -16,6 +16,8 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -481,6 +483,26 @@ final class YamlSourceContext { return "root"; } + String findParentYamlPath(int fromRow) { + String line = editState.getLine(fromRow); + int indent = line.isBlank() ? effectiveBlankIndent(fromRow) : countLeadingSpaces(line); + List<String> keys = new ArrayList<>(); + for (int i = fromRow - 1; i >= 0; i--) { + String parent = editState.getLine(i); + int parentIndent = countLeadingSpaces(parent); + if (!parent.isBlank() && parentIndent < indent) { + String key = extractEipName(parent.trim()); + if (key == null) { + break; + } + keys.add(URLEncoder.encode(dashToCamelCase(key), StandardCharsets.UTF_8)); + indent = parentIndent; + } + } + Collections.reverse(keys); + return keys.isEmpty() ? "root" : "/" + String.join("/", keys); + } + YamlEipContext findEnclosingEip(int fromRow) { String cursorLine = editState.getLine(fromRow); int cursorIndent = cursorLine.isBlank() ? effectiveBlankIndent(fromRow) : countLeadingSpaces(cursorLine); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticCompletionTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticCompletionTest.java new file mode 100644 index 000000000000..98934f8df4e4 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticCompletionTest.java @@ -0,0 +1,60 @@ +/* + * 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.dsl.jbang.core.commands.tui; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.widgets.input.TextAreaState; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SemanticCompletionTest { + private SourceEditAssist assist() { + return new SourceEditAssist( + new MonitorContext( + new AtomicReference<List<IntegrationInfo>>(List.of()), + new AtomicReference<List<InfraInfo>>(List.of()))); + } + + @Test + void completionFollowsNamedQuestionMapAndOffersVariantFields() { + TextAreaState state = new TextAreaState("- semantic:\n question:\n department:\n "); + SourceEditorNavigation.positionCursor(state, 3, 8); + String path = new YamlSourceContext(state).findParentYamlPath(3); + assertEquals("/semantic/question/department", path); + SourceEditAssist assist = assist(); + List<String> keys = assist.provideTreeCompletions(path).stream().map(AutocompletePopup.CompletionItem::key).toList(); + assertTrue(keys.containsAll(List.of("type", "instructions", "state", "criteria", "threshold", "uncertaintyPolicy")), + keys.toString()); + assertFalse(assist.provideTreeCompletions(path + ":instructions").stream() + .anyMatch(item -> item.key().equals("instructions"))); + List<String> types = assist.provideTreeValueCompletions(path + ":type").stream() + .map(AutocompletePopup.CompletionItem::key).toList(); + assertTrue(types.containsAll(List.of("boolean", "choice", "score")), types.toString()); + assertFalse(String.valueOf(assist.getTreeNode("semantic").get("label")).contains("language")); + } + + @Test + void ordinaryRouteCompletionStillResolvesThroughItsAncestors() { + SourceEditAssist assist = assist(); + assertEquals(assist.provideTreeCompletions("log"), assist.provideTreeCompletions("/route/from/steps/log")); + } +} diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/SemanticDefinitionDeserializer.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/SemanticDefinitionDeserializer.java index 7c760c8a44e7..64d93f41b03b 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/SemanticDefinitionDeserializer.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/SemanticDefinitionDeserializer.java @@ -71,8 +71,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } } } - String location = dc.getResource().getLocation(); - SemanticQuestions.get(context).replace(location, definitions); + SemanticQuestions.get(context).replace(dc.getResource(), definitions); } private static Map<String, SemanticQuestion> read(Node node) { @@ -83,11 +82,6 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl Map<String, SemanticQuestion> result = new LinkedHashMap<>(); fields(semantic.get("question")).forEach((name, definition) -> { Map<String, Node> values = fields(definition); - if (values.containsKey("uncertainty-policy")) { - if (values.putIfAbsent("uncertaintyPolicy", values.remove("uncertainty-policy")) != null) { - throw new IllegalArgumentException("Duplicate uncertainty policy: " + name); - } - } if (!FIELDS.containsAll(values.keySet())) { throw new IllegalArgumentException("Unknown property in semantic question: " + name); } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlCompletionMojo.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlCompletionMojo.java index 157f0badaf1a..3d6a03832da5 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlCompletionMojo.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlCompletionMojo.java @@ -376,6 +376,14 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { } ArrayNode children = buildChildrenFromDefinition(def, fqName); + for (JsonNode group : def.path("anyOf")) { + for (JsonNode variant : group.path("oneOf")) { + String variantName = resolveRefFq(variant); + if (variantName != null && definitions.has(variantName)) { + mergeChildren(children, buildChildrenFromDefinition(definitions.get(variantName), variantName)); + } + } + } node.set("children", children); nodes.set(nodeName, node); @@ -418,7 +426,8 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { } // check for language model - LanguageModel langModel = catalog.languageModel(nodeName); + LanguageModel langModel = fqName.startsWith("org.apache.camel.model.language.") + ? catalog.languageModel(nodeName) : null; if (langModel != null) { node.put("title", langModel.getTitle()); node.put("description", langModel.getDescription()); @@ -448,7 +457,7 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { // look up catalog model for enriched option metadata String nodeName = defToNodeName.get(fqName); - Map<String, BaseOptionModel> catalogOptions = loadCatalogOptions(nodeName); + Map<String, BaseOptionModel> catalogOptions = loadCatalogOptions(nodeName, fqName); Iterator<Map.Entry<String, JsonNode>> propFields = props.fields(); while (propFields.hasNext()) { @@ -480,6 +489,13 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { child.put("type", type); } } + if (prop.has("additionalProperties") && prop.get("additionalProperties").isObject()) { + String entryRef = resolveRef(prop.get("additionalProperties")); + if (entryRef != null) { + child.put("type", "map"); + child.put("ref", entryRef); + } + } putIfPresent(child, "description", prop, "description"); putIfPresent(child, "title", prop, "title"); @@ -546,7 +562,35 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { return children; } - private Map<String, BaseOptionModel> loadCatalogOptions(String nodeName) { + private void mergeChildren(ArrayNode children, ArrayNode variants) { + for (JsonNode variant : variants) { + ObjectNode existing = null; + for (JsonNode child : children) { + if (child.path("name").equals(variant.path("name"))) { + existing = (ObjectNode) child; + break; + } + } + if (existing == null) { + children.add(variant); + } else if (existing.has("enum") && variant.has("enum")) { + ArrayNode values = (ArrayNode) existing.get("enum"); + variant.get("enum").forEach(value -> { + boolean present = false; + for (JsonNode item : values) { + present |= item.equals(value); + } + if (!present) { + values.add(value); + } + }); + } else if (!existing.path("type").equals(variant.path("type"))) { + existing.put("type", "any"); + } + } + } + + private Map<String, BaseOptionModel> loadCatalogOptions(String nodeName, String fqName) { Map<String, BaseOptionModel> optMap = new LinkedHashMap<>(); if (nodeName == null) { return optMap; @@ -562,7 +606,8 @@ public class GenerateYamlCompletionMojo extends AbstractMojo { } // try language model - LanguageModel langModel = catalog.languageModel(nodeName); + LanguageModel langModel = fqName.startsWith("org.apache.camel.model.language.") + ? catalog.languageModel(nodeName) : null; if (langModel != null) { for (LanguageModel.LanguageOptionModel opt : langModel.getOptions()) { optMap.put(opt.getName(), opt); diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SemanticSchemaTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SemanticSchemaTest.java index 6c91cf6bb8f4..97a401de8433 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SemanticSchemaTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SemanticSchemaTest.java @@ -39,9 +39,14 @@ class SemanticSchemaTest { assertThat(validator.validate(choice)).isEmpty(); assertThat(validator.validate(choice.replace("type: choice", "type: boolean"))) .isEmpty(); // criterion key constraints are checked by the runtime - assertThat(validator.validate(choice.replace("type: choice", "type: score"))).isNotEmpty(); - assertThat(validator.validate(choice.replace("instructions:", "threshold: 0.5\n instructions:"))).isNotEmpty(); - assertThat(validator.validate(choice.replace("instructions:", "typo:"))).isNotEmpty(); + for (String invalid : new String[] { + choice.replace("type: choice", "type: score"), + choice.replace("instructions:", "threshold: 0.5\n instructions:"), + choice.replace("instructions:", "typo:"), + choice.replace("instructions:", "uncertainty-policy: fail\n instructions:") }) { + assertThat(validator.validate(invalid)).isNotEmpty().allSatisfy( + error -> assertThat(error.getInstanceLocation().toString()).startsWith("/0/semantic/question/topic")); + } assertThat(validator.validate(""" - semantic: question: diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml index f064d0132cf5..d65c92e7c259 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml @@ -44,6 +44,11 @@ </properties> <dependencies> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-yaml-io</artifactId> + <scope>test</scope> + </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-yaml-dsl-common</artifactId> diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json index 9a2023971bc9..7f813a5f8ecf 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json @@ -863,13 +863,11 @@ } ] }, "semantic" : { - "title" : "Semantic", - "description" : "Evaluate named semantic questions through a provider adapter", - "label" : "language,ai", "isListItem" : true, "children" : [ { "name" : "question", - "type" : "object", + "type" : "map", + "ref" : "semanticDefinitionDeserializer$QuestionSchema", "required" : true } ] }, @@ -921,7 +919,32 @@ } ] }, "semanticDefinitionDeserializer$QuestionSchema" : { - "children" : [ ] + "children" : [ { + "name" : "criteria", + "type" : "any" + }, { + "name" : "instructions", + "type" : "string", + "required" : true + }, { + "name" : "state", + "type" : "string" + }, { + "name" : "threshold", + "type" : "number" + }, { + "name" : "type", + "type" : "enum", + "enum" : [ "boolean", "choice", "score" ], + "required" : true + }, { + "name" : "uncertainty", + "type" : "number" + }, { + "name" : "uncertaintyPolicy", + "type" : "enum", + "enum" : [ "fail", "non-match" ] + } ] }, "semanticDefinitionDeserializer$ScoreSchema" : { "children" : [ { @@ -1374,9 +1397,9 @@ } ] }, "bean" : { - "title" : "Bean Method", - "description" : "Calls a Java bean method", - "label" : "language,core,java", + "title" : "Bean", + "description" : "Invokes a method on a Java bean, with automatic parameter binding from the message body, headers, and exchange properties", + "label" : "eip,endpoint", "input" : true, "isListItem" : true, "children" : [ { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java index ffbfe8cd4ecc..73c323456942 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java @@ -358,7 +358,9 @@ public class YamlRoutesBuilderLoader extends YamlRoutesBuilderLoaderSupport { } private Object preConfigureNode(Node root, YamlDeserializationContext ctx, boolean preParse) { - SemanticDefinitionDeserializer.configure(getCamelContext(), ctx, root); + if (preParse) { + SemanticDefinitionDeserializer.configure(getCamelContext(), ctx, root); + } // backwards compatible fixes Object target = root; diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ChoiceSelectorYamlDumpTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ChoiceSelectorYamlDumpTest.java new file mode 100644 index 000000000000..5404aa2320c6 --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ChoiceSelectorYamlDumpTest.java @@ -0,0 +1,46 @@ +/* + * 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.dsl.yaml; + +import java.util.Map; + +import org.apache.camel.dsl.yaml.support.YamlTestSupport; +import org.apache.camel.model.ChoiceDefinition; +import org.apache.camel.model.RouteDefinition; +import org.apache.camel.model.language.XPathExpression; +import org.apache.camel.yaml.LwModelToYAMLDumper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChoiceSelectorYamlDumpTest extends YamlTestSupport { + @Test + void selectorNamespacesAndLiteralBranchesSurviveYamlDump() throws Exception { + XPathExpression selector = new XPathExpression("/t:ticket/t:department/text()"); + selector.setNamespaces(Map.of("t", "urn:tickets")); + RouteDefinition route = new RouteDefinition().from("direct:start").routeId("selector"); + route.choice(selector).when("billing").to("mock:billing").otherwise().to("mock:other"); + String yaml = new LwModelToYAMLDumper().dumpModelAsYaml(context, route); + loadRoutes(yaml); + ChoiceDefinition restored = (ChoiceDefinition) context.getRouteDefinition("selector").getOutputs().get(0); + XPathExpression xpath = (XPathExpression) restored.getSelector().getExpressionType(); + assertThat(xpath.getExpression()).isEqualTo("/t:ticket/t:department/text()"); + assertThat(xpath.getNamespaces()).containsEntry("t", "urn:tickets"); + assertThat(restored.getWhenClauses().get(0).getValue()).isEqualTo("billing"); + assertThat(restored.getWhenClauses().get(0).getExpression()).isNull(); + } +} 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 e8c4c2c46c2e..4b71a86826ae 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 @@ -16,6 +16,9 @@ */ package org.apache.camel.dsl.yaml; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.component.mock.MockEndpoint; @@ -25,14 +28,19 @@ import org.apache.camel.semantic.SemanticAdapter; import org.apache.camel.semantic.SemanticQuestion; import org.apache.camel.semantic.SemanticQuestions; import org.apache.camel.semantic.SemanticResult; +import org.apache.camel.spi.Resource; import org.apache.camel.support.PluginHelper; import org.apache.camel.support.ResourceHelper; +import org.apache.camel.support.RouteWatcherReloadStrategy; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class SemanticQuestionTest extends YamlTestSupport { + @TempDir + Path directory; private final AtomicInteger calls = new AtomicInteger(); private Object selected; @@ -136,14 +144,46 @@ class SemanticQuestionTest extends YamlTestSupport { assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown"); } + @Test + void watcherDropsDeletedQuestionOnlyResourcesBeforeLoadingRenamedFiles() throws Exception { + Path original = directory.resolve("questions.yaml"); + Files.writeString(original, declarations("${body}")); + Resource source = ResourceHelper.resolveResource(context, original.toUri().toString()); + loadRoutes(source); + context.start(); + TestWatcher watcher = new TestWatcher(); + watcher.setCamelContext(context); + Path renamed = Files.move(original, directory.resolve("q.yaml")); + watcher.reload(source); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unknown"); + Resource replacement = ResourceHelper.resolveResource(context, renamed.toUri().toString()); + watcher.reload(replacement); + assertThat(watcher.getLastError()).isNull(); + assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${body}"); + Files.delete(renamed); + watcher.reload(replacement); + assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unknown"); + } + + private static class TestWatcher extends RouteWatcherReloadStrategy { + void reload(Resource resource) { + onRouteReload(List.of(resource), false); + } + } + @Test void duplicatesAndInvalidDefinitionsAreRejected() { assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}") + declarations("${body}"))) .hasStackTraceContaining("Duplicate semantic question"); assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}").replace("instructions:", "typo:"))) .hasStackTraceContaining("Unknown property"); - assertThatThrownBy(() -> loadRoutes(declarations("${body}").replace("type: choice", "type: unsupported"))) - .isInstanceOf(Exception.class); + assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}").replace("type: choice", "type: unsupported"))) + .hasRootCauseInstanceOf(IllegalArgumentException.class).hasStackTraceContaining("UNSUPPORTED"); + assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}") + .replace("instructions:", "uncertainty-policy: fail\n instructions:"))) + .hasStackTraceContaining("Unknown property"); } @Test @@ -163,7 +203,7 @@ class SemanticQuestionTest extends YamlTestSupport { criteria: [Routine, Urgent] """); assertThat(SemanticQuestions.get(context).get("urgency").getLevels()).containsExactly("Routine", "Urgent"); - assertThatThrownBy(() -> loadRoutes(declarations("${body}").replace("type: choice", "type: score"))) - .isInstanceOf(Exception.class); + assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}").replace("type: choice", "type: score"))) + .hasStackTraceContaining("Node type map is invalid, expected array"); } }
