[ML Inference] Multi-field selector for ELSER (#154598)

## Summary

This PR adds UI components for creating multiple field mappings for an
inference pipeline. In the field configuration step existing source
fields can be selected from the mapping, or non-existent fields can be
typed in. Clicking the Add button sets the mapping; the target field
name is derived from the source field name as
`ml.inference.<source_field_name>_expanded`.

The selected mappings are listed in a table on the same screen. The
trashcan icon can be used to remove a mapping.

This field configuration screen only shows if an ELSER model was
selected for the pipeline. For any other model types the classic "single
source and target field" screen is shown (the target field can be set by
the user).

Attaching an existing ELSER pipeline is disabled - supporting this will
be in scope for a separate PR.

I also added the logic to modify the pipeline generator logic in case
multiple field mappings are selected. In this case a `remove` and an
`inference` processor are generated for each selected mapping.

![Screenshot 2023-04-10 at 5 46 24
PM](https://user-images.githubusercontent.com/14224983/231008803-26a0c5ba-748d-4377-87c5-a40717426c4c.png)
![Screenshot 2023-04-10 at 5 46 34
PM](https://user-images.githubusercontent.com/14224983/231008804-a6f7e508-afcd-4227-87cb-9fac5f607132.png)
![Screenshot 2023-04-10 at 5 46 44
PM](https://user-images.githubusercontent.com/14224983/231008805-0ba61948-c8b6-411a-8969-f6aeaa820c39.png)
![Screenshot 2023-04-10 at 6 01 55
PM](https://user-images.githubusercontent.com/14224983/231008808-70ab3b10-dab6-41aa-a2e6-e46bdc259ca3.png)


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Adam Demjen 2023-04-11 16:02:37 -04:00 committed by GitHub
parent 26f65b3262
commit d2f7860cff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 905 additions and 200 deletions

View file

@ -231,6 +231,62 @@ describe('generateMlInferencePipelineBody lib function', () => {
})
);
});
it('should return something expected with multiple fields', () => {
const actual: MlInferencePipeline = generateMlInferencePipelineBody({
description: 'my-description',
model: mockModel,
pipelineName: 'my-pipeline',
fieldMappings: [
{ sourceField: 'my-source-field1', targetField: 'my-destination-field1' },
{ sourceField: 'my-source-field2', targetField: 'my-destination-field2' },
{ sourceField: 'my-source-field3', targetField: 'my-destination-field3' },
],
});
expect(actual).toEqual(
expect.objectContaining({
processors: expect.arrayContaining([
{
remove: expect.objectContaining({
field: 'ml.inference.my-destination-field1',
}),
},
{
remove: expect.objectContaining({
field: 'ml.inference.my-destination-field2',
}),
},
{
remove: expect.objectContaining({
field: 'ml.inference.my-destination-field3',
}),
},
{
inference: expect.objectContaining({
field_map: {
'my-source-field1': 'MODEL_INPUT_FIELD',
},
}),
},
{
inference: expect.objectContaining({
field_map: {
'my-source-field2': 'MODEL_INPUT_FIELD',
},
}),
},
{
inference: expect.objectContaining({
field_map: {
'my-source-field3': 'MODEL_INPUT_FIELD',
},
}),
},
]),
})
);
});
});
describe('parseMlInferenceParametersFromPipeline', () => {

View file

@ -6,6 +6,7 @@
*/
import {
IngestInferenceProcessor,
IngestPipeline,
IngestRemoveProcessor,
IngestSetProcessor,
@ -54,67 +55,93 @@ export const generateMlInferencePipelineBody = ({
model,
pipelineName,
}: MlInferencePipelineParams): MlInferencePipeline => {
// if model returned no input field, insert a placeholder
const inferenceType = Object.keys(model.inference_config)[0];
const pipelineDefinition: MlInferencePipeline = {
description: description ?? '',
processors: [],
version: 1,
};
pipelineDefinition.processors = [
// Add remove and inference processors
...fieldMappings.flatMap(({ sourceField, targetField }) => {
const remove = getRemoveProcessorForInferenceType(targetField, inferenceType);
const inference = getInferenceProcessor(
sourceField,
targetField,
inferenceConfig,
model,
pipelineName
);
return [
{
remove: {
field: getMlInferencePrefixedFieldName(targetField),
ignore_missing: true,
},
},
...(remove ? [{ remove }] : []),
{ inference },
];
}),
// Add single append processor
{
append: {
field: '_source._ingest.processors',
value: [
{
model_version: model.version,
pipeline: pipelineName,
processed_timestamp: '{{{ _ingest.timestamp }}}',
types: getMlModelTypesForModelConfig(model),
},
],
},
},
// Add set processors
...fieldMappings.flatMap(({ targetField }) => {
const set = getSetProcessorForInferenceType(targetField, inferenceType);
return set ? [{ set }] : [];
}),
];
return pipelineDefinition;
};
export const getInferenceProcessor = (
sourceField: string,
targetField: string,
inferenceConfig: InferencePipelineInferenceConfig | undefined,
model: MlTrainedModelConfig,
pipelineName: string
): IngestInferenceProcessor => {
// If model returned no input field, insert a placeholder
const modelInputField =
model.input?.field_names?.length > 0 ? model.input.field_names[0] : 'MODEL_INPUT_FIELD';
// For now this only works for a single field mapping
const sourceField = fieldMappings[0].sourceField;
const targetField = fieldMappings[0].targetField;
const inferenceType = Object.keys(model.inference_config)[0];
const remove = getRemoveProcessorForInferenceType(targetField, inferenceType);
const set = getSetProcessorForInferenceType(targetField, inferenceType);
return {
description: description ?? '',
processors: [
{
remove: {
field: getMlInferencePrefixedFieldName(targetField),
ignore_missing: true,
},
},
...(remove ? [{ remove }] : []),
{
inference: {
field_map: {
[sourceField]: modelInputField,
},
inference_config: inferenceConfig,
model_id: model.model_id,
on_failure: [
{
append: {
field: '_source._ingest.inference_errors',
value: [
{
message: `Processor 'inference' in pipeline '${pipelineName}' failed with message '{{ _ingest.on_failure_message }}'`,
pipeline: pipelineName,
timestamp: '{{{ _ingest.timestamp }}}',
},
],
},
},
],
target_field: getMlInferencePrefixedFieldName(targetField),
},
},
field_map: {
[sourceField]: modelInputField,
},
inference_config: inferenceConfig,
model_id: model.model_id,
on_failure: [
{
append: {
field: '_source._ingest.processors',
field: '_source._ingest.inference_errors',
value: [
{
model_version: model.version,
message: `Processor 'inference' in pipeline '${pipelineName}' failed with message '{{ _ingest.on_failure_message }}'`,
pipeline: pipelineName,
processed_timestamp: '{{{ _ingest.timestamp }}}',
types: getMlModelTypesForModelConfig(model),
timestamp: '{{{ _ingest.timestamp }}}',
},
],
},
},
...(set ? [{ set }] : []),
],
version: 1,
target_field: getMlInferencePrefixedFieldName(targetField),
};
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { ConfigureFields } from './configure_fields';
import { MultiFieldMapping, SelectedFieldMappings } from './multi_field_selector';
import { SingleFieldMapping } from './single_field_selector';
describe('ConfigureFields', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues({});
});
it('renders multi-field selector components if non-text expansion model is selected', () => {
setMockValues({
isTextExpansionModelSelected: false,
});
const wrapper = shallow(<ConfigureFields />);
expect(wrapper.find(SingleFieldMapping)).toHaveLength(1);
expect(wrapper.find(MultiFieldMapping)).toHaveLength(0);
expect(wrapper.find(SelectedFieldMappings)).toHaveLength(0);
});
it('renders multi-field selector components if text expansion model is selected', () => {
setMockValues({
isTextExpansionModelSelected: true,
});
const wrapper = shallow(<ConfigureFields />);
expect(wrapper.find(SingleFieldMapping)).toHaveLength(0);
expect(wrapper.find(MultiFieldMapping)).toHaveLength(1);
expect(wrapper.find(SelectedFieldMappings)).toHaveLength(1);
});
});

View file

@ -7,65 +7,19 @@
import React from 'react';
import { useValues, useActions } from 'kea';
import { useValues } from 'kea';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiPanel,
EuiSelect,
EuiSpacer,
EuiTitle,
EuiText,
} from '@elastic/eui';
import { EuiForm, EuiSpacer, EuiTitle, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../../../shared/doc_links';
import { IndexViewLogic } from '../../index_view_logic';
import { InferenceConfiguration } from './inference_config';
import { MLInferenceLogic } from './ml_inference_logic';
import { TargetFieldHelpText } from './target_field_help_text';
const NoSourceFieldsError: React.FC = () => (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.error"
defaultMessage="Selecting a source field is required for pipeline configuration, but this index does not have a field mapping. {learnMore}"
values={{
learnMore: (
<EuiLink href={docLinks.elasticsearchMapping} target="_blank" color="danger">
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.error.docLink',
{ defaultMessage: 'Learn more about field mapping' }
)}
</EuiLink>
),
}}
/>
);
import { MultiFieldMapping, SelectedFieldMappings } from './multi_field_selector';
import { SingleFieldMapping } from './single_field_selector';
export const ConfigureFields: React.FC = () => {
const {
addInferencePipelineModal: { configuration },
formErrors,
supportedMLModels,
sourceFields,
} = useValues(MLInferenceLogic);
const { setInferencePipelineConfiguration } = useActions(MLInferenceLogic);
const { ingestionMethod } = useValues(IndexViewLogic);
const { destinationField, modelID, pipelineName, sourceField } = configuration;
const emptySourceFields = (sourceFields?.length ?? 0) === 0;
const inputsDisabled = configuration.existingPipeline !== false;
const selectedModel = supportedMLModels.find((model) => model.model_id === modelID);
const { isTextExpansionModelSelected } = useValues(MLInferenceLogic);
return (
<>
<EuiFlexGroup>
@ -94,89 +48,19 @@ export const ConfigureFields: React.FC = () => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiPanel hasBorder hasShadow={false}>
<EuiForm component="form">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceFieldLabel',
{
defaultMessage: 'Source field',
}
)}
error={emptySourceFields && <NoSourceFieldsError />}
isInvalid={emptySourceFields}
>
<EuiSelect
fullWidth
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureInferencePipeline-selectSchemaField`}
disabled={inputsDisabled}
value={sourceField}
options={[
{
disabled: true,
text: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.placeholder',
{ defaultMessage: 'Select a schema field' }
),
value: '',
},
...(sourceFields?.map((field) => ({
text: field,
value: field,
})) ?? []),
]}
onChange={(e) =>
setInferencePipelineConfiguration({
...configuration,
sourceField: e.target.value,
})
}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.targetField.label',
{
defaultMessage: 'Target field (optional)',
}
)}
helpText={
formErrors.destinationField === undefined &&
configuration.existingPipeline !== true && (
<TargetFieldHelpText
pipelineName={pipelineName}
targetField={destinationField}
model={selectedModel}
/>
)
}
error={formErrors.destinationField}
isInvalid={formErrors.destinationField !== undefined}
fullWidth
>
<EuiFieldText
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureInferencePipeline-targetField`}
disabled={inputsDisabled}
placeholder="custom_field_name"
value={destinationField}
onChange={(e) =>
setInferencePipelineConfiguration({
...configuration,
destinationField: e.target.value,
})
}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</EuiPanel>
<EuiForm component="form">
{isTextExpansionModelSelected ? (
<>
<MultiFieldMapping />
<SelectedFieldMappings />
</>
) : (
<>
<SingleFieldMapping />
<InferenceConfiguration />
</>
)}
</EuiForm>
</>
);
};

