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 05f2482ab839b4f2596126eff40b81c3665485f8 Author: Luigi De Masi <[email protected]> AuthorDate: Thu Sep 24 12:49:29 2026 +0200 CAMEL-24977: Refine semantic YAML diagnostics and completion coverage Use the common YAML enum parser while preserving question and field context. Leave malformed top-level entries to the route loader without replacing existing semantic definitions. Restore the inheritance-processing guard; regenerated schemas remain unchanged. Pin the Bean EIP versus Bean Method language metadata distinction and exercise TUI key/value completion through real ancestor paths and encoded map keys. Document the internal completion context format and link semantic examples to the TypeSafe AI component, YAML DSL and relevant EIPs. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 11 +++--- .../src/main/docs/semantic-language.adoc | 11 +++--- .../yaml/SemanticDefinitionDeserializer.java | 16 +++++---- .../jbang/core/commands/tui/SourceEditAssist.java | 4 +-- .../dsl/jbang/core/commands/tui/SourceViewer.java | 2 +- .../core/commands/tui/SemanticCompletionTest.java | 21 +++++++++--- .../maven/dsl/yaml/GenerateYamlSchemaMojo.java | 4 ++- .../camel/dsl/yaml/SemanticQuestionTest.java | 39 ++++++++++++++++++++-- .../camel/dsl/yaml/YamlCompletionTreeTest.java | 12 +++++++ 9 files changed, 93 insertions(+), 27 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 3eddbfba958f..187ad719cdf8 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 @@ -16,8 +16,9 @@ inference runs. Provider errors propagate through normal Camel error handling. == Dependencies and providers -Add `org.apache.camel:camel-semantic` and a provider, such as `camel-typesafe-ai`, using the -same Camel version. The provider owns credentials, model selection, request timeout, +Add `org.apache.camel:camel-semantic` and a provider, such as +xref:ROOT:typesafe-ai-component.adoc[TypeSafe AI] (`camel-typesafe-ai`), using the same Camel version. +The provider owns credentials, model selection, request timeout, concurrency limits and transport resources. TypeSafe AI uses `camel.component.typesafe-ai.*` settings even when a route contains no TypeSafe AI endpoint. @@ -38,7 +39,7 @@ until generic-language starter configuration is available. == Named questions -The YAML DSL supports declarations alongside routes, including declarations after their use: +The xref:others:yaml-dsl.adoc[YAML DSL] supports declarations alongside routes, including declarations after their use: [source,yaml] ---- @@ -174,7 +175,7 @@ from("direct:tag") .otherwise().to("direct:technical"); ---- -Storing the category before Choice performs one semantic evaluation each time execution +Storing the category before xref:eips:choice-eip.adoc[Choice] performs one semantic evaluation each time execution reaches that Set Header or Set Property step. The branches compare the stored result without calling the provider again. Place that step inside a loop when the decision must be refreshed on each iteration. Nested decisions can use separate properties to retain their own results. @@ -183,7 +184,7 @@ the existing EIP model and work with the Java, XML and YAML DSLs. === Aggregation and destinations -For Java APIs accepting an `Expression`, use +For xref:eips:aggregate-eip.adoc[Aggregate] and other Java APIs accepting an `Expression`, use `new LanguageExpression("semantic", "ref:department")` from `org.apache.camel.model.language`. For example, group messages with that expression and a `GroupedBodyAggregationStrategy`, using `completionSize(10)` and `completionTimeout(5000)` to bound the group. Include a trusted 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 3eddbfba958f..187ad719cdf8 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 @@ -16,8 +16,9 @@ inference runs. Provider errors propagate through normal Camel error handling. == Dependencies and providers -Add `org.apache.camel:camel-semantic` and a provider, such as `camel-typesafe-ai`, using the -same Camel version. The provider owns credentials, model selection, request timeout, +Add `org.apache.camel:camel-semantic` and a provider, such as +xref:ROOT:typesafe-ai-component.adoc[TypeSafe AI] (`camel-typesafe-ai`), using the same Camel version. +The provider owns credentials, model selection, request timeout, concurrency limits and transport resources. TypeSafe AI uses `camel.component.typesafe-ai.*` settings even when a route contains no TypeSafe AI endpoint. @@ -38,7 +39,7 @@ until generic-language starter configuration is available. == Named questions -The YAML DSL supports declarations alongside routes, including declarations after their use: +The xref:others:yaml-dsl.adoc[YAML DSL] supports declarations alongside routes, including declarations after their use: [source,yaml] ---- @@ -174,7 +175,7 @@ from("direct:tag") .otherwise().to("direct:technical"); ---- -Storing the category before Choice performs one semantic evaluation each time execution +Storing the category before xref:eips:choice-eip.adoc[Choice] performs one semantic evaluation each time execution reaches that Set Header or Set Property step. The branches compare the stored result without calling the provider again. Place that step inside a loop when the decision must be refreshed on each iteration. Nested decisions can use separate properties to retain their own results. @@ -183,7 +184,7 @@ the existing EIP model and work with the Java, XML and YAML DSLs. === Aggregation and destinations -For Java APIs accepting an `Expression`, use +For xref:eips:aggregate-eip.adoc[Aggregate] and other Java APIs accepting an `Expression`, use `new LanguageExpression("semantic", "ref:department")` from `org.apache.camel.model.language`. For example, group messages with that expression and a `GroupedBodyAggregationStrategy`, using `completionSize(10)` and `completionTimeout(5000)` to bound the group. Include a trusted 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 a849031c2f2b..b4f049f8aca5 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 @@ -18,7 +18,6 @@ package org.apache.camel.semantic.yaml; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; @@ -26,6 +25,7 @@ import org.apache.camel.CamelContext; import org.apache.camel.dsl.yaml.common.YamlDeserializationContext; import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver; import org.apache.camel.dsl.yaml.common.YamlDeserializerSupport; +import org.apache.camel.dsl.yaml.common.exception.InvalidEnumException; import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException; import org.apache.camel.semantic.SemanticQuestion; import org.apache.camel.semantic.SemanticQuestions; @@ -34,6 +34,7 @@ import org.apache.camel.spi.annotations.YamlIn; import org.apache.camel.spi.annotations.YamlProperty; import org.apache.camel.spi.annotations.YamlType; import org.snakeyaml.engine.v2.api.ConstructNode; +import org.snakeyaml.engine.v2.nodes.MappingNode; import org.snakeyaml.engine.v2.nodes.Node; import org.snakeyaml.engine.v2.nodes.NodeTuple; import org.snakeyaml.engine.v2.nodes.SequenceNode; @@ -69,7 +70,11 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } Map<String, SemanticQuestion> definitions = new LinkedHashMap<>(); for (Node node : sequence.getValue()) { - for (NodeTuple tuple : asMappingNode(node).getValue()) { + if (!(node instanceof MappingNode mapping)) { + // Leave malformed entries to the route loader without replacing the resource's questions. + return; + } + for (NodeTuple tuple : mapping.getValue()) { if ("semantic".equals(asText(tuple.getKeyNode()))) { read(tuple.getValueNode()).forEach((name, question) -> { if (definitions.putIfAbsent(name, question) != null) { @@ -151,12 +156,11 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } private static <T extends Enum<T>> T enumeration(Node node, String question, String field, Class<T> type) { - String raw = asText(node); try { - return Enum.valueOf(type, raw.replace('-', '_').toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException e) { + return asEnum(node, type); + } catch (InvalidEnumException e) { throw new YamlDeserializationException( - node, "Invalid value for '" + field + "' in semantic question '" + question + "': " + raw, e); + node, "Invalid value for '" + field + "' in semantic question '" + question + "': " + asText(node), e); } } 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 8b7085f8f401..ca1c8e260148 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 @@ -1063,7 +1063,7 @@ final class SourceEditAssist { } List<AutocompletePopup.CompletionItem> provideTreeCompletions(String contextAfterPrefix) { - // context format: "nodeName" or "nodeName:existingKey1,existingKey2,..." + // The node is a name or slash-separated path with URL-encoded keys, optionally followed by :existingKey1,... String[] parts = contextAfterPrefix.split(":", 2); String nodeName = parts[0]; @@ -1120,7 +1120,7 @@ final class SourceEditAssist { } List<AutocompletePopup.CompletionItem> provideTreeValueCompletions(String contextAfterPrefix) { - // context format: "nodeName:optionName" + // The node is a name or slash-separated path with URL-encoded keys, followed by :optionName. String[] parts = contextAfterPrefix.split(":", 2); if (parts.length < 2) { return List.of(); 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 61427ee054d2..1bdb50ecff74 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 @@ -1104,7 +1104,7 @@ class SourceViewer { return; } - // tree-driven completion — walk up to find parent key, use completion tree + // Tree completion uses the full YAML path so named map entries retain their schema context. if (autocompleteProvider != null) { String parentKey = yaml.findParentYamlKey(row); String parentPath = yaml.findParentYamlPath(row); 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 index 98934f8df4e4..8e860df56d4b 100644 --- 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 @@ -21,6 +21,8 @@ import java.util.concurrent.atomic.AtomicReference; import dev.tamboui.widgets.input.TextAreaState; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -34,12 +36,13 @@ class SemanticCompletionTest { new AtomicReference<List<InfraInfo>>(List.of()))); } - @Test - void completionFollowsNamedQuestionMapAndOffersVariantFields() { - TextAreaState state = new TextAreaState("- semantic:\n question:\n department:\n "); + @ParameterizedTest + @CsvSource({ "department,/semantic/question/department", "support/team,/semantic/question/support%2Fteam" }) + void completionFollowsNamedQuestionMapAndOffersVariantFields(String question, String expectedPath) { + TextAreaState state = new TextAreaState("- semantic:\n question:\n " + question + ":\n "); SourceEditorNavigation.positionCursor(state, 3, 8); String path = new YamlSourceContext(state).findParentYamlPath(3); - assertEquals("/semantic/question/department", path); + assertEquals(expectedPath, 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")), @@ -54,7 +57,15 @@ class SemanticCompletionTest { @Test void ordinaryRouteCompletionStillResolvesThroughItsAncestors() { + TextAreaState state = new TextAreaState( + "- route:\n from:\n uri: direct:start\n steps:\n - log:\n "); + SourceEditorNavigation.positionCursor(state, 5, 12); + String path = new YamlSourceContext(state).findParentYamlPath(5); + assertEquals("/route/from/steps/log", path); SourceEditAssist assist = assist(); - assertEquals(assist.provideTreeCompletions("log"), assist.provideTreeCompletions("/route/from/steps/log")); + assertEquals(assist.provideTreeCompletions("log"), assist.provideTreeCompletions(path)); + assertEquals(assist.provideTreeValueCompletions("log:loggingLevel"), + assist.provideTreeValueCompletions(path + ":loggingLevel")); + assertFalse(assist.provideTreeValueCompletions(path + ":loggingLevel").isEmpty()); } } diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java index d7a3e141537a..a8b7f029af42 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-maven-plugin/src/main/java/org/apache/camel/maven/dsl/yaml/GenerateYamlSchemaMojo.java @@ -168,7 +168,9 @@ public class GenerateYamlSchemaMojo extends GenerateYamlSupportMojo { kebabToCamelCase(step); kebabToCamelCase(root.withObject("/items")); - postProcessInheritance(inheritedDefinitions, inlineDefinitions); + if (!inheritedDefinitions.isEmpty()) { + postProcessInheritance(inheritedDefinitions, inlineDefinitions); + } try { ToolingSupport.mkparents(outputFile); 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 97031b07a3f9..639141d0dc06 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.component.mock.MockEndpoint; +import org.apache.camel.dsl.yaml.common.exception.InvalidEnumException; import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException; import org.apache.camel.dsl.yaml.support.YamlTestSupport; import org.apache.camel.language.semantic.SemanticLanguage; @@ -167,6 +168,24 @@ class SemanticQuestionTest extends YamlTestSupport { assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown"); } + @ParameterizedTest + @MethodSource("malformedTopLevelResources") + void malformedTopLevelEntriesUseNormalLoaderErrorsAndKeepQuestions(String yaml) throws Exception { + loadRoutes(ResourceHelper.fromString("questions.yaml", declarations("${body}"))); + context.start(); + SemanticQuestion original = SemanticQuestions.get(context).get("department"); + assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context) + .updateRoutes(ResourceHelper.fromString("questions.yaml", yaml))) + .isInstanceOf(YamlDeserializationException.class) + .hasMessageContaining("Unable to find constructor for node"); + assertThat(SemanticQuestions.get(context).get("department")).isSameAs(original); + } + + static Stream<String> malformedTopLevelResources() { + return Stream.of("- invalid\n", "- []\n") + .flatMap(entry -> Stream.of(entry, declarations("${header.updated}") + entry)); + } + @Test void watcherDropsDeletedQuestionOnlyResourcesBeforeLoadingRenamedFiles() throws Exception { Path original = directory.resolve("questions.yaml"); @@ -203,7 +222,7 @@ class SemanticQuestionTest extends YamlTestSupport { assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}").replace("instructions:", "typo:"))) .hasStackTraceContaining("Unknown property"); assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}").replace("type: choice", "type: unsupported"))) - .hasRootCauseInstanceOf(IllegalArgumentException.class).hasStackTraceContaining("UNSUPPORTED"); + .hasRootCauseInstanceOf(InvalidEnumException.class).hasStackTraceContaining("unsupported"); assertThatThrownBy(() -> loadRoutesNoValidate(declarations("${body}") .replace("instructions:", "uncertainty-policy: fail\n instructions:"))) .hasStackTraceContaining("Unknown property"); @@ -270,7 +289,7 @@ class SemanticQuestionTest extends YamlTestSupport { String source = yaml; assertThatThrownBy(() -> loadRoutesNoValidate(source)) .hasMessageContaining("route-0.yaml") - .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasRootCauseInstanceOf(InvalidEnumException.class) .cause().isInstanceOfSatisfying(YamlDeserializationException.class, error -> { assertThat(error).hasMessageContaining("Invalid value for '" + field + "' in semantic question 'spam'"); assertThat(error.getProblemMark()).hasValueSatisfying(mark -> { @@ -289,6 +308,22 @@ class SemanticQuestionTest extends YamlTestSupport { Arguments.of("uncertaintyPolicy", "null", 5)); } + @ParameterizedTest + @ValueSource(strings = { "non-match", "NON_MATCH", "nonMatch" }) + void enumParsingFollowsYamlDslConventions(String policy) throws Exception { + loadRoutesNoValidate(""" + - semantic: + question: + spam: + type: BoOlEaN + instructions: Is this spam? + uncertaintyPolicy: %s + """.formatted(policy)); + SemanticQuestion question = SemanticQuestions.get(context).get("spam"); + assertThat(question.getType()).isEqualTo(SemanticQuestion.Type.BOOLEAN); + assertThat(question.getUncertaintyPolicy()).isEqualTo(SemanticQuestion.UncertaintyPolicy.NON_MATCH); + } + @ParameterizedTest @MethodSource("invalidStructures") void invalidStructuresIdentifySourceAndOffendingNode(String yaml, String message, int line, int column) { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/YamlCompletionTreeTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/YamlCompletionTreeTest.java index 73872c14a910..36d10c2916ba 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/YamlCompletionTreeTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/YamlCompletionTreeTest.java @@ -129,6 +129,18 @@ class YamlCompletionTreeTest { assertThat(exprChild.get("kind").asText()).isEqualTo("value"); } + @Test + void beanEipMetadataIsNotOverwrittenByBeanLanguage() { + JsonNode bean = nodes.get("bean"); + assertThat(bean.get("title").asText()).isEqualTo("Bean"); + assertThat(bean.get("description").asText()).startsWith("Invokes a method on a Java bean"); + assertThat(bean.get("label").asText()).contains("eip").doesNotContain("language"); + assertThat(childNames(bean)).contains("ref", "method", "scope"); + JsonNode language = findChild(nodes.get("expression"), "method"); + assertThat(language.get("title").asText()).isEqualTo("Bean Method"); + assertThat(language.get("label").asText()).contains("language").doesNotContain("eip"); + } + @Test void marshalNodeHasDataFormats() { JsonNode marshal = nodes.get("marshal");
