[Observability AI Assistant] ES|QL query generation (#166041)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2023-09-25 17:39:34 +02:00 committed by GitHub
parent 4957d87a66
commit 13e5c076d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1137 additions and 132 deletions

View file

@ -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>;

View file

@ -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>

View file

@ -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={

View file

@ -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}

View file

@ -132,6 +132,7 @@ const defaultProps: ComponentProps<typeof Component> = {
onFeedback: () => {},
onRegenerate: () => {},
onStopGenerating: () => {},
onActionClick: async () => {},
};
export const ChatTimeline = Template.bind({});

View file

@ -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}
/>
))
)}

View file

@ -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),
}));
}

View file

@ -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>;

View file

@ -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 ? (

View file

@ -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={

View file

@ -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,
};

View file

@ -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>
);
}

View file

@ -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={() => {}} />,
},

View file

@ -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}>

View file

@ -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$);
});
}
);
}

View file

@ -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 });

View file

@ -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,
}));
}
);
}

View file

@ -437,6 +437,7 @@ describe('useTimeline', () => {
expect(props.chatService.executeFunction).toHaveBeenCalledWith({
name: 'my_function',
args: '{}',
connectorId: 'foo',
messages: [
{
'@timestamp': expect.any(String),

View file

@ -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;
}
},
};
}

View file

@ -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,

View file

@ -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'),
},
};
}

View file

@ -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 };

View file

@ -109,7 +109,7 @@ export class ObservabilityAIAssistantPlugin
taskManager: plugins.taskManager,
});
addLensDocsToKb(service);
addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') });
registerServerRoutes({
core,

View file

@ -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,
}
: {}),
});

View file

@ -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 });
},
});

View file

@ -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,
});
};

View file

@ -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,
},
};
})
);
}
}

View file

@ -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,
})),
};

View file

@ -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: [

View file

@ -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,
},
},
];
}