View file

@ -308,6 +308,7 @@ export const ConfigurePipeline: React.FC = () => {
...configuration,
inferenceConfig: undefined,
modelID: value,
fieldMappings: undefined,
})
}
options={modelOptions}

View file

@ -10,8 +10,10 @@ import { kea, MakeLogicType } from 'kea';
import { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types';
import {
FieldMapping,
formatPipelineName,
generateMlInferencePipelineBody,
getMlInferencePrefixedFieldName,
getMlModelTypesForModelConfig,
parseMlInferenceParametersFromPipeline,
} from '../../../../../../../common/ml_inference_pipeline';
@ -70,10 +72,9 @@ import {
} from './types';
import {
getDisabledReason,
validateInferencePipelineConfiguration,
validateInferencePipelineFields,
EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD,
EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS,
} from './utils';
export const EMPTY_PIPELINE_CONFIGURATION: InferencePipelineConfiguration = {
@ -97,6 +98,7 @@ export interface MLInferencePipelineOption {
}
interface MLInferenceProcessorsActions {
addSelectedFieldsToMapping: () => void;
attachApiError: Actions<
AttachMlInferencePipelineApiLogicArgs,
AttachMlInferencePipelineResponse
@ -135,9 +137,11 @@ interface MLInferenceProcessorsActions {
FetchMlInferencePipelinesResponse
>['apiSuccess'];
mlModelsApiError: TrainedModelsApiLogicActions['apiError'];
removeFieldFromMapping: (fieldName: string) => { fieldName: string };
selectExistingPipeline: (pipelineName: string) => {
pipelineName: string;
};
selectFields: (fieldNames: string[]) => { fieldNames: string[] };
setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => {
step: AddInferencePipelineSteps;
};
@ -151,6 +155,7 @@ export interface AddInferencePipelineModal {
configuration: InferencePipelineConfiguration;
indexName: string;
step: AddInferencePipelineSteps;
selectedSourceFields?: string[] | undefined;
}
export interface MLInferenceProcessorsValues {
@ -179,10 +184,13 @@ export const MLInferenceLogic = kea<
MakeLogicType<MLInferenceProcessorsValues, MLInferenceProcessorsActions>
>({
actions: {
addSelectedFieldsToMapping: true,
attachPipeline: true,
clearFormErrors: true,
createPipeline: true,
removeFieldFromMapping: (fieldName: string) => ({ fieldName }),
selectExistingPipeline: (pipelineName: string) => ({ pipelineName }),
selectFields: (fieldNames: string[]) => ({ fieldNames }),
setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => ({ step }),
setFormErrors: (inputErrors: AddInferencePipelineFormErrors) => ({ inputErrors }),
setIndexName: (indexName: string) => ({ indexName }),
@ -310,6 +318,53 @@ export const MLInferenceLogic = kea<
step: AddInferencePipelineSteps.Configuration,
},
{
addSelectedFieldsToMapping: (modal) => {
const {
configuration: { fieldMappings },
selectedSourceFields,
} = modal;
const mergedFieldMappings: FieldMapping[] = [
...(fieldMappings || []),
...(selectedSourceFields || []).map((fieldName) => ({
sourceField: fieldName,
targetField: getMlInferencePrefixedFieldName(`${fieldName}_expanded`),
})),
];
return {
...modal,
configuration: {
...modal.configuration,
fieldMappings: mergedFieldMappings,
},
selectedSourceFields: [],
};
},
removeFieldFromMapping: (modal, { fieldName }) => {
const {
configuration: { fieldMappings },
} = modal;
if (!fieldMappings) {
return modal;
}
return {
...modal,
configuration: {
...modal.configuration,
fieldMappings: fieldMappings?.filter(({ sourceField }) => sourceField !== fieldName),
},
};
},
selectFields: (modal, { fieldNames }) => ({
...modal,
configuration: {
...modal.configuration,
},
selectedSourceFields: fieldNames,
}),
setAddInferencePipelineStep: (modal, { step }) => ({ ...modal, step }),
setIndexName: (modal, { indexName }) => ({ ...modal, indexName }),
setInferencePipelineConfiguration: (modal, { configuration }) => ({
@ -388,7 +443,7 @@ export const MLInferenceLogic = kea<
return generateMlInferencePipelineBody({
model,
pipelineName: configuration.pipelineName,
fieldMappings: [
fieldMappings: configuration.fieldMappings || [
{
sourceField: configuration.sourceField,
targetField:
@ -461,21 +516,19 @@ export const MLInferenceLogic = kea<
source_field: sourceField,
} = pipelineParams;
let disabled: boolean = false;
let disabledReason: string | undefined;
if (!(sourceFields?.includes(sourceField) ?? false)) {
disabled = true;
disabledReason = EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD;
} else if (indexProcessorNames.includes(pipelineName)) {
disabled = true;
disabledReason = EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS;
}
const mlModel = supportedMLModels.find((model) => model.model_id === modelId);
const modelType = mlModel ? getMLType(getMlModelTypesForModelConfig(mlModel)) : '';
const disabledReason = getDisabledReason(
sourceFields,
sourceField,
indexProcessorNames,
pipelineName,
modelType
);
return {
destinationField: destinationField ?? '',
disabled,
disabled: disabledReason !== undefined,
disabledReason,
modelId,
modelType,

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBasicTable, EuiButton, EuiComboBox } from '@elastic/eui';
import { MultiFieldMapping, SelectedFieldMappings } from './multi_field_selector';
const DEFAULT_VALUES = {
addInferencePipelineModal: {
configuration: {},
},
sourceFields: ['my-source-field1', 'my-source-field2', 'my-source-field3'],
};
describe('MultiFieldMapping', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues({});
});
it('renders multi field selector with options', () => {
setMockValues(DEFAULT_VALUES);
const wrapper = shallow(<MultiFieldMapping />);
expect(wrapper.find(EuiComboBox)).toHaveLength(1);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('options')).toEqual([
{
label: 'my-source-field1',
},
{
label: 'my-source-field2',
},
{
label: 'my-source-field3',
},
]);
});
it('renders multi field selector with options excluding mapped and selected fields', () => {
setMockValues({
...DEFAULT_VALUES,
addInferencePipelineModal: {
configuration: {
fieldMappings: [
{
sourceField: 'my-source-field2',
targetField: 'my-target-field2',
},
],
},
selectedSourceFields: ['my-source-field1'],
},
});
const wrapper = shallow(<MultiFieldMapping />);
expect(wrapper.find(EuiComboBox)).toHaveLength(1);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('options')).toEqual([
{
label: 'my-source-field3',
},
]);
});
it('disables add mapping button if no fields are selected', () => {
setMockValues(DEFAULT_VALUES);
const wrapper = shallow(<MultiFieldMapping />);
expect(wrapper.find(EuiButton)).toHaveLength(1);
const button = wrapper.find(EuiButton);
expect(button.prop('disabled')).toBe(true);
});
it('enables add mapping button if some fields are selected', () => {
setMockValues({
...DEFAULT_VALUES,
addInferencePipelineModal: {
...DEFAULT_VALUES.addInferencePipelineModal,
selectedSourceFields: ['my-source-field1'],
},
});
const wrapper = shallow(<MultiFieldMapping />);
expect(wrapper.find(EuiButton)).toHaveLength(1);
const button = wrapper.find(EuiButton);
expect(button.prop('disabled')).toBe(false);
});
});
describe('SelectedFieldMappings', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues({});
});
it('renders field mapping list', () => {
const mockValues = {
...DEFAULT_VALUES,
addInferencePipelineModal: {
configuration: {
fieldMappings: [
{
sourceField: 'my-source-field1',
targetField: 'my-target-field1',
},
{
sourceField: 'my-source-field2',
targetField: 'my-target-field2',
},
],
},
},
};
setMockValues(mockValues);
const wrapper = shallow(<SelectedFieldMappings />);
expect(wrapper.find(EuiBasicTable)).toHaveLength(1);
const table = wrapper.find(EuiBasicTable);
expect(table.prop('items')).toEqual(
mockValues.addInferencePipelineModal.configuration.fieldMappings
);
});
});

View file

@ -0,0 +1,245 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiButton,
EuiComboBox,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldMapping } from '../../../../../../../common/ml_inference_pipeline';
import { IndexViewLogic } from '../../index_view_logic';
import { MLInferenceLogic } from './ml_inference_logic';
type FieldNames = Array<{ label: string }>;
export const MultiFieldMapping: React.FC = () => {
const {
addInferencePipelineModal: { configuration, selectedSourceFields = [] },
sourceFields,
} = useValues(MLInferenceLogic);
const { ingestionMethod } = useValues(IndexViewLogic);
const { addSelectedFieldsToMapping, selectFields } = useActions(MLInferenceLogic);
const mappedSourceFields =
configuration.fieldMappings?.map(({ sourceField }) => sourceField) ?? [];
// Remove fields that have already been selected or mapped from selectable field options
const fieldOptions = (sourceFields || [])
.filter((fieldName) => ![...selectedSourceFields, ...mappedSourceFields].includes(fieldName))
.map((fieldName) => ({ label: fieldName }));
const selectedFields = selectedSourceFields.map((fieldName) => ({
label: fieldName,
}));
const onChangeSelectedFields = (selectedFieldNames: FieldNames) => {
selectFields(selectedFieldNames.map(({ label }) => label));
};
const onCreateField = (fieldName: string) => {
const normalizedFieldName = fieldName.trim();
if (!normalizedFieldName) return;
selectedFields.push({ label: normalizedFieldName });
selectFields([...selectedSourceFields, fieldName]);
};
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={4}>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceFieldLabel',
{
defaultMessage: 'Source field',
}
)}
helpText={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.helpText',
{
defaultMessage: 'Select an existing field or type in a field name.',
}
)}
>
<EuiComboBox
fullWidth
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureFields-selectSchemaField`}
placeholder={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.selectedFields',
{
defaultMessage: 'Selected fields',
}
)}
options={fieldOptions}
selectedOptions={selectedFields}
onChange={onChangeSelectedFields}
onCreateOption={onCreateField}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ paddingTop: '32px' }}>
<EuiIcon type="sortRight" />
</EuiFlexItem>
<EuiFlexItem grow={4}>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.targetFieldLabel',
{
defaultMessage: 'Target field',
}
)}
helpText={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.targetField.helpText',
{
defaultMessage: 'This name is automatically created based on your source field.',
}
)}
fullWidth
>
<EuiFieldText
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureFields-targetField`}
disabled
value={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.targetField.defaultValue',
{
defaultMessage: 'This is automatically created',
}
)}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem style={{ paddingTop: '20px' }}>
<EuiButton
color="primary"
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureFields-addSelectedFieldsToMapping`}
disabled={selectedFields.length === 0}
iconType="plusInCircle"
onClick={addSelectedFieldsToMapping}
style={{ width: '60px' }}
>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.addMapping',
{
defaultMessage: 'Add',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
export const SelectedFieldMappings: React.FC = () => {
const { removeFieldFromMapping } = useActions(MLInferenceLogic);
const {
addInferencePipelineModal: { configuration },
} = useValues(MLInferenceLogic);
const columns: Array<EuiBasicTableColumn<FieldMapping>> = [
{
'data-test-subj': 'sourceFieldCell',
field: 'sourceField',
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.sourceFieldHeader',
{
defaultMessage: 'Source fields',
}
),
},
{
align: 'left',
name: '',
render: () => <EuiIcon type="sortRight" />,
width: '60px',
},
{
field: 'targetField',
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.targetFieldHeader',
{
defaultMessage: 'Target fields',
}
),
},
{
actions: [
{
color: 'danger',
description: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.actions.deleteMapping',
{
defaultMessage: 'Delete this mapping',
}
),
icon: 'trash',
isPrimary: true,
name: (fieldMapping) =>
i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.actions.deleteMapping.caption',
{
defaultMessage: `Delete mapping '{sourceField}' - '{targetField}'`,
values: {
sourceField: fieldMapping.sourceField,
targetField: fieldMapping.targetField,
},
}
),
onClick: (fieldMapping) => removeFieldFromMapping(fieldMapping.sourceField),
type: 'icon',
},
],
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.actions',
{
defaultMessage: 'Actions',
}
),
width: '10%',
},
];
return (
<>
<EuiBasicTable
columns={columns}
items={configuration.fieldMappings ?? []}
rowHeader="sourceField"
tableCaption={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.tableCaption',
{
defaultMessage: 'Field mappings',
}
)}
noItemsMessage={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.noFieldMappings',
{
defaultMessage: 'No field mappings selected',
}
)}
/>
</>
);
};

View file

@ -7,9 +7,11 @@
import React from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTextColor, EuiTitle } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTextColor, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { MLModelTypeBadge } from '../ml_model_type_badge';
import { MLInferencePipelineOption } from './ml_inference_logic';
import { EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD, MODEL_REDACTED_VALUE } from './utils';
@ -51,9 +53,7 @@ export const PipelineSelectOption: React.FC<PipelineSelectOptionProps> = ({ pipe
</EuiFlexItem>
{pipeline.modelType.length > 0 && (
<EuiFlexItem grow={false}>
<span>
<EuiBadge color="hollow">{pipeline.modelType}</EuiBadge>
</span>
<MLModelTypeBadge type={pipeline.modelType} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiFieldText, EuiSelect } from '@elastic/eui';
import { SingleFieldMapping } from './single_field_selector';
const DEFAULT_VALUES = {
addInferencePipelineModal: {
configuration: {
sourceField: 'my-source-field',
destinationField: 'my-target-field',
},
},
formErrors: {},
sourceFields: ['my-source-field1', 'my-source-field2', 'my-source-field3'],
supportedMLModels: [],
};
describe('SingleFieldMapping', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues({});
});
it('renders source field selector and target field text field', () => {
setMockValues(DEFAULT_VALUES);
const wrapper = shallow(<SingleFieldMapping />);
expect(wrapper.find(EuiSelect)).toHaveLength(1);
const select = wrapper.find(EuiSelect);
expect(select.prop('options')).toEqual([
{
disabled: true,
text: 'Select a schema field',
value: '',
},
{
text: 'my-source-field1',
value: 'my-source-field1',
},
{
text: 'my-source-field2',
value: 'my-source-field2',
},
{
text: 'my-source-field3',
value: 'my-source-field3',
},
]);
expect(select.prop('value')).toEqual('my-source-field');
expect(wrapper.find(EuiFieldText)).toHaveLength(1);
const textField = wrapper.find(EuiFieldText);
expect(textField.prop('value')).toEqual('my-target-field');
});
it('disables inputs when selecting an existing pipeline', () => {
setMockValues({
...DEFAULT_VALUES,
addInferencePipelineModal: {
...DEFAULT_VALUES.addInferencePipelineModal,
configuration: {
...DEFAULT_VALUES.addInferencePipelineModal.configuration,
existingPipeline: true,
},
},
});
const wrapper = shallow(<SingleFieldMapping />);
expect(wrapper.find(EuiSelect)).toHaveLength(1);
const select = wrapper.find(EuiSelect);
expect(select.prop('disabled')).toBe(true);
expect(wrapper.find(EuiFieldText)).toHaveLength(1);
const textField = wrapper.find(EuiFieldText);
expect(textField.prop('disabled')).toBe(true);
});
});

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiSelect,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../../../shared/doc_links';
import { IndexViewLogic } from '../../index_view_logic';
import { MLInferenceLogic } from './ml_inference_logic';
import { TargetFieldHelpText } from './target_field_help_text';
const NoSourceFieldsError: React.FC = () => (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.error"
defaultMessage="Selecting a source field is required for pipeline configuration, but this index does not have a field mapping. {learnMore}"
values={{
learnMore: (
<EuiLink href={docLinks.elasticsearchMapping} target="_blank" color="danger">
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.error.docLink',
{ defaultMessage: 'Learn more about field mapping' }
)}
</EuiLink>
),
}}
/>
);
export const SingleFieldMapping: React.FC = () => {
const {
addInferencePipelineModal: { configuration },
formErrors,
supportedMLModels,
sourceFields,
} = useValues(MLInferenceLogic);
const { setInferencePipelineConfiguration } = useActions(MLInferenceLogic);
const { ingestionMethod } = useValues(IndexViewLogic);
const { destinationField, modelID, pipelineName, sourceField } = configuration;
const isEmptySourceFields = (sourceFields?.length ?? 0) === 0;
const areInputsDisabled = configuration.existingPipeline !== false;
const selectedModel = supportedMLModels.find((model) => model.model_id === modelID);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceFieldLabel',
{
defaultMessage: 'Source field',
}
)}
error={isEmptySourceFields && <NoSourceFieldsError />}
isInvalid={isEmptySourceFields}
>
<EuiSelect
fullWidth
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureFields-selectSchemaField`}
disabled={areInputsDisabled}
value={sourceField}
options={[
{
disabled: true,
text: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.sourceField.placeholder',
{ defaultMessage: 'Select a schema field' }
),
value: '',
},
...(sourceFields?.map((field) => ({
text: field,
value: field,
})) ?? []),
]}
onChange={(e) =>
setInferencePipelineConfiguration({
...configuration,
sourceField: e.target.value,
})
}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.targetField.label',
{
defaultMessage: 'Target field (optional)',
}
)}
helpText={
formErrors.destinationField === undefined &&
configuration.existingPipeline !== true && (
<TargetFieldHelpText
pipelineName={pipelineName}
targetField={destinationField}
model={selectedModel}
/>
)
}
error={formErrors.destinationField}
isInvalid={formErrors.destinationField !== undefined}
fullWidth
>
<EuiFieldText
data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-configureFields-targetField`}
disabled={areInputsDisabled}
placeholder="custom_field_name"
value={destinationField}
onChange={(e) =>
setInferencePipelineConfiguration({
...configuration,
destinationField: e.target.value,
})
}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -62,7 +62,6 @@ export const TestPipeline: React.FC = () => {
)}
</h4>
</EuiTitle>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiText color="subdued" size="s">

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { FieldMapping } from '../../../../../../../common/ml_inference_pipeline';
import { InferencePipelineInferenceConfig } from '../../../../../../../common/types/pipelines';
export interface InferencePipelineConfiguration {
@ -14,6 +16,7 @@ export interface InferencePipelineConfiguration {
modelID: string;
pipelineName: string;
sourceField: string;
fieldMappings?: FieldMapping[];
}
export interface AddInferencePipelineFormErrors {

View file

@ -7,6 +7,8 @@
import { i18n } from '@kbn/i18n';
import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-trained-models-utils';
import { AddInferencePipelineFormErrors, InferencePipelineConfiguration } from './types';
const VALID_PIPELINE_NAME_REGEX = /^[\w\-]+$/;
@ -53,6 +55,12 @@ export const validateInferencePipelineFields = (
config: InferencePipelineConfiguration
): AddInferencePipelineFormErrors => {
const errors: AddInferencePipelineFormErrors = {};
// If there are field mappings, we don't need to validate the single source field
if (config.fieldMappings && Object.keys(config.fieldMappings).length > 0) {
return errors;
}
if (config.sourceField.trim().length === 0) {
errors.sourceField = FIELD_REQUIRED_ERROR;
}
@ -75,6 +83,32 @@ export const EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS = i18n.translate(
}
);
export const EXISTING_PIPELINE_DISABLED_TEXT_EXPANSION = i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledElserNotSupportedDescription',
{
defaultMessage:
'This pipeline cannot be selected because attaching an ELSER pipeline is not supported yet.',
}
);
export const getDisabledReason = (
sourceFields: string[] | undefined,
sourceField: string,
indexProcessorNames: string[],
pipelineName: string,
modelType: string
): string | undefined => {
if (!(sourceFields?.includes(sourceField) ?? false)) {
return EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD;
} else if (indexProcessorNames.includes(pipelineName)) {
return EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS;
} else if (modelType === SUPPORTED_PYTORCH_TASKS.TEXT_EXPANSION) {
return EXISTING_PIPELINE_DISABLED_TEXT_EXPANSION;
}
return undefined;
};
export const MODEL_SELECT_PLACEHOLDER = i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.model.placeholder',
{ defaultMessage: 'Select a model' }