mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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.     ### 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:
parent
26f65b3262
commit
d2f7860cff
14 changed files with 905 additions and 200 deletions
|
@ -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', () => {
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -308,6 +308,7 @@ export const ConfigurePipeline: React.FC = () => {
|
|||
...configuration,
|
||||
inferenceConfig: undefined,
|
||||
modelID: value,
|
||||
fieldMappings: undefined,
|
||||
})
|
||||
}
|
||||
options={modelOptions}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -62,7 +62,6 @@ export const TestPipeline: React.FC = () => {
|
|||
)}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={7}>
|
||||
<EuiText color="subdued" size="s">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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' }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue