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 921bea1b1420dfb5dac01cca87d39400445db899
Author: Luigi De Masi <[email protected]>
AuthorDate: Thu Oct 8 08:43:21 2026 +0200

    CAMEL-25382: Isolate semantic replacement validation
    
    Keep public evaluation lookups on the published snapshot. Pass candidate
    snapshots into validation explicitly and consolidate the two ThreadLocals
    into one private language compilation scope, suspended across expert
    callbacks. Preserve existing Simple APIs and lazy compilation behavior.
    
    Cover callback reads, reentrant validation, rollback and nested selectors.
    Add HTTP-level tests proving that missing TypeSafe response fields are
    rejected before adapter mapping, and address the review documentation nits.
    
    Co-authored-by: Codex <[email protected]>
    Signed-off-by: Luigi De Masi <[email protected]>
---
 .../camel/catalog/docs/semantic-language.adoc      |   3 +
 .../src/main/docs/semantic-language.adoc           |   3 +
 .../camel/language/semantic/SemanticLanguage.java  |  70 +++++++++++---
 .../apache/camel/semantic/SemanticEvaluations.java |  64 ++++++------
 .../camel/semantic/SemanticContractTest.java       | 107 +++++++++++++++++++++
 .../camel/semantic/SemanticInitializationTest.java |   3 +-
 .../typesafeai/TypeSafeAiSemanticAdapterTest.java  |  26 +++++
 .../language/simple/SimplePredicateExpression.java |   2 +
 8 files changed, 225 insertions(+), 53 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 699f055934d8..be66d12c1a70 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
@@ -374,6 +374,9 @@ Use `.type("boolean")` with optional `.threshold(0.8)`, 
`.uncertainty(0.1)` and
 `.uncertaintyPolicy("non-match")` for boolean evaluations. Use 
`.type("score")` and successive
 `.level("description")` calls for ordered score levels. Applications can also 
register immutable
 `SemanticEvaluation` definitions using 
`SemanticEvaluations.get(context).replace(source, evaluations)`.
+On a running context, replacement definitions are validated before 
publication. If validation fails,
+the previous definitions remain available. The public `get(name)` and 
`get(names)` methods always
+read published definitions, including calls made from an expert's validation 
callback.
 
 Each Java resource owns one group. Register all of its evaluations together; 
registering again
 replaces that resource's previous group. Reloading a Java resource without the 
helper removes
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 699f055934d8..be66d12c1a70 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
@@ -374,6 +374,9 @@ Use `.type("boolean")` with optional `.threshold(0.8)`, 
`.uncertainty(0.1)` and
 `.uncertaintyPolicy("non-match")` for boolean evaluations. Use 
`.type("score")` and successive
 `.level("description")` calls for ordered score levels. Applications can also 
register immutable
 `SemanticEvaluation` definitions using 
`SemanticEvaluations.get(context).replace(source, evaluations)`.
+On a running context, replacement definitions are validated before 
publication. If validation fails,
+the previous definitions remain available. The public `get(name)` and 
`get(names)` methods always
+read published definitions, including calls made from an expert's validation 
callback.
 
 Each Java resource owns one group. Register all of its evaluations together; 
registering again
 replaces that resource's previous group. Reloading a Java resource without the 
