Separate "takes a ${{...}} placeholder" from being drawn as a textarea

LongText.acceptVariableAsPlaceholder welded a fact about the value to a
rendering choice: only a field also drawn as a textarea could say it is
interpolated. That left LLMDescriptor.model unable to declare it - the
capability worked, since the executors resolve the field as a template,
but nothing in the schema said so and nothing in the editor showed it.

@AcceptsVariablePlaceholder is that fact on its own. LongText keeps its
flag as the shorthand for the many prompt fields that are both, and the
new metadata is applied after it so that LongText writing the same key as
false cannot win: either annotation saying yes is enough.

The model field also gains a description, so the editor can say what an
empty value and a placeholder each do rather than leaving both to be
guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-07 14:31:12 +02:00
parent 3c5b44e108
commit d024f80239
4 changed files with 126 additions and 0 deletions

View File

@ -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<Class<?>, Map<String, FieldRetriever>> retrieverMap = collectRetrieverMetadata(type);
Map<Class<?>, Map<String, DynamicSchema>> dynamicSchemaMap = collectDynamicSchemaMetadata(type);
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(type);
Map<Class<?>, Set<String>> acceptsPlaceholderMap = collectAcceptsVariablePlaceholderMetadata(type);
Map<Class<?>, Map<String, Structural>> structuralMap = collectStructuralMetadata(type);
Map<Class<?>, Map<String, UiOptionalGroup>> uiOptionalGroupMap = collectUiOptionalGroupMetadata(type);
Map<Class<?>, Map<String, UiEnabledWhen>> 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<String, JsonNode> 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<String> mergedNames(Map<Class<?>, Set<String>> metadataMap, Class<?> type) {
if (type == null || metadataMap == null || metadataMap.isEmpty()) {
return Set.of();
}
Deque<Class<?>> hierarchy = new ArrayDeque<>();
Class<?> current = type;
while (current != null && current != Object.class) {
hierarchy.addFirst(current);
current = current.getSuperclass();
}
Set<String> merged = new LinkedHashSet<>();
for (Class<?> clazz : hierarchy) {
merged.addAll(metadataMap.getOrDefault(clazz, Set.of()));
}
return merged;
}
private <A> Map<String, A> getMergedMetadata(Map<Class<?>, Map<String, A>> metadataMap, Class<?> type) {
if (type == null || metadataMap == null || metadataMap.isEmpty()) {
return Map.of();
@ -737,6 +760,69 @@ public class JsonSchemaProducer {
}
}
private Map<Class<?>, Set<String>> collectAcceptsVariablePlaceholderMetadata(Class<?> rootClass) {
Map<Class<?>, Set<String>> 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;
}
Set<String> 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<String> 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<Class<?>, Map<String, Structural>> collectStructuralMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, Structural>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();

View File

@ -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.
*
* <p>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}}}.
*
* <p>{@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 {
}

View File

@ -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)

View File

@ -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");