Skip to content

Commit 83752f6

Browse files
committed
refactor: use map to hold entries
1 parent e4b4feb commit 83752f6

5 files changed

Lines changed: 276 additions & 52 deletions

File tree

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,8 @@ private FormAIController applyValueOptions(ValueOptions<?> config) {
391391
// forField(HasValue) accepts MultiSelect fields whose static reference
392392
// is upcast. Reject so the typed MultiSelect overload is the only
393393
// entry for multi-select registrations.
394-
var isMultiSelect = config.field() instanceof MultiSelect;
394+
var field = config.field();
395+
var isMultiSelect = field instanceof MultiSelect;
395396
if (!config.isMulti() && isMultiSelect) {
396397
throw new IllegalArgumentException(
397398
"Field implements MultiSelect — declare the reference as "
@@ -400,49 +401,89 @@ private FormAIController applyValueOptions(ValueOptions<?> config) {
400401
}
401402
// Collection-valued fields must implement MultiSelect — otherwise
402403
// there is no defined aggregation for the resolved items.
403-
if (!isMultiSelect
404-
&& config.field().getEmptyValue() instanceof Collection) {
404+
if (!isMultiSelect && field.getEmptyValue() instanceof Collection) {
405405
throw new IllegalArgumentException(
406406
"Field's value type is a Collection but the field does "
407407
+ "not implement MultiSelect. Collection-valued "
408408
+ "fields must implement MultiSelect to be "
409409
+ "registered via fieldValueOptions(...).");
410410
}
411-
var labeler = resolveItemLabeler(config.field(),
412-
config.itemLabelGenerator());
413-
var hints = hintsFor(config.field());
414-
hints.itemLabelGenerator = labeler;
415-
// Items the converter resolves chosen labels against. Pre-populated
416-
// with the fixed list, or appended on each query-callback invocation.
417-
var observedItems = new ArrayList<>();
418-
hints.valueOptionsItems = observedItems;
411+
var hints = hintsFor(field);
412+
var explicit = config.itemLabelGenerator();
419413
if (fixed != null) {
420414
@SuppressWarnings({ "unchecked", "rawtypes" })
421415
List<Object> items = (List) fixed;
422-
observedItems.addAll(items);
423-
warnOnDuplicateLabels(items, labeler);
424-
hints.valueOptionsQuery = (filter, limit) -> filterAndLimit(items,
425-
filter, limit, labeler);
426416
hints.fixedOptions = true;
417+
hints.valueOptionsTurnSetup = () -> rebindFixedOptions(hints, field,
418+
explicit, items);
419+
hints.valueOptionsTurnSetup.run();
420+
warnOnDuplicateLabels(items, hints.itemLabelGenerator);
427421
} else {
428422
@SuppressWarnings({ "unchecked", "rawtypes" })
429423
BiFunction<String, Integer, List<Object>> rawQuery = (BiFunction) query;
430-
hints.valueOptionsQuery = (filter, limit) -> {
431-
var batch = rawQuery.apply(filter, limit);
432-
observedItems.addAll(batch);
433-
return batch.stream().map(labeler).toList();
434-
};
435424
hints.fixedOptions = false;
425+
hints.valueOptionsTurnSetup = () -> rebindQueryOptions(hints, field,
426+
explicit, rawQuery);
427+
hints.valueOptionsTurnSetup.run();
436428
}
437429
return this;
438430
}
439431

432+
/**
433+
* Rebuilds the fixed-options bindings on {@code hints} from the original
434+
* item list and the current state of {@code field} / {@code explicit}.
435+
* Invoked at registration and again on every {@link #onRequest()} so the
436+
* labeler captured in {@link FormFieldHints#itemLabelGenerator} reflects
437+
* the field's current {@code setItemLabelGenerator(...)}.
438+
*/
439+
private static void rebindFixedOptions(FormFieldHints hints,
440+
HasValue<?, ?> field, ItemLabelGenerator<?> explicit,
441+
List<Object> items) {
442+
var labeler = resolveItemLabeler(field, explicit);
443+
hints.itemLabelGenerator = labeler;
444+
var map = new LinkedHashMap<String, Object>(items.size());
445+
for (var item : items) {
446+
map.putIfAbsent(labeler.apply(item), item);
447+
}
448+
hints.valueOptionsItems = map;
449+
hints.valueOptionsQuery = (filter, limit) -> filterLabels(map.keySet(),
450+
filter, limit);
451+
}
452+
453+
/**
454+
* Rebuilds the query-options bindings on {@code hints} and resets the
455+
* observed-items map. The wrapped query callback dedupes by label as
456+
* batches arrive (first item per label wins) so repeated overlapping
457+
* queries within the same turn don't inflate the map.
458+
*/
459+
private static void rebindQueryOptions(FormFieldHints hints,
460+
HasValue<?, ?> field, ItemLabelGenerator<?> explicit,
461+
BiFunction<String, Integer, List<Object>> rawQuery) {
462+
var labeler = resolveItemLabeler(field, explicit);
463+
hints.itemLabelGenerator = labeler;
464+
var map = new LinkedHashMap<String, Object>();
465+
hints.valueOptionsItems = map;
466+
hints.valueOptionsQuery = (filter, limit) -> {
467+
var batch = rawQuery.apply(filter, limit);
468+
var labels = new ArrayList<String>(batch.size());
469+
for (var item : batch) {
470+
var label = labeler.apply(item);
471+
labels.add(label);
472+
map.putIfAbsent(label, item);
473+
}
474+
return labels;
475+
};
476+
}
477+
440478
/**
441479
* Logs a warning when two or more items in a fixed-options registration
442-
* render to the same label. Resolution falls back to first-in-list
443-
* ordering, so duplicates are recoverable but ambiguous — a unique
480+
* render to the same label. Resolution under
481+
* {@link FormFieldHints#valueOptionsItems} keeps only the first-per-label
482+
* (the Map's {@code putIfAbsent} semantic), so duplicates are recoverable
483+
* but ambiguous — a unique
444484
* {@link ValueOptions#itemLabelGenerator(ItemLabelGenerator)} is the
445-
* unambiguous fix.
485+
* unambiguous fix. Fires once at registration; a labeler swap between turns
486+
* that introduces new duplicates does not re-warn.
446487
*/
447488
private static void warnOnDuplicateLabels(List<Object> items,
448489
Function<Object, String> labeler) {
@@ -469,7 +510,8 @@ private static void warnOnDuplicateLabels(List<Object> items,
469510
* Priority: an explicit {@link ItemLabelGenerator} on the registration,
470511
* otherwise the field's own {@code getItemLabelGenerator()} (via
471512
* {@link FormValueConverter#renderItem}, which also covers the
472-
* {@link String#valueOf} fallback).
513+
* {@link String#valueOf} fallback). Called once per turn at
514+
* {@link #onRequest()} so a swap on the field between turns is picked up.
473515
*/
474516
private static Function<Object, String> resolveItemLabeler(
475517
HasValue<?, ?> field, ItemLabelGenerator<?> explicit) {
@@ -729,10 +771,23 @@ public void onRequest() {
729771
// are picked up.
730772
attachIds();
731773
seedDescriptionsFromBinder();
774+
refreshValueOptionsBindings();
732775
lockFields();
733776
snapshotPreTurnValues();
734777
}
735778

779+
/**
780+
* Re-runs each value-options registration's setup at the start of a turn.
781+
* Captures the current item-label generator from the field so a swap
782+
* between turns is reflected, repopulates the fixed-options map from its
783+
* immutable list, and clears the query-options map so each turn starts with
784+
* a fresh cache.
785+
*/
786+
private void refreshValueOptionsBindings() {
787+
hintsById.values().stream().map(hints -> hints.valueOptionsTurnSetup)
788+
.filter(Objects::nonNull).forEach(Runnable::run);
789+
}
790+
736791
@Override
737792
public void onResponse(Throwable error) {
738793
try {
@@ -978,13 +1033,13 @@ private static String getOrCreateId(HasValue<?, ?> field) {
9781033
return id;
9791034
}
9801035

981-
private static List<String> filterAndLimit(List<Object> source,
982-
String filter, int limit, Function<Object, String> labeler) {
983-
var stream = source.stream().map(labeler);
1036+
private static List<String> filterLabels(Collection<String> labels,
1037+
String filter, int limit) {
1038+
var stream = labels.stream();
9841039
if (filter != null && !filter.isEmpty()) {
9851040
var needle = filter.toLowerCase(Locale.ROOT);
986-
stream = stream
987-
.filter(o -> o.toLowerCase(Locale.ROOT).contains(needle));
1041+
stream = stream.filter(
1042+
label -> label.toLowerCase(Locale.ROOT).contains(needle));
9881043
}
9891044
return stream.limit(limit).toList();
9901045
}

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormFieldHints.java

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.vaadin.flow.component.ai.form;
1717

1818
import java.util.List;
19+
import java.util.Map;
1920
import java.util.function.BiFunction;
2021
import java.util.function.Function;
2122

@@ -40,25 +41,25 @@ final class FormFieldHints {
4041
*/
4142
BiFunction<String, Integer, List<String>> valueOptionsQuery;
4243
/**
43-
* Items the controller has seen for this registration: the fixed list for
44-
* {@link ValueOptions#options(java.util.Collection)}, or items accumulated
45-
* from {@link ValueOptions#options(java.util.function.BiFunction)} batches.
46-
* {@link FormValueConverter} walks this list at fill time, applies
47-
* {@link #itemLabelGenerator} per item, and returns the first whose label
48-
* matches the LLM-supplied one (insertion order — first-wins on
49-
* duplicates). Non-{@code null} whenever {@link #valueOptionsQuery} is set;
50-
* empty until the query callback runs for query-mode registrations.
44+
* Items the controller has seen for this registration, keyed by their
45+
* LLM-facing label. {@link FormValueConverter} resolves a chosen label at
46+
* fill time via {@link Map#get(Object)} — O(1) — and the first put per
47+
* label wins. Populated upfront for fixed-options registrations and as each
48+
* query-callback batch arrives for query-mode registrations. Iteration
49+
* order is insertion order. Reset at each
50+
* {@link FormAIController#onRequest()} turn boundary; non-{@code null}
51+
* whenever {@link #valueOptionsQuery} is set.
5152
*/
52-
List<Object> valueOptionsItems;
53+
Map<String, Object> valueOptionsItems;
5354
/**
5455
* Item-to-label function used to render the field's current value and to
55-
* resolve LLM-supplied labels back to items via {@link #valueOptionsItems}.
56-
* Resolved at registration to the explicit
56+
* compute the keys in {@link #valueOptionsItems}. Captured at each
57+
* {@link FormAIController#onRequest()} from the explicit
5758
* {@link ValueOptions#itemLabelGenerator(com.vaadin.flow.component.ItemLabelGenerator)}
58-
* or to a delegate that defers to {@link FormValueConverter#renderItem}
59-
* (field's own {@code getItemLabelGenerator()}, then
60-
* {@link String#valueOf(Object)}). Non-{@code null} whenever
61-
* {@link #valueOptionsQuery} is set.
59+
* if set, otherwise from the field's own {@code getItemLabelGenerator()}
60+
* (read reflectively), otherwise {@link String#valueOf(Object)}. Stable
61+
* within a turn so {@link #valueOptionsItems}' keys remain valid for
62+
* lookup. Non-{@code null} whenever {@link #valueOptionsQuery} is set.
6263
*/
6364
Function<Object, String> itemLabelGenerator;
6465
/**
@@ -67,5 +68,16 @@ final class FormFieldHints {
6768
* vs {@code queryable} choice in {@link FormFieldSchema}.
6869
*/
6970
boolean fixedOptions;
71+
/**
72+
* Rebuilds {@link #valueOptionsItems}, {@link #valueOptionsQuery}, and
73+
* {@link #itemLabelGenerator} from the registration's captured config
74+
* (fixed list or query callback, explicit labeler or field reference).
75+
* Invoked once at registration so the schema works before the first turn,
76+
* and again at each {@link FormAIController#onRequest()} so a labeler
77+
* change on the field between turns is picked up and the query-mode map
78+
* starts each turn empty. {@code null} when no value-options registration
79+
* exists for this field.
80+
*/
81+
Runnable valueOptionsTurnSetup;
7082
boolean ignored;
7183
}

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormValueConverter.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,18 +300,17 @@ private static Object convertMultiSelect(FormFieldDescriptor field,
300300
}
301301

302302
/**
303-
* Walks {@link FormFieldHints#valueOptionsItems} and returns the first item
304-
* whose label (via {@link FormFieldHints#itemLabelGenerator}) matches the
305-
* LLM-supplied one. An empty list is called out with a hint at
303+
* Returns the item that was indexed under the LLM-supplied label in
304+
* {@link FormFieldHints#valueOptionsItems}. An empty map is the query-mode
305+
* "not queried yet this turn" case and is called out with a hint at
306306
* {@code query_field_options}, since that's the only way the LLM could have
307307
* reached this point without seeing options.
308308
*/
309309
private static Object resolveLabelAgainstObservedItems(String label,
310310
FormFieldHints hints) {
311-
for (var item : hints.valueOptionsItems) {
312-
if (label.equals(hints.itemLabelGenerator.apply(item))) {
313-
return item;
314-
}
311+
var item = hints.valueOptionsItems.get(label);
312+
if (item != null) {
313+
return item;
315314
}
316315
if (hints.valueOptionsItems.isEmpty()) {
317316
throw new RejectedValueException("No matching option for label: "

0 commit comments

Comments
 (0)