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 dfee376d1a223bb509998bb7797e46d680365577 Author: Luigi De Masi <[email protected]> AuthorDate: Mon Sep 28 14:46:41 2026 +0200 CAMEL-25049: Preserve single-question behavior and test XML batching Retain bare predicate validation exceptions and accept state placeholders that resolve to an empty string, matching existing single-question behavior. Avoid repeated reference parsing during predicate creation and reuse the single-question selection map until its definition changes. Cover empty selectors and exception types with regression tests. Load an XML route through the generic language element and verify mixed batch decisions can be reused after one TypeSafe AI HTTP request. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 6 ++- .../src/main/docs/semantic-language.adoc | 6 ++- .../camel/language/semantic/SemanticLanguage.java | 51 +++++++++++++--------- .../apache/camel/semantic/SemanticBatchTest.java | 11 +++++ .../camel/semantic/SemanticLanguageTest.java | 39 +++++++++++++++-- .../typesafeai/TypeSafeAiSemanticAdapterTest.java | 42 +++++++++++++++++- 6 files changed, 127 insertions(+), 28 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 a077f0b645b4..a62eb7f92db4 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 @@ -102,8 +102,10 @@ Loading declarations does not perform inference. Java applications can register 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. -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 +A missing selected header fails instead of falling back to the body. A selector must be nonblank +before property placeholders are resolved. A placeholder that resolves to an empty string retains +the existing Simple behavior: it selects an empty string as state. Invalid Simple syntax fails. +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 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 a077f0b645b4..a62eb7f92db4 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 @@ -102,8 +102,10 @@ Loading declarations does not perform inference. Java applications can register 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. -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 +A missing selected header fails instead of falling back to the body. A selector must be nonblank +before property placeholders are resolved. A placeholder that resolves to an empty string retains +the existing Simple behavior: it selects an empty string as state. Invalid Simple syntax fails. +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 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 376677a50a06..f9280466d3c3 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 @@ -128,10 +128,11 @@ public class SemanticLanguage extends LanguageSupport { private Evaluation createEvaluation(String expression, boolean predicate) { List<String> names = references(expression); - if (predicate) { - validatePredicate(expression); + boolean batch = expression.startsWith("refs:"); + if (predicate && batch) { + throw new IllegalArgumentException("Semantic batch expressions cannot be predicates"); } - Evaluation evaluation = new Evaluation(names, expression.startsWith("refs:"), predicate); + Evaluation evaluation = new Evaluation(names, batch, predicate); if (getCamelContext() != null) { evaluation.init(getCamelContext()); } @@ -298,17 +299,14 @@ public class SemanticLanguage extends LanguageSupport { questions = SemanticQuestions.get(context); Map<String, SemanticQuestion> selected = questions.get(names); if (predicate) { - requireBoolean(selected); + requireBoolean(selected.get(names.get(0))); } provider = adapter(); compile(selected); } - private void requireBoolean(Map<String, SemanticQuestion> selected) { - if (batch) { - throw new IllegalArgumentException("Semantic batch expressions cannot be predicates"); - } - if (selected.get(names.get(0)).getType() != SemanticQuestion.Type.BOOLEAN) { + private void requireBoolean(SemanticQuestion question) { + if (question.getType() != SemanticQuestion.Type.BOOLEAN) { throw new IllegalArgumentException("Semantic predicate requires a boolean question: " + names.get(0)); } } @@ -316,20 +314,18 @@ public class SemanticLanguage extends LanguageSupport { private synchronized Compiled compile(Map<String, SemanticQuestion> selected) { if (compiled == null || !compiled.questions.equals(selected)) { if (predicate) { - requireBoolean(selected); + requireBoolean(selected.get(names.get(0))); } String selector = null; for (var entry : selected.entrySet()) { SemanticQuestion question = entry.getValue(); provider.validate(question); String effective = question.getState() != null ? question.getState() : defaultState; - if (effective != null) { - effective = getCamelContext().resolvePropertyPlaceholders(effective); - } if (effective == null || effective.isBlank()) { throw new IllegalArgumentException( "Semantic state selector must not be blank for question: " + entry.getKey()); } + effective = getCamelContext().resolvePropertyPlaceholders(effective); if (selector != null && !selector.equals(effective)) { throw new IllegalArgumentException( "Semantic batch questions must use the same effective state selector"); @@ -345,20 +341,35 @@ public class SemanticLanguage extends LanguageSupport { @Override public Object evaluate(Exchange exchange) { - return evaluate(exchange, predicate); + return evaluate(exchange, false); } private Object evaluate(Exchange exchange, boolean asPredicate) { exchange.removeProperty(RESULT); exchange.removeProperty(RESULTS); - try { - Map<String, SemanticQuestion> selected = questions.get(names); - if (asPredicate) { - requireBoolean(selected); + SemanticQuestion single = null; + if (asPredicate) { + if (batch) { + throw new IllegalArgumentException("Semantic batch expressions cannot be predicates"); } + single = questions.get(names.get(0)); + requireBoolean(single); + } + try { Compiled current = compiled; - if (current == null || !current.questions.equals(selected)) { - current = compile(selected); + if (batch) { + Map<String, SemanticQuestion> selected = questions.get(names); + if (current == null || !current.questions.equals(selected)) { + current = compile(selected); + } + } else { + String name = names.get(0); + if (single == null) { + single = questions.get(name); + } + if (current == null || current.questions.get(name) != single) { + current = compile(Map.of(name, single)); + } } Object state = current.state.evaluate(exchange, Object.class); if (state == null) { diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java index c434a4b58176..ff40c43e190b 100644 --- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java @@ -165,6 +165,17 @@ class SemanticBatchTest { assertThat(exchange.getProperty(SemanticLanguage.RESULTS)).isNull(); } + @Test + void batchUsesTheSameEmptyPlaceholderStateAsSingleQuestions() { + Properties properties = new Properties(); + properties.setProperty("selected", ""); + context.getPropertiesComponent().setInitialProperties(properties); + language.setDefaultState("{{selected}}"); + assertThat(language.createExpression("refs:urgent,department").evaluate(exchange, Map.class)) + .containsEntry("urgent", true).containsEntry("department", "billing"); + assertThat(adapter.states).containsExactly("", ""); + } + @Test void incompatibleSelectorsAndUnsupportedCapabilitiesFailBeforeInference() { SemanticQuestions.get(context).replace("other", Map.of("other", 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 4dab738ed1ce..c95d7f1488cd 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 @@ -29,6 +29,7 @@ import java.util.Enumeration; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Properties; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.Expression; @@ -301,6 +302,35 @@ class SemanticLanguageTest { assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("Unknown function: invalidFunction"); } + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void placeholderResolvingToEmptyStateRetainsSingleQuestionBehavior(boolean questionState) { + Properties properties = new Properties(); + properties.setProperty("selected", ""); + context.getPropertiesComponent().setInitialProperties(properties); + if (questionState) { + questions(question(SemanticQuestion.Type.BOOLEAN, "{{selected}}", 0.5, 0, + SemanticQuestion.UncertaintyPolicy.FAIL)); + } else { + language.setDefaultState("{{selected}}"); + } + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody("original"); + assertThat(language.createExpression("ref:q").evaluate(exchange, Boolean.class)).isTrue(); + CountingAdapter adapter + = context.getRegistry().lookupByNameAndType(SemanticLanguage.ADAPTER_NAME, CountingAdapter.class); + assertThat(adapter.state).isEqualTo(""); + assertThat(exchange.getMessage().getBody()).isEqualTo("original"); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + void literalBlankSelectorsStillFailBeforeEvaluation(String selector) { + language.setDefaultState(selector); + assertThatThrownBy(() -> language.createExpression("ref:q")) + .isExactlyInstanceOf(IllegalArgumentException.class).hasMessageContaining("must not be blank"); + } + @Test void registryNameTakesPrecedenceOverClassName() { CountingAdapter bean = new CountingAdapter(); @@ -344,7 +374,8 @@ class SemanticLanguageTest { questions(question(SemanticQuestion.Type.BOOLEAN, null, 0.5, 0.1, SemanticQuestion.UncertaintyPolicy.NON_MATCH)); assertThat(predicate.matches(exchange)).isFalse(); adapter.failure = new IllegalStateException("provider failed"); - assertThatThrownBy(() -> predicate.matches(exchange)).hasMessageContaining("provider failed"); + assertThatThrownBy(() -> predicate.matches(exchange)).isExactlyInstanceOf(RuntimeCamelException.class) + .hasCause(adapter.failure).hasMessageContaining("provider failed"); assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); } @@ -375,11 +406,13 @@ class SemanticLanguageTest { assertThat(predicate.matches(exchange)).isTrue(); assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNotNull(); questions(question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL)); - assertThatThrownBy(() -> predicate.matches(exchange)).hasMessageContaining("boolean"); + assertThatThrownBy(() -> predicate.matches(exchange)) + .isExactlyInstanceOf(IllegalArgumentException.class).hasMessageContaining("boolean"); assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); exchange.setProperty(SemanticLanguage.RESULT, "old"); SemanticQuestions.get(context).replace("test", Map.of()); - assertThatThrownBy(() -> predicate.matches(exchange)).hasMessageContaining("Unknown"); + assertThatThrownBy(() -> predicate.matches(exchange)) + .isExactlyInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unknown"); assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); } 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 3215add1d79b..49fd663b6bd1 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 @@ -21,12 +21,15 @@ import java.util.Map; import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.camel.Exchange; import org.apache.camel.Expression; import org.apache.camel.language.semantic.SemanticLanguage; import org.apache.camel.semantic.SemanticQuestion; import org.apache.camel.semantic.SemanticQuestions; import org.apache.camel.semantic.SemanticResult; import org.apache.camel.support.DefaultExchange; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResourceHelper; import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -112,6 +115,11 @@ class TypeSafeAiSemanticAdapterTest extends TypeSafeAiTestSupport { } private Expression batchExpression() { + batchQuestions(); + return context.resolveLanguage("semantic").createExpression("refs:refund,department,urgency"); + } + + private void batchQuestions() { SemanticQuestions.get(context).replace("test", Map.of( "refund", new SemanticQuestion( SemanticQuestion.Type.BOOLEAN, "Refund requested?", null, @@ -123,7 +131,39 @@ class TypeSafeAiSemanticAdapterTest extends TypeSafeAiTestSupport { "urgency", new SemanticQuestion( SemanticQuestion.Type.SCORE, "How urgent?", null, Map.of(), List.of("Routine", "Urgent", "Critical"), 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL))); - return context.resolveLanguage("semantic").createExpression("refs:refund,department,urgency"); + } + + @Test + void xmlBatchUsesGenericLanguageAndReusesResults() throws Exception { + batchQuestions(); + respond = request -> mixedResponse(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("batch.xml", """ + <routes xmlns="http://camel.apache.org/schema/spring"> + <route> + <from uri="direct:batch"/> + <setProperty name="decision"> + <language language="semantic">refs:refund,department,urgency</language> + </setProperty> + <setHeader name="department"> + <simple>${exchangeProperty.decision[department]}</simple> + </setHeader> + <setHeader name="urgency"> + <simple>${exchangeProperty.decision[urgency]}</simple> + </setHeader> + </route> + </routes> + """)); + assertThat(requests).isEmpty(); + Exchange exchange = template.request("direct:batch", e -> e.getMessage().setBody("refund requested")); + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getProperty("decision", Map.class)) + .containsEntry("refund", false).containsEntry("department", "billing").containsEntry("urgency", 1.2); + assertThat(exchange.getMessage().getHeader("department")).isEqualTo("billing"); + assertThat(exchange.getMessage().getHeader("urgency", Double.class)).isEqualTo(1.2); + assertThat(exchange.getMessage().getBody()).isEqualTo("refund requested"); + assertThat(exchange.getProperty(SemanticLanguage.RESULTS, Map.class)).containsKeys("refund", "department", "urgency"); + assertThat(requests).hasSize(1); + assertThat(requests.peek().get("state")).isEqualTo("refund requested"); } @Test
