Add chat interaction runtime contract and schema metadata

This commit is contained in:
Lucio Lelii 2026-03-19 15:14:54 +01:00
parent 56424d2f0f
commit ad42c38259
45 changed files with 2231 additions and 170 deletions

View File

@ -25,11 +25,21 @@ public class BlockCatalogService {
String type,
String description,
boolean userInteractive,
AssistantInteractionContractDescriptor interactionContract,
String configurationClass,
JsonNode schema,
Block<?> exampleBlock) {
}
public record AssistantInteractionContractDescriptor(
String kind,
String messageField,
String completionField,
String historyField,
String responseField,
boolean supportsPartialResult) {
}
public record AssistantPromptFieldDescriptor(
String name,
String type,
@ -87,6 +97,7 @@ public class BlockCatalogService {
blockType.getName(),
blockType.getDescription(),
blockType.isUserInteractive(),
resolveInteractionContract(blockType),
configurationClass == null ? null : configurationClass.getName(),
schema,
exampleBlock);
@ -176,4 +187,26 @@ public class BlockCatalogService {
fields.forEachRemaining(entries::add);
return entries;
}
private AssistantInteractionContractDescriptor resolveInteractionContract(BlockType blockType) {
if ("ChatInteraction".equals(blockType.getName())) {
return new AssistantInteractionContractDescriptor(
"chat-session",
"message",
"response",
"history",
"response",
true);
}
if ("HumanInteractionBlock".equals(blockType.getName())) {
return new AssistantInteractionContractDescriptor(
"single-response",
null,
"output",
null,
"output",
false);
}
return null;
}
}

View File

@ -287,6 +287,7 @@ public class FlowAssistantService {
switch (descriptor.configurationType()) {
case "LLMBlockConfiguration" -> config.set("llmDescriptor", llmDescriptorNode(model));
case "HumanInteractiveBlockConfiguration" -> config.set("simulateWith", llmDescriptorNode(model));
case "ChatInteractionBlockConfiguration" -> config.set("llmDescriptor", llmDescriptorNode(model));
case "ConditionalBlockConfiguration" -> {
boolean useLlm = inferConditionalUseLlm(config);
config.put("useLlm", useLlm);

View File

@ -0,0 +1,69 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
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.NotNull;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.NonNull;
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class ChatInteractionBlockConfiguration extends BlockConfiguration<ChatInteractionBlockType> {
@NotNull
@Valid
@JsonProperty(required = true)
private LLMDescriptor llmDescriptor;
@Structural
@UiUniqueItemsBy("name")
@Valid
private List<ChatInteractionInput> inputs = List.of();
@Override
public Class<ChatInteractionBlockType> getBlockType() {
return ChatInteractionBlockType.class;
}
@Builder
public ChatInteractionBlockConfiguration(@NonNull String name, LLMDescriptor llmDescriptor,
List<ChatInteractionInput> inputs) {
super(name);
this.llmDescriptor = llmDescriptor;
this.inputs = inputs == null ? List.of() : List.copyOf(inputs);
}
public static ChatInteractionBlockConfiguration empty() {
ChatInteractionBlockConfiguration configuration = new ChatInteractionBlockConfiguration();
configuration.name = ChatInteractionBlockType.TYPE;
configuration.inputs = List.of();
return configuration;
}
@AssertTrue(message = "inputs must have unique names")
boolean areInputNamesUnique() {
if (inputs == null || inputs.isEmpty()) {
return true;
}
return inputs.stream()
.map(ChatInteractionInput::name)
.filter(name -> name != null && !name.isBlank())
.distinct()
.count() == inputs.stream()
.map(ChatInteractionInput::name)
.filter(name -> name != null && !name.isBlank())
.count();
}
}

View File

@ -0,0 +1,36 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
import it.cnr.isti.workflow.manager.configurations.annotations.UiLabel;
import it.cnr.isti.workflow.manager.ios.IOType;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record ChatInteractionInput(
@Size(max = 8)
@NotBlank String name,
@UiLabel("type")
@SchemaAllowedValues({ "TEXT" })
@JsonProperty("ioType")
@JsonAlias("type")
@NotNull IOType ioType,
boolean multiple) {
public ChatInteractionInput {
ioType = ioType == null ? IOType.TEXT : ioType;
}
public ChatInteractionInput(String name, IOType ioType) {
this(name, ioType, false);
}
@AssertTrue(message = "ChatInteraction inputs support only TEXT or TEXT[]")
boolean hasSupportedType() {
return ioType == IOType.TEXT;
}
}

View File

@ -14,6 +14,9 @@ import java.util.Map;
public class DynamicBlockConfigurationTypeResolver extends TypeIdResolverBase {
private static final String CHAT_INTERACTION_LEGACY_CONFIGURATION_ID = "ChatHumanInteractionBlockConfiguration";
private static final String CHAT_INTERACTION_CONFIGURATION_ID = "ChatInteractionBlockConfiguration";
private Map<String, Class<?>> idToClass = new HashMap<>();
private Map<Class<?>, String> classToId = new HashMap<>();
@ -44,6 +47,9 @@ public class DynamicBlockConfigurationTypeResolver extends TypeIdResolverBase {
@Override
public JavaType typeFromId(DatabindContext context, String id) {
if (CHAT_INTERACTION_LEGACY_CONFIGURATION_ID.equals(id)) {
id = CHAT_INTERACTION_CONFIGURATION_ID;
}
Class<?> clazz = idToClass.get(id);
if (clazz == null) {
throw new IllegalArgumentException("Unknown BlockConfiguration type id: " + id);

View File

@ -28,7 +28,14 @@ import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiDependency;
import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema;
import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput;
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiLabel;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionsFromNode;
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
import jakarta.validation.constraints.Size;
@Component
public class JsonSchemaProducer {
@ -50,14 +57,28 @@ public class JsonSchemaProducer {
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(type);
Map<Class<?>, Map<String, Structural>> structuralMap = collectStructuralMetadata(type);
Map<Class<?>, Map<String, UiDependency>> uiDependencyMap = collectUiDependencyMetadata(type);
Map<Class<?>, Map<String, UiEnabledWhen>> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type);
Map<Class<?>, Map<String, UiOptionsFromNode>> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type);
Map<Class<?>, Map<String, UiRequiredWhen>> uiRequiredWhenMap = collectUiRequiredWhenMetadata(type);
Map<Class<?>, Map<String, UiUniqueItemsBy>> uiUniqueItemsByMap = collectUiUniqueItemsByMetadata(type);
Map<Class<?>, Map<String, UiLabel>> uiLabelMap = collectUiLabelMetadata(type);
Map<Class<?>, Map<String, UiDescription>> uiDescriptionMap = collectUiDescriptionMetadata(type);
Map<Class<?>, Map<String, Size>> sizeMap = collectSizeMetadata(type);
Map<Class<?>, Map<String, SchemaAllowedValues>> schemaAllowedValuesMap = collectSchemaAllowedValuesMetadata(type);
Map<Class<?>, Map<String, ConfigurableAsInput>> configurableAsInputMap = collectConfigurableAsInputMetadata(type);
applyRetrieverMetadata(root, type, retrieverMap.getOrDefault(type, Map.of()));
applyDynamicSchemaMetadata(root, dynamicSchemaMap.getOrDefault(type, Map.of()));
applyLongTextMetadata(root, longTextMap.getOrDefault(type, Map.of()));
applyStructuralMetadata(root, structuralMap.getOrDefault(type, Map.of()));
applyUiDependencyMetadata(root, uiDependencyMap.getOrDefault(type, Map.of()));
applyUiEnabledWhenMetadata(root, uiEnabledWhenMap.getOrDefault(type, Map.of()));
applyUiOptionsFromNodeMetadata(root, uiOptionsFromNodeMap.getOrDefault(type, Map.of()));
applyUiRequiredWhenMetadata(root, uiRequiredWhenMap.getOrDefault(type, Map.of()));
applyUiUniqueItemsByMetadata(root, uiUniqueItemsByMap.getOrDefault(type, Map.of()));
applyUiLabelMetadata(root, uiLabelMap.getOrDefault(type, Map.of()));
applyUiDescriptionMetadata(root, uiDescriptionMap.getOrDefault(type, Map.of()));
applySizeMetadata(root, sizeMap.getOrDefault(type, Map.of()));
applySchemaAllowedValuesMetadata(root, schemaAllowedValuesMap.getOrDefault(type, Map.of()));
applyConfigurableAsInputMetadata(root, configurableAsInputMap.getOrDefault(type, Map.of()));
JsonNode definitionsNode = root.has("definitions") ? root.get("definitions") : root.get("$defs");
@ -67,7 +88,14 @@ public class JsonSchemaProducer {
metadataClasses.addAll(longTextMap.keySet());
metadataClasses.addAll(structuralMap.keySet());
metadataClasses.addAll(uiDependencyMap.keySet());
metadataClasses.addAll(uiEnabledWhenMap.keySet());
metadataClasses.addAll(uiOptionsFromNodeMap.keySet());
metadataClasses.addAll(uiRequiredWhenMap.keySet());
metadataClasses.addAll(uiUniqueItemsByMap.keySet());
metadataClasses.addAll(uiLabelMap.keySet());
metadataClasses.addAll(uiDescriptionMap.keySet());
metadataClasses.addAll(sizeMap.keySet());
metadataClasses.addAll(schemaAllowedValuesMap.keySet());
metadataClasses.addAll(configurableAsInputMap.keySet());
for (Entry<String, JsonNode> entry : iterable(definitions.fields())) {
if (!(entry.getValue() instanceof ObjectNode classSchema)) {
@ -80,7 +108,14 @@ public class JsonSchemaProducer {
applyLongTextMetadata(classSchema, longTextMap.get(matchedClass));
applyStructuralMetadata(classSchema, structuralMap.get(matchedClass));
applyUiDependencyMetadata(classSchema, uiDependencyMap.get(matchedClass));
applyUiEnabledWhenMetadata(classSchema, uiEnabledWhenMap.get(matchedClass));
applyUiOptionsFromNodeMetadata(classSchema, uiOptionsFromNodeMap.get(matchedClass));
applyUiRequiredWhenMetadata(classSchema, uiRequiredWhenMap.get(matchedClass));
applyUiUniqueItemsByMetadata(classSchema, uiUniqueItemsByMap.get(matchedClass));
applyUiLabelMetadata(classSchema, uiLabelMap.get(matchedClass));
applyUiDescriptionMetadata(classSchema, uiDescriptionMap.get(matchedClass));
applySizeMetadata(classSchema, sizeMap.get(matchedClass));
applySchemaAllowedValuesMetadata(classSchema, schemaAllowedValuesMap.get(matchedClass));
applyConfigurableAsInputMetadata(classSchema, configurableAsInputMap.get(matchedClass));
}
}
@ -253,6 +288,162 @@ public class JsonSchemaProducer {
return result;
}
private Map<Class<?>, Map<String, UiLabel>> collectUiLabelMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiLabel>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiLabel> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiLabel uiLabel = field.getAnnotation(UiLabel.class);
if (uiLabel != null) {
metadata.put(field.getName(), uiLabel);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiLabel uiLabel = component.getAnnotation(UiLabel.class);
if (uiLabel != null) {
metadata.put(component.getName(), uiLabel);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private Map<Class<?>, Map<String, UiDescription>> collectUiDescriptionMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiDescription>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiDescription> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiDescription uiDescription = field.getAnnotation(UiDescription.class);
if (uiDescription != null) {
metadata.put(field.getName(), uiDescription);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiDescription uiDescription = component.getAnnotation(UiDescription.class);
if (uiDescription != null) {
metadata.put(component.getName(), uiDescription);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private Map<Class<?>, Map<String, Size>> collectSizeMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, Size>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, Size> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
Size size = field.getAnnotation(Size.class);
if (size != null) {
metadata.put(field.getName(), size);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
Size size = component.getAnnotation(Size.class);
if (size != null) {
metadata.put(component.getName(), size);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private Map<Class<?>, Map<String, SchemaAllowedValues>> collectSchemaAllowedValuesMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, SchemaAllowedValues>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, SchemaAllowedValues> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
SchemaAllowedValues allowedValues = field.getAnnotation(SchemaAllowedValues.class);
if (allowedValues != null) {
metadata.put(field.getName(), allowedValues);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
SchemaAllowedValues allowedValues = component.getAnnotation(SchemaAllowedValues.class);
if (allowedValues != null) {
metadata.put(component.getName(), allowedValues);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private void applyDynamicSchemaMetadata(ObjectNode classSchema, Map<String, DynamicSchema> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
@ -404,6 +595,45 @@ public class JsonSchemaProducer {
return result;
}
private Map<Class<?>, Map<String, UiUniqueItemsBy>> collectUiUniqueItemsByMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiUniqueItemsBy>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiUniqueItemsBy> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiUniqueItemsBy annotation = field.getAnnotation(UiUniqueItemsBy.class);
if (annotation != null) {
metadata.put(field.getName(), annotation);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiUniqueItemsBy annotation = component.getAnnotation(UiUniqueItemsBy.class);
if (annotation != null) {
metadata.put(component.getName(), annotation);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private void applyUiDependencyMetadata(ObjectNode classSchema, Map<String, UiDependency> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
@ -437,6 +667,109 @@ public class JsonSchemaProducer {
}
}
private void applyUiUniqueItemsByMetadata(ObjectNode classSchema, Map<String, UiUniqueItemsBy> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiUniqueItemsBy> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
propertySchema.put("x-ui-unique-by", entry.getValue().value());
}
}
private void applyUiLabelMetadata(ObjectNode classSchema, Map<String, UiLabel> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiLabel> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
if (!entry.getValue().value().isBlank()) {
propertySchema.put("x-ui-label", entry.getValue().value());
}
}
}
private void applyUiDescriptionMetadata(ObjectNode classSchema, Map<String, UiDescription> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiDescription> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
if (!entry.getValue().value().isBlank()) {
propertySchema.put("x-ui-description", entry.getValue().value());
}
}
}
private void applySizeMetadata(ObjectNode classSchema, Map<String, Size> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, Size> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
Size size = entry.getValue();
if (size.min() > 0) {
propertySchema.put("minLength", size.min());
}
if (size.max() < Integer.MAX_VALUE) {
propertySchema.put("maxLength", size.max());
}
}
}
private void applySchemaAllowedValuesMetadata(ObjectNode classSchema, Map<String, SchemaAllowedValues> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, SchemaAllowedValues> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
ArrayNode enumValues = propertySchema.putArray("enum");
for (String value : entry.getValue().value()) {
enumValues.add(value);
}
}
}
private Map<Class<?>, Map<String, UiRequiredWhen>> collectUiRequiredWhenMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiRequiredWhen>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
@ -476,6 +809,140 @@ public class JsonSchemaProducer {
return result;
}
private Map<Class<?>, Map<String, UiOptionsFromNode>> collectUiOptionsFromNodeMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiOptionsFromNode>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiOptionsFromNode> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiOptionsFromNode annotation = field.getAnnotation(UiOptionsFromNode.class);
if (annotation != null) {
metadata.put(field.getName(), annotation);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiOptionsFromNode annotation = component.getAnnotation(UiOptionsFromNode.class);
if (annotation != null) {
metadata.put(component.getName(), annotation);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private void applyUiOptionsFromNodeMetadata(ObjectNode classSchema, Map<String, UiOptionsFromNode> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiOptionsFromNode> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
UiOptionsFromNode annotation = entry.getValue();
ObjectNode optionsFromNode = propertySchema.putObject("x-ui-options-from-node");
optionsFromNode.put("collection", annotation.collection());
optionsFromNode.put("valueField", annotation.valueField());
optionsFromNode.put("labelField", annotation.labelField());
}
}
private Map<Class<?>, Map<String, UiEnabledWhen>> collectUiEnabledWhenMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiEnabledWhen>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiEnabledWhen> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiEnabledWhen annotation = field.getAnnotation(UiEnabledWhen.class);
if (annotation != null) {
metadata.put(field.getName(), annotation);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiEnabledWhen annotation = component.getAnnotation(UiEnabledWhen.class);
if (annotation != null) {
metadata.put(component.getName(), annotation);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private void applyUiEnabledWhenMetadata(ObjectNode classSchema, Map<String, UiEnabledWhen> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiEnabledWhen> entry : metadata.entrySet()) {
JsonNode propNode = properties.get(entry.getKey());
if (!(propNode instanceof ObjectNode propertySchema)) {
continue;
}
UiEnabledWhen dependency = entry.getValue();
ObjectNode enabledWhen = propertySchema.putObject("x-ui-enabled-when");
enabledWhen.put("field", dependency.field());
if (!dependency.equals().isBlank()) {
enabledWhen.put("equals", dependency.equals());
}
if (dependency.equalsAny().length > 0) {
ArrayNode equalsAny = enabledWhen.putArray("in");
for (String value : dependency.equalsAny()) {
equalsAny.add(value);
}
}
if (dependency.present()) {
enabledWhen.put("present", true);
}
}
}
private void applyUiRequiredWhenMetadata(ObjectNode classSchema, Map<String, UiRequiredWhen> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;

View File

@ -0,0 +1,116 @@
package it.cnr.isti.workflow.manager.blocks.factories;
import java.util.List;
import java.util.Objects;
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.ChatInteractionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
@Component
public class ChatInteractionBlockFactory
implements BlockFactory<ChatInteractionBlockType, ChatInteractionBlockConfiguration> {
public static final String INTERACTION_FIELD = "message";
public static final String FINAL_RESPONSE_FIELD = "response";
public static final String RESPONSE_OUTPUT = "response";
public static final String HISTORY_OUTPUT = "history";
private static final List<IOCapability> RESPONSE_CAPABILITIES = List.of(
new IOCapability(IOCapabilityType.TEXT, false));
private static final List<IOCapability> HISTORY_CAPABILITIES = List.of(
new IOCapability(IOCapabilityType.TEXT, true));
@Autowired
private ChatInteractionBlockType blockType;
@Override
public Block<ChatInteractionBlockType> create(ChatInteractionBlockConfiguration configuration) {
validateConfiguration(configuration);
return Block.<ChatInteractionBlockType>builder()
.inputs(resolveInputs(configuration))
.output(IODescriptor.output(RESPONSE_OUTPUT, IOType.TEXT, false, RESPONSE_CAPABILITIES))
.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES))
.specificConfiguration(configuration)
.type(blockType)
.build();
}
@Override
public Block<ChatInteractionBlockType> createEmpty() {
return create(ChatInteractionBlockConfiguration.empty());
}
@Override
public Class<ChatInteractionBlockType> getBlockType() {
return ChatInteractionBlockType.class;
}
@Override
public List<IOCapability> supportedInputCapabilities() {
return List.of(
new IOCapability(IOCapabilityType.TEXT, false),
new IOCapability(IOCapabilityType.TEXT, true));
}
@Override
public List<IOCapability> supportedOutputCapabilities() {
return List.of(
new IOCapability(IOCapabilityType.TEXT, false),
new IOCapability(IOCapabilityType.TEXT, true));
}
private List<IODescriptor> resolveInputs(ChatInteractionBlockConfiguration configuration) {
if (configuration.getInputs() == null || configuration.getInputs().isEmpty()) {
return List.of();
}
return configuration.getInputs().stream()
.map(this::toDescriptor)
.toList();
}
private IODescriptor toDescriptor(ChatInteractionInput input) {
IOType type = input.ioType() == null ? IOType.TEXT : input.ioType();
return IODescriptor.input(input.name(), type, input.multiple(),
List.of(new IOCapability(toCapabilityType(type), input.multiple())));
}
private void validateConfiguration(ChatInteractionBlockConfiguration configuration) {
if (configuration == null || configuration.getInputs() == null || configuration.getInputs().isEmpty()) {
return;
}
long distinctNames = configuration.getInputs().stream()
.map(ChatInteractionInput::name)
.filter(Objects::nonNull)
.distinct()
.count();
long names = configuration.getInputs().stream()
.map(ChatInteractionInput::name)
.filter(Objects::nonNull)
.count();
if (distinctNames != names) {
throw new IllegalArgumentException("inputs must have unique names");
}
boolean unsupportedType = configuration.getInputs().stream()
.anyMatch(input -> input.ioType() != null && input.ioType() != IOType.TEXT);
if (unsupportedType) {
throw new IllegalArgumentException("ChatInteraction inputs support only TEXT or TEXT[]");
}
}
private IOCapabilityType toCapabilityType(IOType type) {
return switch (type) {
case FILE, CSV -> IOCapabilityType.FILE;
case TEXT -> IOCapabilityType.TEXT;
case ANY -> IOCapabilityType.ANY;
};
}
}

View File

@ -10,10 +10,18 @@ import org.springframework.stereotype.Component;
public class BlockTypes {
private static Map<String, BlockType> blockTypes = new HashMap<>();
private static final String CHAT_INTERACTION_LEGACY_TYPE = "ChatHumanInteraction";
public static BlockType get(String name) {
return blockTypes.get(name);
BlockType blockType = blockTypes.get(name);
if (blockType != null) {
return blockType;
}
if (CHAT_INTERACTION_LEGACY_TYPE.equals(name)) {
return blockTypes.get("ChatInteraction");
}
return null;
}
BlockTypes(List<BlockType> types) {

View File

@ -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.ChatInteractionBlockConfiguration;
@Component(ChatInteractionBlockType.TYPE)
public class ChatInteractionBlockType implements BlockType {
public static final String TYPE = "ChatInteraction";
@Override
public String getName() {
return TYPE;
}
@Override
public String getDescription() {
return "A human-interactive chat block backed by an LLM with persistent conversation history";
}
@Override
public boolean validate() {
return true;
}
@Override
public boolean isUserInteractive() {
return true;
}
@Override
public Class<? extends BlockConfiguration<?>> getBlockConfigurationClass() {
return ChatInteractionBlockConfiguration.class;
}
}

View File

@ -0,0 +1,12 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface SchemaAllowedValues {
String[] value();
}

View File

@ -0,0 +1,12 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface UiDescription {
String value();
}

View File

@ -0,0 +1,18 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface UiEnabledWhen {
String field();
String equals() default "";
String[] equalsAny() default {};
boolean present() default false;
}

View File

@ -0,0 +1,12 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface UiLabel {
String value();
}

View File

@ -0,0 +1,16 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface UiOptionsFromNode {
String collection();
String valueField() default "name";
String labelField() default "name";
}

View File

@ -0,0 +1,12 @@
package it.cnr.isti.workflow.manager.configurations.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface UiUniqueItemsBy {
String value();
}

View File

@ -1,12 +1,17 @@
package it.cnr.isti.workflow.manager.containers.configurations;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnore;
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionsFromNode;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterfaceResolver;
import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType;
import it.cnr.isti.workflow.manager.flows.model.FlowData;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@ -30,6 +35,8 @@ public class IteratorContainerConfiguration extends ContainerConfiguration<Itera
private FlowData subFlow;
@Structural
@UiEnabledWhen(field = "subFlow", present = true)
@UiOptionsFromNode(collection = "inputs", valueField = "name", labelField = "name")
@JsonProperty(required = false)
private String iterationInput;
@ -51,4 +58,24 @@ public class IteratorContainerConfiguration extends ContainerConfiguration<Itera
FlowData.builder().build(),
null);
}
@AssertTrue(message = "iterationInput is required when subFlow has at least one node")
@JsonIgnore
boolean isIterationInputPresentWhenSubFlowConfigured() {
return !hasSubFlowNodes() || (iterationInput != null && !iterationInput.isBlank());
}
@AssertTrue(message = "iterationInput must target an open non-multiple input of the subFlow")
@JsonIgnore
boolean isIterationInputValid() {
if (!hasSubFlowNodes() || iterationInput == null || iterationInput.isBlank()) {
return true;
}
return ContainerFlowInterfaceResolver.getExposedInputs(subFlow).stream()
.anyMatch(handle -> handle.publicName().equals(iterationInput) && !handle.handle().io().isMultiple());
}
private boolean hasSubFlowNodes() {
return subFlow != null && !subFlow.getNodes().isEmpty();
}
}

View File

@ -6,6 +6,7 @@ import it.cnr.isti.workflow.manager.blocks.Position;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterface;
import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterfaceResolver;
import it.cnr.isti.workflow.manager.containers.iresolvers.IteratorContainerInterfaceResolver;
import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType;
@ -22,13 +23,12 @@ public class IteratorContainerFactory implements ContainerFactory<IteratorContai
@Override
public Container<IteratorContainerType> create(IteratorContainerConfiguration configuration) {
ContainerFlowInterface exposedInterface = isEmpty(configuration)
? new ContainerFlowInterface(java.util.List.of(), java.util.List.of())
: IteratorContainerInterfaceResolver.resolve(configuration);
IteratorContainerConfiguration effectiveConfiguration = applyDefaultIterationInput(configuration);
ContainerFlowInterface exposedInterface = resolveInterface(effectiveConfiguration);
return Container.<IteratorContainerType>builder()
.inputs(exposedInterface.inputs())
.outputs(exposedInterface.outputs())
.specificConfiguration(configuration)
.specificConfiguration(effectiveConfiguration)
.type(containerType)
.position(DEFAULT_POSITION)
.build();
@ -49,4 +49,38 @@ public class IteratorContainerFactory implements ContainerFactory<IteratorContai
|| configuration.getSubFlow() == null
|| configuration.getSubFlow().getNodes().isEmpty();
}
private IteratorContainerConfiguration applyDefaultIterationInput(IteratorContainerConfiguration configuration) {
if (isEmpty(configuration)) {
return configuration;
}
java.util.List<it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterfaceResolver.ExposedHandle> openInputs =
ContainerFlowInterfaceResolver.getExposedInputs(configuration.getSubFlow());
if (openInputs.size() != 1) {
return configuration;
}
return IteratorContainerConfiguration.builder()
.name(configuration.getName())
.subFlow(configuration.getSubFlow())
.iterationInput(openInputs.getFirst().publicName())
.build();
}
private ContainerFlowInterface resolveInterface(IteratorContainerConfiguration configuration) {
if (isEmpty(configuration)) {
return new ContainerFlowInterface(java.util.List.of(), java.util.List.of());
}
if (configuration.getIterationInput() == null || configuration.getIterationInput().isBlank()) {
return new ContainerFlowInterface(
ContainerFlowInterfaceResolver.resolveGenericInputs(configuration.getSubFlow()),
ContainerFlowInterfaceResolver.resolveGenericOutputs(configuration.getSubFlow()));
}
try {
return IteratorContainerInterfaceResolver.resolve(configuration);
} catch (IllegalArgumentException exception) {
return new ContainerFlowInterface(
ContainerFlowInterfaceResolver.resolveGenericInputs(configuration.getSubFlow()),
ContainerFlowInterfaceResolver.resolveGenericOutputs(configuration.getSubFlow()));
}
}
}

View File

@ -33,16 +33,21 @@ public final class ContainerFlowInterfaceResolver {
public static ContainerFlowInterface resolve(GenericContainerConfiguration configuration) {
FlowData subFlow = configuration == null ? null : configuration.getSubFlow();
List<ExposedHandle> openInputs = getExposedInputs(subFlow);
List<ExposedHandle> openOutputs = getExposedOutputs(subFlow);
List<IODescriptor> inputs = openInputs.stream()
.map(handle -> cloneDescriptor(handle.publicName(), handle.handle().io()))
.toList();
List<IODescriptor> outputs = openOutputs.stream()
.map(handle -> cloneDescriptor(handle.publicName(), handle.handle().io()))
.toList();
return new ContainerFlowInterface(resolveGenericInputs(subFlow), resolveGenericOutputs(subFlow));
}
return new ContainerFlowInterface(inputs, outputs);
public static List<IODescriptor> resolveGenericInputs(FlowData subFlow) {
List<ExposedHandle> openInputs = getExposedInputs(subFlow);
return openInputs.stream()
.map(handle -> cloneDescriptor(handle.publicName(), handle.handle().io()))
.toList();
}
public static List<IODescriptor> resolveGenericOutputs(FlowData subFlow) {
List<ExposedHandle> openOutputs = getExposedOutputs(subFlow);
return openOutputs.stream()
.map(handle -> cloneDescriptor(handle.publicName(), handle.handle().io()))
.toList();
}
public static List<ExposedHandle> getExposedInputs(FlowData subFlow) {

View File

@ -2,10 +2,8 @@ package it.cnr.isti.workflow.manager.containers.iresolvers;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
@ -49,11 +47,7 @@ public final class IteratorContainerInterfaceResolver {
"IteratorContainer iterationInput must target a non-multiple subFlow input: " + iterationInput);
}
String iteratedPublicName = uniquePluralizedName(iteratedHandle.publicName(),
exposedInputs.stream()
.map(ContainerFlowInterfaceResolver.ExposedHandle::publicName)
.filter(name -> !name.equals(iteratedHandle.publicName()))
.toList());
String iteratedPublicName = iteratedHandle.publicName();
List<ResolvedInput> resolvedInputs = new ArrayList<>();
for (ContainerFlowInterfaceResolver.ExposedHandle exposedHandle : exposedInputs) {
@ -91,49 +85,6 @@ public final class IteratorContainerInterfaceResolver {
return new IODescriptor(name, source.getType(), multiple, source.getValueKinds());
}
private static String uniquePluralizedName(String name, List<String> reservedNames) {
Set<String> reserved = new LinkedHashSet<>(reservedNames);
String candidate = pluralize(name);
if (!reserved.contains(candidate)) {
return candidate;
}
candidate = name + "List";
if (!reserved.contains(candidate)) {
return candidate;
}
int counter = 2;
while (reserved.contains(candidate + counter)) {
counter++;
}
return candidate + counter;
}
private static String pluralize(String name) {
int separatorIndex = name.lastIndexOf('.');
if (separatorIndex >= 0) {
return name.substring(0, separatorIndex + 1) + pluralizeSegment(name.substring(separatorIndex + 1));
}
return pluralizeSegment(name);
}
private static String pluralizeSegment(String segment) {
if (segment.endsWith("y") && segment.length() > 1 && !isVowel(segment.charAt(segment.length() - 2))) {
return segment.substring(0, segment.length() - 1) + "ies";
}
if (segment.endsWith("s") || segment.endsWith("x") || segment.endsWith("z")
|| segment.endsWith("ch") || segment.endsWith("sh")) {
return segment + "es";
}
return segment + "s";
}
private static boolean isVowel(char character) {
return switch (Character.toLowerCase(character)) {
case 'a', 'e', 'i', 'o', 'u' -> true;
default -> false;
};
}
public record Resolution(
List<ResolvedInput> resolvedInputs,
List<ResolvedOutput> resolvedOutputs,

View File

@ -29,6 +29,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
public class BlocksController {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(BlocksController.class);
private static final String CHAT_INTERACTION_LEGACY_TYPE = "ChatHumanInteraction";
@Autowired
Map<String, BlockType> blockTypes;
@ -43,6 +44,7 @@ public class BlocksController {
String type,
String description,
boolean userInteractive,
InteractionContractDescriptor interactionContract,
boolean hasExampleBlock,
String exampleBlockEndpoint,
String configurationType,
@ -50,6 +52,15 @@ public class BlocksController {
Object schema) {
}
public record InteractionContractDescriptor(
String kind,
String messageField,
String completionField,
String historyField,
String responseField,
boolean supportsPartialResult) {
}
@GetMapping("types")
@Operation(summary = "Get block types", description = "Returns all block types with their configuration descriptor and JSON schema.")
@ -66,7 +77,7 @@ public class BlocksController {
@GetMapping("/types/{type}/configuration/descriptor")
@Operation(summary = "Get block configuration descriptor by type", description = "Returns the descriptor and JSON schema for the requested block type.")
public BlockConfigurationDescriptor getConfigurationDescriptorForType(@PathVariable String type) {
BlockType blockType = blockTypes.get(type);
BlockType blockType = resolveBlockType(type);
if (blockType == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Block type not found: " + type);
}
@ -77,7 +88,7 @@ public class BlocksController {
@GetMapping("/types/{type}/example")
@Operation(summary = "Get block example by type", description = "Returns an empty example block for the requested block type, intended for UI scaffolding.")
public <T extends BlockType> Block<T> getExampleForType(@PathVariable String type) {
BlockType blockType = blockTypes.get(type);
BlockType blockType = resolveBlockType(type);
if (blockType == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Block type not found: " + type);
}
@ -97,6 +108,7 @@ public class BlocksController {
blockType.getName(),
blockType.getDescription(),
blockType.isUserInteractive(),
resolveInteractionContract(blockType),
true,
getExampleEndpoint(blockType),
null,
@ -109,6 +121,7 @@ public class BlocksController {
blockType.getName(),
blockType.getDescription(),
blockType.isUserInteractive(),
resolveInteractionContract(blockType),
true,
getExampleEndpoint(blockType),
configurationClass.getSimpleName(),
@ -121,6 +134,39 @@ public class BlocksController {
return "/blocks/types/" + blockType.getName() + "/example";
}
private InteractionContractDescriptor resolveInteractionContract(BlockType blockType) {
if ("ChatInteraction".equals(blockType.getName())) {
return new InteractionContractDescriptor(
"chat-session",
"message",
"response",
"history",
"response",
true);
}
if ("HumanInteractionBlock".equals(blockType.getName())) {
return new InteractionContractDescriptor(
"single-response",
null,
"output",
null,
"output",
false);
}
return null;
}
private BlockType resolveBlockType(String type) {
BlockType blockType = blockTypes.get(type);
if (blockType != null) {
return blockType;
}
if (CHAT_INTERACTION_LEGACY_TYPE.equals(type)) {
return blockTypes.get("ChatInteraction");
}
return null;
}
@SuppressWarnings("unchecked")
@PostMapping
@SecurityRequirement(name = "bearerAuth")

View File

@ -2,11 +2,8 @@ package it.cnr.isti.workflow.manager.controllers;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -31,17 +28,24 @@ import it.cnr.isti.workflow.manager.ios.IODescriptor;
@RequestMapping("/containers")
public class ContainersController {
@Autowired
Map<String, ContainerType> containerTypes;
@Autowired
List<ContainerFactory<?, ?>> containerFactories;
@Autowired
JsonSchemaProducer schemaProducer;
@Autowired
ContainerSubFlowValidator containerSubFlowValidator;
private final ContainerSubFlowValidator containerSubFlowValidator;
public ContainersController(
Map<String, ContainerType> containerTypes,
List<ContainerFactory<?, ?>> containerFactories,
JsonSchemaProducer schemaProducer,
ContainerSubFlowValidator containerSubFlowValidator) {
this.containerTypes = containerTypes;
this.containerFactories = containerFactories;
this.schemaProducer = schemaProducer;
this.containerSubFlowValidator = containerSubFlowValidator;
}
public record ContainerConfigurationDescriptor(
String type,
@ -115,7 +119,6 @@ public class ContainersController {
.filter(f -> f.getContainerType().equals(configuration.getContainerType()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Container factory not found for type: " + configuration.getContainerType()));
validateConfiguration(configuration, factory);
return factory.create(configuration);
}
@ -135,28 +138,6 @@ public class ContainersController {
private ContainerHandleDescriptor toHandle(ContainerFlowInterfaceResolver.OpenHandle handle) {
return new ContainerHandleDescriptor(handle.blockId(), handle.blockName(), handle.io());
}
private <T extends ContainerType, C extends ContainerConfiguration<T>> void validateConfiguration(C configuration,
ContainerFactory<T, C> factory) {
if (configuration == null) {
return;
}
ContainerSubFlowValidator.ValidationResult validation = containerSubFlowValidator
.validate(configuration.getSubFlow());
if (!validation.valid()) {
String message = validation.errors().stream()
.map(error -> error.message())
.collect(Collectors.joining(", "));
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid parameter subFlow: " + message);
}
try {
factory.create(configuration);
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
}
}
private ContainerConfigurationDescriptor toDescriptor(ContainerType containerType) {
Class<? extends ContainerConfiguration<?>> configurationClass = containerType.getContainerConfigurationClass();
return new ContainerConfigurationDescriptor(

View File

@ -26,6 +26,9 @@ public class ExecutionContext implements ExecutionListener {
@Getter()
private Map<FieldKey, Object> result = new HashMap<>();
@Getter()
private Map<FieldKey, Object> partialResult = new HashMap<>();
Long startTime = null;
Long endTime = null;
@ -59,6 +62,14 @@ public class ExecutionContext implements ExecutionListener {
this.result.put(new FieldKey(nodeId, key), value);
}
private void addPartialResult(String nodeId, String key, Object value) {
this.partialResult.put(new FieldKey(nodeId, key), value);
}
private void clearPartialResults(String nodeId) {
this.partialResult.keySet().removeIf(key -> key.nodeId().equals(nodeId));
}
private void addInput(String nodeId, String key, Object value) {
this.inputs.put(new FieldKey(nodeId, key), value);
}
@ -74,6 +85,7 @@ public class ExecutionContext implements ExecutionListener {
@Override
public void completed(String id, Map<String, Object> result) {
logger.info("Step " + id + " completed with result: " + result);
clearPartialResults(id);
Step<?> completedStep = this.steps.get(id);
completedStep.getOutputs().forEach(output -> {
String outputName = output.getDescriptor().getName();
@ -113,7 +125,9 @@ public class ExecutionContext implements ExecutionListener {
public synchronized void paused(String id) {
logger.info("Step " + id + " paused");
synchronized (this.waitingSteps) {
this.waitingSteps.add(id);
if (!this.waitingSteps.contains(id)) {
this.waitingSteps.add(id);
}
if (this.status != ExecutionStatus.WAITING) {
this.setStatus(ExecutionStatus.WAITING);
}
@ -131,6 +145,12 @@ public class ExecutionContext implements ExecutionListener {
}
}
@Override
public synchronized void partialUpdated(String id, Map<String, Object> partialResult) {
clearPartialResults(id);
partialResult.forEach((key, value) -> addPartialResult(id, key, value));
}
protected void setInput(String stepId, String inputName, Object value) {
Step<?> step = this.steps.get(stepId);
if (step == null)

View File

@ -15,4 +15,6 @@ public interface ExecutionListener {
void paused(String id);
void resumed(String id);
void partialUpdated(String id, Map<String, Object> partialResult);
}

View File

@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
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.ChatInteractionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
@ -147,6 +148,9 @@ public class ExecutionsService {
if (configuration instanceof HumanInteractiveBlockConfiguration humanConfiguration) {
return humanConfiguration.getSimulateWith();
}
if (configuration instanceof ChatInteractionBlockConfiguration chatConfiguration) {
return chatConfiguration.getLlmDescriptor();
}
if (configuration instanceof ConditionalBlockConfiguration conditionalConfiguration
&& conditionalConfiguration.isUseLlm()) {
return conditionalConfiguration.getLlmDescriptor();

View File

@ -0,0 +1,19 @@
package it.cnr.isti.workflow.manager.executions;
import java.util.Map;
public record InteractionResult(boolean completed, Map<String, Object> outputs, Map<String, Object> partialResults) {
public InteractionResult {
outputs = outputs == null ? Map.of() : Map.copyOf(outputs);
partialResults = partialResults == null ? Map.of() : Map.copyOf(partialResults);
}
public static InteractionResult completed(Map<String, Object> outputs) {
return new InteractionResult(true, outputs, Map.of());
}
public static InteractionResult partial(Map<String, Object> partialResults) {
return new InteractionResult(false, Map.of(), partialResults);
}
}

View File

@ -5,6 +5,7 @@ import java.util.Map;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.executions.InteractionResult;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.flows.model.FlowNode;
@ -32,4 +33,12 @@ public final class NodeExecutors {
}
throw new IllegalStateException("No executor found for node type " + node.getClass().getName());
}
public static InteractionResult interact(FlowNode node, List<Input> inputs, Map<String, Object> interaction,
Map<String, Object> partialResults, Map<String, Object> authorizations) {
if (node instanceof Block<?> block) {
return BlockExecutors.get(block.getType()).interact((Block) block, inputs, interaction, partialResults, authorizations);
}
throw new IllegalStateException("No interactive executor found for node type " + node.getClass().getName());
}
}

View File

@ -4,6 +4,7 @@ import java.util.List;
import java.util.Map;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.executions.InteractionResult;
import it.cnr.isti.workflow.manager.blocks.types.BlockType;
import it.cnr.isti.workflow.manager.executions.steps.Input;
@ -22,4 +23,9 @@ public interface BlockExecutor<B extends BlockType> {
throw new UnsupportedOperationException("Simulation not supported for this block type");
}
default InteractionResult interact(Block<B> block, List<Input> inputs, Map<String, Object> interaction,
Map<String, Object> partialResults, Map<String, Object> authorizations) {
throw new UnsupportedOperationException("Interaction not supported for this block type");
}
}

View File

@ -0,0 +1,168 @@
package it.cnr.isti.workflow.manager.executions.executors.blocks;
import java.util.ArrayList;
import java.util.Collection;
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.ChatInteractionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.factories.ChatInteractionBlockFactory;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.executions.InteractionResult;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlockType> {
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
throw new UnsupportedOperationException("ChatInteraction blocks require user interaction.");
}
@Override
public Class<ChatInteractionBlockType> getBlockType() {
return ChatInteractionBlockType.class;
}
@Override
public boolean isInteractive() {
return true;
}
@Override
public InteractionResult interact(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations) {
ChatInteractionBlockConfiguration configuration =
(ChatInteractionBlockConfiguration) block.getSpecificConfiguration();
LLMDescriptor llmDescriptor = configuration.getLlmDescriptor();
LLMProvider llmProvider = resolveProvider(llmDescriptor.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: " + llmDescriptor.provider());
}
if (interaction.containsKey(ChatInteractionBlockFactory.INTERACTION_FIELD)) {
Object messageValue = interaction.get(ChatInteractionBlockFactory.INTERACTION_FIELD);
if (!(messageValue instanceof String message) || !StringUtils.hasText(message)) {
throw new IllegalArgumentException("Missing interaction value for field: "
+ ChatInteractionBlockFactory.INTERACTION_FIELD);
}
String resolvedMessage = resolvePlaceholders(message, inputs);
List<String> history = existingHistory(partialResults);
List<ChatMessage> messages = history.stream()
.map(this::parseHistoryLine)
.collect(Collectors.toList());
messages.add(new ChatMessage(ChatMessage.Role.USER, resolvedMessage));
String assistantResponse = llmProvider.requiresAuthorization()
? llmProvider.chat(llmDescriptor.model(), List.copyOf(messages), String.valueOf(authorizations.get(authKey)))
: llmProvider.chat(llmDescriptor.model(), List.copyOf(messages));
List<String> updatedHistory = new ArrayList<>(history);
updatedHistory.add(formatConversationLine(ChatMessage.Role.USER, resolvedMessage));
updatedHistory.add(formatConversationLine(ChatMessage.Role.ASSISTANT, assistantResponse));
return InteractionResult.partial(Map.of(
ChatInteractionBlockFactory.RESPONSE_OUTPUT, assistantResponse,
ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(updatedHistory)));
}
if (interaction.containsKey(ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD)) {
Object responseValue = interaction.get(ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD);
if (!(responseValue instanceof String response) || !StringUtils.hasText(response)) {
throw new IllegalArgumentException("Missing interaction value for field: "
+ ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD);
}
List<String> history = existingHistory(partialResults);
return InteractionResult.completed(Map.of(
ChatInteractionBlockFactory.RESPONSE_OUTPUT, response,
ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(history)));
}
throw new IllegalArgumentException("Unsupported interaction field for ChatInteraction");
}
private LLMProvider resolveProvider(String providerName) {
LLMProvider llmProvider = llmProviders.get(providerName);
if (llmProvider == null) {
llmProvider = llmProviders.values().stream()
.filter(candidate -> candidate.getName().equals(providerName))
.findFirst()
.orElse(null);
}
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + providerName);
}
return llmProvider;
}
private String formatConversationLine(ChatMessage.Role role, String content) {
return "[" + role.name() + "] " + content;
}
@SuppressWarnings("unchecked")
private List<String> existingHistory(Map<String, Object> partialResults) {
Object value = partialResults.get(ChatInteractionBlockFactory.HISTORY_OUTPUT);
if (value instanceof List<?> list) {
return ((List<Object>) list).stream().map(String::valueOf).toList();
}
return List.of();
}
private ChatMessage parseHistoryLine(String line) {
if (!StringUtils.hasText(line)) {
return new ChatMessage(ChatMessage.Role.USER, "");
}
if (line.startsWith("[") && line.contains("]")) {
int endRole = line.indexOf(']');
String rawRole = line.substring(1, endRole);
String content = line.substring(endRole + 1).stripLeading();
try {
return new ChatMessage(ChatMessage.Role.valueOf(rawRole), content);
} catch (IllegalArgumentException ignored) {
return new ChatMessage(ChatMessage.Role.USER, line);
}
}
return new ChatMessage(ChatMessage.Role.USER, line);
}
private String resolvePlaceholders(String template, List<Input> inputs) {
if (!StringUtils.hasText(template)) {
return template;
}
Map<String, String> values = new LinkedHashMap<>();
for (Input input : inputs) {
values.put(input.getDescriptor().getName(), formatInputValue(input.getValue()));
}
String resolved = template;
for (Map.Entry<String, String> entry : values.entrySet()) {
resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue());
}
return resolved;
}
private String formatInputValue(Object value) {
if (value instanceof Collection<?> collection) {
return collection.stream()
.map(item -> item == null ? "null" : item.toString())
.collect(Collectors.joining(System.lineSeparator()));
}
return value == null ? "null" : value.toString();
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.util.StringUtils;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
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;
@ -75,4 +76,14 @@ public class HumanInteractionExecutor implements BlockExecutor<HumanInteractionB
throw new UnsupportedOperationException("HumanInteractionExecutor does not support simulation.");
}
@Override
public InteractionResult interact(Block<HumanInteractionBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations) {
Object value = interaction.get("output");
if (value == null) {
throw new IllegalArgumentException("Missing interaction value for field: output");
}
return InteractionResult.completed(Map.of("output", value));
}
}

View File

@ -1,6 +1,7 @@
package it.cnr.isti.workflow.manager.executions.steps;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
@ -38,6 +39,10 @@ public class Step<N extends FlowNode> implements InputListener {
@Getter
private final List<Output> outputs = new ArrayList<>();
@Getter
@JsonIgnore
private final Map<String, Object> partialResults = new HashMap<>();
@Getter
private StepStatus status = StepStatus.WAITING_FOR_INPUT;
@ -150,15 +155,27 @@ public class Step<N extends FlowNode> implements InputListener {
logger.info("Resuming step " + this.id + " of node " + this.node.getName());
this.status = StepStatus.RUNNING;
listener.resumed(this.id);
var interactionResult = NodeExecutors.interact(this.node, this.inputs, providedOutputs, Map.copyOf(this.partialResults), authorizations);
this.partialResults.clear();
this.partialResults.putAll(interactionResult.partialResults());
listener.partialUpdated(this.id, this.partialResults);
if (!interactionResult.completed()) {
this.status = StepStatus.WAITING_FOR_INTERACTION;
listener.paused(this.id);
return;
}
Map<String, Object> resolvedOutputs = interactionResult.outputs();
for (Output output : this.outputs) {
if (providedOutputs.containsKey(output.getDescriptor().getName())) {
output.setValue(providedOutputs.get(output.getDescriptor().getName()));
if (resolvedOutputs.containsKey(output.getDescriptor().getName())) {
output.setValue(resolvedOutputs.get(output.getDescriptor().getName()));
} else {
throw new IllegalArgumentException("Missing output value for: " + output.getDescriptor().getName());
}
}
this.partialResults.clear();
this.status = StepStatus.COMPLETED;
listener.completed(this.id, providedOutputs);
listener.completed(this.id, resolvedOutputs);
}
private void refreshStateFromInputs() {

View File

@ -115,26 +115,23 @@ public class FlowDataValidator implements ConstraintValidator<ValidFlowStructure
ContainerConfiguration<?> containerConfiguration = container.getSpecificConfiguration();
FlowData subFlow = containerConfiguration.getSubFlow();
if (subFlow == null || subFlow.getNodes().isEmpty()) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Container subFlow must contain at least one node"));
}
if (subFlow != null && !subFlow.getNodes().isEmpty()) {
List<FlowNode> nestedNodes = subFlow.getNodes();
if (nestedNodes.stream().anyMatch(candidate -> candidate instanceof Container<?>)) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Nested containers are not supported"));
}
if (nestedNodes.stream().anyMatch(FlowNode::isUserInteractive)) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Interactive blocks inside containers are not supported yet"));
}
List<FlowNode> nestedNodes = subFlow.getNodes();
if (nestedNodes.stream().anyMatch(candidate -> candidate instanceof Container<?>)) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Nested containers are not supported"));
}
if (nestedNodes.stream().anyMatch(FlowNode::isUserInteractive)) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Interactive blocks inside containers are not supported yet"));
}
try {
validateFlowData(subFlow);
} catch (FlowValidationException e) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Invalid container subFlow: " + e.getMessage()));
try {
validateFlowData(subFlow);
} catch (FlowValidationException e) {
throw validationError(error("container", container.getId(), "specificConfiguration.subFlow",
"Invalid container subFlow: " + e.getMessage()));
}
}
final Container<?> canonicalContainer;

View File

@ -67,6 +67,33 @@ public class FlowExecutionValidator {
if (container.getSpecificConfiguration() instanceof ContainerConfiguration<?> containerConfiguration
&& containerConfiguration.getSubFlow() != null) {
FlowData subFlow = containerConfiguration.getSubFlow();
if (subFlow.getNodes().isEmpty()) {
errors.add(new ValidationError(
"container",
container.getId(),
"specificConfiguration.subFlow",
"Container subFlow must contain at least one node"));
} else {
subFlow.getNodes().stream()
.filter(candidate -> candidate instanceof Container<?>)
.findFirst()
.ifPresent(candidate -> errors.add(new ValidationError(
"container",
container.getId(),
"specificConfiguration.subFlow",
"Nested containers are not supported")));
subFlow.getNodes().stream()
.filter(FlowNode::isUserInteractive)
.findFirst()
.ifPresent(candidate -> errors.add(new ValidationError(
"container",
container.getId(),
"specificConfiguration.subFlow",
"Interactive nodes inside containers are not supported yet")));
}
errors.addAll(collectErrors(containerConfiguration.getSubFlow()).stream()
.map(error -> new ValidationError(
"container",

View File

@ -0,0 +1,17 @@
package it.cnr.isti.workflow.manager.llms;
import java.util.Objects;
public record ChatMessage(Role role, String content) {
public ChatMessage {
role = role == null ? Role.USER : role;
content = Objects.requireNonNullElse(content, "");
}
public enum Role {
SYSTEM,
USER,
ASSISTANT
}
}

View File

@ -1,6 +1,9 @@
package it.cnr.isti.workflow.manager.llms.providers;
import java.util.List;
import java.util.stream.Collectors;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
public interface LLMProvider {
@ -12,6 +15,16 @@ public interface LLMProvider {
return generate(model, prompt);
}
default String chat(String model, List<ChatMessage> messages) {
return generate(model, flattenConversation(messages));
}
default String chat(String model, List<ChatMessage> messages, String authorization) {
return requiresAuthorization()
? generate(model, flattenConversation(messages), authorization)
: chat(model, messages);
}
default boolean requiresAuthorization() {
return false;
}
@ -28,4 +41,10 @@ public interface LLMProvider {
return "Provider authorization value (API key or token).";
}
private static String flattenConversation(List<ChatMessage> messages) {
return messages == null ? "" : messages.stream()
.map(message -> "[" + message.role().name() + "] " + message.content())
.collect(Collectors.joining(System.lineSeparator() + System.lineSeparator()));
}
}

View File

@ -14,6 +14,8 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
@Service
public class GeminiLLMProvider implements LLMProvider {
@ -40,23 +42,22 @@ public class GeminiLLMProvider implements LLMProvider {
}
@Override
public String generate(String model, String prompt, String authorization) {
Objects.requireNonNull(prompt, "prompt cannot be null");
Objects.requireNonNull(model, "model cannot be null");
public String chat(String model, List<ChatMessage> messages, String authorization) {
Objects.requireNonNull(messages, "messages cannot be null");
Objects.requireNonNull(authorization, "authorization cannot be null");
if (!MODELS.contains(model))
throw new IllegalArgumentException("Model not supported: " + model);
Map<String, Object> requestBody = Map.of(
"contents", List.of( // Usa List.of invece di new Object[]
Map.of(
"role", "user",
"parts", List.of(Map.of("text", prompt)) // Usa List.of per "parts"
)));
"contents", messages.stream()
.map(message -> Map.of(
"role", toGeminiRole(message.role()),
"parts", List.of(Map.of("text", message.content()))))
.toList());
WebClient webClient = webClientBuilder.baseUrl(GEMINI_URL).build();
Mono<String> result = webClient.post()
.uri(uriBuilder -> uriBuilder.path("v1beta/models/gemini-2.0-flash:generateContent")
.uri(uriBuilder -> uriBuilder.path("v1beta/models/" + model + ":generateContent")
.queryParam("key", authorization)
.build())
.contentType(MediaType.APPLICATION_JSON)
@ -79,6 +80,11 @@ public class GeminiLLMProvider implements LLMProvider {
return result.block();
}
@Override
public String generate(String model, String prompt, String authorization) {
return chat(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), authorization);
}
@Override
public boolean requiresAuthorization() {
return true;
@ -94,4 +100,11 @@ public class GeminiLLMProvider implements LLMProvider {
return MODELS;
}
private String toGeminiRole(ChatMessage.Role role) {
return switch (role) {
case SYSTEM, USER -> "user";
case ASSISTANT -> "model";
};
}
}

View File

@ -12,7 +12,9 @@ import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
import it.cnr.isti.workflow.manager.llms.providers.ollama.response.ChatResponse;
import it.cnr.isti.workflow.manager.llms.providers.ollama.response.GenerateResponse;
import it.cnr.isti.workflow.manager.llms.providers.ollama.response.ModelInfo;
import it.cnr.isti.workflow.manager.llms.providers.ollama.response.ModelResponse;
@ -74,6 +76,40 @@ public class InternalOllamaLLMProvider implements LLMProvider {
return parseGenerateResponse(result.block(), mapper);
}
@Override
public String chat(String model, List<ChatMessage> messages) {
Objects.requireNonNull(messages, "messages cannot be null");
Objects.requireNonNull(model, "model cannot be null");
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> bodyMap = Map.of(
"model", model,
"messages", messages.stream()
.map(message -> Map.of(
"role", message.role().name().toLowerCase(),
"content", message.content()))
.toList(),
"stream", false);
WebClient webClient = webClientBuilder.baseUrl(this.ollamaURL).build();
Mono<String> result = webClient.post()
.uri(uriBuilder -> uriBuilder.pathSegment("chat").build())
.header("Authorization", "Bearer " + ollamaKey)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(bodyMap)
.retrieve()
.onStatus(
status -> status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.defaultIfEmpty("Error: server without body")
.flatMap(body -> Mono.error(new RuntimeException(
"Error 5xx: " + body))))
.bodyToMono(String.class)
.timeout(Duration.ofMinutes(2));
return parseChatResponse(result.block(), mapper);
}
private String parseGenerateResponse(String responseBody, ObjectMapper mapper) {
if (responseBody == null || responseBody.isBlank()) {
throw new RuntimeException("Empty response body from Ollama generate endpoint");
@ -96,6 +132,28 @@ public class InternalOllamaLLMProvider implements LLMProvider {
return trimmedResponse;
}
private String parseChatResponse(String responseBody, ObjectMapper mapper) {
if (responseBody == null || responseBody.isBlank()) {
throw new RuntimeException("Empty response body from Ollama chat endpoint");
}
String trimmedResponse = responseBody.trim();
if (trimmedResponse.startsWith("{")) {
try {
ChatResponse response = mapper.readValue(trimmedResponse, ChatResponse.class);
if (response.getMessage() == null || response.getMessage().getContent() == null) {
throw new RuntimeException("Missing 'message.content' field in Ollama chat response");
}
return response.getMessage().getContent();
} catch (Exception e) {
throw new RuntimeException("Unable to parse Ollama chat response", e);
}
}
log.debug("Ollama chat endpoint returned text/plain response");
return trimmedResponse;
}
public List<String> getRegisteredModels() {
WebClient webClient = webClientBuilder.baseUrl(this.ollamaURL).build();

View File

@ -0,0 +1,13 @@
package it.cnr.isti.workflow.manager.llms.providers.ollama.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.NoArgsConstructor;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
@NoArgsConstructor
public class ChatResponse {
private ChatResponseMessage message;
}

View File

@ -0,0 +1,14 @@
package it.cnr.isti.workflow.manager.llms.providers.ollama.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.NoArgsConstructor;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
@NoArgsConstructor
public class ChatResponseMessage {
private String role;
private String content;
}

View File

@ -353,5 +353,320 @@
}
]
}
},
{
"name": "Resume Analysis Pipeline",
"description": "Analyze a curriculum vitae through multiple LLM steps and produce a final recruiter-oriented assessment without human interaction.",
"owner": "testuser",
"createdAt": "2026-03-18T09:30:00",
"lastUpdateAt": "2026-03-18T09:30:00",
"published": false,
"finalized": false,
"flow": {
"blocks": [
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da1001",
"position": {
"x": -1200,
"y": 0
},
"name": "normalize-resume",
"inputs": [
{
"name": "resume",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
},
{
"type": "TEXT",
"multiple": true
}
]
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "normalize-resume",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "You are a recruiting analyst. Normalize the following curriculum vitae into a clean structured profile with sections for identity, role, years of experience, skills, experiences, education, certifications, and languages. Resume: ${{resume}}"
},
"typeName": "LLMBlock"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da1002",
"position": {
"x": -600,
"y": -220
},
"name": "extract-core-skills",
"inputs": [
{
"name": "profile",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
},
{
"type": "TEXT",
"multiple": true
}
]
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "extract-core-skills",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "From this normalized candidate profile, extract the 10 most relevant professional skills for recruiting evaluation. For each skill include strength level, evidence, and business relevance. Profile: ${{profile}}"
},
"typeName": "LLMBlock"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da1003",
"position": {
"x": -600,
"y": 0
},
"name": "summarize-experience",
"inputs": [
{
"name": "profile",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
},
{
"type": "TEXT",
"multiple": true
}
]
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "summarize-experience",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "Summarize the candidate's professional experience from this normalized profile. Highlight seniority, domains, progression, leadership, and most relevant achievements. Profile: ${{profile}}"
},
"typeName": "LLMBlock"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da1004",
"position": {
"x": -600,
"y": 220
},
"name": "detect-risks-and-gaps",
"inputs": [
{
"name": "profile",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
},
{
"type": "TEXT",
"multiple": true
}
]
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "detect-risks-and-gaps",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "Review this normalized candidate profile and identify hiring risks, possible gaps, unclear claims, missing evidence, and follow-up questions a recruiter should ask. Profile: ${{profile}}"
},
"typeName": "LLMBlock"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da1005",
"position": {
"x": 100,
"y": 0
},
"name": "build-final-assessment",
"inputs": [
{
"name": "skillsAnalysis",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
},
{
"name": "experienceSummary",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
},
{
"name": "riskAnalysis",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"outputs": [
{
"name": "response",
"type": "TEXT",
"multiple": false,
"valueKinds": [
{
"type": "TEXT",
"multiple": false
}
]
}
],
"specificConfiguration": {
"type": "LLMBlockConfiguration",
"name": "build-final-assessment",
"llmDescriptor": {
"provider": "InternalOllama",
"model": "gemma:7b"
},
"prompt": "Create a recruiter-facing final assessment using these three analyses. Return: executive summary, strengths, concerns, seniority estimate, best-fit roles, and recommendation (strong yes / yes / maybe / no). Skills: ${{skillsAnalysis}} Experience: ${{experienceSummary}} Risks: ${{riskAnalysis}}"
},
"typeName": "LLMBlock"
}
],
"connections": [
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2001",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1001",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1002",
"targetName": "profile"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2002",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1001",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1003",
"targetName": "profile"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2003",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1001",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1004",
"targetName": "profile"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2004",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1002",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1005",
"targetName": "skillsAnalysis"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2005",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1003",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1005",
"targetName": "experienceSummary"
},
{
"id": "cc1f2b22-1b38-4a53-a4f3-4bde50da2006",
"sourceId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1004",
"sourceName": "response",
"targetId": "cc1f2b22-1b38-4a53-a4f3-4bde50da1005",
"targetName": "riskAnalysis"
}
]
}
}
]

View File

@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
@ -17,7 +18,10 @@ import com.fasterxml.jackson.databind.JsonNode;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.IOCapabilityType;
import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput;
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType;
import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration;
@ -79,6 +83,132 @@ public class BlocksControllerTest {
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
}
@Test
public void createChatInteractionBlockExposesInteractiveOutputs() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
.name("Recruiter chat")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.inputs(List.of(new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT)))
.build();
Block<ChatInteractionBlockType> block = blocksController.create(config);
assertNotNull(block);
assertEquals(ChatInteractionBlockType.TYPE, block.getType().getName());
assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("candidate")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && !output.isMultiple()));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple()));
}
@Test
public void createChatInteractionBlockAllowsPartialConfigurationForUiDraft() {
Block<ChatInteractionBlockType> block = blocksController.create(ChatInteractionBlockConfiguration.empty());
assertNotNull(block);
assertEquals(ChatInteractionBlockType.TYPE, block.getType().getName());
assertEquals(ChatInteractionBlockType.TYPE, block.getName());
assertTrue(block.getInputs().isEmpty());
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history")));
}
@Test
public void createChatInteractionBlockDefaultsMissingInputTypeToText() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
.name("Recruiter chat")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.inputs(List.of(new ChatInteractionInput("candidate", null)))
.build();
Block<ChatInteractionBlockType> block = blocksController.create(config);
assertNotNull(block);
assertEquals(it.cnr.isti.workflow.manager.ios.IOType.TEXT,
block.getInputs().stream()
.filter(input -> input.getName().equals("candidate"))
.findFirst()
.orElseThrow()
.getType());
}
@Test
public void createChatInteractionBlockAcceptsLegacyInputTypeAlias() throws Exception {
String payload = """
{
"type": "ChatInteractionBlockConfiguration",
"name": "Recruiter chat",
"llmDescriptor": {
"provider": "testProvider",
"model": "testModel"
},
"inputs": [
{
"name": "candidate",
"type": "TEXT",
"multiple": true
}
]
}
""";
ChatInteractionBlockConfiguration config = ObjectMapperHolder.mapper.readValue(
payload,
ChatInteractionBlockConfiguration.class);
Block<ChatInteractionBlockType> block = blocksController.create(config);
assertEquals(it.cnr.isti.workflow.manager.ios.IOType.TEXT,
block.getInputs().stream()
.filter(input -> input.getName().equals("candidate"))
.findFirst()
.orElseThrow()
.getType());
assertTrue(block.getInputs().stream()
.filter(input -> input.getName().equals("candidate"))
.findFirst()
.orElseThrow()
.isMultiple());
}
@Test
public void createChatInteractionBlockRejectsDuplicateInputNames() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
.name("Recruiter chat")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.inputs(List.of(
new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT),
new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT, true)))
.build();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> blocksController.create(config));
assertTrue(exception.getMessage().contains("inputs must have unique names"));
}
@Test
public void createChatInteractionBlockRejectsNonTextInputType() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
.name("Recruiter chat")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.inputs(List.of(new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.FILE)))
.build();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> blocksController.create(config));
assertTrue(exception.getMessage().contains("ChatInteraction inputs support only TEXT or TEXT[]"));
}
@Test
public void LLMSchemaHasLongTextHints() {
BlockConfigurationDescriptor descriptor = blocksController
@ -102,6 +232,68 @@ public class BlocksControllerTest {
assertFalse(prompt.has("x-ui-rows"));
}
@Test
public void chatInteractionSchemaDeclaresUniqueInputNames() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(ChatInteractionBlockType.TYPE);
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode inputs = schema.path("properties").path("inputs");
assertEquals("name", inputs.path("x-ui-unique-by").asText());
JsonNode definitions = schema.has("definitions") ? schema.path("definitions") : schema.path("$defs");
JsonNode inputDefinition = definitions.fields().next().getValue();
for (java.util.Iterator<java.util.Map.Entry<String, JsonNode>> it = definitions.fields(); it.hasNext();) {
java.util.Map.Entry<String, JsonNode> entry = it.next();
if (entry.getKey().contains("ChatInteractionInput")) {
inputDefinition = entry.getValue();
break;
}
}
JsonNode itemProperties = inputDefinition.path("properties");
assertEquals(8, itemProperties.path("name").path("maxLength").asInt());
assertTrue(itemProperties.has("ioType"));
assertTrue(itemProperties.has("multiple"));
assertFalse(itemProperties.has("type"));
assertEquals("TEXT", itemProperties.path("ioType").path("enum").get(0).asText());
assertEquals(1, itemProperties.path("ioType").path("enum").size());
assertEquals("type", itemProperties.path("ioType").path("x-ui-label").asText());
assertFalse(itemProperties.path("ioType").has("x-ui-description"));
}
@Test
public void chatInteractionDescriptorExposesInteractionContract() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(ChatInteractionBlockType.TYPE);
assertNotNull(descriptor.interactionContract());
assertEquals("chat-session", descriptor.interactionContract().kind());
assertEquals("message", descriptor.interactionContract().messageField());
assertEquals("response", descriptor.interactionContract().completionField());
assertEquals("history", descriptor.interactionContract().historyField());
assertEquals("response", descriptor.interactionContract().responseField());
assertTrue(descriptor.interactionContract().supportsPartialResult());
}
@Test
public void humanInteractionDescriptorExposesInteractionContract() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(HumanInteractionBlockType.TYPE);
assertNotNull(descriptor.interactionContract());
assertEquals("single-response", descriptor.interactionContract().kind());
assertEquals("output", descriptor.interactionContract().completionField());
assertEquals("output", descriptor.interactionContract().responseField());
assertFalse(descriptor.interactionContract().supportsPartialResult());
}
@Test
public void llmDescriptorDoesNotExposeInteractionContract() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(LLMBlockType.TYPE);
assertNull(descriptor.interactionContract());
}
@Test
public void getLLMExampleForType() {
Block<LLMBlockType> block = blocksController.getExampleForType(LLMBlockType.TYPE);
@ -129,6 +321,28 @@ public class BlocksControllerTest {
.anyMatch(capability -> capability.type() == IOCapabilityType.TEXT && !capability.multiple()));
}
@Test
public void getChatInteractionExampleForType() {
Block<ChatInteractionBlockType> block = blocksController.getExampleForType(ChatInteractionBlockType.TYPE);
assertNotNull(block);
assertEquals(ChatInteractionBlockType.TYPE, block.getType().getName());
assertEquals(ChatInteractionBlockType.TYPE, block.getName());
assertNotNull(block.getSpecificConfiguration());
assertTrue(block.getInputs().isEmpty());
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple()));
}
@Test
public void getLegacyChatHumanInteractionExampleForType() {
Block<ChatInteractionBlockType> block = blocksController.getExampleForType("ChatHumanInteraction");
assertNotNull(block);
assertEquals(ChatInteractionBlockType.TYPE, block.getType().getName());
assertEquals(ChatInteractionBlockType.TYPE, block.getName());
}
@Test
public void createLlmBlockWithoutConfiguredPromptUsesPromptInput() {
Block<LLMBlockType> block = blocksController.create(LLMBlockConfiguration.builder()

View File

@ -3,7 +3,6 @@ package it.cnr.isti.workflow.manager.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@ -12,7 +11,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.JsonNode;
@ -100,6 +98,22 @@ public class ContainersControllerTest {
assertTrue(subFlow.path("x-ui-structural").asBoolean());
}
@Test
public void iteratorContainerSchemaMarksIterationInputAsEnabledWhenSubFlowIsPresent() {
ContainersController.ContainerConfigurationDescriptor descriptor = containersController.getTypes().stream()
.filter(type -> IteratorContainerType.TYPE.equals(type.type()))
.findFirst()
.orElseThrow();
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode iterationInput = schema.path("properties").path("iterationInput");
assertEquals("subFlow", iterationInput.path("x-ui-enabled-when").path("field").asText());
assertTrue(iterationInput.path("x-ui-enabled-when").path("present").asBoolean());
assertEquals("inputs", iterationInput.path("x-ui-options-from-node").path("collection").asText());
assertEquals("name", iterationInput.path("x-ui-options-from-node").path("valueField").asText());
assertEquals("name", iterationInput.path("x-ui-options-from-node").path("labelField").asText());
}
@Test
public void createGenericContainerExposesOpenHandles() {
Block<LLMBlockType> internalBlock = blocksController.create(LLMBlockConfiguration.builder()
@ -227,15 +241,14 @@ public class ContainersControllerTest {
}
@Test
public void createGenericContainerRejectsEmptySubFlow() {
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> containersController.create(GenericContainerConfiguration.builder()
.name("Container")
.build()));
public void createGenericContainerAllowsEmptySubFlow() {
Container<GenericContainerType> container = containersController.create(GenericContainerConfiguration.builder()
.name("Container")
.build());
assertEquals(400, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("Invalid parameter subFlow"));
assertTrue(exception.getReason().contains("must contain at least one node"));
assertNotNull(container);
assertTrue(container.getInputs().isEmpty());
assertTrue(container.getOutputs().isEmpty());
}
@Test
@ -256,12 +269,12 @@ public class ContainersControllerTest {
.build());
assertNotNull(container);
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("candidates") && input.isMultiple()));
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("candidate") && input.isMultiple()));
assertTrue(container.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && output.isMultiple()));
}
@Test
public void createIteratorContainerRejectsUnknownIterationInput() {
public void createIteratorContainerAllowsUnknownIterationInputDuringCreate() {
Block<LLMBlockType> internalBlock = blocksController.create(LLMBlockConfiguration.builder()
.name("Analyze")
.llmDescriptor(LLMDescriptor.builder()
@ -271,14 +284,59 @@ public class ContainersControllerTest {
.prompt("Analyze ${{candidate}}")
.build());
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> containersController.create(IteratorContainerConfiguration.builder()
.name("Iterator")
.subFlow(FlowData.builder().block(internalBlock).build())
.iterationInput("unknown")
.build()));
Container<IteratorContainerType> container = containersController.create(IteratorContainerConfiguration.builder()
.name("Iterator")
.subFlow(FlowData.builder().block(internalBlock).build())
.iterationInput("unknown")
.build());
assertEquals(400, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("iterationInput"));
assertNotNull(container);
assertEquals("candidate", ((IteratorContainerConfiguration) container.getSpecificConfiguration()).getIterationInput());
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("candidate") && input.isMultiple()));
assertTrue(container.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && output.isMultiple()));
}
@Test
public void createIteratorContainerWithoutIterationInputDefaultsIterationInputWhenOnlyOneChoiceExists() {
Block<LLMBlockType> internalBlock = blocksController.create(LLMBlockConfiguration.builder()
.name("Analyze")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.prompt("Analyze ${{candidate}}")
.build());
Container<IteratorContainerType> container = containersController.create(IteratorContainerConfiguration.builder()
.name("Iterator")
.subFlow(FlowData.builder().block(internalBlock).build())
.build());
assertNotNull(container);
assertEquals("candidate", ((IteratorContainerConfiguration) container.getSpecificConfiguration()).getIterationInput());
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("candidate") && input.isMultiple()));
assertTrue(container.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && output.isMultiple()));
}
@Test
public void createIteratorContainerOverridesIterationInputWhenNewSubFlowHasSingleOpenInput() {
Block<LLMBlockType> internalBlock = blocksController.create(LLMBlockConfiguration.builder()
.name("Analyze")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.prompt("Analyze ${{person}}")
.build());
Container<IteratorContainerType> container = containersController.create(IteratorContainerConfiguration.builder()
.name("Iterator")
.subFlow(FlowData.builder().block(internalBlock).build())
.iterationInput("candidate")
.build());
assertNotNull(container);
assertEquals("person", ((IteratorContainerConfiguration) container.getSpecificConfiguration()).getIterationInput());
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("person") && input.isMultiple()));
}
}

View File

@ -106,7 +106,8 @@ public class ExecutionControllerTest {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
@ -116,8 +117,7 @@ public class ExecutionControllerTest {
try {
logger.info("Execution object: {}", ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(executionObject));
} catch (JsonProcessingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
throw new RuntimeException(e);
}
}

View File

@ -31,6 +31,9 @@ import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
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.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration;
import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType;
import it.cnr.isti.workflow.manager.flows.FlowTestCreator;
import it.cnr.isti.workflow.manager.flows.model.Connection;
import it.cnr.isti.workflow.manager.flows.model.Flow;
@ -56,6 +59,9 @@ public class FlowControllerTest {
@Autowired
private BlocksController blocksController;
@Autowired
private ContainersController containersController;
@Autowired
private MockMvc mockMvc;
@ -106,7 +112,7 @@ public class FlowControllerTest {
try {
System.out.println(ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(retrieved));
} catch (JsonProcessingException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
assertNotNull(retrieved);
@ -498,6 +504,73 @@ public class FlowControllerTest {
assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status());
}
@Test
public void createFlowWithDraftIteratorContainerReturnsDraftStatus() {
Block<LLMBlockType> firstInternalBlock = blocksController.create(LLMBlockConfiguration.builder()
.name("First")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.prompt("Analyze ${{candidate}}")
.build());
Block<LLMBlockType> secondInternalBlock = blocksController.create(LLMBlockConfiguration.builder()
.name("Second")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.prompt("Analyze ${{profile}}")
.build());
Container<IteratorContainerType> iteratorContainer = containersController.create(
IteratorContainerConfiguration.builder()
.name("Iterator")
.subFlow(FlowData.builder()
.block(firstInternalBlock)
.block(secondInternalBlock)
.build())
.build());
FlowCreateRequest request = new FlowCreateRequest(
"Draft Iterator Flow",
"Container missing iterationInput should still be savable",
FlowData.builder()
.container(iteratorContainer)
.build());
ResponseEntity<FlowView> createResponse = flowController.createFlow(
request,
new LoginEntity("testuser", "testpassword"));
assertTrue(createResponse.getStatusCode().is2xxSuccessful());
assertNotNull(createResponse.getBody());
assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status());
}
@Test
public void createFlowWithEmptyIteratorContainerReturnsDraftStatus() {
Container<IteratorContainerType> iteratorContainer = containersController.create(
IteratorContainerConfiguration.builder()
.name("Iterator")
.build());
FlowCreateRequest request = new FlowCreateRequest(
"Empty Iterator Flow",
"Container with empty subFlow should still be savable as draft",
FlowData.builder()
.container(iteratorContainer)
.build());
ResponseEntity<FlowView> createResponse = flowController.createFlow(
request,
new LoginEntity("testuser", "testpassword"));
assertTrue(createResponse.getStatusCode().is2xxSuccessful());
assertNotNull(createResponse.getBody());
assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status());
}
@Test
public void createFlowRejectsConditionalBranchMerge() {
LLMDescriptor llmDescriptor = LLMDescriptor.builder()

View File

@ -15,12 +15,15 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.ResourceUtils;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.cnr.isti.workflow.manager.app.ObjectMapperHolder;
import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput;
import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration;
import it.cnr.isti.workflow.manager.containers.Container;
import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration;
@ -39,12 +42,15 @@ import it.cnr.isti.workflow.manager.flows.model.Flow;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
import it.cnr.isti.workflow.manager.blocks.factories.ChatInteractionBlockFactory;
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.types.ChatInteractionBlockType;
import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@ -73,6 +79,11 @@ public class ExecutionTest {
return "Hello, " + prompt.replace("Hello, ", "").replace("!", "") + "!";
}
@Override
public String chat(String model, List<ChatMessage> messages) {
return "Chat, " + model + "!";
}
};
}
}
@ -86,6 +97,9 @@ public class ExecutionTest {
@Autowired
LLMBlockFactory llmBlockFactory;
@Autowired
ChatInteractionBlockFactory chatInteractionBlockFactory;
@Autowired
LLMBlockType llmBlockType;
@ -127,14 +141,88 @@ public class ExecutionTest {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertTrue(eo.getContext().getStatus().isFinalState());
eo.getContext().getResult().forEach((k,v) -> System.out.println("Result: " + k + " -> " + v));
}
@Test
public void createChatInteractionExecutionSetInteractionAndStart() {
Block<ChatInteractionBlockType> chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder()
.name("Recruiter Chat")
.llmDescriptor(llmBrick)
.inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false)))
.build());
Flow flow = Flow.builder()
.name("Chat flow")
.description("Single chat block")
.block(chatBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.WAITING, execObject.getContext().getStatus());
execObject.setInteractionValue(chatBlock.getId(), ChatInteractionBlockFactory.INTERACTION_FIELD, "Hello ${{cand}}");
execObject = executionsService.getExecution(execObject.getId());
assertEquals(ExecutionStatus.WAITING, execObject.getContext().getStatus());
Object partialResponse = execObject.getContext().getPartialResult()
.get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.RESPONSE_OUTPUT));
assertEquals("Chat, testModel!", partialResponse);
Object partialConversation = execObject.getContext().getPartialResult()
.get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.HISTORY_OUTPUT));
assertTrue(partialConversation instanceof List<?>);
assertEquals(2, ((List<?>) partialConversation).size());
assertTrue(((List<?>) partialConversation).contains("[USER] Hello John Doe"));
assertTrue(((List<?>) partialConversation).contains("[ASSISTANT] Chat, testModel!"));
assertTrue(execObject.getContext().getResult().isEmpty());
execObject.setInteractionValue(chatBlock.getId(), ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD,
"Candidate approved");
execObject = executionsService.getExecution(execObject.getId());
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
Object response = execObject.getContext().getResult()
.get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.RESPONSE_OUTPUT));
assertEquals("Candidate approved", response);
Object conversation = execObject.getContext().getResult()
.get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.HISTORY_OUTPUT));
assertTrue(conversation instanceof List<?>);
assertEquals(2, ((List<?>) conversation).size());
assertTrue(((List<?>) conversation).contains("[USER] Hello John Doe"));
assertTrue(((List<?>) conversation).contains("[ASSISTANT] Chat, testModel!"));
assertTrue(execObject.getContext().getPartialResult().isEmpty());
}
@Test
public void createExecutionRejectsChatInteractionWithoutLlmDescriptor() {
Block<ChatInteractionBlockType> chatBlock = chatInteractionBlockFactory
.create(ChatInteractionBlockConfiguration.empty());
Flow flow = Flow.builder()
.name("Invalid Chat Flow")
.description("Chat flow without llmDescriptor")
.block(chatBlock)
.build();
ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows(
ResponseStatusException.class,
() -> executionsService.createExecution(flow));
assertTrue(exception.getReason().contains("\"field\":\"specificConfiguration.llmDescriptor\""));
}
@Test
public void createInteractiveExecutionSetInputAndStart() {
ExecutionObject eo = createInteractiveExecutionAndSetInputInternally();
@ -145,8 +233,8 @@ public class ExecutionTest {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertFalse(eo.getContext().getStatus().isFinalState());
@ -353,7 +441,7 @@ public class ExecutionTest {
FlowData flow = FlowData.builder().container(container).build();
ExecutionObject execObject = executionsService.createExecution("Iterator flow", flow);
executionsService.prepareInput(execObject.getId(), container.getId(), "names", List.of("Alice", "Bob"));
executionsService.prepareInput(execObject.getId(), container.getId(), "name", List.of("Alice", "Bob"));
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
try {

View File

@ -80,7 +80,8 @@ public class ExecutionWithContainer {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertTrue(execObject.getContext().getStatus().isFinalState());
@ -113,7 +114,8 @@ public class ExecutionWithContainer {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertTrue(execObject.getContext().getStatus().isFinalState());
@ -145,7 +147,8 @@ public class ExecutionWithContainer {
Thread.sleep(100);
System.out.println("Execution status: " + execObject.getContext().getStatus());
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertTrue(execObject.getContext().getStatus().isFinalState());
@ -154,8 +157,7 @@ public class ExecutionWithContainer {
try {
logger.info("Execution object: {}", ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(execObject));
} catch (JsonProcessingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
throw new RuntimeException(e);
}
}
@ -170,8 +172,7 @@ public class ExecutionWithContainer {
try {
logger.info("Execution object in CREATED State: {}", ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(execObject));
} catch (JsonProcessingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
throw new RuntimeException(e);
}
for (Step<?> s : execObject.getContext().getSteps().values()) {
if (s.getInputs().stream().anyMatch(i -> i.getDescriptor().getName().equals("name") && !i.isRegistered())) {
@ -184,8 +185,7 @@ public class ExecutionWithContainer {
try {
logger.info("Execution object in READY State: {}", ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(execObject));
} catch (JsonProcessingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
throw new RuntimeException(e);
}
@ -196,7 +196,8 @@ public class ExecutionWithContainer {
logger.debug("Execution status: " + execObject.getContext().getStatus());
execObject = executionsService.getExecution(execObject.getId());
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
assertEquals(ExecutionStatus.WAITING, execObject.getContext().getStatus());
@ -204,8 +205,7 @@ public class ExecutionWithContainer {
try {
logger.info("Execution object in WAITING State: {}", ObjectMapperHolder.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(execObject));
} catch (JsonProcessingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
throw new RuntimeException(e);
}
}