fix: let HumanDecision blocks support execution simulation

HumanDecisionExecutor explicitly disabled supportsSimulation(), so any
execution containing a HumanDecision node lost the aggregate
simulationAvailable flag entirely, hiding the simulate feature from
clients even though the /simulate endpoint itself was untouched.
simulate() now prompts the simulator LLM to pick one of the configured
options (and a rationale when required), validates the choice, and
returns the same output shape a real human interaction would produce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-23 14:54:59 +02:00
parent a0ed4874d8
commit 2a349befa5
1 changed files with 99 additions and 5 deletions

View File

@ -3,11 +3,15 @@ package it.cnr.isti.workflow.manager.executions.executors.blocks;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanDecisionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanDecisionOption;
import it.cnr.isti.workflow.manager.blocks.factories.HumanDecisionBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.HumanDecisionBlockType;
import it.cnr.isti.workflow.manager.executions.ExecutionEventLogger;
@ -15,10 +19,15 @@ import it.cnr.isti.workflow.manager.executions.ExecutionEventType;
import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor;
import it.cnr.isti.workflow.manager.executions.InteractionResult;
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 HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockType> {
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<HumanDecisionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -26,6 +35,96 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
throw new UnsupportedOperationException("HumanDecision blocks require user interaction");
}
@Override
public Map<String, Object> simulate(Block<HumanDecisionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors,
LLMDescriptor simulatorDescriptor, ExecutionEventLogger eventLogger) {
HumanDecisionBlockConfiguration configuration =
(HumanDecisionBlockConfiguration) block.getSpecificConfiguration();
if (simulatorDescriptor == null) {
throw new IllegalArgumentException("Missing simulation descriptor for HumanDecision execution");
}
LLMProvider llmProvider = llmProviders.get(simulatorDescriptor.provider());
if (llmProvider == null) {
llmProvider = llmProviders.values().stream()
.filter(p -> p.getName().equals(simulatorDescriptor.provider()))
.findFirst()
.orElse(null);
}
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + simulatorDescriptor.provider());
}
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey))))) {
throw new IllegalArgumentException("Missing authorization for provider: " + simulatorDescriptor.provider());
}
String context = inputs.stream()
.map(input -> "%s= %s".formatted(input.getDescriptor().getName(), input.getValue()))
.collect(Collectors.joining(", "));
String optionsDescription = configuration.getOptions().stream()
.map(option -> "- %s: %s".formatted(option.name(), option.label()))
.collect(Collectors.joining("\n"));
String prompt = """
Given the following context as input { %s }, answer this decision question: "%s"
Choose exactly one of the following options (use its exact name):
%s
Respond in exactly this format:
CHOICE: <option name>
%s
""".formatted(context, configuration.getQuestion(), optionsDescription,
configuration.isRationaleRequired() ? "RATIONALE: <short rationale>" : "");
String response = llmProvider.requiresAuthorization()
? llmProvider.generate(simulatorDescriptor.model(), prompt, String.valueOf(authorizations.get(authKey)))
: llmProvider.generate(simulatorDescriptor.model(), prompt);
if (!StringUtils.hasText(response)) {
throw new IllegalArgumentException("Simulated HumanDecision produced an empty response");
}
String rawChoice = null;
String rationale = null;
for (String line : response.lines().toList()) {
String trimmed = line.strip();
if (trimmed.regionMatches(true, 0, "CHOICE:", 0, 7)) {
rawChoice = trimmed.substring(7).strip();
} else if (trimmed.regionMatches(true, 0, "RATIONALE:", 0, 10)) {
rationale = trimmed.substring(10).strip();
}
}
if (!StringUtils.hasText(rawChoice)) {
throw new NodeDecisionException("HUMAN_DECISION_SIMULATION_FAILED",
"Simulator did not return a choice");
}
String resolvedChoice = rawChoice;
String choice = configuration.getOptions().stream()
.map(HumanDecisionOption::name)
.filter(name -> name.equalsIgnoreCase(resolvedChoice))
.findFirst()
.orElseThrow(() -> new NodeDecisionException("HUMAN_DECISION_INVALID_CHOICE",
"Simulator returned unsupported HumanDecision choice: " + resolvedChoice));
if (configuration.isRationaleRequired() && !StringUtils.hasText(rationale)) {
throw new NodeDecisionException("HUMAN_DECISION_SIMULATION_FAILED",
"Simulator did not provide a required rationale");
}
if (eventLogger != null) {
eventLogger.info(ExecutionEventType.HUMAN_DECISION_RECORDED,
"Recorded simulated human decision",
Map.of("choice", choice, "rationalePresent", rationale != null));
}
Object payload = inputs.stream()
.filter(input -> HumanDecisionBlockFactory.INPUT_NAME.equals(input.getDescriptor().getName()))
.findFirst()
.map(Input::getValue)
.orElseThrow(() -> new IllegalArgumentException("HumanDecision input is missing"));
return Map.of(choice, payload);
}
@Override
public InteractionResult interact(Block<HumanDecisionBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations,
@ -85,11 +184,6 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
return true;
}
@Override
public boolean supportsSimulation() {
return false;
}
private static final class NodeDecisionException extends it.cnr.isti.workflow.manager.executions.NodeExecutionException {
private NodeDecisionException(String errorCode, String message) {
super(errorCode, message);