diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e74ad30..19d60dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,4 +25,5 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add experimental feature to support text to visualization ([#218](https://github.com/opensearch-project/dashboards-assistant/pull/218)) - Be compatible with ML configuration index mapping change ([#239](https://github.com/opensearch-project/dashboards-assistant/pull/239)) - Support context aware alert analysis by reusing incontext insight component([#215](https://github.com/opensearch-project/dashboards-assistant/pull/215)) -Use smaller and compressed variants of buttons and form components ([#250](https://github.com/opensearch-project/dashboards-assistant/pull/250)) \ No newline at end of file +Use smaller and compressed variants of buttons and form components ([#250](https://github.com/opensearch-project/dashboards-assistant/pull/250)) +- Support insight with RAG in alert analysis and refine the UX diff --git a/common/constants/llm.ts b/common/constants/llm.ts index 63ac5a59..bfc623ad 100644 --- a/common/constants/llm.ts +++ b/common/constants/llm.ts @@ -24,6 +24,11 @@ export const TEXT2VIZ_API = { TEXT2VEGA: `${API_BASE}/text2vega`, }; +export const SUMMARY_ASSISTANT_API = { + SUMMARIZE: `${API_BASE}/summary`, + INSIGHT: `${API_BASE}/insight`, +}; + export const NOTEBOOK_API = { CREATE_NOTEBOOK: `${NOTEBOOK_PREFIX}/note`, SET_PARAGRAPH: `${NOTEBOOK_PREFIX}/set_paragraphs/`, diff --git a/public/assets/shiny_sparkle.svg b/public/assets/shiny_sparkle.svg new file mode 100644 index 00000000..1aeadce0 --- /dev/null +++ b/public/assets/shiny_sparkle.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/sparkle.svg b/public/assets/sparkle.svg new file mode 100644 index 00000000..885e5c63 --- /dev/null +++ b/public/assets/sparkle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/components/incontext_insight/generate_popover_body.test.tsx b/public/components/incontext_insight/generate_popover_body.test.tsx index 5001e1c7..75a6478f 100644 --- a/public/components/incontext_insight/generate_popover_body.test.tsx +++ b/public/components/incontext_insight/generate_popover_body.test.tsx @@ -8,7 +8,7 @@ import { render, cleanup, fireEvent, waitFor } from '@testing-library/react'; import { getConfigSchema, getNotifications } from '../../services'; import { GeneratePopoverBody } from './generate_popover_body'; import { HttpSetup } from '../../../../../src/core/public'; -import { ASSISTANT_API } from '../../../common/constants/llm'; +import { SUMMARY_ASSISTANT_API } from '../../../common/constants/llm'; jest.mock('../../services'); @@ -42,25 +42,28 @@ describe('GeneratePopoverBody', () => { const closePopoverMock = jest.fn(); - it('renders the generate summary button', () => { - const { getByText } = render( - - ); - - expect(getByText('Generate summary')).toBeInTheDocument(); - }); - - it('calls onGenerateSummary when button is clicked', async () => { - mockPost.mockResolvedValue({ - interactions: [{ conversation_id: 'test-conversation' }], - messages: [{ type: 'output', content: 'Generated summary content' }], + it('auto generates summary and insight', async () => { + mockPost.mockImplementation((path: string, body) => { + let value; + switch (path) { + case SUMMARY_ASSISTANT_API.SUMMARIZE: + value = { + summary: 'Generated summary content', + insightAgentId: 'insight_agent_id', + }; + break; + + case SUMMARY_ASSISTANT_API.INSIGHT: + value = 'Generated insight content'; + break; + + default: + return null; + } + return Promise.resolve(value); }); - const { getByText } = render( + const { getByText, getByLabelText, queryByText, queryByLabelText } = render( { /> ); - const button = getByText('Generate summary'); - fireEvent.click(button); + // 1. Auto generate summary + // title is assistant icon + 'Summary' + expect(getByLabelText('alert-assistant')).toBeInTheDocument(); + expect(getByText('Summary')).toBeInTheDocument(); + // content is loading + expect(getByLabelText('loading_content')).toBeInTheDocument(); // Wait for loading to complete and summary to render await waitFor(() => { expect(getByText('Generated summary content')).toBeInTheDocument(); }); - - expect(mockPost).toHaveBeenCalledWith(ASSISTANT_API.SEND_MESSAGE, expect.any(Object)); + // loading content disappeared + expect(queryByLabelText('loading_content')).toBeNull(); + expect(mockPost).toHaveBeenCalledWith(SUMMARY_ASSISTANT_API.SUMMARIZE, expect.any(Object)); expect(mockToasts.addDanger).not.toHaveBeenCalled(); - }); - - it('shows loading state while generating summary', async () => { - const { getByText } = render( - - ); - const button = getByText('Generate summary'); - fireEvent.click(button); + // insight tip icon is visible + const insightTipIcon = getByLabelText('Insight'); + expect(insightTipIcon).toBeInTheDocument(); - // Wait for loading state to appear - expect(getByText('Generating summary...')).toBeInTheDocument(); - }); - - it('handles error during summary generation', async () => { - mockPost.mockRejectedValue(new Error('Network Error')); - - const { getByText } = render( - - ); - - const button = getByText('Generate summary'); - fireEvent.click(button); + // 2. Click insight tip icon to view insight + fireEvent.click(insightTipIcon); + // title is back button + 'Insight With RAG' + const backButton = getByLabelText('back-to-summary'); + expect(backButton).toBeInTheDocument(); + expect(getByText('Insight With RAG')).toBeInTheDocument(); + // Wait for loading to complete and insight to render await waitFor(() => { - expect(mockToasts.addDanger).toHaveBeenCalledWith('Generate summary error'); + expect(getByText('Generated insight content')).toBeInTheDocument(); }); + expect(queryByText('Generated summary content')).toBeNull(); + + // loading content disappeared + expect(queryByLabelText('loading_content')).toBeNull(); + expect(mockPost).toHaveBeenCalledWith(SUMMARY_ASSISTANT_API.INSIGHT, expect.any(Object)); + expect(mockToasts.addDanger).not.toHaveBeenCalled(); + + // 3. Click back button to view summary + fireEvent.click(backButton); + expect(queryByText('Generated insight content')).toBeNull(); + expect(queryByText('Generated summary content')).toBeInTheDocument(); }); - it('renders the continue in chat button after summary is generated', async () => { - mockPost.mockResolvedValue({ - interactions: [{ conversation_id: 'test-conversation' }], - messages: [{ type: 'output', content: 'Generated summary content' }], + it('auto generates summary without insight agent id', async () => { + mockPost.mockImplementation((path: string, body) => { + let value; + switch (path) { + case SUMMARY_ASSISTANT_API.SUMMARIZE: + value = { + summary: 'Generated summary content', + insightAgentId: undefined, + }; + break; + + case SUMMARY_ASSISTANT_API.INSIGHT: + value = 'Generated insight content'; + break; + + default: + return null; + } + return Promise.resolve(value); }); - const { getByText } = render( + const { getByText, getByLabelText, queryByLabelText } = render( { /> ); - const button = getByText('Generate summary'); - fireEvent.click(button); + // title is assistant icon + 'Summary' + expect(getByLabelText('alert-assistant')).toBeInTheDocument(); + expect(getByText('Summary')).toBeInTheDocument(); + // content is loading + expect(getByLabelText('loading_content')).toBeInTheDocument(); - // Wait for the summary to be displayed + // Wait for loading to complete and summary to render await waitFor(() => { expect(getByText('Generated summary content')).toBeInTheDocument(); }); + // loading content disappeared + expect(queryByLabelText('loading_content')).toBeNull(); + expect(mockPost).toHaveBeenCalledWith(SUMMARY_ASSISTANT_API.SUMMARIZE, expect.any(Object)); + expect(mockToasts.addDanger).not.toHaveBeenCalled(); - // Check for continue in chat button - expect(getByText('Continue in chat')).toBeInTheDocument(); + // insight tip icon is not visible + expect(queryByLabelText('Insight')).toBeNull(); + // Only call http post 1 time. + expect(mockPost).toHaveBeenCalledTimes(1); }); - it('calls onChatContinuation when continue in chat button is clicked', async () => { - mockPost.mockResolvedValue({ - interactions: [{ conversation_id: 'test-conversation' }], - messages: [{ type: 'output', content: 'Generated summary content' }], - }); + it('handles error during summary generation', async () => { + mockPost.mockRejectedValue(new Error('Network Error')); - const { getByText } = render( + const { queryByText } = render( ); - const button = getByText('Generate summary'); - fireEvent.click(button); + // Auto close popover window if error occurs + expect(queryByText('test-generated-popover')).toBeNull(); await waitFor(() => { - expect(getByText('Generated summary content')).toBeInTheDocument(); + expect(mockToasts.addDanger).toHaveBeenCalledWith('Generate summary error'); }); - - const continueButton = getByText('Continue in chat'); - fireEvent.click(continueButton); - - expect(mockPost).toHaveBeenCalledTimes(1); - expect(closePopoverMock).toHaveBeenCalled(); }); - it("continue in chat button doesn't appear when chat is disabled", async () => { - mockPost.mockResolvedValue({ - interactions: [{ conversation_id: 'test-conversation' }], - messages: [{ type: 'output', content: 'Generated summary content' }], - }); - (getConfigSchema as jest.Mock).mockReturnValue({ - chat: { enabled: false }, + it('handles error during insight generation', async () => { + mockPost.mockImplementation((path: string, body) => { + let value; + switch (path) { + case SUMMARY_ASSISTANT_API.SUMMARIZE: + value = { + summary: 'Generated summary content', + insightAgentId: 'insight_agent_id', + }; + break; + + case SUMMARY_ASSISTANT_API.INSIGHT: + return Promise.reject(new Error('Network Error')); + + default: + return null; + } + return Promise.resolve(value); }); - const { getByText, queryByText } = render( + const { getByText, queryByLabelText } = render( ); - const button = getByText('Generate summary'); - fireEvent.click(button); - + expect(getByText('Summary')).toBeInTheDocument(); + // Wait for loading to complete and summary to render await waitFor(() => { - expect(getByText('Generated summary content')).toBeInTheDocument(); + expect(mockToasts.addDanger).toHaveBeenCalledWith('Generate insight error'); }); - - expect(queryByText('Continue in chat')).toBeNull(); - expect(mockPost).toHaveBeenCalledTimes(1); + // Show summary content although insight generation failed + expect(getByText('Generated summary content')).toBeInTheDocument(); + // insight tip icon is not visible for this alert + expect(queryByLabelText('Insight')).toBeNull(); }); }); diff --git a/public/components/incontext_insight/generate_popover_body.tsx b/public/components/incontext_insight/generate_popover_body.tsx index 2a9db358..22171fe0 100644 --- a/public/components/incontext_insight/generate_popover_body.tsx +++ b/public/components/incontext_insight/generate_popover_body.tsx @@ -6,74 +6,87 @@ import React, { useState } from 'react'; import { i18n } from '@osd/i18n'; import { - EuiButton, + EuiBadge, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiIcon, + EuiIconTip, + EuiLoadingContent, + EuiMarkdownFormat, EuiPanel, + EuiPopoverFooter, + EuiPopoverTitle, EuiSpacer, EuiText, } from '@elastic/eui'; +import { useEffectOnce } from 'react-use'; import { IncontextInsight as IncontextInsightInput } from '../../types'; -import { getConfigSchema, getIncontextInsightRegistry, getNotifications } from '../../services'; +import { getNotifications } from '../../services'; import { HttpSetup } from '../../../../../src/core/public'; -import { ASSISTANT_API } from '../../../common/constants/llm'; -import { getAssistantRole } from '../../utils/constants'; +import { SUMMARY_ASSISTANT_API } from '../../../common/constants/llm'; +import shiny_sparkle from '../../assets/shiny_sparkle.svg'; export const GeneratePopoverBody: React.FC<{ incontextInsight: IncontextInsightInput; httpSetup?: HttpSetup; closePopover: () => void; }> = ({ incontextInsight, httpSetup, closePopover }) => { - const [isLoading, setIsLoading] = useState(false); const [summary, setSummary] = useState(''); - const [conversationId, setConversationId] = useState(''); + const [insight, setInsight] = useState(''); + const [insightAvailable, setInsightAvailable] = useState(false); + const [showInsight, setShowInsight] = useState(false); const toasts = getNotifications().toasts; - const registry = getIncontextInsightRegistry(); - const onChatContinuation = () => { - registry?.continueInChat(incontextInsight, conversationId); - closePopover(); - }; + useEffectOnce(() => { + onGenerateSummary( + incontextInsight.suggestions && incontextInsight.suggestions.length > 0 + ? incontextInsight.suggestions[0] + : 'Please summarize the input' + ); + }); const onGenerateSummary = (summarizationQuestion: string) => { - setIsLoading(true); - setSummary(''); - setConversationId(''); const summarize = async () => { - const contextContent = incontextInsight.contextProvider + const contextObj = incontextInsight.contextProvider ? await incontextInsight.contextProvider() - : ''; - let incontextInsightType: string; + : undefined; + const contextContent = contextObj?.context || ''; + let summaryType: string; const endIndex = incontextInsight.key.indexOf('_', 0); if (endIndex !== -1) { - incontextInsightType = incontextInsight.key.substring(0, endIndex); + summaryType = incontextInsight.key.substring(0, endIndex); } else { - incontextInsightType = incontextInsight.key; + summaryType = incontextInsight.key; } + const insightType = + summaryType === 'alerts' + ? contextObj?.additionalInfo.monitorType === 'cluster_metrics_monitor' + ? 'os_insight' + : 'user_insight' + : undefined; await httpSetup - ?.post(ASSISTANT_API.SEND_MESSAGE, { + ?.post(SUMMARY_ASSISTANT_API.SUMMARIZE, { body: JSON.stringify({ - messages: [], - input: { - type: 'input', - content: summarizationQuestion, - contentType: 'text', - context: { content: contextContent, dataSourceId: incontextInsight.datasourceId }, - promptPrefix: getAssistantRole(incontextInsightType), - }, + type: summaryType, + insightType, + question: summarizationQuestion, + context: contextContent, }), }) .then((response) => { - const interactionLength = response.interactions.length; - if (interactionLength > 0) { - setConversationId(response.interactions[interactionLength - 1].conversation_id); - } - - const messageLength = response.messages.length; - if (messageLength > 0 && response.messages[messageLength - 1].type === 'output') { - setSummary(response.messages[messageLength - 1].content); + const summaryContent = response.summary; + setSummary(summaryContent); + const insightAgentIdExists = !!response.insightAgentId; + setInsightAvailable(insightAgentIdExists); + if (insightAgentIdExists) { + onGenerateInsightBasedOnSummary( + response.insightAgentId, + summaryType, + summaryContent, + contextContent, + 'Please provide your insight on this alert to help users understand this alert, find potential causes and give feasible solutions to address this alert' + ); } }) .catch((error) => { @@ -82,65 +95,144 @@ export const GeneratePopoverBody: React.FC<{ defaultMessage: 'Generate summary error', }) ); - }) - .finally(() => { - setIsLoading(false); + closePopover(); }); }; return summarize(); }; - return summary ? ( - <> - - {summary} - - - {getConfigSchema().chat.enabled && ( + const onGenerateInsightBasedOnSummary = ( + insightAgentId: string, + insightType: string, + summaryContent: string, + context: string, + insightQuestion: string + ) => { + const generateInsight = async () => { + httpSetup + ?.post(SUMMARY_ASSISTANT_API.INSIGHT, { + body: JSON.stringify({ + insightAgentId, + insightType, + summary: summaryContent, + context, + question: insightQuestion, + }), + }) + .then((response) => { + setInsight(response); + }) + .catch((error) => { + toasts.addDanger( + i18n.translate('assistantDashboards.incontextInsight.generateSummaryError', { + defaultMessage: 'Generate insight error', + }) + ); + setInsightAvailable(false); + setShowInsight(false); + }); + }; + + return generateInsight(); + }; + + const renderContent = () => { + const content = showInsight && insightAvailable ? insight : summary; + return content ? ( + <> onChatContinuation()} - grow={false} - paddingSize="none" - style={{ width: '120px', float: 'right' }} + color="subdued" > - + + {content} + + + + + ) : ( + + ); + }; + + const renderInnerTitle = () => { + return ( + + {showInsight ? ( + - + { + setShowInsight(false); + }} + iconType="arrowLeft" + iconSide={'left'} + color={'text'} + > + {i18n.translate('assistantDashboards.incontextInsight.InsightWithRAG', { + defaultMessage: 'Insight With RAG', + })} + + + ) : ( + - - {i18n.translate('assistantDashboards.incontextInsight.continueInChat', { - defaultMessage: 'Continue in chat', - })} - +
+ + {i18n.translate('assistantDashboards.incontextInsight.Summary', { + defaultMessage: 'Summary', + })} + +
- - )} + )} +
+ ); + }; + + const renderInnerFooter = () => { + return ( + + + {insightAvailable && ( + { + setShowInsight(true); + }} + > + + + )} + + + ); + }; + + return ( + <> + {renderInnerTitle()} + {renderContent()} + {renderInnerFooter()} - ) : ( - { - await onGenerateSummary( - incontextInsight.suggestions && incontextInsight.suggestions.length > 0 - ? incontextInsight.suggestions[0] - : 'Please summarize the input' - ); - }} - isLoading={isLoading} - disabled={isLoading} - > - {isLoading - ? i18n.translate('assistantDashboards.incontextInsight.generatingSummary', { - defaultMessage: 'Generating summary...', - }) - : i18n.translate('assistantDashboards.incontextInsight.generateSummary', { - defaultMessage: 'Generate summary', - })} - ); }; diff --git a/public/components/incontext_insight/index.scss b/public/components/incontext_insight/index.scss index 914f9b94..42813465 100644 --- a/public/components/incontext_insight/index.scss +++ b/public/components/incontext_insight/index.scss @@ -107,10 +107,75 @@ } .incontextInsightPopoverBody { - max-width: 400px; + max-width: 486px; + min-width: 486px; width: 100%; } +.incontextInsightGeneratePopoverTitle { + // TODO: Remove this one paddingSize is fixed + padding: 0 !important; + margin-bottom: 0 !important; + min-height: 30px; + max-height: 30px; + border: none; + + :first-child { + border: none; + } + + .euiBadge__text { + text-transform: none; + font-weight: bold; + font-size: 15px; + } + + .euiBadge__icon { + margin: 2px 0 2px 0; + } + + .euiIcon--small { + height: 100%; + width: 22px; + padding: 2px 0; + } + + .euiButtonEmpty{ + padding-inline: 8px; + + .euiButtonEmpty__content { + margin-block: 2px; + padding-block: 2px; + + .euiIcon--small { + height: 100%; + width: 16px; + padding: 2px 0; + } + + .euiButtonEmpty__text { + margin-inline-start: 4px; + text-transform: none; + font-weight: bold; + font-size: 15px; + } + } + } +} + +.incontextInsightGeneratePopoverFooter{ + padding-block: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + border: none; +} + +.incontextInsightGeneratePopoverContent { + display: flex; + overflow: auto; + max-height: 300px; +} + .incontextInsightSummary { border: $euiBorderThin; border-radius: 4px; diff --git a/public/components/incontext_insight/index.tsx b/public/components/incontext_insight/index.tsx index add28340..feaa87cf 100644 --- a/public/components/incontext_insight/index.tsx +++ b/public/components/incontext_insight/index.tsx @@ -7,29 +7,30 @@ import './index.scss'; import { i18n } from '@osd/i18n'; import { - EuiWrappingPopover, - EuiSmallButton, + EuiBadge, EuiCompressedFieldText, + EuiCompressedFormRow, EuiFlexGroup, EuiFlexItem, - EuiCompressedFormRow, - EuiPopoverTitle, - EuiText, - EuiPopoverFooter, - EuiBadge, - EuiSpacer, + EuiIcon, EuiListGroup, EuiListGroupItem, EuiPanel, - keys, - EuiIcon, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSmallButton, EuiSmallButtonIcon, + EuiSpacer, + EuiText, + EuiWrappingPopover, + keys, } from '@elastic/eui'; import React, { Children, isValidElement, useEffect, useRef, useState } from 'react'; import { IncontextInsight as IncontextInsightInput } from '../../types'; import { getIncontextInsightRegistry, getNotifications } from '../../services'; // TODO: Replace with getChrome().logos.Chat.url import chatIcon from '../../assets/chat.svg'; +import sparkle from '../../assets/sparkle.svg'; import { HttpSetup } from '../../../../../src/core/public'; import { GeneratePopoverBody } from './generate_popover_body'; @@ -260,7 +261,7 @@ export const IncontextInsight = ({ children, httpSetup }: IncontextInsightProps)
- +
@@ -305,37 +306,42 @@ export const IncontextInsight = ({ children, httpSetup }: IncontextInsightProps) offset={6} panelPaddingSize="s" > - - - -
- - {i18n.translate('assistantDashboards.incontextInsight.assistant', { - defaultMessage: 'OpenSearch Assistant', - })} - -
-
- -
- -
-
-
-
+ { + // For 'generate' type insights, we don't want to show this title but its own inner title + input.type !== 'generate' && ( + + + +
+ + {i18n.translate('assistantDashboards.incontextInsight.assistant', { + defaultMessage: 'OpenSearch Assistant', + })} + +
+
+ +
+ +
+
+
+
+ ) + }
{popoverBody()}
); diff --git a/public/types.ts b/public/types.ts index 538fadff..fa1020b8 100644 --- a/public/types.ts +++ b/public/types.ts @@ -84,13 +84,18 @@ export interface ChatConfig { export type IncontextInsights = Map; +export interface ContextObj { + context: string; + additionalInfo: Record; +} + export interface IncontextInsight { key: string; type?: IncontextInsightType; summary?: string; suggestions?: string[]; interactionId?: string; - contextProvider?: () => Promise; + contextProvider?: () => Promise; datasourceId?: string; } diff --git a/server/plugin.ts b/server/plugin.ts index 48777819..c74033a9 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -17,6 +17,7 @@ import { BasicInputOutputParser } from './parsers/basic_input_output_parser'; import { VisualizationCardParser } from './parsers/visualization_card_parser'; import { registerChatRoutes } from './routes/chat_routes'; import { registerText2VizRoutes } from './routes/text2viz_routes'; +import { registerSummaryAssistantRoutes } from './routes/summary_routes'; export class AssistantPlugin implements Plugin { private readonly logger: Logger; @@ -51,6 +52,7 @@ export class AssistantPlugin implements Plugin ({ diff --git a/server/routes/get_agent.ts b/server/routes/get_agent.ts index 76cdd467..76b2bbc1 100644 --- a/server/routes/get_agent.ts +++ b/server/routes/get_agent.ts @@ -26,3 +26,33 @@ export const getAgent = async (id: string, client: OpenSearchClient['transport'] throw new Error(`get agent ${id} failed, reason: ${errorMessage}`); } }; + +export const searchAgentByName = async (name: string, client: OpenSearchClient['transport']) => { + try { + const requestParams = { + query: { + term: { + 'name.keyword': name, + }, + }, + sort: { + created_time: 'desc', + }, + size: 1, + }; + + const response = await client.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/agents/_search`, + body: requestParams, + }); + + if (!response || response.body.hits.total.value === 0) { + throw new Error(`cannot find any agent by name: ${name}`); + } + return response.body.hits.hits[0]._id; + } catch (error) { + const errorMessage = JSON.stringify(error.meta?.body) || error; + throw new Error(`search ${name} agent failed, reason: ` + errorMessage); + } +}; diff --git a/server/routes/summary_routes.ts b/server/routes/summary_routes.ts new file mode 100644 index 00000000..c5a8ea60 --- /dev/null +++ b/server/routes/summary_routes.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../../../src/core/server'; +import { SUMMARY_ASSISTANT_API } from '../../common/constants/llm'; +import { getOpenSearchClientTransport } from '../utils/get_opensearch_client_transport'; +import { getAgent, searchAgentByName } from './get_agent'; +import { ML_COMMONS_BASE_API } from '../utils/constants'; +import { InsightType, SummaryType } from '../types'; + +const SUMMARY_AGENT_CONFIG_ID = 'summary'; + +export function registerSummaryAssistantRoutes(router: IRouter) { + router.post( + { + path: SUMMARY_ASSISTANT_API.SUMMARIZE, + validate: { + body: schema.object({ + type: schema.string(), + insightType: schema.maybe(schema.string()), + question: schema.string(), + context: schema.maybe(schema.string()), + }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const client = await getOpenSearchClientTransport({ + context, + dataSourceId: req.query.dataSourceId, + }); + const agentId = await getAgent(SUMMARY_AGENT_CONFIG_ID, client); + const prompt = SummaryType.find((type) => type.id === req.body.type)?.prompt; + const response = await client.request({ + method: 'POST', + path: `${ML_COMMONS_BASE_API}/agents/${agentId}/_execute`, + body: { + parameters: { + prompt, + context: req.body.context, + question: req.body.question, + }, + }, + }); + let summary; + let insightAgentId; + try { + if (req.body.insightType) { + // We have separate agent for os_insight and user_insight. And for user_insight, we can + // only get it by searching on name since it is not stored in agent config. + if (req.body.insightType === 'os_insight') { + insightAgentId = await getAgent(req.body.insightType, client); + } else if (req.body.insightType === 'user_insight') { + if (req.body.type === 'alerts') { + insightAgentId = await searchAgentByName('KB_For_Alert_Insight', client); + } + } + } + } catch (e) { + console.log(`Cannot find insight agent for ${req.body.insightType}`); + } + try { + summary = response.body.inference_results[0].output[0].result; + return res.ok({ body: { summary, insightAgentId } }); + } catch (e) { + return res.internalError(); + } + }) + ); + router.post( + { + path: SUMMARY_ASSISTANT_API.INSIGHT, + validate: { + body: schema.object({ + insightAgentId: schema.string(), + insightType: schema.string(), + summary: schema.string(), + context: schema.string(), + question: schema.string(), + }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const client = await getOpenSearchClientTransport({ + context, + dataSourceId: req.query.dataSourceId, + }); + const prompt = InsightType.find((type) => type.id === req.body.insightType)?.prompt; + const response = await client.request({ + method: 'POST', + path: `${ML_COMMONS_BASE_API}/agents/${req.body.insightAgentId}/_execute`, + body: { + parameters: { + text: prompt, + context: req.body.context, + summary: req.body.summary, + question: req.body.question, + }, + }, + }); + try { + let result = response.body.inference_results[0].output[0].result; + result = JSON.parse(result).output.text; + return res.ok({ body: result }); + } catch (e) { + return res.internalError(); + } + }) + ); +} diff --git a/server/types.ts b/server/types.ts index a47b42bd..5130c888 100644 --- a/server/types.ts +++ b/server/types.ts @@ -50,3 +50,17 @@ declare module '../../../src/core/server' { }; } } + +export const SummaryType = [ + { + id: 'alerts', + prompt: `You are an OpenSearch Alert Assistant to help summarize the alerts. Here is the detail of alert: $\{parameters.context}; The question is $\{parameters.question}`, + }, +]; + +export const InsightType = [ + { + id: 'alerts', + prompt: `You are an OpenSearch Alert Assistant to provide your insight on this alert. Here is the detail of alert: $\{parameters.context}; The alert summary is $\{parameters.summary}; The question is $\{parameters.question}`, + }, +];