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

Reply via email to