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:
parent
3c5b44e108
commit
d024f80239
|
|
@ -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<>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue