Add SwitchBlock with multi-branch routing
This commit is contained in:
parent
d14fc6862d
commit
99472ffec4
|
|
@ -296,6 +296,15 @@ public class FlowAssistantService {
|
|||
config.remove("llmDescriptor");
|
||||
}
|
||||
}
|
||||
case "SwitchBlockConfiguration" -> {
|
||||
boolean useLlm = inferConditionalUseLlm(config);
|
||||
config.put("useLlm", useLlm);
|
||||
if (useLlm) {
|
||||
config.set("llmDescriptor", llmDescriptorNode(model));
|
||||
} else {
|
||||
config.remove("llmDescriptor");
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package it.cnr.isti.workflow.manager.auth.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
|
||||
public class ChangePasswordRequest {
|
||||
private String username;
|
||||
@JsonAlias("currentPassword")
|
||||
private String oldPassword;
|
||||
private String newPassword;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import static it.cnr.isti.workflow.manager.auth.services.PasswordHasher.*;
|
|||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
public enum ChangePasswordResult {
|
||||
SUCCESS,
|
||||
USER_NOT_FOUND,
|
||||
INVALID_OLD_PASSWORD,
|
||||
INVALID_NEW_PASSWORD
|
||||
}
|
||||
|
||||
@Autowired
|
||||
AuthRepository authRepository;
|
||||
|
|
@ -34,17 +39,45 @@ public class AuthService {
|
|||
return true;
|
||||
}
|
||||
|
||||
public boolean changePassword(String username, String oldPassword, String newPassword) {
|
||||
return authRepository.findById(username)
|
||||
.filter(user -> checkPassword(oldPassword, user.getPassword()))
|
||||
.map(user -> {
|
||||
user.setPassword(hashPassword(newPassword));
|
||||
authRepository.save(user);
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
public ChangePasswordResult changePassword(String username, String oldPassword, String newPassword) {
|
||||
LoginEntity user = authRepository.findById(username).orElse(null);
|
||||
if (user == null) {
|
||||
return ChangePasswordResult.USER_NOT_FOUND;
|
||||
}
|
||||
if (!checkPassword(oldPassword, user.getPassword())) {
|
||||
return ChangePasswordResult.INVALID_OLD_PASSWORD;
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return ChangePasswordResult.INVALID_NEW_PASSWORD;
|
||||
}
|
||||
user.setPassword(hashPassword(newPassword));
|
||||
authRepository.save(user);
|
||||
return ChangePasswordResult.SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
private boolean isValidPassword(String password) {
|
||||
if (password == null || password.length() < 8) {
|
||||
return false;
|
||||
}
|
||||
boolean hasUppercase = false;
|
||||
boolean hasLowercase = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecial = false;
|
||||
for (char c : password.toCharArray()) {
|
||||
if (Character.isWhitespace(c)) {
|
||||
return false;
|
||||
}
|
||||
if (Character.isUpperCase(c)) {
|
||||
hasUppercase = true;
|
||||
} else if (Character.isLowerCase(c)) {
|
||||
hasLowercase = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else {
|
||||
hasSpecial = true;
|
||||
}
|
||||
}
|
||||
return hasUppercase && hasLowercase && hasDigit && hasSpecial;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
package it.cnr.isti.workflow.manager.blocks.configurations;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
|
||||
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Builder;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
|
||||
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||
@Getter
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType> {
|
||||
|
||||
@Structural
|
||||
@UiUniqueItemsBy("name")
|
||||
@Size(max = 10)
|
||||
@Valid
|
||||
@JsonProperty(required = false)
|
||||
private List<SwitchCase> cases = List.of();
|
||||
|
||||
@UiEnabledWhen(field = "useLlm", equals = "false")
|
||||
@Structural
|
||||
@LongText(
|
||||
placeholder = "Add a deterministic selector expression that resolves to one output name, for example: 'approved' or ${{status}}",
|
||||
tip = "Supports SpEL. The expression must resolve to one of the configured case names. Inputs are derived automatically from placeholders ${{}}.",
|
||||
acceptVariableAsPlaceholder = true)
|
||||
private String condition;
|
||||
|
||||
@JsonProperty(required = true)
|
||||
@Structural
|
||||
private boolean useLlm;
|
||||
|
||||
@Valid
|
||||
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
|
||||
@UiRequiredWhen(field = "useLlm", equals = "true")
|
||||
private LLMDescriptor llmDescriptor;
|
||||
|
||||
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
|
||||
@UiRequiredWhen(field = "useLlm", equals = "true")
|
||||
@Structural
|
||||
@LongText(
|
||||
placeholder = "Add the prompt the LLM should use to choose one output name",
|
||||
tip = "The LLM must choose exactly one configured case name. Inputs are derived automatically from placeholders.",
|
||||
acceptVariableAsPlaceholder = true)
|
||||
private String prompt;
|
||||
|
||||
@Structural
|
||||
@LongText(
|
||||
placeholder = "Define the output payload to propagate on the selected branch",
|
||||
tip = "The selected branch receives the rendered output template.",
|
||||
acceptVariableAsPlaceholder = true)
|
||||
@JsonProperty(required = true)
|
||||
private String outputTemplate;
|
||||
|
||||
@Builder
|
||||
public SwitchBlockConfiguration(@NonNull String name,
|
||||
List<SwitchCase> cases,
|
||||
String condition,
|
||||
boolean useLlm,
|
||||
LLMDescriptor llmDescriptor,
|
||||
String prompt,
|
||||
String outputTemplate) {
|
||||
super(name);
|
||||
this.cases = cases == null ? List.of() : List.copyOf(cases);
|
||||
this.condition = condition;
|
||||
this.useLlm = useLlm;
|
||||
this.llmDescriptor = llmDescriptor;
|
||||
this.prompt = prompt;
|
||||
this.outputTemplate = outputTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<SwitchBlockType> getBlockType() {
|
||||
return SwitchBlockType.class;
|
||||
}
|
||||
|
||||
public static SwitchBlockConfiguration empty() {
|
||||
SwitchBlockConfiguration configuration = new SwitchBlockConfiguration();
|
||||
configuration.name = SwitchBlockType.TYPE;
|
||||
configuration.cases = List.of();
|
||||
configuration.condition = "";
|
||||
configuration.useLlm = false;
|
||||
configuration.outputTemplate = "";
|
||||
return configuration;
|
||||
}
|
||||
|
||||
@AssertTrue(message = "condition is required when useLlm is false")
|
||||
@JsonIgnore
|
||||
boolean isDeterministicConfigurationValid() {
|
||||
return useLlm || (condition != null && !condition.isBlank());
|
||||
}
|
||||
|
||||
@AssertTrue(message = "llmDescriptor is required when useLlm is true")
|
||||
@JsonIgnore
|
||||
boolean isLlmConfigurationValid() {
|
||||
return !useLlm || llmDescriptor != null;
|
||||
}
|
||||
|
||||
@AssertTrue(message = "prompt is required when useLlm is true")
|
||||
@JsonIgnore
|
||||
boolean isLlmPromptConfigurationValid() {
|
||||
return !useLlm || (prompt != null && !prompt.isBlank());
|
||||
}
|
||||
|
||||
@AssertTrue(message = "outputTemplate is required")
|
||||
@JsonIgnore
|
||||
boolean isOutputTemplateValid() {
|
||||
return outputTemplate != null && !outputTemplate.isBlank();
|
||||
}
|
||||
|
||||
@AssertTrue(message = "cases must contain unique non-blank names")
|
||||
@JsonIgnore
|
||||
boolean areCasesUnique() {
|
||||
if (cases == null || cases.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
long distinct = cases.stream()
|
||||
.map(SwitchCase::name)
|
||||
.filter(name -> name != null && !name.isBlank())
|
||||
.distinct()
|
||||
.count();
|
||||
long nonBlank = cases.stream()
|
||||
.map(SwitchCase::name)
|
||||
.filter(name -> name != null && !name.isBlank())
|
||||
.count();
|
||||
return distinct == nonBlank;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package it.cnr.isti.workflow.manager.blocks.configurations;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record SwitchCase(
|
||||
@NotBlank
|
||||
@Size(max = 64)
|
||||
String name) {
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package it.cnr.isti.workflow.manager.blocks.factories;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import it.cnr.isti.workflow.manager.blocks.Block;
|
||||
import it.cnr.isti.workflow.manager.blocks.IOCapability;
|
||||
import it.cnr.isti.workflow.manager.blocks.IOCapabilityType;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.ios.IODescriptor;
|
||||
import it.cnr.isti.workflow.manager.ios.IOType;
|
||||
|
||||
@Component
|
||||
public class SwitchBlockFactory implements BlockFactory<SwitchBlockType, SwitchBlockConfiguration> {
|
||||
|
||||
private static final List<IOCapability> INPUT_CAPABILITIES = List.of(
|
||||
new IOCapability(IOCapabilityType.TEXT, false),
|
||||
new IOCapability(IOCapabilityType.TEXT, true));
|
||||
private static final List<IOCapability> OUTPUT_CAPABILITIES = List.of(new IOCapability(IOCapabilityType.TEXT, false));
|
||||
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}");
|
||||
private static final Pattern SPEL_VARIABLE_PATTERN = Pattern.compile("#([a-zA-Z_][a-zA-Z0-9_]*)");
|
||||
|
||||
@Autowired
|
||||
private SwitchBlockType blockType;
|
||||
|
||||
@Override
|
||||
public Block<SwitchBlockType> create(SwitchBlockConfiguration configuration) {
|
||||
List<IODescriptor> inputs = retrieveInputs(configuration).stream()
|
||||
.map(inputName -> IODescriptor.input(inputName, IOType.TEXT, false, INPUT_CAPABILITIES))
|
||||
.toList();
|
||||
|
||||
Block.BlockBuilder<SwitchBlockType> builder = Block.<SwitchBlockType>builder()
|
||||
.inputs(inputs)
|
||||
.specificConfiguration(configuration)
|
||||
.type(blockType);
|
||||
for (SwitchCase outputCase : configuration.getCases() == null ? List.<SwitchCase>of() : configuration.getCases()) {
|
||||
if (outputCase != null && outputCase.name() != null && !outputCase.name().isBlank()) {
|
||||
builder.output(IODescriptor.output(outputCase.name(), IOType.TEXT, false, OUTPUT_CAPABILITIES));
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Block<SwitchBlockType> createEmpty() {
|
||||
return create(SwitchBlockConfiguration.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<SwitchBlockType> getBlockType() {
|
||||
return SwitchBlockType.class;
|
||||
}
|
||||
|
||||
private List<String> retrieveInputs(SwitchBlockConfiguration configuration) {
|
||||
Set<String> inputNames = new LinkedHashSet<>();
|
||||
if (configuration.isUseLlm()) {
|
||||
collectMatches(inputNames, configuration.getPrompt(), PLACEHOLDER_PATTERN, 1);
|
||||
} else {
|
||||
collectMatches(inputNames, configuration.getCondition(), PLACEHOLDER_PATTERN, 1);
|
||||
collectMatches(inputNames, configuration.getCondition(), SPEL_VARIABLE_PATTERN, 1);
|
||||
}
|
||||
collectMatches(inputNames, configuration.getOutputTemplate(), PLACEHOLDER_PATTERN, 1);
|
||||
return List.copyOf(inputNames);
|
||||
}
|
||||
|
||||
private void collectMatches(Set<String> target, String source, Pattern pattern, int group) {
|
||||
if (source == null || source.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Matcher matcher = pattern.matcher(source);
|
||||
while (matcher.find()) {
|
||||
String placeholder = matcher.group(group);
|
||||
if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) {
|
||||
target.add(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IOCapability> supportedInputCapabilities() {
|
||||
return INPUT_CAPABILITIES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IOCapability> supportedOutputCapabilities() {
|
||||
return OUTPUT_CAPABILITIES;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package it.cnr.isti.workflow.manager.blocks.types;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
|
||||
@Component(SwitchBlockType.TYPE)
|
||||
public class SwitchBlockType implements BlockType {
|
||||
|
||||
public static final String TYPE = "SwitchBlock";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "A switch block that routes the workflow to one user-defined output";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUserInteractive() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends BlockConfiguration<?>> getBlockConfigurationClass() {
|
||||
return SwitchBlockConfiguration.class;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import java.util.Collections;
|
|||
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
|
@ -68,9 +69,31 @@ public class AuthController {
|
|||
@PostMapping("/change-password")
|
||||
@Operation(summary = "Change password", description = "Changes the user password after validating the current password.")
|
||||
public ResponseEntity<?> changePassword(@RequestBody ChangePasswordRequest request) {
|
||||
if (authService.changePassword(request.getUsername(), request.getOldPassword(), request.getNewPassword())) {
|
||||
return ResponseEntity.ok().build();
|
||||
if (request == null
|
||||
|| isBlank(request.getUsername())
|
||||
|| isBlank(request.getOldPassword())
|
||||
|| isBlank(request.getNewPassword())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Username, oldPassword/currentPassword and newPassword are required");
|
||||
}
|
||||
return ResponseEntity.badRequest().body("Change password failed");
|
||||
|
||||
AuthService.ChangePasswordResult result = authService.changePassword(
|
||||
request.getUsername(),
|
||||
request.getOldPassword(),
|
||||
request.getNewPassword());
|
||||
|
||||
return switch (result) {
|
||||
case SUCCESS -> ResponseEntity.ok().build();
|
||||
case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("User " + request.getUsername() + " not found");
|
||||
case INVALID_OLD_PASSWORD -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Current password is invalid");
|
||||
case INVALID_NEW_PASSWORD -> ResponseEntity.badRequest()
|
||||
.body("INVALID_NEW_PASSWORD");
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockCon
|
|||
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.containers.Container;
|
||||
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
|
||||
import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot;
|
||||
|
|
@ -265,6 +266,10 @@ public class ExecutionsService {
|
|||
&& conditionalConfiguration.isUseLlm()) {
|
||||
return listOfDescriptors(conditionalConfiguration.getLlmDescriptor());
|
||||
}
|
||||
if (configuration instanceof SwitchBlockConfiguration switchConfiguration
|
||||
&& switchConfiguration.isUseLlm()) {
|
||||
return listOfDescriptors(switchConfiguration.getLlmDescriptor());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
package it.cnr.isti.workflow.manager.executions.executors.blocks;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import it.cnr.isti.workflow.manager.blocks.Block;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.executions.ExecutionEventLogger;
|
||||
import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver;
|
||||
import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor;
|
||||
import it.cnr.isti.workflow.manager.executions.steps.Input;
|
||||
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
|
||||
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
|
||||
|
||||
@Component
|
||||
public class SwitchExecutor implements BlockExecutor<SwitchBlockType> {
|
||||
|
||||
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(SwitchExecutor.class);
|
||||
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}");
|
||||
private static final String LLM_SYSTEM_PROMPT = """
|
||||
You are a workflow switch evaluator.
|
||||
Choose exactly one output name from the allowed outputs.
|
||||
Reply strictly as JSON in the form {"result":"outputName"} or just the output name.
|
||||
Do not add any extra text.
|
||||
""";
|
||||
|
||||
private final ExpressionParser expressionParser = new SpelExpressionParser();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private Map<String, LLMProvider> llmProviders;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> execute(Block<SwitchBlockType> block, List<Input> inputs,
|
||||
Map<String, Object> authorizations, Map<String, Object> executionVariables,
|
||||
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors, ExecutionEventLogger eventLogger) {
|
||||
SwitchBlockConfiguration config = (SwitchBlockConfiguration) block.getSpecificConfiguration();
|
||||
Map<String, Object> inputValues = collectInputValues(inputs);
|
||||
Set<String> allowedOutputs = config.getCases().stream()
|
||||
.map(SwitchCase::name)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
String selectedOutput = config.isUseLlm()
|
||||
? evaluateWithLlm(config, inputValues, authorizations, executionVariables, allowedOutputs)
|
||||
: evaluateWithExpression(config.getCondition(), inputValues, executionVariables, allowedOutputs);
|
||||
String payload = resolvePlaceholders(config.getOutputTemplate(), inputValues, executionVariables);
|
||||
return Map.of(selectedOutput, payload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<SwitchBlockType> getBlockType() {
|
||||
return SwitchBlockType.class;
|
||||
}
|
||||
|
||||
private Map<String, Object> collectInputValues(List<Input> inputs) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (Input input : inputs) {
|
||||
values.put(input.getDescriptor().getName(), input.getValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String evaluateWithExpression(String expression, Map<String, Object> inputValues,
|
||||
Map<String, Object> executionVariables, Set<String> allowedOutputs) {
|
||||
String normalizedExpression = normalizeExpression(expression);
|
||||
StandardEvaluationContext context = new StandardEvaluationContext(inputValues);
|
||||
inputValues.forEach(context::setVariable);
|
||||
context.setVariable("vars", executionVariables == null ? Map.of() : executionVariables);
|
||||
Object result = expressionParser.parseExpression(normalizedExpression).getValue(context);
|
||||
if (result == null) {
|
||||
throw new IllegalArgumentException("Switch expression did not resolve to any output");
|
||||
}
|
||||
return validateSelectedOutput(result.toString(), allowedOutputs);
|
||||
}
|
||||
|
||||
private String evaluateWithLlm(SwitchBlockConfiguration config, Map<String, Object> inputValues,
|
||||
Map<String, Object> authorizations, Map<String, Object> executionVariables, Set<String> allowedOutputs) {
|
||||
LLMDescriptor llmDescriptor = config.getLlmDescriptor();
|
||||
LLMProvider llmProvider = resolveProvider(llmDescriptor.provider());
|
||||
String authKey = llmProvider.authorizationKey();
|
||||
if (llmProvider.requiresAuthorization()
|
||||
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) {
|
||||
throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider());
|
||||
}
|
||||
|
||||
String prompt = buildLlmPrompt(config, inputValues, executionVariables, allowedOutputs);
|
||||
String response = llmProvider.requiresAuthorization()
|
||||
? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString())
|
||||
: llmProvider.generate(llmDescriptor.model(), prompt);
|
||||
return parseSelectedOutput(response, allowedOutputs);
|
||||
}
|
||||
|
||||
private LLMProvider resolveProvider(String providerName) {
|
||||
LLMProvider llmProvider = llmProviders.get(providerName);
|
||||
if (llmProvider != null) {
|
||||
return llmProvider;
|
||||
}
|
||||
return llmProviders.values().stream()
|
||||
.filter(provider -> provider.getName().equals(providerName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
|
||||
}
|
||||
|
||||
private String buildLlmPrompt(SwitchBlockConfiguration config, Map<String, Object> inputValues,
|
||||
Map<String, Object> executionVariables, Set<String> allowedOutputs) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(LLM_SYSTEM_PROMPT).append("\n");
|
||||
builder.append("Allowed outputs: ").append(allowedOutputs).append("\n");
|
||||
builder.append("Decision prompt: ").append(resolvePlaceholders(config.getPrompt(), inputValues, executionVariables)).append("\n");
|
||||
builder.append("Inputs: ").append(inputValues).append("\n");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String normalizeExpression(String expression) {
|
||||
Matcher matcher = PLACEHOLDER_PATTERN.matcher(expression);
|
||||
StringBuffer buffer = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
matcher.appendReplacement(buffer, Matcher.quoteReplacement("#" + matcher.group(1)));
|
||||
}
|
||||
matcher.appendTail(buffer);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
private String resolvePlaceholders(String template, Map<String, Object> inputValues,
|
||||
Map<String, Object> executionVariables) {
|
||||
return ExecutionTemplateResolver.resolve(template, inputValues, executionVariables);
|
||||
}
|
||||
|
||||
private String parseSelectedOutput(String response, Set<String> allowedOutputs) {
|
||||
if (!StringUtils.hasText(response)) {
|
||||
throw new IllegalArgumentException("Empty response from LLM switch evaluator");
|
||||
}
|
||||
|
||||
String trimmed = response.trim();
|
||||
if (allowedOutputs.contains(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Object result = payload.get("result");
|
||||
if (result != null) {
|
||||
return validateSelectedOutput(result.toString(), allowedOutputs);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Unable to parse switch LLM response as JSON: {}", trimmed, e);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Switch LLM response must contain one configured output name");
|
||||
}
|
||||
|
||||
private String validateSelectedOutput(String selectedOutput, Set<String> allowedOutputs) {
|
||||
if (!StringUtils.hasText(selectedOutput)) {
|
||||
throw new IllegalArgumentException("Switch evaluator selected a blank output");
|
||||
}
|
||||
if (!allowedOutputs.contains(selectedOutput)) {
|
||||
throw new IllegalArgumentException("Switch evaluator selected unsupported output: " + selectedOutput);
|
||||
}
|
||||
return selectedOutput;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package it.cnr.isti.workflow.manager.flows.validation;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -14,7 +15,9 @@ import it.cnr.isti.workflow.manager.blocks.Block;
|
|||
import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.SwitchBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.containers.Container;
|
||||
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
|
||||
import it.cnr.isti.workflow.manager.containers.factories.ContainerFactory;
|
||||
|
|
@ -66,7 +69,7 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
|
|||
validateConnection(connection, nodesById);
|
||||
}
|
||||
|
||||
validateConditionalBranches(nodes, connections, nodesById);
|
||||
validateExclusiveRoutingBranches(nodes, connections, nodesById);
|
||||
}
|
||||
|
||||
private void validateNode(FlowNode node) {
|
||||
|
|
@ -179,7 +182,7 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
|
|||
return descriptors.stream().anyMatch(descriptor -> name.equals(descriptor.getName()));
|
||||
}
|
||||
|
||||
private void validateConditionalBranches(List<FlowNode> nodes, List<Connection> connections,
|
||||
private void validateExclusiveRoutingBranches(List<FlowNode> nodes, List<Connection> connections,
|
||||
Map<String, FlowNode> nodesById) {
|
||||
Map<String, List<Connection>> outgoingBySource = new HashMap<>();
|
||||
for (Connection connection : connections) {
|
||||
|
|
@ -188,29 +191,53 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
|
|||
}
|
||||
|
||||
for (FlowNode node : nodes) {
|
||||
if (!(node instanceof Block<?> block) || !ConditionalBlockType.TYPE.equals(block.getType().getName())) {
|
||||
if (!(node instanceof Block<?> block)) {
|
||||
continue;
|
||||
}
|
||||
List<String> exclusiveOutputs = exclusiveRoutingOutputs(block);
|
||||
if (exclusiveOutputs.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> trueBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.TRUE_OUTPUT, outgoingBySource);
|
||||
Set<String> falseBranch = collectReachableTargets(block.getId(), ConditionalBlockFactory.FALSE_OUTPUT, outgoingBySource);
|
||||
Map<String, Set<String>> reachableByOutput = new LinkedHashMap<>();
|
||||
for (String outputName : exclusiveOutputs) {
|
||||
reachableByOutput.put(outputName, collectReachableTargets(block.getId(), outputName, outgoingBySource));
|
||||
}
|
||||
|
||||
Set<String> mergedNodes = new LinkedHashSet<>(trueBranch);
|
||||
mergedNodes.retainAll(falseBranch);
|
||||
if (!mergedNodes.isEmpty()) {
|
||||
String mergedNodeNames = mergedNodes.stream()
|
||||
.map(nodeId -> {
|
||||
FlowNode target = nodesById.get(nodeId);
|
||||
return target == null ? nodeId : target.getName() + " (" + nodeId + ")";
|
||||
})
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElse("unknown");
|
||||
throw validationError(error("block", block.getId(), "outputs",
|
||||
"Conditional true/false branches must not merge. Shared downstream nodes: " + mergedNodeNames));
|
||||
for (int leftIndex = 0; leftIndex < exclusiveOutputs.size(); leftIndex++) {
|
||||
for (int rightIndex = leftIndex + 1; rightIndex < exclusiveOutputs.size(); rightIndex++) {
|
||||
String leftOutput = exclusiveOutputs.get(leftIndex);
|
||||
String rightOutput = exclusiveOutputs.get(rightIndex);
|
||||
Set<String> mergedNodes = new LinkedHashSet<>(reachableByOutput.get(leftOutput));
|
||||
mergedNodes.retainAll(reachableByOutput.get(rightOutput));
|
||||
if (!mergedNodes.isEmpty()) {
|
||||
String mergedNodeNames = mergedNodes.stream()
|
||||
.map(nodeId -> {
|
||||
FlowNode target = nodesById.get(nodeId);
|
||||
return target == null ? nodeId : target.getName() + " (" + nodeId + ")";
|
||||
})
|
||||
.reduce((first, second) -> first + ", " + second)
|
||||
.orElse("unknown");
|
||||
throw validationError(error("block", block.getId(), "outputs",
|
||||
"Exclusive routing branches must not merge. Shared downstream nodes: " + mergedNodeNames));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> exclusiveRoutingOutputs(Block<?> block) {
|
||||
if (ConditionalBlockType.TYPE.equals(block.getType().getName())) {
|
||||
return List.of(ConditionalBlockFactory.TRUE_OUTPUT, ConditionalBlockFactory.FALSE_OUTPUT);
|
||||
}
|
||||
if (SwitchBlockType.TYPE.equals(block.getType().getName())) {
|
||||
return block.getOutputs().stream()
|
||||
.map(IODescriptor::getName)
|
||||
.toList();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private Set<String> collectReachableTargets(String conditionalId, String sourceOutput,
|
||||
Map<String, List<Connection>> outgoingBySource) {
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ package it.cnr.isti.workflow.manager.controllers;
|
|||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import it.cnr.isti.workflow.manager.auth.model.AuthRequest;
|
||||
import it.cnr.isti.workflow.manager.auth.model.ChangePasswordRequest;
|
||||
|
||||
@SpringBootTest
|
||||
@TestPropertySource(locations = "classpath:test.properties")
|
||||
|
|
@ -20,4 +22,43 @@ public class AuthControllerTest {
|
|||
ResponseEntity<?> response = authController.login(new AuthRequest("testuser", "testpassword"));
|
||||
assert response.getStatusCode().is2xxSuccessful();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testChangePassword() {
|
||||
ChangePasswordRequest request = new ChangePasswordRequest();
|
||||
request.setUsername("testuser");
|
||||
request.setOldPassword("testpassword");
|
||||
request.setNewPassword("Newpassword1!");
|
||||
|
||||
ResponseEntity<?> response = authController.changePassword(request);
|
||||
assert response.getStatusCode().is2xxSuccessful();
|
||||
|
||||
ResponseEntity<?> loginResponse = authController.login(new AuthRequest("testuser", "Newpassword1!"));
|
||||
assert loginResponse.getStatusCode().is2xxSuccessful();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testChangePasswordReturnsUnauthorizedForInvalidCurrentPassword() {
|
||||
ChangePasswordRequest request = new ChangePasswordRequest();
|
||||
request.setUsername("testuser");
|
||||
request.setOldPassword("wrong-password");
|
||||
request.setNewPassword("Anotherpass1!");
|
||||
|
||||
ResponseEntity<?> response = authController.changePassword(request);
|
||||
assert response.getStatusCode() == HttpStatus.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testChangePasswordReturnsInvalidNewPasswordForWeakPassword() {
|
||||
authController.register(new AuthRequest("weakpwduser", "Startpass1!"));
|
||||
|
||||
ChangePasswordRequest request = new ChangePasswordRequest();
|
||||
request.setUsername("weakpwduser");
|
||||
request.setOldPassword("Startpass1!");
|
||||
request.setNewPassword("weakpass");
|
||||
|
||||
ResponseEntity<?> response = authController.changePassword(request);
|
||||
assert response.getStatusCode() == HttpStatus.BAD_REQUEST;
|
||||
assert "INVALID_NEW_PASSWORD".equals(response.getBody());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,14 @@ import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockCon
|
|||
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
|
||||
import it.cnr.isti.workflow.manager.controllers.BlocksController.BlockConfigurationDescriptor;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
|
||||
import it.cnr.isti.workflow.manager.configurations.retrievers.ExecutionVariablesFieldRetriever;
|
||||
import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest;
|
||||
|
|
@ -747,6 +750,40 @@ public class BlocksControllerTest {
|
|||
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void switchBlockSchemaContainsConditionalRequiredMetadata() {
|
||||
BlockConfigurationDescriptor descriptor = blocksController
|
||||
.getConfigurationDescriptorForType(SwitchBlockType.TYPE);
|
||||
|
||||
assertNotNull(descriptor.schema());
|
||||
JsonNode schema = (JsonNode) descriptor.schema();
|
||||
JsonNode llmDescriptor = schema.path("properties").path("llmDescriptor");
|
||||
JsonNode prompt = schema.path("properties").path("prompt");
|
||||
|
||||
assertEquals("useLlm", llmDescriptor.path("x-ui-required-when").path("field").asText());
|
||||
assertEquals("true", llmDescriptor.path("x-ui-required-when").path("equals").asText());
|
||||
assertEquals("useLlm", prompt.path("x-ui-required-when").path("field").asText());
|
||||
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createSwitchBlockCreatesUserDefinedOutputs() {
|
||||
Block<SwitchBlockType> block = blocksController.create(SwitchBlockConfiguration.builder()
|
||||
.name("Decision")
|
||||
.useLlm(false)
|
||||
.condition("'approved'")
|
||||
.outputTemplate("${{candidate}}")
|
||||
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected"), new SwitchCase("review")))
|
||||
.build());
|
||||
|
||||
assertNotNull(block);
|
||||
assertEquals(SwitchBlockType.TYPE, block.getType().getName());
|
||||
assertEquals(3, block.getOutputs().size());
|
||||
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("approved")));
|
||||
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("rejected")));
|
||||
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("review")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMCPBridgeExampleForType() {
|
||||
Block<MCPAgentBlockType> block = blocksController.getExampleForType(MCPAgentBlockType.TYPE);
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ import it.cnr.isti.workflow.manager.blocks.Block;
|
|||
import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.containers.Container;
|
||||
import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration;
|
||||
import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType;
|
||||
|
|
@ -870,6 +873,94 @@ public class FlowControllerTest {
|
|||
() -> flowController.createFlow(request, new LoginEntity("testuser", "testpassword")));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode());
|
||||
assertTrue(exception.getReason().contains("Conditional true/false branches must not merge"));
|
||||
assertTrue(exception.getReason().contains("Exclusive routing branches must not merge"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createFlowRejectsSwitchBranchMerge() {
|
||||
LLMDescriptor llmDescriptor = LLMDescriptor.builder()
|
||||
.provider("testProvider")
|
||||
.model("testModel")
|
||||
.build();
|
||||
|
||||
Block<LLMBlockType> source = blocksController.create(LLMBlockConfiguration.builder()
|
||||
.name("Source")
|
||||
.llmDescriptor(llmDescriptor)
|
||||
.prompt("Classify candidate from ${{candidate}}")
|
||||
.build());
|
||||
|
||||
Block<SwitchBlockType> switchBlock = blocksController.create(SwitchBlockConfiguration.builder()
|
||||
.name("Decision")
|
||||
.condition("'approved'")
|
||||
.useLlm(false)
|
||||
.outputTemplate("${{response}}")
|
||||
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected"), new SwitchCase("review")))
|
||||
.build());
|
||||
|
||||
Block<LLMBlockType> approvedBranch = blocksController.create(LLMBlockConfiguration.builder()
|
||||
.name("Approved branch")
|
||||
.llmDescriptor(llmDescriptor)
|
||||
.prompt("Summarize approved outcome from ${{response}}")
|
||||
.build());
|
||||
|
||||
Block<LLMBlockType> reviewBranch = blocksController.create(LLMBlockConfiguration.builder()
|
||||
.name("Review branch")
|
||||
.llmDescriptor(llmDescriptor)
|
||||
.prompt("Summarize review outcome from ${{response}}")
|
||||
.build());
|
||||
|
||||
Block<LLMBlockType> merged = blocksController.create(LLMBlockConfiguration.builder()
|
||||
.name("Merged result")
|
||||
.llmDescriptor(llmDescriptor)
|
||||
.prompt("Combine approved: ${{fromApproved}} and review: ${{fromReview}}")
|
||||
.build());
|
||||
|
||||
FlowCreateRequest request = new FlowCreateRequest(
|
||||
"Invalid switch merge",
|
||||
"Switch branches must not merge",
|
||||
FlowData.builder()
|
||||
.block(source)
|
||||
.block(switchBlock)
|
||||
.block(approvedBranch)
|
||||
.block(reviewBranch)
|
||||
.block(merged)
|
||||
.connection(Connection.builder()
|
||||
.sourceId(source.getId())
|
||||
.sourceName("response")
|
||||
.targetId(switchBlock.getId())
|
||||
.targetName("response")
|
||||
.build())
|
||||
.connection(Connection.builder()
|
||||
.sourceId(switchBlock.getId())
|
||||
.sourceName("approved")
|
||||
.targetId(approvedBranch.getId())
|
||||
.targetName("response")
|
||||
.build())
|
||||
.connection(Connection.builder()
|
||||
.sourceId(switchBlock.getId())
|
||||
.sourceName("review")
|
||||
.targetId(reviewBranch.getId())
|
||||
.targetName("response")
|
||||
.build())
|
||||
.connection(Connection.builder()
|
||||
.sourceId(approvedBranch.getId())
|
||||
.sourceName("response")
|
||||
.targetId(merged.getId())
|
||||
.targetName("fromApproved")
|
||||
.build())
|
||||
.connection(Connection.builder()
|
||||
.sourceId(reviewBranch.getId())
|
||||
.sourceName("response")
|
||||
.targetId(merged.getId())
|
||||
.targetName("fromReview")
|
||||
.build())
|
||||
.build());
|
||||
|
||||
ResponseStatusException exception = assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> flowController.createFlow(request, new LoginEntity("testuser", "testpassword")));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode());
|
||||
assertTrue(exception.getReason().contains("Exclusive routing branches must not merge"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput;
|
|||
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchBlockConfiguration;
|
||||
import it.cnr.isti.workflow.manager.blocks.configurations.SwitchCase;
|
||||
import it.cnr.isti.workflow.manager.containers.Container;
|
||||
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
|
||||
import it.cnr.isti.workflow.manager.containers.configurations.GenericContainerConfiguration;
|
||||
|
|
@ -54,12 +56,14 @@ import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory;
|
|||
import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentChatBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.factories.SwitchBlockFactory;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
|
||||
import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind;
|
||||
import it.cnr.isti.workflow.manager.executions.ExecutionEventType;
|
||||
import it.cnr.isti.workflow.manager.ios.IODescriptor;
|
||||
|
|
@ -104,6 +108,9 @@ public class ExecutionTest {
|
|||
if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_FINAL###")) {
|
||||
return "FINAL: Candidate approved";
|
||||
}
|
||||
if (prompt.contains("You are a workflow switch evaluator.")) {
|
||||
return "{\"result\":\"approved\"}";
|
||||
}
|
||||
return "Hello, " + prompt.replace("Hello, ", "").replace("!", "") + "!";
|
||||
|
||||
}
|
||||
|
|
@ -135,6 +142,9 @@ public class ExecutionTest {
|
|||
@Autowired
|
||||
MCPAgentBlockFactory mcpAgentBlockFactory;
|
||||
|
||||
@Autowired
|
||||
SwitchBlockFactory switchBlockFactory;
|
||||
|
||||
@Autowired
|
||||
HumanInteractiveBlockFactory humanInteractiveBlockFactory;
|
||||
|
||||
|
|
@ -546,6 +556,65 @@ public class ExecutionTest {
|
|||
assertEquals("Ada Lovelace", restored.getContext().getExecutionVariables().get("person"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void switchExecutionRoutesToConfiguredOutputUsingExpression() {
|
||||
Block<SwitchBlockType> switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder()
|
||||
.name("Decision")
|
||||
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected")))
|
||||
.condition("${{decision}}")
|
||||
.useLlm(false)
|
||||
.outputTemplate("Outcome for ${{candidate}}")
|
||||
.build());
|
||||
|
||||
Flow flow = Flow.builder()
|
||||
.name("Switch flow")
|
||||
.description("Single switch block")
|
||||
.block(switchBlock)
|
||||
.build();
|
||||
|
||||
ExecutionObject execObject = executionsService.createExecution(flow);
|
||||
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "decision", "approved");
|
||||
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "candidate", "John Doe");
|
||||
execObject = executionsService.startExecution(execObject.getId());
|
||||
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
|
||||
execObject = executionsService.getExecution(execObject.getId());
|
||||
}
|
||||
|
||||
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
|
||||
assertEquals("Outcome for John Doe",
|
||||
execObject.getContext().getResult().get(new FieldKey(switchBlock.getId(), "approved")));
|
||||
assertFalse(execObject.getContext().getResult().containsKey(new FieldKey(switchBlock.getId(), "rejected")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void switchExecutionRoutesToConfiguredOutputUsingLlm() {
|
||||
Block<SwitchBlockType> switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder()
|
||||
.name("Decision")
|
||||
.cases(List.of(new SwitchCase("approved"), new SwitchCase("rejected")))
|
||||
.useLlm(true)
|
||||
.llmDescriptor(llmBrick)
|
||||
.prompt("Choose the output for candidate ${{candidate}}")
|
||||
.outputTemplate("Outcome for ${{candidate}}")
|
||||
.build());
|
||||
|
||||
Flow flow = Flow.builder()
|
||||
.name("Switch flow llm")
|
||||
.description("Single switch block with llm")
|
||||
.block(switchBlock)
|
||||
.build();
|
||||
|
||||
ExecutionObject execObject = executionsService.createExecution(flow);
|
||||
execObject = executionsService.prepareInput(execObject.getId(), switchBlock.getId(), "candidate", "John Doe");
|
||||
execObject = executionsService.startExecution(execObject.getId());
|
||||
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
|
||||
execObject = executionsService.getExecution(execObject.getId());
|
||||
}
|
||||
|
||||
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
|
||||
assertEquals("Outcome for John Doe",
|
||||
execObject.getContext().getResult().get(new FieldKey(switchBlock.getId(), "approved")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelExecutionClearsExecutionVariables() {
|
||||
Block<HumanInteractionBlockType> reviewBlock = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder()
|
||||
|
|
|
|||
Loading…
Reference in New Issue