diff --git a/common/interfaces.ts b/common/interfaces.ts index c445a4f0..baa08165 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -122,6 +122,20 @@ export type RequestFormValues = { }; export type RequestSchema = WorkflowSchema; +// Form / schema interfaces for the input transform sub-form +export type InputTransformFormValues = { + input_map: MapArrayFormValue; + one_to_one: ConfigFieldValue; +}; +export type InputTransformSchema = WorkflowSchema; + +// Form / schema interfaces for the output transform sub-form +export type OutputTransformFormValues = { + output_map: MapArrayFormValue; + full_response_path: ConfigFieldValue; +}; +export type OutputTransformSchema = WorkflowSchema; + /** ********** WORKSPACE TYPES/INTERFACES ********** */ diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx index 5daf11f2..196f0077 100644 --- a/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/map_array_field.tsx @@ -18,7 +18,6 @@ import { import { Field, FieldProps, getIn, useFormikContext } from 'formik'; import { EMPTY_MAP_ENTRY, - IConfigField, MapArrayFormValue, MapEntry, WorkflowFormValues, @@ -26,7 +25,6 @@ import { import { MapField } from './map_field'; interface MapArrayFieldProps { - field: IConfigField; fieldPath: string; // the full path in string-form to the field (e.g., 'ingest.enrich.processors.text_embedding_processor.inputField') helpText?: string; keyTitle?: string; diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx index 39c7f649..20a6c936 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs.tsx @@ -25,7 +25,6 @@ import { PROCESSOR_CONTEXT, WorkflowConfig, JSONPATH_ROOT_SELECTOR, - ML_INFERENCE_DOCS_LINK, WorkflowFormValues, ModelInterface, IndexMappings, @@ -75,15 +74,9 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { ) as IConfigField; const modelFieldPath = `${props.baseConfigPath}.${props.config.id}.${modelField.id}`; const modelIdFieldPath = `${modelFieldPath}.id`; - const inputMapField = props.config.fields.find( - (field) => field.id === 'input_map' - ) as IConfigField; - const inputMapFieldPath = `${props.baseConfigPath}.${props.config.id}.${inputMapField.id}`; + const inputMapFieldPath = `${props.baseConfigPath}.${props.config.id}.input_map`; const inputMapValue = getIn(values, inputMapFieldPath); - const outputMapField = props.config.fields.find( - (field) => field.id === 'output_map' - ) as IConfigField; - const outputMapFieldPath = `${props.baseConfigPath}.${props.config.id}.${outputMapField.id}`; + const outputMapFieldPath = `${props.baseConfigPath}.${props.config.id}.output_map`; const outputMapValue = getIn(values, outputMapFieldPath); const fullResponsePath = getIn( values, @@ -250,7 +243,6 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { config={props.config} baseConfigPath={props.baseConfigPath} context={props.context} - inputMapField={inputMapField} inputMapFieldPath={inputMapFieldPath} modelInterface={modelInterface} valueOptions={ @@ -269,7 +261,6 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { config={props.config} baseConfigPath={props.baseConfigPath} context={props.context} - outputMapField={outputMapField} outputMapFieldPath={outputMapFieldPath} modelInterface={modelInterface} onClose={() => setIsOutputTransformModalOpen(false)} @@ -365,7 +356,6 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { (); + const { values, setFieldValue, setFieldTouched } = useFormikContext< + WorkflowFormValues + >(); + + // sub-form values/schema + const inputTransformFormValues = { + input_map: getInitialValue('mapArray'), + one_to_one: getInitialValue('boolean'), + } as InputTransformFormValues; + const inputTransformFormSchema = yup.object({ + input_map: getFieldSchema({ + type: 'mapArray', + } as IConfigField), + one_to_one: getFieldSchema( + { + type: 'boolean', + } as IConfigField, + true + ), + }) as InputTransformSchema; + + // persist standalone values. update / initialize when it is first opened + const [tempErrors, setTempErrors] = useState(false); + const [tempOneToOne, setTempOneToOne] = useState(false); + const [tempInputMap, setTempInputMap] = useState([]); // various prompt states const [viewPromptDetails, setViewPromptDetails] = useState(false); @@ -103,9 +131,7 @@ export function InputTransformModal(props: InputTransformModalProps) { const [transformedInput, setTransformedInput] = useState('{}'); // get some current form values - const map = getIn(values, props.inputMapFieldPath) as MapArrayFormValue; const oneToOnePath = `${props.baseConfigPath}.${props.config.id}.one_to_one`; - const oneToOne = getIn(values, oneToOnePath); const docs = getIn(values, 'ingest.docs'); let docObjs = [] as {}[] | undefined; try { @@ -124,13 +150,13 @@ export function InputTransformModal(props: InputTransformModalProps) { isEmpty(queryObj); // selected transform state - const transformOptions = map.map((_, idx) => ({ + const transformOptions = tempInputMap.map((_, idx) => ({ value: idx, text: `Prediction ${idx + 1}`, })) as EuiSelectOption[]; const [selectedTransformOption, setSelectedTransformOption] = useState< - number | undefined - >((transformOptions[0]?.value as number) ?? undefined); + number + >((transformOptions[0]?.value as number) ?? 0); // popover state containing the model interface details, if applicable const [popoverOpen, setPopoverOpen] = useState(false); @@ -148,27 +174,23 @@ export function InputTransformModal(props: InputTransformModalProps) { // hook to re-generate the transform when any inputs to the transform are updated useEffect(() => { - if ( - !isEmpty(map) && - !isEmpty(JSON.parse(sourceInput)) && - selectedTransformOption !== undefined - ) { + if (!isEmpty(tempInputMap) && !isEmpty(JSON.parse(sourceInput))) { let sampleSourceInput = {} as {} | []; try { sampleSourceInput = JSON.parse(sourceInput); const output = // Edge case: users are collapsing input docs into a single input field when many-to-one is selected // fo input transforms on search response processors. - oneToOne === false && + tempOneToOne === false && props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE && Array.isArray(sampleSourceInput) ? generateArrayTransform( sampleSourceInput as [], - map[selectedTransformOption] + tempInputMap[selectedTransformOption] ) : generateTransform( sampleSourceInput, - map[selectedTransformOption] + tempInputMap[selectedTransformOption] ); setTransformedInput(customStringify(output)); @@ -176,7 +198,7 @@ export function InputTransformModal(props: InputTransformModalProps) { } else { setTransformedInput('{}'); } - }, [map, sourceInput, selectedTransformOption]); + }, [tempInputMap, sourceInput, selectedTransformOption]); // hook to re-determine validity when the generated output changes // utilize Ajv JSON schema validator library. For more info/examples, see @@ -231,384 +253,370 @@ export function InputTransformModal(props: InputTransformModalProps) { // hook to clear the source input when one_to_one is toggled useEffect(() => { setSourceInput('{}'); - }, [oneToOne]); + }, [tempOneToOne]); return ( - - - -

