[8.10] [ESRE] Support attaching ELSER pipeline (#161388)

## Summary

Attaching ELSER pipelines to an index was disabled when ELSER pipelines
were introduced. This PR enables this feature with the following logic:
- An ELSER pipeline is selectable if all the source fields in its
`fieldMapping` configuration are present in the index. Otherwise it's
disabled in the dropdown with a message indicating which fields are
missing.
- When an ELSER pipeline is selected for attachment to an index, the
field configuration screen shows a read-only version of the field
mappings. The modification widgets (field selector dropdown, Add button,
delete button in the list) are hidden in this mode.

ELSER pipeline is selectable:
![Screenshot 2023-07-06 at 18 16
54](3650c0fb-7ae1-4639-bcf6-94fe488f35c6)

ELSER pipeline is not selectable if source fields are missing from the
index mapping:
![Screenshot 2023-07-06 at 18 21
07](a94c23d8-d212-4628-b1e5-16012cbc989c)

Field configuration panel in read-only mode:
![Screenshot 2023-07-07 at 11 14
10](ee663499-2193-4e91-a81b-b3fe418a2780)

### Checklist
- [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] 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))

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Adam Demjen 2023-07-07 14:48:01 -04:00 committed by GitHub
parent f0bd9bfb89
commit 86fa655990
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 174 additions and 189 deletions

View file

@ -21,10 +21,13 @@ describe('ConfigureFields', () => {
setMockValues({});
});
const mockValues = {
isTextExpansionModelSelected: false,
addInferencePipelineModal: { configuration: { existingPipeline: false } },
};
it('renders multi-field selector components if non-text expansion model is selected', () => {
setMockValues({
isTextExpansionModelSelected: false,
});
setMockValues(mockValues);
const wrapper = shallow(<ConfigureFields />);
expect(wrapper.find(SingleFieldMapping)).toHaveLength(1);
expect(wrapper.find(MultiFieldMapping)).toHaveLength(0);
@ -32,6 +35,7 @@ describe('ConfigureFields', () => {
});
it('renders multi-field selector components if text expansion model is selected', () => {
setMockValues({
...mockValues,
isTextExpansionModelSelected: true,
});
const wrapper = shallow(<ConfigureFields />);
@ -39,4 +43,15 @@ describe('ConfigureFields', () => {
expect(wrapper.find(MultiFieldMapping)).toHaveLength(1);
expect(wrapper.find(SelectedFieldMappings)).toHaveLength(1);
});
it('only renders field mappings in read-only mode', () => {
setMockValues({
...mockValues,
isTextExpansionModelSelected: true,
addInferencePipelineModal: { configuration: { existingPipeline: true } },
});
const wrapper = shallow(<ConfigureFields />);
expect(wrapper.find(SingleFieldMapping)).toHaveLength(0);
expect(wrapper.find(MultiFieldMapping)).toHaveLength(0);
expect(wrapper.find(SelectedFieldMappings)).toHaveLength(1);
});
});

View file

@ -11,7 +11,7 @@ import { useValues } from 'kea';
import { EuiForm, EuiSpacer, EuiTitle, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { InferenceConfiguration } from './inference_config';
import { MLInferenceLogic } from './ml_inference_logic';
@ -19,16 +19,28 @@ import { MultiFieldMapping, SelectedFieldMappings } from './multi_field_selector
import { SingleFieldMapping } from './single_field_selector';
export const ConfigureFields: React.FC = () => {
const { isTextExpansionModelSelected } = useValues(MLInferenceLogic);
const {
isTextExpansionModelSelected,
addInferencePipelineModal: { configuration },
} = useValues(MLInferenceLogic);
const areInputsDisabled = configuration.existingPipeline !== false;
return (
<>
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiTitle size="s">
<h4>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.title',
{ defaultMessage: 'Select field mappings' }
{areInputsDisabled ? (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.titleReview"
defaultMessage="Review field mappings"
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.title"
defaultMessage="Select field mappings"
/>
)}
</h4>
</EuiTitle>
@ -36,12 +48,26 @@ export const ConfigureFields: React.FC = () => {
<EuiFlexItem grow={7}>
<EuiText color="subdued" size="s">
<p>
{i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.description',
{
defaultMessage:
'Choose fields to be enhanced from your existing documents or manually enter in fields you anticipate using.',
}
{areInputsDisabled ? (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.descriptionReview"
defaultMessage="Examine the field mappings of your chosen pipeline to ensure that the source and target fields align with your specific use case. {notEditable}"
values={{
notEditable: (
<strong>
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.descriptionReviewNotEditable"
defaultMessage="The fields from existing pipelines are not editable."
/>
</strong>
),
}}
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.description"
defaultMessage="Choose fields to be enhanced from your existing documents or manually enter in fields you anticipate using."
/>
)}
</p>
</EuiText>
@ -51,8 +77,8 @@ export const ConfigureFields: React.FC = () => {
<EuiForm component="form">
{isTextExpansionModelSelected ? (
<>
<MultiFieldMapping />
<SelectedFieldMappings />
{areInputsDisabled || <MultiFieldMapping />}
<SelectedFieldMappings isReadOnly={areInputsDisabled} />
</>
) : (
<>

View file

@ -220,17 +220,35 @@ describe('MlInferenceLogic', () => {
},
]);
});
it('returns disabled pipeline option if missing source field', () => {
it('returns disabled pipeline option if missing source fields', () => {
FetchMlInferencePipelinesApiLogic.actions.apiSuccess({
'unit-test': {
processors: [
{
inference: {
field_map: {
body_content: 'text_field',
title: 'text_field', // Does not exist in index
},
model_id: 'test-model',
target_field: 'ml.inference.test-field',
target_field: 'ml.inference.title',
},
},
{
inference: {
field_map: {
body: 'text_field', // Exists in index
},
model_id: 'test-model',
target_field: 'ml.inference.body',
},
},
{
inference: {
field_map: {
body_content: 'text_field', // Does not exist in index
},
model_id: 'test-model',
target_field: 'ml.inference.body_content',
},
},
],
@ -240,13 +258,13 @@ describe('MlInferenceLogic', () => {
expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([
{
destinationField: 'test-field',
destinationField: 'title',
disabled: true,
disabledReason: expect.any(String),
disabledReason: expect.stringContaining('title, body_content'),
modelId: 'test-model',
modelType: '',
pipelineName: 'unit-test',
sourceField: 'body_content',
sourceField: 'title',
},
]);
});
@ -318,77 +336,6 @@ describe('MlInferenceLogic', () => {
},
]);
});
it('filter text expansion model from existing pipelines list', () => {
MLModelsApiLogic.actions.apiSuccess([
{
inference_config: {
text_expansion: {},
},
input: {
field_names: ['text_field'],
},
model_id: 'text-expansion-mocked-model',
model_type: 'pytorch',
tags: [],
version: '1',
},
{
inference_config: {
classification: {},
},
input: {
field_names: ['text_field'],
},
model_id: 'classification-mocked-model',
model_type: 'lang_ident',
tags: [],
version: '1',
},
]);
FetchMlInferencePipelinesApiLogic.actions.apiSuccess({
'unit-test-1': {
processors: [
{
inference: {
field_map: {
body: 'text_field',
},
model_id: 'text-expansion-mocked-model',
target_field: 'ml.inference.test-field',
},
},
],
version: 1,
},
'unit-test-2': {
processors: [
{
inference: {
field_map: {
body: 'text_field',
},
model_id: 'classification-mocked-model',
target_field: 'ml.inference.test-field',
},
},
],
version: 1,
},
});
expect(MLInferenceLogic.values.existingInferencePipelines).toEqual([
{
destinationField: 'test-field',
disabled: false,
disabledReason: undefined,
pipelineName: 'unit-test-2',
modelType: 'lang_ident',
modelId: 'classification-mocked-model',
sourceField: 'body',
},
]);
});
});
describe('mlInferencePipeline', () => {
it('returns undefined when configuration is invalid', () => {

View file

@ -9,8 +9,6 @@ import { kea, MakeLogicType } from 'kea';
import { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types';
import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-trained-models-utils';
import {
FieldMapping,
formatPipelineName,
@ -97,10 +95,6 @@ export const EMPTY_PIPELINE_CONFIGURATION: InferencePipelineConfiguration = {
sourceField: '',
};
const isNotTextExpansionModel = (model: MLInferencePipelineOption): boolean => {
return model.modelType !== SUPPORTED_PYTORCH_TASKS.TEXT_EXPANSION;
};
const API_REQUEST_COMPLETE_STATUSES = [Status.SUCCESS, Status.ERROR];
const DEFAULT_CONNECTOR_FIELDS = ['body', 'title', 'id', 'type', 'url'];
@ -597,16 +591,18 @@ export const MLInferenceLogic = kea<
destination_field: destinationField,
model_id: modelId,
source_field: sourceField,
field_mappings: fieldMappings,
} = pipelineParams;
const missingSourceFields =
fieldMappings?.map((f) => f.sourceField).filter((f) => !sourceFields?.includes(f)) ??
[];
const mlModel = supportedMLModels.find((model) => model.model_id === modelId);
const modelType = mlModel ? getMLType(getMlModelTypesForModelConfig(mlModel)) : '';
const disabledReason = getDisabledReason(
sourceFields,
sourceField,
missingSourceFields,
indexProcessorNames,
pipelineName,
modelType
pipelineName
);
return {
@ -619,9 +615,7 @@ export const MLInferenceLogic = kea<
sourceField,
};
})
.filter(
(p): p is MLInferencePipelineOption => p !== undefined && isNotTextExpansionModel(p)
);
.filter((p): p is MLInferencePipelineOption => p !== undefined);
return existingPipelines;
},

View file

@ -95,28 +95,29 @@ describe('MultiFieldMapping', () => {
});
describe('SelectedFieldMappings', () => {
const mockValues = {
...DEFAULT_VALUES,
addInferencePipelineModal: {
configuration: {
fieldMappings: [
{
sourceField: 'my-source-field1',
targetField: 'my-target-field1',
},
{
sourceField: 'my-source-field2',
targetField: 'my-target-field2',
},
],
},
},
};
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 />);
@ -126,4 +127,12 @@ describe('SelectedFieldMappings', () => {
mockValues.addInferencePipelineModal.configuration.fieldMappings
);
});
it('does not render action column in read-only mode', () => {
setMockValues(mockValues);
const wrapper = shallow(<SelectedFieldMappings isReadOnly />);
expect(wrapper.find(EuiBasicTable)).toHaveLength(1);
const table = wrapper.find(EuiBasicTable);
expect(table.prop('columns').map((c) => c.name)).toEqual(['Source field', '', 'Target field']);
});
});

View file

@ -152,7 +152,11 @@ export const MultiFieldMapping: React.FC = () => {
);
};
export const SelectedFieldMappings: React.FC = () => {
export interface SelectedFieldMappingsProps {
isReadOnly?: boolean;
}
export const SelectedFieldMappings: React.FC<SelectedFieldMappingsProps> = ({ isReadOnly }) => {
const { removeFieldFromMapping } = useActions(MLInferenceLogic);
const {
addInferencePipelineModal: { configuration },
@ -165,7 +169,7 @@ export const SelectedFieldMappings: React.FC = () => {
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.sourceFieldHeader',
{
defaultMessage: 'Source fields',
defaultMessage: 'Source field',
}
),
},
@ -176,49 +180,55 @@ export const SelectedFieldMappings: React.FC = () => {
width: '60px',
},
{
align: 'right',
field: 'targetField',
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.fieldMappings.targetFieldHeader',
{
defaultMessage: 'Target fields',
defaultMessage: 'Target field',
}
),
},
{
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',
// Do not add action column in read-only mode
...(isReadOnly
? []
: [
{
actions: [
{
defaultMessage: `Delete mapping '{sourceField}' - '{targetField}'`,
values: {
sourceField: fieldMapping.sourceField,
targetField: fieldMapping.targetField,
},
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',
}
),
onClick: (fieldMapping) => removeFieldFromMapping(fieldMapping.sourceField),
type: 'icon',
},
],
name: i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.fields.actions',
{
defaultMessage: 'Actions',
}
),
width: '10%',
},
width: '10%',
} as EuiBasicTableColumn<FieldMapping>,
]),
];
return (

View file

@ -7,8 +7,6 @@
import { i18n } from '@kbn/i18n';
import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-trained-models-utils';
import { FetchPipelineResponse } from '../../../../api/pipelines/fetch_pipeline';
import { AddInferencePipelineFormErrors, InferencePipelineConfiguration } from './types';
@ -86,13 +84,17 @@ export const validateInferencePipelineFields = (
return errors;
};
export const EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD = i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription',
{
defaultMessage:
'This pipeline cannot be selected because the source field does not exist on this index.',
}
);
export const EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD = (
commaSeparatedMissingSourceFields: string
) =>
i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription',
{
defaultMessage:
"This pipeline cannot be selected because some source fields don't exist in this index: {commaSeparatedMissingSourceFields}.",
values: { commaSeparatedMissingSourceFields },
}
);
export const EXISTING_PIPELINE_DISABLED_PIPELINE_EXISTS = i18n.translate(
'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledPipelineExistsDescription',
@ -101,27 +103,15 @@ 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,
missingSourceFields: string[],
indexProcessorNames: string[],
pipelineName: string,
modelType: string
pipelineName: string
): string | undefined => {
if (!(sourceFields?.includes(sourceField) ?? false)) {
return EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD;
if (missingSourceFields.length > 0) {
return EXISTING_PIPELINE_DISABLED_MISSING_SOURCE_FIELD(missingSourceFields.join(', '));
} 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;

View file

@ -13150,9 +13150,7 @@
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.emptyValueError": "Champ obligatoire.",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.chooseLabel": "Choisir",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.destinationField": "Champ de destination",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledElserNotSupportedDescription": "Impossible de sélectionner ce pipeline, car l'association d'un pipeline ELSER n'est pas encore prise en charge.",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledPipelineExistsDescription": "Ce pipeline ne peut pas être sélectionné car il est déjà attaché.",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription": "Ce pipeline ne peut pas être sélectionné car le champ source n'existe pas dans cet index.",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.existingLabel": "Pipeline existant",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.newLabel": "Nouveau pipeline",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.placeholder": "Effectuez une sélection",

View file

@ -13149,9 +13149,7 @@
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.emptyValueError": "フィールドが必要です。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.chooseLabel": "選択",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.destinationField": "デスティネーションフィールド",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledElserNotSupportedDescription": "ELSERパイプラインの関連付けがまだサポートされていないため、このパイプラインを選択できません。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledPipelineExistsDescription": "このパイプラインはすでにアタッチされているため、選択できません。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription": "ソースフィールドがこのインデックスに存在しないため、このパイプラインを選択できません。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.existingLabel": "既存のパイプライン",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.newLabel": "新しいパイプライン",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.placeholder": "1 つ選択してください",

View file

@ -13149,9 +13149,7 @@
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.emptyValueError": "“字段”必填。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.chooseLabel": "选择",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.destinationField": "目标字段",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledElserNotSupportedDescription": "不能选择此管道,因为尚不支持附加 ELSER 管道。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledPipelineExistsDescription": "无法选择此管道,因为已附加该管道。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.disabledSourceFieldDescription": "无法选择此管道,因为该索引上不存在源字段。",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.existingLabel": "现有管道",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.newLabel": "新建管道",
"xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.existingPipeline.placeholder": "选择一个",