diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java index 0d66762..b4b4825 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java @@ -39,6 +39,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup; import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema; +import it.cnr.isti.workflow.manager.configurations.annotations.AcceptsVariablePlaceholder; 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; @@ -94,6 +95,7 @@ public class JsonSchemaProducer { Map, Map> retrieverMap = collectRetrieverMetadata(type); Map, Map> dynamicSchemaMap = collectDynamicSchemaMetadata(type); Map, Map> longTextMap = collectLongTextMetadata(type); + Map, Set> acceptsPlaceholderMap = collectAcceptsVariablePlaceholderMetadata(type); Map, Map> structuralMap = collectStructuralMetadata(type); Map, Map> uiOptionalGroupMap = collectUiOptionalGroupMetadata(type); Map, Map> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type); @@ -110,6 +112,7 @@ public class JsonSchemaProducer { applyRetrieverMetadata(root, type, getMergedMetadata(retrieverMap, type)); applyDynamicSchemaMetadata(root, getMergedMetadata(dynamicSchemaMap, type)); applyLongTextMetadata(root, getMergedMetadata(longTextMap, type)); + applyAcceptsVariablePlaceholderMetadata(root, mergedNames(acceptsPlaceholderMap, type)); applyStructuralMetadata(root, getMergedMetadata(structuralMap, type)); applyUiOptionalGroupMetadata(root, getMergedMetadata(uiOptionalGroupMap, type)); applyUiEnabledWhenMetadata(root, getMergedMetadata(uiEnabledWhenMap, type)); @@ -142,6 +145,7 @@ public class JsonSchemaProducer { metadataClasses.addAll(numericBoundsMap.keySet()); metadataClasses.addAll(schemaAllowedValuesMap.keySet()); metadataClasses.addAll(configurableAsInputMap.keySet()); + metadataClasses.addAll(acceptsPlaceholderMap.keySet()); for (Entry entry : definitions.properties()) { if (!(entry.getValue() instanceof ObjectNode classSchema)) { continue; @@ -154,6 +158,7 @@ public class JsonSchemaProducer { applyRetrieverMetadata(classSchema, matchedClass, getMergedMetadata(retrieverMap, matchedClass)); applyDynamicSchemaMetadata(classSchema, getMergedMetadata(dynamicSchemaMap, matchedClass)); applyLongTextMetadata(classSchema, getMergedMetadata(longTextMap, matchedClass)); + applyAcceptsVariablePlaceholderMetadata(classSchema, mergedNames(acceptsPlaceholderMap, matchedClass)); applyStructuralMetadata(classSchema, getMergedMetadata(structuralMap, matchedClass)); applyUiOptionalGroupMetadata(classSchema, getMergedMetadata(uiOptionalGroupMap, matchedClass)); applyUiEnabledWhenMetadata(classSchema, getMergedMetadata(uiEnabledWhenMap, matchedClass)); @@ -319,6 +324,24 @@ public class JsonSchemaProducer { } } + /** The set-valued sibling of {@link #getMergedMetadata}, for marker annotations. */ + private Set mergedNames(Map, Set> metadataMap, Class type) { + if (type == null || metadataMap == null || metadataMap.isEmpty()) { + return Set.of(); + } + Deque> hierarchy = new ArrayDeque<>(); + Class current = type; + while (current != null && current != Object.class) { + hierarchy.addFirst(current); + current = current.getSuperclass(); + } + Set merged = new LinkedHashSet<>(); + for (Class clazz : hierarchy) { + merged.addAll(metadataMap.getOrDefault(clazz, Set.of())); + } + return merged; + } + private Map getMergedMetadata(Map, Map> metadataMap, Class type) { if (type == null || metadataMap == null || metadataMap.isEmpty()) { return Map.of(); @@ -737,6 +760,69 @@ public class JsonSchemaProducer { } } + private Map, Set> collectAcceptsVariablePlaceholderMetadata(Class rootClass) { + Map, Set> result = new HashMap<>(); + Set> visited = new HashSet<>(); + Queue> queue = new ArrayDeque<>(); + queue.add(rootClass); + + while (!queue.isEmpty()) { + Class current = queue.poll(); + if (current == null || !visited.add(current) || isTerminalType(current)) { + continue; + } + + Set names = new LinkedHashSet<>(); + for (Field field : current.getDeclaredFields()) { + if (field.getAnnotation(AcceptsVariablePlaceholder.class) != null) { + names.add(field.getName()); + } + enqueueRelatedTypes(queue, field.getGenericType(), field.getType()); + } + + if (current.isRecord()) { + for (RecordComponent component : current.getRecordComponents()) { + if (component.getAnnotation(AcceptsVariablePlaceholder.class) != null) { + names.add(component.getName()); + } + enqueueRelatedTypes(queue, component.getGenericType(), component.getType()); + } + } + + if (current.getSuperclass() != null) { + queue.add(current.getSuperclass()); + } + + if (!names.isEmpty()) { + result.put(current, names); + } + } + + return result; + } + + /** + * Applied after {@code applyLongTextMetadata}, which writes the same key unconditionally and + * would otherwise be able to write {@code false} over this. Only ever turns it on: either + * annotation saying yes is enough. + */ + private void applyAcceptsVariablePlaceholderMetadata(ObjectNode classSchema, Set names) { + if (names == null || names.isEmpty()) { + return; + } + JsonNode propsNode = classSchema.get("properties"); + if (!(propsNode instanceof ObjectNode properties)) { + return; + } + + for (String name : names) { + JsonNode propNode = properties.get(name); + if (propNode instanceof ObjectNode propertySchema) { + propertySchema.put("x-ui-accept-variable-as-placeholder", true); + } + } + } + private Map, Map> collectStructuralMetadata(Class rootClass) { Map, Map> result = new HashMap<>(); Set> visited = new HashSet<>(); diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/AcceptsVariablePlaceholder.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/AcceptsVariablePlaceholder.java new file mode 100644 index 0000000..613a92c --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/AcceptsVariablePlaceholder.java @@ -0,0 +1,24 @@ +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; + +/** + * Says the value may contain {@code ${{...}}} placeholders, resolved against the run's inputs, + * execution variables and global inputs. + * + *

It exists apart from {@link LongText} because that annotation ties the fact to a rendering + * choice: its {@code acceptVariableAsPlaceholder} flag only reaches a field that is also drawn as + * a textarea. Whether a value is interpolated is a property of the value, not of the control used + * to type it - a one-line field, or one backed by a list of suggestions, can equally well take + * {@code ${{global.modelName}}}. + * + *

{@code LongText.acceptVariableAsPlaceholder} remains as the shorthand for the many prompt + * fields that are both; either one turning it on is enough. + */ +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AcceptsVariablePlaceholder { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java index af65798..84ca59c 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java @@ -2,8 +2,10 @@ package it.cnr.isti.workflow.manager.llms; import com.fasterxml.jackson.annotation.JsonProperty; +import it.cnr.isti.workflow.manager.configurations.annotations.AcceptsVariablePlaceholder; import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput; import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever; +import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription; import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup; import it.cnr.isti.workflow.manager.ios.IOType; import jakarta.validation.Valid; @@ -36,6 +38,8 @@ public record LLMDescriptor( */ @JsonProperty(required = true) @ConfigurableAsInput(inputName = "model", type = IOType.TEXT) + @AcceptsVariablePlaceholder + @UiDescription("Leave empty to take it from a node input, or use a placeholder such as ${{global.modelName}}.") @FieldRetriever(name = "LLM", url = "/retriever/LLM/models", dependsOn = {"provider"}) String model, @Valid @JsonProperty(required = false) diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index e9d5028..e3a0a3b 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -129,6 +129,18 @@ public class BlocksControllerTest { // before the run starts. assertFalse(descriptor.path("properties").path("provider").has("x-ui-bindable-as-input")); + // It also takes a ${{...}} placeholder, which is the only way a global input can decide + // the model. Declared by @AcceptsVariablePlaceholder rather than by LongText, whose own + // flag would have made this a textarea - and applied after it, so LongText writing the + // same key as false cannot win. + assertTrue(model.path("x-ui-accept-variable-as-placeholder").asBoolean()); + assertFalse(model.has("x-ui-widget")); + // x-ui-description, which is what the editor reads first as a field's tip. + assertTrue(model.path("x-ui-description").asText().contains("${{global.modelName}}")); + // The provider takes no placeholder: it is matched against the registered providers. + assertFalse(descriptor.path("properties").path("provider") + .path("x-ui-accept-variable-as-placeholder").asBoolean()); + // The parameters themselves: present, optional, and carrying their range so the editor can // bound the input rather than accepting anything and failing on save. JsonNode parameters = catalog.sharedDefinitions().path("ModelParameters");