Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dedicated UX for query template building (ML search req processors) #407

Merged
merged 4 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
ohltyler marked this conversation as resolved.
Show resolved Hide resolved
<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>
ohltyler marked this conversation as resolved.
Show resolved Hide resolved
</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
Loading