{`Configure input`}

-
-
- - - - <> - {(onIngestAndNoDocs || onSearchAndNoQuery) && ( - <> - - - - )} - {description} - - {props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE && ( - <> - - - - )} - Source input - { - setIsFetching(true); - switch (props.context) { - case PROCESSOR_CONTEXT.INGEST: { - // get the current ingest pipeline up to, but not including, this processor - const curIngestPipeline = formikToPartialPipeline( - values, - props.uiConfig, - props.config.id, - false, - PROCESSOR_CONTEXT.INGEST - ); - // if there are preceding processors, we need to simulate the partial ingest pipeline, - // in order to get the latest transformed version of the docs - if (curIngestPipeline !== undefined) { - const curDocs = prepareDocsForSimulate( - values.ingest.docs, - values.ingest.index.name - ); - await dispatch( - simulatePipeline({ - apiBody: { - pipeline: curIngestPipeline as IngestPipelineConfig, - docs: [curDocs[0]], - }, - dataSourceId, - }) - ) - .unwrap() - .then((resp: SimulateIngestPipelineResponse) => { - const docObjs = unwrapTransformedDocs(resp); - if (docObjs.length > 0) { - setSourceInput(customStringify(docObjs[0])); + {}} + validate={(values) => {}} + > + {(formikProps) => { + // override to parent form values when changes detected + useEffect(() => { + formikProps.setFieldValue( + 'input_map', + getIn(values, props.inputMapFieldPath) + ); + }, [getIn(values, props.inputMapFieldPath)]); + useEffect(() => { + formikProps.setFieldValue('one_to_one', getIn(values, oneToOnePath)); + }, [getIn(values, oneToOnePath)]); + + // update temp vars when form changes are detected + useEffect(() => { + setTempInputMap(getIn(formikProps.values, 'input_map')); + }, [getIn(formikProps.values, 'input_map')]); + useEffect(() => { + setTempOneToOne(getIn(formikProps.values, 'one_to_one')); + }, [getIn(formikProps.values, 'one_to_one')]); + + // update tempErrors if errors detected + useEffect(() => { + setTempErrors(!isEmpty(formikProps.errors)); + }, [formikProps.errors]); + + return ( + + + +

{`Configure input`}

+
+
+ + + + <> + {(onIngestAndNoDocs || onSearchAndNoQuery) && ( + <> + + + + )} + {description} + + {props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE && ( + <> + + + + )} + Source input + { + setIsFetching(true); + switch (props.context) { + case PROCESSOR_CONTEXT.INGEST: { + // get the current ingest pipeline up to, but not including, this processor + const curIngestPipeline = formikToPartialPipeline( + values, + props.uiConfig, + props.config.id, + false, + PROCESSOR_CONTEXT.INGEST + ); + // if there are preceding processors, we need to simulate the partial ingest pipeline, + // in order to get the latest transformed version of the docs + if (curIngestPipeline !== undefined) { + const curDocs = prepareDocsForSimulate( + values.ingest.docs, + values.ingest.index.name + ); + await dispatch( + simulatePipeline({ + apiBody: { + pipeline: curIngestPipeline as IngestPipelineConfig, + docs: [curDocs[0]], + }, + dataSourceId, + }) + ) + .unwrap() + .then( + (resp: SimulateIngestPipelineResponse) => { + const docObjs = unwrapTransformedDocs(resp); + if (docObjs.length > 0) { + setSourceInput( + customStringify(docObjs[0]) + ); + } + } + ) + .catch((error: any) => { + getCore().notifications.toasts.addDanger( + `Failed to fetch input data` + ); + }) + .finally(() => { + setIsFetching(false); + }); + } else { + try { + const docObjs = JSON.parse( + values.ingest.docs + ) as {}[]; + if (docObjs.length > 0) { + setSourceInput(customStringify(docObjs[0])); + } + } catch { + } finally { + setIsFetching(false); + } } - }) - .catch((error: any) => { - getCore().notifications.toasts.addDanger( - `Failed to fetch input data` + break; + } + case PROCESSOR_CONTEXT.SEARCH_REQUEST: { + // get the current search pipeline up to, but not including, this processor + const curSearchPipeline = formikToPartialPipeline( + values, + props.uiConfig, + props.config.id, + false, + PROCESSOR_CONTEXT.SEARCH_REQUEST ); - }) - .finally(() => { + // if there are preceding processors, we cannot generate. The button to render + // this modal should be disabled if the search pipeline would be enabled. We add + // this if check as an extra layer of checking, and if mechanism for gating + // this is changed in the future. + if (curSearchPipeline === undefined) { + setSourceInput(values.search.request); + } setIsFetching(false); - }); - } else { - try { - const docObjs = JSON.parse( - values.ingest.docs - ) as {}[]; - if (docObjs.length > 0) { - setSourceInput(customStringify(docObjs[0])); + break; } - } catch { - } finally { - setIsFetching(false); - } - } - break; - } - case PROCESSOR_CONTEXT.SEARCH_REQUEST: { - // get the current search pipeline up to, but not including, this processor - const curSearchPipeline = formikToPartialPipeline( - values, - props.uiConfig, - props.config.id, - false, - PROCESSOR_CONTEXT.SEARCH_REQUEST - ); - // if there are preceding processors, we cannot generate. The button to render - // this modal should be disabled if the search pipeline would be enabled. We add - // this if check as an extra layer of checking, and if mechanism for gating - // this is changed in the future. - if (curSearchPipeline === undefined) { - setSourceInput(values.search.request); - } - setIsFetching(false); - break; - } - case PROCESSOR_CONTEXT.SEARCH_RESPONSE: { - // get the current search pipeline up to, but not including, this processor - const curSearchPipeline = formikToPartialPipeline( - values, - props.uiConfig, - props.config.id, - false, - PROCESSOR_CONTEXT.SEARCH_RESPONSE - ); - // Execute search. If there are preceding processors, augment the existing query with - // the partial search pipeline (inline) to get the latest transformed version of the response. - dispatch( - searchIndex({ - apiBody: { - index: values.search.index.name, - body: JSON.stringify({ - ...JSON.parse(values.search.request as string), - search_pipeline: curSearchPipeline || {}, - }), - }, - dataSourceId, - }) - ) - .unwrap() - .then(async (resp) => { - const hits = resp.hits.hits - .map((hit: SearchHit) => hit._source) - .slice(0, MAX_INPUT_DOCS); - if (hits.length > 0) { - setSourceInput( - // if one-to-one, treat the source input as a single retrieved document - // else, treat it as all of the returned documents - customStringify(oneToOne ? hits[0] : hits) + case PROCESSOR_CONTEXT.SEARCH_RESPONSE: { + // get the current search pipeline up to, but not including, this processor + const curSearchPipeline = formikToPartialPipeline( + values, + props.uiConfig, + props.config.id, + false, + PROCESSOR_CONTEXT.SEARCH_RESPONSE ); + // Execute search. If there are preceding processors, augment the existing query with + // the partial search pipeline (inline) to get the latest transformed version of the response. + dispatch( + searchIndex({ + apiBody: { + index: values.search.index.name, + body: JSON.stringify({ + ...JSON.parse( + values.search.request as string + ), + search_pipeline: curSearchPipeline || {}, + }), + }, + dataSourceId, + }) + ) + .unwrap() + .then(async (resp) => { + const hits = resp.hits.hits + .map((hit: SearchHit) => hit._source) + .slice(0, MAX_INPUT_DOCS); + if (hits.length > 0) { + setSourceInput( + // if one-to-one, treat the source input as a single retrieved document + // else, treat it as all of the returned documents + customStringify( + tempOneToOne ? hits[0] : hits + ) + ); + } + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger( + `Failed to fetch source input data` + ); + }) + .finally(() => { + setIsFetching(false); + }); + break; } - }) - .catch((error: any) => { - getCore().notifications.toasts.addDanger( - `Failed to fetch source input data` - ); - }) - .finally(() => { - setIsFetching(false); - }); - break; - } - } - }} - > - Fetch - - - - - - - <> - Define transform - - { - if (isEmpty(curArray)) { - setSelectedTransformOption(0); - } - }} - // If the map we are deleting is the one we last used to test, reset the state and - // default to the first map in the list. - onMapDelete={(idxToDelete) => { - if (selectedTransformOption === idxToDelete) { - setSelectedTransformOption(0); - setTransformedInput('{}'); - } - }} - addMapEntryButtonText="Add input" - addMapButtonText="(Advanced) Add input group" - /> - - - - <> - - {isValid !== undefined && ( - - + Fetch +
+ + -
- )} - - {transformOptions.length <= 1 ? ( - Transformed input - ) : ( - Transformed input for + + + + <> + Define transform + + { - setSelectedTransformOption(Number(e.target.value)); + valueOptions={props.valueOptions} + // If the map we are adding is the first one, populate the selected option to index 0 + onMapAdd={(curArray) => { + if (isEmpty(curArray)) { + setSelectedTransformOption(0); + } + }} + // If the map we are deleting is the one we last used to test, reset the state and + // default to the first map in the list. + onMapDelete={(idxToDelete) => { + if (selectedTransformOption === idxToDelete) { + setSelectedTransformOption(0); + setTransformedInput('{}'); + } }} + addMapEntryButtonText="Add input" + addMapButtonText="(Advanced) Add input group" /> - )} + - {!isEmpty(parseModelInputsObj(props.modelInterface)) && ( - - setPopoverOpen(false)} - panelPaddingSize="s" - button={ - setPopoverOpen(!popoverOpen)} + + <> + + {isValid !== undefined && ( + - View input schema - - } - > - - The JSON Schema defining the model's expected input - - - {customStringify( - parseModelInputsObj(props.modelInterface) + + + )} + + {transformOptions.length <= 1 ? ( + Transformed input + ) : ( + Transformed input for + } + options={transformOptions} + value={selectedTransformOption} + onChange={(e) => { + setSelectedTransformOption( + Number(e.target.value) + ); + }} + /> )} - - - - )} -
- - - - - {!isEmpty(originalPrompt) && ( - - <> - - - Transformed prompt - - - setViewPromptDetails(!viewPromptDetails)} - disabled={isEmpty(JSON.parse(transformedInput))} - /> - - {isEmpty(JSON.parse(transformedInput)) && ( - - - Transformed input is empty - - - )} - - {viewPromptDetails && ( - <> + + {!isEmpty(parseModelInputsObj(props.modelInterface)) && ( + + setPopoverOpen(false)} + panelPaddingSize="s" + button={ + setPopoverOpen(!popoverOpen)} + > + View input schema + + } + > + + The JSON Schema defining the model's expected + input + + + {customStringify( + parseModelInputsObj(props.modelInterface) + )} + + + + )} + - - setViewTransformedPrompt(!viewTransformedPrompt) - } - /> - + + {!isEmpty(originalPrompt) && ( + + <> + + + Transformed prompt + + + + setViewPromptDetails(!viewPromptDetails) + } + disabled={isEmpty(JSON.parse(transformedInput))} + /> + + {isEmpty(JSON.parse(transformedInput)) && ( + + + Transformed input is empty + + + )} + + {viewPromptDetails && ( + <> + + + setViewTransformedPrompt(!viewTransformedPrompt) + } + /> + + + + )} + + )} - - - )} - -
- - - Close - - -
+ + + + + Cancel + + { + // update the parent form values + if (props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE) { + setFieldValue( + oneToOnePath, + getIn(formikProps.values, 'one_to_one') + ); + setFieldTouched(oneToOnePath, true); + } + setFieldValue( + props.inputMapFieldPath, + getIn(formikProps.values, 'input_map') + ); + setFieldTouched(props.inputMapFieldPath, true); + props.onClose(); + }} + isDisabled={tempErrors} // blocking update until valid input is given + fill={true} + color="primary" + data-testid="updateInputTransformModalButton" + > + Update + + + + ); + }} + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/modals/output_transform_modal.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/modals/output_transform_modal.tsx index ab7aaf5c..d9070120 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/modals/output_transform_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/modals/output_transform_modal.tsx @@ -4,8 +4,9 @@ */ import React, { useState, useEffect } from 'react'; -import { useFormikContext, getIn } from 'formik'; +import { useFormikContext, getIn, Formik } from 'formik'; import { cloneDeep, isEmpty, set } from 'lodash'; +import * as yup from 'yup'; import { EuiCodeEditor, EuiFlexGroup, @@ -35,6 +36,8 @@ import { ML_INFERENCE_RESPONSE_DOCS_LINK, MapArrayFormValue, ModelInterface, + OutputTransformFormValues, + OutputTransformSchema, PROCESSOR_CONTEXT, SearchHit, SearchPipelineConfig, @@ -46,6 +49,8 @@ import { import { formikToPartialPipeline, generateTransform, + getFieldSchema, + getInitialValue, prepareDocsForSimulate, unwrapTransformedDocs, } from '../../../../../utils'; @@ -67,7 +72,6 @@ interface OutputTransformModalProps { config: IProcessorConfig; baseConfigPath: string; context: PROCESSOR_CONTEXT; - outputMapField: IConfigField; outputMapFieldPath: string; modelInterface: ModelInterface | undefined; onClose: () => void; @@ -79,7 +83,33 @@ interface OutputTransformModalProps { export function OutputTransformModal(props: OutputTransformModalProps) { const dispatch = useAppDispatch(); const dataSourceId = getDataSourceId(); - const { values } = useFormikContext(); + const { values, setFieldValue, setFieldTouched } = useFormikContext< + WorkflowFormValues + >(); + + // sub-form values/schema + const outputTransformFormValues = { + output_map: getInitialValue('mapArray'), + full_response_path: getInitialValue('boolean'), + } as OutputTransformFormValues; + const outputTransformFormSchema = yup.object({ + output_map: getFieldSchema({ + type: 'mapArray', + } as IConfigField), + full_response_path: getFieldSchema( + { + type: 'boolean', + } as IConfigField, + true + ), + }) as OutputTransformSchema; + + // persist standalone values. update / initialize when it is first opened + const [tempErrors, setTempErrors] = useState(false); + const [tempFullResponsePath, setTempFullResponsePath] = useState( + false + ); + const [tempOutputMap, setTempOutputMap] = useState([]); // fetching input data state const [isFetching, setIsFetching] = useState(false); @@ -89,9 +119,7 @@ export function OutputTransformModal(props: OutputTransformModalProps) { const [transformedOutput, setTransformedOutput] = useState('{}'); // get some current form values - const map = getIn(values, props.outputMapFieldPath) as MapArrayFormValue; const fullResponsePathPath = `${props.baseConfigPath}.${props.config.id}.full_response_path`; - const fullResponsePath = getIn(values, fullResponsePathPath); const docs = getIn(values, 'ingest.docs'); let docObjs = [] as {}[] | undefined; try { @@ -113,360 +141,449 @@ export function OutputTransformModal(props: OutputTransformModalProps) { const [popoverOpen, setPopoverOpen] = useState(false); // selected transform state - const transformOptions = map.map((_, idx) => ({ + const transformOptions = tempOutputMap.map((_, idx) => ({ value: idx, text: `Prediction ${idx + 1}`, })) as EuiSelectOption[]; const [selectedTransformOption, setSelectedTransformOption] = useState< - number | undefined - >((transformOptions[0]?.value as number) ?? undefined); + number + >((transformOptions[0]?.value as number) ?? 0); // hook to re-generate the transform when any inputs to the transform are updated useEffect(() => { - if ( - !isEmpty(map) && - !isEmpty(JSON.parse(sourceOutput)) && - selectedTransformOption !== undefined - ) { + if (!isEmpty(tempOutputMap) && !isEmpty(JSON.parse(sourceOutput))) { let sampleSourceOutput = {}; try { sampleSourceOutput = JSON.parse(sourceOutput); const output = generateTransform( sampleSourceOutput, - map[selectedTransformOption] + tempOutputMap[selectedTransformOption] ); setTransformedOutput(customStringify(output)); } catch {} } else { setTransformedOutput('{}'); } - }, [map, sourceOutput, selectedTransformOption]); + }, [tempOutputMap, sourceOutput, selectedTransformOption]); // hook to clear the source output when full_response_path is toggled useEffect(() => { setSourceOutput('{}'); - }, [fullResponsePath]); + }, [tempFullResponsePath]); return ( - - - -

{`Configure output`}

-
-
- - - - <> - {(onIngestAndNoDocs || onSearchAndNoQuery) && ( - <> - - - - )} - - Fetch some sample output data and see how it is transformed. - - - {(props.context === PROCESSOR_CONTEXT.INGEST || - props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE) && ( - <> - - - - )} - + {}} + validate={(values) => {}} + > + {(formikProps) => { + // override to parent form values when changes detected + useEffect(() => { + formikProps.setFieldValue( + 'output_map', + getIn(values, props.outputMapFieldPath) + ); + }, [getIn(values, props.outputMapFieldPath)]); + useEffect(() => { + formikProps.setFieldValue( + 'full_response_path', + getIn(values, fullResponsePathPath) + ); + }, [getIn(values, fullResponsePathPath)]); + + // update temp vars when form changes are detected + useEffect(() => { + setTempOutputMap(getIn(formikProps.values, 'output_map')); + }, [getIn(formikProps.values, 'output_map')]); + useEffect(() => { + setTempFullResponsePath( + getIn(formikProps.values, 'full_response_path') + ); + }, [getIn(formikProps.values, 'full_response_path')]); + + // update tempErrors if errors detected + useEffect(() => { + setTempErrors(!isEmpty(formikProps.errors)); + }, [formikProps.errors]); + + return ( + + + +

{`Configure output`}

+
+
+ + - Source output + <> + {(onIngestAndNoDocs || onSearchAndNoQuery) && ( + <> + + + + )} + + Fetch some sample output data and see how it is + transformed. + + + {(props.context === PROCESSOR_CONTEXT.INGEST || + props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE) && ( + <> + + + + )} + + + Source output + + {!isEmpty( + parseModelOutputsObj( + props.modelInterface, + tempFullResponsePath + ) + ) && ( + + setPopoverOpen(false)} + panelPaddingSize="s" + button={ + setPopoverOpen(!popoverOpen)} + > + View output schema + + } + > + + The JSON Schema defining the model's expected + output + + + {customStringify( + parseModelOutputsObj( + props.modelInterface, + tempFullResponsePath + ) + )} + + + + )} + + { + setIsFetching(true); + switch (props.context) { + // note we skip search request processor context. that is because empty output maps are not supported. + // for more details, see comment in ml_processor_inputs.tsx + case PROCESSOR_CONTEXT.INGEST: { + // get the current ingest pipeline up to, and including this processor. + // remove any currently-configured output map since we only want the transformation + // up to, and including, the input map transformations + const valuesWithoutOutputMapConfig = cloneDeep( + values + ); + set( + valuesWithoutOutputMapConfig, + props.outputMapFieldPath, + [] + ); + set( + valuesWithoutOutputMapConfig, + fullResponsePathPath, + getIn(formikProps.values, 'full_response_path') + ); + const curIngestPipeline = formikToPartialPipeline( + valuesWithoutOutputMapConfig, + props.uiConfig, + props.config.id, + true, + PROCESSOR_CONTEXT.INGEST + ) as IngestPipelineConfig; + const curDocs = prepareDocsForSimulate( + values.ingest.docs, + values.ingest.index.name + ); + await dispatch( + simulatePipeline({ + apiBody: { + pipeline: curIngestPipeline, + docs: [curDocs[0]], + }, + dataSourceId, + }) + ) + .unwrap() + .then((resp: SimulateIngestPipelineResponse) => { + try { + const docObjs = unwrapTransformedDocs(resp); + if (docObjs.length > 0) { + const sampleModelResult = + docObjs[0]?.inference_results || {}; + setSourceOutput( + customStringify(sampleModelResult) + ); + } + } catch {} + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger( + `Failed to fetch input data` + ); + }) + .finally(() => { + setIsFetching(false); + }); + break; + } + case PROCESSOR_CONTEXT.SEARCH_RESPONSE: { + // get the current search pipeline up to, and including this processor. + // remove any currently-configured output map since we only want the transformation + // up to, and including, the input map transformations + const valuesWithoutOutputMapConfig = cloneDeep( + values + ); + set( + valuesWithoutOutputMapConfig, + props.outputMapFieldPath, + [] + ); + set( + valuesWithoutOutputMapConfig, + fullResponsePathPath, + getIn(formikProps.values, 'full_response_path') + ); + const curSearchPipeline = formikToPartialPipeline( + valuesWithoutOutputMapConfig, + props.uiConfig, + props.config.id, + true, + PROCESSOR_CONTEXT.SEARCH_RESPONSE + ) as SearchPipelineConfig; + + // Execute search. Augment the existing query with + // the partial search pipeline (inline) to get the latest transformed + // version of the request. + dispatch( + searchIndex({ + apiBody: { + index: values.ingest.index.name, + body: JSON.stringify({ + ...JSON.parse( + values.search.request as string + ), + search_pipeline: curSearchPipeline || {}, + }), + }, + dataSourceId, + }) + ) + .unwrap() + .then(async (resp) => { + const hits = resp.hits.hits.map( + (hit: SearchHit) => hit._source + ) as any[]; + if (hits.length > 0) { + const sampleModelResult = + hits[0].inference_results || {}; + setSourceOutput( + customStringify(sampleModelResult) + ); + } + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger( + `Failed to fetch source output data` + ); + }) + .finally(() => { + setIsFetching(false); + }); + break; + } + } + }} + > + Fetch + + + + - {!isEmpty( - parseModelOutputsObj(props.modelInterface, fullResponsePath) - ) && ( - - setPopoverOpen(false)} - panelPaddingSize="s" - button={ - setPopoverOpen(!popoverOpen)} - > - View output schema - + + <> + Define transform + + - - The JSON Schema defining the model's expected output - - - {customStringify( - parseModelOutputsObj( - props.modelInterface, - fullResponsePath - ) - )} - - - - )} + keyPlaceholder={ + props.context === PROCESSOR_CONTEXT.SEARCH_REQUEST + ? 'Specify a query field' + : 'Define a document field' + } + valueTitle="Name" + valuePlaceholder="Name" + valueOptions={ + tempFullResponsePath + ? undefined + : parseModelOutputs(props.modelInterface, false) + } + // If the map we are adding is the first one, populate the selected option to index 0 + onMapAdd={(curArray) => { + if (isEmpty(curArray)) { + setSelectedTransformOption(0); + } + }} + // If the map we are deleting is the one we last used to test, reset the state and + // default to the first map in the list. + onMapDelete={(idxToDelete) => { + if (selectedTransformOption === idxToDelete) { + setSelectedTransformOption(0); + setTransformedOutput('{}'); + } + }} + addMapEntryButtonText="Add output" + addMapButtonText="(Advanced) Add output group" + /> + +
+ + <> + {transformOptions.length <= 1 ? ( + Transformed output + ) : ( + Transformed output for + } + options={transformOptions} + value={selectedTransformOption} + onChange={(e) => { + setSelectedTransformOption(Number(e.target.value)); + }} + /> + )} + + + +
+
+ { - setIsFetching(true); - switch (props.context) { - // note we skip search request processor context. that is because empty output maps are not supported. - // for more details, see comment in ml_processor_inputs.tsx - case PROCESSOR_CONTEXT.INGEST: { - // get the current ingest pipeline up to, and including this processor. - // remove any currently-configured output map since we only want the transformation - // up to, and including, the input map transformations - const valuesWithoutOutputMapConfig = cloneDeep(values); - set( - valuesWithoutOutputMapConfig, - `ingest.enrich.${props.config.id}.output_map`, - [] - ); - const curIngestPipeline = formikToPartialPipeline( - valuesWithoutOutputMapConfig, - props.uiConfig, - props.config.id, - true, - PROCESSOR_CONTEXT.INGEST - ) as IngestPipelineConfig; - const curDocs = prepareDocsForSimulate( - values.ingest.docs, - values.ingest.index.name - ); - await dispatch( - simulatePipeline({ - apiBody: { - pipeline: curIngestPipeline, - docs: [curDocs[0]], - }, - dataSourceId, - }) - ) - .unwrap() - .then((resp: SimulateIngestPipelineResponse) => { - try { - const docObjs = unwrapTransformedDocs(resp); - if (docObjs.length > 0) { - const sampleModelResult = - docObjs[0]?.inference_results || {}; - setSourceOutput( - customStringify(sampleModelResult) - ); - } - } catch {} - }) - .catch((error: any) => { - getCore().notifications.toasts.addDanger( - `Failed to fetch input data` - ); - }) - .finally(() => { - setIsFetching(false); - }); - break; - } - case PROCESSOR_CONTEXT.SEARCH_RESPONSE: { - // get the current search pipeline up to, and including this processor. - // remove any currently-configured output map since we only want the transformation - // up to, and including, the input map transformations - const valuesWithoutOutputMapConfig = cloneDeep(values); - set( - valuesWithoutOutputMapConfig, - `search.enrichResponse.${props.config.id}.output_map`, - [] - ); - const curSearchPipeline = formikToPartialPipeline( - valuesWithoutOutputMapConfig, - props.uiConfig, - props.config.id, - true, - PROCESSOR_CONTEXT.SEARCH_RESPONSE - ) as SearchPipelineConfig; + onClick={props.onClose} + fill={false} + color="primary" + data-testid="cancelOutputTransformModalButton" + > + Cancel + + { + // update the parent form values + setFieldValue( + fullResponsePathPath, + getIn(formikProps.values, 'full_response_path') + ); + setFieldTouched(fullResponsePathPath, true); - // Execute search. Augment the existing query with - // the partial search pipeline (inline) to get the latest transformed - // version of the request. - dispatch( - searchIndex({ - apiBody: { - index: values.ingest.index.name, - body: JSON.stringify({ - ...JSON.parse(values.search.request as string), - search_pipeline: curSearchPipeline || {}, - }), - }, - dataSourceId, - }) - ) - .unwrap() - .then(async (resp) => { - const hits = resp.hits.hits.map( - (hit: SearchHit) => hit._source - ) as any[]; - if (hits.length > 0) { - const sampleModelResult = - hits[0].inference_results || {}; - setSourceOutput(customStringify(sampleModelResult)); - } - }) - .catch((error: any) => { - getCore().notifications.toasts.addDanger( - `Failed to fetch source output data` - ); - }) - .finally(() => { - setIsFetching(false); - }); - break; - } - } + setFieldValue( + props.outputMapFieldPath, + getIn(formikProps.values, 'output_map') + ); + setFieldTouched(props.outputMapFieldPath, true); + props.onClose(); }} + isDisabled={tempErrors} // blocking update until valid input is given + fill={true} + color="primary" + data-testid="updateOutputTransformModalButton" > - Fetch + Update - - - - - - <> - Define transform - - { - if (isEmpty(curArray)) { - setSelectedTransformOption(0); - } - }} - // If the map we are deleting is the one we last used to test, reset the state and - // default to the first map in the list. - onMapDelete={(idxToDelete) => { - if (selectedTransformOption === idxToDelete) { - setSelectedTransformOption(0); - setTransformedOutput('{}'); - } - }} - addMapEntryButtonText="Add output" - addMapButtonText="(Advanced) Add output group" - /> - - - - <> - {transformOptions.length <= 1 ? ( - Transformed output - ) : ( - Transformed output for} - options={transformOptions} - value={selectedTransformOption} - onChange={(e) => { - setSelectedTransformOption(Number(e.target.value)); - }} - /> - )} - - - - - - - - - Close - - -
+ + + ); + }} + ); }