Skip to content

Commit

Permalink
Add dedicated UX for query template building (ML search req processor…
Browse files Browse the repository at this point in the history
…s) (opensearch-project#407)

Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler authored Oct 3, 2024
1 parent a7a0264 commit 228735e
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
parseModelOutputs,
} from '../../../../utils';
import { ConfigFieldList } from '../config_field_list';
import { OverrideQueryModal } from './modals/override_query_modal';

interface MLProcessorInputsProps {
uiConfig: WorkflowConfig;
Expand Down Expand Up @@ -127,6 +128,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
boolean
>(false);
const [isPromptModalOpen, setIsPromptModalOpen] = useState<boolean>(false);
const [isQueryModalOpen, setIsQueryModalOpen] = useState<boolean>(false);

// model interface state
const [modelInterface, setModelInterface] = useState<
Expand Down Expand Up @@ -281,6 +283,14 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
onClose={() => setIsPromptModalOpen(false)}
/>
)}
{isQueryModalOpen && (
<OverrideQueryModal
config={props.config}
baseConfigPath={props.baseConfigPath}
modelInterface={modelInterface}
onClose={() => setIsQueryModalOpen(false)}
/>
)}
<ModelField
field={modelField}
fieldPath={modelFieldPath}
Expand All @@ -290,6 +300,24 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
{!isEmpty(getIn(values, modelFieldPath)?.id) && (
<>
<EuiSpacer size="s" />
{props.context === PROCESSOR_CONTEXT.SEARCH_REQUEST && (
<>
<EuiText
size="m"
style={{ marginTop: '4px' }}
>{`Override query (Optional)`}</EuiText>
<EuiSpacer size="s" />
<EuiSmallButton
style={{ width: '100px' }}
fill={false}
onClick={() => setIsQueryModalOpen(true)}
data-testid="overrideQueryButton"
>
Override
</EuiSmallButton>
<EuiSpacer size="l" />
</>
)}
{containsPromptField && (
<>
<EuiText
Expand All @@ -301,6 +329,7 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
style={{ width: '100px' }}
fill={false}
onClick={() => setIsPromptModalOpen(true)}
data-testid="configurePromptButton"
>
Configure
</EuiSmallButton>
Expand Down Expand Up @@ -442,7 +471,17 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) {
<EuiSpacer size="s" />
<ConfigFieldList
configId={props.config.id}
configFields={props.config.optionalFields || []}
configFields={
// For ML search request processors, we don't expose the optional query_template field, since we have a dedicated
// UI for configuring that. See override_query_modal.tsx for details.
props.context === PROCESSOR_CONTEXT.SEARCH_REQUEST
? [
...(props.config.optionalFields?.filter(
(optionalField) => optionalField.id !== 'query_template'
) || []),
]
: props.config.optionalFields || []
}
baseConfigPath={props.baseConfigPath}
/>
</EuiAccordion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export function ConfigurePromptModal(props: ConfigurePromptModalProps) {
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody style={{ height: '40vh' }}>
<EuiText color="subdued">
Configure a custom prompt template for the model. Optionally inject
dynamic model inputs into the template.
</EuiText>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState } from 'react';
import { useFormikContext, getIn } from 'formik';
import {
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSmallButton,
EuiSpacer,
EuiText,
EuiPopover,
EuiCode,
EuiBasicTable,
EuiAccordion,
EuiCopy,
EuiButtonIcon,
EuiContextMenu,
} from '@elastic/eui';
import {
IMAGE_FIELD_PATTERN,
IProcessorConfig,
LABEL_FIELD_PATTERN,
MapEntry,
MODEL_ID_PATTERN,
ModelInterface,
QUERY_IMAGE_PATTERN,
QUERY_PRESETS,
QUERY_TEXT_PATTERN,
QueryPreset,
TEXT_FIELD_PATTERN,
VECTOR_FIELD_PATTERN,
VECTOR_PATTERN,
WorkflowFormValues,
} from '../../../../../../common';
import { parseModelOutputs } from '../../../../../utils/utils';
import { JsonField } from '../../input_fields';

interface OverrideQueryModalProps {
config: IProcessorConfig;
baseConfigPath: string;
modelInterface: ModelInterface | undefined;
onClose: () => void;
}

/**
* A modal to configure a query template & override the existing query. Can manually configure,
* include placeholder values using model outputs, and/or select from a presets library.
*/
export function OverrideQueryModal(props: OverrideQueryModalProps) {
const { values, setFieldValue, setFieldTouched } = useFormikContext<
WorkflowFormValues
>();

// get some current form values
const modelOutputs = parseModelOutputs(props.modelInterface);
const queryFieldPath = `${props.baseConfigPath}.${props.config.id}.query_template`;
const outputMap = getIn(
values,
`${props.baseConfigPath}.${props.config.id}.output_map`
);
// TODO: should handle edge case of multiple output maps configured. Currently
// defaulting to prediction 0 / assuming not multiple predictions to track.
const outputMapKeys = getIn(outputMap, '0', []).map(
(mapEntry: MapEntry) => mapEntry.key
) as string[];
const finalModelOutputs =
outputMapKeys.length > 0
? outputMapKeys.map((outputMapKey) => {
return { label: outputMapKey };
})
: modelOutputs.map((modelOutput) => {
return { label: modelOutput.label };
});

// popover states
const [presetsPopoverOpen, setPresetsPopoverOpen] = useState<boolean>(false);

return (
<EuiModal onClose={props.onClose} style={{ width: '70vw' }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<p>{`Override query`}</p>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody style={{ height: '40vh' }}>
<EuiText color="subdued">
Configure a custom query template to override the existing one.
Optionally inject dynamic model outputs into the new query.
</EuiText>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<>
<EuiSpacer size="s" />
<EuiPopover
button={
<EuiSmallButton
onClick={() => setPresetsPopoverOpen(!presetsPopoverOpen)}
>
Choose from a preset
</EuiSmallButton>
}
isOpen={presetsPopoverOpen}
closePopover={() => setPresetsPopoverOpen(false)}
anchorPosition="downLeft"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
items: QUERY_PRESETS.map((preset: QueryPreset) => ({
name: preset.name,
onClick: () => {
setFieldValue(
queryFieldPath,
preset.query
// sanitize the query preset string into valid template placeholder format, for
// any placeholder values in the query.
// for example, replacing `"{{vector}}"` with `${vector}`
.replace(
new RegExp(`"${VECTOR_FIELD_PATTERN}"`, 'g'),
`\$\{vector_field\}`
)
.replace(
new RegExp(`"${VECTOR_PATTERN}"`, 'g'),
`\$\{vector\}`
)
.replace(
new RegExp(`"${TEXT_FIELD_PATTERN}"`, 'g'),
`\$\{text_field\}`
)
.replace(
new RegExp(`"${IMAGE_FIELD_PATTERN}"`, 'g'),
`\$\{image_field\}`
)
.replace(
new RegExp(`"${LABEL_FIELD_PATTERN}"`, 'g'),
`\$\{label_field\}`
)
.replace(
new RegExp(`"${QUERY_TEXT_PATTERN}"`, 'g'),
`\$\{query_text\}`
)
.replace(
new RegExp(`"${QUERY_IMAGE_PATTERN}"`, 'g'),
`\$\{query_image\}`
)
.replace(
new RegExp(`"${MODEL_ID_PATTERN}"`, 'g'),
`\$\{model_id\}`
)
);
setFieldTouched(queryFieldPath, true);
setPresetsPopoverOpen(false);
},
})),
},
]}
/>
</EuiPopover>
<EuiSpacer size="m" />
<JsonField
validate={false}
label={'Query template'}
fieldPath={queryFieldPath}
/>
{finalModelOutputs.length > 0 && (
<>
<EuiSpacer size="m" />
<EuiAccordion
id={`modelOutputsAccordion`}
buttonContent="Model outputs"
style={{ marginLeft: '-8px' }}
>
<>
<EuiSpacer size="s" />
<EuiText
style={{ paddingLeft: '8px' }}
size="s"
color="subdued"
>
To use any model outputs in the query template, copy the
placeholder string directly.
</EuiText>
<EuiSpacer size="s" />
<EuiBasicTable
// @ts-ignore
items={finalModelOutputs}
columns={columns}
/>
</>
</EuiAccordion>
<EuiSpacer size="m" />
</>
)}
</>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiSmallButton
onClick={props.onClose}
fill={false}
color="primary"
data-testid="closeModalButton"
>
Close
</EuiSmallButton>
</EuiModalFooter>
</EuiModal>
);
}

const columns = [
{
name: 'Name',
field: 'label',
width: '40%',
},
{
name: 'Placeholder string',
field: 'label',
width: '50%',
render: (label: string) => (
<EuiCode
style={{
marginLeft: '-10px',
}}
language="json"
transparentBackground={true}
>
{getPlaceholderString(label)}
</EuiCode>
),
},
{
name: 'Actions',
field: 'label',
width: '10%',
render: (label: string) => (
<EuiCopy textToCopy={getPlaceholderString(label)}>
{(copy) => (
<EuiButtonIcon
aria-label="Copy"
iconType="copy"
onClick={copy}
></EuiButtonIcon>
)}
</EuiCopy>
),
},
];

// small util fn to get the full placeholder string to be
// inserted into the template
function getPlaceholderString(label: string) {
return `\$\{${label}\}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ export function WorkflowInputs(props: WorkflowInputsProps) {
ingestTemplatesDifferent || isRunningIngest;
const searchBackButtonDisabled =
isRunningSearch ||
(isProposingNoSearchResources ? false : searchTemplatesDifferent);
(isProposingNoSearchResources || !ingestProvisioned
? false
: searchTemplatesDifferent);
const searchUndoButtonDisabled =
isRunningSave || isRunningSearch
? true
Expand Down

0 comments on commit 228735e

Please sign in to comment.