Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,68 @@
package com.vaadin.flow.component.ai.form;

import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;

/**
* Mutable per-field hint state held by {@link FormAIController}, keyed by the
* field's opaque id.
* <p>
* Set by {@link FormAIController#fieldValueOptions(ValueOptions)
* controller.fieldValueOptions(...)}: {@link #valueOptionsQuery} carries the
* filter callback (or a fixed-options snapshot wrapped in one),
* {@link #fixedOptions} flags whether the schema should render the options as
* {@code enum} or {@code queryable}, and {@link #valueOptionsToValue} resolves
* one label to one element. For multi-select fields the controller wraps the
* resolved elements into a {@link java.util.LinkedHashSet} before
* {@code setValue}; the hint state is the same shape in both cases.
* field's opaque id. Populated by {@code describeField}, {@code ignoreField},
* and {@code fieldValueOptions}; consumed by {@link FormFieldSchema} when
* building the {@code get_form_state} payload and by {@link FormValueConverter}
* when applying {@code fill_form} values.
*
* @author Vaadin Ltd
*/
final class FormFieldHints {

String description;
/**
* Label-producing callback the {@code query_field_options} tool drives.
* Wraps the {@link ValueOptions} item source plus
* {@link #itemLabelGenerator} into a single (filter, limit) → labels
* function. Non-{@code null} whenever {@code fieldValueOptions} has been
* called for this field.
*/
BiFunction<String, Integer, List<String>> valueOptionsQuery;
Function<String, ?> valueOptionsToValue;
/**
* {@code true} when the field was registered with the fixed-options
* variant; {@code false} when registered with a query callback or with no
* value-options hint at all. Used by {@link FormFieldSchema} to choose
* {@code enum} vs {@code queryable} in the {@code get_form_state} JSON.
* Items the controller has seen for this registration, keyed by their
* LLM-facing label. {@link FormValueConverter} resolves a chosen label at
* fill time via {@link Map#get(Object)} — O(1) — and the first put per
* label wins. Populated upfront for fixed-options registrations and as each
* query-callback batch arrives for query-mode registrations. Iteration
* order is insertion order. Reset at each
* {@link FormAIController#onRequest()} turn boundary; non-{@code null}
* whenever {@link #valueOptionsQuery} is set.
*/
Map<String, Object> valueOptionsItems;
/**
* Item-to-label function used to render the field's current value and to
* compute the keys in {@link #valueOptionsItems}. Captured at each
* {@link FormAIController#onRequest()} from the explicit
* {@link ValueOptions#itemLabelGenerator(com.vaadin.flow.component.ItemLabelGenerator)}
* if set, otherwise from the field's own {@code getItemLabelGenerator()}
* (read reflectively), otherwise {@link String#valueOf(Object)}. Stable
* within a turn so {@link #valueOptionsItems}' keys remain valid for
* lookup. Non-{@code null} whenever {@link #valueOptionsQuery} is set.
*/
Function<Object, String> itemLabelGenerator;
/**
* {@code true} for fixed-options registrations, {@code false} for
* query-callback or no-value-options registrations. Drives the {@code enum}
* vs {@code queryable} choice in {@link FormFieldSchema}.
*/
boolean fixedOptions;
/**
* Rebuilds {@link #valueOptionsItems}, {@link #valueOptionsQuery}, and
* {@link #itemLabelGenerator} from the registration's captured config
* (fixed list or query callback, explicit labeler or field reference).
* Invoked once at registration so the schema works before the first turn,
* and again at each {@link FormAIController#onRequest()} so a labeler
* change on the field between turns is picked up and the query-mode map
* starts each turn empty. {@code null} when no value-options registration
* exists for this field.
*/
Runnable valueOptionsTurnSetup;
boolean ignored;
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ private static void applyType(ObjectNode node, HasValue<?, ?> field,
return;
}
// fieldValueOptions turns any field into a constrained-choice field
// from the LLM's perspective: the LLM picks a label,
// valueOptionsToValue converts back. Emit type=string + enum/queryable
// so the LLM sees the signal regardless of the underlying value type.
// from the LLM's perspective: the LLM picks a label, the controller
// resolves it to an item. Emit type=string + enum/queryable so the
// LLM sees the signal regardless of the underlying value type.
if (type == FormFieldType.SINGLE_SELECT || hasValueOptions(hints)) {
node.put(FIELD_TYPE, TYPE_STRING);
applySelectionOptions(node, field, hints);
Expand Down Expand Up @@ -157,8 +157,8 @@ private static void applySelectionOptions(ObjectNode target,
return;
}
var arr = target.putArray("enum");
items.stream().limit(ENUM_MAX_ITEMS).forEach(
item -> arr.add(FormValueConverter.renderItem(field, item)));
items.stream().limit(ENUM_MAX_ITEMS)
.forEach(item -> arr.add(renderItem(field, hints, item)));
}

private static void applyValue(ObjectNode node, HasValue<?, ?> field,
Expand All @@ -173,7 +173,7 @@ private static void applyValue(ObjectNode node, HasValue<?, ?> field,
// string so the two halves of the payload agree.
if (hasValueOptions(hints) && type != FormFieldType.SINGLE_SELECT
&& type != FormFieldType.MULTI_SELECT) {
node.put(FIELD_VALUE, FormValueConverter.renderItem(field, value));
node.put(FIELD_VALUE, renderItem(field, hints, value));
return;
}
switch (type) {
Expand All @@ -188,12 +188,29 @@ private static void applyValue(ObjectNode node, HasValue<?, ?> field,
node.put(FIELD_VALUE, ((BigDecimal) value).toPlainString());
case BOOLEAN -> node.put(FIELD_VALUE, (Boolean) value);
case SINGLE_SELECT ->
node.put(FIELD_VALUE, FormValueConverter.renderItem(field, value));
case MULTI_SELECT -> applyMultiSelectValue(node, field, value);
node.put(FIELD_VALUE, renderItem(field, hints, value));
case MULTI_SELECT -> applyMultiSelectValue(node, field, hints, value);
default -> node.put(FIELD_VALUE, value.toString());
}
}

/**
* Renders one item to the LLM-facing label. Prefers the resolved
* valueOptions item-label generator (which honours an explicit
* {@link ValueOptions#itemLabelGenerator(com.vaadin.flow.component.ItemLabelGenerator)}
* over the field's own generator) so the value string agrees with the
* labels surfaced in {@code enum} / {@code query_field_options}. Falls back
* to {@link FormValueConverter#renderItem} when no valueOptions hint is
* registered.
*/
private static String renderItem(HasValue<?, ?> field, FormFieldHints hints,
Object item) {
if (hints != null && hints.itemLabelGenerator != null) {
return hints.itemLabelGenerator.apply(item);
}
return FormValueConverter.renderItem(field, item);
}

private static void applyNumberValue(ObjectNode node, Object value) {
// NaN / ±Infinity are valid Java doubles but not legal JSON numbers;
// surface as null rather than corrupting the payload with a
Expand All @@ -217,15 +234,15 @@ private static void applyIntegerValue(ObjectNode node, Object value) {
}

private static void applyMultiSelectValue(ObjectNode node,
HasValue<?, ?> field, Object value) {
HasValue<?, ?> field, FormFieldHints hints, Object value) {
// MULTI_SELECT is only assigned when the field implements MultiSelect,
// whose contract guarantees getValue() returns a Set. A non-Collection
// value would be a contract violation and produces an empty array
// here as graceful degradation.
var arr = node.putArray(FIELD_VALUE);
if (value instanceof Collection<?> coll) {
for (var v : coll) {
arr.add(FormValueConverter.renderItem(field, v));
arr.add(renderItem(field, hints, v));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -178,34 +177,27 @@ static final class RejectedValueException extends RuntimeException {

/**
* Converts a JSON node into a typed value suitable for
* {@link HasValue#setValue}. Returns the field's empty value when the JSON
* node is {@code null} or a JSON {@code null}. Throws
* {@link RejectedValueException} when the JSON shape doesn't match the
* field's type, or when the registered {@code fieldValueOptions} cannot
* resolve a label.
* {@link HasValue#setValue}. Returns the field's empty value for
* {@code null} or JSON {@code null}; throws {@link RejectedValueException}
* when the JSON shape doesn't match the field's type, or when a registered
* {@code fieldValueOptions} cannot resolve a label.
* <p>
* <b>Selection and {@code fieldValueOptions} routing:</b> when a field has
* a {@code valueOptionsToValue} registered, the LLM sees the field as an
* {@code enum} or {@code queryable} string in {@code get_form_state} and
* picks one or more labels. The label-to-value resolution happens here so
* the resulting object matches the field's value type before
* {@link HasValue#setValue} sees it. Multi-select fields expect a JSON
* array of labels; single-select and other label-routed fields expect a
* single string. A selection field without a {@code fieldValueOptions}
* registration falls back to matching the supplied label(s) against the
* field's own in-memory items (an eager {@code setItems(...)}), which carry
* the same labels {@code get_form_state} advertised; only a field that has
* neither a {@code fieldValueOptions} registration nor items is rejected
* with a curated reason naming the missing registration.
* For selection fields, resolution tries the registration's observed items
* first (matching the LLM-supplied label against
* {@link FormFieldHints#itemLabelGenerator} per item), then falls back to
* the field's own in-memory items (an eager {@code setItems(...)}). A
* selection field with no item source is rejected with a curated reason
* naming the missing registration. Multi-select fields expect a JSON array
* of labels; single-select and other label-routed fields expect a single
* string.
*/
static Object convert(FormFieldDescriptor field, JsonNode value) {
if (value == null || value.isNull()) {
return field.field().getEmptyValue();
}
// Multi-select takes an array of labels and applies toValue per
// element; handled before the fieldValueOptions check so the array
// shape is enforced even on fields whose value type is itself a String
// set.
// Multi-select takes an array of labels; handled before the
// fieldValueOptions check so the array shape is enforced even on
// fields whose value type is itself a String set.
if (field.type() == FormFieldType.MULTI_SELECT) {
return convertMultiSelect(field, value);
}
Expand All @@ -214,8 +206,8 @@ static Object convert(FormFieldDescriptor field, JsonNode value) {
// parsing would hand setValue a raw String and the field would reject
// it.
var hints = field.hints();
if (hints != null && hints.valueOptionsToValue != null) {
return convertSingleLabel(value, hints.valueOptionsToValue);
if (hints != null && hints.valueOptionsItems != null) {
return convertSingleLabelFromObservedItems(value, hints);
}
return switch (field.type()) {
case STRING, EMAIL -> convertString(value);
Expand Down Expand Up @@ -262,13 +254,21 @@ private static Object convertSingleSelectFromItems(
return resolveLabelAgainstItems(field.field(), value.asString(), items);
}

private static Object convertSingleLabel(JsonNode value,
Function<String, ?> toValue) {
/**
* Resolves one LLM-supplied label against the items the registration has
* seen. Walks {@link FormFieldHints#valueOptionsItems} and returns the
* first item whose label (via {@link FormFieldHints#itemLabelGenerator})
* matches. An empty observed-items list is the query-mode "registered but
* never queried" case and produces a rejection that nudges the LLM to call
* {@code query_field_options} first.
*/
private static Object convertSingleLabelFromObservedItems(JsonNode value,
FormFieldHints hints) {
if (!value.isString()) {
throw new RejectedValueException(
"Expected string label, got " + value);
}
return resolveLabel(value.asString(), toValue);
return resolveLabelAgainstObservedItems(value.asString(), hints);
}

private static Object convertMultiSelect(FormFieldDescriptor field,
Expand All @@ -284,19 +284,41 @@ private static Object convertMultiSelect(FormFieldDescriptor field,
return field.field().getEmptyValue();
}
var hints = field.hints();
var toValue = hints != null ? hints.valueOptionsToValue : null;
if (toValue == null) {
return convertMultiSelectFromItems(field, value);
}
var result = new LinkedHashSet<>();
for (var node : value) {
if (!node.isString()) {
throw new RejectedValueException(
"Expected string label, got " + node);
if (hints != null && hints.valueOptionsItems != null) {
var result = new LinkedHashSet<>();
for (var node : value) {
if (!node.isString()) {
throw new RejectedValueException(
"Expected string label, got " + node);
}
result.add(resolveLabelAgainstObservedItems(node.asString(),
hints));
}
result.add(resolveLabel(node.asString(), toValue));
return result;
}
return result;
return convertMultiSelectFromItems(field, value);
}

/**
* Returns the item that was indexed under the LLM-supplied label in
* {@link FormFieldHints#valueOptionsItems}. An empty map is the query-mode
* "not queried yet this turn" case and is called out with a hint at
* {@code query_field_options}, since that's the only way the LLM could have
* reached this point without seeing options.
*/
private static Object resolveLabelAgainstObservedItems(String label,
FormFieldHints hints) {
var item = hints.valueOptionsItems.get(label);
if (item != null) {
return item;
}
if (hints.valueOptionsItems.isEmpty()) {
throw new RejectedValueException("No matching option for label: "
+ label
+ " (call query_field_options first to load the field's options)");
}
throw new RejectedValueException(
"No matching option for label: " + label);
}

/**
Expand Down Expand Up @@ -346,30 +368,6 @@ private static Object resolveLabelAgainstItems(HasValue<?, ?> field,
"No matching option for label: " + label);
}

/**
* Resolves one LLM-supplied label via the application's
* {@code valueOptionsToValue}. Wraps the application's throw and the
* application's {@code null} return into curated rejection reasons — both
* surface back to the LLM verbatim through the {@code rejected} block, so
* the message must not leak third-party detail.
*/
private static Object resolveLabel(String label,
Function<String, ?> toValue) {
Object resolved;
try {
resolved = toValue.apply(label);
} catch (RuntimeException ex) {
LOGGER.debug("valueOptionsToValue threw for label {}", label, ex);
throw new RejectedValueException(
"Could not resolve label '" + label + "' to a value.");
}
if (resolved == null) {
throw new RejectedValueException(
"No matching option for label: " + label);
}
return resolved;
}

private static String convertString(JsonNode value) {
if (!value.isString()) {
throw new RejectedValueException("Expected string, got " + value);
Expand Down
Loading
Loading