diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/common/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/common/types.ts index 51ae37b39d90f..210eb08b31e1a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/common/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/common/types.ts @@ -82,8 +82,8 @@ export type ConversationUpdateRequest = ConversationRequestBase & { export interface KnowledgeBaseEntry { '@timestamp': string; id: string; + title?: string; text: string; - doc_id: string; confidence: 'low' | 'medium' | 'high'; is_correction: boolean; type?: 'user_instruction' | 'contextual'; @@ -96,12 +96,12 @@ export interface KnowledgeBaseEntry { } export interface Instruction { - doc_id: string; + id: string; text: string; } export interface AdHocInstruction { - doc_id?: string; + id?: string; text: string; instruction_type: 'user_instruction' | 'application_instruction'; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/short_id_table.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/short_id_table.test.ts index 784cf67530652..03d1cb177826e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/short_id_table.test.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/short_id_table.test.ts @@ -7,6 +7,16 @@ import { ShortIdTable } from './short_id_table'; describe('shortIdTable', () => { + it('generates a short id from a uuid', () => { + const table = new ShortIdTable(); + + const uuid = 'd877f65c-4036-42c4-b105-19e2f1a1c045'; + const shortId = table.take(uuid); + + expect(shortId.length).toBe(4); + expect(table.lookup(shortId)).toBe(uuid); + }); + it('generates at least 10k unique ids consistently', () => { const ids = new Set(); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/analytics/recall_ranking.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/analytics/recall_ranking.ts index 4c82f79fcba8d..4371310811edf 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/analytics/recall_ranking.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/analytics/recall_ranking.ts @@ -52,9 +52,9 @@ const schema: RootSchema = { }, }; -export const RecallRankingEventType = 'observability_ai_assistant_recall_ranking'; +export const recallRankingEventType = 'observability_ai_assistant_recall_ranking'; export const recallRankingEvent: EventTypeOpts = { - eventType: RecallRankingEventType, + eventType: recallRankingEventType, schema, }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/summarize.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/summarize.ts index 8865861d81f45..1f4afdbdd56bb 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/summarize.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/summarize.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KnowledgeBaseType } from '../../common/types'; +import { v4 } from 'uuid'; import type { FunctionRegistrationParameters } from '.'; import { KnowledgeBaseEntryRole } from '../../common'; @@ -14,6 +14,7 @@ export const SUMMARIZE_FUNCTION_NAME = 'summarize'; export function registerSummarizationFunction({ client, functions, + resources, }: FunctionRegistrationParameters) { functions.registerFunction( { @@ -28,10 +29,10 @@ export function registerSummarizationFunction({ parameters: { type: 'object', properties: { - id: { + title: { type: 'string', description: - 'An id for the document. This should be a short human-readable keyword field with only alphabetic characters and underscores, that allow you to update it later.', + 'A human readable title that can be used to identify the document later. This should be no longer than 255 characters', }, text: { type: 'string', @@ -54,7 +55,7 @@ export function registerSummarizationFunction({ }, }, required: [ - 'id' as const, + 'title' as const, 'text' as const, 'is_correction' as const, 'confidence' as const, @@ -62,21 +63,23 @@ export function registerSummarizationFunction({ ], }, }, - ( - { arguments: { id, text, is_correction: isCorrection, confidence, public: isPublic } }, + async ( + { arguments: { title, text, is_correction: isCorrection, confidence, public: isPublic } }, signal ) => { + const id = v4(); + resources.logger.debug(`Creating new knowledge base entry with id: ${id}`); + return client .addKnowledgeBaseEntry({ entry: { - doc_id: id, - role: KnowledgeBaseEntryRole.AssistantSummarization, id, + title, text, - is_correction: isCorrection, - type: KnowledgeBaseType.Contextual, - confidence, public: isPublic, + role: KnowledgeBaseEntryRole.AssistantSummarization, + confidence, + is_correction: isCorrection, labels: {}, }, // signal, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts index 8bc88cca10b01..a6fe57cb58adc 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts @@ -42,7 +42,7 @@ const chatCompleteBaseRt = t.type({ ]), instructions: t.array( t.intersection([ - t.partial({ doc_id: t.string }), + t.partial({ id: t.string }), t.type({ text: t.string, instruction_type: t.union([ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/functions/route.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/functions/route.ts index c402a0506736f..1571487765c09 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/functions/route.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/functions/route.ts @@ -7,6 +7,7 @@ import { notImplemented } from '@hapi/boom'; import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; +import { v4 } from 'uuid'; import { FunctionDefinition } from '../../../common/functions/types'; import { KnowledgeBaseEntryRole } from '../../../common/types'; import type { RecalledEntry } from '../../service/knowledge_base_service'; @@ -114,7 +115,8 @@ const functionRecallRoute = createObservabilityAIAssistantServerRoute({ throw notImplemented(); } - return client.recall({ queries, categories }); + const entries = await client.recall({ queries, categories }); + return { entries }; }, }); @@ -122,11 +124,10 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/functions/summarize', params: t.type({ body: t.type({ - id: t.string, + title: t.string, text: nonEmptyStringRt, confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]), is_correction: toBooleanRt, - type: t.union([t.literal('user_instruction'), t.literal('contextual')]), public: toBooleanRt, labels: t.record(t.string, t.string), }), @@ -142,10 +143,9 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ } const { + title, confidence, - id, is_correction: isCorrection, - type, text, public: isPublic, labels, @@ -153,11 +153,10 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ return client.addKnowledgeBaseEntry({ entry: { + title, confidence, - id, - doc_id: id, + id: v4(), is_correction: isCorrection, - type, text, public: isPublic, labels, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/knowledge_base/route.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/knowledge_base/route.ts index 1eb1650545781..0f1852c0e396c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/knowledge_base/route.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/knowledge_base/route.ts @@ -9,16 +9,12 @@ import type { MlDeploymentAllocationState, MlDeploymentState, } from '@elastic/elasticsearch/lib/api/types'; +import pLimit from 'p-limit'; import { notImplemented } from '@hapi/boom'; import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route'; -import { - Instruction, - KnowledgeBaseEntry, - KnowledgeBaseEntryRole, - KnowledgeBaseType, -} from '../../../common/types'; +import { Instruction, KnowledgeBaseEntry, KnowledgeBaseEntryRole } from '../../../common/types'; const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({ endpoint: 'GET /internal/observability_ai_assistant/kb/status', @@ -108,18 +104,8 @@ const saveKnowledgeBaseUserInstruction = createObservabilityAIAssistantServerRou } const { id, text, public: isPublic } = resources.params.body; - return client.addKnowledgeBaseEntry({ - entry: { - id, - doc_id: id, - text, - public: isPublic, - confidence: 'high', - is_correction: false, - type: KnowledgeBaseType.UserInstruction, - labels: {}, - role: KnowledgeBaseEntryRole.UserEntry, - }, + return client.addUserInstruction({ + entry: { id, text, public: isPublic }, }); }, }); @@ -153,26 +139,29 @@ const getKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ }, }); +const knowledgeBaseEntryRt = t.intersection([ + t.type({ + id: t.string, + title: t.string, + text: nonEmptyStringRt, + }), + t.partial({ + confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]), + is_correction: toBooleanRt, + public: toBooleanRt, + labels: t.record(t.string, t.string), + role: t.union([ + t.literal(KnowledgeBaseEntryRole.AssistantSummarization), + t.literal(KnowledgeBaseEntryRole.UserEntry), + t.literal(KnowledgeBaseEntryRole.Elastic), + ]), + }), +]); + const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/kb/entries/save', params: t.type({ - body: t.intersection([ - t.type({ - id: t.string, - text: nonEmptyStringRt, - }), - t.partial({ - confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]), - is_correction: toBooleanRt, - public: toBooleanRt, - labels: t.record(t.string, t.string), - role: t.union([ - t.literal('assistant_summarization'), - t.literal('user_entry'), - t.literal('elastic'), - ]), - }), - ]), + body: knowledgeBaseEntryRt, }), options: { tags: ['access:ai_assistant'], @@ -184,27 +173,15 @@ const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({ throw notImplemented(); } - const { - id, - text, - public: isPublic, - confidence, - is_correction: isCorrection, - labels, - role, - } = resources.params.body; - + const entry = resources.params.body; return client.addKnowledgeBaseEntry({ entry: { - id, - text, - doc_id: id, - confidence: confidence ?? 'high', - is_correction: isCorrection ?? false, - type: 'contextual', - public: isPublic ?? true, - labels: labels ?? {}, - role: (role as KnowledgeBaseEntryRole) ?? KnowledgeBaseEntryRole.UserEntry, + confidence: 'high', + is_correction: false, + public: true, + labels: {}, + role: KnowledgeBaseEntryRole.UserEntry, + ...entry, }, }); }, @@ -235,12 +212,7 @@ const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import', params: t.type({ body: t.type({ - entries: t.array( - t.type({ - id: t.string, - text: nonEmptyStringRt, - }) - ), + entries: t.array(knowledgeBaseEntryRt), }), }), options: { @@ -253,18 +225,29 @@ const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ throw notImplemented(); } - const entries = resources.params.body.entries.map((entry) => ({ - doc_id: entry.id, - confidence: 'high' as KnowledgeBaseEntry['confidence'], - is_correction: false, - type: 'contextual' as const, - public: true, - labels: {}, - role: KnowledgeBaseEntryRole.UserEntry, - ...entry, - })); - - return await client.importKnowledgeBaseEntries({ entries }); + const status = await client.getKnowledgeBaseStatus(); + if (!status.ready) { + throw new Error('Knowledge base is not ready'); + } + + const limiter = pLimit(5); + + const promises = resources.params.body.entries.map(async (entry) => { + return limiter(async () => { + return client.addKnowledgeBaseEntry({ + entry: { + confidence: 'high', + is_correction: false, + public: true, + labels: {}, + role: KnowledgeBaseEntryRole.UserEntry, + ...entry, + }, + }); + }); + }); + + await Promise.all(promises); }, }); @@ -273,8 +256,8 @@ export const knowledgeBaseRoutes = { ...getKnowledgeBaseStatus, ...getKnowledgeBaseEntries, ...saveKnowledgeBaseUserInstruction, - ...getKnowledgeBaseUserInstructions, ...importKnowledgeBaseEntries, + ...getKnowledgeBaseUserInstructions, ...saveKnowledgeBaseEntry, ...deleteKnowledgeBaseEntry, }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.ts index 162220ec7a7f1..048bbd2d362c2 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.ts @@ -47,21 +47,19 @@ import { } from '../../../common/conversation_complete'; import { CompatibleJSONSchema } from '../../../common/functions/types'; import { - AdHocInstruction, + type AdHocInstruction, type Conversation, type ConversationCreateRequest, type ConversationUpdateRequest, type KnowledgeBaseEntry, type Message, + KnowledgeBaseType, + KnowledgeBaseEntryRole, } from '../../../common/types'; import { withoutTokenCountEvents } from '../../../common/utils/without_token_count_events'; import { CONTEXT_FUNCTION_NAME } from '../../functions/context'; import type { ChatFunctionClient } from '../chat_function_client'; -import { - KnowledgeBaseEntryOperationType, - KnowledgeBaseService, - RecalledEntry, -} from '../knowledge_base_service'; +import { KnowledgeBaseService, RecalledEntry } from '../knowledge_base_service'; import { getAccessQuery } from '../util/get_access_query'; import { getSystemMessageFromInstructions } from '../util/get_system_message_from_instructions'; import { replaceSystemMessage } from '../util/replace_system_message'; @@ -709,7 +707,7 @@ export class ObservabilityAIAssistantClient { }: { queries: Array<{ text: string; boost?: number }>; categories?: string[]; - }): Promise<{ entries: RecalledEntry[] }> => { + }): Promise => { return ( this.dependencies.knowledgeBaseService?.recall({ namespace: this.dependencies.namespace, @@ -718,7 +716,7 @@ export class ObservabilityAIAssistantClient { categories, esClient: this.dependencies.esClient, uiSettingsClient: this.dependencies.uiSettingsClient, - }) || { entries: [] } + }) || [] ); }; @@ -730,29 +728,55 @@ export class ObservabilityAIAssistantClient { return this.dependencies.knowledgeBaseService.setup(); }; - addKnowledgeBaseEntry = async ({ + addUserInstruction = async ({ entry, }: { - entry: Omit; + entry: Omit< + KnowledgeBaseEntry, + '@timestamp' | 'confidence' | 'is_correction' | 'type' | 'role' + >; }): Promise => { + // for now we want to limit the number of user instructions to 1 per user + // if a user instruction already exists for the user, we get the id and update it + this.dependencies.logger.debug('Adding user instruction entry'); + const existingId = await this.dependencies.knowledgeBaseService.getPersonalUserInstructionId({ + isPublic: entry.public, + namespace: this.dependencies.namespace, + user: this.dependencies.user, + }); + + if (existingId) { + entry.id = existingId; + this.dependencies.logger.debug(`Updating user instruction with id "${existingId}"`); + } + return this.dependencies.knowledgeBaseService.addEntry({ namespace: this.dependencies.namespace, user: this.dependencies.user, - entry, + entry: { + ...entry, + confidence: 'high', + is_correction: false, + type: KnowledgeBaseType.UserInstruction, + labels: {}, + role: KnowledgeBaseEntryRole.UserEntry, + }, }); }; - importKnowledgeBaseEntries = async ({ - entries, + addKnowledgeBaseEntry = async ({ + entry, }: { - entries: Array>; + entry: Omit; }): Promise => { - const operations = entries.map((entry) => ({ - type: KnowledgeBaseEntryOperationType.Index, - document: { ...entry, '@timestamp': new Date().toISOString() }, - })); - - await this.dependencies.knowledgeBaseService.addEntries({ operations }); + return this.dependencies.knowledgeBaseService.addEntry({ + namespace: this.dependencies.namespace, + user: this.dependencies.user, + entry: { + ...entry, + type: KnowledgeBaseType.Contextual, + }, + }); }; getKnowledgeBaseEntries = async ({ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts index d1aba4f232b0d..eb7eab19340ce 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/index.ts @@ -13,18 +13,14 @@ import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common'; import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import { once } from 'lodash'; import type { AssistantScope } from '@kbn/ai-assistant-common'; -import { - KnowledgeBaseEntryRole, - ObservabilityAIAssistantScreenContextRequest, -} from '../../common/types'; +import { ObservabilityAIAssistantScreenContextRequest } from '../../common/types'; import type { ObservabilityAIAssistantPluginStartDependencies } from '../types'; import { ChatFunctionClient } from './chat_function_client'; import { ObservabilityAIAssistantClient } from './client'; import { conversationComponentTemplate } from './conversation_component_template'; import { kbComponentTemplate } from './kb_component_template'; -import { KnowledgeBaseEntryOperationType, KnowledgeBaseService } from './knowledge_base_service'; +import { KnowledgeBaseService } from './knowledge_base_service'; import type { RegistrationCallback, RespondFunctionResources } from './types'; -import { splitKbText } from './util/split_kb_text'; function getResourceName(resource: string) { return `.kibana-observability-ai-assistant-${resource}`; @@ -52,24 +48,11 @@ export const resourceNames = { }, }; -export const INDEX_QUEUED_DOCUMENTS_TASK_ID = 'observabilityAIAssistant:indexQueuedDocumentsTask'; - -export const INDEX_QUEUED_DOCUMENTS_TASK_TYPE = INDEX_QUEUED_DOCUMENTS_TASK_ID + 'Type'; - -type KnowledgeBaseEntryRequest = { id: string; labels?: Record } & ( - | { - text: string; - } - | { - texts: string[]; - } -); - export class ObservabilityAIAssistantService { private readonly core: CoreSetup; private readonly logger: Logger; private readonly getModelId: () => Promise; - private kbService?: KnowledgeBaseService; + public kbService?: KnowledgeBaseService; private enableKnowledgeBase: boolean; private readonly registrations: RegistrationCallback[] = []; @@ -93,26 +76,6 @@ export class ObservabilityAIAssistantService { this.enableKnowledgeBase = enableKnowledgeBase; this.allowInit(); - if (enableKnowledgeBase) { - taskManager.registerTaskDefinitions({ - [INDEX_QUEUED_DOCUMENTS_TASK_TYPE]: { - title: 'Index queued KB articles', - description: - 'Indexes previously registered entries into the knowledge base when it is ready', - timeout: '30m', - maxAttempts: 2, - createTaskRunner: (context) => { - return { - run: async () => { - if (this.kbService) { - await this.kbService.processQueue(); - } - }, - }; - }, - }, - }); - } } getKnowledgeBaseStatus() { @@ -336,65 +299,6 @@ export class ObservabilityAIAssistantService { return fnClient; } - addToKnowledgeBaseQueue(entries: KnowledgeBaseEntryRequest[]): void { - if (this.enableKnowledgeBase) { - this.init() - .then(() => { - this.kbService!.queue( - entries.flatMap((entry) => { - const entryWithSystemProperties = { - ...entry, - '@timestamp': new Date().toISOString(), - doc_id: entry.id, - public: true, - confidence: 'high' as const, - type: 'contextual' as const, - is_correction: false, - labels: { - ...entry.labels, - }, - role: KnowledgeBaseEntryRole.Elastic, - }; - - const operations = - 'texts' in entryWithSystemProperties - ? splitKbText(entryWithSystemProperties) - : [ - { - type: KnowledgeBaseEntryOperationType.Index, - document: entryWithSystemProperties, - }, - ]; - - return operations; - }) - ); - }) - .catch((error) => { - this.logger.error( - `Could not index ${entries.length} entries because of an initialisation error` - ); - this.logger.error(error); - }); - } - } - - addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) { - if (this.enableKnowledgeBase) { - this.addToKnowledgeBaseQueue( - entries.map((entry) => { - return { - ...entry, - labels: { - ...entry.labels, - category: categoryId, - }, - }; - }) - ); - } - } - register(cb: RegistrationCallback) { this.registrations.push(cb); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/kb_component_template.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/kb_component_template.ts index a4c6dc25d2e57..b1b2d3293a234 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/kb_component_template.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/kb_component_template.ts @@ -31,7 +31,16 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template'] properties: { '@timestamp': date, id: keyword, - doc_id: { type: 'text', fielddata: true }, + doc_id: { type: 'text', fielddata: true }, // deprecated but kept for backwards compatibility + title: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, user: { properties: { id: keyword, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/index.ts index 7306a0df7c572..92ce3a4a7e03b 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/index.ts @@ -9,16 +9,11 @@ import { serverUnavailable, gatewayTimeout, badRequest } from '@hapi/boom'; import type { ElasticsearchClient, IUiSettingsClient } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; -import pLimit from 'p-limit'; import pRetry from 'p-retry'; -import { map, orderBy } from 'lodash'; +import { orderBy } from 'lodash'; import { encode } from 'gpt-tokenizer'; import { MlTrainedModelDeploymentNodesStats } from '@elastic/elasticsearch/lib/api/types'; -import { - INDEX_QUEUED_DOCUMENTS_TASK_ID, - INDEX_QUEUED_DOCUMENTS_TASK_TYPE, - resourceNames, -} from '..'; +import { resourceNames } from '..'; import { Instruction, KnowledgeBaseEntry, @@ -63,36 +58,11 @@ function throwKnowledgeBaseNotReady(body: any) { throw serverUnavailable(`Knowledge base is not ready yet`, body); } -export enum KnowledgeBaseEntryOperationType { - Index = 'index', - Delete = 'delete', -} - -interface KnowledgeBaseDeleteOperation { - type: KnowledgeBaseEntryOperationType.Delete; - doc_id?: string; - labels?: Record; -} - -interface KnowledgeBaseIndexOperation { - type: KnowledgeBaseEntryOperationType.Index; - document: KnowledgeBaseEntry; -} - -export type KnowledgeBaseEntryOperation = - | KnowledgeBaseDeleteOperation - | KnowledgeBaseIndexOperation; - export class KnowledgeBaseService { - private hasSetup: boolean = false; - - private _queue: KnowledgeBaseEntryOperation[] = []; - - constructor(private readonly dependencies: Dependencies) { - this.ensureTaskScheduled(); - } + constructor(private readonly dependencies: Dependencies) {} setup = async () => { + this.dependencies.logger.debug('Setting up knowledge base'); if (!this.dependencies.enabled) { return; } @@ -192,7 +162,7 @@ export class KnowledgeBaseService { ); if (isReady) { - return Promise.resolve(); + return; } this.dependencies.logger.debug(`${elserModelId} model is not allocated yet`); @@ -202,116 +172,10 @@ export class KnowledgeBaseService { }, retryOptions); this.dependencies.logger.info(`${elserModelId} model is ready`); - this.ensureTaskScheduled(); }; - private ensureTaskScheduled() { - if (!this.dependencies.enabled) { - return; - } - this.dependencies.taskManagerStart - .ensureScheduled({ - taskType: INDEX_QUEUED_DOCUMENTS_TASK_TYPE, - id: INDEX_QUEUED_DOCUMENTS_TASK_ID, - state: {}, - params: {}, - schedule: { - interval: '1h', - }, - }) - .then(() => { - this.dependencies.logger.debug('Scheduled queue task'); - return this.dependencies.taskManagerStart.runSoon(INDEX_QUEUED_DOCUMENTS_TASK_ID); - }) - .then(() => { - this.dependencies.logger.debug('Queue task ran'); - }) - .catch((err) => { - this.dependencies.logger.error(`Failed to schedule queue task`); - this.dependencies.logger.error(err); - }); - } - - private async processOperation(operation: KnowledgeBaseEntryOperation) { - if (operation.type === KnowledgeBaseEntryOperationType.Delete) { - await this.dependencies.esClient.asInternalUser.deleteByQuery({ - index: resourceNames.aliases.kb, - query: { - bool: { - filter: [ - ...(operation.doc_id ? [{ term: { _id: operation.doc_id } }] : []), - ...(operation.labels - ? map(operation.labels, (value, key) => { - return { term: { [key]: value } }; - }) - : []), - ], - }, - }, - }); - return; - } - - await this.addEntry({ - entry: operation.document, - }); - } - - async processQueue() { - if (!this._queue.length || !this.dependencies.enabled) { - return; - } - - if (!(await this.status()).ready) { - this.dependencies.logger.debug(`Bailing on queue task: KB is not ready yet`); - return; - } - - this.dependencies.logger.debug(`Processing queue`); - - this.hasSetup = true; - - this.dependencies.logger.info(`Processing ${this._queue.length} queue operations`); - - const limiter = pLimit(5); - - const operations = this._queue.concat(); - - await Promise.all( - operations.map((operation) => - limiter(async () => { - this._queue.splice(operations.indexOf(operation), 1); - await this.processOperation(operation); - }) - ) - ); - - this.dependencies.logger.info('Processed all queued operations'); - } - - queue(operations: KnowledgeBaseEntryOperation[]): void { - if (!operations.length) { - return; - } - - if (!this.hasSetup) { - this._queue.push(...operations); - return; - } - - const limiter = pLimit(5); - - const limitedFunctions = this._queue.map((operation) => - limiter(() => this.processOperation(operation)) - ); - - Promise.all(limitedFunctions).catch((err) => { - this.dependencies.logger.error(`Failed to process all queued operations`); - this.dependencies.logger.error(err); - }); - } - status = async () => { + this.dependencies.logger.debug('Checking model status'); if (!this.dependencies.enabled) { return { ready: false, enabled: false }; } @@ -324,15 +188,24 @@ export class KnowledgeBaseService { const elserModelStats = modelStats.trained_model_stats[0]; const deploymentState = elserModelStats.deployment_stats?.state; const allocationState = elserModelStats.deployment_stats?.allocation_status.state; + const ready = deploymentState === 'started' && allocationState === 'fully_allocated'; + + this.dependencies.logger.debug( + `Model deployment state: ${deploymentState}, allocation state: ${allocationState}, ready: ${ready}` + ); return { - ready: deploymentState === 'started' && allocationState === 'fully_allocated', + ready, deployment_state: deploymentState, allocation_state: allocationState, model_name: elserModelId, enabled: true, }; } catch (error) { + this.dependencies.logger.debug( + `Failed to get status for model "${elserModelId}" due to ${error.message}` + ); + return { error: error instanceof errors.ResponseError ? error.body.error : String(error), ready: false, @@ -380,18 +253,21 @@ export class KnowledgeBaseService { }; const response = await this.dependencies.esClient.asInternalUser.search< - Pick + Pick & { doc_id?: string } >({ index: [resourceNames.aliases.kb], query: esQuery, size: 20, _source: { - includes: ['text', 'is_correction', 'labels'], + includes: ['text', 'is_correction', 'labels', 'doc_id', 'title'], }, }); return response.hits.hits.map((hit) => ({ - ...hit._source!, + text: hit._source?.text!, + is_correction: hit._source?.is_correction, + labels: hit._source?.labels, + title: hit._source?.title ?? hit._source?.doc_id, // use `doc_id` as fallback title for backwards compatibility score: hit._score!, id: hit._id!, })); @@ -411,12 +287,11 @@ export class KnowledgeBaseService { namespace: string; esClient: { asCurrentUser: ElasticsearchClient; asInternalUser: ElasticsearchClient }; uiSettingsClient: IUiSettingsClient; - }): Promise<{ - entries: RecalledEntry[]; - }> => { + }): Promise => { if (!this.dependencies.enabled) { - return { entries: [] }; + return []; } + this.dependencies.logger.debug( () => `Recalling entries from KB for queries: "${JSON.stringify(queries)}"` ); @@ -480,9 +355,7 @@ export class KnowledgeBaseService { this.dependencies.logger.info(`Dropped ${droppedEntries} entries because of token limit`); } - return { - entries: returnedEntries, - }; + return returnedEntries; }; getUserInstructions = async ( @@ -508,11 +381,11 @@ export class KnowledgeBaseService { }, }, size: 500, - _source: ['doc_id', 'text', 'public'], + _source: ['id', 'text', 'public'], }); return response.hits.hits.map((hit) => ({ - doc_id: hit._source?.doc_id ?? '', + id: hit._id!, text: hit._source?.text ?? '', public: hit._source?.public, })); @@ -536,13 +409,17 @@ export class KnowledgeBaseService { return { entries: [] }; } try { - const response = await this.dependencies.esClient.asInternalUser.search({ + const response = await this.dependencies.esClient.asInternalUser.search< + KnowledgeBaseEntry & { doc_id?: string } + >({ index: resourceNames.aliases.kb, query: { bool: { filter: [ - // filter title by query - ...(query ? [{ wildcard: { doc_id: { value: `${query}*` } } }] : []), + // filter by search query + ...(query + ? [{ query_string: { query: `${query}*`, fields: ['doc_id', 'title'] } }] + : []), { // exclude user instructions bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } }, @@ -550,16 +427,17 @@ export class KnowledgeBaseService { ], }, }, - sort: [ - { - [String(sortBy)]: { - order: sortDirection, - }, - }, - ], + sort: + sortBy === 'title' + ? [ + { ['title.keyword']: { order: sortDirection } }, + { doc_id: { order: sortDirection } }, // sort by doc_id for backwards compatibility + ] + : [{ [String(sortBy)]: { order: sortDirection } }], size: 500, _source: { includes: [ + 'title', 'doc_id', 'text', 'is_correction', @@ -577,6 +455,7 @@ export class KnowledgeBaseService { return { entries: response.hits.hits.map((hit) => ({ ...hit._source!, + title: hit._source!.title ?? hit._source!.doc_id, // use `doc_id` as fallback title for backwards compatibility role: hit._source!.role ?? KnowledgeBaseEntryRole.UserEntry, score: hit._score, id: hit._id!, @@ -590,7 +469,7 @@ export class KnowledgeBaseService { } }; - getExistingUserInstructionId = async ({ + getPersonalUserInstructionId = async ({ isPublic, user, namespace, @@ -602,9 +481,7 @@ export class KnowledgeBaseService { if (!this.dependencies.enabled) { return null; } - const res = await this.dependencies.esClient.asInternalUser.search< - Pick - >({ + const res = await this.dependencies.esClient.asInternalUser.search({ index: resourceNames.aliases.kb, query: { bool: { @@ -616,14 +493,47 @@ export class KnowledgeBaseService { }, }, size: 1, - _source: ['doc_id'], + _source: false, }); - return res.hits.hits[0]?._source?.doc_id; + return res.hits.hits[0]?._id; + }; + + getUuidFromDocId = async ({ + docId, + user, + namespace, + }: { + docId: string; + user?: { name: string; id?: string }; + namespace?: string; + }) => { + const query = { + bool: { + filter: [ + { term: { doc_id: docId } }, + + // exclude user instructions + { bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } } }, + + // restrict access to user's own entries + ...getAccessQuery({ user, namespace }), + ], + }, + }; + + const response = await this.dependencies.esClient.asInternalUser.search({ + size: 1, + index: resourceNames.aliases.kb, + query, + _source: false, + }); + + return response.hits.hits[0]?._id; }; addEntry = async ({ - entry: { id, ...document }, + entry: { id, ...doc }, user, namespace, }: { @@ -634,19 +544,6 @@ export class KnowledgeBaseService { if (!this.dependencies.enabled) { return; } - // for now we want to limit the number of user instructions to 1 per user - if (document.type === KnowledgeBaseType.UserInstruction) { - const existingId = await this.getExistingUserInstructionId({ - isPublic: document.public, - user, - namespace, - }); - - if (existingId) { - id = existingId; - document.doc_id = existingId; - } - } try { await this.dependencies.esClient.asInternalUser.index({ @@ -654,7 +551,7 @@ export class KnowledgeBaseService { id, document: { '@timestamp': new Date().toISOString(), - ...document, + ...doc, user, namespace, }, @@ -669,29 +566,6 @@ export class KnowledgeBaseService { } }; - addEntries = async ({ - operations, - }: { - operations: KnowledgeBaseEntryOperation[]; - }): Promise => { - if (!this.dependencies.enabled) { - return; - } - this.dependencies.logger.info(`Starting import of ${operations.length} entries`); - - const limiter = pLimit(5); - - await Promise.all( - operations.map((operation) => - limiter(async () => { - await this.processOperation(operation); - }) - ) - ); - - this.dependencies.logger.info(`Completed import of ${operations.length} entries`); - }; - deleteEntry = async ({ id }: { id: string }): Promise => { try { await this.dependencies.esClient.asInternalUser.delete({ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/kb_docs/lens.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/kb_docs/lens.ts deleted file mode 100644 index 9baf75f6ff552..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/knowledge_base_service/kb_docs/lens.ts +++ /dev/null @@ -1,596 +0,0 @@ -/* - * 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 { Logger } from '@kbn/logging'; -import type { ObservabilityAIAssistantService } from '../..'; - -export function addLensDocsToKb({ - service, -}: { - service: ObservabilityAIAssistantService; - logger: Logger; -}) { - service.addCategoryToKnowledgeBase('lens', [ - { - id: 'lens_formulas_how_it_works', - texts: [ - `Lens formulas let you do math using a combination of Elasticsearch aggregations and - math functions. There are three main types of functions: - - * Elasticsearch metrics, like \`sum(bytes)\` - * Time series functions use Elasticsearch metrics as input, like \`cumulative_sum()\` - * Math functions like \`round()\` - - An example formula that uses all of these: - - \`\`\` - round(100 * moving_average( - average(cpu.load.pct), - window=10, - kql='datacenter.name: east*' - )) - \`\`\` - `, - `Elasticsearch functions take a field name, which can be in quotes. \`sum(bytes)\` is the same - as \`sum('bytes')\`. - - Some functions take named arguments, like \`moving_average(count(), window=5)\`. - - Elasticsearch metrics can be filtered using KQL or Lucene syntax. To add a filter, use the named - parameter \`kql='field: value'\` or \`lucene=''\`. Always use single quotes when writing KQL or Lucene - queries. If your search has a single quote in it, use a backslash to escape, like: \`kql='Women's'\' - - Math functions can take positional arguments, like pow(count(), 3) is the same as count() * count() * count() - - Use the symbols +, -, /, and * to perform basic math.`, - ], - }, - { - id: 'lens_common_formulas', - texts: [ - `The most common formulas are dividing two values to produce a percent. To display accurately, set - "value format" to "percent"`, - `### Filter ratio: - - Use \`kql=''\` to filter one set of documents and compare it to other documents within the same grouping. - For example, to see how the error rate changes over time: - - \`\`\` - count(kql='response.status_code > 400') / count() - \`\`\``, - `### Week over week: - - Use \`shift='1w'\` to get the value of each grouping from - the previous week. Time shift should not be used with the *Top values* function. - - \`\`\` - percentile(system.network.in.bytes, percentile=99) / - percentile(system.network.in.bytes, percentile=99, shift='1w') - \`\`\``, - - `### Percent of total - - Formulas can calculate \`overall_sum\` for all the groupings, - which lets you convert each grouping into a percent of total: - - \`\`\` - sum(products.base_price) / overall_sum(sum(products.base_price)) - \`\`\``, - - `### Recent change - - Use \`reducedTimeRange='30m'\` to add an additional filter on the - time range of a metric aligned with the end of the global time range. - This can be used to calculate how much a value changed recently. - - \`\`\` - max(system.network.in.bytes, reducedTimeRange="30m") - - min(system.network.in.bytes, reducedTimeRange="30m") - \`\`\` - `, - ], - }, - { - id: 'lens_formulas_elasticsearch_functions', - texts: [ - `## Elasticsearch functions - - These functions will be executed on the raw documents for each row of the - resulting table, aggregating all documents matching the break down - dimensions into a single value.`, - - `#### average(field: string) - Returns the average of a field. This function only works for number fields. - - Example: Get the average of price: \`average(price)\` - - Example: Get the average of price for orders from the UK: \`average(price, - kql='location:UK')\``, - - `#### count([field: string]) - The total number of documents. When you provide a field, the total number of - field values is counted. When you use the Count function for fields that have - multiple values in a single document, all values are counted. - - To calculate the total number of documents, use \`count().\` - - To calculate the number of products in all orders, use \`count(products.id)\`. - - To calculate the number of documents that match a specific filter, use - \`count(kql='price > 500')\`.`, - - `#### last_value(field: string) - Returns the value of a field from the last document, ordered by the default - time field of the data view. - - This function is usefull the retrieve the latest state of an entity. - - Example: Get the current status of server A: \`last_value(server.status, - kql='server.name="A"')\``, - - `#### max(field: string) - Returns the max of a field. This function only works for number fields. - - Example: Get the max of price: \`max(price)\` - - Example: Get the max of price for orders from the UK: \`max(price, - kql='location:UK')\``, - - `#### median(field: string) - Returns the median of a field. This function only works for number fields. - - Example: Get the median of price: \`median(price)\` - - Example: Get the median of price for orders from the UK: \`median(price, - kql='location:UK')\``, - - `#### min(field: string) - Returns the min of a field. This function only works for number fields. - - Example: Get the min of price: \`min(price)\` - - Example: Get the min of price for orders from the UK: \`min(price, - kql='location:UK')\``, - - `#### percentile(field: string, [percentile]: number) - Returns the specified percentile of the values of a field. This is the value n - percent of the values occuring in documents are smaller. - - Example: Get the number of bytes larger than 95 % of values: - \`percentile(bytes, percentile=95)\``, - - `#### percentile_rank(field: string, [value]: number) - Returns the percentage of values which are below a certain value. For example, - if a value is greater than or equal to 95% of the observed values it is said to - be at the 95th percentile rank - - Example: Get the percentage of values which are below of 100: - \`percentile_rank(bytes, value=100)\``, - - `#### standard_deviation(field: string) - Returns the amount of variation or dispersion of the field. The function works - only for number fields. - - Example: To get the standard deviation of price, use - \`standard_deviation(price).\` - - Example: To get the variance of price for orders from the UK, use - \`square(standard_deviation(price, kql='location:UK'))\`.`, - - `#### sum(field: string) - Returns the sum of a field. This function only works for number fields. - - Example: Get the sum of price: sum(price) - - Example: Get the sum of price for orders from the UK: \`sum(price, - kql='location:UK')\``, - - `#### unique_count(field: string) - Calculates the number of unique values of a specified field. Works for number, - string, date and boolean values. - - Example: Calculate the number of different products: - \`unique_count(product.name)\` - - Example: Calculate the number of different products from the "clothes" group: - \`unique_count(product.name, kql='product.group=clothes')\` - - `, - ], - }, - { - id: 'lens_formulas_column_functions', - texts: [ - `## Column calculations - These functions are executed for each row, but are provided with the whole - column as context. This is also known as a window function.`, - - `#### counter_rate(metric: number) - Calculates the rate of an ever increasing counter. This function will only - yield helpful results on counter metric fields which contain a measurement of - some kind monotonically growing over time. If the value does get smaller, it - will interpret this as a counter reset. To get most precise results, - counter_rate should be calculated on the max of a field. - - This calculation will be done separately for separate series defined by filters - or top values dimensions. It uses the current interval when used in Formula. - - Example: Visualize the rate of bytes received over time by a memcached server: - counter_rate(max(memcached.stats.read.bytes))`, - - `cumulative_sum(metric: number) - Calculates the cumulative sum of a metric over time, adding all previous values - of a series to each value. To use this function, you need to configure a date - histogram dimension as well. - - This calculation will be done separately for separate series defined by filters - or top values dimensions. - - Example: Visualize the received bytes accumulated over time: - cumulative_sum(sum(bytes))`, - - `differences(metric: number) - Calculates the difference to the last value of a metric over time. To use this - function, you need to configure a date histogram dimension as well. Differences - requires the data to be sequential. If your data is empty when using - differences, try increasing the date histogram interval. - - This calculation will be done separately for separate series defined by filters - or top values dimensions. - - Example: Visualize the change in bytes received over time: - differences(sum(bytes))`, - - `moving_average(metric: number, [window]: number) - Calculates the moving average of a metric over time, averaging the last n-th - values to calculate the current value. To use this function, you need to - configure a date histogram dimension as well. The default window value is 5. - - This calculation will be done separately for separate series defined by filters - or top values dimensions. - - Takes a named parameter window which specifies how many last values to include - in the average calculation for the current value. - - Example: Smooth a line of measurements: moving_average(sum(bytes), window=5)`, - - `normalize_by_unit(metric: number, unit: s|m|h|d|w|M|y) - This advanced function is useful for normalizing counts and sums to a specific - time interval. It allows for integration with metrics that are stored already - normalized to a specific time interval. - - This function can only be used if there's a date histogram function used in the - current chart. - - Example: A ratio comparing an already normalized metric to another metric that - needs to be normalized. - normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / - last_value(apache.status.bytes_per_second)`, - - `overall_average(metric: number) - Calculates the average of a metric for all data points of a series in the - current chart. A series is defined by a dimension using a date histogram or - interval function. Other dimensions breaking down the data like top values or - filter are treated as separate series. - - If no date histograms or interval functions are used in the current chart, - overall_average is calculating the average over all dimensions no matter the - used function - - Example: Divergence from the mean: sum(bytes) - overall_average(sum(bytes))`, - - `overall_max(metric: number) - Calculates the maximum of a metric for all data points of a series in the - current chart. A series is defined by a dimension using a date histogram or - interval function. Other dimensions breaking down the data like top values or - filter are treated as separate series. - - If no date histograms or interval functions are used in the current chart, - overall_max is calculating the maximum over all dimensions no matter the used - function - - Example: Percentage of range (sum(bytes) - overall_min(sum(bytes))) / - (overall_max(sum(bytes)) - overall_min(sum(bytes)))`, - - `overall_min(metric: number) - Calculates the minimum of a metric for all data points of a series in the - current chart. A series is defined by a dimension using a date histogram or - interval function. Other dimensions breaking down the data like top values or - filter are treated as separate series. - - If no date histograms or interval functions are used in the current chart, - overall_min is calculating the minimum over all dimensions no matter the used - function - - Example: Percentage of range (sum(bytes) - overall_min(sum(bytes)) / - (overall_max(sum(bytes)) - overall_min(sum(bytes)))`, - - `overall_sum(metric: number) - Calculates the sum of a metric of all data points of a series in the current - chart. A series is defined by a dimension using a date histogram or interval - function. Other dimensions breaking down the data like top values or filter are - treated as separate series. - - If no date histograms or interval functions are used in the current chart, - overall_sum is calculating the sum over all dimensions no matter the used - function. - - Example: Percentage of total sum(bytes) / overall_sum(sum(bytes))`, - ], - }, - { - id: 'lens_formulas_math_functions', - texts: [ - `Math - These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.`, - - `abs([value]: number) - Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same. - - Example: Calculate average distance to sea level abs(average(altitude))`, - - `add([left]: number, [right]: number) - Adds up two numbers. - - Also works with + symbol. - - Example: Calculate the sum of two fields - - sum(price) + sum(tax) - - Example: Offset count by a static value - - add(count(), 5)`, - - `cbrt([value]: number) - Cube root of value. - - Example: Calculate side length from volume - - cbrt(last_value(volume)) - - ceil([value]: number) - Ceiling of value, rounds up. - - Example: Round up price to the next dollar - - ceil(sum(price))`, - - `clamp([value]: number, [min]: number, [max]: number) - Limits the value from a minimum to maximum. - - Example: Make sure to catch outliers - - clamp( - average(bytes), - percentile(bytes, percentile=5), - percentile(bytes, percentile=95) - )`, - `cube([value]: number) - Calculates the cube of a number. - - Example: Calculate volume from side length - - cube(last_value(length))`, - - `defaults([value]: number, [default]: number) - Returns a default numeric value when value is null. - - Example: Return -1 when a field has no data - - defaults(average(bytes), -1)`, - - `divide([left]: number, [right]: number) - Divides the first number by the second number. - - Also works with / symbol - - Example: Calculate profit margin - - sum(profit) / sum(revenue) - - Example: divide(sum(bytes), 2)`, - - `exp([value]: number) - Raises e to the nth power. - - Example: Calculate the natural exponential function - - exp(last_value(duration))`, - - `fix([value]: number) - For positive values, takes the floor. For negative values, takes the ceiling. - - Example: Rounding towards zero - - fix(sum(profit))`, - - `floor([value]: number) - Round down to nearest integer value - - Example: Round down a price - - floor(sum(price))`, - - `log([value]: number, [base]?: number) - Logarithm with optional base. The natural base e is used as default. - - Example: Calculate number of bits required to store values - - log(sum(bytes)) - log(sum(bytes), 2)`, - `mod([value]: number, [base]: number) - Remainder after dividing the function by a number - - Example: Calculate last three digits of a value - - mod(sum(price), 1000)`, - - `multiply([left]: number, [right]: number) - Multiplies two numbers. - - Also works with * symbol. - - Example: Calculate price after current tax rate - - sum(bytes) * last_value(tax_rate) - - Example: Calculate price after constant tax rate - - multiply(sum(price), 1.2)`, - - `pick_max([left]: number, [right]: number) - Finds the maximum value between two numbers. - - Example: Find the maximum between two fields averages - - pick_max(average(bytes), average(memory))`, - - `pick_min([left]: number, [right]: number) - Finds the minimum value between two numbers. - - Example: Find the minimum between two fields averages - - pick_min(average(bytes), average(memory))`, - - `pow([value]: number, [base]: number) - Raises the value to a certain power. The second argument is required - - Example: Calculate volume based on side length - - pow(last_value(length), 3)`, - - `round([value]: number, [decimals]?: number) - Rounds to a specific number of decimal places, default of 0 - - Examples: Round to the cent - - round(sum(bytes)) - round(sum(bytes), 2)`, - `sqrt([value]: number) - Square root of a positive value only - - Example: Calculate side length based on area - - sqrt(last_value(area))`, - - `square([value]: number) - Raise the value to the 2nd power - - Example: Calculate area based on side length - - square(last_value(length))`, - - `subtract([left]: number, [right]: number) - Subtracts the first number from the second number. - - Also works with - symbol. - - Example: Calculate the range of a field - - subtract(max(bytes), min(bytes))`, - ], - }, - { - id: 'lens_formulas_comparison_functions', - texts: [ - `Comparison - These functions are used to perform value comparison.`, - - `eq([left]: number, [right]: number) - Performs an equality comparison between two values. - - To be used as condition for ifelse comparison function. - - Also works with == symbol. - - Example: Returns true if the average of bytes is exactly the same amount of average memory - - average(bytes) == average(memory) - - Example: eq(sum(bytes), 1000000)`, - - `gt([left]: number, [right]: number) - Performs a greater than comparison between two values. - - To be used as condition for ifelse comparison function. - - Also works with > symbol. - - Example: Returns true if the average of bytes is greater than the average amount of memory - - average(bytes) > average(memory) - - Example: gt(average(bytes), 1000)`, - - `gte([left]: number, [right]: number) - Performs a greater than comparison between two values. - - To be used as condition for ifelse comparison function. - - Also works with >= symbol. - - Example: Returns true if the average of bytes is greater than or equal to the average amount of memory - - average(bytes) >= average(memory) - - Example: gte(average(bytes), 1000)`, - - `ifelse([condition]: boolean, [left]: number, [right]: number) - Returns a value depending on whether the element of condition is true or false. - - Example: Average revenue per customer but in some cases customer id is not provided which counts as additional customer - - sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`, - - `lt([left]: number, [right]: number) - Performs a lower than comparison between two values. - - To be used as condition for ifelse comparison function. - - Also works with < symbol. - - Example: Returns true if the average of bytes is lower than the average amount of memory - - average(bytes) <= average(memory) - - Example: lt(average(bytes), 1000)`, - - `lte([left]: number, [right]: number) - Performs a lower than or equal comparison between two values. - - To be used as condition for ifelse comparison function. - - Also works with <= symbol. - - Example: Returns true if the average of bytes is lower than or equal to the average amount of memory - - average(bytes) <= average(memory) - - Example: lte(average(bytes), 1000)`, - ], - }, - { - id: 'lens_formulas_kibana_context', - text: dedent(`Kibana context - - These functions are used to retrieve Kibana context variables, which are the - date histogram \`interval\`, the current \`now\` and the selected \`time_range\` - and help you to compute date math operations. - - interval() - The specified minimum interval for the date histogram, in milliseconds (ms). - - now() - The current now moment used in Kibana expressed in milliseconds (ms). - - time_range() - The specified time range, in milliseconds (ms).`), - }, - ]); -} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/types.ts index 2df3f36163972..2e24cf25902e0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/types.ts @@ -17,7 +17,6 @@ import type { import type { Message, ObservabilityAIAssistantScreenContextRequest, - InstructionOrPlainText, AdHocInstruction, } from '../../common/types'; import type { ObservabilityAIAssistantRouteHandlerResources } from '../routes/types'; @@ -67,13 +66,13 @@ export interface FunctionHandler { respond: RespondFunction; } -export type InstructionOrCallback = InstructionOrPlainText | RegisterInstructionCallback; +export type InstructionOrCallback = string | RegisterInstructionCallback; export type RegisterInstructionCallback = ({ availableFunctionNames, }: { availableFunctionNames: string[]; -}) => InstructionOrPlainText | InstructionOrPlainText[] | undefined; +}) => string | string[] | undefined; export type RegisterInstruction = (...instruction: InstructionOrCallback[]) => void; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.test.ts index 8e4075bed7b9d..82f22e08d1fc7 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.test.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.test.ts @@ -41,10 +41,10 @@ describe('getSystemMessageFromInstructions', () => { expect( getSystemMessageFromInstructions({ applicationInstructions: ['first'], - userInstructions: [{ doc_id: 'second', text: 'second from kb' }], + userInstructions: [{ id: 'second', text: 'second from kb' }], adHocInstructions: [ { - doc_id: 'second', + id: 'second', text: 'second from adhoc instruction', instruction_type: 'user_instruction', }, @@ -58,7 +58,7 @@ describe('getSystemMessageFromInstructions', () => { expect( getSystemMessageFromInstructions({ applicationInstructions: ['first'], - userInstructions: [{ doc_id: 'second', text: 'second_kb' }], + userInstructions: [{ id: 'second', text: 'second_kb' }], adHocInstructions: [], availableFunctionNames: [], }) diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.ts index 570449673084b..7b59b4ce59219 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/get_system_message_from_instructions.ts @@ -45,7 +45,7 @@ export function getSystemMessageFromInstructions({ const adHocInstructionsWithId = adHocInstructions.map((adHocInstruction) => ({ ...adHocInstruction, - doc_id: adHocInstruction?.doc_id ?? v4(), + id: adHocInstruction?.id ?? v4(), })); // split ad hoc instructions into user instructions and application instructions @@ -55,15 +55,16 @@ export function getSystemMessageFromInstructions({ ); // all adhoc instructions and KB instructions. - // adhoc instructions will be prioritized over Knowledge Base instructions if the doc_id is the same + // adhoc instructions will be prioritized over Knowledge Base instructions if the id is the same const allUserInstructions = withTokenBudget( - uniqBy([...adHocUserInstructions, ...kbUserInstructions], (i) => i.doc_id), + uniqBy([...adHocUserInstructions, ...kbUserInstructions], (i) => i.id), 1000 ); return [ // application instructions - ...allApplicationInstructions.concat(adHocApplicationInstructions), + ...allApplicationInstructions, + ...adHocApplicationInstructions, // user instructions ...(allUserInstructions.length ? [USER_INSTRUCTIONS_HEADER, ...allUserInstructions] : []), diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/split_kb_text.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/split_kb_text.ts deleted file mode 100644 index 9a2f047b60f9b..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/util/split_kb_text.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { merge } from 'lodash'; -import type { KnowledgeBaseEntry } from '../../../common/types'; -import { - type KnowledgeBaseEntryOperation, - KnowledgeBaseEntryOperationType, -} from '../knowledge_base_service'; - -export function splitKbText({ - id, - texts, - ...rest -}: Omit & { texts: string[] }): KnowledgeBaseEntryOperation[] { - return [ - { - type: KnowledgeBaseEntryOperationType.Delete, - doc_id: id, - labels: {}, - }, - ...texts.map((text, index) => ({ - type: KnowledgeBaseEntryOperationType.Index, - document: merge({}, rest, { - id: [id, index].join('_'), - doc_id: id, - labels: {}, - text, - }), - })), - ]; -} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts index 8885ff7e1d7a2..fefe324805e59 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/recall_and_score.ts @@ -7,13 +7,14 @@ import type { Logger } from '@kbn/logging'; import { AnalyticsServiceStart } from '@kbn/core/server'; +import { scoreSuggestions } from './score_suggestions'; import type { Message } from '../../../common'; import type { ObservabilityAIAssistantClient } from '../../service/client'; import type { FunctionCallChatFunction } from '../../service/types'; -import { retrieveSuggestions } from './retrieve_suggestions'; -import { scoreSuggestions } from './score_suggestions'; -import type { RetrievedSuggestion } from './types'; -import { RecallRanking, RecallRankingEventType } from '../../analytics/recall_ranking'; +import { RecallRanking, recallRankingEventType } from '../../analytics/recall_ranking'; +import { RecalledEntry } from '../../service/knowledge_base_service'; + +export type RecalledSuggestion = Pick; export async function recallAndScore({ recall, @@ -34,19 +35,18 @@ export async function recallAndScore({ logger: Logger; signal: AbortSignal; }): Promise<{ - relevantDocuments?: RetrievedSuggestion[]; + relevantDocuments?: RecalledSuggestion[]; scores?: Array<{ id: string; score: number }>; - suggestions: RetrievedSuggestion[]; + suggestions: RecalledSuggestion[]; }> { const queries = [ { text: userPrompt, boost: 3 }, { text: context, boost: 1 }, ].filter((query) => query.text.trim()); - const suggestions = await retrieveSuggestions({ - recall, - queries, - }); + const suggestions: RecalledSuggestion[] = (await recall({ queries })).map( + ({ id, text, score }) => ({ id, text, score }) + ); if (!suggestions.length) { return { @@ -67,7 +67,7 @@ export async function recallAndScore({ chat, }); - analytics.reportEvent(RecallRankingEventType, { + analytics.reportEvent(recallRankingEventType, { prompt: queries.map((query) => query.text).join('\n\n'), scoredDocuments: suggestions.map((suggestion) => { const llmScore = scores.find((score) => score.id === suggestion.id); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/retrieve_suggestions.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/retrieve_suggestions.ts deleted file mode 100644 index 3c680229cd5d2..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/retrieve_suggestions.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 { omit } from 'lodash'; -import { ObservabilityAIAssistantClient } from '../../service/client'; -import { RetrievedSuggestion } from './types'; - -export async function retrieveSuggestions({ - queries, - recall, -}: { - queries: Array<{ text: string; boost?: number }>; - recall: ObservabilityAIAssistantClient['recall']; -}): Promise { - const recallResponse = await recall({ - queries, - }); - - return recallResponse.entries.map((entry) => omit(entry, 'labels', 'is_correction')); -} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/score_suggestions.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/score_suggestions.ts index 009b91a7a8c2c..7d1a19463cceb 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/score_suggestions.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/score_suggestions.ts @@ -5,15 +5,15 @@ * 2.0. */ import * as t from 'io-ts'; -import { omit } from 'lodash'; import { Logger } from '@kbn/logging'; import dedent from 'dedent'; import { lastValueFrom } from 'rxjs'; import { decodeOrThrow, jsonRt } from '@kbn/io-ts-utils'; +import { omit } from 'lodash'; import { concatenateChatCompletionChunks, Message, MessageRole } from '../../../common'; import type { FunctionCallChatFunction } from '../../service/types'; -import type { RetrievedSuggestion } from './types'; import { parseSuggestionScores } from './parse_suggestion_scores'; +import { RecalledSuggestion } from './recall_and_score'; import { ShortIdTable } from '../../../common/utils/short_id_table'; const scoreFunctionRequestRt = t.type({ @@ -38,7 +38,7 @@ export async function scoreSuggestions({ signal, logger, }: { - suggestions: RetrievedSuggestion[]; + suggestions: RecalledSuggestion[]; messages: Message[]; userPrompt: string; context: string; @@ -46,28 +46,21 @@ export async function scoreSuggestions({ signal: AbortSignal; logger: Logger; }): Promise<{ - relevantDocuments: RetrievedSuggestion[]; + relevantDocuments: RecalledSuggestion[]; scores: Array<{ id: string; score: number }>; }> { const shortIdTable = new ShortIdTable(); - const suggestionsWithShortId = suggestions.map((suggestion) => ({ - ...omit(suggestion, 'score', 'id'), // To not bias the LLM - originalId: suggestion.id, - shortId: shortIdTable.take(suggestion.id), - })); - const newUserMessageContent = - dedent(`Given the following question, score the documents that are relevant to the question. on a scale from 0 to 7, - 0 being completely irrelevant, and 7 being extremely relevant. Information is relevant to the question if it helps in - answering the question. Judge it according to the following criteria: - - - The document is relevant to the question, and the rest of the conversation - - The document has information relevant to the question that is not mentioned, - or more detailed than what is available in the conversation - - The document has a high amount of information relevant to the question compared to other documents - - The document contains new information not mentioned before in the conversation - + dedent(`Given the following prompt, score the documents that are relevant to the prompt on a scale from 0 to 7, + 0 being completely irrelevant, and 7 being extremely relevant. Information is relevant to the prompt if it helps in + answering the prompt. Judge the document according to the following criteria: + + - The document is relevant to the prompt, and the rest of the conversation + - The document has information relevant to the prompt that is not mentioned, or more detailed than what is available in the conversation + - The document has a high amount of information relevant to the prompt compared to other documents + - The document contains new information not mentioned before in the conversation or provides a correction to previously stated information. + User prompt: ${userPrompt} @@ -76,9 +69,9 @@ export async function scoreSuggestions({ Documents: ${JSON.stringify( - suggestionsWithShortId.map((suggestion) => ({ - id: suggestion.shortId, - content: suggestion.text, + suggestions.map((suggestion) => ({ + ...omit(suggestion, 'score'), // Omit score to not bias the LLM + id: shortIdTable.take(suggestion.id), // Shorten id to save tokens })), null, 2 @@ -127,15 +120,9 @@ export async function scoreSuggestions({ scoreFunctionRequest.message.function_call.arguments ); - const scores = parseSuggestionScores(scoresAsString).map(({ id, score }) => { - const originalSuggestion = suggestionsWithShortId.find( - (suggestion) => suggestion.shortId === id - ); - return { - originalId: originalSuggestion?.originalId, - score, - }; - }); + const scores = parseSuggestionScores(scoresAsString) + // Restore original IDs + .map(({ id, score }) => ({ id: shortIdTable.lookup(id)!, score })); if (scores.length === 0) { // seemingly invalid or no scores, return all @@ -144,12 +131,13 @@ export async function scoreSuggestions({ const suggestionIds = suggestions.map((document) => document.id); + // get top 5 documents ids with scores > 4 const relevantDocumentIds = scores - .filter((document) => suggestionIds.includes(document.originalId ?? '')) // Remove hallucinated documents - .filter((document) => document.score > 4) + .filter(({ score }) => score > 4) .sort((a, b) => b.score - a.score) .slice(0, 5) - .map((document) => document.originalId); + .filter(({ id }) => suggestionIds.includes(id ?? '')) // Remove hallucinated documents + .map(({ id }) => id); const relevantDocuments = suggestions.filter((suggestion) => relevantDocumentIds.includes(suggestion.id) @@ -159,6 +147,6 @@ export async function scoreSuggestions({ return { relevantDocuments, - scores: scores.map((score) => ({ id: score.originalId!, score: score.score })), + scores: scores.map((score) => ({ id: score.id, score: score.score })), }; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/types.ts deleted file mode 100644 index 3774df64c1ee1..0000000000000 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/utils/recall/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * 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 { RecalledEntry } from '../../service/knowledge_base_service'; - -export type RetrievedSuggestion = Omit; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/categorize_entries.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/categorize_entries.ts index 8d32ea4664f9f..7ea25eab8c661 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/categorize_entries.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/categorize_entries.ts @@ -9,21 +9,30 @@ import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/ export interface KnowledgeBaseEntryCategory { '@timestamp': string; - categoryName: string; + categoryKey: string; + title: string; entries: KnowledgeBaseEntry[]; } -export function categorizeEntries({ entries }: { entries: KnowledgeBaseEntry[] }) { +export function categorizeEntries({ + entries, +}: { + entries: KnowledgeBaseEntry[]; +}): KnowledgeBaseEntryCategory[] { return entries.reduce((acc, entry) => { - const categoryName = entry.labels?.category ?? entry.id; - - const index = acc.findIndex((item) => item.categoryName === categoryName); + const categoryKey = entry.labels?.category ?? entry.id; - if (index > -1) { - acc[index].entries.push(entry); + const existingEntry = acc.find((item) => item.categoryKey === categoryKey); + if (existingEntry) { + existingEntry.entries.push(entry); return acc; - } else { - return acc.concat({ categoryName, entries: [entry], '@timestamp': entry['@timestamp'] }); } - }, [] as Array<{ categoryName: string; entries: KnowledgeBaseEntry[]; '@timestamp': string }>); + + return acc.concat({ + categoryKey, + title: entry.labels?.category ?? entry.title ?? 'No title', + entries: [entry], + '@timestamp': entry['@timestamp'], + }); + }, [] as Array<{ categoryKey: string; title: string; entries: KnowledgeBaseEntry[]; '@timestamp': string }>); } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_entry.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_entry.ts index 459de7be2d528..4cc6c8e2b9bf1 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_entry.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_entry.ts @@ -28,10 +28,9 @@ export function useCreateKnowledgeBaseEntry() { void, ServerError, { - entry: Omit< - KnowledgeBaseEntry, - '@timestamp' | 'confidence' | 'is_correction' | 'role' | 'doc_id' - >; + entry: Omit & { + title: string; + }; } >( [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], @@ -41,10 +40,7 @@ export function useCreateKnowledgeBaseEntry() { { signal: null, params: { - body: { - ...entry, - role: 'user_entry', - }, + body: entry, }, } ); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_user_instruction.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_user_instruction.ts index b51e45a3bdd6b..8adf5a7d8cfd0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_user_instruction.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_user_instruction.ts @@ -32,7 +32,7 @@ export function useCreateKnowledgeBaseUserInstruction() { signal: null, params: { body: { - id: entry.doc_id, + id: entry.id, text: entry.text, public: entry.public, }, @@ -62,7 +62,7 @@ export function useCreateKnowledgeBaseUserInstruction() { 'xpack.observabilityAiAssistantManagement.kb.addUserInstruction.errorNotification', { defaultMessage: 'Something went wrong while creating {name}', - values: { name: entry.doc_id }, + values: { name: entry.id }, } ), }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_import_knowledge_base_entries.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_import_knowledge_base_entries.ts index 9ff0748793bc8..239e72d99109e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_import_knowledge_base_entries.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_import_knowledge_base_entries.ts @@ -30,7 +30,7 @@ export function useImportKnowledgeBaseEntries() { Omit< KnowledgeBaseEntry, '@timestamp' | 'confidence' | 'is_correction' | 'public' | 'labels' - > + > & { title: string } >; } >( diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_bulk_import_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_bulk_import_flyout.tsx index ac7632243a2a0..23d5ad00af12a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_bulk_import_flyout.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_bulk_import_flyout.tsx @@ -47,15 +47,13 @@ export function KnowledgeBaseBulkImportFlyout({ onClose }: { onClose: () => void }; const handleSubmitNewEntryClick = async () => { - let entries: Array> = []; + let entries: Array & { title: string }> = []; const text = await files[0].text(); const elements = text.split('\n').filter(Boolean); try { - entries = elements.map((el) => JSON.parse(el)) as Array< - Omit - >; + entries = elements.map((el) => JSON.parse(el)); } catch (_) { toasts.addError( new Error( diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_category_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_category_flyout.tsx index fab3b34809c19..8dcf76e4bc56e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_category_flyout.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_category_flyout.tsx @@ -104,13 +104,13 @@ export function KnowledgeBaseCategoryFlyout({ ]; const hasDescription = - CATEGORY_MAP[category.categoryName as unknown as keyof typeof CATEGORY_MAP]?.description; + CATEGORY_MAP[category.categoryKey as unknown as keyof typeof CATEGORY_MAP]?.description; return ( -

{capitalize(category.categoryName)}

+

{capitalize(category.categoryKey)}

diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx index d809b6cd96d6d..20c7a75a401a8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_manual_entry_flyout.tsx @@ -26,7 +26,9 @@ import { EuiTitle, } from '@elastic/eui'; import moment from 'moment'; -import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { KnowledgeBaseEntryRole } from '@kbn/observability-ai-assistant-plugin/public'; +import { type KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common'; +import { v4 } from 'uuid'; import { useCreateKnowledgeBaseEntry } from '../../hooks/use_create_knowledge_base_entry'; import { useDeleteKnowledgeBaseEntry } from '../../hooks/use_delete_knowledge_base_entry'; import { useKibana } from '../../hooks/use_kibana'; @@ -45,20 +47,24 @@ export function KnowledgeBaseEditManualEntryFlyout({ const { mutateAsync: deleteEntry, isLoading: isDeleting } = useDeleteKnowledgeBaseEntry(); const [isPublic, setIsPublic] = useState(entry?.public ?? false); - - const [newEntryId, setNewEntryId] = useState(entry?.id ?? ''); + const [newEntryTitle, setNewEntryTitle] = useState(entry?.title ?? ''); const [newEntryText, setNewEntryText] = useState(entry?.text ?? ''); - const isEntryIdInvalid = newEntryId.trim() === ''; + const isEntryTitleInvalid = newEntryTitle.trim() === ''; const isEntryTextInvalid = newEntryText.trim() === ''; - const isFormInvalid = isEntryIdInvalid || isEntryTextInvalid; + const isFormInvalid = isEntryTitleInvalid || isEntryTextInvalid; const handleSubmit = async () => { await createEntry({ entry: { - id: newEntryId, + id: entry?.id ?? v4(), + title: newEntryTitle, text: newEntryText, public: isPublic, + role: KnowledgeBaseEntryRole.UserEntry, + confidence: 'high', + is_correction: false, + labels: {}, }, }); @@ -85,8 +91,8 @@ export function KnowledgeBaseEditManualEntryFlyout({ : i18n.translate( 'xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel', { - defaultMessage: 'Edit {id}', - values: { id: entry.id }, + defaultMessage: 'Edit {title}', + values: { title: entry?.title }, } )} @@ -94,23 +100,7 @@ export function KnowledgeBaseEditManualEntryFlyout({ - {!entry ? ( - - setNewEntryId(e.target.value)} - isInvalid={isEntryIdInvalid} - /> - - ) : ( + {entry ? ( @@ -136,7 +126,26 @@ export function KnowledgeBaseEditManualEntryFlyout({ - )} + ) : null} + + + + + setNewEntryTitle(e.target.value)} + isInvalid={isEntryTitleInvalid} + /> + + diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx index e8152738e3807..1c05ca6d52b3f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx @@ -30,19 +30,19 @@ export function KnowledgeBaseEditUserInstructionFlyout({ onClose }: { onClose: ( const { userInstructions, isLoading: isFetching } = useGetUserInstructions(); const { mutateAsync: createEntry, isLoading: isSaving } = useCreateKnowledgeBaseUserInstruction(); const [newEntryText, setNewEntryText] = useState(''); - const [newEntryDocId, setNewEntryDocId] = useState(); + const [newEntryId, setNewEntryId] = useState(); const isSubmitDisabled = newEntryText.trim() === ''; useEffect(() => { const userInstruction = userInstructions?.find((entry) => !entry.public); - setNewEntryDocId(userInstruction?.doc_id); setNewEntryText(userInstruction?.text ?? ''); + setNewEntryId(userInstruction?.id); }, [userInstructions]); const handleSubmit = async () => { await createEntry({ entry: { - doc_id: newEntryDocId ?? uuidv4(), + id: newEntryId ?? uuidv4(), text: newEntryText, public: false, // limit user instructions to private (for now) }, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx index d8e2897c6878c..50d0cb8ba47c8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.test.tsx @@ -81,7 +81,18 @@ describe('KnowledgeBaseTab', () => { getByTestId('knowledgeBaseEditManualEntryFlyoutSaveButton').click(); - expect(createMock).toHaveBeenCalledWith({ entry: { id: 'foo', public: false, text: 'bar' } }); + expect(createMock).toHaveBeenCalledWith({ + entry: { + id: expect.any(String), + title: 'foo', + public: false, + text: 'bar', + role: 'user_entry', + confidence: 'high', + is_correction: false, + labels: expect.any(Object), + }, + }); }); it('should require an id', () => { @@ -126,7 +137,7 @@ describe('KnowledgeBaseTab', () => { entries: [ { id: 'test', - doc_id: 'test', + title: 'test', text: 'test', '@timestamp': 1638340456, labels: {}, @@ -134,7 +145,7 @@ describe('KnowledgeBaseTab', () => { }, { id: 'test2', - doc_id: 'test2', + title: 'test2', text: 'test', '@timestamp': 1638340456, labels: { @@ -144,7 +155,7 @@ describe('KnowledgeBaseTab', () => { }, { id: 'test3', - doc_id: 'test3', + title: 'test3', text: 'test', '@timestamp': 1638340456, labels: { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx index 6ba09101b6227..23fbf796290c8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_tab.tsx @@ -57,10 +57,10 @@ export function KnowledgeBaseTab() { data-test-subj="pluginsColumnsButton" onClick={() => setSelectedCategory(category)} aria-label={ - category.categoryName === selectedCategory?.categoryName ? 'Collapse' : 'Expand' + category.categoryKey === selectedCategory?.categoryKey ? 'Collapse' : 'Expand' } iconType={ - category.categoryName === selectedCategory?.categoryName ? 'minimize' : 'expand' + category.categoryKey === selectedCategory?.categoryKey ? 'minimize' : 'expand' } /> ); @@ -85,7 +85,8 @@ export function KnowledgeBaseTab() { width: '40px', }, { - field: 'categoryName', + 'data-test-subj': 'knowledgeBaseTableTitleCell', + field: 'title', name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.name', { defaultMessage: 'Name', }), @@ -107,6 +108,7 @@ export function KnowledgeBaseTab() { }, }, { + 'data-test-subj': 'knowledgeBaseTableAuthorCell', name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.author', { defaultMessage: 'Author', }), @@ -183,7 +185,7 @@ export function KnowledgeBaseTab() { const [isNewEntryPopoverOpen, setIsNewEntryPopoverOpen] = useState(false); const [isEditUserInstructionFlyoutOpen, setIsEditUserInstructionFlyoutOpen] = useState(false); const [query, setQuery] = useState(''); - const [sortBy, setSortBy] = useState<'doc_id' | '@timestamp'>('doc_id'); + const [sortBy, setSortBy] = useState('title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const { @@ -193,17 +195,10 @@ export function KnowledgeBaseTab() { } = useGetKnowledgeBaseEntries({ query, sortBy, sortDirection }); const categorizedEntries = categorizeEntries({ entries }); - const handleChangeSort = ({ - sort, - }: Criteria) => { + const handleChangeSort = ({ sort }: Criteria) => { if (sort) { const { field, direction } = sort; - if (field === '@timestamp') { - setSortBy(field); - } - if (field === 'categoryName') { - setSortBy('doc_id'); - } + setSortBy(field); setSortDirection(direction); } }; @@ -329,7 +324,7 @@ export function KnowledgeBaseTab() { loading={isLoading} sorting={{ sort: { - field: sortBy === 'doc_id' ? 'categoryName' : sortBy, + field: sortBy, direction: sortDirection, }, }} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 53ddb2e664587..e900781b4e0de 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -32566,7 +32566,6 @@ "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "Contenu", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "Nom", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "Entrer du contenu", - "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "Modifier {id}", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "Nouvelle entrée", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "Annuler", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5b646ff86ce3f..e42175ef72874 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -32313,7 +32313,6 @@ "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "目次", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "名前", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "コンテンツを入力", - "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "{id}を編集", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "新しいエントリー", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "キャンセル", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 739a59ef97477..ef58462770e1b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -32355,7 +32355,6 @@ "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "内容", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "名称", "xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "输入内容", - "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "编辑 {id}", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "新条目", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "取消", "xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor", diff --git a/x-pack/test/observability_ai_assistant_api_integration/common/config.ts b/x-pack/test/observability_ai_assistant_api_integration/common/config.ts index 198fcefdc2bc8..427258a6e2910 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/common/config.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/common/config.ts @@ -11,7 +11,7 @@ import { ObservabilityAIAssistantFtrConfigName } from '../configs'; import { getApmSynthtraceEsClient } from './create_synthtrace_client'; import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context'; import { getScopedApiClient } from './observability_ai_assistant_api_client'; -import { editorUser, viewerUser } from './users/users'; +import { editor, secondaryEditor, viewer } from './users/users'; export interface ObservabilityAIAssistantFtrConfig { name: ObservabilityAIAssistantFtrConfigName; @@ -23,6 +23,10 @@ export type CreateTestConfig = ReturnType; export type CreateTest = ReturnType; +export type ObservabilityAIAssistantApiClients = Awaited< + ReturnType +>; + export type ObservabilityAIAssistantAPIClient = Awaited< ReturnType >; @@ -46,21 +50,23 @@ export function createObservabilityAIAssistantAPIConfig({ const apmSynthtraceKibanaClient = services.apmSynthtraceKibanaClient(); const allConfigs = config.getAll() as Record; + const getScopedApiClientForUsername = (username: string) => + getScopedApiClient(kibanaServer, username); + return { ...allConfigs, servers, services: { ...services, - getScopedApiClientForUsername: () => { - return (username: string) => getScopedApiClient(kibanaServer, username); - }, + getScopedApiClientForUsername: () => getScopedApiClientForUsername, apmSynthtraceEsClient: (context: InheritedFtrProviderContext) => getApmSynthtraceEsClient(context, apmSynthtraceKibanaClient), observabilityAIAssistantAPIClient: async () => { return { - adminUser: await getScopedApiClient(kibanaServer, 'elastic'), - viewerUser: await getScopedApiClient(kibanaServer, viewerUser.username), - editorUser: await getScopedApiClient(kibanaServer, editorUser.username), + admin: getScopedApiClientForUsername('elastic'), + viewer: getScopedApiClientForUsername(viewer.username), + editor: getScopedApiClientForUsername(editor.username), + secondaryEditor: getScopedApiClientForUsername(secondaryEditor.username), }; }, }, diff --git a/x-pack/test/observability_ai_assistant_api_integration/common/users/users.ts b/x-pack/test/observability_ai_assistant_api_integration/common/users/users.ts index b6fa38e52e60b..898954a9bfb97 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/common/users/users.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/common/users/users.ts @@ -9,21 +9,27 @@ import { kbnTestConfig } from '@kbn/test'; const password = kbnTestConfig.getUrlParts().password!; export interface User { - username: 'elastic' | 'editor' | 'viewer'; + username: 'elastic' | 'editor' | 'viewer' | 'secondary_editor'; password: string; roles: string[]; } -export const editorUser: User = { +export const editor: User = { username: 'editor', password, roles: ['editor'], }; -export const viewerUser: User = { +export const secondaryEditor: User = { + username: 'secondary_editor', + password, + roles: ['editor'], +}; + +export const viewer: User = { username: 'viewer', password, roles: ['viewer'], }; -export const allUsers = [editorUser, viewerUser]; +export const allUsers = [editor, secondaryEditor, viewer]; diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/complete.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/complete.spec.ts index a7606d21408c5..2eb7c6f986cfd 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/complete.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/complete.spec.ts @@ -290,7 +290,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { )[0]?.conversation.id; await observabilityAIAssistantAPIClient - .adminUser({ + .admin({ endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -366,7 +366,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ).to.eql(0); const conversations = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }) .expect(200); @@ -396,7 +396,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .completeAfterIntercept(); const createResponse = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/chat/complete', params: { body: { @@ -415,7 +415,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { conversationCreatedEvent = getConversationCreatedEvent(createResponse.body); const conversationId = conversationCreatedEvent.conversation.id; - const fullConversation = await observabilityAIAssistantAPIClient.editorUser({ + const fullConversation = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -429,7 +429,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .completeAfterIntercept(); const updatedResponse = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/chat/complete', params: { body: { @@ -460,7 +460,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { after(async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/helpers.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/helpers.ts index b83221869baec..6ce506c502b5e 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/helpers.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/helpers.ts @@ -43,7 +43,7 @@ export async function invokeChatCompleteWithFunctionRequest({ scopes?: AssistantScope[]; }) { const { body } = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/chat/complete', params: { body: { diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/summarize.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/summarize.spec.ts index 923e8b0206070..34da4270f7721 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/summarize.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/summarize.spec.ts @@ -32,10 +32,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { let connectorId: string; before(async () => { - await clearKnowledgeBase(es); await createKnowledgeBaseModel(ml); await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup', }) .expect(200); @@ -55,7 +54,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { name: 'summarize', trigger: MessageRole.User, arguments: JSON.stringify({ - id: 'my-id', + title: 'My Title', text: 'Hello world', is_correction: false, confidence: 'high', @@ -72,28 +71,29 @@ export default function ApiTest({ getService }: FtrProviderContext) { await deleteActionConnector({ supertest, connectorId, log }); await deleteKnowledgeBaseModel(ml); + await clearKnowledgeBase(es); }); it('persists entry in knowledge base', async () => { - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { query: { query: '', - sortBy: 'doc_id', + sortBy: 'title', sortDirection: 'asc', }, }, }); - const { role, public: isPublic, text, type, user, id } = res.body.entries[0]; + const { role, public: isPublic, text, type, user, title } = res.body.entries[0]; expect(role).to.eql('assistant_summarization'); expect(isPublic).to.eql(false); expect(text).to.eql('Hello world'); expect(type).to.eql('contextual'); expect(user?.name).to.eql('editor'); - expect(id).to.eql('my-id'); + expect(title).to.eql('My Title'); expect(res.body.entries).to.have.length(1); }); }); diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/connectors/connectors.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/connectors/connectors.spec.ts index e8363ba41513b..41700b21555fa 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/connectors/connectors.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/connectors/connectors.spec.ts @@ -26,14 +26,14 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('Returns a 2xx for enterprise license', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/connectors', }) .expect(200); }); it('returns an empty list of connectors', async () => { - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/connectors', }); @@ -43,7 +43,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it("returns the gen ai connector if it's been created", async () => { const connectorId = await createProxyActionConnector({ supertest, log, port: 1234 }); - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/connectors', }); diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/conversations.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/conversations.spec.ts index 91a418b3000ee..71eb37d357696 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/conversations.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/conversations.spec.ts @@ -48,7 +48,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { describe('without conversations', () => { it('returns no conversations when listing', async () => { const response = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }) .expect(200); @@ -58,7 +58,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns a 404 for updating conversations', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -74,7 +74,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns a 404 for retrieving a conversation', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -92,7 +92,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { >; before(async () => { createResponse = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/conversation', params: { body: { @@ -105,7 +105,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { after(async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -116,7 +116,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .expect(200); await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -148,7 +148,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns a 404 for updating a non-existing conversation', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -164,7 +164,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns a 404 for retrieving a non-existing conversation', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -177,7 +177,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns the conversation that was created', async () => { const response = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -192,7 +192,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns the created conversation when listing', async () => { const response = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }) .expect(200); @@ -210,7 +210,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { updateResponse = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { @@ -234,7 +234,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns the updated conversation after get', async () => { const updateAfterCreateResponse = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', params: { path: { diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base.spec.ts index c8881e82e43bb..27659f62ad579 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base.spec.ts @@ -6,67 +6,66 @@ */ import expect from '@kbn/expect'; +import { type KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { clearKnowledgeBase, createKnowledgeBaseModel, deleteKnowledgeBaseModel } from './helpers'; export default function ApiTest({ getService }: FtrProviderContext) { const ml = getService('ml'); const es = getService('es'); - const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); describe('Knowledge base', () => { before(async () => { await createKnowledgeBaseModel(ml); + + await observabilityAIAssistantAPIClient + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' }) + .expect(200); }); after(async () => { await deleteKnowledgeBaseModel(ml); + await clearKnowledgeBase(es); }); - it('returns 200 on knowledge base setup', async () => { - const res = await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'POST /internal/observability_ai_assistant/kb/setup', - }) - .expect(200); - expect(res.body).to.eql({}); - }); describe('when managing a single entry', () => { const knowledgeBaseEntry = { id: 'my-doc-id-1', + title: 'My title', text: 'My content', }; it('returns 200 on create', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/entries/save', params: { body: knowledgeBaseEntry }, }) .expect(200); - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { query: { query: '', - sortBy: 'doc_id', + sortBy: 'title', sortDirection: 'asc', }, }, }); const entry = res.body.entries[0]; expect(entry.id).to.equal(knowledgeBaseEntry.id); + expect(entry.title).to.equal(knowledgeBaseEntry.title); expect(entry.text).to.equal(knowledgeBaseEntry.text); }); it('returns 200 on get entries and entry exists', async () => { const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { query: { query: '', - sortBy: 'doc_id', + sortBy: 'title', sortDirection: 'asc', }, }, @@ -74,13 +73,14 @@ export default function ApiTest({ getService }: FtrProviderContext) { .expect(200); const entry = res.body.entries[0]; expect(entry.id).to.equal(knowledgeBaseEntry.id); + expect(entry.title).to.equal(knowledgeBaseEntry.title); expect(entry.text).to.equal(knowledgeBaseEntry.text); }); it('returns 200 on delete', async () => { const entryId = 'my-doc-id-1'; await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}', params: { path: { entryId }, @@ -89,12 +89,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { .expect(200); const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { query: { query: '', - sortBy: 'doc_id', + sortBy: 'title', sortDirection: 'asc', }, }, @@ -108,7 +108,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns 500 on delete not found', async () => { const entryId = 'my-doc-id-1'; await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}', params: { path: { entryId }, @@ -117,119 +117,88 @@ export default function ApiTest({ getService }: FtrProviderContext) { .expect(500); }); }); - describe('when managing multiple entries', () => { - before(async () => { - await clearKnowledgeBase(es); - }); - afterEach(async () => { - await clearKnowledgeBase(es); - }); - const knowledgeBaseEntries = [ - { - id: 'my_doc_a', - text: 'My content a', - }, - { - id: 'my_doc_b', - text: 'My content b', - }, - { - id: 'my_doc_c', - text: 'My content c', - }, - ]; - it('returns 200 on create', async () => { - await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import', - params: { body: { entries: knowledgeBaseEntries } }, - }) - .expect(200); + describe('when managing multiple entries', () => { + async function getEntries({ + query = '', + sortBy = 'title', + sortDirection = 'asc', + }: { query?: string; sortBy?: string; sortDirection?: 'asc' | 'desc' } = {}) { const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { - query: { - query: '', - sortBy: 'doc_id', - sortDirection: 'asc', - }, + query: { query, sortBy, sortDirection }, }, }) .expect(200); - expect(res.body.entries.filter((entry) => entry.id.startsWith('my_doc')).length).to.eql(3); - }); - it('allows sorting', async () => { + return omitCategories(res.body.entries); + } + + beforeEach(async () => { + await clearKnowledgeBase(es); + await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import', - params: { body: { entries: knowledgeBaseEntries } }, - }) - .expect(200); - - const res = await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'GET /internal/observability_ai_assistant/kb/entries', params: { - query: { - query: '', - sortBy: 'doc_id', - sortDirection: 'desc', + body: { + entries: [ + { + id: 'my_doc_a', + title: 'My title a', + text: 'My content a', + }, + { + id: 'my_doc_b', + title: 'My title b', + text: 'My content b', + }, + { + id: 'my_doc_c', + title: 'My title c', + text: 'My content c', + }, + ], }, }, }) .expect(200); + }); - const entries = res.body.entries.filter((entry) => entry.id.startsWith('my_doc')); - expect(entries[0].id).to.eql('my_doc_c'); - expect(entries[1].id).to.eql('my_doc_b'); - expect(entries[2].id).to.eql('my_doc_a'); - - // asc - const resAsc = await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'GET /internal/observability_ai_assistant/kb/entries', - params: { - query: { - query: '', - sortBy: 'doc_id', - sortDirection: 'asc', - }, - }, - }) - .expect(200); + afterEach(async () => { + await clearKnowledgeBase(es); + }); - const entriesAsc = resAsc.body.entries.filter((entry) => entry.id.startsWith('my_doc')); - expect(entriesAsc[0].id).to.eql('my_doc_a'); - expect(entriesAsc[1].id).to.eql('my_doc_b'); - expect(entriesAsc[2].id).to.eql('my_doc_c'); + it('returns 200 on create', async () => { + const entries = await getEntries(); + expect(omitCategories(entries).length).to.eql(3); }); - it('allows searching', async () => { - await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import', - params: { body: { entries: knowledgeBaseEntries } }, - }) - .expect(200); - const res = await observabilityAIAssistantAPIClient - .editorUser({ - endpoint: 'GET /internal/observability_ai_assistant/kb/entries', - params: { - query: { - query: 'my_doc_a', - sortBy: 'doc_id', - sortDirection: 'asc', - }, - }, - }) - .expect(200); + describe('when sorting ', () => { + const ascendingOrder = ['my_doc_a', 'my_doc_b', 'my_doc_c']; + + it('allows sorting ascending', async () => { + const entries = await getEntries({ sortBy: 'title', sortDirection: 'asc' }); + expect(entries.map(({ id }) => id)).to.eql(ascendingOrder); + }); + + it('allows sorting descending', async () => { + const entries = await getEntries({ sortBy: 'title', sortDirection: 'desc' }); + expect(entries.map(({ id }) => id)).to.eql([...ascendingOrder].reverse()); + }); + }); - expect(res.body.entries.length).to.eql(1); - expect(res.body.entries[0].id).to.eql('my_doc_a'); + it('allows searching by title', async () => { + const entries = await getEntries({ query: 'b' }); + expect(entries.length).to.eql(1); + expect(entries[0].title).to.eql('My title b'); }); }); }); } + +function omitCategories(entries: KnowledgeBaseEntry[]) { + return entries.filter((entry) => entry.labels?.category === undefined); +} diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_setup.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_setup.spec.ts index 9099eff540d35..77f010d851f3c 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_setup.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_setup.spec.ts @@ -17,7 +17,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns empty object when successful', async () => { await createKnowledgeBaseModel(ml); const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup', }) .expect(200); @@ -27,7 +27,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns bad request if model cannot be installed', async () => { await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup', }) .expect(400); diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_status.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_status.spec.ts index 4e9778630a535..6561c416f02cf 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_status.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_status.spec.ts @@ -17,7 +17,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { await createKnowledgeBaseModel(ml); await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup', }) .expect(200); @@ -29,7 +29,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns correct status after knowledge base is setup', async () => { const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/status', }) .expect(200); @@ -41,7 +41,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { await ml.api.stopTrainedModelDeploymentES(TINY_ELSER.id, true); const res = await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/status', }) .expect(200); diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_user_instructions.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_user_instructions.spec.ts index a93c194c85daa..a9c1f245a1ac3 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_user_instructions.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_user_instructions.spec.ts @@ -6,7 +6,6 @@ */ import expect from '@kbn/expect'; -import { kbnTestConfig } from '@kbn/test'; import { sortBy } from 'lodash'; import { Message, MessageRole } from '@kbn/observability-ai-assistant-plugin/common'; import { CONTEXT_FUNCTION_NAME } from '@kbn/observability-ai-assistant-plugin/server/functions/context'; @@ -21,33 +20,27 @@ import { import { getConversationCreatedEvent } from '../conversations/helpers'; import { LlmProxy, createLlmProxy } from '../../common/create_llm_proxy'; import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; +import { User } from '../../common/users/users'; export default function ApiTest({ getService }: FtrProviderContext) { const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); - const getScopedApiClientForUsername = getService('getScopedApiClientForUsername'); - const security = getService('security'); const supertest = getService('supertest'); const es = getService('es'); const ml = getService('ml'); const log = getService('log'); + const getScopedApiClientForUsername = getService('getScopedApiClientForUsername'); describe('Knowledge base user instructions', () => { - const userJohn = 'john'; - before(async () => { - // create user - const password = kbnTestConfig.getUrlParts().password!; - await security.user.create(userJohn, { password, roles: ['editor'] }); await createKnowledgeBaseModel(ml); await observabilityAIAssistantAPIClient - .editorUser({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' }) + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' }) .expect(200); }); after(async () => { await deleteKnowledgeBaseModel(ml); - await security.user.delete(userJohn); await clearKnowledgeBase(es); await clearConversations(es); }); @@ -58,19 +51,19 @@ export default function ApiTest({ getService }: FtrProviderContext) { const promises = [ { - username: 'editor', + username: 'editor' as const, isPublic: true, }, { - username: 'editor', + username: 'editor' as const, isPublic: false, }, { - username: userJohn, + username: 'secondary_editor' as const, isPublic: true, }, { - username: userJohn, + username: 'secondary_editor' as const, isPublic: false, }, ].map(async ({ username, isPublic }) => { @@ -92,61 +85,59 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('"editor" can retrieve their own private instructions and the public instruction', async () => { - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', }); const instructions = res.body.userInstructions; - const sortByDocId = (data: Array) => - sortBy(data, 'doc_id'); + const sortById = (data: Array) => sortBy(data, 'id'); - expect(sortByDocId(instructions)).to.eql( - sortByDocId([ + expect(sortById(instructions)).to.eql( + sortById([ { - doc_id: 'private-doc-from-editor', + id: 'private-doc-from-editor', public: false, text: 'Private user instruction from "editor"', }, { - doc_id: 'public-doc-from-editor', + id: 'public-doc-from-editor', public: true, text: 'Public user instruction from "editor"', }, { - doc_id: 'public-doc-from-john', + id: 'public-doc-from-secondary_editor', public: true, - text: 'Public user instruction from "john"', + text: 'Public user instruction from "secondary_editor"', }, ]) ); }); - it('"john" can retrieve their own private instructions and the public instruction', async () => { - const res = await getScopedApiClientForUsername(userJohn)({ + it('"secondaryEditor" can retrieve their own private instructions and the public instruction', async () => { + const res = await observabilityAIAssistantAPIClient.secondaryEditor({ endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', }); const instructions = res.body.userInstructions; - const sortByDocId = (data: Array) => - sortBy(data, 'doc_id'); + const sortById = (data: Array) => sortBy(data, 'id'); - expect(sortByDocId(instructions)).to.eql( - sortByDocId([ + expect(sortById(instructions)).to.eql( + sortById([ { - doc_id: 'public-doc-from-editor', + id: 'public-doc-from-editor', public: true, text: 'Public user instruction from "editor"', }, { - doc_id: 'public-doc-from-john', + id: 'public-doc-from-secondary_editor', public: true, - text: 'Public user instruction from "john"', + text: 'Public user instruction from "secondary_editor"', }, { - doc_id: 'private-doc-from-john', + id: 'private-doc-from-secondary_editor', public: false, - text: 'Private user instruction from "john"', + text: 'Private user instruction from "secondary_editor"', }, ]) ); @@ -158,7 +149,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { await clearKnowledgeBase(es); await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', params: { body: { @@ -171,7 +162,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .expect(200); await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', params: { body: { @@ -185,14 +176,14 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('updates the user instruction', async () => { - const res = await observabilityAIAssistantAPIClient.editorUser({ + const res = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', }); const instructions = res.body.userInstructions; expect(instructions).to.eql([ { - doc_id: 'doc-to-update', + id: 'doc-to-update', text: 'Updated text', public: false, }, @@ -207,12 +198,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { const userInstructionText = 'Be polite and use language that is easy to understand. Never disagree with the user.'; - async function getConversationForUser(username: string) { + async function getConversationForUser(username: User['username']) { const apiClient = getScopedApiClientForUsername(username); // the user instruction is always created by "editor" user await observabilityAIAssistantAPIClient - .editorUser({ + .editor({ endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', params: { body: { @@ -314,7 +305,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('does not add the instruction conversation for other users', async () => { - const conversation = await getConversationForUser('john'); + const conversation = await getConversationForUser('secondary_editor'); const systemMessage = conversation.messages.find( (message) => message.message.role === MessageRole.System )!; diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/public_complete/public_complete.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/public_complete/public_complete.spec.ts index a25a7cf5ef8eb..bb8984256f27c 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/public_complete/public_complete.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/public_complete/public_complete.spec.ts @@ -70,7 +70,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { (body) => !isFunctionTitleRequest(body) ); - const responsePromise = observabilityAIAssistantAPIClient.adminUser({ + const responsePromise = observabilityAIAssistantAPIClient.admin({ endpoint: 'POST /api/observability_ai_assistant/chat/complete 2023-10-31', params: { query: { format }, diff --git a/x-pack/test/observability_ai_assistant_functional/common/config.ts b/x-pack/test/observability_ai_assistant_functional/common/config.ts index c0f649d51d90d..99213e629e0e3 100644 --- a/x-pack/test/observability_ai_assistant_functional/common/config.ts +++ b/x-pack/test/observability_ai_assistant_functional/common/config.ts @@ -13,8 +13,9 @@ import { KibanaEBTUIProvider, } from '@kbn/test-suites-src/analytics/services/kibana_ebt'; import { - editorUser, - viewerUser, + secondaryEditor, + editor, + viewer, } from '../../observability_ai_assistant_api_integration/common/users/users'; import { ObservabilityAIAssistantFtrConfig, @@ -61,9 +62,10 @@ async function getTestConfig({ ObservabilityAIAssistantUIProvider(context), observabilityAIAssistantAPIClient: async (context: InheritedFtrProviderContext) => { return { - adminUser: await getScopedApiClient(kibanaServer, 'elastic'), - viewerUser: await getScopedApiClient(kibanaServer, viewerUser.username), - editorUser: await getScopedApiClient(kibanaServer, editorUser.username), + admin: getScopedApiClient(kibanaServer, 'elastic'), + viewer: getScopedApiClient(kibanaServer, viewer.username), + editor: getScopedApiClient(kibanaServer, editor.username), + secondaryEditor: getScopedApiClient(kibanaServer, secondaryEditor.username), }; }, kibana_ebt_server: KibanaEBTServerProvider, diff --git a/x-pack/test/observability_ai_assistant_functional/common/ui/index.ts b/x-pack/test/observability_ai_assistant_functional/common/ui/index.ts index 2c6852988cde5..d072cc3777a7d 100644 --- a/x-pack/test/observability_ai_assistant_functional/common/ui/index.ts +++ b/x-pack/test/observability_ai_assistant_functional/common/ui/index.ts @@ -27,6 +27,11 @@ export interface ObservabilityAIAssistantUIService { } const pages = { + kbManagementTab: { + table: 'knowledgeBaseTable', + tableTitleCell: 'knowledgeBaseTableTitleCell', + tableAuthorCell: 'knowledgeBaseTableAuthorCell', + }, conversations: { setupGenAiConnectorsButtonSelector: `observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton`, chatInput: 'observabilityAiAssistantChatPromptEditorTextArea', diff --git a/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts b/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts index 68b1d97d531dc..de780d2f46b0e 100644 --- a/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts +++ b/x-pack/test/observability_ai_assistant_functional/tests/conversations/index.spec.ts @@ -36,12 +36,12 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte const flyoutService = getService('flyout'); async function deleteConversations() { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }); for (const conversation of response.body.conversations) { - await observabilityAIAssistantAPIClient.editorUser({ + await observabilityAIAssistantAPIClient.editor({ endpoint: `DELETE /internal/observability_ai_assistant/conversation/{conversationId}`, params: { path: { @@ -53,7 +53,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte } async function deleteConnectors() { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/connectors', }); @@ -66,7 +66,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte } async function createOldConversation() { - await observabilityAIAssistantAPIClient.editorUser({ + await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversation', params: { body: { @@ -204,7 +204,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte }); it('creates a connector', async () => { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'GET /internal/observability_ai_assistant/connectors', }); @@ -259,7 +259,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte }); it('creates a conversation and updates the URL', async () => { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }); @@ -325,7 +325,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte }); it('does not create another conversation', async () => { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }); @@ -333,7 +333,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte }); it('appends to the existing one', async () => { - const response = await observabilityAIAssistantAPIClient.editorUser({ + const response = await observabilityAIAssistantAPIClient.editor({ endpoint: 'POST /internal/observability_ai_assistant/conversations', }); diff --git a/x-pack/test/observability_ai_assistant_functional/tests/knowledge_base_management/index.spec.ts b/x-pack/test/observability_ai_assistant_functional/tests/knowledge_base_management/index.spec.ts new file mode 100644 index 0000000000000..7a5a51ae58b6a --- /dev/null +++ b/x-pack/test/observability_ai_assistant_functional/tests/knowledge_base_management/index.spec.ts @@ -0,0 +1,124 @@ +/* + * 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 expect from '@kbn/expect'; +import { subj as testSubjSelector } from '@kbn/test-subj-selector'; +import { + clearKnowledgeBase, + createKnowledgeBaseModel, + deleteKnowledgeBaseModel, +} from '../../../observability_ai_assistant_api_integration/tests/knowledge_base/helpers'; +import { ObservabilityAIAssistantApiClient } from '../../../observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) { + const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); + const ui = getService('observabilityAIAssistantUI'); + const testSubjects = getService('testSubjects'); + const log = getService('log'); + const ml = getService('ml'); + const es = getService('es'); + const { common } = getPageObjects(['common']); + + async function saveKbEntry({ + apiClient, + text, + }: { + apiClient: ObservabilityAIAssistantApiClient; + text: string; + }) { + return apiClient({ + endpoint: 'POST /internal/observability_ai_assistant/functions/summarize', + params: { + body: { + title: 'Favourite color', + text, + confidence: 'high', + is_correction: false, + public: false, + labels: {}, + }, + }, + }).expect(200); + } + + describe('Knowledge management tab', () => { + before(async () => { + await clearKnowledgeBase(es); + + // create a knowledge base model + await createKnowledgeBaseModel(ml); + + await Promise.all([ + // setup the knowledge base + observabilityAIAssistantAPIClient + .editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' }) + .expect(200), + + // login as editor + ui.auth.login('editor'), + ]); + }); + + after(async () => { + await Promise.all([deleteKnowledgeBaseModel(ml), clearKnowledgeBase(es), ui.auth.logout()]); + }); + + describe('when the LLM calls the "summarize" function for two different users', () => { + async function getKnowledgeBaseEntries() { + await common.navigateToUrlWithBrowserHistory( + 'management', + '/kibana/observabilityAiAssistantManagement', + 'tab=knowledge_base' + ); + + const entryTitleCells = await testSubjects.findAll(ui.pages.kbManagementTab.tableTitleCell); + + const rows = await Promise.all( + entryTitleCells.map(async (cell) => { + const title = await cell.getVisibleText(); + const parentRow = await cell.findByXpath('ancestor::tr'); + + const authorElm = await parentRow.findByCssSelector( + testSubjSelector(ui.pages.kbManagementTab.tableAuthorCell) + ); + const author = await authorElm.getVisibleText(); + const rowText = (await parentRow.getVisibleText()).split('\n'); + + return { rowText, author, title }; + }) + ); + + log.debug(`Found ${rows.length} rows in the KB management table: ${JSON.stringify(rows)}`); + + return rows.filter(({ title }) => title === 'Favourite color'); + } + + before(async () => { + await saveKbEntry({ + apiClient: observabilityAIAssistantAPIClient.editor, + text: 'My favourite color is red', + }); + + await saveKbEntry({ + apiClient: observabilityAIAssistantAPIClient.secondaryEditor, + text: 'My favourite color is blue', + }); + }); + + it('shows two entries', async () => { + const entries = await getKnowledgeBaseEntries(); + expect(entries.length).to.eql(2); + }); + + it('shows two different authors', async () => { + const entries = await getKnowledgeBaseEntries(); + expect(entries.map(({ author }) => author)).to.eql(['secondary_editor', 'editor']); + }); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 79bdd3ad7df09..091e0fe01e415 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -151,7 +151,6 @@ export default function ({ getService }: FtrProviderContext) { 'fleet:update_agent_tags:retry', 'fleet:upgrade_action:retry', 'logs-data-telemetry', - 'observabilityAIAssistant:indexQueuedDocumentsTaskType', 'osquery:telemetry-configs', 'osquery:telemetry-packs', 'osquery:telemetry-saved-queries', diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/ai_assistant/tests/knowledge_base/knowledge_base.spec.ts b/x-pack/test_serverless/api_integration/test_suites/observability/ai_assistant/tests/knowledge_base/knowledge_base.spec.ts index b540ee5829e59..ce46939c365be 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/ai_assistant/tests/knowledge_base/knowledge_base.spec.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/ai_assistant/tests/knowledge_base/knowledge_base.spec.ts @@ -52,6 +52,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { describe('when managing a single entry', () => { const knowledgeBaseEntry = { id: 'my-doc-id-1', + title: 'My title', text: 'My content', }; it('returns 200 on create', async () => { @@ -156,14 +157,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { const knowledgeBaseEntries = [ { id: 'my_doc_a', + title: 'My title a', text: 'My content a', }, { id: 'my_doc_b', + title: 'My title b', text: 'My content b', }, { id: 'my_doc_c', + title: 'My title c', text: 'My content c', }, ];