helper removes
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 f2eebc7c2c88..3ddc4d7620c4 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
@@ -68,7 +68,9 @@ public class SemanticLanguage extends LanguageSupport {
     public static final String ADAPTER_FACTORY = "semantic-adapter";
     public static final String ADAPTER_RESOURCE = FactoryFinder.DEFAULT_PATH + 
ADAPTER_FACTORY;
 
-    private final ThreadLocal<Boolean> validating = new ThreadLocal<>();
+    // Nested Simple functions re-enter the language while compiling state 
selectors. Keep that scope private to
+    // compilation; registry reads and expert callbacks must always observe 
published declarations.
+    private final ThreadLocal<Validation> validation = new ThreadLocal<>();
     private String adapter;
     private String defaultExpert;
     private String defaultState = "${body}";
@@ -225,9 +227,6 @@ public class SemanticLanguage extends LanguageSupport {
     }
 
     private synchronized void startAdapter(List<Group> groups) {
-        if (Boolean.TRUE.equals(validating.get())) {
-            return;
-        }
         ManagedAdapter owned = managedAdapter;
         if (owned == null || groups.stream().noneMatch(group -> 
group.expert.provider == owned.instance)) {
             return;
@@ -299,7 +298,13 @@ public class SemanticLanguage extends LanguageSupport {
 
     /** Validate declarations, including unused evaluations, without starting 
managed expert resources. */
     public void validateDeclarations(Map<String, SemanticEvaluation> 
declarations) {
-        validationOnly(() -> {
+        validateDeclarations(declarations, null);
+    }
+
+    /** Validate changed declarations against the complete candidate snapshot 
without publishing it. */
+    public void validateDeclarations(
+            Map<String, SemanticEvaluation> declarations, Map<String, 
SemanticEvaluation> snapshot) {
+        validationOnly(snapshot, () -> {
             declarations.forEach((name, evaluation) -> {
                 expert(name, evaluation);
                 String selector = evaluation.getState() != null ? 
evaluation.getState() : defaultState;
@@ -314,21 +319,33 @@ public class SemanticLanguage extends LanguageSupport {
         });
     }
 
-    private <T> T validationOnly(Supplier<T> action) {
-        Boolean previous = validating.get();
-        validating.set(true);
+    private <T> T validationOnly(Map<String, SemanticEvaluation> snapshot, 
Supplier<T> action) {
+        Validation previous = validation.get();
+        validation.set(snapshot != null || previous == null ? new 
Validation(snapshot) : previous);
         try {
             return action.get();
         } finally {
             if (previous == null) {
-                validating.remove();
+                validation.remove();
             } else {
-                validating.set(previous);
+                validation.set(previous);
             }
         }
     }
 
     private ResolvedExpert expert(String name, SemanticEvaluation evaluation) {
+        Validation previous = validation.get();
+        validation.remove();
+        try {
+            return resolveExpert(name, evaluation);
+        } finally {
+            if (previous != null) {
+                validation.set(previous);
+            }
+        }
+    }
+
+    private ResolvedExpert resolveExpert(String name, SemanticEvaluation 
evaluation) {
         String reference = evaluation.getExpert() != null ? 
evaluation.getExpert() : defaultExpert;
         String label = reference != null ? reference : adapter != null ? 
adapter : "automatic";
         try {
@@ -431,7 +448,7 @@ public class SemanticLanguage extends LanguageSupport {
         private final List<String> names;
         private final boolean batch;
         private final boolean predicate;
-        private final Consumer<Map<String, SemanticEvaluation>> validator = 
this::prepare;
+        private final Consumer<Map<String, SemanticEvaluation>> validator = 
this::validateReplacement;
         private volatile Compiled compiled;
         private volatile SemanticEvaluations evaluations;
 
@@ -445,9 +462,11 @@ public class SemanticLanguage extends LanguageSupport {
         public void init(CamelContext context) {
             super.init(context);
             evaluations = SemanticEvaluations.get(context);
-            Map<String, SemanticEvaluation> selected = evaluations.get(names);
+            Validation scope = validation.get();
+            Map<String, SemanticEvaluation> selected = scope != null && 
scope.snapshot != null
+                    ? select(scope.snapshot, true) : evaluations.get(names);
             compile(selected);
-            if (!Boolean.TRUE.equals(validating.get())) {
+            if (scope == null) {
                 evaluations.setValidator(names, validator);
             }
         }
@@ -459,7 +478,7 @@ public class SemanticLanguage extends LanguageSupport {
         }
 
         private synchronized Compiled compile(Map<String, SemanticEvaluation> 
selected) {
-            if (Boolean.TRUE.equals(validating.get())) {
+            if (validation.get() != null) {
                 // Revisit cached nested selectors against the candidate 
snapshot without publishing compilation state.
                 return prepare(selected);
             }
@@ -472,7 +491,25 @@ public class SemanticLanguage extends LanguageSupport {
         }
 
         private Compiled prepare(Map<String, SemanticEvaluation> selected) {
-            return validationOnly(() -> prepareDeclarations(selected));
+            return validationOnly(null, () -> prepareDeclarations(selected));
+        }
+
+        private void validateReplacement(Map<String, SemanticEvaluation> 
snapshot) {
+            validationOnly(snapshot, () -> 
prepareDeclarations(select(snapshot, false)));
+        }
+
+        private Map<String, SemanticEvaluation> select(Map<String, 
SemanticEvaluation> snapshot, boolean required) {
+            Map<String, SemanticEvaluation> selected = new LinkedHashMap<>();
+            for (String name : names) {
+                SemanticEvaluation evaluation = snapshot.get(name);
+                // Removed declarations remain removable; their existing 
expressions fail if evaluated again.
+                if (evaluation != null) {
+                    selected.put(name, evaluation);
+                } else if (required) {
+                    throw new IllegalArgumentException("Unknown semantic 
evaluation: " + name);
+                }
+            }
+            return Collections.unmodifiableMap(selected);
         }
 
         private Compiled prepareDeclarations(Map<String, SemanticEvaluation> 
selected) {
@@ -643,6 +680,9 @@ public class SemanticLanguage extends LanguageSupport {
         }
     }
 
+    private record Validation(Map<String, SemanticEvaluation> snapshot) {
+    }
+
     private record ResolvedExpert(SemanticAdapter provider, 
SemanticCapabilities capabilities, String reference) {
     }
 
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluations.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluations.java
index 80816e246840..30f65e8a1730 100644
--- 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluations.java
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluations.java
@@ -35,7 +35,6 @@ public final class SemanticEvaluations {
     private final Map<String, Map<String, SemanticEvaluation>> sources = new 
HashMap<>();
     private final Map<String, Resource> resources = new HashMap<>();
     private volatile Map<String, SemanticEvaluation> evaluations = Map.of();
-    private final ThreadLocal<Map<String, SemanticEvaluation>> candidate = new 
ThreadLocal<>();
     private final Map<Consumer<Map<String, SemanticEvaluation>>, List<String>> 
validators = new WeakHashMap<>();
 
     private SemanticEvaluations(CamelContext context) {
@@ -49,10 +48,14 @@ public final class SemanticEvaluations {
         }
     }
 
-    /** Validate current declarations and weakly track a callback owned by its 
initialized expression. */
+    /**
+     * Validate current declarations and weakly track a callback owned by its 
initialized expression. The callback
+     * receives the complete immutable snapshot so nested references can be 
validated against the same replacement.
+     */
     public synchronized void setValidator(List<String> names, 
Consumer<Map<String, SemanticEvaluation>> validator) {
         // A replacement may have occurred between the expression's initial 
compilation and registration.
-        validator.accept(get(names));
+        get(names);
+        validator.accept(evaluations);
         validators.put(validator, List.copyOf(names));
     }
 
@@ -93,39 +96,23 @@ public final class SemanticEvaluations {
                 throw new IllegalArgumentException("Duplicate semantic 
evaluation: " + name);
             }
         });
-        Map<String, SemanticEvaluation> previous = candidate.get();
-        candidate.set(Map.copyOf(replacement));
-        try {
-            if (context.isStarted() && !definitions.isEmpty()) {
-                ((SemanticLanguage) 
context.resolveLanguage("semantic")).validateDeclarations(definitions);
-            }
-            validators.forEach((validator, names) -> {
-                if (!Collections.disjoint(names, definitions.keySet())) {
-                    Map<String, SemanticEvaluation> selected = new 
LinkedHashMap<>();
-                    names.forEach(name -> {
-                        SemanticEvaluation evaluation = replacement.get(name);
-                        // Removed declarations remain removable; their 
existing expressions fail if evaluated again.
-                        if (evaluation != null) {
-                            selected.put(name, evaluation);
-                        }
-                    });
-                    validator.accept(Collections.unmodifiableMap(selected));
-                }
-            });
-        } finally {
-            if (previous == null) {
-                candidate.remove();
-            } else {
-                candidate.set(previous);
-            }
+        Map<String, SemanticEvaluation> snapshot = Map.copyOf(replacement);
+        if (context.isStarted() && !definitions.isEmpty()) {
+            ((SemanticLanguage) 
context.resolveLanguage("semantic")).validateDeclarations(definitions, 
snapshot);
         }
+        // Expert callbacks may initialize expressions and register additional 
validators reentrantly.
+        new LinkedHashMap<>(validators).forEach((validator, names) -> {
+            if (!Collections.disjoint(names, definitions.keySet())) {
+                validator.accept(snapshot);
+            }
+        });
         if (definitions.isEmpty()) {
             sources.remove(source);
         } else {
             sources.put(source, Map.copyOf(definitions));
         }
         resources.remove(source);
-        evaluations = Map.copyOf(replacement);
+        evaluations = snapshot;
     }
 
     /** Track a route resource so deleted files can be discarded before 
development-mode reload. */
@@ -157,17 +144,25 @@ public final class SemanticEvaluations {
         return evaluations.isEmpty();
     }
 
+    /**
+     * Resolve a named evaluation from the published snapshot, including when 
called during replacement validation.
+     * Candidate definitions become visible only after validation succeeds.
+     */
     public SemanticEvaluation get(String name) {
-        SemanticEvaluation evaluation = snapshot().get(name);
+        SemanticEvaluation evaluation = evaluations.get(name);
         if (evaluation == null) {
             throw new IllegalArgumentException("Unknown semantic evaluation: " 
+ name);
         }
         return evaluation;
     }
 
-    /** Resolve all requested names from one immutable snapshot, preserving 
reference order. */
+    /**
+     * Resolve all requested names from one immutable published snapshot, 
preserving reference order. This also reads
+     * the published snapshot during replacement validation; candidate 
definitions become visible only after validation
+     * succeeds.
+     */
     public Map<String, SemanticEvaluation> get(List<String> names) {
-        Map<String, SemanticEvaluation> snapshot = snapshot();
+        Map<String, SemanticEvaluation> snapshot = evaluations;
         Map<String, SemanticEvaluation> selected = new LinkedHashMap<>();
         for (String name : names) {
             SemanticEvaluation evaluation = snapshot.get(name);
@@ -179,9 +174,4 @@ public final class SemanticEvaluations {
         return Collections.unmodifiableMap(selected);
     }
 
-    private Map<String, SemanticEvaluation> snapshot() {
-        Map<String, SemanticEvaluation> validation = candidate.get();
-        return validation != null ? validation : evaluations;
-    }
-
 }
diff --git 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticContractTest.java
 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticContractTest.java
index 868bc6188692..7825fc0cffce 100644
--- 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticContractTest.java
+++ 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticContractTest.java
@@ -36,6 +36,7 @@ import org.apache.camel.support.PluginHelper;
 import org.apache.camel.support.ResourceHelper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.snakeyaml.engine.v2.api.LoadSettings;
 import org.snakeyaml.engine.v2.api.lowlevel.Compose;
@@ -158,6 +159,8 @@ class SemanticContractTest {
                 @Override
                 public void validate(SemanticEvaluation evaluation) {
                     if (evaluation == candidate) {
+                        assertThat(registry.get("q")).as("callback reads 
published evaluation").isSameAs(original);
+                        
assertThat(registry.get(List.of("q"))).containsEntry("q", original);
                         entered.countDown();
                         try {
                             assertThat(release.await(10, 
TimeUnit.SECONDS)).isTrue();
@@ -185,6 +188,110 @@ class SemanticContractTest {
         }
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void expertCallbacksDoNotInheritValidationScope(boolean fail) throws 
Exception {
+        try (var context = new DefaultCamelContext()) {
+            var registry = SemanticEvaluations.get(context);
+            var original = new SemanticEvaluation("detect", "content", null, 
Map.of());
+            var candidate = new SemanticEvaluation("detect", "content", 
"${semantic('added')} ready", Map.of());
+            var nested = new SemanticEvaluation("detect", "content", null, 
Map.of("threshold", 0.9));
+            var expert = new ContentExpert() {
+                boolean checked;
+
+                @Override
+                public void validate(SemanticEvaluation evaluation) {
+                    if (evaluation == candidate) {
+                        checked = true;
+                        var exchange = new DefaultExchange(context);
+                        exchange.getMessage().setBody("published state");
+                        var language = context.resolveLanguage("semantic");
+                        
assertThat(language.createExpression("ref:nested").evaluate(exchange, 
Boolean.class)).isTrue();
+                        // Reentrant validation must restore the outer 
replacement's candidate snapshot.
+                        registry.validate();
+                        if (fail) {
+                            throw new IllegalArgumentException("Reject 
replacement");
+                        }
+                    }
+                }
+            };
+            context.getRegistry().bind("content", expert);
+            registry.replace("test", Map.of("q", original, "nested", 
original));
+            context.start();
+            var current = 
context.resolveLanguage("semantic").createExpression("ref:q");
+            Map<String, SemanticEvaluation> replacement = Map.of("q", 
candidate, "nested", nested, "added", original);
+            if (fail) {
+                assertThatThrownBy(() -> registry.replace("test", 
replacement)).hasMessageContaining("Reject replacement");
+                assertThat(registry.get("q")).isSameAs(original);
+                assertThatThrownBy(() -> 
registry.get("added")).hasMessageContaining("Unknown semantic evaluation");
+            } else {
+                registry.replace("test", replacement);
+                assertThat(registry.get("q")).isSameAs(candidate);
+            }
+            assertThat(expert.checked).isTrue();
+            // A later successful replacement and runtime compilation must not 
retain either validation scope.
+            registry.replace("test", Map.of("q", original, "nested", nested));
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("published state");
+            
assertThat(context.resolveLanguage("semantic").createExpression("ref:nested")
+                    .evaluate(exchange, Boolean.class)).isFalse();
+            assertThat(current.evaluate(exchange, Boolean.class)).isTrue();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "${semantic('inner')} ready",
+            "${semantic('inner')} ? 'yes' : 'no'" })
+    void nestedSimpleFunctionsValidateCandidateReferences(String selector) 
throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            context.getRegistry().bind("content", new ContentExpert());
+            var registry = SemanticEvaluations.get(context);
+            var inner = new SemanticEvaluation("detect", "content", null, 
Map.of());
+            registry.replace("test", Map.of("inner", inner));
+            context.start();
+            var outer = new SemanticEvaluation("detect", "content", selector, 
Map.of());
+            registry.replace("test", Map.of("inner", inner, "outer", outer));
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("content");
+            var expression = 
context.resolveLanguage("semantic").createExpression("ref:outer");
+            assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+            assertThatThrownBy(() -> registry.replace("test", Map.of("outer", 
outer)))
+                    .hasMessageContaining("Unknown semantic evaluation: 
inner");
+            assertThat(registry.get("inner")).isSameAs(inner);
+            assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource(delimiter = '|', value = {
+            "${not(${semantic('inner')})} ready | false ready | true ready",
+            "${iif(${semantic('inner')},yes,no)} | yes | no" })
+    void lazyNestedSimpleFunctionsReadPublishedReplacements(String selector, 
String before, String after) throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            var expert = new ContentExpert();
+            context.getRegistry().bind("content", expert);
+            var registry = SemanticEvaluations.get(context);
+            var inner = new SemanticEvaluation("detect", "content", null, 
Map.of());
+            var outer = new SemanticEvaluation("detect", "content", selector, 
Map.of());
+            registry.replace("test", Map.of("inner", inner, "outer", outer));
+            context.start();
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("content");
+            var expression = 
context.resolveLanguage("semantic").createExpression("ref:outer");
+            assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+            assertThat(expert.state).isEqualTo(before);
+            registry.replace("test", Map.of("inner",
+                    new SemanticEvaluation("detect", "content", null, 
Map.of("threshold", 0.9)), "outer", outer));
+            assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+            assertThat(expert.state).isEqualTo(after);
+            // Simple compiles these composite functions lazily, when they are 
evaluated.
+            registry.replace("test", Map.of("outer", outer));
+            assertThatThrownBy(() -> expression.evaluate(exchange, 
Boolean.class))
+                    .hasMessageContaining("Unknown semantic evaluation: 
inner");
+        }
+    }
+
     @Test
     void invalidProgrammaticReplacementRetainsUnusedDeclarations() throws 
Exception {
         try (var context = new DefaultCamelContext()) {
diff --git 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
index ef47233e353f..c5e55284da86 100644
--- 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
+++ 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
@@ -101,7 +101,8 @@ class SemanticInitializationTest {
                 assertThat(lookup.get(10, 
TimeUnit.SECONDS)).isSameAs(expected);
             } finally {
                 release.countDown();
-                assertThat(creation.get(10, TimeUnit.SECONDS)).isNotNull();
+                assertThat(creation.get(10, TimeUnit.SECONDS))
+                        .as("registry created after the lazy lookup returns no 
plugin").isNotNull();
             }
         } finally {
             release.countDown();
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 c637ba51db2e..b77fa9f4b8cb 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
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.component.typesafeai;
 
+import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -85,6 +86,31 @@ class TypeSafeAiSemanticAdapterTest extends 
TypeSafeAiTestSupport {
         assertThat(requests).isEmpty();
     }
 
+    @ParameterizedTest
+    @CsvSource({
+            "probability, Expected a number",
+            "answer, Answer names must match question names",
+            "answers, Expected an object"
+    })
+    void missingBooleanResponseFieldsFailBeforeAdapterMapping(String missing, 
String message) throws Exception {
+        JsonObject response = TypeSafeAiJson.parse(result(Map.of("question", 
Map.of("type", "noul", "noul", 0.9))));
+        JsonObject answers = response.getJsonObject("answers");
+        switch (missing) {
+            case "probability" -> 
answers.getJsonObject("question").remove("noul");
+            case "answer" -> answers.remove("question");
+            case "answers" -> response.remove("answers");
+            default -> throw new IllegalArgumentException(missing);
+        }
+        respond = request -> response.toJson();
+        var adapter = new TypeSafeAiSemanticAdapter();
+        adapter.setCamelContext(context);
+        var evaluation = new SemanticEvaluation("boolean", null, null, 
Map.of("instructions", "Classify"));
+        assertThatThrownBy(() -> adapter.evaluate(evaluation, "private-input"))
+                .isExactlyInstanceOf(IOException.class)
+                .hasMessage("Invalid TypeSafe AI response: " + message);
+        assertThat(requests).hasSize(1);
+    }
+
     @Test
     void omittedPolicyUsesAdapterDefaults() throws Exception {
         respond = request -> result(Map.of("question", Map.of("type", "noul", 
"noul", 0.5)));
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateExpression.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateExpression.java
index 6b351d6e1504..080018a34acf 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateExpression.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateExpression.java
@@ -24,6 +24,8 @@ import org.apache.camel.Predicate;
  * Opts a Simple function into using its predicate contract for negation, 
including any validation in
  * {@link #matches(org.apache.camel.Exchange)}. Existing expressions retain 
value-based truthiness, even when they also
  * implement {@link Predicate}.
+ *
+ * @since 4.23
  */
 public interface SimplePredicateExpression extends Expression, Predicate {
     @Override

Reply via email to