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:
Lucio Lelii 2026-09-04 12:24:34 +02:00
parent 871acc4423
commit 19f9fcdbe0
6 changed files with 120 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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