diff --git a/common/interfaces.ts b/common/interfaces.ts index 80caca40..b05cda74 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -362,12 +362,33 @@ export type ModelConfig = { embeddingDimension?: number; }; +// Based off of JSONSchema. For more info, see https://json-schema.org/understanding-json-schema/reference/type +export type ModelInput = { + type: string; + description?: string; +}; + +export type ModelOutput = ModelInput; + +// For rendering options, we extract the name (the key in the input/output obj) and combine into a single obj +export type ModelInputFormField = ModelInput & { + label: string; +}; + +export type ModelOutputFormField = ModelInputFormField; + +export type ModelInterface = { + input: { [key: string]: ModelInput }; + output: { [key: string]: ModelOutput }; +}; + export type Model = { id: string; name: string; algorithm: MODEL_ALGORITHM; state: MODEL_STATE; modelConfig?: ModelConfig; + interface?: ModelInterface; }; export type ModelDict = { diff --git a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx index a6983f71..e28ca6bf 100644 --- a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx +++ b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx @@ -36,6 +36,7 @@ export function ConfigFieldList(props: ConfigFieldListProps) { // Default to ID if no optional formatted / prettified label provided label={field.label || field.id} fieldPath={`${props.baseConfigPath}.${configId}.${field.id}`} + showError={true} onFormChange={props.onFormChange} /> @@ -56,19 +57,6 @@ export function ConfigFieldList(props: ConfigFieldListProps) { ); break; } - case 'model': { - el = ( - - - - - ); - break; - } } return el; })} diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx index 4e2f8fed..be004acd 100644 --- a/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx @@ -35,6 +35,8 @@ interface MapArrayFieldProps { onFormChange: () => void; onMapAdd?: (curArray: MapArrayFormValue) => void; onMapDelete?: (idxToDelete: number) => void; + keyOptions?: any[]; + valueOptions?: any[]; } /** @@ -122,6 +124,8 @@ export function MapArrayField(props: MapArrayFieldProps) { keyPlaceholder={props.keyPlaceholder} valuePlaceholder={props.valuePlaceholder} onFormChange={props.onFormChange} + keyOptions={props.keyOptions} + valueOptions={props.valueOptions} /> diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/map_field.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/map_field.tsx index 3959170c..d601a961 100644 --- a/public/pages/workflow_detail/workflow_inputs/input_fields/map_field.tsx +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/map_field.tsx @@ -9,17 +9,20 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - EuiFormControlLayoutDelimited, EuiFormRow, + EuiIcon, EuiLink, EuiText, } from '@elastic/eui'; import { Field, FieldProps, getIn, useFormikContext } from 'formik'; +import { isEmpty } from 'lodash'; import { MapEntry, MapFormValue, WorkflowFormValues, } from '../../../../../common'; +import { SelectWithCustomOptions } from './select_with_custom_options'; +import { TextField } from './text_field'; interface MapFieldProps { fieldPath: string; // the full path in string-form to the field (e.g., 'ingest.enrich.processors.text_embedding_processor.inputField') @@ -29,10 +32,14 @@ interface MapFieldProps { keyPlaceholder?: string; valuePlaceholder?: string; onFormChange: () => void; + keyOptions?: any[]; + valueOptions?: any[]; } /** - * Input component for configuring field mappings + * Input component for configuring field mappings. Input forms are defaulted to text fields. If + * keyOptions or valueOptions are set, set the respective input form as a select field, with those options. + * Allow custom options as a backup/default to ensure flexibility. */ export function MapField(props: MapFieldProps) { const { setFieldValue, setFieldTouched, errors, touched } = useFormikContext< @@ -96,47 +103,56 @@ export function MapField(props: MapFieldProps) { - { - form.setFieldTouched( - `${props.fieldPath}.${idx}.key`, - true - ); - form.setFieldValue( - `${props.fieldPath}.${idx}.key`, - e.target.value - ); - props.onFormChange(); - }} - /> - } - endControl={ - { - form.setFieldTouched( - `${props.fieldPath}.${idx}.value`, - true - ); - form.setFieldValue( - `${props.fieldPath}.${idx}.value`, - e.target.value - ); - props.onFormChange(); - }} - /> - } - /> + + + <> + {!isEmpty(props.keyOptions) ? ( + + ) : ( + + )} + + + + + + + <> + {!isEmpty(props.valueOptions) ? ( + + ) : ( + + )} + + + void; onFormChange: () => void; } type ModelItem = ModelFormValue & { name: string; + interface?: {}; }; /** @@ -56,6 +61,7 @@ export function ModelField(props: ModelFieldProps) { id: modelId, name: models[modelId].name, algorithm: models[modelId].algorithm, + interface: models[modelId].interface, } as ModelItem); } }); @@ -64,61 +70,75 @@ export function ModelField(props: ModelFieldProps) { }, [models]); return ( - - {({ field, form }: FieldProps) => { - return ( - - - Learn more - - - } - helpText={'The model ID.'} - > - - ({ - value: option.id, - inputDisplay: ( - <> - {option.name} - - ), - dropdownDisplay: ( - <> - {option.name} - - Deployed - - - {option.algorithm} - - - ), - disabled: false, - } as EuiSuperSelectOption) - )} - valueOfSelected={field.value?.id || ''} - onChange={(option: string) => { - form.setFieldTouched(props.fieldPath, true); - form.setFieldValue(props.fieldPath, { - id: option, - } as ModelFormValue); - props.onFormChange(); - }} - isInvalid={ - getIn(errors, field.name) && getIn(touched, field.name) - ? true - : undefined + <> + {!props.hasModelInterface && ( + <> + + + + )} + + {({ field, form }: FieldProps) => { + return ( + + + Learn more + + } - /> - - ); - }} - + helpText={'The model ID.'} + > + + ({ + value: option.id, + inputDisplay: ( + <> + {option.name} + + ), + dropdownDisplay: ( + <> + {option.name} + + Deployed + + + {option.algorithm} + + + ), + disabled: false, + } as EuiSuperSelectOption) + )} + valueOfSelected={field.value?.id || ''} + onChange={(option: string) => { + form.setFieldTouched(props.fieldPath, true); + form.setFieldValue(props.fieldPath, { + id: option, + } as ModelFormValue); + props.onFormChange(); + props.onModelChange(option); + }} + isInvalid={ + getIn(errors, field.name) && getIn(touched, field.name) + ? true + : undefined + } + /> + + ); + }} + + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/select_with_custom_options.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/select_with_custom_options.tsx new file mode 100644 index 00000000..f3afe702 --- /dev/null +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/select_with_custom_options.tsx @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { getIn, useFormikContext } from 'formik'; +import { get, isEmpty } from 'lodash'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { WorkspaceFormValues } from '../../../../../common'; + +interface SelectWithCustomOptionsProps { + fieldPath: string; + placeholder: string; + options: any[]; + onFormChange: () => void; +} + +/** + * A generic select field from a list of preconfigured options, and the functionality to add more options + */ +export function SelectWithCustomOptions(props: SelectWithCustomOptionsProps) { + const { values, setFieldTouched, setFieldValue } = useFormikContext< + WorkspaceFormValues + >(); + + // selected option state + const [selectedOption, setSelectedOption] = useState([]); + + // update the selected option when the form is updated. set to empty if the form value is undefined + // or an empty string ('') + useEffect(() => { + const formValue = getIn(values, props.fieldPath); + if (!isEmpty(formValue)) { + setSelectedOption([{ label: getIn(values, props.fieldPath) }]); + } else { + setSelectedOption([]); + } + }, [getIn(values, props.fieldPath)]); + + // custom handler when users create a custom option + // only update the form value if non-empty + function onCreateOption(searchValue: any): void { + const normalizedSearchValue = searchValue.trim()?.toLowerCase(); + if (!normalizedSearchValue) { + return; + } + setFieldTouched(props.fieldPath, true); + setFieldValue(props.fieldPath, searchValue); + props.onFormChange(); + } + + // custom render fn. + function renderOption(option: any, searchValue: string) { + return ( + + + {option.label} + + + + {`(${option.type || 'unknown type'})`} + + + + ); + } + + return ( + { + setFieldTouched(props.fieldPath, true); + setFieldValue(props.fieldPath, get(options, '0.label')); + props.onFormChange(); + }} + onCreateOption={onCreateOption} + customOptionText="Add {searchValue} as a custom option" + /> + ); +} diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/text_field.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/text_field.tsx index 6afb2efb..e9c40447 100644 --- a/public/pages/workflow_detail/workflow_inputs/input_fields/text_field.tsx +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/text_field.tsx @@ -16,6 +16,7 @@ interface TextFieldProps { helpLink?: string; helpText?: string; placeholder?: string; + showError?: boolean; } /** @@ -41,7 +42,7 @@ export function TextField(props: TextFieldProps) { ) : undefined } helpText={props.helpText || undefined} - error={getIn(errors, field.name)} + error={props.showError && getIn(errors, field.name)} isInvalid={getIn(errors, field.name) && getIn(touched, field.name)} > void; onFormChange: () => void; } @@ -173,6 +174,7 @@ export function InputTransformModal(props: InputTransformModalProps) { helpLink={ML_INFERENCE_DOCS_LINK} keyPlaceholder="Model input field" valuePlaceholder="Document field" + keyOptions={props.inputFields} onFormChange={props.onFormChange} // If the map we are adding is the first one, populate the selected option to index 0 onMapAdd={(curArray) => { diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx index ba6640e2..acbd7b48 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { getIn, useFormikContext } from 'formik'; +import { useSelector } from 'react-redux'; import { EuiButtonEmpty, EuiCallOut, @@ -20,12 +21,16 @@ import { PROCESSOR_CONTEXT, WorkflowConfig, JSONPATH_ROOT_SELECTOR, + ModelInputFormField, + ModelOutputFormField, ML_INFERENCE_DOCS_LINK, } from '../../../../../common'; import { MapArrayField, ModelField } from '../input_fields'; import { isEmpty } from 'lodash'; import { InputTransformModal } from './input_transform_modal'; import { OutputTransformModal } from './output_transform_modal'; +import { AppState } from '../../../../store'; +import { parseModelInputs, parseModelOutputs } from '../../../../utils'; interface MLProcessorInputsProps { uiConfig: WorkflowConfig; @@ -36,11 +41,16 @@ interface MLProcessorInputsProps { } /** - * Component to render ML processor inputs. Offers simple and advanced flows for configuring data transforms - * before and after executing an ML inference request + * Component to render ML processor inputs, including the model selection, and the + * optional configurations of input maps and output maps. We persist any model interface + * state here as well, to propagate expected model inputs / outputs to to the input map / + * output map configuration forms, respectively. */ export function MLProcessorInputs(props: MLProcessorInputsProps) { - const { values } = useFormikContext(); + const models = useSelector((state: AppState) => state.models.models); + const { values, setFieldValue, setFieldTouched } = useFormikContext< + WorkspaceFormValues + >(); // extracting field info from the ML processor config // TODO: have a better mechanism for guaranteeing the expected fields/config instead of hardcoding them here @@ -67,6 +77,46 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { boolean >(false); + // model interface state + const [hasModelInterface, setHasModelInterface] = useState(true); + const [inputFields, setInputFields] = useState([]); + const [outputFields, setOutputFields] = useState([]); + + // Hook to listen when the selected model has changed. We do a few checks here: + // 1: update model interface states + // 2. clear out any persisted inputMap/outputMap form values, as those would now be invalid + function onModelChange(modelId: string) { + updateModelInterfaceStates(modelId); + setFieldValue(inputMapFieldPath, []); + setFieldValue(outputMapFieldPath, []); + setFieldTouched(inputMapFieldPath, false); + setFieldTouched(outputMapFieldPath, false); + } + + // on initial load of the models, update model interface states + useEffect(() => { + if (!isEmpty(models)) { + const modelId = getIn(values, `${modelFieldPath}.id`); + if (modelId) { + updateModelInterfaceStates(modelId); + } + } + }, [models]); + + // reusable function to update interface states based on the model ID + function updateModelInterfaceStates(modelId: string) { + const newSelectedModel = models[modelId]; + if (newSelectedModel?.interface !== undefined) { + setInputFields(parseModelInputs(newSelectedModel.interface)); + setOutputFields(parseModelOutputs(newSelectedModel.interface)); + setHasModelInterface(true); + } else { + setInputFields([]); + setOutputFields([]); + setHasModelInterface(false); + } + } + return ( <> {isInputTransformModalOpen && ( @@ -76,6 +126,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { context={props.context} inputMapField={inputMapField} inputMapFieldPath={inputMapFieldPath} + inputFields={inputFields} onFormChange={props.onFormChange} onClose={() => setIsInputTransformModalOpen(false)} /> @@ -87,6 +138,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { context={props.context} outputMapField={outputMapField} outputMapFieldPath={outputMapFieldPath} + outputFields={outputFields} onFormChange={props.onFormChange} onClose={() => setIsOutputTransformModalOpen(false)} /> @@ -94,6 +146,8 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { {!isEmpty(getIn(values, modelFieldPath)?.id) && ( @@ -133,6 +187,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { keyPlaceholder="Model input field" valuePlaceholder="Document field" onFormChange={props.onFormChange} + keyOptions={inputFields} /> @@ -159,11 +214,12 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { field={outputMapField} fieldPath={outputMapFieldPath} label="Output Map" - helpText={`An array specifying how to map the model’s output to new fields.`} + helpText={`An array specifying how to map the model’s output to new document fields.`} helpLink={ML_INFERENCE_DOCS_LINK} keyPlaceholder="New document field" valuePlaceholder="Model output field" onFormChange={props.onFormChange} + valueOptions={outputFields} /> {inputMapValue.length !== outputMapValue.length && diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/output_transform_modal.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/output_transform_modal.tsx index d2232115..fad5d0d6 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/output_transform_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/output_transform_modal.tsx @@ -49,6 +49,7 @@ interface OutputTransformModalProps { context: PROCESSOR_CONTEXT; outputMapField: IConfigField; outputMapFieldPath: string; + outputFields: any[]; onClose: () => void; onFormChange: () => void; } @@ -170,6 +171,7 @@ export function OutputTransformModal(props: OutputTransformModalProps) { helpLink={ML_INFERENCE_DOCS_LINK} keyPlaceholder="New document field" valuePlaceholder="Model output field" + valueOptions={props.outputFields} onFormChange={props.onFormChange} // If the map we are adding is the first one, populate the selected option to index 0 onMapAdd={(curArray) => { diff --git a/public/utils/utils.ts b/public/utils/utils.ts index 3473f495..4e78bbb8 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -9,6 +9,11 @@ import { get } from 'lodash'; import { JSONPATH_ROOT_SELECTOR, MapFormValue, + ModelInput, + ModelInputFormField, + ModelInterface, + ModelOutput, + ModelOutputFormField, SimulateIngestPipelineDoc, SimulateIngestPipelineResponse, WORKFLOW_RESOURCE_TYPE, @@ -187,3 +192,39 @@ export function generateTransform(input: {}, map: MapFormValue): {} { }); return output; } + +// Derive the collection of model inputs from the model interface JSONSchema into a form-ready list +export function parseModelInputs( + modelInterface: ModelInterface +): ModelInputFormField[] { + const modelInputsObj = get( + modelInterface, + // model interface input values will always be nested under a base "parameters" obj. + // we iterate through the obj properties to extract the individual inputs + 'input.properties.parameters.properties', + {} + ) as { [key: string]: ModelInput }; + return Object.keys(modelInputsObj).map( + (inputName: string) => + ({ + label: inputName, + ...modelInputsObj[inputName], + } as ModelInputFormField) + ); +} + +// Derive the collection of model outputs from the model interface JSONSchema into a form-ready list +export function parseModelOutputs( + modelInterface: ModelInterface +): ModelOutputFormField[] { + const modelOutputsObj = get(modelInterface, 'output.properties', {}) as { + [key: string]: ModelOutput; + }; + return Object.keys(modelOutputsObj).map( + (outputName: string) => + ({ + label: outputName, + ...modelOutputsObj[outputName], + } as ModelOutputFormField) + ); +} diff --git a/server/routes/helpers.ts b/server/routes/helpers.ts index 74dcb60d..25b57955 100644 --- a/server/routes/helpers.ts +++ b/server/routes/helpers.ts @@ -10,6 +10,7 @@ import { MODEL_STATE, Model, ModelDict, + ModelInterface, WORKFLOW_RESOURCE_TYPE, WORKFLOW_STATE, Workflow, @@ -90,6 +91,21 @@ export function getModelsFromResponses(modelHits: any[]): ModelDict { // search model API returns hits for each deployed model chunk. ignore these hits if (modelHit._source.chunk_number === undefined) { const modelId = modelHit._id; + + // the persisted model interface (if available) is a mix of an obj and string. + // We parse the string values for input/output to have a complete + // end-to-end JSONSchema obj + let indexedModelInterface = modelHit._source.interface as + | { input: string; output: string } + | undefined; + let modelInterface = undefined as ModelInterface | undefined; + if (indexedModelInterface !== undefined) { + modelInterface = { + input: JSON.parse(indexedModelInterface.input), + output: JSON.parse(indexedModelInterface.output), + } as ModelInterface; + } + // in case of schema changes from ML plugin, this may crash. That is ok, as the error // produced will help expose the root cause modelDict[modelId] = { @@ -104,6 +120,7 @@ export function getModelsFromResponses(modelHits: any[]): ModelDict { embeddingDimension: modelHit._source?.model_config?.embedding_dimension, }, + interface: modelInterface, } as Model; } });