Skip to content

Commit 228735e

Browse files
authored
Add dedicated UX for query template building (ML search req processors) (opensearch-project#407)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent a7a0264 commit 228735e

File tree

4 files changed

+313
-2
lines changed

4 files changed

+313
-2
lines changed

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

+40-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
parseModelOutputs,
4646
} from '../../../../utils';
4747
import { ConfigFieldList } from '../config_field_list';
48+
import { OverrideQueryModal } from './modals/override_query_modal';
4849

4950
interface MLProcessorInputsProps {
5051
uiConfig: WorkflowConfig;
@@ -127,6 +128,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
127128
boolean
128129
>(false);
129130
const [isPromptModalOpen, setIsPromptModalOpen] = useState<boolean>(false);
131+
const [isQueryModalOpen, setIsQueryModalOpen] = useState<boolean>(false);
130132

131133
// model interface state
132134
const [modelInterface, setModelInterface] = useState<
@@ -281,6 +283,14 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
281283
onClose={() => setIsPromptModalOpen(false)}
282284
/>
283285
)}
286+
{isQueryModalOpen && (
287+
<OverrideQueryModal
288+
config={props.config}
289+
baseConfigPath={props.baseConfigPath}
290+
modelInterface={modelInterface}
291+
onClose={() => setIsQueryModalOpen(false)}
292+
/>
293+
)}
284294
<ModelField
285295
field={modelField}
286296
fieldPath={modelFieldPath}
@@ -290,6 +300,24 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
290300
{!isEmpty(getIn(values, modelFieldPath)?.id) && (
291301
<>
292302
<EuiSpacer size="s" />
303+
{props.context === PROCESSOR_CONTEXT.SEARCH_REQUEST && (
304+
<>
305+
<EuiText
306+
size="m"
307+
style={{ marginTop: '4px' }}
308+
>{`Override query (Optional)`}</EuiText>
309+
<EuiSpacer size="s" />
310+
<EuiSmallButton
311+
style={{ width: '100px' }}
312+
fill={false}
313+
onClick={() => setIsQueryModalOpen(true)}
314+
data-testid="overrideQueryButton"
315+
>
316+
Override
317+
</EuiSmallButton>
318+
<EuiSpacer size="l" />
319+
</>
320+
)}
293321
{containsPromptField && (
294322
<>
295323
<EuiText
@@ -301,6 +329,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
301329
style={{ width: '100px' }}
302330
fill={false}
303331
onClick={() => setIsPromptModalOpen(true)}
332+
data-testid="configurePromptButton"
304333
>
305334
Configure
306335
</EuiSmallButton>
@@ -442,7 +471,17 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
442471
<EuiSpacer size="s" />
443472
<ConfigFieldList
444473
configId={props.config.id}
445-
configFields={props.config.optionalFields || []}
474+
configFields={
475+
// For ML search request processors, we don't expose the optional query_template field, since we have a dedicated
476+
// UI for configuring that. See override_query_modal.tsx for details.
477+
props.context === PROCESSOR_CONTEXT.SEARCH_REQUEST
478+
? [
479+
...(props.config.optionalFields?.filter(
480+
(optionalField) => optionalField.id !== 'query_template'
481+
) || []),
482+
]
483+
: props.config.optionalFields || []
484+
}
446485
baseConfigPath={props.baseConfigPath}
447486
/>
448487
</EuiAccordion>

public/pages/workflow_detail/workflow_inputs/processor_inputs/modals/configure_prompt_modal.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export function ConfigurePromptModal(props: ConfigurePromptModalProps) {
9494
</EuiModalHeaderTitle>
9595
</EuiModalHeader>
9696
<EuiModalBody style={{ height: '40vh' }}>
97+
<EuiText color="subdued">
98+
Configure a custom prompt template for the model. Optionally inject
99+
dynamic model inputs into the template.
100+
</EuiText>
97101
<EuiFlexGroup direction="column">
98102
<EuiFlexItem>
99103
<>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { useState } from 'react';
7+
import { useFormikContext, getIn } from 'formik';
8+
import {
9+
EuiFlexGroup,
10+
EuiFlexItem,
11+
EuiModal,
12+
EuiModalBody,
13+
EuiModalFooter,
14+
EuiModalHeader,
15+
EuiModalHeaderTitle,
16+
EuiSmallButton,
17+
EuiSpacer,
18+
EuiText,
19+
EuiPopover,
20+
EuiCode,
21+
EuiBasicTable,
22+
EuiAccordion,
23+
EuiCopy,
24+
EuiButtonIcon,
25+
EuiContextMenu,
26+
} from '@elastic/eui';
27+
import {
28+
IMAGE_FIELD_PATTERN,
29+
IProcessorConfig,
30+
LABEL_FIELD_PATTERN,
31+
MapEntry,
32+
MODEL_ID_PATTERN,
33+
ModelInterface,
34+
QUERY_IMAGE_PATTERN,
35+
QUERY_PRESETS,
36+
QUERY_TEXT_PATTERN,
37+
QueryPreset,
38+
TEXT_FIELD_PATTERN,
39+
VECTOR_FIELD_PATTERN,
40+
VECTOR_PATTERN,
41+
WorkflowFormValues,
42+
} from '../../../../../../common';
43+
import { parseModelOutputs } from '../../../../../utils/utils';
44+
import { JsonField } from '../../input_fields';
45+
46+
interface OverrideQueryModalProps {
47+
config: IProcessorConfig;
48+
baseConfigPath: string;
49+
modelInterface: ModelInterface | undefined;
50+
onClose: () => void;
51+
}
52+
53+
/**
54+
* A modal to configure a query template & override the existing query. Can manually configure,
55+
* include placeholder values using model outputs, and/or select from a presets library.
56+
*/
57+
export function OverrideQueryModal(props: OverrideQueryModalProps) {
58+
const { values, setFieldValue, setFieldTouched } = useFormikContext<
59+
WorkflowFormValues
60+
>();
61+
62+
// get some current form values
63+
const modelOutputs = parseModelOutputs(props.modelInterface);
64+
const queryFieldPath = `${props.baseConfigPath}.${props.config.id}.query_template`;
65+
const outputMap = getIn(
66+
values,
67+
`${props.baseConfigPath}.${props.config.id}.output_map`
68+
);
69+
// TODO: should handle edge case of multiple output maps configured. Currently
70+
// defaulting to prediction 0 / assuming not multiple predictions to track.
71+
const outputMapKeys = getIn(outputMap, '0', []).map(
72+
(mapEntry: MapEntry) => mapEntry.key
73+
) as string[];
74+
const finalModelOutputs =
75+
outputMapKeys.length > 0
76+
? outputMapKeys.map((outputMapKey) => {
77+
return { label: outputMapKey };
78+
})
79+
: modelOutputs.map((modelOutput) => {
80+
return { label: modelOutput.label };
81+
});
82+
83+
// popover states
84+
const [presetsPopoverOpen, setPresetsPopoverOpen] = useState<boolean>(false);
85+
86+
return (
87+
<EuiModal onClose={props.onClose} style={{ width: '70vw' }}>
88+
<EuiModalHeader>
89+
<EuiModalHeaderTitle>
90+
<p>{`Override query`}</p>
91+
</EuiModalHeaderTitle>
92+
</EuiModalHeader>
93+
<EuiModalBody style={{ height: '40vh' }}>
94+
<EuiText color="subdued">
95+
Configure a custom query template to override the existing one.
96+
Optionally inject dynamic model outputs into the new query.
97+
</EuiText>
98+
<EuiFlexGroup direction="column">
99+
<EuiFlexItem>
100+
<>
101+
<EuiSpacer size="s" />
102+
<EuiPopover
103+
button={
104+
<EuiSmallButton
105+
onClick={() => setPresetsPopoverOpen(!presetsPopoverOpen)}
106+
>
107+
Choose from a preset
108+
</EuiSmallButton>
109+
}
110+
isOpen={presetsPopoverOpen}
111+
closePopover={() => setPresetsPopoverOpen(false)}
112+
anchorPosition="downLeft"
113+
>
114+
<EuiContextMenu
115+
initialPanelId={0}
116+
panels={[
117+
{
118+
id: 0,
119+
items: QUERY_PRESETS.map((preset: QueryPreset) => ({
120+
name: preset.name,
121+
onClick: () => {
122+
setFieldValue(
123+
queryFieldPath,
124+
preset.query
125+
// sanitize the query preset string into valid template placeholder format, for
126+
// any placeholder values in the query.
127+
// for example, replacing `"{{vector}}"` with `${vector}`
128+
.replace(
129+
new RegExp(`"${VECTOR_FIELD_PATTERN}"`, 'g'),
130+
`\$\{vector_field\}`
131+
)
132+
.replace(
133+
new RegExp(`"${VECTOR_PATTERN}"`, 'g'),
134+
`\$\{vector\}`
135+
)
136+
.replace(
137+
new RegExp(`"${TEXT_FIELD_PATTERN}"`, 'g'),
138+
`\$\{text_field\}`
139+
)
140+
.replace(
141+
new RegExp(`"${IMAGE_FIELD_PATTERN}"`, 'g'),
142+
`\$\{image_field\}`
143+
)
144+
.replace(
145+
new RegExp(`"${LABEL_FIELD_PATTERN}"`, 'g'),
146+
`\$\{label_field\}`
147+
)
148+
.replace(
149+
new RegExp(`"${QUERY_TEXT_PATTERN}"`, 'g'),
150+
`\$\{query_text\}`
151+
)
152+
.replace(
153+
new RegExp(`"${QUERY_IMAGE_PATTERN}"`, 'g'),
154+
`\$\{query_image\}`
155+
)
156+
.replace(
157+
new RegExp(`"${MODEL_ID_PATTERN}"`, 'g'),
158+
`\$\{model_id\}`
159+
)
160+
);
161+
setFieldTouched(queryFieldPath, true);
162+
setPresetsPopoverOpen(false);
163+
},
164+
})),
165+
},
166+
]}
167+
/>
168+
</EuiPopover>
169+
<EuiSpacer size="m" />
170+
<JsonField
171+
validate={false}
172+
label={'Query template'}
173+
fieldPath={queryFieldPath}
174+
/>
175+
{finalModelOutputs.length > 0 && (
176+
<>
177+
<EuiSpacer size="m" />
178+
<EuiAccordion
179+
id={`modelOutputsAccordion`}
180+
buttonContent="Model outputs"
181+
style={{ marginLeft: '-8px' }}
182+
>
183+
<>
184+
<EuiSpacer size="s" />
185+
<EuiText
186+
style={{ paddingLeft: '8px' }}
187+
size="s"
188+
color="subdued"
189+
>
190+
To use any model outputs in the query template, copy the
191+
placeholder string directly.
192+
</EuiText>
193+
<EuiSpacer size="s" />
194+
<EuiBasicTable
195+
// @ts-ignore
196+
items={finalModelOutputs}
197+
columns={columns}
198+
/>
199+
</>
200+
</EuiAccordion>
201+
<EuiSpacer size="m" />
202+
</>
203+
)}
204+
</>
205+
</EuiFlexItem>
206+
</EuiFlexGroup>
207+
</EuiModalBody>
208+
<EuiModalFooter>
209+
<EuiSmallButton
210+
onClick={props.onClose}
211+
fill={false}
212+
color="primary"
213+
data-testid="closeModalButton"
214+
>
215+
Close
216+
</EuiSmallButton>
217+
</EuiModalFooter>
218+
</EuiModal>
219+
);
220+
}
221+
222+
const columns = [
223+
{
224+
name: 'Name',
225+
field: 'label',
226+
width: '40%',
227+
},
228+
{
229+
name: 'Placeholder string',
230+
field: 'label',
231+
width: '50%',
232+
render: (label: string) => (
233+
<EuiCode
234+
style={{
235+
marginLeft: '-10px',
236+
}}
237+
language="json"
238+
transparentBackground={true}
239+
>
240+
{getPlaceholderString(label)}
241+
</EuiCode>
242+
),
243+
},
244+
{
245+
name: 'Actions',
246+
field: 'label',
247+
width: '10%',
248+
render: (label: string) => (
249+
<EuiCopy textToCopy={getPlaceholderString(label)}>
250+
{(copy) => (
251+
<EuiButtonIcon
252+
aria-label="Copy"
253+
iconType="copy"
254+
onClick={copy}
255+
></EuiButtonIcon>
256+
)}
257+
</EuiCopy>
258+
),
259+
},
260+
];
261+
262+
// small util fn to get the full placeholder string to be
263+
// inserted into the template
264+
function getPlaceholderString(label: string) {
265+
return `\$\{${label}\}`;
266+
}

public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
292292
ingestTemplatesDifferent || isRunningIngest;
293293
const searchBackButtonDisabled =
294294
isRunningSearch ||
295-
(isProposingNoSearchResources ? false : searchTemplatesDifferent);
295+
(isProposingNoSearchResources || !ingestProvisioned
296+
? false
297+
: searchTemplatesDifferent);
296298
const searchUndoButtonDisabled =
297299
isRunningSave || isRunningSearch
298300
? true

0 commit comments

Comments
 (0)