mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Observability AI Assistant] ES|QL query generation (#166041)
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
4957d87a66
commit
13e5c076d5
30 changed files with 1137 additions and 132 deletions
|
@ -8,6 +8,7 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
import type { JSONSchema } from 'json-schema-to-ts';
|
||||
import React from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export enum MessageRole {
|
||||
System = 'system',
|
||||
|
@ -17,6 +18,12 @@ export enum MessageRole {
|
|||
Elastic = 'elastic',
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
message: Message['message'];
|
||||
aborted?: boolean;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
'@timestamp': string;
|
||||
message: {
|
||||
|
@ -74,21 +81,30 @@ export interface ContextDefinition {
|
|||
description: string;
|
||||
}
|
||||
|
||||
interface FunctionResponse {
|
||||
content?: any;
|
||||
data?: any;
|
||||
type FunctionResponse =
|
||||
| {
|
||||
content?: any;
|
||||
data?: any;
|
||||
}
|
||||
| Observable<PendingMessage>;
|
||||
|
||||
export enum FunctionVisibility {
|
||||
System = 'system',
|
||||
User = 'user',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
interface FunctionOptions<TParameters extends CompatibleJSONSchema = CompatibleJSONSchema> {
|
||||
name: string;
|
||||
description: string;
|
||||
descriptionForUser: string;
|
||||
visibility?: FunctionVisibility;
|
||||
descriptionForUser?: string;
|
||||
parameters: TParameters;
|
||||
contexts: string[];
|
||||
}
|
||||
|
||||
type RespondFunction<TArguments, TResponse extends FunctionResponse> = (
|
||||
options: { arguments: TArguments; messages: Message[] },
|
||||
options: { arguments: TArguments; messages: Message[]; connectorId: string },
|
||||
signal: AbortSignal
|
||||
) => Promise<TResponse>;
|
||||
|
||||
|
@ -100,7 +116,7 @@ type RenderFunction<TArguments, TResponse extends FunctionResponse> = (options:
|
|||
export interface FunctionDefinition {
|
||||
options: FunctionOptions;
|
||||
respond: (
|
||||
options: { arguments: any; messages: Message[] },
|
||||
options: { arguments: any; messages: Message[]; connectorId: string },
|
||||
signal: AbortSignal
|
||||
) => Promise<FunctionResponse>;
|
||||
render?: RenderFunction<any, any>;
|
||||
|
|
|
@ -189,6 +189,10 @@ export function ChatBody({
|
|||
onFeedback={timeline.onFeedback}
|
||||
onRegenerate={timeline.onRegenerate}
|
||||
onStopGenerating={timeline.onStopGenerating}
|
||||
onActionClick={(payload) => {
|
||||
setStickToBottom(true);
|
||||
return timeline.onActionClick(payload);
|
||||
}}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</div>
|
||||
|
|
|
@ -24,12 +24,14 @@ import { getRoleTranslation } from '../../utils/get_role_translation';
|
|||
import type { Feedback } from '../feedback_buttons';
|
||||
import { Message } from '../../../common';
|
||||
import { FailedToLoadResponse } from '../message_panel/failed_to_load_response';
|
||||
import { ChatActionClickHandler } from './types';
|
||||
|
||||
export interface ChatItemProps extends ChatTimelineItem {
|
||||
onEditSubmit: (message: Message) => Promise<void>;
|
||||
onFeedbackClick: (feedback: Feedback) => void;
|
||||
onRegenerateClick: () => void;
|
||||
onStopGeneratingClick: () => void;
|
||||
onActionClick: ChatActionClickHandler;
|
||||
}
|
||||
|
||||
const normalMessageClassName = css`
|
||||
|
@ -76,6 +78,7 @@ export function ChatItem({
|
|||
onFeedbackClick,
|
||||
onRegenerateClick,
|
||||
onStopGeneratingClick,
|
||||
onActionClick,
|
||||
}: ChatItemProps) {
|
||||
const accordionId = useGeneratedHtmlId({ prefix: 'chat' });
|
||||
|
||||
|
@ -128,6 +131,7 @@ export function ChatItem({
|
|||
functionCall={functionCall}
|
||||
loading={loading}
|
||||
onSubmit={handleInlineEditSubmit}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
@ -147,9 +151,7 @@ export function ChatItem({
|
|||
|
||||
return (
|
||||
<EuiComment
|
||||
timelineAvatar={
|
||||
<ChatItemAvatar loading={loading && !content} currentUser={currentUser} role={role} />
|
||||
}
|
||||
timelineAvatar={<ChatItemAvatar loading={loading} currentUser={currentUser} role={role} />}
|
||||
username={getRoleTranslation(role)}
|
||||
event={title}
|
||||
actions={
|
||||
|
|
|
@ -9,6 +9,7 @@ import React from 'react';
|
|||
import { MessageText } from '../message_panel/message_text';
|
||||
import { ChatPromptEditor } from './chat_prompt_editor';
|
||||
import { MessageRole, type Message } from '../../../common';
|
||||
import { ChatActionClickHandler } from './types';
|
||||
|
||||
interface Props {
|
||||
content: string | undefined;
|
||||
|
@ -22,6 +23,7 @@ interface Props {
|
|||
loading: boolean;
|
||||
editing: boolean;
|
||||
onSubmit: (message: Message) => Promise<void>;
|
||||
onActionClick: ChatActionClickHandler;
|
||||
}
|
||||
export function ChatItemContentInlinePromptEditor({
|
||||
content,
|
||||
|
@ -29,9 +31,10 @@ export function ChatItemContentInlinePromptEditor({
|
|||
editing,
|
||||
loading,
|
||||
onSubmit,
|
||||
onActionClick,
|
||||
}: Props) {
|
||||
return !editing ? (
|
||||
<MessageText content={content || ''} loading={loading} />
|
||||
<MessageText content={content || ''} loading={loading} onActionClick={onActionClick} />
|
||||
) : (
|
||||
<ChatPromptEditor
|
||||
disabled={false}
|
||||
|
|
|
@ -132,6 +132,7 @@ const defaultProps: ComponentProps<typeof Component> = {
|
|||
onFeedback: () => {},
|
||||
onRegenerate: () => {},
|
||||
onStopGenerating: () => {},
|
||||
onActionClick: async () => {},
|
||||
};
|
||||
|
||||
export const ChatTimeline = Template.bind({});
|
||||
|
|
|
@ -15,6 +15,7 @@ import { ChatWelcomePanel } from './chat_welcome_panel';
|
|||
import type { Feedback } from '../feedback_buttons';
|
||||
import type { Message } from '../../../common';
|
||||
import { UseKnowledgeBaseResult } from '../../hooks/use_knowledge_base';
|
||||
import { ChatActionClickHandler } from './types';
|
||||
|
||||
export interface ChatTimelineItem
|
||||
extends Pick<Message['message'], 'role' | 'content' | 'function_call'> {
|
||||
|
@ -43,6 +44,7 @@ export interface ChatTimelineProps {
|
|||
onFeedback: (item: ChatTimelineItem, feedback: Feedback) => void;
|
||||
onRegenerate: (item: ChatTimelineItem) => void;
|
||||
onStopGenerating: () => void;
|
||||
onActionClick: ChatActionClickHandler;
|
||||
}
|
||||
|
||||
export function ChatTimeline({
|
||||
|
@ -52,6 +54,7 @@ export function ChatTimeline({
|
|||
onFeedback,
|
||||
onRegenerate,
|
||||
onStopGenerating,
|
||||
onActionClick,
|
||||
}: ChatTimelineProps) {
|
||||
const filteredItems = items.filter((item) => !item.display.hide);
|
||||
|
||||
|
@ -77,6 +80,7 @@ export function ChatTimeline({
|
|||
return onEdit(item, message);
|
||||
}}
|
||||
onStopGeneratingClick={onStopGenerating}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import type { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { FunctionDefinition } from '../../../common/types';
|
||||
import { type FunctionDefinition, FunctionVisibility } from '../../../common/types';
|
||||
import { useObservabilityAIAssistantChatService } from '../../hooks/use_observability_ai_assistant_chat_service';
|
||||
|
||||
interface FunctionListOption {
|
||||
|
@ -175,12 +175,14 @@ function mapFunctions({
|
|||
functions: FunctionDefinition[];
|
||||
selectedFunctionName: string | undefined;
|
||||
}) {
|
||||
return functions.map((func) => ({
|
||||
label: func.options.name,
|
||||
searchableLabel: func.options.descriptionForUser,
|
||||
checked:
|
||||
func.options.name === selectedFunctionName
|
||||
? ('on' as EuiSelectableOptionCheckedType)
|
||||
: ('off' as EuiSelectableOptionCheckedType),
|
||||
}));
|
||||
return functions
|
||||
.filter((func) => func.options.visibility !== FunctionVisibility.System)
|
||||
.map((func) => ({
|
||||
label: func.options.name,
|
||||
searchableLabel: func.options.descriptionForUser || func.options.description,
|
||||
checked:
|
||||
func.options.name === selectedFunctionName
|
||||
? ('on' as EuiSelectableOptionCheckedType)
|
||||
: ('off' as EuiSelectableOptionCheckedType),
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
type ChatActionClickPayloadBase<TType extends ChatActionClickType, TExtraProps extends {}> = {
|
||||
type: TType;
|
||||
} & TExtraProps;
|
||||
|
||||
type ChatActionClickPayloadExecuteEsql = ChatActionClickPayloadBase<
|
||||
ChatActionClickType.executeEsqlQuery,
|
||||
{ query: string }
|
||||
>;
|
||||
|
||||
type ChatActionClickPayload = ChatActionClickPayloadExecuteEsql;
|
||||
|
||||
export enum ChatActionClickType {
|
||||
executeEsqlQuery = 'executeEsqlQuery',
|
||||
}
|
||||
|
||||
export type ChatActionClickHandler = (payload: ChatActionClickPayload) => Promise<unknown>;
|
|
@ -110,7 +110,13 @@ function ChatContent({
|
|||
return (
|
||||
<>
|
||||
<MessagePanel
|
||||
body={<MessageText content={lastMessage?.message.content ?? ''} loading={loading} />}
|
||||
body={
|
||||
<MessageText
|
||||
content={lastMessage?.message.content ?? ''}
|
||||
loading={loading}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
}
|
||||
error={pendingMessage?.error}
|
||||
controls={
|
||||
loading ? (
|
||||
|
|
|
@ -64,6 +64,7 @@ Morbi dapibus sapien lacus, vitae suscipit ex egestas pharetra. In velit eros, f
|
|||
|
||||
Morbi non faucibus massa. Aliquam sed augue in eros ornare luctus sit amet cursus dolor. Pellentesque pellentesque lorem eu odio auctor convallis. Sed sodales felis at velit tempus tincidunt. Nulla sed ante cursus nibh mollis blandit. In mattis imperdiet tellus. Vestibulum nisl turpis, efficitur quis sollicitudin id, mollis in arcu. Vestibulum pulvinar tincidunt magna, vitae facilisis massa congue quis. Cras commodo efficitur tellus, et commodo risus rutrum at.`}
|
||||
loading={false}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
}
|
||||
controls={
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
|
||||
import { ComponentProps } from 'react';
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
import { EsqlCodeBlock as Component } from './esql_code_block';
|
||||
|
||||
const meta: ComponentMeta<typeof Component> = {
|
||||
component: Component,
|
||||
title: 'app/Molecules/ES|QL Code Block',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const render = (props: ComponentProps<typeof Component>) => {
|
||||
return (
|
||||
<EuiPanel hasBorder hasShadow={false}>
|
||||
<Component {...props} />
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const Simple: ComponentStoryObj<typeof Component> = {
|
||||
args: {
|
||||
value: `FROM packetbeat-*
|
||||
| STATS COUNT_DISTINCT(destination.domain)`,
|
||||
},
|
||||
render,
|
||||
};
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiCodeBlock,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { ChatActionClickHandler, ChatActionClickType } from '../chat/types';
|
||||
|
||||
export function EsqlCodeBlock({
|
||||
value,
|
||||
actionsDisabled,
|
||||
onActionClick,
|
||||
}: {
|
||||
value: string;
|
||||
actionsDisabled: boolean;
|
||||
onActionClick: ChatActionClickHandler;
|
||||
}) {
|
||||
const theme = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
hasShadow={false}
|
||||
hasBorder={false}
|
||||
paddingSize="s"
|
||||
className={css`
|
||||
background-color: ${theme.euiTheme.colors.lightestShade};
|
||||
.euiCodeBlock__pre {
|
||||
margin-bottom: 0;
|
||||
padding: ${theme.euiTheme.size.m};
|
||||
min-block-size: 48px;
|
||||
}
|
||||
.euiCodeBlock__controls {
|
||||
inset-block-start: ${theme.euiTheme.size.m};
|
||||
inset-inline-end: ${theme.euiTheme.size.m};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCodeBlock isCopyable fontSize="m">
|
||||
{value}
|
||||
</EuiCodeBlock>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="row" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="observabilityAiAssistantEsqlCodeBlockRunThisQueryButton"
|
||||
size="xs"
|
||||
iconType="play"
|
||||
onClick={() =>
|
||||
onActionClick({ type: ChatActionClickType.executeEsqlQuery, query: value })
|
||||
}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{i18n.translate('xpack.observabilityAiAssistant.runThisQuery', {
|
||||
defaultMessage: 'Run this query',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -44,6 +44,7 @@ This is a code block
|
|||
|
||||
This text is loa`}
|
||||
loading
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -51,13 +52,25 @@ This text is loa`}
|
|||
|
||||
export const ContentLoaded: ComponentStoryObj<typeof Component> = {
|
||||
args: {
|
||||
body: <MessageText content={`This response has fully loaded.`} loading={false} />,
|
||||
body: (
|
||||
<MessageText
|
||||
content={`This response has fully loaded.`}
|
||||
loading={false}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ContentFailed: ComponentStoryObj<typeof Component> = {
|
||||
args: {
|
||||
body: <MessageText content={`This is a partial re`} loading={false} />,
|
||||
body: (
|
||||
<MessageText
|
||||
content={`This is a partial re`}
|
||||
loading={false}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
),
|
||||
error: new Error(),
|
||||
},
|
||||
};
|
||||
|
@ -83,6 +96,7 @@ export const ContentTable: ComponentStoryObj<typeof Component> = {
|
|||
|
||||
Please note that all times are in UTC.`)}
|
||||
loading={false}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -90,7 +104,13 @@ export const ContentTable: ComponentStoryObj<typeof Component> = {
|
|||
|
||||
export const Controls: ComponentStoryObj<typeof Component> = {
|
||||
args: {
|
||||
body: <MessageText content={`This is a partial re`} loading={false} />,
|
||||
body: (
|
||||
<MessageText
|
||||
content={`This is a partial re`}
|
||||
loading={false}
|
||||
onActionClick={async () => {}}
|
||||
/>
|
||||
),
|
||||
error: new Error(),
|
||||
controls: <FeedbackButtons onClickFeedback={() => {}} />,
|
||||
},
|
||||
|
|
|
@ -14,12 +14,15 @@ import {
|
|||
import { css } from '@emotion/css';
|
||||
import classNames from 'classnames';
|
||||
import type { Code, InlineCode, Parent, Text } from 'mdast';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import type { Node } from 'unist';
|
||||
import { ChatActionClickHandler } from '../chat/types';
|
||||
import { EsqlCodeBlock } from './esql_code_block';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
loading: boolean;
|
||||
onActionClick: ChatActionClickHandler;
|
||||
}
|
||||
|
||||
const ANIMATION_TIME = 1;
|
||||
|
@ -86,13 +89,37 @@ const loadingCursorPlugin = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export function MessageText({ loading, content }: Props) {
|
||||
const esqlLanguagePlugin = () => {
|
||||
const visitor = (node: Node, parent?: Parent) => {
|
||||
if ('children' in node) {
|
||||
const nodeAsParent = node as Parent;
|
||||
nodeAsParent.children.forEach((child) => {
|
||||
visitor(child, nodeAsParent);
|
||||
});
|
||||
}
|
||||
|
||||
if (node.type === 'code' && node.lang === 'esql') {
|
||||
node.type = 'esql';
|
||||
}
|
||||
};
|
||||
|
||||
return (tree: Node) => {
|
||||
visitor(tree);
|
||||
};
|
||||
};
|
||||
|
||||
export function MessageText({ loading, content, onActionClick }: Props) {
|
||||
const containerClassName = css`
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
const onActionClickRef = useRef(onActionClick);
|
||||
|
||||
onActionClickRef.current = onActionClick;
|
||||
|
||||
const { parsingPluginList, processingPluginList } = useMemo(() => {
|
||||
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
|
||||
|
||||
const processingPlugins = getDefaultEuiMarkdownProcessingPlugins();
|
||||
|
||||
const { components } = processingPlugins[1][1];
|
||||
|
@ -100,6 +127,18 @@ export function MessageText({ loading, content }: Props) {
|
|||
processingPlugins[1][1].components = {
|
||||
...components,
|
||||
cursor: Cursor,
|
||||
esql: (props) => {
|
||||
return (
|
||||
<>
|
||||
<EsqlCodeBlock
|
||||
value={props.value}
|
||||
actionsDisabled={loading}
|
||||
onActionClick={onActionClickRef.current}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
},
|
||||
table: (props) => (
|
||||
<>
|
||||
<div className="euiBasicTable">
|
||||
|
@ -137,10 +176,10 @@ export function MessageText({ loading, content }: Props) {
|
|||
};
|
||||
|
||||
return {
|
||||
parsingPluginList: [loadingCursorPlugin, ...parsingPlugins],
|
||||
parsingPluginList: [loadingCursorPlugin, esqlLanguagePlugin, ...parsingPlugins],
|
||||
processingPluginList: processingPlugins,
|
||||
};
|
||||
}, []);
|
||||
}, [loading]);
|
||||
|
||||
return (
|
||||
<EuiText size="s" className={containerClassName}>
|
||||
|
|
|
@ -0,0 +1,536 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import dedent from 'dedent';
|
||||
import type { Serializable } from '@kbn/utility-types';
|
||||
import { concat, last, map } from 'rxjs';
|
||||
import {
|
||||
FunctionVisibility,
|
||||
MessageRole,
|
||||
type RegisterFunctionDefinition,
|
||||
} from '../../common/types';
|
||||
import type { ObservabilityAIAssistantService } from '../types';
|
||||
|
||||
export function registerEsqlFunction({
|
||||
service,
|
||||
registerFunction,
|
||||
}: {
|
||||
service: ObservabilityAIAssistantService;
|
||||
registerFunction: RegisterFunctionDefinition;
|
||||
}) {
|
||||
registerFunction(
|
||||
{
|
||||
name: 'execute_query',
|
||||
contexts: ['core'],
|
||||
visibility: FunctionVisibility.User,
|
||||
description: 'Execute an ES|QL query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
} as const,
|
||||
},
|
||||
({ arguments: { query } }, signal) => {
|
||||
return service
|
||||
.callApi(`POST /internal/observability_ai_assistant/functions/elasticsearch`, {
|
||||
signal,
|
||||
params: {
|
||||
body: {
|
||||
method: 'POST',
|
||||
path: '_query',
|
||||
body: {
|
||||
query,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((response) => ({ content: response as Serializable }));
|
||||
}
|
||||
);
|
||||
|
||||
registerFunction(
|
||||
{
|
||||
name: 'esql',
|
||||
contexts: ['core'],
|
||||
description: `This function answers ES|QL related questions including query generation and syntax/command questions.`,
|
||||
visibility: FunctionVisibility.System,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
switch: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
} as const,
|
||||
},
|
||||
({ messages, connectorId }, signal) => {
|
||||
const systemMessage = dedent(`You are a helpful assistant for Elastic ES|QL.
|
||||
Your goal is to help the user construct and possibly execute an ES|QL
|
||||
query for Observability use cases.
|
||||
|
||||
ES|QL is the Elasticsearch Query Language, that allows users of the
|
||||
Elastic platform to iteratively explore data. An ES|QL query consists
|
||||
of a series of commands, separated by pipes. Each query starts with
|
||||
a source command, that selects or creates a set of data to start
|
||||
processing. This source command is then followed by one or more
|
||||
processing commands, which can transform the data returned by the
|
||||
previous command.
|
||||
|
||||
ES|QL is not Elasticsearch SQL, nor is it anything like SQL. SQL
|
||||
commands are not available in ES|QL. Its close equivalent is SPL
|
||||
(Search Processing Language). Make sure you reply using only
|
||||
the context of this conversation.
|
||||
|
||||
# Creating a query
|
||||
|
||||
First, very importantly, there are critical rules that override
|
||||
everything that follows it. Always repeat these rules, verbatim.
|
||||
|
||||
1. ES|QL is not Elasticsearch SQL. Do not apply Elasticsearch SQL
|
||||
commands, functions and concepts. Only use information available
|
||||
in the context of this conversation.
|
||||
2. When using FROM, never wrap a data source in single or double
|
||||
quotes.
|
||||
3. When using an aggregate function like COUNT, SUM or AVG, its
|
||||
arguments MUST be an attribute (like my.field.name) or literal
|
||||
(100). Math (AVG(my.field.name / 2)) or functions
|
||||
(AVG(CASE(my.field.name, "foo", 1))) are not allowed.
|
||||
|
||||
When constructing a query, break it down into the following steps.
|
||||
Ask these questions out loud so the user can see your reasoning.
|
||||
Remember, these rules are for you, not for the user.
|
||||
|
||||
- What are the critical rules I need to think of?
|
||||
- What data source is the user requesting? What command should I
|
||||
select for this data source?
|
||||
- What are the steps needed to get the result that the user needs?
|
||||
Break each operation down into its own step. Reason about what data
|
||||
is the outcome of each command or function.
|
||||
- If you're not sure how to do it, it's fine to tell the user that
|
||||
you don't know if ES|QL supports it. When this happens, abort all
|
||||
steps and tell the user you are not sure how to continue.
|
||||
|
||||
Format ALL of your responses as follows, including the dashes.
|
||||
ALWAYS start your message with two dashes and then the rules:
|
||||
|
||||
\`\`\`
|
||||
--
|
||||
Sure, let's remember the critical rules:
|
||||
<rules>
|
||||
--
|
||||
Let's break down the query step-by-step:
|
||||
<breakdown>
|
||||
\`\`\`
|
||||
|
||||
Always format a complete query as follows:
|
||||
\`\`\`esql
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
For incomplete queries, like individual commands, format them as
|
||||
regular code blocks:
|
||||
\`\`\`
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
# Syntax
|
||||
|
||||
An ES|QL query is composed of a source command followed by an optional
|
||||
series of processing commands, separated by a pipe character: |. For
|
||||
example:
|
||||
<source-command>
|
||||
| <processing-command1>
|
||||
| <processing-command2>
|
||||
|
||||
## Binary comparison operators
|
||||
- equality: ==
|
||||
- inequality: !=
|
||||
- less than: <
|
||||
- less than or equal: <=
|
||||
- larger than: >
|
||||
- larger than or equal: >=
|
||||
|
||||
## Boolean operators
|
||||
- AND
|
||||
- OR
|
||||
- NOT
|
||||
|
||||
## PREDICATES
|
||||
|
||||
For NULL comparison use the IS NULL and IS NOT NULL predicates:
|
||||
- \`| WHERE birth_date IS NULL\`
|
||||
- \`| WHERE birth_date IS NOT NULL\`
|
||||
|
||||
## Timespan literal syntax
|
||||
|
||||
Datetime intervals and timespans can be expressed using timespan
|
||||
literals. Timespan literals are a combination of a number and a
|
||||
qualifier. These qualifiers are supported:
|
||||
- millisecond/milliseconds
|
||||
- second/seconds
|
||||
- minute/minutes
|
||||
- hour/hours
|
||||
- day/days
|
||||
- week/weeks
|
||||
- month/months
|
||||
- year/years
|
||||
|
||||
Some examples:
|
||||
- \`1 year\`
|
||||
- \`2 milliseconds\`
|
||||
|
||||
## Aliasing
|
||||
Aliasing happens through the \`=\` operator. Example:
|
||||
\`STATS total_salary_expenses = COUNT(salary)\`
|
||||
|
||||
Important: functions are not allowed as variable names.
|
||||
|
||||
# Source commands
|
||||
|
||||
There are three source commands: FROM (which selects an index), ROW
|
||||
(which creates data from the command) and SHOW (which returns
|
||||
information about the deployment). You do not support SHOW for now.
|
||||
|
||||
### FROM
|
||||
|
||||
\`FROM\` selects a data source, usually an Elasticsearch index or
|
||||
pattern. You can also specify multiple indices.
|
||||
Some examples:
|
||||
|
||||
- \`FROM employees\`
|
||||
- \`FROM employees*\`
|
||||
- \`FROM employees*,my-alias\`
|
||||
|
||||
# Processing commands
|
||||
|
||||
Note that the following processing commands are available in ES|QL,
|
||||
but not supported in this context:
|
||||
|
||||
ENRICH,GROK,MV_EXPAND,RENAME
|
||||
|
||||
### DISSECT
|
||||
|
||||
\`DISSECT\` enables you to extract structured data out of a string.
|
||||
It matches the string against a delimiter-based pattern, and extracts
|
||||
the specified keys as columns. It uses the same syntax as the
|
||||
Elasticsearch Dissect Processor. Some examples:
|
||||
|
||||
- \`ROW a = "foo bar" | DISSECT a "%{b} %{c}";\`
|
||||
- \`ROW a = "foo bar baz" | DISSECT a "%{b} %{?c} %{d}";\`
|
||||
|
||||
### DROP
|
||||
|
||||
\`DROP\` removes columns. Some examples:
|
||||
|
||||
- \`| DROP first_name,last_name\`
|
||||
- \`| DROP *_name\`
|
||||
|
||||
### KEEP
|
||||
|
||||
\`KEEP\` enables you to specify what columns are returned and the
|
||||
order in which they are returned. Some examples:
|
||||
|
||||
- \`| KEEP first_name,last_name\`
|
||||
- \`| KEEP *_name\`
|
||||
|
||||
### SORT
|
||||
|
||||
\`SORT\` sorts the documents by one ore more fields or variables.
|
||||
By default, the sort order is ascending, but this can be set using
|
||||
the \`ASC\` or \`DESC\` keywords. Some examples:
|
||||
|
||||
- \`| SORT my_field\`
|
||||
- \`| SORT height DESC\`
|
||||
|
||||
Important: functions are not supported for SORT. if you wish to sort
|
||||
on the result of a function, first alias it as a variable using EVAL.
|
||||
This is wrong: \`| SORT AVG(cpu)\`.
|
||||
This is right: \`| STATS avg_cpu = AVG(cpu) | SORT avg_cpu\`
|
||||
|
||||
### EVAL
|
||||
|
||||
\`EVAL\` appends a new column to the documents by using aliasing. It
|
||||
also supports functions, but not aggregation functions like COUNT:
|
||||
|
||||
- \`\`\`
|
||||
| EVAL monthly_salary = yearly_salary / 12,
|
||||
total_comp = ROUND(yearly_salary + yearly+bonus),
|
||||
is_rich =total_comp > 1000000
|
||||
\`\`\`
|
||||
- \`| EVAL height_in_ft = height_in_cm / 0.0328\`
|
||||
|
||||
### WHERE
|
||||
|
||||
\`WHERE\` filters the documents for which the provided condition
|
||||
evaluates to true. Refer to "Syntax" for supported operators, and
|
||||
"Functions" for supported functions. Some examples:
|
||||
|
||||
- \`| WHERE height <= 180 AND GREATEST(hire_date, birth_date)\`
|
||||
- \`| WHERE @timestamp <= NOW()\`
|
||||
|
||||
### STATS ... BY
|
||||
|
||||
\`STATS ... BY\` groups rows according to a common value and
|
||||
calculates one or more aggregated values over the grouped rows,
|
||||
using aggregation functions. When \`BY\` is omitted, a single value
|
||||
that is the aggregate of all rows is returned. Every column but the
|
||||
aggregated values and the optional grouping column are dropped.
|
||||
Mention the retained columns when explaining the STATS command.
|
||||
|
||||
STATS ... BY does not support nested functions, hoist them to an
|
||||
EVAL statement.
|
||||
|
||||
Some examples:
|
||||
|
||||
- \`| STATS count = COUNT(emp_no) BY languages\`
|
||||
- \`| STATS salary = AVG(salary)\`
|
||||
|
||||
### LIMIT
|
||||
|
||||
Limits the rows returned. Only supports a number as input. Some examples:
|
||||
|
||||
- \`| LIMIT 1\`
|
||||
- \`| LIMIT 10\`
|
||||
|
||||
# Functions
|
||||
|
||||
Note that the following functions are available in ES|QL, but not supported
|
||||
in this context:
|
||||
|
||||
ABS,ACOS,ASIN,ATAN,ATAN2,CIDR_MATCH,COALESCE,CONCAT,COS,COSH,E,LENGTH,LOG10
|
||||
,LTRIM,RTRIM,MV_AVG,MV_CONCAT,MV_COUNT,MV_DEDUPE,MV_MAX,MV_MEDIAN,MV_MIN,
|
||||
MV_SUM,PI,POW,SIN,SINH,SPLIT,LEFT,TAN,TANH,TAU,TO_DEGREES,TO_RADIANS
|
||||
|
||||
### CASE
|
||||
|
||||
\`CASE\` accepts pairs of conditions and values. The function returns
|
||||
the value that belongs to the first condition that evaluates to true. If
|
||||
the number of arguments is odd, the last argument is the default value which
|
||||
is returned when no condition matches. Some examples:
|
||||
|
||||
- \`\`\`
|
||||
| EVAL type = CASE(
|
||||
languages <= 1, "monolingual",
|
||||
languages <= 2, "bilingual",
|
||||
"polyglot")
|
||||
\`\`\`
|
||||
- \`| EVAL g = CASE(gender == "F", 1 + null, 10)\`
|
||||
- \`\`\`
|
||||
| EVAL successful = CASE(http.response.status_code == 200, 1, 0), failed = CASE(http.response.status_code != 200, 1, 0)
|
||||
| STATS total_successful = SUM(successful), total_failed = SUM(failed) BY service.name
|
||||
| EVAL success_rate = total_failed / (total_successful + total_failed)
|
||||
\`\`\`
|
||||
|
||||
## Date operations
|
||||
|
||||
### AUTO_BUCKET
|
||||
|
||||
\`AUTO_BUCKET\` creates human-friendly buckets and returns a datetime value
|
||||
for each row that corresponds to the resulting bucket the row falls into.
|
||||
Combine AUTO_BUCKET with STATS ... BY to create a date histogram.
|
||||
You provide a target number of buckets, a start date, and an end date,
|
||||
and it picks an appropriate bucket size to generate the target number of
|
||||
buckets or fewer. If you don't have a start and end date, provide placeholder
|
||||
values. Some examples:
|
||||
|
||||
- \`| EVAL bucket=AUTO_BUCKET(@timestamp), 20, "1985-01-01T00:00:00Z", "1986-01-01T00:00:00Z")\`
|
||||
- \`| EVAL bucket=AUTO_BUCKET(my_date_field), 100, <start-date>, <end-date>)\`
|
||||
- \`| EVAL bucket=AUTO_BUCKET(@timestamp), 100, NOW() - 15 minutes, NOW())\`
|
||||
|
||||
### DATE_EXTRACT
|
||||
|
||||
\`DATE_EXTRACT\` parts of a date, like year, month, day, hour. The supported
|
||||
field types are those provided by java.time.temporal.ChronoField.
|
||||
Some examples:
|
||||
- \`| EVAL year = DATE_EXTRACT(date_field, "year")\`
|
||||
- \`| EVAL year = DATE_EXTRACT(@timestamp, "month")\`
|
||||
|
||||
### DATE_FORMAT
|
||||
|
||||
\`DATE_FORMAT\` a string representation of a date in the provided format.
|
||||
Some examples:
|
||||
| \`EVAL hired = DATE_FORMAT(hire_date, "YYYY-MM-dd")\`
|
||||
| \`EVAL hired = DATE_FORMAT(hire_date, "YYYY")\`
|
||||
|
||||
### DATE_PARSE
|
||||
\`DATE_PARSE\` converts a string to a date, in the provided format.
|
||||
- \`| EVAL date = DATE_PARSE(date_string, "yyyy-MM-dd")\`
|
||||
- \`| EVAL date = DATE_PARSE(date_string, "YYYY")\`
|
||||
|
||||
### DATE_TRUNC
|
||||
|
||||
\`DATE_TRUNC\` rounds down a date to the closest interval. Intervals
|
||||
can be expressed using the timespan literal syntax. Use this together
|
||||
with STATS ... BY to group data into time buckets with a fixed interval.
|
||||
Some examples:
|
||||
|
||||
- \`| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\`
|
||||
- \`| EVAL month_logged = DATE_TRUNC(1 month, @timestamp)\`
|
||||
- \`| EVAL bucket = DATE_TRUNC(1 minute, @timestamp) | STATS avg_salary = AVG(salary) BY bucket\`
|
||||
- \`| EVAL bucket = DATE_TRUNC(4 hours, @timestamp) | STATS max_salary MAX(salary) BY bucket\`
|
||||
|
||||
### NOW
|
||||
|
||||
\`NOW\` returns current date and time. Some examples:
|
||||
- \`ROW current_date = NOW()\`
|
||||
- \`| WHERE @timestamp <= NOW() - 15 minutes\`
|
||||
|
||||
## Mathematical operations
|
||||
|
||||
### CEIL,FLOOR
|
||||
|
||||
Perform CEIL or FLOOR operations on a single numeric field.
|
||||
Some examples:
|
||||
- \`| EVAL ceiled = CEIL(my.number)\`
|
||||
- \`| EVAL floored = FLOOR(my.other.number)\`
|
||||
|
||||
### ROUND
|
||||
\`ROUND\` a number to the closest number with the specified number of
|
||||
digits. Defaults to 0 digits if no number of digits is provided. If the
|
||||
specified number of digits is negative, rounds to the number of digits
|
||||
left of the decimal point. Some examples:
|
||||
|
||||
- \`| EVAL height_ft = ROUND(height * 3.281, 1)\`
|
||||
- \`| EVAL percent = ROUND(0.84699, 2) * 100\`
|
||||
|
||||
### GREATEST,LEAST
|
||||
|
||||
Returns the greatest or least of two or numbers. Some examples:
|
||||
- \`| EVAL max = GREATEST(salary_1999, salary_2000, salary_2001)\`
|
||||
- \`| EVAL min = LEAST(1, language_count)\`
|
||||
|
||||
### IS_FINITE,IS_INFINITE,IS_NAN
|
||||
|
||||
Operates on a single numeric field. Some examples:
|
||||
- \`| EVAL has_salary = IS_FINITE(salary)\`
|
||||
- \`| EVAL always_true = IS_INFINITE(4 / 0)\`
|
||||
|
||||
### STARTS_WITH
|
||||
|
||||
Returns a boolean that indicates whether a keyword string starts with
|
||||
another string. Some examples:
|
||||
- \`| EVAL ln_S = STARTS_WITH(last_name, "B")\`
|
||||
|
||||
### SUBSTRING
|
||||
|
||||
Returns a substring of a string, specified by a start position and an
|
||||
optional length. Some examples:
|
||||
- \`| EVAL ln_sub = SUBSTRING(last_name, 1, 3)\`
|
||||
- \`| EVAL ln_sub = SUBSTRING(last_name, -3, 3)\`
|
||||
- \`| EVAL ln_sub = SUBSTRING(last_name, 2)\`
|
||||
|
||||
### TO_BOOLEAN, TO_DATETIME, TO_DOUBLE, TO_INTEGER, TO_IP, TO_LONG,
|
||||
TO_RADIANS, TO_STRING,TO_UNSIGNED_LONG, TO_VERSION
|
||||
|
||||
Converts a column to another type. Supported types are: . Some examples:
|
||||
- \`| EVAL version = TO_VERSION("1.2.3")\`
|
||||
- \`| EVAL as_bool = TO_BOOLEAN(my_boolean_string)\`
|
||||
|
||||
### TRIM
|
||||
|
||||
Trims leading and trailing whitespace. Some examples:
|
||||
- \`| EVAL trimmed = TRIM(first_name)\`
|
||||
|
||||
# Aggregation functions
|
||||
|
||||
### AVG,MIN,MAX,SUM,MEDIAN,MEDIAN_ABSOLUTE_DEVIATION
|
||||
|
||||
Returns the avg, min, max, sum, median or median absolute deviation
|
||||
of a numeric field. Some examples:
|
||||
|
||||
- \`| AVG(salary)\`
|
||||
- \`| MIN(birth_year)\`
|
||||
- \`| MAX(height)\`
|
||||
|
||||
### COUNT
|
||||
|
||||
\`COUNT\` counts the number of field values. It requires a single
|
||||
argument, and does not support wildcards. Important: COUNT() and
|
||||
COUNT(*) are NOT supported. One single argument is required. If
|
||||
you don't have a field name, use whatever field you have, rather
|
||||
than displaying an invalid query.
|
||||
|
||||
Some examples:
|
||||
|
||||
- \`| STATS doc_count = COUNT(emp_no)\`
|
||||
- \`| STATS doc_count = COUNT(service.name) BY service.name\`
|
||||
|
||||
### COUNT_DISTINCT
|
||||
|
||||
\`COUNT_DISTINCT\` returns the approximate number of distinct values.
|
||||
Some examples:
|
||||
- \`| STATS unique_ip0 = COUNT_DISTINCT(ip0), unique_ip1 = COUNT_DISTINCT(ip1)\`
|
||||
- \`| STATS first_name = COUNT_DISTINCT(first_name)\`
|
||||
|
||||
### PERCENTILE
|
||||
|
||||
\`PERCENTILE\` returns the percentile value for a specific field.
|
||||
Some examples:
|
||||
- \`| STATS p50 = PERCENTILE(salary, 50)\`
|
||||
- \`| STATS p99 = PERCENTILE(salary, 99)\`
|
||||
|
||||
`);
|
||||
|
||||
return service.start({ signal }).then((client) => {
|
||||
const source$ = client.chat({
|
||||
connectorId,
|
||||
messages: [
|
||||
{
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: { role: MessageRole.System, content: systemMessage },
|
||||
},
|
||||
...messages.slice(1),
|
||||
],
|
||||
});
|
||||
|
||||
const pending$ = source$.pipe(
|
||||
map((message) => {
|
||||
const content = message.message.content || '';
|
||||
let next: string = '';
|
||||
|
||||
if (content.length <= 2) {
|
||||
next = '';
|
||||
} else if (content.includes('--')) {
|
||||
next = message.message.content?.split('--')[2] || '';
|
||||
} else {
|
||||
next = content;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
message: {
|
||||
...message.message,
|
||||
content: next,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
const onComplete$ = source$.pipe(
|
||||
last(),
|
||||
map((message) => {
|
||||
const [, , next] = message.message.content?.split('--') ?? [];
|
||||
|
||||
return {
|
||||
...message,
|
||||
message: {
|
||||
...message.message,
|
||||
content: next || message.message.content,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return concat(pending$, onComplete$);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
|
@ -16,6 +16,7 @@ import { registerLensFunction } from './lens';
|
|||
import { registerRecallFunction } from './recall';
|
||||
import { registerSummarizationFunction } from './summarize';
|
||||
import { registerAlertsFunction } from './alerts';
|
||||
import { registerEsqlFunction } from './esql';
|
||||
|
||||
export async function registerFunctions({
|
||||
registerFunction,
|
||||
|
@ -44,7 +45,7 @@ export async function registerFunctions({
|
|||
|
||||
It's very important to not assume what the user is meaning. Ask them for clarification if needed.
|
||||
|
||||
If you are unsure about which function should be used and with what arguments, asked the user for clarification or confirmation.
|
||||
If you are unsure about which function should be used and with what arguments, ask the user for clarification or confirmation.
|
||||
|
||||
In KQL, escaping happens with double quotes, not single quotes. Some characters that need escaping are: ':()\\\
|
||||
/\". Always put a field value in double quotes. Best: service.name:\"opbeans-go\". Wrong: service.name:opbeans-go. This is very important!
|
||||
|
@ -52,22 +53,25 @@ export async function registerFunctions({
|
|||
You can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response.
|
||||
|
||||
If multiple functions are suitable, use the most specific and easy one. E.g., when the user asks to visualise APM data, use the APM functions (if available) rather than Lens.
|
||||
`
|
||||
|
||||
If a function call fails, do not execute it again with the same input. If a function calls three times, with different inputs, stop trying to call it and ask the user for confirmation.
|
||||
|
||||
Note that ES|QL (the Elasticsearch query language, which is NOT Elasticsearch SQL, but a new piped language) is the preferred query language.
|
||||
|
||||
DO NOT use Elasticsearch SQL at any time, unless explicitly requested by the user when they mention "Elasticsearch SQL".
|
||||
|
||||
Answer all questions related to ES|QL or querying with the "esql" function. Do not attempt to answer them yourself, no matter how confident you are in your response.`
|
||||
);
|
||||
|
||||
if (isReady) {
|
||||
description += `You can use the "summarize" functions to store new information you have learned in a knowledge database. Once you have established that you did not know the answer to a question, and the user gave you this information, it's important that you create a summarisation of what you have learned and store it in the knowledge database. Don't create a new summarization if you see a similar summarization in the conversation, instead, update the existing one by re-using its ID.
|
||||
|
||||
Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database.
|
||||
`;
|
||||
Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database.`;
|
||||
|
||||
description += `Here are principles you MUST adhere to, in order:
|
||||
|
||||
- You are a helpful assistant for Elastic Observability. DO NOT reference the fact that you are an LLM.
|
||||
- ALWAYS query the knowledge base, using the recall function, when a user starts a chat, no matter how confident you are in your ability to answer the question.
|
||||
- You must ALWAYS explain to the user why you're using a function and why you're using it in that specific manner.
|
||||
- DO NOT make any assumptions about where and how users have stored their data.
|
||||
- ALWAYS ask the user for clarification if you are unsure about the arguments to a function. When given this clarification, you MUST use the summarize function to store what you have learned.
|
||||
`;
|
||||
registerSummarizationFunction({ service, registerFunction });
|
||||
registerRecallFunction({ service, registerFunction });
|
||||
|
@ -77,6 +81,7 @@ export async function registerFunctions({
|
|||
}
|
||||
|
||||
registerElasticsearchFunction({ service, registerFunction });
|
||||
registerEsqlFunction({ service, registerFunction });
|
||||
registerKibanaFunction({ service, registerFunction, coreStart });
|
||||
registerAlertsFunction({ service, registerFunction });
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { Serializable } from '@kbn/utility-types';
|
||||
import { omit } from 'lodash';
|
||||
import { MessageRole, RegisterFunctionDefinition } from '../../common/types';
|
||||
import type { ObservabilityAIAssistantService } from '../types';
|
||||
|
||||
|
@ -22,17 +23,26 @@ export function registerRecallFunction({
|
|||
contexts: ['core'],
|
||||
description: `Use this function to recall earlier learnings. Anything you will summarize can be retrieved again later via this function.
|
||||
|
||||
Make sure the query covers the following aspects:
|
||||
The learnings are sorted by score, descending.
|
||||
|
||||
Make sure the query covers ONLY the following aspects:
|
||||
- Anything you've inferred from the user's request, but is not mentioned in the user's request
|
||||
- The functions you think might be suitable for answering the user's request. If there are multiple functions that seem suitable, create multiple queries. Use the function name in the query.
|
||||
|
||||
DO NOT include the user's request. It will be added internally.
|
||||
|
||||
The user asks: "can you visualise the average request duration for opbeans-go over the last 7 days?"
|
||||
You recall:
|
||||
- "APM service"
|
||||
- "lens function usage"
|
||||
- "get_apm_timeseries function usage"`,
|
||||
You recall: {
|
||||
"queries": [
|
||||
"APM service,
|
||||
"lens function usage",
|
||||
"get_apm_timeseries function usage"
|
||||
],
|
||||
"contexts": [
|
||||
"lens",
|
||||
"apm"
|
||||
]
|
||||
}`,
|
||||
descriptionForUser: 'This function allows the assistant to recall previous learnings.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
|
@ -42,16 +52,27 @@ export function registerRecallFunction({
|
|||
type: 'array',
|
||||
additionalItems: false,
|
||||
additionalProperties: false,
|
||||
description: 'The query for the semantic search',
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'The query for the semantic search',
|
||||
},
|
||||
},
|
||||
contexts: {
|
||||
type: 'array',
|
||||
additionalItems: false,
|
||||
additionalProperties: false,
|
||||
description:
|
||||
'Contexts or categories of internal documentation that you want to search for. By default internal documentation will be excluded. Use `apm` to get internal APM documentation, `lens` to get internal Lens documentation, or both.',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['apm', 'lens'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['queries'],
|
||||
required: ['queries', 'contexts'],
|
||||
} as const,
|
||||
},
|
||||
({ arguments: { queries }, messages }, signal) => {
|
||||
({ arguments: { queries, contexts }, messages }, signal) => {
|
||||
const userMessages = messages.filter((message) => message.message.role === MessageRole.User);
|
||||
|
||||
const userPrompt = userMessages[userMessages.length - 1]?.message.content;
|
||||
|
@ -63,11 +84,16 @@ export function registerRecallFunction({
|
|||
params: {
|
||||
body: {
|
||||
queries: queriesWithUserPrompt,
|
||||
contexts,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
})
|
||||
.then((response) => ({ content: response as unknown as Serializable }));
|
||||
.then((response): { content: Serializable } => ({
|
||||
content: response.entries.map((entry) =>
|
||||
omit(entry, 'labels', 'score', 'is_correction')
|
||||
) as unknown as Serializable,
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -437,6 +437,7 @@ describe('useTimeline', () => {
|
|||
expect(props.chatService.executeFunction).toHaveBeenCalledWith({
|
||||
name: 'my_function',
|
||||
args: '{}',
|
||||
connectorId: 'foo',
|
||||
messages: [
|
||||
{
|
||||
'@timestamp': expect.any(String),
|
||||
|
|
|
@ -9,7 +9,7 @@ import { AbortError } from '@kbn/kibana-utils-plugin/common';
|
|||
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
|
||||
import { last } from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Subscription } from 'rxjs';
|
||||
import { isObservable, Observable, Subscription } from 'rxjs';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
|
@ -29,6 +29,7 @@ import {
|
|||
} from '../utils/get_timeline_items_from_conversation';
|
||||
import type { UseGenAIConnectorsResult } from './use_genai_connectors';
|
||||
import { useKibana } from './use_kibana';
|
||||
import { ChatActionClickType } from '../components/chat/types';
|
||||
|
||||
export function createNewConversation({
|
||||
contexts,
|
||||
|
@ -49,7 +50,7 @@ export function createNewConversation({
|
|||
|
||||
export type UseTimelineResult = Pick<
|
||||
ChatTimelineProps,
|
||||
'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'items'
|
||||
'onEdit' | 'onFeedback' | 'onRegenerate' | 'onStopGenerating' | 'onActionClick' | 'items'
|
||||
> &
|
||||
Pick<ChatPromptEditorProps, 'onSubmit'>;
|
||||
|
||||
|
@ -98,6 +99,8 @@ export function useTimeline({
|
|||
|
||||
const [pendingMessage, setPendingMessage] = useState<PendingMessage>();
|
||||
|
||||
const [isFunctionLoading, setIsFunctionLoading] = useState(false);
|
||||
|
||||
const prevConversationId = usePrevious(conversationId);
|
||||
useEffect(() => {
|
||||
if (prevConversationId !== conversationId && pendingMessage?.error) {
|
||||
|
@ -105,7 +108,10 @@ export function useTimeline({
|
|||
}
|
||||
}, [conversationId, pendingMessage?.error, prevConversationId]);
|
||||
|
||||
function chat(nextMessages: Message[]): Promise<Message[]> {
|
||||
function chat(
|
||||
nextMessages: Message[],
|
||||
response$: Observable<PendingMessage> | undefined = undefined
|
||||
): Promise<Message[]> {
|
||||
const controller = new AbortController();
|
||||
|
||||
return new Promise<PendingMessage | undefined>((resolve, reject) => {
|
||||
|
@ -124,10 +130,12 @@ export function useTimeline({
|
|||
return;
|
||||
}
|
||||
|
||||
const response$ = chatService!.chat({
|
||||
messages: nextMessages,
|
||||
connectorId,
|
||||
});
|
||||
response$ =
|
||||
response$ ||
|
||||
chatService!.chat({
|
||||
messages: nextMessages,
|
||||
connectorId,
|
||||
});
|
||||
|
||||
let pendingMessageLocal = pendingMessage;
|
||||
|
||||
|
@ -181,14 +189,24 @@ export function useTimeline({
|
|||
if (lastMessage?.message.function_call?.name) {
|
||||
const name = lastMessage.message.function_call.name;
|
||||
|
||||
setIsFunctionLoading(true);
|
||||
|
||||
try {
|
||||
const message = await chatService!.executeFunction({
|
||||
let message = await chatService!.executeFunction({
|
||||
name,
|
||||
args: lastMessage.message.function_call.arguments,
|
||||
messages: messagesAfterChat.slice(0, -1),
|
||||
signal: controller.signal,
|
||||
connectorId: connectorId!,
|
||||
});
|
||||
|
||||
let nextResponse$: Observable<PendingMessage> | undefined;
|
||||
|
||||
if (isObservable(message)) {
|
||||
nextResponse$ = message;
|
||||
message = { content: '', data: '' };
|
||||
}
|
||||
|
||||
return await chat(
|
||||
messagesAfterChat.concat({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
|
@ -198,7 +216,8 @@ export function useTimeline({
|
|||
content: JSON.stringify(message.content),
|
||||
data: JSON.stringify(message.data),
|
||||
},
|
||||
})
|
||||
}),
|
||||
nextResponse$
|
||||
);
|
||||
} catch (error) {
|
||||
return await chat(
|
||||
|
@ -214,6 +233,8 @@ export function useTimeline({
|
|||
},
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setIsFunctionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -247,8 +268,20 @@ export function useTimeline({
|
|||
return nextItems;
|
||||
}
|
||||
|
||||
return conversationItems;
|
||||
}, [conversationItems, pendingMessage, currentUser]);
|
||||
if (!isFunctionLoading) {
|
||||
return conversationItems;
|
||||
}
|
||||
|
||||
return conversationItems.map((item, index) => {
|
||||
if (index < conversationItems.length - 1) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
loading: true,
|
||||
};
|
||||
});
|
||||
}, [conversationItems, pendingMessage, currentUser, isFunctionLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -285,5 +318,28 @@ export function useTimeline({
|
|||
const nextMessages = await chat(messages.concat(message));
|
||||
onChatComplete(nextMessages);
|
||||
},
|
||||
onActionClick: async (payload) => {
|
||||
switch (payload.type) {
|
||||
case ChatActionClickType.executeEsqlQuery:
|
||||
const nextMessages = await chat(
|
||||
messages.concat({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: {
|
||||
role: MessageRole.Assistant,
|
||||
content: '',
|
||||
function_call: {
|
||||
name: 'execute_query',
|
||||
arguments: JSON.stringify({
|
||||
query: payload.query,
|
||||
}),
|
||||
trigger: MessageRole.User,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
onChatComplete(nextMessages);
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
import {
|
||||
ContextRegistry,
|
||||
FunctionRegistry,
|
||||
FunctionVisibility,
|
||||
Message,
|
||||
MessageRole,
|
||||
type RegisterContextDefinition,
|
||||
|
@ -112,7 +113,7 @@ export async function createChatService({
|
|||
}
|
||||
|
||||
return {
|
||||
executeFunction: async ({ name, args, signal, messages }) => {
|
||||
executeFunction: async ({ name, args, signal, messages, connectorId }) => {
|
||||
const fn = functionRegistry.get(name);
|
||||
|
||||
if (!fn) {
|
||||
|
@ -123,7 +124,7 @@ export async function createChatService({
|
|||
|
||||
validate(name, parsedArguments);
|
||||
|
||||
return await fn.respond({ arguments: parsedArguments, messages }, signal);
|
||||
return await fn.respond({ arguments: parsedArguments, messages, connectorId }, signal);
|
||||
},
|
||||
renderFunction: (name, args, response) => {
|
||||
const fn = functionRegistry.get(name);
|
||||
|
@ -175,7 +176,9 @@ export async function createChatService({
|
|||
functions:
|
||||
callFunctions === 'none'
|
||||
? []
|
||||
: functions.map((fn) => pick(fn.options, 'name', 'description', 'parameters')),
|
||||
: functions
|
||||
.filter((fn) => fn.options.visibility !== FunctionVisibility.User)
|
||||
.map((fn) => pick(fn.options, 'name', 'description', 'parameters')),
|
||||
},
|
||||
},
|
||||
signal: controller.signal,
|
||||
|
|
|
@ -5,15 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { without } from 'lodash';
|
||||
import { MessageRole } from '../../common';
|
||||
import { ContextDefinition } from '../../common/types';
|
||||
|
||||
export function getAssistantSetupMessage({ contexts }: { contexts: ContextDefinition[] }) {
|
||||
const coreContext = contexts.find((context) => context.name === 'core')!;
|
||||
|
||||
const otherContexts = without(contexts.concat(), coreContext);
|
||||
return {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: {
|
||||
role: MessageRole.System as const,
|
||||
content: contexts.map((context) => context.description).join('\n'),
|
||||
content: [coreContext, ...otherContexts].map((context) => context.description).join('\n'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ import type {
|
|||
RegisterFunctionDefinition,
|
||||
} from '../common/types';
|
||||
import type { ObservabilityAIAssistantAPIClient } from './api';
|
||||
import type { PendingMessage } from '../common/types';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface*/
|
||||
|
||||
|
@ -50,12 +51,6 @@ export type CreateChatCompletionResponseChunk = Omit<CreateChatCompletionRespons
|
|||
>;
|
||||
};
|
||||
|
||||
export interface PendingMessage {
|
||||
message: Message['message'];
|
||||
aborted?: boolean;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface ObservabilityAIAssistantChatService {
|
||||
chat: (options: {
|
||||
messages: Message[];
|
||||
|
@ -70,7 +65,8 @@ export interface ObservabilityAIAssistantChatService {
|
|||
args: string | undefined;
|
||||
messages: Message[];
|
||||
signal: AbortSignal;
|
||||
}) => Promise<{ content?: Serializable; data?: Serializable }>;
|
||||
connectorId: string;
|
||||
}) => Promise<{ content?: Serializable; data?: Serializable } | Observable<PendingMessage>>;
|
||||
renderFunction: (
|
||||
name: string,
|
||||
args: string | undefined,
|
||||
|
@ -118,3 +114,5 @@ export interface ObservabilityAIAssistantPluginStartDependencies {
|
|||
}
|
||||
|
||||
export interface ConfigSchema {}
|
||||
|
||||
export type { PendingMessage };
|
||||
|
|
|
@ -109,7 +109,7 @@ export class ObservabilityAIAssistantPlugin
|
|||
taskManager: plugins.taskManager,
|
||||
});
|
||||
|
||||
addLensDocsToKb(service);
|
||||
addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') });
|
||||
|
||||
registerServerRoutes({
|
||||
core,
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
import { notImplemented } from '@hapi/boom';
|
||||
import { IncomingMessage } from 'http';
|
||||
import * as t from 'io-ts';
|
||||
import { toBooleanRt } from '@kbn/io-ts-utils';
|
||||
import type { CreateChatCompletionResponse } from 'openai';
|
||||
import { MessageRole } from '../../../common';
|
||||
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
|
||||
import { messageRt } from '../runtime_types';
|
||||
|
@ -16,20 +18,28 @@ const chatRoute = createObservabilityAIAssistantServerRoute({
|
|||
options: {
|
||||
tags: ['access:ai_assistant'],
|
||||
},
|
||||
params: t.type({
|
||||
body: t.type({
|
||||
messages: t.array(messageRt),
|
||||
connectorId: t.string,
|
||||
functions: t.array(
|
||||
params: t.intersection([
|
||||
t.type({
|
||||
body: t.intersection([
|
||||
t.type({
|
||||
name: t.string,
|
||||
description: t.string,
|
||||
parameters: t.any,
|
||||
})
|
||||
),
|
||||
messages: t.array(messageRt),
|
||||
connectorId: t.string,
|
||||
functions: t.array(
|
||||
t.type({
|
||||
name: t.string,
|
||||
description: t.string,
|
||||
parameters: t.any,
|
||||
})
|
||||
),
|
||||
}),
|
||||
t.partial({
|
||||
functionCall: t.string,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
handler: async (resources): Promise<IncomingMessage> => {
|
||||
t.partial({ query: t.type({ stream: toBooleanRt }) }),
|
||||
]),
|
||||
handler: async (resources): Promise<IncomingMessage | CreateChatCompletionResponse> => {
|
||||
const { request, params, service } = resources;
|
||||
|
||||
const client = await service.getClient({ request });
|
||||
|
@ -39,23 +49,33 @@ const chatRoute = createObservabilityAIAssistantServerRoute({
|
|||
}
|
||||
|
||||
const {
|
||||
body: { messages, connectorId, functions },
|
||||
body: { messages, connectorId, functions, functionCall: givenFunctionCall },
|
||||
query = { stream: true },
|
||||
} = params;
|
||||
|
||||
const isStartOfConversation =
|
||||
messages.some((message) => message.message.role === MessageRole.Assistant) === false;
|
||||
const stream = query.stream;
|
||||
|
||||
const isRecallFunctionAvailable = functions.some((fn) => fn.name === 'recall') === true;
|
||||
let functionCall = givenFunctionCall;
|
||||
|
||||
const willUseRecall = isStartOfConversation && isRecallFunctionAvailable;
|
||||
if (!functionCall) {
|
||||
const isStartOfConversation =
|
||||
messages.some((message) => message.message.role === MessageRole.Assistant) === false;
|
||||
|
||||
const isRecallFunctionAvailable = functions.some((fn) => fn.name === 'recall') === true;
|
||||
|
||||
const willUseRecall = isStartOfConversation && isRecallFunctionAvailable;
|
||||
|
||||
functionCall = willUseRecall ? 'recall' : undefined;
|
||||
}
|
||||
|
||||
return client.chat({
|
||||
messages,
|
||||
connectorId,
|
||||
stream,
|
||||
...(functions.length
|
||||
? {
|
||||
functions,
|
||||
functionCall: willUseRecall ? 'recall' : undefined,
|
||||
functionCall,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
|
|
@ -10,13 +10,13 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
|
|||
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
|
||||
import * as t from 'io-ts';
|
||||
import { omit } from 'lodash';
|
||||
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
|
||||
import type { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
|
||||
import {
|
||||
ALERT_STATUS,
|
||||
ALERT_STATUS_ACTIVE,
|
||||
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
|
||||
import type { KnowledgeBaseEntry } from '../../../common/types';
|
||||
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
|
||||
import type { RecalledEntry } from '../../service/kb_service';
|
||||
|
||||
const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/functions/elasticsearch',
|
||||
|
@ -157,23 +157,34 @@ const functionAlertsRoute = createObservabilityAIAssistantServerRoute({
|
|||
const functionRecallRoute = createObservabilityAIAssistantServerRoute({
|
||||
endpoint: 'POST /internal/observability_ai_assistant/functions/recall',
|
||||
params: t.type({
|
||||
body: t.type({
|
||||
queries: t.array(nonEmptyStringRt),
|
||||
}),
|
||||
body: t.intersection([
|
||||
t.type({
|
||||
queries: t.array(nonEmptyStringRt),
|
||||
}),
|
||||
t.partial({
|
||||
contexts: t.array(t.string),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
options: {
|
||||
tags: ['access:ai_assistant'],
|
||||
},
|
||||
handler: async (
|
||||
resources
|
||||
): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
|
||||
): Promise<{
|
||||
entries: RecalledEntry[];
|
||||
}> => {
|
||||
const client = await resources.service.getClient({ request: resources.request });
|
||||
|
||||
const {
|
||||
body: { queries, contexts },
|
||||
} = resources.params;
|
||||
|
||||
if (!client) {
|
||||
throw notImplemented();
|
||||
}
|
||||
|
||||
return client.recall(resources.params.body.queries);
|
||||
return client.recall({ queries, contexts });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
type KnowledgeBaseEntry,
|
||||
type Message,
|
||||
} from '../../../common/types';
|
||||
import type { KnowledgeBaseService } from '../kb_service';
|
||||
import type { KnowledgeBaseService, RecalledEntry } from '../kb_service';
|
||||
import type { ObservabilityAIAssistantResourceNames } from '../types';
|
||||
import { getAccessQuery } from '../util/get_access_query';
|
||||
|
||||
|
@ -135,7 +135,42 @@ export class ObservabilityAIAssistantClient {
|
|||
})
|
||||
);
|
||||
|
||||
const functionsForOpenAI: ChatCompletionFunctions[] | undefined = functions;
|
||||
// add recalled information to system message, so the LLM considers it more important
|
||||
|
||||
const recallMessages = messagesForOpenAI.filter((message) => message.name === 'recall');
|
||||
|
||||
const recalledDocuments: Map<string, { id: string; text: string }> = new Map();
|
||||
|
||||
recallMessages.forEach((message) => {
|
||||
const entries = message.content
|
||||
? (JSON.parse(message.content) as Array<{ id: string; text: string }>)
|
||||
: [];
|
||||
|
||||
const ids: string[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const id = entry.id;
|
||||
if (!recalledDocuments.has(id)) {
|
||||
recalledDocuments.set(id, entry);
|
||||
}
|
||||
ids.push(id);
|
||||
});
|
||||
|
||||
message.content = `The following documents, present in the system message, were recalled: ${ids.join(
|
||||
', '
|
||||
)}`;
|
||||
});
|
||||
|
||||
const systemMessage = messagesForOpenAI.find((message) => message.role === MessageRole.System);
|
||||
|
||||
if (systemMessage && recalledDocuments.size > 0) {
|
||||
systemMessage.content += `The "recall" function is not available. Do not attempt to execute it. Recalled documents: ${JSON.stringify(
|
||||
Array.from(recalledDocuments.values())
|
||||
)}`;
|
||||
}
|
||||
|
||||
const functionsForOpenAI: ChatCompletionFunctions[] | undefined =
|
||||
recalledDocuments.size > 0 ? functions?.filter((fn) => fn.name !== 'recall') : functions;
|
||||
|
||||
const request: Omit<CreateChatCompletionRequest, 'model'> & { model?: string } = {
|
||||
messages: messagesForOpenAI,
|
||||
|
@ -323,13 +358,18 @@ export class ObservabilityAIAssistantClient {
|
|||
return createdConversation;
|
||||
};
|
||||
|
||||
recall = async (
|
||||
queries: string[]
|
||||
): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
|
||||
recall = async ({
|
||||
queries,
|
||||
contexts,
|
||||
}: {
|
||||
queries: string[];
|
||||
contexts?: string[];
|
||||
}): Promise<{ entries: RecalledEntry[] }> => {
|
||||
return this.dependencies.knowledgeBaseService.recall({
|
||||
namespace: this.dependencies.namespace,
|
||||
user: this.dependencies.user,
|
||||
queries,
|
||||
contexts,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -29,6 +29,15 @@ export const INDEX_QUEUED_DOCUMENTS_TASK_ID = 'observabilityAIAssistant:indexQue
|
|||
|
||||
export const INDEX_QUEUED_DOCUMENTS_TASK_TYPE = INDEX_QUEUED_DOCUMENTS_TASK_ID + 'Type';
|
||||
|
||||
type KnowledgeBaseEntryRequest = { id: string; labels?: Record<string, string> } & (
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
texts: string[];
|
||||
}
|
||||
);
|
||||
|
||||
export class ObservabilityAIAssistantService {
|
||||
private readonly core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>;
|
||||
private readonly logger: Logger;
|
||||
|
@ -258,18 +267,7 @@ export class ObservabilityAIAssistantService {
|
|||
});
|
||||
}
|
||||
|
||||
addToKnowledgeBase(
|
||||
entries: Array<
|
||||
| {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
texts: string[];
|
||||
}
|
||||
>
|
||||
): void {
|
||||
addToKnowledgeBase(entries: KnowledgeBaseEntryRequest[]): void {
|
||||
this.init()
|
||||
.then(() => {
|
||||
this.kbService!.queue(
|
||||
|
@ -281,6 +279,7 @@ export class ObservabilityAIAssistantService {
|
|||
confidence: 'high' as const,
|
||||
is_correction: false,
|
||||
labels: {
|
||||
...entry.labels,
|
||||
document_id: entry.id,
|
||||
},
|
||||
};
|
||||
|
@ -306,4 +305,18 @@ export class ObservabilityAIAssistantService {
|
|||
this.logger.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) {
|
||||
this.addToKnowledgeBase(
|
||||
entries.map((entry) => {
|
||||
return {
|
||||
...entry,
|
||||
labels: {
|
||||
...entry.labels,
|
||||
category: categoryId,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import { INDEX_QUEUED_DOCUMENTS_TASK_ID, INDEX_QUEUED_DOCUMENTS_TASK_TYPE } from
|
|||
import type { KnowledgeBaseEntry } from '../../../common/types';
|
||||
import type { ObservabilityAIAssistantResourceNames } from '../types';
|
||||
import { getAccessQuery } from '../util/get_access_query';
|
||||
import { getCategoryQuery } from '../util/get_category_query';
|
||||
|
||||
interface Dependencies {
|
||||
esClient: ElasticsearchClient;
|
||||
|
@ -25,6 +26,14 @@ interface Dependencies {
|
|||
taskManagerStart: TaskManagerStartContract;
|
||||
}
|
||||
|
||||
export interface RecalledEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
score: number | null;
|
||||
is_correction: boolean;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(error: Error) {
|
||||
return (
|
||||
error instanceof errors.ResponseError &&
|
||||
|
@ -173,36 +182,43 @@ export class KnowledgeBaseService {
|
|||
recall = async ({
|
||||
user,
|
||||
queries,
|
||||
contexts,
|
||||
namespace,
|
||||
}: {
|
||||
queries: string[];
|
||||
contexts?: string[];
|
||||
user: { name: string };
|
||||
namespace: string;
|
||||
}): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
|
||||
}): Promise<{
|
||||
entries: RecalledEntry[];
|
||||
}> => {
|
||||
try {
|
||||
const query = {
|
||||
bool: {
|
||||
should: queries.map((text) => ({
|
||||
text_expansion: {
|
||||
'ml.tokens': {
|
||||
model_text: text,
|
||||
model_id: '.elser_model_1',
|
||||
},
|
||||
} as unknown as QueryDslTextExpansionQuery,
|
||||
})),
|
||||
filter: [
|
||||
...getAccessQuery({
|
||||
user,
|
||||
namespace,
|
||||
}),
|
||||
...getCategoryQuery({ contexts }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.dependencies.esClient.search<
|
||||
Pick<KnowledgeBaseEntry, 'text' | 'id'>
|
||||
Pick<KnowledgeBaseEntry, 'text' | 'is_correction' | 'labels'>
|
||||
>({
|
||||
index: this.dependencies.resources.aliases.kb,
|
||||
query: {
|
||||
bool: {
|
||||
should: queries.map((query) => ({
|
||||
text_expansion: {
|
||||
'ml.tokens': {
|
||||
model_text: query,
|
||||
model_id: '.elser_model_1',
|
||||
},
|
||||
} as unknown as QueryDslTextExpansionQuery,
|
||||
})),
|
||||
filter: [
|
||||
...getAccessQuery({
|
||||
user,
|
||||
namespace,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
size: 5,
|
||||
query,
|
||||
size: 10,
|
||||
_source: {
|
||||
includes: ['text', 'is_correction', 'labels'],
|
||||
},
|
||||
|
@ -211,7 +227,7 @@ export class KnowledgeBaseService {
|
|||
return {
|
||||
entries: response.hits.hits.map((hit) => ({
|
||||
...hit._source!,
|
||||
score: hit._score,
|
||||
score: hit._score!,
|
||||
id: hit._id,
|
||||
})),
|
||||
};
|
||||
|
|
|
@ -6,10 +6,16 @@
|
|||
*/
|
||||
|
||||
import dedent from 'dedent';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import type { ObservabilityAIAssistantService } from '../..';
|
||||
|
||||
export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
|
||||
service.addToKnowledgeBase([
|
||||
export function addLensDocsToKb({
|
||||
service,
|
||||
}: {
|
||||
service: ObservabilityAIAssistantService;
|
||||
logger: Logger;
|
||||
}) {
|
||||
service.addCategoryToKnowledgeBase('lens', [
|
||||
{
|
||||
id: 'lens_formulas_how_it_works',
|
||||
texts: [
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export function getCategoryQuery({ contexts }: { contexts?: string[] }) {
|
||||
const noCategoryFilter = {
|
||||
bool: {
|
||||
must_not: {
|
||||
exists: {
|
||||
field: 'labels.category',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!contexts) {
|
||||
return [noCategoryFilter];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
noCategoryFilter,
|
||||
{
|
||||
terms: {
|
||||
'labels.category': contexts,
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue