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;
}
});