Skip to content

Commit e908023

Browse files
authored
Support multiple input/output maps for ML processors (#244)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent 23179d6 commit e908023

12 files changed

+331
-47
lines changed

common/interfaces.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export type ConfigFieldType =
2323
| 'jsonArray'
2424
| 'select'
2525
| 'model'
26-
| 'map';
26+
| 'map'
27+
| 'mapArray';
2728
export type ConfigFieldValue = string | {};
2829
export interface IConfigField {
2930
type: ConfigFieldType;
@@ -81,6 +82,8 @@ export type MapEntry = {
8182

8283
export type MapFormValue = MapEntry[];
8384

85+
export type MapArrayFormValue = MapFormValue[];
86+
8487
export type WorkflowFormValues = {
8588
ingest: FormikValues;
8689
search: FormikValues;

public/configs/ml_processor.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ export abstract class MLProcessor extends Processor {
2222
},
2323
{
2424
id: 'inputMap',
25-
type: 'map',
25+
type: 'mapArray',
2626
},
2727
{
2828
id: 'outputMap',
29-
type: 'map',
29+
type: 'mapArray',
3030
},
3131
];
3232
}

public/pages/workflow_detail/workflow_inputs/input_fields/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export { TextField } from './text_field';
77
export { JsonField } from './json_field';
88
export { ModelField } from './model_field';
99
export { MapField } from './map_field';
10+
export { MapArrayField } from './map_array_field';
1011
export { BooleanField } from './boolean_field';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React from 'react';
7+
import {
8+
EuiAccordion,
9+
EuiButton,
10+
EuiButtonIcon,
11+
EuiFlexGroup,
12+
EuiFlexItem,
13+
EuiFormRow,
14+
EuiLink,
15+
EuiPanel,
16+
EuiText,
17+
} from '@elastic/eui';
18+
import { Field, FieldProps, getIn, useFormikContext } from 'formik';
19+
import {
20+
IConfigField,
21+
MapArrayFormValue,
22+
MapEntry,
23+
WorkflowFormValues,
24+
} from '../../../../../common';
25+
import { MapField } from './map_field';
26+
27+
interface MapArrayFieldProps {
28+
field: IConfigField;
29+
fieldPath: string; // the full path in string-form to the field (e.g., 'ingest.enrich.processors.text_embedding_processor.inputField')
30+
label?: string;
31+
helpLink?: string;
32+
helpText?: string;
33+
keyPlaceholder?: string;
34+
valuePlaceholder?: string;
35+
onFormChange: () => void;
36+
onMapAdd?: (curArray: MapArrayFormValue) => void;
37+
onMapDelete?: (idxToDelete: number) => void;
38+
}
39+
40+
/**
41+
* Input component for configuring an array of field mappings
42+
*/
43+
export function MapArrayField(props: MapArrayFieldProps) {
44+
const { setFieldValue, setFieldTouched, errors, touched } = useFormikContext<
45+
WorkflowFormValues
46+
>();
47+
48+
// Adding a map to the end of the existing arr
49+
function addMap(curMapArray: MapArrayFormValue): void {
50+
setFieldValue(props.fieldPath, [...curMapArray, []]);
51+
setFieldTouched(props.fieldPath, true);
52+
props.onFormChange();
53+
if (props.onMapAdd) {
54+
props.onMapAdd(curMapArray);
55+
}
56+
}
57+
58+
// Deleting a map
59+
function deleteMap(
60+
curMapArray: MapArrayFormValue,
61+
entryIndexToDelete: number
62+
): void {
63+
const updatedMapArray = [...curMapArray];
64+
updatedMapArray.splice(entryIndexToDelete, 1);
65+
setFieldValue(props.fieldPath, updatedMapArray);
66+
setFieldTouched(props.fieldPath, true);
67+
props.onFormChange();
68+
if (props.onMapDelete) {
69+
props.onMapDelete(entryIndexToDelete);
70+
}
71+
}
72+
73+
return (
74+
<Field name={props.fieldPath}>
75+
{({ field, form }: FieldProps) => {
76+
return (
77+
<EuiFormRow
78+
fullWidth={true}
79+
key={props.fieldPath}
80+
label={props.label}
81+
labelAppend={
82+
props.helpLink ? (
83+
<EuiText size="xs">
84+
<EuiLink href={props.helpLink} target="_blank">
85+
Learn more
86+
</EuiLink>
87+
</EuiText>
88+
) : undefined
89+
}
90+
helpText={props.helpText || undefined}
91+
isInvalid={
92+
getIn(errors, field.name) !== undefined &&
93+
getIn(errors, field.name).length > 0 &&
94+
getIn(touched, field.name) !== undefined &&
95+
getIn(touched, field.name).length > 0
96+
}
97+
>
98+
<EuiFlexGroup direction="column">
99+
{field.value?.map((mapping: MapEntry, idx: number) => {
100+
return (
101+
<EuiFlexItem key={idx}>
102+
<EuiAccordion
103+
key={idx}
104+
id={`accordion${idx}`}
105+
buttonContent={`Prediction ${idx + 1}`}
106+
paddingSize="m"
107+
extraAction={
108+
<EuiButtonIcon
109+
style={{ marginTop: '8px' }}
110+
iconType={'trash'}
111+
color="danger"
112+
aria-label="Delete"
113+
onClick={() => {
114+
deleteMap(field.value, idx);
115+
}}
116+
/>
117+
}
118+
>
119+
<EuiPanel grow={true}>
120+
<MapField
121+
fieldPath={`${props.fieldPath}.${idx}`}
122+
keyPlaceholder={props.keyPlaceholder}
123+
valuePlaceholder={props.valuePlaceholder}
124+
onFormChange={props.onFormChange}
125+
/>
126+
</EuiPanel>
127+
</EuiAccordion>
128+
</EuiFlexItem>
129+
);
130+
})}
131+
<EuiFlexItem grow={false}>
132+
<div>
133+
<EuiButton
134+
size="s"
135+
onClick={() => {
136+
addMap(field.value);
137+
}}
138+
>
139+
{field.value?.length > 0 ? 'Add another map' : 'Add map'}
140+
</EuiButton>
141+
</div>
142+
</EuiFlexItem>
143+
</EuiFlexGroup>
144+
</EuiFormRow>
145+
);
146+
}}
147+
</Field>
148+
);
149+
}

public/pages/workflow_detail/workflow_inputs/input_fields/map_field.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,14 @@ import {
1616
} from '@elastic/eui';
1717
import { Field, FieldProps, getIn, useFormikContext } from 'formik';
1818
import {
19-
IConfigField,
2019
MapEntry,
2120
MapFormValue,
2221
WorkflowFormValues,
2322
} from '../../../../../common';
2423

2524
interface MapFieldProps {
26-
field: IConfigField;
2725
fieldPath: string; // the full path in string-form to the field (e.g., 'ingest.enrich.processors.text_embedding_processor.inputField')
28-
label: string;
26+
label?: string;
2927
helpLink?: string;
3028
helpText?: string;
3129
keyPlaceholder?: string;
@@ -62,10 +60,11 @@ export function MapField(props: MapFieldProps) {
6260
}
6361

6462
return (
65-
<Field name={props.fieldPath}>
63+
<Field name={props.fieldPath} key={props.fieldPath}>
6664
{({ field, form }: FieldProps) => {
6765
return (
6866
<EuiFormRow
67+
fullWidth={true}
6968
key={props.fieldPath}
7069
label={props.label}
7170
labelAppend={
@@ -95,9 +94,10 @@ export function MapField(props: MapFieldProps) {
9594
{field.value?.map((mapping: MapEntry, idx: number) => {
9695
return (
9796
<EuiFlexItem key={idx}>
98-
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
99-
<EuiFlexItem grow={false}>
97+
<EuiFlexGroup direction="row">
98+
<EuiFlexItem grow={true}>
10099
<EuiFormControlLayoutDelimited
100+
fullWidth={true}
101101
startControl={
102102
<input
103103
type="string"

public/pages/workflow_detail/workflow_inputs/processor_inputs/input_transform_modal.tsx

+52-8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
EuiModalFooter,
1717
EuiModalHeader,
1818
EuiModalHeaderTitle,
19+
EuiSelect,
20+
EuiSelectOption,
1921
EuiSpacer,
2022
EuiText,
2123
} from '@elastic/eui';
@@ -25,6 +27,7 @@ import {
2527
IngestPipelineConfig,
2628
JSONPATH_ROOT_SELECTOR,
2729
ML_INFERENCE_DOCS_LINK,
30+
MapArrayFormValue,
2831
PROCESSOR_CONTEXT,
2932
SimulateIngestPipelineResponse,
3033
WorkflowConfig,
@@ -38,7 +41,7 @@ import {
3841
} from '../../../../utils';
3942
import { simulatePipeline, useAppDispatch } from '../../../../store';
4043
import { getCore } from '../../../../services';
41-
import { MapField } from '../input_fields';
44+
import { MapArrayField } from '../input_fields';
4245

4346
interface InputTransformModalProps {
4447
uiConfig: WorkflowConfig;
@@ -50,6 +53,10 @@ interface InputTransformModalProps {
5053
onFormChange: () => void;
5154
}
5255

56+
// TODO: InputTransformModal and OutputTransformModal are very similar, and can
57+
// likely be refactored and have more reusable components. Leave as-is until the
58+
// UI is more finalized.
59+
5360
/**
5461
* A modal to configure advanced JSON-to-JSON transforms into a model's expected input
5562
*/
@@ -59,10 +66,19 @@ export function InputTransformModal(props: InputTransformModalProps) {
5966

6067
// source input / transformed output state
6168
const [sourceInput, setSourceInput] = useState<string>('[]');
62-
const [transformedOutput, setTransformedOutput] = useState<string>('[]');
69+
const [transformedOutput, setTransformedOutput] = useState<string>('{}');
6370

6471
// get the current input map
65-
const map = getIn(values, `ingest.enrich.${props.config.id}.inputMap`);
72+
const map = getIn(values, props.inputMapFieldPath) as MapArrayFormValue;
73+
74+
// selected output state
75+
const outputOptions = map.map((_, idx) => ({
76+
value: idx,
77+
text: `Prediction ${idx + 1}`,
78+
})) as EuiSelectOption[];
79+
const [selectedOutputOption, setSelectedOutputOption] = useState<
80+
number | undefined
81+
>((outputOptions[0]?.value as number) ?? undefined);
6682

6783
return (
6884
<EuiModal onClose={props.onClose} style={{ width: '70vw' }}>
@@ -149,34 +165,62 @@ export function InputTransformModal(props: InputTransformModalProps) {
149165
root object selector "${JSONPATH_ROOT_SELECTOR}"`}
150166
</EuiText>
151167
<EuiSpacer size="s" />
152-
<MapField
168+
<MapArrayField
153169
field={props.inputMapField}
154170
fieldPath={props.inputMapFieldPath}
155-
label="Input map"
171+
label="Input Map"
156172
helpText={`An array specifying how to map fields from the ingested document to the model’s input.`}
157173
helpLink={ML_INFERENCE_DOCS_LINK}
158174
keyPlaceholder="Model input field"
159175
valuePlaceholder="Document field"
160176
onFormChange={props.onFormChange}
177+
// If the map we are adding is the first one, populate the selected option to index 0
178+
onMapAdd={(curArray) => {
179+
if (isEmpty(curArray)) {
180+
setSelectedOutputOption(0);
181+
}
182+
}}
183+
// If the map we are deleting is the one we last used to test, reset the state and
184+
// default to the first map in the list.
185+
onMapDelete={(idxToDelete) => {
186+
if (selectedOutputOption === idxToDelete) {
187+
setSelectedOutputOption(0);
188+
setTransformedOutput('{}');
189+
}
190+
}}
161191
/>
162192
</>
163193
</EuiFlexItem>
164194
<EuiFlexItem>
165195
<>
166-
<EuiText>Expected output</EuiText>
196+
<EuiSelect
197+
prepend={<EuiText>Expected output for</EuiText>}
198+
compressed={true}
199+
options={outputOptions}
200+
value={selectedOutputOption}
201+
onChange={(e) => {
202+
setSelectedOutputOption(Number(e.target.value));
203+
setTransformedOutput('{}');
204+
}}
205+
/>
206+
<EuiSpacer size="s" />
167207
<EuiButton
168208
style={{ width: '100px' }}
169209
disabled={isEmpty(map) || isEmpty(JSON.parse(sourceInput))}
170210
onClick={async () => {
171211
switch (props.context) {
172212
case PROCESSOR_CONTEXT.INGEST: {
173-
if (!isEmpty(map) && !isEmpty(JSON.parse(sourceInput))) {
213+
if (
214+
!isEmpty(map) &&
215+
!isEmpty(JSON.parse(sourceInput)) &&
216+
selectedOutputOption !== undefined
217+
) {
174218
let sampleSourceInput = {};
175219
try {
176220
sampleSourceInput = JSON.parse(sourceInput)[0];
177221
const output = generateTransform(
178222
sampleSourceInput,
179-
map
223+
map[selectedOutputOption]
180224
);
181225
setTransformedOutput(
182226
JSON.stringify(output, undefined, 2)

0 commit comments

Comments
 (0)