Mark a nested object as a group of optional settings
@UiOptionalGroup says what a field is - a group of settings that are all optional - rather than how to draw it. The editor will render it as one control that opens a dialog instead of unfolding five empty chips inline; the read-only execution view will keep showing only what was actually set. Two renderings, one declaration. A field annotation, not a type one, and that is forced rather than preferred. Shared definitions are hoisted into sharedDefinitions only when their JSON is identical in every schema containing them, so a label that varies by owner would un-share ModelParameters silently. The web merges a property's x-ui-* keys over the definition it $refs, so the renderer sees the marker on the object node anyway - the constraint costs nothing. The test asserts the marker is on the property, that the $ref survives beside it, that it is absent from the definition, and that ModelParameters is still shared. Making the label vary by owner fails it. Applied to LLMDescriptor.parameters and to both MCP configurations. Nothing reads it yet; the editor comes next. 534 backend tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
871acc4423
commit
19f9fcdbe0
|
|
@ -37,6 +37,7 @@ import com.github.victools.jsonschema.module.jackson.JacksonSchemaModule;
|
|||
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
|
||||
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.ConfigurableAsInput;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
|
||||
|
|
@ -94,6 +95,7 @@ public class JsonSchemaProducer {
|
|||
Map<Class<?>, Map<String, DynamicSchema>> dynamicSchemaMap = collectDynamicSchemaMetadata(type);
|
||||
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(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);
|
||||
Map<Class<?>, Map<String, UiOrder>> uiOrderMap = collectUiOrderMetadata(type);
|
||||
Map<Class<?>, Map<String, UiOptionsFromNode>> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type);
|
||||
|
|
@ -109,6 +111,7 @@ public class JsonSchemaProducer {
|
|||
applyDynamicSchemaMetadata(root, getMergedMetadata(dynamicSchemaMap, type));
|
||||
applyLongTextMetadata(root, getMergedMetadata(longTextMap, type));
|
||||
applyStructuralMetadata(root, getMergedMetadata(structuralMap, type));
|
||||
applyUiOptionalGroupMetadata(root, getMergedMetadata(uiOptionalGroupMap, type));
|
||||
applyUiEnabledWhenMetadata(root, getMergedMetadata(uiEnabledWhenMap, type));
|
||||
applyUiOrderMetadata(root, type, getMergedMetadata(uiOrderMap, type));
|
||||
applyUiOptionsFromNodeMetadata(root, getMergedMetadata(uiOptionsFromNodeMap, type));
|
||||
|
|
@ -127,6 +130,7 @@ public class JsonSchemaProducer {
|
|||
metadataClasses.addAll(dynamicSchemaMap.keySet());
|
||||
metadataClasses.addAll(longTextMap.keySet());
|
||||
metadataClasses.addAll(structuralMap.keySet());
|
||||
metadataClasses.addAll(uiOptionalGroupMap.keySet());
|
||||
metadataClasses.addAll(uiEnabledWhenMap.keySet());
|
||||
metadataClasses.addAll(uiOrderMap.keySet());
|
||||
metadataClasses.addAll(uiOptionsFromNodeMap.keySet());
|
||||
|
|
@ -151,6 +155,7 @@ public class JsonSchemaProducer {
|
|||
applyDynamicSchemaMetadata(classSchema, getMergedMetadata(dynamicSchemaMap, matchedClass));
|
||||
applyLongTextMetadata(classSchema, getMergedMetadata(longTextMap, matchedClass));
|
||||
applyStructuralMetadata(classSchema, getMergedMetadata(structuralMap, matchedClass));
|
||||
applyUiOptionalGroupMetadata(classSchema, getMergedMetadata(uiOptionalGroupMap, matchedClass));
|
||||
applyUiEnabledWhenMetadata(classSchema, getMergedMetadata(uiEnabledWhenMap, matchedClass));
|
||||
applyUiOrderMetadata(classSchema, matchedClass, getMergedMetadata(uiOrderMap, matchedClass));
|
||||
applyUiOptionsFromNodeMetadata(classSchema, getMergedMetadata(uiOptionsFromNodeMap, matchedClass));
|
||||
|
|
@ -775,6 +780,77 @@ public class JsonSchemaProducer {
|
|||
return result;
|
||||
}
|
||||
|
||||
private Map<Class<?>, Map<String, UiOptionalGroup>> collectUiOptionalGroupMetadata(Class<?> rootClass) {
|
||||
Map<Class<?>, Map<String, UiOptionalGroup>> 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, UiOptionalGroup> metadata = new LinkedHashMap<>();
|
||||
for (Field field : current.getDeclaredFields()) {
|
||||
UiOptionalGroup group = field.getAnnotation(UiOptionalGroup.class);
|
||||
if (group != null) {
|
||||
metadata.put(field.getName(), group);
|
||||
}
|
||||
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
|
||||
}
|
||||
|
||||
if (current.isRecord()) {
|
||||
for (RecordComponent component : current.getRecordComponents()) {
|
||||
UiOptionalGroup group = component.getAnnotation(UiOptionalGroup.class);
|
||||
if (group != null) {
|
||||
metadata.put(component.getName(), group);
|
||||
}
|
||||
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
|
||||
}
|
||||
}
|
||||
|
||||
if (current.getSuperclass() != null) {
|
||||
queue.add(current.getSuperclass());
|
||||
}
|
||||
|
||||
if (!metadata.isEmpty()) {
|
||||
result.put(current, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Written on the property, never on the shared definition: the label varies by owner, and a
|
||||
* definition whose JSON differs between schemas silently stops being hoisted into
|
||||
* sharedDefinitions. The web merges a property's x-ui-* keys over the definition it $refs, so
|
||||
* the renderer sees them on the object node regardless.
|
||||
*/
|
||||
private void applyUiOptionalGroupMetadata(ObjectNode classSchema, Map<String, UiOptionalGroup> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
JsonNode propsNode = classSchema.get("properties");
|
||||
if (!(propsNode instanceof ObjectNode properties)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Entry<String, UiOptionalGroup> entry : metadata.entrySet()) {
|
||||
JsonNode propNode = properties.get(entry.getKey());
|
||||
if (!(propNode instanceof ObjectNode propertySchema)) {
|
||||
continue;
|
||||
}
|
||||
propertySchema.put("x-ui-optional-group", true);
|
||||
String label = entry.getValue().label();
|
||||
if (label != null && !label.isBlank()) {
|
||||
propertySchema.put("x-ui-optional-group-label", label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyStructuralMetadata(ObjectNode classSchema, Map<String, Structural> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInp
|
|||
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType;
|
||||
|
|
@ -46,6 +47,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration<MCPAgentBlock
|
|||
*/
|
||||
@UiOrder(15)
|
||||
@Valid
|
||||
@UiOptionalGroup(label = "Model parameters")
|
||||
@JsonProperty(required = false)
|
||||
private ModelParameters parameters;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
|
||||
import it.cnr.isti.workflow.manager.llms.ModelParameters;
|
||||
import jakarta.validation.Valid;
|
||||
|
|
@ -47,6 +48,7 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
|
|||
*/
|
||||
@UiOrder(15)
|
||||
@Valid
|
||||
@UiOptionalGroup(label = "Model parameters")
|
||||
@JsonProperty(required = false)
|
||||
private ModelParameters parameters;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
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;
|
||||
|
||||
/**
|
||||
* Marks a nested object as a group of settings that are all optional.
|
||||
*
|
||||
* <p>It says what the field <em>is</em>, not how to draw it: the editor renders it as one control
|
||||
* that opens a dialog instead of unfolding its properties inline, while the read-only execution
|
||||
* view shows only the values that were actually set. Five empty chips for parameters most nodes
|
||||
* never touch is what this exists to avoid.
|
||||
*
|
||||
* <p>Deliberately a field annotation rather than a type one. Shared definitions are hoisted only
|
||||
* when their JSON is identical in every schema that contains them, so anything that varies by
|
||||
* owner - a label, above all - has to live on the property or the definition silently stops being
|
||||
* shared.
|
||||
*/
|
||||
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface UiOptionalGroup {
|
||||
|
||||
/** Name for the control. Falls back to the field's own label when empty. */
|
||||
String label() default "";
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package it.cnr.isti.workflow.manager.llms;
|
|||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
|
||||
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Builder;
|
||||
|
|
@ -23,4 +24,5 @@ public record LLMDescriptor(
|
|||
@NotBlank @JsonProperty(required = true)
|
||||
@FieldRetriever(name = "LLM", url = "/retriever/LLM/models", dependsOn = {"provider"}) String model,
|
||||
|
||||
@Valid @JsonProperty(required = false) ModelParameters parameters) {}
|
||||
@Valid @JsonProperty(required = false)
|
||||
@UiOptionalGroup(label = "Model parameters") ModelParameters parameters) {}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,16 @@ public class BlocksControllerTest {
|
|||
assertEquals(0.0, temperature.path("minimum").asDouble());
|
||||
assertEquals(2.0, temperature.path("maximum").asDouble());
|
||||
assertFalse(parameters.path("required").isArray() && !parameters.path("required").isEmpty());
|
||||
|
||||
// The optional-group marker sits on the *property*, beside its $ref, and never on the
|
||||
// shared definition. Definitions are hoisted only when their JSON is identical in every
|
||||
// schema containing them, so a label that varies by owner would silently un-share
|
||||
// ModelParameters - which the sharedDefinitions assertion above is here to catch.
|
||||
JsonNode parametersProperty = descriptor.path("properties").path("parameters");
|
||||
assertTrue(parametersProperty.path("x-ui-optional-group").asBoolean());
|
||||
assertEquals("Model parameters", parametersProperty.path("x-ui-optional-group-label").asText());
|
||||
assertEquals("#/sharedDefinitions/ModelParameters", parametersProperty.path("$ref").asText());
|
||||
assertFalse(parameters.has("x-ui-optional-group"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Reference in New Issue