diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HumanDecisionBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HumanDecisionBlockConfiguration.java index 14034bb..bf77b8f 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HumanDecisionBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HumanDecisionBlockConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonIgnore; +import it.cnr.isti.workflow.manager.blocks.factories.TemplateInputs; import it.cnr.isti.workflow.manager.blocks.types.HumanDecisionBlockType; import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; @@ -75,6 +76,12 @@ public class HumanDecisionBlockConfiguration extends BlockConfiguration { return configuration; } + @AssertTrue(message = "prompt placeholder names must start with a letter and contain only letters, digits, '-' or '_'") + @JsonIgnore + boolean isPromptPlaceholderNamesValid() { + return TemplateInputs.hasValidPlaceholderNames(prompt); + } + @AssertTrue(message = "skills must have unique skillId values") boolean areSkillIdsUnique() { if (skills == null || skills.isEmpty()) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/TemplateInputs.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/TemplateInputs.java index 68957c8..fcf0353 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/TemplateInputs.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/TemplateInputs.java @@ -16,10 +16,19 @@ import it.cnr.isti.workflow.manager.ios.IOType; * description, a decision question, ...), so a block can expose more than * one upstream-wired input without a separate explicit input list. */ -final class TemplateInputs { +public final class TemplateInputs { private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{\\{(.*?)}}"); + // Allows '.' since it is already a load-bearing separator for LoopContainer + // guard template values (inputs. / outputs., see + // ExecutionsService.buildGuardTemplateValues) and for container-qualified + // exposed names (nodeName.ioName, see + // ContainerFlowInterfaceResolver.qualifyWithNodeName). Deliberately excludes + // '[' and ']' so a future marker syntax on placeholder names (e.g. an + // array-input suffix) can be introduced unambiguously later. + private static final Pattern VALID_NAME = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]*$"); + private TemplateInputs() { } @@ -41,4 +50,13 @@ final class TemplateInputs { static List toInputs(Set names, IOType type, List capabilities) { return names.stream().map(name -> IODescriptor.input(name, type, false, capabilities)).toList(); } + + /** + * Whether every {@code ${{name}}} placeholder found in the given text has + * a name that starts with a letter and contains only letters, digits, + * '-' or '_'. + */ + public static boolean hasValidPlaceholderNames(String text) { + return extractNames(text).stream().allMatch(name -> VALID_NAME.matcher(name).matches()); + } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfigurationValidationTest.java b/src/test/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfigurationValidationTest.java index 9b73130..64cdd84 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfigurationValidationTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfigurationValidationTest.java @@ -121,6 +121,75 @@ class BlockConfigurationValidationTest { assertTrue(validator.validate(configuration).isEmpty()); } + @Test + void humanDecisionRejectsMalformedPlaceholderNameInQuestion() { + HumanDecisionBlockConfiguration configuration = HumanDecisionBlockConfiguration.builder() + .name("decision") + .question("Proceed for ${{candidate profile}}?") + .options(List.of(new HumanDecisionOption("yes", "Yes"), new HumanDecisionOption("no", "No"))) + .build(); + + Set> violations = validator.validate(configuration); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("placeholder names"))); + } + + @Test + void humanDecisionAcceptsDottedPlaceholderNameInQuestion() { + HumanDecisionBlockConfiguration configuration = HumanDecisionBlockConfiguration.builder() + .name("decision") + .question("Proceed given ${{outputs.response}}?") + .options(List.of(new HumanDecisionOption("yes", "Yes"), new HumanDecisionOption("no", "No"))) + .build(); + + assertTrue(validator.validate(configuration).isEmpty()); + } + + @Test + void llmBlockRejectsMalformedPlaceholderNameInPrompt() { + LLMBlockConfiguration configuration = LLMBlockConfiguration.builder() + .name("llm") + .llmDescriptor(it.cnr.isti.workflow.manager.llms.LLMDescriptor.builder() + .provider("testProvider").model("testModel").build()) + .prompt("Summarize ${{candidate!}}") + .build(); + + Set> violations = validator.validate(configuration); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("placeholder names"))); + } + + @Test + void llmBlockAcceptsAWellFormedPrompt() { + LLMBlockConfiguration configuration = LLMBlockConfiguration.builder() + .name("llm") + .llmDescriptor(it.cnr.isti.workflow.manager.llms.LLMDescriptor.builder() + .provider("testProvider").model("testModel").build()) + .prompt("Summarize ${{candidate-profile}}") + .build(); + + assertTrue(validator.validate(configuration).isEmpty()); + } + + @Test + void humanInteractionRejectsMalformedPlaceholderNameInActionDescription() { + HumanInteractiveBlockConfiguration configuration = HumanInteractiveBlockConfiguration.builder() + .name("interaction") + .actionDescription("Review ${{candidate[]}} and decide") + .build(); + + Set> violations = validator.validate(configuration); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("placeholder names"))); + } + + @Test + void humanInteractionAcceptsAWellFormedActionDescription() { + HumanInteractiveBlockConfiguration configuration = HumanInteractiveBlockConfiguration.builder() + .name("interaction") + .actionDescription("Review ${{candidate_profile}} and decide") + .build(); + + assertTrue(validator.validate(configuration).isEmpty()); + } + @Test void endBlockRequiresAnOutcomeCode() { EndBlockConfiguration configuration = EndBlockConfiguration.builder()