Observability AI Assistant Service + Client

This commit is contained in:
Dario Gieselaar 2023-07-18 11:12:57 +02:00
parent 26bf6172a0
commit d98b43ac19
22 changed files with 1205 additions and 17 deletions

View file

@ -772,6 +772,7 @@
"@opentelemetry/sdk-metrics-base": "^0.31.0",
"@opentelemetry/semantic-conventions": "^1.4.0",
"@reduxjs/toolkit": "1.7.2",
"@sindresorhus/fnv1a": "^3.0.0",
"@slack/webhook": "^5.0.4",
"@tanstack/react-query": "^4.29.12",
"@tanstack/react-query-devtools": "^4.29.12",
@ -905,7 +906,7 @@
"normalize-path": "^3.0.0",
"object-hash": "^1.3.1",
"object-path-immutable": "^3.1.1",
"openai": "^3.2.1",
"openai": "^3.3.0",
"openpgp": "5.3.0",
"opn": "^5.5.0",
"ora": "^4.0.4",
@ -1550,4 +1551,4 @@
"xmlbuilder": "13.0.2",
"yargs": "^15.4.1"
}
}
}

View file

@ -11,6 +11,8 @@ export { formatRequest } from './src/format_request';
export { parseEndpoint } from './src/parse_endpoint';
export { decodeRequestParams } from './src/decode_request_params';
export { routeValidationObject } from './src/route_validation_object';
export { registerRoutes } from './src/register_routes';
export type {
RouteRepositoryClient,
ReturnOf,

View file

@ -0,0 +1,141 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { errors } from '@elastic/elasticsearch';
import { isBoom } from '@hapi/boom';
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
import type { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import type { CoreSetup } from '@kbn/core-lifecycle-server';
import type { Logger } from '@kbn/logging';
import * as t from 'io-ts';
import { merge, pick } from 'lodash';
import { decodeRequestParams } from './decode_request_params';
import { parseEndpoint } from './parse_endpoint';
import { routeValidationObject } from './route_validation_object';
import type { ServerRoute, ServerRouteCreateOptions } from './typings';
const CLIENT_CLOSED_REQUEST = {
statusCode: 499,
body: {
message: 'Client closed request',
},
};
export function registerRoutes({
core,
repository,
logger,
dependencies,
}: {
core: CoreSetup;
repository: Record<string, ServerRoute<string, any, any, any, ServerRouteCreateOptions>>;
logger: Logger;
dependencies: Record<string, any>;
}) {
const routes = Object.values(repository);
const router = core.http.createRouter();
routes.forEach((route) => {
const { params, endpoint, options, handler } = route;
const { method, pathname, version } = parseEndpoint(endpoint);
const wrappedHandler = async (
context: RequestHandlerContext,
request: KibanaRequest,
response: KibanaResponseFactory
) => {
try {
const runtimeType = params || t.strict({});
const validatedParams = decodeRequestParams(
pick(request, 'params', 'body', 'query'),
runtimeType
);
const { aborted, data } = await Promise.race([
handler({
request,
context,
params: validatedParams,
...dependencies,
}).then((value) => {
return {
aborted: false,
data: value,
};
}),
request.events.aborted$.toPromise().then(() => {
return {
aborted: true,
data: undefined,
};
}),
]);
if (aborted) {
return response.custom(CLIENT_CLOSED_REQUEST);
}
const body = data || {};
return response.ok({ body });
} catch (error) {
logger.error(error);
const opts = {
statusCode: 500,
body: {
message: error.message,
attributes: {
data: {},
},
},
};
if (error instanceof errors.RequestAbortedError) {
return response.custom(merge(opts, CLIENT_CLOSED_REQUEST));
}
if (isBoom(error)) {
opts.statusCode = error.output.statusCode;
opts.body.attributes.data = error?.data;
}
return response.custom(opts);
}
};
logger.debug(`Registering endpoint ${endpoint}`);
if (!version) {
router[method](
{
path: pathname,
options,
validate: routeValidationObject,
},
wrappedHandler
);
} else {
router.versioned[method]({
path: pathname,
access: pathname.startsWith('/internal/') ? 'internal' : 'public',
options,
}).addVersion(
{
version,
validate: {
request: routeValidationObject,
},
},
wrappedHandler
);
}
});
}

View file

@ -13,7 +13,11 @@
],
"kbn_references": [
"@kbn/config-schema",
"@kbn/io-ts-utils"
"@kbn/io-ts-utils",
"@kbn/core-http-request-handler-context-server",
"@kbn/core-http-server",
"@kbn/core-lifecycle-server",
"@kbn/logging"
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,56 @@
/*
* 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 { Serializable } from '@kbn/utility-types';
export enum MessageRole {
System = 'system',
Assistant = 'assistant',
User = 'user',
Function = 'function',
Event = 'event',
Elastic = 'elastic',
}
export interface Message {
'@timestamp': string;
message: {
content?: string;
name?: string;
role: MessageRole;
function_call?: {
name: string;
args?: Serializable;
trigger: MessageRole.Assistant | MessageRole.User | MessageRole.Elastic;
};
data?: Serializable;
};
}
export interface Conversation {
'@timestamp': string;
user: {
id?: string;
name: string;
};
conversation: {
id: string;
title: string;
last_updated: string;
};
messages: Message[];
labels: Record<string, string>;
numeric_labels: Record<string, number>;
namespace: string;
}
export type ConversationRequestBase = Omit<Conversation, 'user' | 'conversation' | 'namespace'> & {
conversation: { title: string };
};
export type ConversationCreateRequest = ConversationRequestBase;
export type ConversationUpdateRequest = ConversationRequestBase & { conversation: { id: string } };

View file

@ -3,14 +3,17 @@
"id": "@kbn/observability-ai-assistant-plugin",
"owner": "@elastic/apm-ui",
"plugin": {
"id": "observabilityAiAssistant",
"id": "observabilityAIAssistant",
"server": true,
"browser": true,
"configPath": [
"xpack",
"observabilityAiAssistant"
"observabilityAIAssistant"
],
"requiredPlugins": [
"triggersActionsUi",
"actions"
],
"requiredPlugins": [],
"optionalPlugins": [],
"extraPublicDirs": []
}

View file

@ -4,12 +4,20 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '@kbn/triggers-actions-ui-plugin/public';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ObservabilityAIAssistantPluginStart {}
export interface ObservabilityAIAssistantPluginSetup {}
export interface ObservabilityAIAssistantPluginSetupDependencies {}
export interface ObservabilityAIAssistantPluginStartDependencies {}
export interface ObservabilityAIAssistantPluginSetupDependencies {
triggersActions: TriggersAndActionsUIPublicPluginSetup;
}
export interface ObservabilityAIAssistantPluginStartDependencies {
triggersActions: TriggersAndActionsUIPublicPluginStart;
}
export interface ConfigSchema {}

View file

@ -12,7 +12,11 @@ import type {
Plugin,
PluginInitializerContext,
} from '@kbn/core/server';
import { mapValues } from 'lodash';
import type { ObservabilityAIAssistantConfig } from './config';
import { registerServerRoutes } from './routes/register_routes';
import { ObservabilityAIAssistantRouteHandlerResources } from './routes/types';
import { ObservabilityAIAssistantService } from './service';
import {
ObservabilityAIAssistantPluginSetup,
ObservabilityAIAssistantPluginStart,
@ -35,17 +39,44 @@ export class ObservabilityAIAssistantPlugin
}
public start(
core: CoreStart,
plugins: ObservabilityAIAssistantPluginSetupDependencies
plugins: ObservabilityAIAssistantPluginStartDependencies
): ObservabilityAIAssistantPluginStart {
return {};
}
public setup(
core: CoreSetup<
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies,
ObservabilityAIAssistantPluginStart
>,
plugins: ObservabilityAIAssistantPluginSetupDependencies
): ObservabilityAIAssistantPluginSetup {
const routeHandlerPlugins = mapValues(plugins, (value, key) => {
return {
setup: value,
start: () =>
core.getStartServices().then((services) => {
const [, pluginsStartContracts] = services;
return pluginsStartContracts[
key as keyof ObservabilityAIAssistantPluginStartDependencies
];
}),
};
}) as ObservabilityAIAssistantRouteHandlerResources['plugins'];
const service = new ObservabilityAIAssistantService({
logger: this.logger.get('service'),
core,
});
registerServerRoutes({
core,
logger: this.logger,
dependencies: {
plugins: routeHandlerPlugins,
service,
},
});
return {};
}
}

View file

@ -0,0 +1,42 @@
/*
* 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 * as t from 'io-ts';
import type { IncomingMessage } from 'http';
import { notImplemented } from '@hapi/boom';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { conversationRt } from '../runtime_types';
const chatRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/chat',
options: {
tags: ['access:ai_assistant'],
},
params: t.type({
body: t.type({
conversation: conversationRt,
connectorId: t.string,
}),
}),
handler: async (resources): Promise<IncomingMessage> => {
const { request, params, service } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.chat({
messages: params.body.conversation.messages,
connectorId: params.body.connectorId,
});
},
});
export const chatRoutes = {
...chatRoute,
};

View file

@ -0,0 +1,140 @@
/*
* 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 { notImplemented } from '@hapi/boom';
import * as t from 'io-ts';
import { merge } from 'lodash';
import { Conversation } from '../../../common/types';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { conversationCreateRt, conversationUpdateRt } from '../runtime_types';
const getConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: t.type({
path: t.type({
conversationId: t.string,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<void> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.get(params.path.conversationId);
},
});
const findConversationsRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
params: t.partial({
body: t.partial({
query: t.string,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<{ conversations: Conversation[] }> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.find({ query: params?.body?.query });
},
});
const createConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'PUT /internal/observability_ai_assistant/conversation',
params: t.type({
body: t.type({
conversation: conversationCreateRt,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<Conversation> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.create(params.body.conversation);
},
});
const updateConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/conversation/{conversationId}',
params: t.type({
path: t.type({
conversationId: t.string,
}),
body: t.type({
conversation: conversationUpdateRt,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<Conversation> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.create(
merge({}, params.body.conversation, { conversation: { id: params.path.conversationId } })
);
},
});
const deleteConversationRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: t.type({
path: t.type({
conversationId: t.string,
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<void> => {
const { service, request, params } = resources;
const client = await service.getClient({ request });
if (!client) {
throw notImplemented();
}
return client.delete(params.path.conversationId);
},
});
export const conversationRoutes = {
...getConversationRoute,
...findConversationsRoute,
...createConversationRoute,
...updateConversationRoute,
...deleteConversationRoute,
};

View file

@ -0,0 +1,16 @@
/*
* 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 { createServerRouteFactory } from '@kbn/server-route-repository';
import type {
ObservabilityAIAssistantRouteCreateOptions,
ObservabilityAIAssistantRouteHandlerResources,
} from './types';
export const createObservabilityAIAssistantServerRoute = createServerRouteFactory<
ObservabilityAIAssistantRouteHandlerResources,
ObservabilityAIAssistantRouteCreateOptions
>();

View file

@ -0,0 +1,20 @@
/*
* 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 { chatRoutes } from './chat/route';
import { conversationRoutes } from './conversations/route';
export function getGlobalObservabilityAIAssistantServerRouteRepository() {
return {
...chatRoutes,
...conversationRoutes,
};
}
export type ObservabilityAIAssistantServerRouteRepository = ReturnType<
typeof getGlobalObservabilityAIAssistantServerRouteRepository
>;

View file

@ -0,0 +1,31 @@
/*
* 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 type { CoreSetup } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { registerRoutes } from '@kbn/server-route-repository';
import { getGlobalObservabilityAIAssistantServerRouteRepository } from './get_global_observability_ai_assistant_route_repository';
import type { ObservabilityAIAssistantRouteHandlerResources } from './types';
export function registerServerRoutes({
core,
logger,
dependencies,
}: {
core: CoreSetup;
logger: Logger;
dependencies: Omit<
ObservabilityAIAssistantRouteHandlerResources,
'request' | 'context' | 'logger' | 'params'
>;
}) {
registerRoutes({
core,
logger,
repository: getGlobalObservabilityAIAssistantServerRouteRepository(),
dependencies,
});
}

View file

@ -0,0 +1,92 @@
/*
* 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 * as t from 'io-ts';
import {
Conversation,
ConversationCreateRequest,
ConversationRequestBase,
ConversationUpdateRequest,
Message,
MessageRole,
} from '../../common/types';
const serializeableRt = t.any;
export const messageRt: t.Type<Message> = t.type({
'@timestamp': t.string,
message: t.intersection([
t.type({
role: t.union([
t.literal(MessageRole.System),
t.literal(MessageRole.Assistant),
t.literal(MessageRole.Event),
t.literal(MessageRole.Function),
t.literal(MessageRole.User),
t.literal(MessageRole.Elastic),
]),
}),
t.partial({
content: t.string,
name: t.string,
function_call: t.intersection([
t.type({
name: t.string,
trigger: t.union([
t.literal(MessageRole.Assistant),
t.literal(MessageRole.User),
t.literal(MessageRole.Elastic),
]),
}),
t.partial({
args: serializeableRt,
data: serializeableRt,
}),
]),
}),
]),
});
export const baseConversationRt: t.Type<ConversationRequestBase> = t.type({
'@timestamp': t.string,
conversation: t.type({
title: t.string,
}),
messages: t.array(messageRt),
labels: t.record(t.string, t.string),
numeric_labels: t.record(t.string, t.number),
});
export const conversationCreateRt: t.Type<ConversationCreateRequest> = t.intersection([
baseConversationRt,
t.type({
conversation: t.type({
title: t.string,
}),
}),
]);
export const conversationUpdateRt: t.Type<ConversationUpdateRequest> = t.intersection([
baseConversationRt,
t.type({
conversation: t.type({
id: t.string,
title: t.string,
}),
}),
]);
export const conversationRt: t.Type<Conversation> = t.intersection([
baseConversationRt,
t.type({
user: t.intersection([t.type({ name: t.string }), t.partial({ id: t.string })]),
namespace: t.string,
conversation: t.type({
id: t.string,
last_updated: t.string,
}),
}),
]);

View file

@ -0,0 +1,36 @@
/*
* 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 type { Logger } from '@kbn/logging';
import type { KibanaRequest, RequestHandlerContext } from '@kbn/core/server';
import type {
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies,
} from '../types';
import type { IObservabilityAIAssistantService } from '../service/types';
export interface ObservabilityAIAssistantRouteHandlerResources {
request: KibanaRequest;
context: RequestHandlerContext;
logger: Logger;
service: IObservabilityAIAssistantService;
plugins: {
[key in keyof ObservabilityAIAssistantPluginSetupDependencies]: {
setup: Required<ObservabilityAIAssistantPluginSetupDependencies>[key];
};
} & {
[key in keyof ObservabilityAIAssistantPluginStartDependencies]: {
start: () => Promise<Required<ObservabilityAIAssistantPluginStartDependencies>[key]>;
};
};
}
export interface ObservabilityAIAssistantRouteCreateOptions {
options: {
tags: Array<'access:ai_assistant'>;
};
}

View file

@ -0,0 +1,217 @@
/*
* 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 { v4 } from 'uuid';
import type { ChatCompletionRequestMessage, CreateChatCompletionRequest } from 'openai';
import type { IncomingMessage } from 'http';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import type { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { internal, notFound } from '@hapi/boom';
import { compact, merge, omit } from 'lodash';
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import {
type Conversation,
type ConversationCreateRequest,
type ConversationUpdateRequest,
type Message,
MessageRole,
} from '../../../common/types';
import type {
IObservabilityAIAssistantClient,
ObservabilityAIAssistantResourceNames,
} from '../types';
export class ObservabilityAIAssistantClient implements IObservabilityAIAssistantClient {
constructor(
private readonly dependencies: {
actionsClient: PublicMethodsOf<ActionsClient>;
namespace: string;
esClient: ElasticsearchClient;
resources: ObservabilityAIAssistantResourceNames;
logger: Logger;
user: {
id?: string;
name: string;
};
}
) {}
private getAccessQuery() {
return [
{
bool: {
filter: [
{
term: {
'user.name': this.dependencies.user.name,
},
},
{
term: {
namespace: this.dependencies.namespace,
},
},
],
},
},
];
}
private getConversationWithMetaFields = async (
conversationId: string
): Promise<SearchHit<Conversation> | undefined> => {
const response = await this.dependencies.esClient.search<Conversation>({
index: this.dependencies.resources.aliases.conversations,
query: {
bool: {
filter: [...this.getAccessQuery(), { term: { 'conversation.id': conversationId } }],
},
},
size: 1,
terminate_after: 1,
});
return response.hits.hits[0];
};
private getConversationUpdateValues = (now: string) => {
return {
conversation: {
last_updated: now,
},
user: this.dependencies.user,
namespace: this.dependencies.namespace,
};
};
get = async (conversationId: string): Promise<Conversation | undefined> => {
return (await this.getConversationWithMetaFields(conversationId))?._source;
};
delete = async (conversationId: string): Promise<void> => {
const conversation = await this.getConversationWithMetaFields(conversationId);
if (!conversation) {
throw notFound();
}
await this.dependencies.esClient.delete({
id: conversation._id,
index: conversation._index,
});
};
chat = async ({
messages,
connectorId,
}: {
messages: Message[];
connectorId: string;
}): Promise<IncomingMessage> => {
const messagesForOpenAI: ChatCompletionRequestMessage[] = compact(
messages.map((message) => {
if (message.message.role === MessageRole.Event) {
return undefined;
}
const role =
message.message.role === MessageRole.Elastic ? MessageRole.User : message.message.role;
return {
role,
content: message.message.content,
function_call: omit(message.message.function_call, 'trigger'),
name: message.message.name,
};
})
);
const request: CreateChatCompletionRequest = {
model: 'gpt-4',
messages: messagesForOpenAI,
stream: true,
};
const executeResult = await this.dependencies.actionsClient.execute({
actionId: connectorId,
params: {
subAction: 'stream',
subActionParams: JSON.stringify(request),
},
});
if (executeResult.status === 'error') {
throw internal(`${executeResult?.message} - ${executeResult?.serviceMessage}`);
}
return executeResult.data as IncomingMessage;
};
find = async (options?: { query?: string }): Promise<{ conversations: Conversation[] }> => {
const response = await this.dependencies.esClient.search<Conversation>({
index: this.dependencies.resources.aliases.conversations,
allow_no_indices: true,
query: {
bool: {
filter: [...this.getAccessQuery()],
},
},
sort: {
'@timestamp': 'desc',
},
size: 100,
});
return {
conversations: response.hits.hits.map((hit) => hit._source!),
};
};
update = async (conversation: ConversationUpdateRequest): Promise<Conversation> => {
const document = await this.getConversationWithMetaFields(conversation.conversation.id);
if (!document) {
throw notFound();
}
const updatedConversation: Conversation = merge(
{},
conversation,
this.getConversationUpdateValues(new Date().toISOString())
);
await this.dependencies.esClient.update({
id: document._id,
index: document._index,
doc: updatedConversation,
});
return updatedConversation;
};
create = async (conversation: ConversationCreateRequest): Promise<Conversation> => {
const now = new Date().toISOString();
const createdConversation: Conversation = merge(
{},
conversation,
{
'@timestamp': now,
conversation: { id: v4() },
},
this.getConversationUpdateValues(now)
);
await this.dependencies.esClient.index({
index: this.dependencies.resources.aliases.conversations,
document: createdConversation,
});
return createdConversation;
};
}

View file

@ -0,0 +1,91 @@
/*
* 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 { ClusterComponentTemplate } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
const keyword = {
type: 'keyword' as const,
ignore_above: 1024,
};
const text = {
type: 'text' as const,
};
const date = {
type: 'date' as const,
};
const dynamic = {
type: 'object' as const,
dynamic: true,
};
export const conversationComponentTemplate: ClusterComponentTemplate['component_template']['template'] =
{
mappings: {
dynamic_templates: [
{
numeric_labels: {
path_match: 'numeric_labels.*',
mapping: {
scaling_factor: 1000000,
type: 'scaled_float',
},
},
},
],
dynamic: false,
properties: {
'@timestamp': date,
labels: dynamic,
numeric_labels: dynamic,
user: {
properties: {
id: keyword,
name: keyword,
},
},
conversation: {
properties: {
id: keyword,
title: text,
last_updated: date,
},
},
namespace: keyword,
messages: {
type: 'object',
properties: {
'@timestamp': date,
message: {
type: 'object',
properties: {
content: text,
role: keyword,
data: {
type: 'object',
enabled: false,
},
function_call: {
type: 'object',
properties: {
name: keyword,
args: {
type: 'object',
enabled: false,
},
trigger: keyword,
},
},
},
},
},
},
},
},
};

View file

@ -0,0 +1,191 @@
/*
* 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 stringify from 'json-stable-stringify';
import type { CoreSetup, CoreStart, KibanaRequest, Logger } from '@kbn/core/server';
import { once } from 'lodash';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import * as Boom from '@hapi/boom';
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server/plugin';
import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common';
import type { ObservabilityAIAssistantResourceNames } from './types';
import { conversationComponentTemplate } from './conversation_component_template';
import type { IObservabilityAIAssistantClient, IObservabilityAIAssistantService } from './types';
import { ObservabilityAIAssistantClient } from './client';
function getResourceName(resource: string) {
return `.kibana-observability-ai-assistant-${resource}`;
}
export class ObservabilityAIAssistantService implements IObservabilityAIAssistantService {
private readonly core: CoreSetup;
private readonly logger: Logger;
private readonly resourceNames: ObservabilityAIAssistantResourceNames = {
componentTemplate: {
conversations: getResourceName('component-template-conversations'),
},
aliases: {
conversations: getResourceName('conversations'),
},
indexPatterns: {
conversations: getResourceName('conversations*'),
},
indexTemplate: {
conversations: getResourceName('index-template-conversations'),
},
ilmPolicy: {
conversations: getResourceName('ilm-policy-conversations'),
},
};
constructor({ logger, core }: { logger: Logger; core: CoreSetup }) {
this.core = core;
this.logger = logger;
this.init();
}
init = once(async () => {
try {
const [coreStart] = await this.core.getStartServices();
const esClient = coreStart.elasticsearch.client.asInternalUser;
const fnv1a = await import('@sindresorhus/fnv1a');
const versionHash = fnv1a.default(stringify(conversationComponentTemplate), { size: 64 });
await esClient.cluster.putComponentTemplate({
create: false,
name: this.resourceNames.componentTemplate.conversations,
template: {
...conversationComponentTemplate,
mappings: {
_meta: {
version: versionHash,
},
...conversationComponentTemplate.mappings,
},
},
});
await esClient.ilm.putLifecycle({
name: this.resourceNames.ilmPolicy.conversations,
policy: {
phases: {
hot: {
min_age: '0s',
actions: {
rollover: {
max_age: '90d',
max_primary_shard_size: '50gb',
},
},
},
},
},
});
await esClient.indices.putIndexTemplate({
name: this.resourceNames.indexTemplate.conversations,
composed_of: [this.resourceNames.componentTemplate.conversations],
create: false,
index_patterns: [this.resourceNames.indexPatterns.conversations],
template: {
settings: {
number_of_shards: 1,
auto_expand_replicas: '0-1',
refresh_interval: '1s',
},
},
});
const aliasName = this.resourceNames.aliases.conversations;
const aliasExists = await esClient.indices.existsAlias({
name: aliasName,
});
if (!aliasExists) {
const firstIndexName = `${this.resourceNames.aliases.conversations}-000001`;
await esClient.indices.create({
index: firstIndexName,
aliases: {
[aliasName]: {
is_write_index: true,
},
},
});
}
const indicesForAlias = await esClient.indices.get({
index: aliasName,
});
const writeIndexName = Object.keys(indicesForAlias).find((indexName) => {
if (indicesForAlias[indexName]!.aliases?.[aliasName].is_write_index) {
return true;
}
return false;
});
if (!writeIndexName) {
throw new Error(`Expected write index for ${aliasName}, but none was found`);
}
const writeIndex = indicesForAlias[writeIndexName];
if (writeIndex.mappings?._meta?.version !== versionHash) {
await esClient.indices.rollover({
alias: aliasName,
conditions: {
min_docs: 0,
},
});
}
} catch (error) {
this.logger.error(`Failed to initialize CoPilotService: ${error.message}`);
this.logger.debug(error);
}
});
async getClient({
request,
}: {
request: KibanaRequest;
}): Promise<IObservabilityAIAssistantClient> {
const [_, [coreStart, { security, actions }]] = await Promise.all([
this.init(),
this.core.getStartServices() as Promise<
[CoreStart, { security: SecurityPluginStart; actions: ActionsPluginStart }, unknown]
>,
]);
const user = security.authc.getCurrentUser(request);
if (!user) {
throw Boom.forbidden(`User not found for current request`);
}
const basePath = coreStart.http.basePath.get(request);
const { spaceId } = getSpaceIdFromPath(basePath, coreStart.http.basePath.serverBasePath);
return new ObservabilityAIAssistantClient({
actionsClient: await actions.getActionsClientWithRequest(request),
namespace: spaceId,
esClient: coreStart.elasticsearch.client.asInternalUser,
resources: this.resourceNames,
logger: this.logger,
user: {
id: user.profile_uid,
name: user.username,
},
});
}
}

View file

@ -0,0 +1,48 @@
/*
* 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 { IncomingMessage } from 'http';
import { KibanaRequest } from '@kbn/core/server';
import {
Conversation,
ConversationCreateRequest,
ConversationUpdateRequest,
Message,
} from '../../common/types';
export interface IObservabilityAIAssistantClient {
chat: (options: { messages: Message[]; connectorId: string }) => Promise<IncomingMessage>;
get: (conversationId: string) => void;
find: (options?: { query?: string }) => Promise<{ conversations: Conversation[] }>;
create: (conversation: ConversationCreateRequest) => Promise<Conversation>;
update: (conversation: ConversationUpdateRequest) => Promise<Conversation>;
delete: (conversationId: string) => Promise<void>;
}
export interface IObservabilityAIAssistantService {
getClient: (options: {
request: KibanaRequest;
}) => Promise<IObservabilityAIAssistantClient | undefined>;
}
export interface ObservabilityAIAssistantResourceNames {
componentTemplate: {
conversations: string;
};
indexTemplate: {
conversations: string;
};
ilmPolicy: {
conversations: string;
};
aliases: {
conversations: string;
};
indexPatterns: {
conversations: string;
};
}

View file

@ -5,8 +5,14 @@
* 2.0.
*/
import { PluginSetupContract, PluginStartContract } from '@kbn/actions-plugin/server';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ObservabilityAIAssistantPluginStart {}
export interface ObservabilityAIAssistantPluginSetup {}
export interface ObservabilityAIAssistantPluginSetupDependencies {}
export interface ObservabilityAIAssistantPluginStartDependencies {}
export interface ObservabilityAIAssistantPluginSetupDependencies {
actions: PluginSetupContract;
}
export interface ObservabilityAIAssistantPluginStartDependencies {
actions: PluginStartContract;
}

View file

@ -12,7 +12,14 @@
"server/**/*"
],
"kbn_references": [
"@kbn/core"
"@kbn/core",
"@kbn/actions-plugin",
"@kbn/utility-types",
"@kbn/server-route-repository",
"@kbn/logging",
"@kbn/triggers-actions-ui-plugin",
"@kbn/config-schema",
"@kbn/security-plugin"
],
"exclude": [
"target/**/*",

View file

@ -6847,6 +6847,11 @@
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
"@sindresorhus/fnv1a@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/fnv1a/-/fnv1a-3.0.0.tgz#e8ce2e7c7738ec8c354867d38e3bfcde622b87ca"
integrity sha512-M6pmbdZqAryzjZ4ELAzrdCMoMZk5lH/fshKrapfSeXdf2W+GDqZvPmfXaNTZp43//FVbSwkTPwpEMnehSyskkQ==
"@sindresorhus/is@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@ -22418,10 +22423,10 @@ open@^8.0.9, open@^8.4.0:
is-docker "^2.1.1"
is-wsl "^2.2.0"
openai@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/openai/-/openai-3.2.1.tgz#1fa35bdf979cbde8453b43f2dd3a7d401ee40866"
integrity sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==
openai@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/openai/-/openai-3.3.0.tgz#a6408016ad0945738e1febf43f2fccca83a3f532"
integrity sha512-uqxI/Au+aPRnsaQRe8CojU0eCR7I0mBiKjD3sNMzY6DaC1ZVrc85u98mtJW6voDug8fgGN+DIZmTDxTthxb7dQ==
dependencies:
axios "^0.26.0"
form-data "^4.0.0"