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 120ed0fa0e77d5dba4f0c70cd257c67cd3ee6a95 Author: Luigi De Masi <[email protected]> AuthorDate: Thu Sep 24 17:32:00 2026 +0200 CAMEL-24977: Document semantic retry and contextual action validation Clarify the Semantic Evaluation title and description while retaining the existing artifact and language names. Document bounded retry and contextual action validation through existing Camel hooks, with regression coverage for budget exhaustion, permission rejection, uncertainty and provider failures. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 98 +++++++- .../apache/camel/catalog/languages/semantic.json | 4 +- components/camel-ai/camel-semantic/pom.xml | 4 +- .../apache/camel/language/semantic/semantic.json | 4 +- .../services/org/apache/camel/language.properties | 4 +- .../src/main/docs/semantic-language.adoc | 98 +++++++- .../camel/language/semantic/SemanticLanguage.java | 3 +- .../semantic/SemanticRetryAndValidationTest.java | 272 +++++++++++++++++++++ docs/components/modules/languages/nav.adoc | 2 +- 9 files changed, 471 insertions(+), 18 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 1e50cb70781e..2b71a6ae2387 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 @@ -1,8 +1,8 @@ -= Semantic Language -:doctitle: Semantic += Semantic Evaluation Language +:doctitle: Semantic Evaluation :shortname: semantic :artifactid: camel-semantic -:description: Evaluate named semantic questions through a provider adapter +:description: Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters :since: 4.23 :supportlevel: Preview :tabs-sync-option: @@ -136,7 +136,7 @@ Use `language("semantic", "ref:name")` wherever an expression or boolean predica [cols="1,3"] |=== -|EIP |Usage +|EIP / integration point |Usage |Choice |Store a category with Set Property, then compare that property in ordinary when predicates. |Filter |Use a boolean question as the filter predicate. |Validate |Use a boolean question; false follows normal validation failure handling. @@ -148,6 +148,8 @@ Use `language("semantic", "ref:name")` wherever an expression or boolean predica |Enrich |Map a category to a configured resource URI and use an ordinary aggregation strategy. |Loop |Reevaluate a boolean question on updated state, with an explicit iteration or time budget. |Sort |Score each item once, store the scores, then sort using a deterministic comparator. +|On Exception / retryWhile |Evaluate whether another attempt is worthwhile, with an explicit retry budget checked before inference. +|Contextual action validation |Use Validate after ordinary permission checks and before executing the action. |=== Destination mappings belong to trusted route configuration; provider output should select @@ -234,6 +236,94 @@ its score. Use `.sort(body(), Comparator.comparingDouble(ScoredItem::score))` wi score. The comparator must not call the provider: sorting can compare an item multiple times and in an implementation-dependent order. +=== Bounded semantic retry + +A boolean question can supply the xref:manual::exception-advanced.adoc[retryWhile predicate] +for an exception clause. Ask whether another attempt is worthwhile using the current failure +and selected request context. Restrict this to operations that are safe to retry. + +IMPORTANT: `retryWhile` replaces the `maximumRedeliveries` decision. Check the retry budget +inside the predicate, before calling the provider. A provider that always returns true must +not cause unlimited retries or evaluations. + +Declare a boolean question named `retryable` with `state: $\{exchangeProperty.retryState}`. +In this example, the request body is a string. `onExceptionOccurred` prepares the state before +the retry predicate runs; `onRedelivery` runs later and is too late for this purpose. +Include only the failure details needed by the question, excluding credentials and sensitive data. + +[source,java] +---- +Predicate retryable = context.resolveLanguage("semantic").createPredicate("ref:retryable"); + +onException(IOException.class) + .onExceptionOccurred(exchange -> { + Exception failure = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class); + exchange.setProperty("retryState", Map.of( + "request", exchange.getMessage().getBody(String.class), + "failureType", failure.getClass().getSimpleName(), + "attempt", exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, Integer.class))); + }) + .retryWhile(exchange -> + exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, 0, Integer.class) <= 3 + && retryable.matches(exchange)) + .redeliveryDelay(1000) + .handled(true) + .to("direct:escalate"); +---- + +The counter starts at one when deciding the first redelivery. This permits at most three +redeliveries after the initial attempt. Each failure refreshes the selected state; redelivery +restarts at the failed processor, not at the beginning of the route. A negative decision or +an exhausted budget sends the message to `direct:escalate` through normal exception handling. + +Evaluation errors are distinct from a negative decision. If the retry predicate throws, +Camel reports the evaluation failure on the exchange; it does not automatically execute +the exception clause's escalation route. Arrange caller or supervising-route handling for +that failure. Bound provider timeouts as well as the retry count. + +=== Contextual action validation + +Use a semantic question for an additional check such as "Does this proposed action serve +the approved task?" after normal identity, permission and tenant checks have succeeded. +The semantic decision must not grant permissions that those checks denied. + +For example, declare this question alongside routes: + +[source,yaml] +---- +- semantic: + question: + withinScope: + type: boolean + instructions: Does the proposed action serve the approved task? + state: ${body} + threshold: 0.8 + uncertainty: 0.05 + uncertaintyPolicy: fail +---- + +The selected state should contain the approved task and the proposed action. Obtain the +approved task from trusted application state; do not let the proposed action redefine it. +In this example, `direct:checkPermissions` performs the application's existing authorization +and rejects unauthorized requests before semantic evaluation: + +[source,java] +---- +from("direct:action") + .to("direct:checkPermissions") + .validate().language("semantic", "ref:withinScope") + .to("direct:performAction"); +---- + +A negative decision raises normal validation failure. With this question's `fail` policy, +an uncertain decision raises an evaluation error. Timeouts and malformed responses also +fail, and none of these outcomes should execute the protected action. Failure handling may +reject the request or send it for human review; do not use `continued(true)` to resume at +the action. The example's threshold is a decision policy, not an accuracy guarantee. + +This pattern uses Validate and existing authorization services. It does not add a semantic +`AuthorizationPolicy` implementation or replace a component's internal guardrail interfaces. + == Implementing an adapter Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/languages/semantic.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/languages/semantic.json index a55839e37138..4e05df3493c2 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/languages/semantic.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/languages/semantic.json @@ -2,8 +2,8 @@ "language": { "kind": "language", "name": "semantic", - "title": "Semantic", - "description": "Evaluate named semantic questions through a provider adapter", + "title": "Semantic Evaluation", + "description": "Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters", "deprecated": false, "firstVersion": "4.23.0", "label": "language,ai", diff --git a/components/camel-ai/camel-semantic/pom.xml b/components/camel-ai/camel-semantic/pom.xml index 0bd826ff70b6..7c3e05e0eafe 100644 --- a/components/camel-ai/camel-semantic/pom.xml +++ b/components/camel-ai/camel-semantic/pom.xml @@ -26,8 +26,8 @@ <version>4.23.0-SNAPSHOT</version> </parent> <artifactId>camel-semantic</artifactId> - <name>Camel :: AI :: Semantic</name> - <description>Provider-independent semantic evaluation language and adapter SPI</description> + <name>Camel :: AI :: Semantic Evaluation</name> + <description>Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters</description> <dependencies> <dependency> <groupId>org.apache.camel</groupId> diff --git a/components/camel-ai/camel-semantic/src/generated/resources/META-INF/org/apache/camel/language/semantic/semantic.json b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/org/apache/camel/language/semantic/semantic.json index a55839e37138..4e05df3493c2 100644 --- a/components/camel-ai/camel-semantic/src/generated/resources/META-INF/org/apache/camel/language/semantic/semantic.json +++ b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/org/apache/camel/language/semantic/semantic.json @@ -2,8 +2,8 @@ "language": { "kind": "language", "name": "semantic", - "title": "Semantic", - "description": "Evaluate named semantic questions through a provider adapter", + "title": "Semantic Evaluation", + "description": "Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters", "deprecated": false, "firstVersion": "4.23.0", "label": "language,ai", diff --git a/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/language.properties b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/language.properties index 263824973361..78cea1e65e68 100644 --- a/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/language.properties +++ b/components/camel-ai/camel-semantic/src/generated/resources/META-INF/services/org/apache/camel/language.properties @@ -3,5 +3,5 @@ languages=semantic groupId=org.apache.camel artifactId=camel-semantic version=4.23.0-SNAPSHOT -projectName=Camel :: AI :: Semantic -projectDescription=Provider-independent semantic evaluation language and adapter SPI +projectName=Camel :: AI :: Semantic Evaluation +projectDescription=Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters 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 1e50cb70781e..2b71a6ae2387 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 @@ -1,8 +1,8 @@ -= Semantic Language -:doctitle: Semantic += Semantic Evaluation Language +:doctitle: Semantic Evaluation :shortname: semantic :artifactid: camel-semantic -:description: Evaluate named semantic questions through a provider adapter +:description: Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters :since: 4.23 :supportlevel: Preview :tabs-sync-option: @@ -136,7 +136,7 @@ Use `language("semantic", "ref:name")` wherever an expression or boolean predica [cols="1,3"] |=== -|EIP |Usage +|EIP / integration point |Usage |Choice |Store a category with Set Property, then compare that property in ordinary when predicates. |Filter |Use a boolean question as the filter predicate. |Validate |Use a boolean question; false follows normal validation failure handling. @@ -148,6 +148,8 @@ Use `language("semantic", "ref:name")` wherever an expression or boolean predica |Enrich |Map a category to a configured resource URI and use an ordinary aggregation strategy. |Loop |Reevaluate a boolean question on updated state, with an explicit iteration or time budget. |Sort |Score each item once, store the scores, then sort using a deterministic comparator. +|On Exception / retryWhile |Evaluate whether another attempt is worthwhile, with an explicit retry budget checked before inference. +|Contextual action validation |Use Validate after ordinary permission checks and before executing the action. |=== Destination mappings belong to trusted route configuration; provider output should select @@ -234,6 +236,94 @@ its score. Use `.sort(body(), Comparator.comparingDouble(ScoredItem::score))` wi score. The comparator must not call the provider: sorting can compare an item multiple times and in an implementation-dependent order. +=== Bounded semantic retry + +A boolean question can supply the xref:manual::exception-advanced.adoc[retryWhile predicate] +for an exception clause. Ask whether another attempt is worthwhile using the current failure +and selected request context. Restrict this to operations that are safe to retry. + +IMPORTANT: `retryWhile` replaces the `maximumRedeliveries` decision. Check the retry budget +inside the predicate, before calling the provider. A provider that always returns true must +not cause unlimited retries or evaluations. + +Declare a boolean question named `retryable` with `state: $\{exchangeProperty.retryState}`. +In this example, the request body is a string. `onExceptionOccurred` prepares the state before +the retry predicate runs; `onRedelivery` runs later and is too late for this purpose. +Include only the failure details needed by the question, excluding credentials and sensitive data. + +[source,java] +---- +Predicate retryable = context.resolveLanguage("semantic").createPredicate("ref:retryable"); + +onException(IOException.class) + .onExceptionOccurred(exchange -> { + Exception failure = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class); + exchange.setProperty("retryState", Map.of( + "request", exchange.getMessage().getBody(String.class), + "failureType", failure.getClass().getSimpleName(), + "attempt", exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, Integer.class))); + }) + .retryWhile(exchange -> + exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, 0, Integer.class) <= 3 + && retryable.matches(exchange)) + .redeliveryDelay(1000) + .handled(true) + .to("direct:escalate"); +---- + +The counter starts at one when deciding the first redelivery. This permits at most three +redeliveries after the initial attempt. Each failure refreshes the selected state; redelivery +restarts at the failed processor, not at the beginning of the route. A negative decision or +an exhausted budget sends the message to `direct:escalate` through normal exception handling. + +Evaluation errors are distinct from a negative decision. If the retry predicate throws, +Camel reports the evaluation failure on the exchange; it does not automatically execute +the exception clause's escalation route. Arrange caller or supervising-route handling for +that failure. Bound provider timeouts as well as the retry count. + +=== Contextual action validation + +Use a semantic question for an additional check such as "Does this proposed action serve +the approved task?" after normal identity, permission and tenant checks have succeeded. +The semantic decision must not grant permissions that those checks denied. + +For example, declare this question alongside routes: + +[source,yaml] +---- +- semantic: + question: + withinScope: + type: boolean + instructions: Does the proposed action serve the approved task? + state: ${body} + threshold: 0.8 + uncertainty: 0.05 + uncertaintyPolicy: fail +---- + +The selected state should contain the approved task and the proposed action. Obtain the +approved task from trusted application state; do not let the proposed action redefine it. +In this example, `direct:checkPermissions` performs the application's existing authorization +and rejects unauthorized requests before semantic evaluation: + +[source,java] +---- +from("direct:action") + .to("direct:checkPermissions") + .validate().language("semantic", "ref:withinScope") + .to("direct:performAction"); +---- + +A negative decision raises normal validation failure. With this question's `fail` policy, +an uncertain decision raises an evaluation error. Timeouts and malformed responses also +fail, and none of these outcomes should execute the protected action. Failure handling may +reject the request or send it for human review; do not use `continued(true)` to resume at +the action. The example's threshold is a decision policy, not an accuracy guarantee. + +This pattern uses Validate and existing authorization services. It does not add a semantic +`AuthorizationPolicy` implementation or replace a component's internal guardrail interfaces. + == Implementing an adapter Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in 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 b88dbee4bdbd..89257cb6ee4d 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 @@ -47,7 +47,8 @@ import org.apache.camel.util.IOHelper; /** Evaluates a named, provider-independent question against selected message state. */ @Language(value = "semantic", modelName = "language") -@Metadata(title = "Semantic", description = "Evaluate named semantic questions through a provider adapter", +@Metadata(title = "Semantic Evaluation", + description = "Evaluate named questions about message content to produce boolean decisions, categories and scores through provider adapters", label = "language,ai", firstVersion = "4.23.0") public class SemanticLanguage extends LanguageSupport { public static final String RESULT = "CamelSemanticResult"; diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticRetryAndValidationTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticRetryAndValidationTest.java new file mode 100644 index 000000000000..96f922aaa3d6 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticRetryAndValidationTest.java @@ -0,0 +1,272 @@ +/* + * 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.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.CamelAuthorizationException; +import org.apache.camel.Exchange; +import org.apache.camel.Predicate; +import org.apache.camel.ValidationException; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.language.semantic.SemanticLanguage; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SemanticRetryAndValidationTest extends CamelTestSupport { + private final AtomicInteger attempts = new AtomicInteger(); + private final AtomicInteger entries = new AtomicInteger(); + private final List<Object> evaluatedStates = new CopyOnWriteArrayList<>(); + private volatile int failures = Integer.MAX_VALUE; + private volatile double probability = 0.9; + private volatile boolean malformedResult; + private volatile Exception evaluationFailure; + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + context.getRegistry().bind("evaluator", new SemanticAdapter() { + @Override + public void validate(SemanticQuestion question) { + } + + @Override + public SemanticResult evaluate(SemanticQuestion question, Object state) throws Exception { + evaluatedStates.add(state); + if (evaluationFailure != null) { + throw evaluationFailure; + } + return malformedResult + ? new SemanticResult("unexpected category", null, null, null, null) + : new SemanticResult(null, probability, null, null, null); + } + }); + SemanticLanguage language = (SemanticLanguage) context.resolveLanguage("semantic"); + language.setAdapter("evaluator"); + SemanticQuestions.get(context).replace("test", Map.of( + "retryable", question("Is another attempt worthwhile?", "${exchangeProperty.retryState}"), + "withinScope", question("Does the proposed action serve the approved task?", "${body}"))); + Predicate retryable = language.createPredicate("ref:retryable"); + + errorHandler(defaultErrorHandler().maximumRedeliveries(0).logExhausted(false)); + onException(IOException.class) + .onExceptionOccurred(exchange -> { + Exception failure = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class); + exchange.setProperty("retryState", Map.of( + "request", exchange.getMessage().getBody(String.class), + "failure", failure.getMessage(), + "attempt", exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, Integer.class))); + }) + .retryWhile( + exchange -> exchange.getMessage().getHeader(Exchange.REDELIVERY_COUNTER, 0, Integer.class) <= 3 + && retryable.matches(exchange)) + .redeliveryDelay(0) + .handled(true) + .to("mock:escalated"); + + from("direct:retry") + .process(exchange -> entries.incrementAndGet()) + .process(exchange -> { + int attempt = attempts.incrementAndGet(); + if (attempt <= failures) { + throw new IOException("Failure " + attempt); + } + }) + .to("mock:completed"); + + from("direct:action") + .to("direct:checkPermissions") + .validate().language("semantic", "ref:withinScope") + .to("mock:performed"); + from("direct:checkPermissions").process(exchange -> { + // Stand-in for the application's trusted authorization service, not a caller-supplied header. + if (!Boolean.TRUE.equals(exchange.getProperty("permissionGranted", Boolean.class))) { + throw new CamelAuthorizationException("Permission denied", exchange); + } + }); + } + }; + } + + private static SemanticQuestion question(String instructions, String state) { + return new SemanticQuestion( + SemanticQuestion.Type.BOOLEAN, instructions, state, null, null, + 0.8, 0.05, SemanticQuestion.UncertaintyPolicy.FAIL); + } + + @Test + void approvedRetryResumesAtFailedProcessorAndPreservesBody() throws Exception { + failures = 1; + getMockEndpoint("mock:completed").expectedBodiesReceived("operation"); + getMockEndpoint("mock:escalated").expectedMessageCount(0); + + Exchange exchange = template.request("direct:retry", e -> e.getMessage().setBody("operation")); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo("operation"); + assertThat(attempts).hasValue(2); + assertThat(entries).hasValue(1); + assertThat(evaluatedStates).containsExactly(retryState(1)); + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void negativeRetryDecisionEscalatesWithoutAnotherAttempt() throws Exception { + probability = 0.1; + getMockEndpoint("mock:completed").expectedMessageCount(0); + getMockEndpoint("mock:escalated").expectedBodiesReceived("operation"); + + Exchange exchange = template.request("direct:retry", e -> e.getMessage().setBody("operation")); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class)) + .isInstanceOf(IOException.class).hasMessage("Failure 1"); + assertThat(attempts).hasValue(1); + assertThat(evaluatedStates).containsExactly(retryState(1)); + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void alwaysApprovedRetryStopsAtBudgetBeforeAnotherEvaluation() throws Exception { + getMockEndpoint("mock:completed").expectedMessageCount(0); + getMockEndpoint("mock:escalated").expectedBodiesReceived("operation"); + + Exchange exchange = template.request("direct:retry", e -> e.getMessage().setBody("operation")); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class)) + .isInstanceOf(IOException.class).hasMessage("Failure 4"); + assertThat(attempts).hasValue(4); + assertThat(entries).hasValue(1); + assertThat(evaluatedStates).containsExactly(retryState(1), retryState(2), retryState(3)); + MockEndpoint.assertIsSatisfied(context); + } + + @ParameterizedTest + @ValueSource(strings = { "timeout", "malformed", "uncertain" }) + void retryEvaluationFailurePropagatesWithoutRetryOrNormalEscalation(String failure) throws Exception { + failEvaluation(failure); + getMockEndpoint("mock:completed").expectedMessageCount(0); + getMockEndpoint("mock:escalated").expectedMessageCount(0); + + Exchange exchange = template.request("direct:retry", e -> e.getMessage().setBody("operation")); + + assertEvaluationFailure(exchange, failure); + assertThat(exchange.getMessage().getBody()).isEqualTo("operation"); + assertThat(exchange.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class)).isInstanceOf(IOException.class); + assertThat(attempts).hasValue(1); + assertThat(evaluatedStates).containsExactly(retryState(1)); + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void approvedContextExecutesActionAndPreservesState() throws Exception { + getMockEndpoint("mock:performed").expectedBodiesReceived(actionState()); + + Exchange exchange = requestAction(true); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo(actionState()); + assertThat(evaluatedStates).containsExactly(actionState()); + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void deniedPermissionPreventsEvaluationAndAction() throws Exception { + getMockEndpoint("mock:performed").expectedMessageCount(0); + + Exchange exchange = requestAction(false); + + assertThat(exchange.getException()).isInstanceOf(CamelAuthorizationException.class); + assertThat(evaluatedStates).isEmpty(); + MockEndpoint.assertIsSatisfied(context); + } + + @Test + void negativeContextDecisionPreventsActionDespitePermission() throws Exception { + probability = 0.1; + getMockEndpoint("mock:performed").expectedMessageCount(0); + + Exchange exchange = requestAction(true); + + assertThat(exchange.getException()).isInstanceOf(ValidationException.class); + assertThat(evaluatedStates).containsExactly(actionState()); + MockEndpoint.assertIsSatisfied(context); + } + + @ParameterizedTest + @ValueSource(strings = { "timeout", "malformed", "uncertain" }) + void contextEvaluationFailurePreventsActionDespitePermission(String failure) throws Exception { + failEvaluation(failure); + getMockEndpoint("mock:performed").expectedMessageCount(0); + + Exchange exchange = requestAction(true); + + assertEvaluationFailure(exchange, failure); + assertThat(exchange.getMessage().getBody()).isEqualTo(actionState()); + assertThat(evaluatedStates).containsExactly(actionState()); + MockEndpoint.assertIsSatisfied(context); + } + + private Exchange requestAction(boolean permissionGranted) { + return template.request("direct:action", exchange -> { + exchange.setProperty("permissionGranted", permissionGranted); + exchange.getMessage().setBody(actionState()); + }); + } + + private static Map<String, Object> retryState(int attempt) { + return Map.of("request", "operation", "failure", "Failure " + attempt, "attempt", attempt); + } + + private static Map<String, String> actionState() { + return Map.of("approvedTask", "Send the customer their order status", "proposedAction", "Email the order status"); + } + + private void failEvaluation(String failure) { + switch (failure) { + case "timeout" -> evaluationFailure = new TimeoutException("Evaluation timed out"); + case "malformed" -> malformedResult = true; + case "uncertain" -> probability = 0.8; + default -> throw new IllegalArgumentException(failure); + } + } + + private static void assertEvaluationFailure(Exchange exchange, String failure) { + switch (failure) { + case "timeout" -> assertThat(exchange.getException(TimeoutException.class)).hasMessage("Evaluation timed out"); + case "malformed" -> assertThat(exchange.getException(IllegalArgumentException.class)) + .hasMessage("Semantic result does not support the question and its decision policy"); + case "uncertain" -> assertThat(exchange.getException(IllegalStateException.class)) + .hasMessage("Semantic boolean decision is uncertain"); + default -> throw new IllegalArgumentException(failure); + } + assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull(); + } +} diff --git a/docs/components/modules/languages/nav.adoc b/docs/components/modules/languages/nav.adoc index 940325e249d6..970ce0154225 100644 --- a/docs/components/modules/languages/nav.adoc +++ b/docs/components/modules/languages/nav.adoc @@ -21,7 +21,7 @@ ** xref:python3-language.adoc[Python 3] ** xref:quickjs-language.adoc[QuickJS] ** xref:ref-language.adoc[Ref] -** xref:semantic-language.adoc[Semantic] +** xref:semantic-language.adoc[Semantic Evaluation] ** xref:simple-language.adoc[Simple] ** xref:simple-advanced.adoc[Simple - Advanced Features] ** xref:simple-functions.adoc[Simple - Built-in Functions]
